Skip to content

Native: save() of a saved record does not ask for the transaction state when auto-transaction is on (refs #317) - #385

Closed
tae898 wants to merge 2 commits into
ArcadeData:mainfrom
humemai:perf/317-native-update-crossings
Closed

tae898 wants to merge 2 commits into
ArcadeData:mainfrom
humemai:perf/317-native-update-crossings

Conversation

@tae898

@tae898 tae898 commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Part of #317 and #315. This is a small, exact piece of the update half of #317, and it leaves the default case at three entries. I am opening it as a draft so that the rest can be decided with the facts below.

On the native wheel, save() of a saved record (record.set(...) then save()) makes three entries into the library, as I read _save_update in src/arcadedb/_native/records.py: is_transaction_active() (arcadedb_n_tx), query(SELECT FROM :rid).first() for the stale-record check, and the UPDATE ... MERGE (exec_query, or arcadedb_n_exec_bin with a float array). The JVM wheel does it in two. #329 did the create half and moved the UPDATE to the eager form; it kept these three on purpose.

The change: the check was not db.is_transaction_active() and not db._auto_transaction, so the crossing was made first and its answer thrown away whenever auto-transaction is on. The operands are swapped, so the state is not asked for when the flag is on (a Python attribute is read instead of a crossing).

case entries before entries after
save() of a saved record, auto-transaction off (the engine default), inside a transaction 3 3
same, outside a transaction (refused with "Transaction not begun") 1 1
same, auto-transaction on, bare or inside a transaction 3 2

I did not count the foreign calls inside one entry, so I make no claim per entry. Nothing observable changes: with auto-transaction off the error, its message and its place (before any read) are the same, and with it on a closed database raises the same "Database is closed", from the SELECT instead of from the state call.

Why the default case stays at three

I looked for a way to get to two without a Java change and found none that keeps every behaviour.

  • The engine's own refusal cannot replace the check. In LocalDatabase.updateRecord the validation and the before-update events run before the engine looks for a transaction, so a save outside a transaction would run Python callbacks (or be swallowed by a veto) before it raised, and an UPDATE of a record that is gone raises nothing. I read this in the 26.12.1-SNAPSHOT source; I have not checked the 26.11.1 tag.
  • A Python-side record of the state would be exact for begin, commit and rollback, which all go through _tx_text, but a SQL COMMIT or ROLLBACK sent through command ends the transaction without passing there, so a stale "active" would skip a refusal the user relies on.
  • The SELECT cannot be folded into the UPDATE from Python. RETURN BEFORE shows the conflict only after the write is in the caller's transaction (Native: a created or updated record reads its row in the statement's crossing (closes #317) #329 said the same). A conditional UPDATE would compare with the engine's =, not with _same (1 against 1.0, NaN, 0.0 against -0.0, a Decimal's scale, a missing key against a null), and a count of zero cannot tell a stale record from one that is gone.

What would need a native change, as a separate PR if you want it: the reply of arcadedb_n_exec carries "active":<bool> under a flag bit (db.isTransactionActive() is a thread-local read), so the SELECT of the stale-record check answers the transaction question too, and the default case is two entries. One entry would be a dedicated entry point that does the check, the compare and the merge in the engine, with the rules of _same ported to Java. I did not add either here.

Related issues

Part of #317 and #315. No closing keyword: #317 stays open.

Performance changes

Not measured yet: the speed claim waits for a timed A/B of a candidate wheel against 26.11.1.post2. This PR makes no speed claim, and it only changes a path that is off by default.

Tests

In tests/test_native_one_row_crossings.py (native backend only), in a first commit of their own:

  • two entries for an update with auto-transaction on, bare and inside a transaction, for the exact form and for the read-back form (four cases). By reading the code they make three entries on the commit before the change; I did not run them there.
  • unchanged on both sides: with auto-transaction switched back off the check is back and refuses before any read (["arcadedb_n_tx"]), and still comes first inside a transaction; a stale record is refused with ConcurrentModificationException under auto-transaction too and nothing is written; an update under auto-transaction stores what the same update inside a transaction stores, and leaves the same baseline (the next save of the same object is no conflict), for NULL, float, text, date-time, date, Decimal, a list and map, a float32 array and a value set unchanged.

Docs

None needs it: no page or README section describes the entries of save().

How I verified it

Only light checks here, no build and no test run: compile, black 26.10.1, isort 9.0.2 (--profile black), ruff 0.17.4 and bandit 1.9.4 on the two files, all clean. The tests run in CI only; test-native.yml was started on my fork for this branch (a draft skips the platform jobs here).

Checklist

  • A regression test fails on the old code and passes on the new (by reading the code, not yet run)
  • The test suite that the change can affect passes (CI on the fork pending)
  • The checks that run locally pass (black, isort, ruff, bandit on the two files); uvx pre-commit run --files itself not run here

🤖 Generated with Claude Code

tae898 and others added 2 commits November 14, 2026 02:59
…ossings, not three (refs ArcadeData#317)

The transaction check of a saved record's save() cannot refuse while auto-transaction is on, yet it
still crossed into the library (arcadedb_n_tx) before the SELECT and the UPDATE. These tests pin two
crossings there, bare and inside a transaction, with and without the read-back of the row, and pin
what must not move: the check is back the moment auto-transaction is off (first, before any read),
a stale record is refused under auto-transaction too, and the stored values and the next save's
baseline are those of an update inside a transaction for NULL, float, text, date-time, date,
Decimal, nested values, a float32 array and a value set unchanged.

The two-crossing tests fail on the code before the change (three crossings); the rest pass on both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…te when auto-transaction is on (refs ArcadeData#317)

_save_update asked is_transaction_active() (an arcadedb_n_tx crossing) first and only then looked at
the auto-transaction flag, so the answer was thrown away whenever the flag was on. The operands are
swapped: the flag is a Python attribute, the state is a crossing. With auto-transaction on, an update
of a saved record is two crossings (the SELECT of the stale-record check and the UPDATE), not three.
With it off nothing changes: the check still comes first, outside a transaction and inside one.

The check is not replaced by the engine's refusal for the default mode: the engine runs the
before-update callbacks and the property validation before it looks for a transaction (read in
LocalDatabase.updateRecord of the 26.12.1-SNAPSHOT source), and an UPDATE of a record that is gone
raises nothing, so a save outside a transaction would run callbacks, or pass silently, instead of
raising "Transaction not begun" before it reads. A Python-side record of the transaction state is not
exact either: a SQL COMMIT or ROLLBACK statement ends the transaction without passing the wheel's
transaction calls.

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

tae898 commented Nov 14, 2026

Copy link
Copy Markdown
Collaborator Author

This change shipped in 26.11.1.post3 (on PyPI since 2026-11-14) through the release line, which #387 merged into main with a merge commit. The release line carries the same change as its own commits, so I close this draft. The measurements are on #315.

@tae898 tae898 closed this Nov 14, 2026
tae898 added a commit that referenced this pull request Nov 14, 2026
…, and the stale statements (refs #315) (#399)

JVM iter_dicts parses a batch with no tagged value without the object hook (#382); JVM insert_arrow and insert_dataframe carry a timestamp or date column as one long[] of epoch ticks (#383); native transaction calls return the state as one integer (#384); native save() of a saved record with auto-transaction on skips the transaction check (#385). The figures are copied by script from the scored gate study and each is checked against the public comment on #315. The n_update_one figure ran with auto-transaction off, so the page says off (the comment says on). The save() paragraph said three calls without the auto-transaction case. The quickstart was re-run on both published 26.11.1.post3 packages on Python 3.12 and printed the output shown.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

native The arcadedb package (native library build) performance Speed or memory of the bindings, with a measurement python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant