Repository navigation
Conversation
… 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>
|
A finding from preparing the timed A/B of this head ( 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 |
…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>
|
Timed A/B of this PR's head Result by that rule: 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:
Each operation is So the PR stays a draft. Options I see, cheapest first, before measuring again:
Of these, the first would make native weaker than JVM: the JVM package's Edited: the last paragraph first said I would try the first option; it now keeps the whole-record check for parity with JVM. |
|
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 |
|
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):
The three fingerprints take 1640 of the 2089 us (79 %). One fingerprint of a medium record splits into canonicalising the values (271.8 us), Plan, keeping the whole-record check: build the fingerprint from the value bytes (floats and vectors packed, not |
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>
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 withConcurrentModificationException. The engine keeps no record version, so the native build now compares the values the record held as read with the values stored atsave()time, in the transaction of the update, and raises the same error (retryable byrun_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 firstset()of a record that is already saved, or atsave(), 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 raisesArcadeDBError("Transaction not begun"), as on main and asDocument.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 tracksset_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 oneSELECTbefore and gets the stored record back from theUPDATE(one extra crossing). Reads are unchanged by design.Tests:
test_save_of_an_existing_record_outside_a_transaction_is_refusedintests/test_graph_api.pyruns 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 intests/test_graph_api.pyrun on both builds.tests/test_native_save_lazy_fingerprint.py(native only) counts calls through therecords._fingerprintseam: 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 whenmodify()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 andtest_graph.py,test_core.py,test_concurrency.py,test_cross_model_atomicity.pyandtest_bridge_fixes.pylocally against a native dev wheel with thisrecords.pyon 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 persave()(atmodify(), on the re-read stored row, on theRETURN AFTERrow). It now writes a type tag and a fixed-width or length-prefixed form of each value straight into onebytearray(a list of floats or of ints goes in onearraycall) 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 theRETURN AFTERrow (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, and1,1.0andTruestay three values. One class is wider on purpose: a float ndarray equals the list of its doubles. New tests intests/test_native_save_lazy_fingerprint.py: one medium-like record has one fingerprint throughlookup_by_rid, aSELECTand theRETURN AFTERrow, 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