Sign in

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.

QuestionSection
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 rowPer-binary detail
Holdsname, architecture, verdict, attributed families, uploader, SBOM components, package membership, entry point, per-dylib import counts, export count, function origins, the one-line summary and titleevery 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 whenany list, filter, facet or visibility check runsone binary is opened, and only the part asked for
Serializationnamed msgpack, then zstd-3pre-projected JSON, plus typed sidecars
On-disk layoutone row per key in the SQL catalogcard 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 size1,388 B compressed for a representative populated rowcard 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 binaryStoreNote
Registry row + thin analysis recordSQL catalogone transaction; the only pieces read across binaries
card-bundle.jsondisk, per scopethe pre-projected detail page
The full analysis treedisk, per scopethe largest artifact class — p99 ~175 MB pretty-printed — stored compact plus zstd-3, which cuts it ~90%
Call-graph artifact, functions sidecar, extras sidecardisk, shardedpostcard + zstd for the call graph, JSON for the two that keep untyped fields
One search documentfull-text indexstored fields plus facets
One vector rowvector storeDNA vector, learned embedding, rerank sets, visibility facet
Document-frequency countsLMDBcorpus-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:

BlobRawCompressedRatio
Registry row840 B457 B1.84×
Analysis record1,892 B931 B2.03×
Catalog row, both parts2,732 B1,388 B1.97×
Keynote call graph84,000,308 B9,676,030 B8.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.

RecordKeyCardinalityIn the sample corpus
Registry rowSHA, globalone per SHA — it exists, here is everyone who has uploaded it7,579 distinct SHAs in the ledger
Analysis recordSHA + visibility scope: /public, /private/<user id>, /workspace/<workspace id>up to one per scope, isolated from each other
Upload ledger recordupload idone per upload9,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.

EngineHoldsChosen forOne operation, measured
etch — in-RAM KV, write-ahead log + snapshotusers, API keys, workspaces, grants, invites, the referral ledger; separately, the analysis job queueread on every authenticated request; bounded by user count, never by corpususer lookup 0.125 µs p50; workspace + members 27.6 µs
turso — in-process SQLite-compatible SQL, on disk, WALregistry rows, analysis records, findings, malware, packages, membership, sightings rollupsindexed point-get, the "older than version" reindex walk, visibility filteringpoint-get 17.3 µs p50 over 100K rows, 125.9 µs over 1M
Lance — columnar vector store, on diskthe 106-dim DNA vector, a 128-dim learned embedding, the rerank sets, the visibility facetANN with the visibility predicate pushed into the search, and an index that extends incrementallyfiltered ANN 5.19 ms p50 over 100K vectors
Tantivyone document per binary: stored fields plus facetsfull-text query and facet aggregation in one passquery + facets + visibility 3.07 ms p50 over 50,000 documents

Three stores sit beside them:

StoreHoldsNote
LMDBcorpus-wide document-frequency countsread 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 welllets 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 filethe CVE indexthe 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.

TierAcross a deployRebuilt from
Accounts WAL — users, API keys, workspaces, grants, invites, referral ledgersurvives, on its own WAL and snapshot, physically isolated from every regenerable tiernothing; irreplaceable
Uploaded bytes + the upload ledgersurvivenothing; irreplaceable
SQL catalog, including the sightings rollupswipedthe uploads directory and the ledger, by the boot reconcile
Job queuewipedre-enqueued from the ledger at boot
Vector and full-text indexeswiped on a schema-revision bumpa corpus walk, or an explicit reindex
Detail files and sidecarssurvive on diska 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 binaries1M binaries
Catalog point-get, p50 / p9917.3 µs / 33.4 µs125.9 µs / 377.1 µs
Ingest one binary (registry row + record + membership, one transaction)91.7 µs p50, 6,672 ops/s99.3 µs p50, 5,864 ops/s
Bulk import17,041 rows/s7,827 rows/s
Full catalog scan288,417 rows/s177,110 rows/s
Concurrent point-gets, 32 readers284,069 reads/s228,486 reads/s
Catalog on disk (compressed)143 MB1,267 MB
Resident memory after seeding180 MB304 MB
Boot: open, then first read228 ms, then 163 µs1,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 readers100K binaries100K p991M binaries
158,450 reads/s27.2 µs47,236 reads/s
8173,368 reads/s101.9 µs163,456 reads/s
32284,069 reads/s (peak)207.1 µs228,486 reads/s
128266,385 reads/s658.9 µs230,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.

Query100K rows500K rows1M rows
Filtered ANN, p505.19 ms44.0 ms
End-to-end similarity, p5017.0 ms80.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 probedQuery 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:

VectorNative dimsStored dimsPQ split
DNA106112, zero-padded7 sub-vectors of 16 — derived by the index builder, not configured
Learned embedding128128already 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:

Operation100K rows1M rows
Fold 10,000 added rows into the existing index0.16 s1.14 s
Full retrain6.0 s10.5 s

Both search planes carry a schema revision, and a bump invalidates the plane rather than migrating it:

PlaneCurrent revisionOn mismatch
Vectorrevision 1 — added the file-kind predicate columnsimilarity queries return 503 until an explicit reindex runs; the daemon logs the mismatch at boot and skips the populate
Full textschema version 12 — renamed the symbol-hash fieldthe 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.

SidecarFiles in the corpusOn diskSymptomReplaced by
.cg.postcard — call graph3,8572,565 MBevery call-graph request returned no grapha 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 imports5,932108 MBundecodable by any reader in the codebaseJSON, which keeps the untyped fields
per-binary function listnot countednot countedevery request returned 404JSON, 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.

PathMeasuredGrows withWhy, and what is missing
API-key lookup, key absent732 µ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 onekeys storedkey 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 package274 ms p50 / 282 ms p99 at 100K binaries; 342 ms p50 / 1,603 ms p99 at 1Mmembers per package, and total rowsthe slowest catalog operation measured, and the only one whose p99 passes a second — at 1M the tail is 4.7× its own p50
Catalog compression1.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 GBrowsa 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, indexes4,883 MB after building and querying the Lance and Tantivy indexes in-process, against 304 MB after seeding a million catalog rowsindex build and query working setsthe corpus catalog left the heap; those working sets did not
Catalog open228 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 µsrowsboot is not corpus-independent, though serving latency after boot is

Not measured

Four gaps bound what the numbers above can be read to claim.

UnmeasuredConsequence
10M and 20M corpora — never runevery claim at those sizes is extrapolation from the 100K→1M curve
Similarity under concurrency — measured single-threaded onlythe 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 benchmarkedall 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 upserttimed store by store, never driven end to end as one binary's ingest