Move flattening to the client and compaction to a merge - #23
Merged
Conversation
A contributed build now arrives as the public tables for that build, already in the shape the census publishes: nine parquet files framed into one compressed bundle. Compaction combines them by copying encoded row groups. It never decodes a value, never holds a row, and needs no parquet codec at all. Measured on 400 staged files holding 684,800 rows: 16 ms and 12 MB, against roughly half a second and 152 MB for reading those rows back -- more memory than a Worker isolate has. The ceiling that made compaction a full in-memory rebuild stops existing. One object per build, not nine. Nine separate parquet files cost four times the bytes of the JSON they replace, a quarter of it footers. Framed together and compressed once they cost 1.8 times, because the outer compressor sees what the tables have in common: `run_id` is in every one of them. `object_key` is gone from `sessions`. A merge cannot write a value the client did not, and the client cannot know its own object key -- the file exists before the upload. The column named a staged object, which is now transient: merged and then deleted. Rows are identified by `run_id`. The JSON path is deleted rather than kept alongside: `ingest.ts`, `compact.ts`, their tests, and the client's JSON transport. What is left of `ship.rs` is one function that posts bytes, which also deletes the second, discarded compression it used to do on every run. Ingest partitions by schema fingerprint, because row groups only copy between files whose schemas match to the byte -- including annotations, so two writers can disagree while looking identical. The fingerprint is declared by the client, and is attacker-controlled, so it is reduced to hexadecimal before it reaches a key. Ingest also now requires `application/zstd`, which is header-only and keeps the streaming write: this endpoint is unauthenticated by design, and 294 junk objects had accumulated in a day and a half. The Workers move from OpenTofu to wrangler, which is the only tool that can upload a Worker with a separate wasm module. OpenTofu keeps the bucket and the hostnames, which reference the scripts by name. `removed` blocks let it forget the scripts rather than destroying them. Verified against real sessions: the client path and the compaction path produce identical published tables -- every row of all nine, hashed and summed, not merely equivalent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
cratebank previewPreview removed because this pull request is closed. |
The action needs no token to read a public release, but it needs one to avoid being rate limited reading it: unauthenticated GitHub API requests are capped per IP and runners share them. It failed twice here, once with a connection reset and once with a 403, without scanning anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
… says now Three upgrade-check tests set a process-global environment variable while a fourth asserted on it, and tests run in parallel, so which answer that fourth test saw depended on scheduling. It passed here and failed on CI. The rule now takes what the environment said as arguments, so the tests exercise it without touching the environment at all, and the HTTP test reads from a named URL rather than setting one. The roundtrip and action jobs still grepped the collector for JSON-era output -- timing units, elapsed, rss, cache state -- which the bundle format does not print. They now assert what it does print: nine parquet footers, a row count, and a schema fingerprint, which is the reading compaction will do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
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.
A contributed build now arrives as the public tables for that build — nine parquet files framed into one compressed bundle — and compaction combines them by copying encoded row groups. It never decodes a value, never holds a row, and needs no parquet codec at all.
This is not deployable by merging alone. See "Deploying this" at the bottom.
Why
Compaction read every stored session, parsed it, flattened it, and re-encoded the result — holding every row in a 128 MB isolate. Measured on 400 staged files holding 684,800 rows:
152 MB does not fit in the isolate. The ceiling in the ops notes stops existing, because memory is now flat in row count — only row-group metadata accumulates.
One object per build, not nine
Parquet holds one schema per file, so a build is nine files. Measured on a real session:
A quarter of the nine-file version is footers. Framed together and compressed once, the outer compressor sees what the tables have in common —
run_idis in every one of them. It also drops nine Class A writes per build to one.The framing is deliberately dull, since nothing reads a staged object except compaction, which slices files out by offset. There is a test asserting the unframed bytes are views into the buffer rather than copies, because a copy would quietly undo the point.
object_keyis gone fromsessionsA merge cannot write a value the client did not, and the client cannot know its own object key — the file exists before the upload. The column named a staged object, which is now transient: merged, then deleted. Rows are identified by
run_id.Schema fingerprints
Row groups only copy between files whose schemas match to the byte, annotations included — I found this by merging our writer's output with DuckDB's, which differed only by
converted_type: INT_64versusNONE. So the client declares a fingerprint inx-cratebank-schemaand ingest files objects under it.That value is attacker-controlled, so it is reduced to at most 32 hexadecimal digits before it touches a key, falling back to
unknown.Ingest now requires
application/zstdHeader-only, so the body still streams past untouched and a 100 MB upload still survives a 128 MB isolate. This endpoint is unauthenticated by design, and 294 junk objects accumulated in a day and a half — that is what was failing every deploy. A scanner sends form-encoded or nothing.
What got deleted
ingest.ts,compact.ts,compact.test.ts, the old wrangler config, and the client's entire JSON transport.ship.rsis now one function that posts bytes — which also deletes the second, discarded compression it did on every successful run, about 44 ms measured.Net: +825 / −1,452.
The Workers move to wrangler
OpenTofu cannot upload a Worker with a separate wasm module — its multipart upload omits the wasm part, and the runtime refuses to instantiate a module from bytes, so it cannot be inlined either.
wrangler deploywithworker-builddoes the whole thing.wrangler owns the scripts, bindings, cron, and workers.dev subdomain; OpenTofu keeps the bucket,
data.cratebank.io, and the custom domains, which reference scripts by name. This also fixes the provider drift the infra README complains about, since script content leaves state entirely.removedblocks make OpenTofu forget the scripts rather than destroy them. Without those, the first apply after this merges would take production down.Verification
The client path and the compaction path produce identical published tables. Two real sessions, every row of all nine tables hashed and summed:
Not equivalent — identical. That check lives in
examples/edge.rsand is the thing to run when either half changes.Also: 5 test suites green, clippy clean with all features, both wasm crates building,
tofu validatepassing, and a real build through the local collector reporting nine tables and 55 rows from its footers alone.The client's cold build goes 16s → 22s with
parquetin the tree, 185 → 227 crates. Paid once, on install.Deploying this
In order:
cargo install worker-buildcd crates/worker-ingest && npx wrangler deploycd crates/worker-compact && npx wrangler deployremovedblocks — before any apply that does not have themremovedblocks can be deleted after that applyAlready done:
COMPACT_SECRETis set on the compact Worker via wrangler and verified against the live endpoint, and the bucket has been reset — nothing reads the old JSON format any more.client/latest.jsonwent with the reset and comes back on the next deploy.🤖 Generated with Claude Code
https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ