Skip to content

native: save() overwrites a record changed after it was read (lost update) #172

Open

Description

@tae898

On the native build, save() of a record object overwrites a record that another transaction changed after the object was read, and silently reverts that transaction's committed values (a lost update). The JVM build refuses the save with ConcurrentModificationException.

Scenario (a probe run on the 26.11.1.dev0 dev wheels, Linux x86-64): a document with a = 1, b = 1 is read and turned into a mutable object with db.lookup_by_rid(rid).modify(); another transaction sets b = 2 and commits; then a = 10 is set on the object and save() is called in a transaction.

build save() stored record afterwards
arcadedb-jvm raises ArcadeDBError (ConcurrentModificationException: Record #1:0 was modified by a concurrent ...) a = 1, b = 2
arcadedb (native) succeeds a = 10, b = 1

Cause: native save() of an existing record runs UPDATE <rid> MERGE :row with the Python snapshot of every property (src/arcadedb/_native/records.py), and carries nothing that identifies the image it was read from. The engine has no record version; the JVM check is the engine's own comparison of the bytes a MutableDocument was read from with the bytes stored now (TransactionContext.checkRecordIsStillTheOneRead).

Fix in progress: the native glue fingerprints the stored record bytes when a record row is read and checks the fingerprint in the same transaction as the update, raising the engine's ConcurrentModificationException on a mismatch. Because the fingerprint would be computed for every record row a read returns, the change gets a timed A/B of the read paths before a PR, so the read speed of the native build is not traded silently for this check.

Activity

  1. tae898 commented on Nov 2, 2026

    @tae898
    CollaboratorAuthor

    The fix on fix/native-save-version (head 8e11677) computes the record fingerprint on every read. We timed it against main on the native wheel (same engine e353330c5c, -O3, interleaved pairs, ab.py --noise on two pinned laptop cores), with a rule written before the run. The fingerprint costs reads more than the rule allows; these figures have narrow intervals (ratio = fix / main, 95% CI):

    read clean pairs ratio 95% CI
    a 10k-row SELECT, small records 26 [1.075, 1.079]
    a lookup by RID, medium records 26 [1.063, 1.075]
    the first row of a query, medium records 26 [1.055, 1.060]
    a 2-hop Cypher query, medium records 26 [1.053, 1.071]

    The two controls (a projection that reads no record) did not move: [0.997, 1.003], [0.984, 1.002].

    So the fix will compute the fingerprint lazily, from the values the record already holds, only when modify() or save() needs it; a record that is read and never saved pays nothing. The redesigned head gets the same timed comparison before its PR is merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions