Repository navigation
Conversation
A filter value that is a compact approximate set (Bloom, split-block Bloom, binary fuse) instead of a list, for sets too large to send as one. It sits behind the membership-filter feature, so the default build does not change. Canaries, members as the client had them, are looked up at compile time: a structure built from other bytes than the column stores is refused instead of answered with rows silently missing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A fuse filter takes no inserts, so a client rebuilds it whenever its set grows, and most clients are TypeScript. This builds the filter value in TypeScript two ways, through the Rust builder compiled to WASM and through a port, and checks both byte for byte against xorf before timing them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Value sets too large for an inList need a compact stand-in, and the portal resends the query for every chunk, so its size decides what it costs. A set is declared once per request as a fuse8 structure with canaries and referred to by name, so from/to and both owners of a token balance share one copy. The ADR is Proposed, and the five new invariants stay unchecked until the engine implements them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Collaborator
Author
|
Superseded by #85, which implements sets as the spec describes them. The measurements in this PR's description stay here as the record behind ADR-17. |
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.
Proposes sets, a filter on value sets too large for an
inList(#82), and measures the options behind them. The PR has three parts:spec/as ADR-17, which is Proposed;membership-filterbuild feature, written before the API was settled;Not meant to merge as it is. The default build does not change, and the prototype has to be rewritten to the spec once ADR-17 is accepted.
API
The API is specified in
spec/: ADR-17 (Proposed), the request in §2.7, the lookup in §4.2.11, four new errors in §6.2, and five invariants, INV-Q15 to INV-Q18 and INV-P17.{ "type": "evm", "sets": { "pairs": { "fuse8": "<base 64>", "canaries": ["0xB4e16d…", "0x0d4a11d5…"] } }, "fields": { "log": { "address": true }, "transaction": { "from": true, "to": true } }, "logs": [{ "address": { "set": "pairs" }, "topic0": ["0x1c41…"] }], "transactions": [{ "from": { "set": "pairs" } }, { "to": { "set": "pairs" } }] }fromandto, or both owners of a Solana token balance, and its bytes travel once. The name means nothing to the engine; an SDK numbers its sets as it serialises the query, so its users never see one.fuse8. The spec fixes the lookup, not the construction, so any builder whose output the lookup accepts is correct.SetCanaryMismatch.inListfilter on ahexBytes,base58orutf8column takes a set. Every chain gets sets from its catalog, with no code.SetFieldNotSelected), so the client can see the false positives it has to drop.UnknownSet,InvalidSet,SetCanaryMismatch,SetFieldNotSelected.setswithUnknownTable. The portal can send every such request to an engine that supports them, and nothing answers one without its filter.The prototype below predates this API. It takes the structure inside the filter value, accepts four formats and a
skipRepeatsflag, and does not check the selected field.spec/GAPS.mdlists the differences.What it adds
P-MAX-SET-CANARIES, and an entry inGAPS.mdfor where the prototype differs.membership-filter. It takes the structure inside the filter value, e.g."address": {"fuse8": "<base64>", "canaries": ["0xAbC…", …]}, and accepts four structures so that they can be compared: the RFC draft's Bloom filter, Parquet's split-block Bloom filter, and binary fuse filters with 8- and 16-bit fingerprints (xorf). The structure never prunes a row group (INV-P16). An optionalskipRepeatsreuses the previous row's verdict when the value repeats.0xprefix, the base58 alphabet) and looks it up in the structure. A miss refuses the request before any row is read.membership(Gungraun, with branch simulation) andmembership_wall(criterion: the whole request, the parse alone, the predicate alone). Before it times anything,membership_wallpages through the chunk like a client and asserts that no member row is missing.benches/membership_client: the Rust builder compiled to WASM and a TypeScript port, each checked byte for byte againstxorf, with benches for both and for native Rust.Results
The set has 400 000 values. On Ethereum it is every address that emits a V2
Syncinevm-big(3 039); on Solana it is 5% of the token-balance owners insolana-small(392). Random values fill the rest: a member the chunk never mentions changes a structure's size and nothing else. AninListtakes the first 40 000, about the most a 2 MiB request carries. Every number is from one pinned core.No structure dropped a member row on any chunk.
logs.address/transactions.frominList, 40kThe others: the split-block Bloom filter is as large as the RFC one, with 1.5–4× its junk and 19 ns per row.
skipRepeatsbrings every structure to about 5 ns per row on a column the table is sorted by, and adds about 1 ns elsewhere.What the numbers say
The filter's size matters more than its junk. The portal sends the whole query in every worker request, one request per chunk, and recent chunks are small: 224 blocks on Ethereum, 48 slots on Solana. On the 224-block chunk the 400k filter is 4–8× the answer it selects (162 KB), while Fuse8's junk adds 25 KB. Over a stream:
Large
inListfilters pay this today. The 40k list is 1.8 MB per chunk, and parsing it is 7.6 ms of an 11.8 ms request on the 224-block chunk.So Fuse8. It is the smallest structure at about 0.4% false positives: 9.2 bits per element, against about 11.5 for a Bloom filter at the same rate. The bound is n·log2(1/p) bits, and Fuse8 is within 15% of it, so no other format does much better. The bits are random, so compression does not help either.
Not Fuse16, though it is faster. It returned no junk here and made the fastest requests, because a junk row costs more than its bytes: the worker reads back the output columns of the row group it sits in. On
transactionsinevm-big, 628 junk rows from a Bloom filter made that read-back 36% of the request. But Fuse16 is twice the size of Fuse8, and the size is paid on every chunk.Drop both Bloom filters. Each is larger than Fuse8 at the same rate and slower per row. The RFC's
kseeded hashes cost 3–4× a hash-set lookup on a column the table is not sorted by. A Bloom filter can also be set to match everything, so it would need a density check. A fuse8 lookup cannot be made to pass every value, whatever bytes it carries, because part of each fingerprint comes from hash bits no index reads.Canaries make a wrong build loud. Without them each of these mistakes compiles and answers an empty or partial result: mixed-case input, no
0xprefix, the 20 raw bytes instead of the text, another hash, other seeds, the other byte order. With them each is an error, and each has a test. Each part of the check was also removed in turn, to confirm a test fails without it. A wrong structure passes four canaries only by false positives, with a probability of about 2 × 10⁻¹⁰ (Fuse8). The one mistake they cannot see is a filter on the wrong field, such as an address set on a topic column, because the canaries are valid values there too.Why not a per-worker filter cache
A cache would send the filter to each worker once and refer to it by digest after that. It does not pay. A factory's set grows while the client discovers its children, so the filter, and with it the digest, changes every time the client rebuilds it. Each change misses on every worker, and each miss costs one more round trip to send the bytes.
Client build time
A fuse filter takes no inserts, so a client rebuilds it whenever its set grows. Most clients are TypeScript, so
benches/membership_clientbuilds thefuse8value there in two ways: through the Rust builder compiled to WASM (25 KB), and through a TypeScript port. Both give the same bytes asxorfon 516xxh3vectors, on every set below, and on every set size from 0 to 3 000. 33 of those sizes needed a second attempt at construction. None needed a third, so that path is untested.From a list of strings to the base64 value, Node 24, median:
A rebuild is cheap. 400k values take 26 ms through WASM, within the range of a single chunk request on the worker in the tables above. The first build in a fresh process takes 50–60 ms through WASM and 150 ms in TypeScript.
Half of the WASM path is strings, not the filter. Packing 400k strings into one buffer takes 17 ms, and the build itself 14 ms. A client that keeps its set packed, and appends each new member to it, pays only the second.
WASM memory does not shrink. It grows to the largest build so far and stays: 32 MB after 400k values, 152 MB after 2M.
2M values do not fit one request. Fuse8 over 2M values is 3.0 MB of base64, over the 2 MiB bound of ADR-13. About 1.4M values fit.
So WASM. It is 2.4–3.9× faster, and it is the engine's own code, so client and engine cannot drift apart. The port is fast enough, but it is a second implementation. A bug that spoils only some values, such as one input length or one retry path, passes the canaries and loses rows without a sign. With the port, the vectors and the size sweep would have to run in CI.
How often a client rebuilds. A stream restarts at the first block of a new member, with a set that holds every member its last batch found. So each restart moves past the end of the last batch, and a client rebuilds at most once per batch. A first pass over the factory's creation events makes it once per backfill.
Found on the way
QueryOutput::read_through()is the last block a page read, not the last it served: a page can read blocks its budget then leaves out. A caller that resumes from it loses rows; the first version of the report here lost 19. The worker resumes fromlast_block(), so it is not affected, but the doc comment invites the mistake.Not measured
Running it
SQD_MEMBERSHIP_TARGETS(logs,transactions,tokenBalances),SQD_MEMBERSHIP_CHUNK(defaultevm-big),SQD_MEMBERSHIP_SOLANA_CHUNK(defaultsolana-small) andSQD_MEMBERSHIP_CASES(a case-name substring) narrow a run. The first run derives the set files intoSQD_MEMBERSHIP_DIR.The client-side build needs
wasm-packand Node 22.18 or later.prepare.shbuilds the WASM module and writes the sets and the native reference outputs:node cold.ts <wasm|ts> <set>times the first build in a fresh process.Full tables
Request and predicate times on
evm-bigwere measured before the canary check was added; it runs once per request and costs four lookups.Ethereum
evm-big(1 592 blocks),logs.addressEthereum
evm-big(1 592 blocks),transactions.fromEthereum
evm-small(224 blocks),logs.addressEthereum
evm-small(224 blocks),transactions.fromSolana
solana-small(48 slots),tokenBalances.postOwner🤖 Generated with Claude Code