Repository navigation
fix(#9970): an interrupted nested begin() pops only the level it pushed - #10032
Conversation
run_in_transaction and TransactionContext.__enter__ decided what to roll back from a boolean taken before begin() and a 'begun' flag set after it. Inside a caller's transaction begin() pushes a NESTED level, so an interrupt after the push but before begin() returned left that level on the stack, and the caller's next commit() committed the empty level and left their work uncommitted. Ownership is now decided by the transaction stack as it was before begin(): every level above the marked nesting depth is rolled back, plus a transaction that became active where none was. A refused begin() still leaves the caller's level alone (#9813). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
This pull request does not currently match the merge queue conditions, so it cannot be queued from here. The box comes back if it matches again. |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Documentation | 2 minor |
🟢 Metrics 9 complexity
Metric Results Complexity 9
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
ReviewI read the diff and checked it against Summary: The fix is sound. Deciding ownership from the stack as it was before Checked
Suggestions (non-blocking)
Test coverage is good: the interrupt, refused-begin and failing-block scenarios are all covered for both entry points. Overall: LGTM, with the nits above. |
…d fixture teardown Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ReviewOverall this is a solid fix. Deciding ownership from the transaction stack captured before What I like
Points to consider (non-blocking)
No security or performance concerns. Python test coverage looks adequate for both entry points. The LGTM, with the optional hardening in point 5. |
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Review of #10032 (Python binding: transaction ownership by stack mark) Summary: Deciding ownership from the transaction stack captured before What looks good
Minor suggestions (non-blocking)
Style: The code follows the surrounding conventions, and I saw no new dependencies, Overall, LGTM with the optional nits above. |
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ReviewOverall this is a sound fix. Deciding ownership from the transaction stack as it was before Correctness
Style and design
Performance
Tests
🤖 Generated with Claude Code |
TransactionContext.__enter__ now asks the database for _transaction_stack_mark() before begin() and ends the levels opened since with _end_transactions_opened_since(). The #9321 fake implemented neither, so all four tests of test_transaction_context_interrupt.py failed with AttributeError on every CI platform. The fake now models LocalDatabase's stack (begin() pushes a nested level when a transaction is active, rollback() pops the top one) and implements both methods. A new test pins #9970 on the context manager: an interrupted nested begin() pops only its own level and leaves the caller's transaction active. It fails against origin/main's TransactionContext. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReviewThe ownership rule is sound: comparing the stack against a mark taken before Strengths
Non-blocking notes
LGTM. |
Closes #9970
Summary
Database.run_in_transactionandTransactionContext.__enter__(with db.transaction():) decided what to roll back from a boolean taken beforebegin()(was_active) and, inrun_in_transaction, abegunflag set after it. Inside a caller's own transactionLocalDatabase.begin()does not refuse, it pushes a NESTED level. So an interrupt landing after the push but beforebegin()returned leftbegun=False, was_active=True, nothing popped the new level, and the caller's nextcommit()committed that empty level and popped it, leaving the caller's own transaction open and their work uncommitted (silent data loss rather than a leaked handle).Ownership is now decided by the transaction stack as it was before
begin(), as the issue suggests: a newDatabase._transaction_stack_mark()captures(is_transaction_active(), getNestedTransactions()), andDatabase._end_transactions_opened_since(mark)rolls back every level above the marked depth, then the transaction that became active where none was active. One helper, two callers, nobegunflag.The second clause is needed because the depth alone does not see the non-nested case:
begin()with no active transaction reuses the base context (depth stays 1 when an inactive context already exists), so "depth grew" and "a transaction became active" are both ownership signals.Finding ledger
run_in_transactionleaves an interrupted nested level on the stack (core.py:478)TransactionContext.__enter__has the same gap (transactions.py:27), and__exit__never runsinsert_many/ graph-batch paths cannot nest (guarded byif not was_active)Completeness
Invariant: a transaction-opening helper in the Python binding rolls back exactly the transaction levels it opened, never a level the caller held when it was called.
grep -rn "\.begin()" bindings/python/src:core.pyrun_in_transactiontest_interrupt_after_a_nested_begin_pops_only_that_level,test_a_failing_block_inside_a_callers_transaction_rolls_back_its_level, existing #9813 teststransactions.pyTransactionContext.__enter__TestTransactionContextBeginHygiene(3 tests)core.pyinsert_many(begin at 677 / 694)if not was_activecore.pyper-row/graph path at 762if not was_activegraph_batch.pycreate_vertexif not isTransactionActive()DocumentBatcher.javainsertManyJson/insertColumnsif (!wasActive)Behavior note: in
run_in_transactionwith a caller transaction held, a block that fails AFTER its nested level was already committed/popped (e.g. a failed nested commit, which pops inLocalDatabase.commit()'sfinally) used to roll back the caller's level too (begunwas True). The depth comparison leaves the caller's level alone, which is the ownership rule this fix establishes. A block that leaves extra levels of its own open is now unwound completely instead of by one level.Test plan
test_interrupt_after_a_nested_begin_pops_only_that_level(run_in_transaction andwith db.transaction():): caller holds a transaction with one saved row,begin()is patched to run the real begin then raiseKeyboardInterrupt; asserts depth is back to 1, the caller's transaction is still active, and the caller's commit persists the row. Both FAILED before the fix (assert 2 == 1) and pass after.TransactionContexttests for the non-nested interrupt and the refusedbegin()inside a caller's transaction.test_a_failing_block_inside_a_callers_transaction_rolls_back_its_level.tests/test_interrupt_hygiene.py tests/test_run_in_transaction_retry.py tests/test_sigint.py tests/test_transaction_config.py tests/test_core.py tests/test_cross_model_atomicity.py tests/test_concurrency.py: all pass excepttest_to_columns_typed_bulk_materialization(numpy not installed locally).test_bulk_insert, test_graph_batch, test_bridge_fixes, test_async_executor, test_graph_api, test_columnar_readers, test_insert_columns: identical failure set (24, locally stalearcadedb-python-bridge.jarwithoutDocumentBatcher, plus numpy) with and without the patch, diffed.arcadedb-engine-26.11.1-SNAPSHOT.jar; CI's Python job runs against freshly built jars.Coverage
Covered:
Database.run_in_transaction,TransactionContext.__enter__(with db.transaction():).Known gaps: None. The remaining
begin()callers only begin when no transaction is active and cannot push a nested level.Adversarial pass
The Phase 1.5 independent subagent could not be spawned in this run (no subagent tool available to this agent); a self-review was done instead against the issue body and the diff:
getNestedTransactions()is unavailable: not real, it is declared onBasicDatabase, so every Java database the binding wraps has it.try, before anything is opened, same as the formerwas_activeread.Review cycles
test_a_failed_nested_commit_leaves_the_callers_transaction_open(fails on main: the caller's level was rolled back),TransactionContextfailing-block test for__exit__, try/except around fixture teardowntest_a_failing_rollback_does_not_skip_the_levels_below_it(fails on cycle-2 code)begin()queuedpast the 15 min window (runner backlog), no review# nosec B110, as the rest of the binding doesDeferred / skipped review items
Databasehas one": none exists;getNestedTransactions()is declared onBasicDatabase, so the private helper calls it directly. Skipped.finalkeyword) and Bump metrics.version from 4.2.2 to 4.2.3 #6 (placeholder section): not applicable / done here.getNestedTransactions()call next tobegin();is_transaction_active()cannot be derived from the depth because an inactive base context still counts as one level.transactions.py": same package, andTransactionContextonly wrapsDatabase. Skipped.rollbackAllNested()failure is silent in the fixture": intended, it only keepsdrop()working after a failed assertion; the assertions themselves check the depth.test_sigint.py, the window here cannot be hit deterministically with a real signal.Final state
max-cycles-reached(4 cycles; the cycle-4 review did not arrive within the window). Every review so far was LGTM with non-blocking nits.🤖 Generated with Claude Code
Summary by CodeRabbit