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>
Conflicts resolved: - src/arcadedb/_native/records.py: keeps this branch's _save_update (lazy fingerprint, refuses a stale overwrite) and applies #202's rule inside it: the record id goes to the engine as the :rid parameter for both the stored-record read (_SELECT = "SELECT FROM :rid") and the UPDATE .. MERGE .. RETURN AFTER (_UPDATE now carries RETURN AFTER, which only _save_update uses). Without this, #202's test_native_statement_texts fails on the pasted ids in the save path. - tests/conftest.py, tests/test_graph_api.py: both sides appended; kept both. The duplicate `import pytest` from the two sides is dropped and main's import order kept (local isort disagrees with main's CI order; left as main has it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Third timed A/B: this PR's head Result by the rule: Reads: unchanged. All 14 read figures and controls are within 1.013x of main (largest median ratio, Save path, against the second run:
So the byte-encoded fingerprint removed most of the medium-record cost, and the small-record cost is about where it was. The medium save is still above the bar the study set before the run (median at most 1.5 with the 95% upper end below 1.6), so by that rule the PR stays draft and the save cost is discussed here before a merge. Proposed next step from our side: an untimed profile of the remaining medium-save cost (where the fingerprint time goes for a medium record: encoding, hashing or the extra read), so that the choice is between concrete options rather than a number: accept this cost for the lost-update guarantee, cut it further, or make the check opt-out. We will post that profile here; the merge decision waits for it. |
|
Untimed profile of the medium save on the same two arms as the third A/B (one fresh process each, 2000 saves of one medium record, median microseconds per call; profile only, the timed figures are the ones above):
One fingerprint of this record (6655 packed bytes, 21 properties) is about 25.6 us of packing plus 7.3 us of hashing, and a medium save computes three of them (109.5 us together). The other added parts are the re-read of the stored row and reading the row back after the update. Proposed change on this PR, then a fourth A/B with the same rule:
If you see a reason against 1 or 2, say so; otherwise we will push them with tests and re-measure. |
…ad, and skip the read-back for values stored as sent The check that refuses to overwrite a record changed after it was read cost three fingerprints per save (the record as read, the stored row, the row after the UPDATE): 109 us of 308 us in the untimed profile of a medium record (21 properties, a 768-float vector), plus the read-back of the row. Now: - modify() and the first set() of a saved record keep a copy of the values as read (nested lists, maps, sets and arrays copied; a NumPy array is the list a read gives, NumPy scalars the Python scalars, a tuple a list), and save() compares the stored row with it by value: same type, equal value, NaN equal to NaN, 0.0 not -0.0, a Decimal only at the same scale, a vector in one C comparison. - After the UPDATE the values just sent become the copy when every changed value is stored exactly as sent, and the statement then has no RETURN AFTER. Which values: the parity test (tests/test_native_save_baseline.py, 54 cases) saves each kind into declared and undeclared properties and compares the baseline with a fresh read after the commit. Exact: str, bool, int (64 bit), a float that float32 holds (the engine rounds a double sent to a FLOAT or ARRAY_OF_FLOATS property), date, finite Decimal, lists and maps of those. Read back: NaN, sets and bytes (they read back as lists), datetimes (precision, time zone), RIDs, a new property or a None before, a list that changes its element kind, and any value whose type differs from what a read gave for that property (an int sent to a DOUBLE property reads back as 3.0). - A before_update veto or a record that is gone leaves the copy as it was (the earlier code fingerprinted the unsaved values after a veto and refused the next save). - _fingerprint and its encoder are removed; nothing else used them. The tests count the copy and the statement texts, spy for any hash on the save path, and check the copy against set(), in-place changes of nested lists and maps and a concurrent change of a nested vector. The contract tests (lost update refused, own save not a conflict, outside a transaction refused on both builds) are unchanged. 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