Skip to content

feat(kv-store): back lmdb-v2 readOnlyTransaction with native read snapshots - #498

Draft
spalladino wants to merge 5 commits into
spl/log-store-read-only-snapshotsfrom
spl/kv-store-native-read-tx
Draft

spalladino wants to merge 5 commits into
spl/log-store-read-only-snapshotsfrom
spl/kv-store-native-read-tx

Conversation

@spalladino

@spalladino spalladino commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

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 v6 in AztecProtocol/aztec-packages#25651 and published in 6.0.0-nightly.20261016.a. This PR bumps to that nightly and swaps the readOnlyTransaction stub from #440 (which delegated to transactionAsync) for a real snapshot, so LogStore queries stop queuing behind checkpoint ingestion.

Approach

chore: bump toolchain pins to 6.0.0-nightly.20261016.a. Done with labs-aztec-toolchain/bootstrap.sh set-pins; noir stays at 1.0.0-rc.3 (same noir/noir-repo commit as rc.6). Lockfile diffs only move the @aztec-foundation/* entries. A full noir-projects then yarn-project bootstrap leaves no generated-file drift.

feat(kv-store):

  • readOnlyTransaction sends START_READ_TX, runs the callback with a ReadTransaction carrying the txId, and sends CLOSE_READ_TX when it settles. Every GET and START_CURSOR issued inside the callback carries the txId (omitted otherwise, never undefined), including reads made through maps, multimaps and arrays: the snapshot is kept in an AsyncLocalStorage, and execInReadTx plus the container iterators use store.getCurrentTx() (enclosing write tx, else enclosing snapshot).
  • Reader accounting: the old cursor semaphore becomes a reader-slot budget of 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.
  • The native side refuses START_READ_TX instead of blocking when it would take the reader kept for point reads. With the budget above this only happens while point reads are in flight, so a refusal is retried up to 8 times with a capped exponential backoff, after which the callback runs in the writer queue via transactionAsync (consistent, just not concurrent with writes, and still rejecting writes) with a warning, rather than failing the read.
  • Cursors a callback leaves open (e.g. an abandoned iterator), including ones whose START_CURSOR response was still in flight, are closed with the snapshot, since a cursor keeps its native read transaction, and so its reader, alive. Such an iterator throws on its next page.
  • Callers waiting for a reader slot reject when the store closes instead of waiting forever.
  • LMDBMap.getManyAsync over more than one GET message (over 1024 keys) is documented as not atomic outside a transaction; inside readOnlyTransaction every 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.
  • Nested calls reuse the enclosing snapshot. Inside a write transaction the write tx is still reused. Starting a write transaction inside a snapshot throws.
  • TAG_SCAN_CONCURRENCY stays at 4. Re-measured with a throwaway bench (100 blocks × 4 txs × 8 logs, 100-tag getPrivateLogsByTags): 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_READERS description updated to mention snapshots.

Testing

New readOnlyTransaction tests in kv-store/src/lmdb-v2/store.test.ts. Each was checked to fail with the behavior it guards reverted:

  • does not wait for a write transaction that is still running (times out on the old transactionAsync delegation)
  • lets writes commit while it is open (times out on the old delegation)
  • rejects a write started inside it (fails on the old delegation)
  • with 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 closes
  • falls back to the writer queue, still rejecting writes, when START_READ_TX keeps being refused
  • map.test.ts: inside a snapshot, a multi-chunk getManyAsync returns 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 slot

kv-store lmdb-v2 suites 133/133, all kv-store node tests 325/325, archiver src/store 305/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.

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.
@spalladino
spalladino added this pull request to stack #499 October 16, 2026 12:55
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​@​aztec-foundation/​noir-types@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a83 +21006699 +1100
Updatednpm/​@​aztec-foundation/​noir-noir_js@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a78 +11006899 +1100
Updatednpm/​@​aztec-foundation/​l1-artifacts@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a89 +11008099 +170
Updatednpm/​@​aztec-foundation/​noir-noir_codegen@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a81 +11007199 +1100
Updatednpm/​@​aztec-foundation/​noir-noirc_abi@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a84 +11007199 +1100
Updatednpm/​@​aztec-foundation/​ipc-runtime@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a84 +11007299 +1100
Updatednpm/​@​aztec-foundation/​mock-protocol-circuits-artifacts@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a82 +11007699 +1100
Updatednpm/​@​aztec-foundation/​noir-acvm_js@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a83 +11007799 +1100
Updatednpm/​@​aztec-foundation/​cdb@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a821007799 +1100
Updatednpm/​@​aztec-foundation/​bb-avm-sim@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a81 +11007799 +1100
Updatednpm/​@​aztec-foundation/​constants-codegen@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a781008499 +1100
Updatednpm/​@​aztec-foundation/​protocol-contracts-artifacts@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a83 +11007999 +1100
Updatednpm/​@​aztec-foundation/​wsdb@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a821008199 +1100
Updatednpm/​@​aztec-foundation/​protocol-circuits-artifacts@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a81 +11008399 +1100
Updatednpm/​@​aztec-foundation/​bb.js@​6.0.0-rc.6 ⏵ 6.0.0-nightly.20261016.a87 +110098 +199 +1100

View full report

@socket-security

Copy link
Copy Markdown

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.

Priority Alert  (click "▶" to expand/collapse) Action
Low priority
Obfuscated code: npm @aztec-foundation/protocol-circuits-artifacts is 98.0% likely obfuscated

Confidence: 0.98

Location: Package overview

From: yarn-project/noir-protocol-circuits-types/package.json → npm/@aztec-foundation/protocol-circuits-artifacts@6.0.0-nightly.20261016.a

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@aztec-foundation/protocol-circuits-artifacts@6.0.0-nightly.20261016.a. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Block

View full report

- 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.

This branch has not been deployed

No deployments
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.

1 participant