Skip to content

perf(archiver): speed up log-by-tag queries - #441

Draft
spalladino wants to merge 9 commits into
spl/kv-store-read-only-txfrom
spl/faster-logs-by-tags
Draft

spalladino wants to merge 9 commits into
spl/kv-store-read-only-txfrom
spl/faster-logs-by-tags

Conversation

@spalladino

@spalladino spalladino commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Stack

Bottom to top, each PR targets the branch below it:

Context

Part of A-1817. Redoes AztecProtocol/aztec-packages#25254 plus the archiver call-site conversions from #25282 (both closed after the repo split; getManyAsync itself already landed in #129).

getPrivateLogsByTags was reported at 100-200ms per call in production. Real PXE traffic is ~100 tags per call, includeEffects on, and a near-total miss rate. Costs were sequential per-tag scans each paying 2-4 native cursor round trips, one point read per tx/log in several archiver paths, and slow Fr hex hydration.

Approach

  • Archiver: getBlock's tx-effect loop, getNoteHashesAndNullifiers and per-block log reads use one batched getManyAsync each. Per-tag scans run 4-wide via a new asyncPoolToCompletion (foundation), which stops scheduling after a failure and waits for every started scan to settle before rejecting, so no scan outlives its transaction.
  • kv-store lmdb-v2: scans with limit <= 128 are a single one-page cursor request; WriteTransaction.iterate takes the same path when its pending batch is empty.
  • Foundation: hexSchemaFor is a single transform and fromHexString builds the value with BigInt('0x…') (~1.4x faster locally). Accepted inputs are unchanged, including leading zeros past 32 bytes.
  • Bench: node_rpc_perf mints to private and adds 100-tag getPrivateLogsByTags/getPublicLogsByTags series shaped after the measured traffic, plus a queued variant.

TAG_SCAN_CONCURRENCY measured on the current write-tx-backed path (100 tags, includeEffects): 1 → 4.4ms, 2-16 → 2.9-3.1ms (mostly misses); 4 was marginally best. To be re-tuned once reads run on native snapshots.

Testing

  • log_store_write_contention: queued writes delete one block's logs each; the racing tag query must match a coherent "first k deletions applied" state (fails 6/6 with the transaction removed).
  • Bounded-scan tests straddling the 128/129 page boundary, forward/reverse, end key, pending writes; cursor slots not held while a consumer pauses.
  • asyncPoolToCompletion and field hex parsing tests.

`getBlock` awaited one store read per tx effect, `getNoteHashesAndNullifiers` (the `includeEffects` path of the tag
queries) sent one read per tx hash, and the per-block log reads behind `getPrivateLogsForBlock` and
`getPublicLogsForBlock` read one log at a time. Each of these now issues a single batched read. Missing-key
handling, warnings and output order are unchanged.
Range scans with `limit <= 128` now ask the native side for the whole limit in one START_CURSOR request with
`onePage` set, instead of paging 10 entries at a time. Such a scan costs one native round trip and does not keep a
cursor (and with it one of the store's limited reader slots) open while the consumer works through the entries.

`WriteTransaction.iterate` and `iterateIndex` take the same path when the transaction has no pending writes in the
relevant batch: nothing pending can shadow or suppress a committed entry, so the limit can be pushed down to the
cursor. Reads inside a transaction, which is where the archiver's tag queries run, benefit as well.
`hexSchemaFor` now validates and hydrates in a single zod transform instead of a refine/refine/transform chain, and
`fromHexString` builds the field element with `BigInt('0x...')` instead of going through a Buffer. Both matter when
a JSON-RPC response carries thousands of field elements, as log-by-tag responses do. Locally, `fromHexString` went
from ~780 to ~565 ns per field and `Fr.schema.parse` from ~1.5 to ~1.1 us.

Over-long values are still rejected with the same error, and exceptions thrown by the hydrating class still
propagate as before.
A tag query scanned its tags one at a time, paying the native cursor round trips of every scan in sequence. The
scans now run four at a time via `asyncPool`, inside the same `transactionAsync` as before, preserving the per-tag
result order and the query's consistency guarantees.

Measured on a seeded store (20 blocks x 4 txs x 5 private logs, 100-tag queries with `includeEffects`), concurrency
1 -> 4 took a mostly-miss query from ~4.4 to ~2.9 ms and an all-hit query from ~8.7 to ~6.5 ms. Concurrency 2 through
16 measured within noise of each other.

Adds a test asserting that a tag query racing queued write transactions returns the same results as an uncontended
one.
The existing log benchmarks queried a single random tag, which does not resemble PXE traffic: recipient sync sends
about 100 tags per call with `includeEffects` set and a near-total miss rate. The bench now mints to private so the
built blocks carry real private logs, harvests their tags, and adds:

- `getPrivateLogsByTags_100tags`: 1 matching tag among 100, `includeEffects`, `referenceBlock`
- `getPrivateLogsByTags_100tags_allhits` and `getPublicLogsByTags_100tags`: every tag matches
- `getPrivateLogsByTags_100tags_queued`: one 100-tag query issued right behind five concurrent ones
The Buffer-based `fromHexString` handed the constructor a bigint, so a string with more than 32 bytes of leading
zeros (e.g. `0x` + 65 zeros + `1`) parsed fine. The faster version added a byte-length check that rejected it. Drop
the check: the constructor still rejects any value that does not fit in the field, and parsing behaves as before.
`asyncPool` rejects as soon as one call fails while the others keep running, so a failed per-tag scan let its
siblings keep issuing cursor reads after the enclosing transaction had closed. Adds `asyncPoolToCompletion` to
foundation, which stops starting new calls after a failure and rejects with the first error only once every started
call has settled, and uses it for the tag scans.
…age boundary

The cursor-slot tests raced the scan against a 500 ms timer; they now await the scan and rely on the test timeout
if it blocks. The pagination tests use limits of 128 and 129 so both the single-page and the paged paths are
covered, forward and reverse, with and without an end key, and with pending writes in a transaction.
The queued writes only slept, so the test could not tell a consistent query from a torn one. They now each delete
one block's logs, and the test asserts that the racing query returns one of the coherent states (the first k
deletions applied) and that the final state is reached once all writes land. With the query's transaction removed,
the test fails in 6 of 6 runs.

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