Skip to content

fix: resolve SQLite transaction nesting errors (#135, #181)#205

Open
ghiemer wants to merge 5 commits intotirth8205:mainfrom
ghiemer:fix/sqlite-transaction-nesting
Open

fix: resolve SQLite transaction nesting errors (#135, #181)#205
ghiemer wants to merge 5 commits intotirth8205:mainfrom
ghiemer:fix/sqlite-transaction-nesting

Conversation

@ghiemer
Copy link
Copy Markdown

@ghiemer ghiemer commented Apr 10, 2026

Summary

Fixes sqlite3.OperationalError: cannot start a transaction within a transaction during full_build and incremental_update when deleted/stale files are present.

Root cause: Python's sqlite3 module with the default isolation_level="" silently opens implicit DEFERRED transactions on every DML statement. When remove_file_data() issues DELETE statements for stale files, the implicit transaction is never committed. The subsequent store_file_nodes_edges() then executes BEGIN IMMEDIATE inside the already-open transaction — SQLite rejects this.

This PR fixes the root cause by setting isolation_level=None (disabling implicit transactions entirely), adds defense-in-depth guards, and wraps all batch-write operations in explicit transactions for atomicity.

Changes

Root cause fix

  • graph.py: Set isolation_level=None on sqlite3.connect() in GraphStore.__init__ — eliminates implicit transactions project-wide
  • registry.py: Apply same fix to ConnectionPool.get()
  • embeddings.py: Apply same fix to EmbeddingStore.__init__

Defense-in-depth

  • graph.py: Add in_transaction guard before BEGIN IMMEDIATE in store_file_nodes_edges() — catches any residual open transaction from external _conn access (flows.py, communities.py, etc.)
  • graph.py: Expose public rollback() method on GraphStore
  • incremental.py: Commit after stale-file purge in full_build() and after deleted-file removal in incremental_update()

Atomic batch operations

  • tools/build.py: Wrap each _compute_summaries block (community_summaries, flow_snapshots, risk_index) in BEGIN IMMEDIATE / COMMIT / ROLLBACK
  • flows.py: Wrap store_flows() in explicit transaction
  • communities.py: Wrap store_communities() in explicit transaction

Tests

  • test_store_after_remove_no_transaction_error — single remove then store
  • test_store_after_multiple_removes_no_transaction_error — bulk removes then store (simulates full_build stale-file purge)

Why not just a guard?

Four other PRs (#94, #162, #181, #193) propose symptom-level fixes (guards, savepoints, commit-after-delete). This PR goes further:

Approach Root cause? Atomic batch ops? All connect() sites?
PR #162 (guard only)
PR #181 (commit + guard)
PR #94 (rollback + guard) Partial
PR #193 (savepoint)
This PR isolation_level=None ✅ All 5 batch-write sites ✅ All 3 connect() calls

Test plan

  • All 617 existing tests pass (uv run pytest tests/)
  • 2 new regression tests pass
  • Full rebuild on real repo (615 files, 4032 nodes, 28302 edges) — zero errors
  • 8 edge-case scenarios tested via MCP server:
    • Full rebuild after full rebuild (stale-file stress)
    • Incremental after full rebuild
    • Build with postprocess=none then separate postprocess
    • Build with postprocess=minimal
    • Impact radius + detect changes after rebuild
    • Rapid-fire incremental updates
    • Full rebuild after postprocess (transaction leak test)
    • Idempotency check (consistent node/edge counts)

Closes #135, closes #181. Relates to #162, #193, #94, #110.

🤖 Generated with Claude Code

ghiemer and others added 5 commits April 10, 2026 10:26
…205#135, tirth8205#181)

Python's sqlite3 with default isolation_level="" silently opens a
DEFERRED transaction on the first DML statement. When remove_file_data()
is called for stale/deleted files, the DELETE statements open an implicit
transaction that is never committed. The subsequent
store_file_nodes_edges() then executes BEGIN IMMEDIATE inside that
already-open transaction, causing:

  sqlite3.OperationalError: cannot start a transaction within a transaction

This affects both full_build (stale-file purge loop) and
incremental_update (deleted-file handling).

Fix:
- graph.py: Add in_transaction guard before BEGIN IMMEDIATE in
  store_file_nodes_edges() to flush any pending implicit transaction
- graph.py: Add public rollback() method to GraphStore
- incremental.py: Commit after stale-file purge loop in full_build()
- incremental.py: Commit after deleted-file removal in incremental_update()

Tests:
- test_store_after_remove_no_transaction_error (single remove → store)
- test_store_after_multiple_removes_no_transaction_error (bulk removes → store)

Closes tirth8205#135, tirth8205#181, relates to tirth8205#162, tirth8205#193, tirth8205#94

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The in_transaction guard from the previous commit addresses the symptom.
This commit fixes the root cause: Python's sqlite3 default
isolation_level="" silently opens implicit DEFERRED transactions on
every DML statement. Setting isolation_level=None disables this legacy
behavior entirely, making all transaction management explicit.

Changes:
- graph.py: Set isolation_level=None in sqlite3.connect() to disable
  implicit transactions — this is the actual root-cause fix
- graph.py: Downgrade in_transaction guard log from debug to warning
  (it should never fire now; if it does, something is wrong)
- tools/build.py: Wrap each _compute_summaries block in explicit
  BEGIN IMMEDIATE / COMMIT / ROLLBACK — previously these DELETE+INSERT
  sequences ran without transaction protection, risking partial writes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- incremental.py: Reword commit-after-delete comments to explain the
  reason (avoiding nested transactions) instead of describing the
  mechanism (flushing implicit transactions)
- test_graph.py: Reword test comments to explain the scenario being
  guarded against rather than implementation details that changed
  with the isolation_level=None fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… ops

Audit of the entire codebase found two additional sqlite3.connect()
calls that were still using the default isolation_level="" (implicit
transactions), plus two batch-write functions without transaction
protection.

Connections:
- registry.py: ConnectionPool.get() now passes isolation_level=None
  to prevent implicit transactions on pooled connections
- embeddings.py: EmbeddingStore.__init__ now passes isolation_level=None
  to match GraphStore behavior

Atomic batch operations:
- flows.py: store_flows() wrapped in BEGIN IMMEDIATE / COMMIT / ROLLBACK
  to prevent partial flow data if an exception interrupts the loop
- communities.py: store_communities() wrapped in BEGIN IMMEDIATE / COMMIT /
  ROLLBACK to prevent partial community data on crash

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lock file was out of sync — still referenced v2.0.0 while
pyproject.toml already declared v2.2.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

sqlite3.OperationalError: cannot start a transaction within a transaction on update when git diff includes deleted files

1 participant