Repository navigation
perf(archiver): speed up log-by-tag queries - #441
Draft
spalladino wants to merge 9 commits into
Draft
spalladino wants to merge 9 commits into
spalladino wants to merge 9 commits into
Conversation
`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 was referenced Oct 9, 2026
spalladino
added this pull request to stack #499
October 16, 2026 12:55
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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;
getManyAsyncitself already landed in #129).getPrivateLogsByTagswas reported at 100-200ms per call in production. Real PXE traffic is ~100 tags per call,includeEffectson, 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 slowFrhex hydration.Approach
getBlock's tx-effect loop,getNoteHashesAndNullifiersand per-block log reads use one batchedgetManyAsynceach. Per-tag scans run 4-wide via a newasyncPoolToCompletion(foundation), which stops scheduling after a failure and waits for every started scan to settle before rejecting, so no scan outlives its transaction.limit <= 128are a single one-page cursor request;WriteTransaction.iteratetakes the same path when its pending batch is empty.hexSchemaForis a single transform andfromHexStringbuilds the value withBigInt('0x…')(~1.4x faster locally). Accepted inputs are unchanged, including leading zeros past 32 bytes.node_rpc_perfmints to private and adds 100-taggetPrivateLogsByTags/getPublicLogsByTagsseries shaped after the measured traffic, plus a queued variant.TAG_SCAN_CONCURRENCYmeasured 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).asyncPoolToCompletionand field hex parsing tests.