Skip to content

native: save() refuses to overwrite a record changed after it was read, fingerprint taken lazily (#172) - #194

Draft
tae898 wants to merge 6 commits into
mainfrom
fix/native-save-version
Draft

tae898 wants to merge 6 commits into
mainfrom
fix/native-save-version

Conversation

@tae898

@tae898 tae898 commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #172

Native save() of a record object overwrote a record that another transaction changed after the object was read (a lost update). The JVM build refuses it with ConcurrentModificationException. The engine keeps no record version, so the native build now compares the values the record held as read with the values stored at save() time, in the transaction of the update, and raises the same error (retryable by run_in_transaction) when they differ. Nothing is written in that case.

The fingerprint is lazy. A first design fingerprinted the stored bytes in Java for every record row a read returned, and a timed A/B (rule written before the run) showed native reads 5 to 8 percent slower (for example a 10k-row SELECT, ratio 95% CI [1.075, 1.079]; a lookup by RID [1.063, 1.075]), see the comment on #172. This head does no fingerprint work on any read path. It takes the fingerprint at modify(), at the first set() of a record that is already saved, or at save(), from the values the record holds then, which are the read-time snapshot, and caches it on the record. After a successful save it moves to the stored values (UPDATE .. MERGE .. RETURN AFTER), so an object's own earlier save is not another transaction's change. A record that is read and never saved pays nothing. There is no Java change.

Parity with the JVM package: save() of an existing record is a write, so it needs an active transaction on both builds. Outside one it raises ArcadeDBError ("Transaction not begun"), as on main and as Document.save() does through the engine; an earlier head of this PR opened and committed a transaction of its own when none was active, which made native accept what the JVM package refuses, and that is removed. The native build tracks set_auto_transaction() (the library has no getter), so with auto-transaction on, the engine's own per-statement transactions apply as before.

The cost moves to the write: save() of an existing record now runs one SELECT before and gets the stored record back from the UPDATE (one extra crossing). Reads are unchanged by design.

Tests: test_save_of_an_existing_record_outside_a_transaction_is_refused in tests/test_graph_api.py runs on both builds (it raises outside a transaction, leaves no transaction behind, writes nothing, and the same object saves inside one). The four refusal tests in tests/test_graph_api.py run on both builds. tests/test_native_save_lazy_fingerprint.py (native only) counts calls through the records._fingerprint seam: no read path (query rows, lookup_by_rid, get_element, edges, Cypher) calls it; modify() calls it once and caches it; a modify-then-save after a concurrent change still raises, also when modify() runs after the change; a record created by the object and changed elsewhere is refused. The new refusal tests fail on main (DID NOT RAISE) and pass here. I ran them and test_graph.py, test_core.py, test_concurrency.py, test_cross_model_atomicity.py and test_bridge_fixes.py locally against a native dev wheel with this records.py on top (51 passed, 1 skipped, plus 25 passed in the first two files).

Fast fingerprint: the first version of the fingerprint built a canonical copy of the record (nested lists) and hashed its repr, about 390 us for a record with 20 properties and a 768-float vector, taken three times per save() (at modify(), on the re-read stored row, on the RETURN AFTER row). It now writes a type tag and a fixed-width or length-prefixed form of each value straight into one bytearray (a list of floats or of ints goes in one array call) and hashes that once, about 23 us for the same record (17 times less, measured with a script that also checks that every pair of a 60-value corpus is equal or different exactly as before, and that 3000 random nested records give one fingerprint in any dict order). What the guard detects is unchanged: it still fingerprints the whole record, the token still comes from the RETURN AFTER row (what the engine stored), and it is taken at the same places. Equal values still give equal fingerprints whatever the dict or set order, a tuple is a list, and 1, 1.0 and True stay three values. One class is wider on purpose: a float ndarray equals the list of its doubles. New tests in tests/test_native_save_lazy_fingerprint.py: one medium-like record has one fingerprint through lookup_by_rid, a SELECT and the RETURN AFTER row, and any one changed property or vector element changes it. The timed A/B on this head is still to be run.

Draft: the same timed A/B (ab.py --noise, native wheel, interleaved pairs against main) runs on this head before it leaves draft, with the same pre-written rule.

🤖 Generated with Claude Code

tae898 and others added 2 commits November 2, 2026 20:06
… after it was read (#172)

The engine keeps no record version, and native save() wrote the Python snapshot back with UPDATE .. MERGE, reverting another
transaction's committed values (the JVM build raises ConcurrentModificationException).

The fingerprint of the values a record held as read is taken lazily: at modify(), at the first set() of a saved record, or at save(),
from the values then held (the read-time snapshot), and cached on the record. The read path takes none (a first design fingerprinted the
stored bytes in Java for every record row and slowed reads 5 to 8 percent in a timed A/B, see the issue). save() compares it with the
values stored now in the transaction of the update, raises a retryable ConcurrentModificationException error when they differ (nothing
written), and moves the fingerprint to the stored values after its own write (UPDATE .. RETURN AFTER).

No Java change. Tests: the refusal on both builds (test_graph_api.py), and on the native build that no read path fingerprints and that a
late modify() still compares the values read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s B608 (#172)

The statements are built from a record's own id (or one a test read); the nosec sits on the one line that builds each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898

tae898 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

A finding from preparing the timed A/B of this head (7008eb4), before it runs: with this PR, native save() of an existing record works outside a transaction, because _save_update opens and commits its own when none is active (own = not db.is_transaction_active(), then db.begin()). On main, native save() outside a transaction raises Transaction not begun, and so does the JVM package (Document.save() calls the engine's MutableDocument.save(), which refuses a write without an active transaction while autoTransaction is false, the engine default). So this head makes the two backends behave differently, which the bindings should not do.

Plan: drop the own-begin branch so that native and JVM both raise outside a transaction, and add a test that checks it on both backends. I will push that after the timed A/B of 7008eb4 finishes. The A/B wraps every save in an explicit transaction on all arms (as users on main must do today), so the own-begin path is not part of what it times, and its figures carry over to the fixed head.

tae898 and others added 2 commits November 3, 2026 00:43
…VM build (#172)

_save_update opened and committed a transaction of its own when none was active, so a native save() outside a transaction worked while main and the JVM package (Document.save() -> MutableDocument.save()) raise "Transaction not begun". It now opens none: outside a transaction, with auto-transaction off (the engine default, tracked by NativeDatabase._auto_transaction because the native library has no getter), it raises ArcadeDBError before reading or writing anything. The fingerprint check and its ConcurrentModificationException path are unchanged inside the caller's transaction.

Tests: test_save_of_an_existing_record_outside_a_transaction_is_refused runs on both builds; the native-only fingerprint test no longer expects a save outside a transaction to succeed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898

tae898 commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Timed A/B of this PR's head 3492acb against main 87ecaf0 (laptop timed lane, ab.py --noise, ABBA pairs, an A/A control, the rule written in the study before the run). Every save in all arms runs inside an explicit with db.transaction():, so the own-begin path that this head removed is not part of it.

Result by that rule: VERDICT: reads **SHIP**; save path **GRAY** (save_medium_us: median 5.882, CI [5.86, 5.938]). Result: ok (all 16 B/A figures have >= 10 clean pairs and CI width <= 0.08, the A/A control is clean).

Reads: the lazy fingerprint costs nothing measurable. All 12 read figures and both controls are within 1.006x of main (largest median ratio), so the read side can ship as it is.

The save path is where it costs:

figure clean pairs median ratio PR / main 95% CI A/A median (control)
save_small_us 14 1.273 [1.268, 1.276] 1.000
save_medium_us 14 5.882 [5.860, 5.938] 0.998

Each operation is lookup_by_rid(rid).modify(), one set("name", ...), then save(). The small documents have a handful of scalar properties; the medium ones have 20 properties plus a 768-float vector. The overhead grows with the size of the record, which points at the save-time work on the whole record: the extra read of the stored row and the fingerprint over every value, the vector included, while the change itself is one string.

So the PR stays a draft. Options I see, cheapest first, before measuring again:

  • compare only what the save writes: check the properties being set (and the ones read through modify()) instead of a fingerprint of the whole record, for example as a conditional UPDATE ... MERGE ... WHERE on the old values of those properties, which also saves the separate read;
  • keep the whole-record check but leave large arrays (vectors) out of the fingerprint, or hash their bytes once at read time;
  • move the fingerprint into the native library, so the comparison does not go through Python values.

Of these, the first would make native weaker than JVM: the JVM package's save() is guarded by the engine's MVCC, which refuses the commit on any concurrent change to the record, not only to the properties being written. So the check stays whole-record, and I will try the cheap ways to keep it so (the third option, then the second) and rerun this A/B on them. If someone prefers another contract for a lost-update guard, say so before I build it.

Edited: the last paragraph first said I would try the first option; it now keeps the whole-record check for parity with JVM.

@tae898

tae898 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Edit to my comment above: I first wrote that I would try comparing only the properties being saved. That would make native weaker than JVM, whose save() the engine refuses on any concurrent change to the record, so the next try keeps the whole-record check and makes it cheaper (fingerprint in the native library, or vectors hashed once at read time).

@tae898

tae898 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Where the medium save goes, from an untimed phase profile of the same operation on the same medium database (2,000 saves per arm, medians in microseconds, alone on 2 cores; the timer overhead is the difference between the unwrapped and the wrapped loop):

phase main this PR
whole operation (no timers) 279.9 2089
lookup_by_rid 66.95 74.98
modify() 1.223 559.7
of which the fingerprint - 556.6
save() 139 1357
re-read of the stored row - 46.57
fingerprint of the stored row - 544.2
UPDATE ... MERGE 136.7 164.1
decode of the RETURN AFTER row - 40.66
fingerprint of the RETURN AFTER row - 539.6
commit 73.66 79.37

The three fingerprints take 1640 of the 2089 us (79 %). One fingerprint of a medium record splits into canonicalising the values (271.8 us), repr of the result (236.2 us) and the hash itself (17.73 us), so it is the Python canonicalisation and repr of the 768 floats, not the hash and not the extra read (46.57 us).

Plan, keeping the whole-record check: build the fingerprint from the value bytes (floats and vectors packed, not repr), and take the token after the save from the values just written instead of decoding and hashing the RETURN AFTER row. Then the same timed A/B again.

tae898 and others added 2 commits November 3, 2026 03:21
The stale-save fingerprint canonicalised the record into nested lists and
printed it with repr before hashing: about 390 us for a record with 20
properties and a 768-float vector, taken three times per save(). It now
appends a type tag and a fixed-width or length-prefixed form of each value to
one bytearray (a float or int list is packed in one array call) and hashes
that once: about 23 us. The equivalence classes are the ones the old form
had (dict and set order, tuple == list, int/float/bool apart, temporals,
RID, Decimal, bytes by value); a float ndarray is now equal to the list of
its doubles. When the token is taken and where it comes from (RETURN AFTER)
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t test for engine 26.11.1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant