Storage architecture
Keynote is a 60,096,016-byte universal Mach-O. The call graph recovered from it is 84,000,308 bytes — larger than the binary it describes. The catalog row that answers is this a Mach-O, is it malicious, who uploaded it measures 1,388 bytes.
Across one corpus of 7,579 binaries, 3,857 of which have a call-graph sidecar on disk:
110 B lib/libresolv-0.9.33.2.so (smallest of 3,857)
20,045 B p50
598,330 B p90
12,842,687 B p99
84,000,308 B Keynote (largest)
─────────────
1,388 B a catalog row, compressed — one hand-built representative
row, not a corpus percentile
Five orders of magnitude in the detail, a near-constant in the catalog. That gap is the design: a field you filter across binaries and a field you read for one binary cannot live in the same store without one of them setting the cost of the other.
Every binary is therefore stored under two shapes — a fixed-size row in a SQL catalog for anything filtered across binaries, and files on disk for anything read about one binary — with an in-RAM key-value store for identity, billing and the job queue, and separate vector and full-text indexes for search. One upload writes into six stores, and only three of the artifacts that result cannot be rebuilt.
| Question | Section |
|---|---|
| What is stored twice, how big is each half, and how do two parties share one SHA? | Catalog rows and detail sidecars · Keys and visibility scopes |
| Which engine holds what, and what does one operation cost? | Storage engines |
| What survives a deploy, and what is re-derived at boot? | Durability and rebuild |
| What changes at ten times the corpus? | Catalog scaling: 100K to 1M binaries |
| Why was vector search slow, and what fixed it? | Vector index metric mismatch · Vector index parameters and maintenance |
| What has been silently lost, and to which defect? | Undecodable postcard sidecars · Vector upsert data loss |
| What is still slow, and what has never been measured? | Known costs · Not measured |
Catalog rows and detail sidecars
The split is by read pattern: the catalog row carries everything a list, a filter, a facet or a visibility check reads, and the per-binary detail carries everything else, opened only when a request asks for that specific part.
| Catalog row | Per-binary detail | |
|---|---|---|
| Holds | name, architecture, verdict, attributed families, uploader, SBOM components, package membership, entry point, per-dylib import counts, export count, function origins, the one-line summary and title | every function, string, section, segment, load command, cross-reference, the disassembly, capabilities, signatures, secrets, and the DNA summary the page renders — the searchable DNA vector itself is in neither half, but a column in the vector store |
| Read when | any list, filter, facet or visibility check runs | one binary is opened, and only the part asked for |
| Serialization | named msgpack, then zstd-3 | pre-projected JSON, plus typed sidecars |
| On-disk layout | one row per key in the SQL catalog | card bundle and full analysis: one unsharded directory per SHA, per scope. Call-graph, functions and extras sidecars: sharded two levels deep by SHA prefix. Uploaded bytes: sharded by upload id, not SHA |
| Measured size | 1,388 B compressed for a representative populated row | card bundle p50 13 KB, p95 62 KB, p99 173 KB, max 2.05 MB across a 7,156-binary corpus; call-graph sidecar 110 B to 84,000,308 B raw across 3,857 files; Keynote's exports-and-imports sidecar 681,495 B raw |
The detail is split further by which request needs it: the call graph opens only when a call-graph node is asked for, the full exports and imports lists only when those are asked for, neither when a binary is merely looked up.
One ingest writes more than the two halves, and everything after the catalog transaction is best-effort: a failed write logs a warning and continues, so a lost card bundle degrades that binary's detail page until the next re-analysis rather than failing the upload.
| Written per binary | Store | Note |
|---|---|---|
| Registry row + thin analysis record | SQL catalog | one transaction; the only pieces read across binaries |
card-bundle.json | disk, per scope | the pre-projected detail page |
| The full analysis tree | disk, per scope | the largest artifact class — p99 ~175 MB pretty-printed — stored compact plus zstd-3, which cuts it ~90% |
| Call-graph artifact, functions sidecar, extras sidecar | disk, sharded | postcard + zstd for the call graph, JSON for the two that keep untyped fields |
| One search document | full-text index | stored fields plus facets |
| One vector row | vector store | DNA vector, learned embedding, rerank sets, visibility facet |
| Document-frequency counts | LMDB | corpus-wide, read during the rerank |
Reading one detail file is a read plus a parse, and the parse dominates: 28.0 µs for a 12 KB card bundle, 55% of it parse; 2,047 µs for a 4 MB firmware bundle, 89% parse. A 256 MiB in-process cache keyed by scope, SHA and analysis version sits in front of that, so a repeat view skips the syscall and the parse, though a hit still copies the record. Its budget is bytes rather than entries because bundle sizes span three orders of magnitude, and a re-analysis writes a new version, so the key misses rather than returning stale.
Compression, measured on the representative row above and on Keynote's real call graph:
| Blob | Raw | Compressed | Ratio |
|---|---|---|---|
| Registry row | 840 B | 457 B | 1.84× |
| Analysis record | 1,892 B | 931 B | 2.03× |
| Catalog row, both parts | 2,732 B | 1,388 B | 1.97× |
| Keynote call graph | 84,000,308 B | 9,676,030 B | 8.7× |
A blob without the zstd frame magic is returned verbatim on read, so rows written before compression still decode. Decompression is bounded — 2 GiB for the full analysis tree, 192 MiB for the strings sidecar — and a decode that reaches its cap is refused as truncated rather than parsed from a partial prefix.
Keys and visibility scopes
The registry row is keyed globally by SHA while the analysis record is keyed by SHA and visibility scope, so the same bytes can carry three isolated analyses at once — three parties analyzed the same file.
| Record | Key | Cardinality | In the sample corpus |
|---|---|---|---|
| Registry row | SHA, global | one per SHA — it exists, here is everyone who has uploaded it | 7,579 distinct SHAs in the ledger |
| Analysis record | SHA + visibility scope: /public, /private/<user id>, /workspace/<workspace id> | up to one per scope, isolated from each other | — |
| Upload ledger record | upload id | one per upload | 9,533 |
lib/libresolv-0.9.33.2.so appears twice in that ledger, under two upload ids and one SHA. The registry row is one; the sightings are many.
Answering when was this hash first seen, and what filenames has it worn is a point-get on a per-SHA rollup in the catalog, folded incrementally on every upload, which keeps the ledger off the request path. On a rollup miss it falls back to scanning the append-only ledger line by line — 14.0 ms p50 over a 100,000-row ledger, 93.8 ms over 650,000 — a scan that grows with total uploads ever made rather than with corpus size, memory bounded by the one SHA's own record count and time by nothing. The rollup is a derived cache, so a dropped write costs one slow read and heals at the next boot reconcile.
Storage engines
Four stores hold the platform's state, chosen by access shape rather than by data size, so a stall in one plane cannot reach another.
| Engine | Holds | Chosen for | One operation, measured |
|---|---|---|---|
| etch — in-RAM KV, write-ahead log + snapshot | users, API keys, workspaces, grants, invites, the referral ledger; separately, the analysis job queue | read on every authenticated request; bounded by user count, never by corpus | user lookup 0.125 µs p50; workspace + members 27.6 µs |
| turso — in-process SQLite-compatible SQL, on disk, WAL | registry rows, analysis records, findings, malware, packages, membership, sightings rollups | indexed point-get, the "older than version" reindex walk, visibility filtering | point-get 17.3 µs p50 over 100K rows, 125.9 µs over 1M |
| Lance — columnar vector store, on disk | the 106-dim DNA vector, a 128-dim learned embedding, the rerank sets, the visibility facet | ANN with the visibility predicate pushed into the search, and an index that extends incrementally | filtered ANN 5.19 ms p50 over 100K vectors |
| Tantivy | one document per binary: stored fields plus facets | full-text query and facet aggregation in one pass | query + facets + visibility 3.07 ms p50 over 50,000 documents |
Three stores sit beside them:
| Store | Holds | Note |
|---|---|---|
| LMDB | corpus-wide document-frequency counts | read during the Jaccard rerank. Below 1,000 binaries the counts are too noisy to trust, so similarity silently falls back to flat per-set weights — a small self-hosted deployment scores neighbours differently from a large one |
| Blob backend — local filesystem, or an S3-compatible bucket (a self-hosted rustfs) | uploaded bytes; on the object-store backend, every derived artifact as well | lets a compute-farm node read the bytes it needs directly instead of relaying them through the coordinator. On the object-store backend a derived file is mirrored to the bucket on write and fetched back on a local miss, so a node that never ingested a binary still serves its sidecars; on the local backend the local file is the only copy |
| CVE database — its own SQL file | the CVE index | the one store whose size has nothing to do with the corpus |
Matching a 12-component SBOM produced 240 findings in 8.2 ms p50 against a 10,000-CVE index and 8.2 ms against a 100,000-CVE index. The cost is the 12-component fan-out, not the index.
Durability and rebuild
Three things cannot be rebuilt — the accounts write-ahead log, the uploaded bytes, and the upload ledger. Everything else is derived, and a deploy re-derives it.
| Tier | Across a deploy | Rebuilt from |
|---|---|---|
| Accounts WAL — users, API keys, workspaces, grants, invites, referral ledger | survives, on its own WAL and snapshot, physically isolated from every regenerable tier | nothing; irreplaceable |
| Uploaded bytes + the upload ledger | survive | nothing; irreplaceable |
| SQL catalog, including the sightings rollups | wiped | the uploads directory and the ledger, by the boot reconcile |
| Job queue | wiped | re-enqueued from the ledger at boot |
| Vector and full-text indexes | wiped on a schema-revision bump | a corpus walk, or an explicit reindex |
| Detail files and sidecars | survive on disk | a re-analysis, if lost |
A re-analysis does not append to the upload ledger. That is what makes the ledger irreplaceable rather than merely inconvenient to lose: the sightings history of every binary is reconstructible only from it.
Catalog scaling: 100K to 1M binaries
Ten times the rows costs 1.7× the resident memory and 7.3× the point-get latency: memory stops being the ceiling and read latency inherits the job.
Measured on one macOS aarch64 box, release build, with catalog rows and vectors synthesized so no analysis time is included. Every latency is single-threaded per-operation unless stated. The catalog runs in WAL journal mode at normal synchronous durability — not an fsync per commit — with a 5-second busy timeout, and bulk import commits every 5,000 rows, the batch the rows-per-second figures amortize over.
| 100K binaries | 1M binaries | |
|---|---|---|
| Catalog point-get, p50 / p99 | 17.3 µs / 33.4 µs | 125.9 µs / 377.1 µs |
| Ingest one binary (registry row + record + membership, one transaction) | 91.7 µs p50, 6,672 ops/s | 99.3 µs p50, 5,864 ops/s |
| Bulk import | 17,041 rows/s | 7,827 rows/s |
| Full catalog scan | 288,417 rows/s | 177,110 rows/s |
| Concurrent point-gets, 32 readers | 284,069 reads/s | 228,486 reads/s |
| Catalog on disk (compressed) | 143 MB | 1,267 MB |
| Resident memory after seeding | 180 MB | 304 MB |
| Boot: open, then first read | 228 ms, then 163 µs | 1,434 ms, then 145 µs |
The catalog is page-cached SQL rather than heap-resident objects, so resident memory tracks the working set and not the corpus — 10× the rows, 1.7× the memory. The counterweight is the point-get: 17.3 µs to 125.9 µs, 7.3× for 10× the rows, because at 1M the working set no longer sits in page cache.
Concurrency has a knee, not a cliff:
| Concurrent readers | 100K binaries | 100K p99 | 1M binaries |
|---|---|---|---|
| 1 | 58,450 reads/s | 27.2 µs | 47,236 reads/s |
| 8 | 173,368 reads/s | 101.9 µs | 163,456 reads/s |
| 32 | 284,069 reads/s (peak) | 207.1 µs | 228,486 reads/s |
| 128 | 266,385 reads/s | 658.9 µs | 230,517 reads/s |
Throughput is bought with latency: across that 100K sweep p99 climbs 24× while reads per second climb under 5×. Writes serialize through a single thread regardless — SQLite admits one writer — but reads run on a pool of eight connections off the same database handle, so readers parallelize while the writer stays alone.
Vector index metric mismatch
The ANN index was trained with the library's default L2 distance while every query asked for cosine, and on a metric mismatch Lance emits a warning and silently falls back to brute force — so the index existed, cost 10.5 seconds to build, and was bypassed by every query.
| Query | 100K rows | 500K rows | 1M rows |
|---|---|---|---|
| Filtered ANN, p50 | 5.19 ms | — | 44.0 ms |
| End-to-end similarity, p50 | 17.0 ms | — | 80.0 ms (p99 100.7 ms) |
| Unfiltered, L2-trained index (flat scan) | — | 13.6 ms | — |
| Unfiltered, cosine-trained index | — | ~1 ms | — |
| Visibility-prefiltered, cosine-trained index | — | ~6 ms | — |
The 100K and 1M rows are benchmark output; the three 500K rows come from the diagnostic run that identified the metric bug and are recorded in no benchmark artifact.
44.0 ms against 5.19 ms is 8.5× for 10× the rows — the scaling of a linear scan, not of an approximate index. The 13.6 ms flat scan at 500K is the same curve that reads 44 ms at 1M.
Roughly half of the 80 ms end-to-end at 1M is not search at all: 44 ms is the approximate query, ~36 ms the rerank. Each query pulls 500 candidates, then point-gets and deserializes every candidate's content sets to score them by weighted Jaccard — a cost linear in that pool. The pool was widened from 100 to 500 because cosine ordering on the count vector and Jaccard ordering on the content sets diverge, and true top-K matches fell outside the tighter pool.
Deploying the metric fix does not repair an index that already exists: a build that finds one present folds the new fragments in and returns without checking which metric it was trained with, so a store built under L2 keeps flat-scanning until it is wiped or explicitly reindexed.
The 1M benchmark has not been re-run since, so the 44 ms above stands as the last measurement at that scale — the cost of brute-forcing a million 112-dimensional vectors, not of the index that now serves the query.
Vector index parameters and maintenance
The index probes 20 IVF partitions per query, prefilters through scalar BTree indexes, stores the DNA vector zero-padded to 112 dimensions so product quantization divides evenly, and is maintained by incremental fold rather than retrain. The probe count changed alongside the metric and had never been exercised, because it is a no-op on a query that flat-scans.
nprobes — the number of IVF partitions probed per query — is now set explicitly on every vector query. From the same 500K diagnostic run:
| Partitions probed | Query p50 |
|---|---|
| 1 | ~0.9 ms |
| 20 (configured) | — |
| 50 | ~1.7 ms |
Scalar BTree indexes on the visibility and file-kind columns make the prefilter an indexed predicate rather than a pre-scan.
Vector widths decide whether product quantization applies at all:
| Vector | Native dims | Stored dims | PQ split |
|---|---|---|---|
| DNA | 106 | 112, zero-padded | 7 sub-vectors of 16 — derived by the index builder, not configured |
| Learned embedding | 128 | 128 | already divisible |
The pad to 112 is the deliberate part; the index's own partition count follows the data, at roughly the square root of the row count.
The learned embedding gets an index of its own only once 1,024 rows carry a non-null value; below that floor its search flat-scans. The floor exists because the attempt itself is a full-table training-sample scan, which ungated ran on every two-minute maintenance tick even on a corpus holding zero embeddings.
Index maintenance is incremental, so ingest never pays a corpus-wide rebuild:
| Operation | 100K rows | 1M rows |
|---|---|---|
| Fold 10,000 added rows into the existing index | 0.16 s | 1.14 s |
| Full retrain | 6.0 s | 10.5 s |
Both search planes carry a schema revision, and a bump invalidates the plane rather than migrating it:
| Plane | Current revision | On mismatch |
|---|---|---|
| Vector | revision 1 — added the file-kind predicate column | similarity queries return 503 until an explicit reindex runs; the daemon logs the mismatch at boot and skips the populate |
| Full text | schema version 12 — renamed the symbol-hash field | the index directory is wiped at open and repopulated from the stored analysis files |
Undecodable postcard sidecars
Every call-graph sidecar written as postcard could be written and never read: its calls, symbols, data_xrefs and noreturn_vas fields are untyped JSON values, postcard can serialize those but can never deserialize them, and a non-self-describing format cannot answer deserialize_any. Every write succeeded. Every read failed. Every call-graph request silently returned no graph. Two more sidecar classes carried the same untyped lists and the same defect.
| Sidecar | Files in the corpus | On disk | Symptom | Replaced by |
|---|---|---|---|---|
.cg.postcard — call graph | 3,857 | 2,565 MB | every call-graph request returned no graph | a flat typed struct — imports resolved once at write time rather than re-walked on every read, data cross-references dropped because no serving path reads them |
.extras.postcard — exports and imports | 5,932 | 108 MB | undecodable by any reader in the codebase | JSON, which keeps the untyped fields |
| per-binary function list | not counted | not counted | every request returned 404 | JSON, which keeps the untyped fields |
All three replacements changed the encoding and the extension, so the dead sidecars are orphaned rather than migrated, and a re-analysis is what restores the graph. The 2.67 GB they still occupy is reclaimed by an operator sweep of the cache roots, safe to run with the server up because no code path can read what it deletes.
Vector upsert data loss
The vector store had the mirror-image failure: its upsert deleted the SHA's row and then appended the replacement, as two commits, so when a row arrived carrying a column the on-disk schema lacked, the delete committed and the append was rejected — re-upserting a binary destroyed its vector. The index drained one row at a time, silently, and only while being refreshed.
It is now a single merge_insert on the SHA key: one commit, matched rows updated, unmatched inserted. A table missing a column the current schema expects is either backfilled from a declared default or refused at open, because refusing to open is the failure mode an operator notices.
Known costs
Five measured paths remain unoptimized.
| Path | Measured | Grows with | Why, and what is missing |
|---|---|---|---|
| API-key lookup, key absent | 732 µs p50 in one run, 1,103 µs in another, at 100,000 keys stored — against 1.33 µs and 1.38 µs for a present key in the same two runs, so a wrong key costs 550–800× a right one | keys stored | key lookup is a hash index, but only on the present-key path; a miss falls through to a linear scan of every key. That scan is what an unauthenticated caller reaches, so a wrong key buys roughly a millisecond of server CPU per request |
| Package membership listing, 50,000-member package | 274 ms p50 / 282 ms p99 at 100K binaries; 342 ms p50 / 1,603 ms p99 at 1M | members per package, and total rows | the slowest catalog operation measured, and the only one whose p99 passes a second — at 1M the tail is 4.7× its own p50 |
| Catalog compression | 1.84× on a registry row, 2.03× on an analysis record; 1,267 bytes per binary on disk, so a 20M-binary catalog is roughly 25 GB | rows | a single thin row has little internal redundancy for a dictionary to find — 2×, not the 3–5× expected; the 25 GB is a floor, because the benchmarked row is a hand-built fixture thinner than a production row carrying real component lists, imports and summary text |
| Resident memory, indexes | 4,883 MB after building and querying the Lance and Tantivy indexes in-process, against 304 MB after seeding a million catalog rows | index build and query working sets | the corpus catalog left the heap; those working sets did not |
| Catalog open | 228 ms at 100K rows, 1,434 ms at 1M — 6× for 10× the rows; the first served read after open stays flat at 163 µs and 145 µs | rows | boot is not corpus-independent, though serving latency after boot is |
Not measured
Four gaps bound what the numbers above can be read to claim.
| Unmeasured | Consequence |
|---|---|
| 10M and 20M corpora — never run | every claim at those sizes is extrapolation from the 100K→1M curve |
| Similarity under concurrency — measured single-threaded only | the eight-thread reader pool parallelizes the fan-out, so throughput is higher than one over the p50, but the per-call number above is what a caller waits |
| Lance on object storage — never benchmarked | all vector numbers are local disk, and per-fragment object-store GETs are exactly the cost that would change them |
| The full per-binary commit funnel — catalog write, detail file, sidecars, findings, search document, vector upsert | timed store by store, never driven end to end as one binary's ingest |
Related briefs
- CVEs & SBOM — the SBOM-to-CVE path these numbers describe.
- Platform — what the engine puts into the catalog in the first place.