Repository navigation
feat(kv-store): back lmdb-v2 readOnlyTransaction with native read snapshots - #498
spalladino wants to merge 5 commits into
Conversation
Bumps the labs toolchain from `6.0.0-rc.6` to `6.0.0-nightly.20261016.a`, cut from AztecProtocol/aztec-packages `v6` at `9a642fcbf00`. - `BB_VERSION`: `6.0.0-rc.6` → `6.0.0-nightly.20261016.a` - `NOIR_VERSION`: unchanged at `1.0.0-rc.3`; the release's `noir/noir-repo` submodule is still `5a7ee9bf5e` On top of `rc.6` the nightly carries the v6 port of AztecProtocol/aztec-packages#25631 (native read-only lmdb transactions), plus the chonk memory and VK generation changes and the v6 L1 upgrade scripts. The rewrite is `./labs-aztec-toolchain/bootstrap.sh set-pins 6.0.0-nightly.20261016.a 1.0.0-rc.3`, plus `yarn` in `yarn-project` and `docs`; the lockfile diffs only move the `@aztec-foundation/*` entries.
…pshots `readOnlyTransaction` opened a write transaction, so every log query waited behind queued writes such as block ingestion and held later writes up. It now opens a native read-only lmdb transaction (START_READ_TX / CLOSE_READ_TX) and sends its id with every GET and START_CURSOR issued inside the callback, including those made through maps, multimaps and arrays. - A snapshot takes one slot from the store's reader budget (`maxReaders - 1`, shared with cursors opened outside a snapshot); cursors inside a snapshot read through it and take none. - The native store refuses a snapshot that would take the reader kept for point reads, which can only happen while point reads are in flight, so a refusal is retried with a short backoff. - Nested calls reuse the enclosing snapshot, a call inside a write transaction still reuses that transaction, and starting a write inside a snapshot throws.
|
Caution Review the following alerts detected in dependencies. According to your organization's Security Policy, you must resolve all "Block" alerts before proceeding. Learn more about Socket for GitHub.
|
- Close the cursors a snapshot's callback left open before closing the snapshot. A cursor keeps its native read transaction alive, so an abandoned iterator kept holding a reader after its JS slot was returned, and later snapshots could find none. - When the native store keeps refusing to open a snapshot, run the callback in the writer queue instead of failing the read. Sustained point reads on a small reader table can hold the spare reader through every retry. - Wake callers waiting for a reader slot when the store closes, so they reject instead of waiting forever. - `LMDBMap.getManyAsync` reads a call that spans more than one GET message from a single snapshot. Outside a transaction each chunk otherwise read its own committed state, and a commit between chunks returned a mix that never existed. - Snapshot tests now commit the competing write before reading again, instead of sleeping and hoping it landed.
…off the reader budget - Register a cursor as soon as its START_CURSOR response arrives, and have `closeOpenCursors` wait for starts still in flight. A callback that settled while a cursor start was pending left that cursor, and its reader, open after the snapshot returned its slot. - A standalone `getManyAsync` over more than one GET message runs in the writer queue instead of a snapshot. A snapshot needs a reader slot, so a bulk read inside a scan holding the last slot waited forever. - A read-only transaction that falls back to the writer queue still rejects writes: `transactionAsync` checks for an enclosing read-only transaction before reusing a write transaction, and `execInWriteTx` goes through it.
Running a multi-request `getManyAsync` in the writer queue could deadlock once the reader budget was exhausted: a scan holding the last reader slot awaits the bulk read, the bulk read waits behind a queued write, and the write waits for a reader slot to scan. A snapshot has the same problem one step earlier. A call outside a transaction is plainly chunked again and documented as not atomic; inside `readOnlyTransaction` every chunk reads the snapshot, which the test now covers.
Stack
Bottom to top, each PR targets the branch below it:
Context
Closes A-2377. AztecProtocol/aztec-packages#25631 added native read-only lmdb transactions, ported to
v6in AztecProtocol/aztec-packages#25651 and published in6.0.0-nightly.20261016.a. This PR bumps to that nightly and swaps thereadOnlyTransactionstub from #440 (which delegated totransactionAsync) for a real snapshot, soLogStorequeries stop queuing behind checkpoint ingestion.Approach
chore: bump toolchain pins to 6.0.0-nightly.20261016.a. Done withlabs-aztec-toolchain/bootstrap.sh set-pins; noir stays at1.0.0-rc.3(samenoir/noir-repocommit asrc.6). Lockfile diffs only move the@aztec-foundation/*entries. A fullnoir-projectsthenyarn-projectbootstrap leaves no generated-file drift.feat(kv-store):readOnlyTransactionsends START_READ_TX, runs the callback with aReadTransactioncarrying thetxId, and sends CLOSE_READ_TX when it settles. Every GET and START_CURSOR issued inside the callback carries thetxId(omitted otherwise, neverundefined), including reads made through maps, multimaps and arrays: the snapshot is kept in anAsyncLocalStorage, andexecInReadTxplus the container iterators usestore.getCurrentTx()(enclosing write tx, else enclosing snapshot).maxReaders - 1, shared by snapshots and by cursors opened outside one. Cursors inside a snapshot read through its slot and take none, which avoids a snapshot waiting on a slot that only it could free. Slot-holding cursor ids are tracked so CLOSE_CURSOR only returns slots it took.transactionAsync(consistent, just not concurrent with writes, and still rejecting writes) with a warning, rather than failing the read.LMDBMap.getManyAsyncover more than one GET message (over 1024 keys) is documented as not atomic outside a transaction; insidereadOnlyTransactionevery chunk reads the snapshot. Making the standalone call atomic (via a snapshot or the writer queue) can deadlock once the reader budget is exhausted by an enclosing scan, so it is left to callers.TAG_SCAN_CONCURRENCYstays at 4. Re-measured with a throwaway bench (100 blocks × 4 txs × 8 logs, 100-taggetPrivateLogsByTags): 1 → 2 still cuts latency by about a third (≈9.0 ms → ≈6.1 ms per query) and 2–16 is flat, also under 40 concurrent queries. The comment now describes the snapshot-based cost model.DATA_STORE_MAX_READERSdescription updated to mention snapshots.Testing
New
readOnlyTransactiontests inkv-store/src/lmdb-v2/store.test.ts. Each was checked to fail with the behavior it guards reverted:transactionAsyncdelegation)maxReaders = 2: returns the slot when the callback throws (hangs if the slot leaks); concurrent multi-page scans inside snapshots plus queued snapshots complete (deadlocks if snapshot cursors take slots); closes cursors left open by the callback, or still starting when it settled, so the next snapshot is a real one; rejects queued snapshots when the store closesmap.test.ts: inside a snapshot, a multi-chunkgetManyAsyncreturns one committed state when a write commits between its chunks (fails without the snapshot); a standalone one completes inside a scan that holds the only reader slotkv-storelmdb-v2 suites 133/133, allkv-storenode tests 325/325,archiversrc/store305/305 (incl. the write-contention test from #441), lint and format clean. Browser kv-store tests not run locally (no Playwright browsers).The retry-then-fallback path is only tested with START_READ_TX refusals injected; a real refusal needs point reads to hold the spare reader at the exact moment a snapshot opens.