memory: read each memU snapshot in one transaction so its parts agree - #256
memory: read each memU snapshot in one transaction so its parts agree#256oranjeai wants to merge 1 commit into
Conversation
Python `sqlite3` with the default `isolation_level=""` issues no `BEGIN` for reads, so every `SELECT` on a connection is its own snapshot. Three memU reads compose one logical result from several statements, and nothing constrains those statements to agree, so a concurrent writer can make the parts disagree. Each query is correct alone; the composition has no isolation boundary. Reproduced by committing a write between the statements of each read path: - `expand_category` (3 statements) returned `total=3` with 2 items, which `memory_expand_category` renders as "showing 2 of 3 items" for an unpaged category; - `_read_memu_snapshot_sync` (4 scans) exported a `category_items` link to an item absent from the same payload's `items`, and left every `items[].resource_id` pointing at a `resources[]` row deleted mid-read; - `_get_db_stats_sync` (5 statements) reported `total_items=3` while its type distribution summed to 4. Each read now runs in an explicit transaction: `BEGIN` after `connect`, `rollback()` in the existing `finally`, since these are read-only and must never `commit`. In `_get_db_stats_sync`, `db.close()` also moves out of the `try` body into a `finally`, so the exception path releases the transaction too; leaving it open there would strand exactly the long-lived reader that starves WAL passive checkpoints. No query is rewritten and no result shape changes. A writer now waits for a read, bounded by the read's own duration. The worst case on the live 5 GiB store is the gateway snapshot at 595 ms, against 577 ms without the transaction, and it closes before `json.dumps`. The store runs `journal_mode=wal`, where a reader never blocks a writer: measured at live row counts, a 5 s writer succeeds 6/6 with and without the change alike, waiting 9-20 ms. In `delete` mode a reader does block writers, but not because of this change: at the live store's size the item scan alone holds ~17 s, so the pre-existing per-statement hold already exceeds the 5 s timeout a startup sanitizer and a CLI backfill use, and the transaction adds ~1 s to it. A 5 s writer there fails with and without the change alike. `isolation_level` and `BEGIN` had no occurrences in `nerve/` before this change, so all three sites are pre-existing and no existing convention is contradicted. All 12 `sqlite3.connect` sites were enumerated: the other nine are single-statement reads, startup-only, CLI-only, the atomic `Connection.backup`, or a write-path read-modify-write that is a separate defect. 24 new test cases interleave a committed write at an exact statement boundary via a trace callback, most parametrized over `wal` and `delete`. Every statement of all three reads is either the first statement or has a needle firing at it, so each contract boundary is covered. Against the unfixed source with the tests kept, 19 fail with the values above; with the fix all 38 in the file pass. A mutation matrix confirms each `BEGIN`, the `close()` move, and each transaction SPAN are individually load-bearing: moving any `BEGIN` past the first read is caught. Each test also asserts whether the interleaved writer committed while the transaction was open, which is what distinguishes this `BEGIN` from a lock-taking one. `rollback()` is deliberate documentation rather than behaviour: `close()` alone releases the transaction, measured, so dropping `rollback()` kills no test.
Internal second-model review (3 rounds, 21 findings adjudicated)Before this PR was opened it went through three independent review rounds: a cold Verdict summary
Agreed and fixed
Disagreed, with evidenceA JOIN on the relation scan is not an isolation fix, so the test pinning that is kept. The writer-progress oracle's one-second sample is not timing-fragile.
Observed, not blocking
Reviewed at three commits; 8 gate runs, $34.73. |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-6:20260803-191600 |
|
cc @alex-fedotyev — could you review this? Python |
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
Description
Python
sqlite3with the defaultisolation_level=""issues noBEGINforreads, so every
SELECTis its own snapshot. Three memU reads compose one logicalresult from several statements, and nothing constrains those statements to agree,
so a concurrent writer can make the parts disagree.
Reproduced by committing a write between the statements of each read path:
expand_category(3 statements) returnedtotal=3with 2 items, rendered asshowing 2 of 3 itemsfor an unpaged category;_read_memu_snapshot_sync(4 scans) exported acategory_itemslink absentfrom the same payload's
items, and left everyitems[].resource_idpointing ata deleted
resources[]row;_get_db_stats_sync(5 statements) reportedtotal_items=3while its typedistribution summed to 4.
The fix gives each read an explicit transaction:
BEGINafterconnect,rollback()in the existingfinally(read-only, so nevercommit). At_get_db_stats_sync,db.close()also moves into afinallyso the exceptionpath releases too. No query is rewritten, and no result shape changes.
A writer now waits for a read, bounded by that read's duration. On the live
5.2 GiB store the gateway snapshot holds 702-717 ms, against 671-736 ms without it,
closing before
json.dumps. That store runsjournal_mode=wal, where a reader neverblocks a writer. In
deletemode a reader does block writers, but not because of thischange: at live row counts the item scan alone is ~2.3 s of a ~2.4 s hold, so a 5 s
writer fares the same either way.
Validation: 24 new test cases interleave a committed write at an exact statement
boundary via a trace callback, most parametrized over
walanddelete. Unfixed,19 fail with the values above; with the fix all pass, and a mutation matrix
confirms each
BEGIN, its span, and theclose()move are load-bearing.Full suite 2957 passed, same 7 pre-existing failures as
main.Enumeration of every
sqlite3.connectread innerve/All 12 call sites, classified by whether they compose more than one
SELECTinto a single result:
memu_bridge.pyexpand_categoryroutes/memory.py_read_memu_snapshot_syncmemu_bridge.py_get_db_stats_synchandlers/memory.py_fetch_history_rowshandlers/memory.py_fetch_records_rowsmemu_bridge.pyevent-date sweepmemu_bridge.pydatetime sanitizememu_bridge.pyWAL pragma, WAL checkpointcli.pyx2backup.pyConnection.backup, atomicisolation_levelandBEGINhad 0 occurrences innerve/before this change,so all three sites are pre-existing and no existing convention is contradicted.