Repository navigation
refactor!: rust rewrite with an 8x smaller image and 100x less idle memory - #288
Conversation
Replaces the Nitro/Node.js server with a single statically linked Rust binary (axum, tokio, sqlx::Any + sea-query, object_store). Wire protocols, environment variables, the database schema with its Kysely migration bookkeeping, and the storage layout are unchanged, so existing deployments upgrade in place. The crate denies a strict rustc/clippy lint set (pedantic, nursery, cargo and selected restriction lints) and is formatted with nightly rustfmt under strict options. Also fixes `DELETE /management-api/cache-entries` ignoring the `repoId` filter, an empty `MANAGEMENT_API_KEY` enabling the management API, and storage location deletion running before its database commit (ADR-0001). BREAKING CHANGE: the `/management-api/_rpc` endpoint and the TypeScript SDK are removed (the REST API and its OpenAPI document remain), the `-caged` image variant and Helm `image.variant` are gone, server-side merge composition on object storage is removed (eager merge streams), `BENCHMARK`, `NITRO_PORT` and `NITRO_HOST` are no longer read, and Node.js default metrics (`nodejs_*`) are no longer exported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local SQLite file can't go stale, and the ping doubled the queue on its single connection: lookups went from ~3000 to ~3500 per second at 64 concurrent requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measures startup, idle and peak memory, CPU time, upload and download throughput (first, merge-on-read, and later), lookup latency and small save/restore throughput through each container's cgroup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Benchmark: Node 9.9.0 vs this PRTo reproduce: Setup: each image runs as a container with SQLite and filesystem storage on a fresh Docker volume (btrfs on NVMe). Cleanup jobs are off, and token validation is skipped so the numbers measure the server, not GitHub's JWKS. A protocol client sends the same requests as
Against Node
What the performance commits changed (
Where the current build is not ahead
|
Every commit synced a rollback journal and the database file; with one connection, concurrent saves queued behind those fsyncs. Small save+restore goes from 4/s to 432/s on filesystem storage and filesystem uploads from 523 to 2348 MiB/s. A crash can lose the last commits but never consistency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
musl's allocator is slow under concurrent allocation; mimalloc cuts server CPU by about 40% and lifts repeat downloads from filesystem storage from 2.4 to 8.5 GiB/s. Larger reads mean fewer hops to tokio's blocking pool. Peak memory rises from 46 to 104 MiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A first download from object storage is paced by the Merge it feeds, which uploaded one 5 MiB part at a time. First downloads from S3 go from 357 to 652 MiB/s. Part uploads stay sequential: clients send many at once and every in-flight part is buffered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WAL lets readers run alongside the single writer, so cache lookups no longer queue behind writes on one connection. Lookups go from 5000/s to 7000/s at 64 concurrent, p50 from 12.7 to 8.5 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matching ran an exact and a prefix query per key and scope, each prefix query a full scan. Now the primary key's exact match in the first scope comes from the key index, and everything else from one query that ranks each row by the first candidate it satisfies, in the same order as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLite's memory statistics take a global mutex on every allocation, which serialized the read connections: three quarters of lookup CPU went to that mutex. Lookups go from 7000/s to 21000/s at 64 concurrent, p50 from 9 to 2.9 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A LIKE pattern bound as a parameter makes SQLite recompile the statement on every run, which was half of a lookup's database time. substr compares exactly, so the case_sensitive_like pragma goes too. Postgres and MySQL keep LIKE, which MySQL serves from the key index. Lookups go from 21000/s to 28000/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A first download teed every chunk through the Merge's writer, so the client waited for the merged copy to be written. On filesystem storage the client now reads the Parts directly while the Merge concatenates them in the background with copy_file_range, which never moves the bytes through this process and is a reflink on btrfs and xfs. First downloads go from 2.0 to 7.3 GiB/s. Object storage still reads once and tees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The warning was logged on every request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A location with one Part, every save up to 128 MiB, already holds its bytes in one object, so it is served from that Part, with ranges and a known size, and never merged; with direct downloads on, its Part is signed under a parts reader lease. A first download from filesystem storage also claims its Merge in the background instead of before responding. Small save+restore goes from 520/s to 720/s on filesystem storage and from 270/s to 345/s on S3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A checkpoint syncs the log and the database file inline in a commit; at the default 1000 pages that stalled the single writer tens of times a second while saving, badly so on btrfs. Finalize also reads its upload from a read connection. Small save+restore goes from 720/s to 1110/s on filesystem storage and from 345/s to 500/s on S3. The log grows to about 40 MiB between checkpoints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…request A Part was buffered into 5 MiB multipart parts uploaded one at a time. When the client announces its length, as the Azure SDK does, the Part now streams unbuffered to a presigned URL in a single PUT, which is atomic like a multipart upload; otherwise, or when the backend can't presign, it is buffered as before. Uploads to S3 go from 560 to 1170 MiB/s with upload-phase memory from 262 to 70 MiB, and to Azure Blob from 69 to 388 MiB/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Merge knows its length from its Parts, so up to S3's 5 GiB single-PUT limit it now streams unbuffered to a presigned URL instead of going up in buffered 5 MiB parts. First downloads from S3 go from 695 to 947 MiB/s and from Azure Blob from 70 to 382 MiB/s, and peak memory on both drops from about 280 to 85 MiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every lookup on object storage confirmed its data with a HEAD request. Objects never change, so one seen to exist is now trusted for a minute; deletions through the server forget it at once, and only data deleted behind the server's back can be promised for that minute. Lookups go from 19500/s to 29000/s on S3 and from 1900/s to 28800/s on Azure Blob. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ports #291: a Range on an empty entry is ignored on every storage driver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…select JWKs like jose - A delayed merge no longer redoes (and on failure rolls back) a completed Merge. - Presigned uploads honour the store's read timeout (STORAGE_S3_SOCKET_TIMEOUT_MS on S3). - Keys meant for encryption are rejected, and a token without kid tries every key that fits it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ranking Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Database credentials and SQLite paths containing % reach the driver unchanged; :memory: works. - Cleanup schedules follow local wall-clock time, like the original cron expressions. - Restore-key prefixes escape with !, which reads the same under NO_BACKSLASH_ESCAPES. - Bulk deletion reads its filters from the JSON body, as the original server did. - Results Passthrough follows redirects again. - Range bounds beyond a safe integer are ignored, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A null bulk-deletion filter is rejected instead of widening the deletion. - Passthrough requests without a body can follow 307/308 redirects. - Cleanup passes over boundaries a DST change skips and runs a repeated one once. - JWKs are selected by the curve their algorithm requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ete filters without Content-Type - Presigned uploads fail after STORAGE_S3_SOCKET_TIMEOUT_MS without progress, not after that long in total. - Passthrough buffers request bodies like proxyRequest, so 307/308 redirects replay them. - Bulk deletion parses a JSON body whatever its Content-Type. - The repeated DST hour resolves to its first occurrence with chrono's Local, too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ough the error body Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MAX_CONCURRENT_MERGES (default 16) caps eager and lazy Merges per process; past it a restore streams the Parts and a later download merges. - DOWNLOAD_IDLE_TIMEOUT_MS (default 300000, 0 disables) closes a connection whose response write has been blocked that long. hyper stops polling the body of a client that stopped reading, so the timeout sits on the socket; closing it drops the storage read, its Storage Reader Lease and the Merge it paces. - Presigned upload URLs and passthrough query strings no longer reach the logs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port #302's commit gate and pre-push hook to the Cargo toolchain: the hook runs nightly rustfmt and clippy, and AGENTS.md documents the Rust test harness instead of vitest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eager Merge streams parts through the server on object storage (ADR-0010), and the distroless image has no shell, so the preStop example uses the native sleep handler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Carry the v9.10.0 release over to Cargo.toml and Cargo.lock, which the release tooling bumps from now that package.json is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
📝 Summary
The cache server ran on Nitro/Node.js: a ~250 MB image that idles at ~280 MB of memory and spends most of its CPU time in the runtime rather than moving bytes. This rewrites the whole server as a single statically linked Rust binary with a deliberately strict toolchain, while keeping every external contract so existing deployments upgrade by swapping the image.
Kept unchanged: the Twirp
CacheService(JSON and protobuf), Azure-style block uploads, downloads withRange, Results Passthrough, the REST management API with its OpenAPI docs at/management-api/_docs,/health,/metricsmetric names, every environment variable, the cleanup schedules, and the storage layout. The database schema and Kysely's bookkeeping tables are reused as-is, including migrations$7/$8from #283 and #286. Migrations take the same advisory locks, so a database written by either implementation stays readable by the other. ADR-0010 records the decision.Strictness: the crate is
unsafe-free and denieswarnings, clippyall/pedantic/nursery/cargoplus ~70 restriction lints, including:unwrap_used,expect_usedandpanicindexing_slicingas_conversionsandarithmetic_side_effectsallow_attributes: exceptions must be#[expect(…, reason = "…")]It also denies
missing_docs,unreachable_pubandunused_crate_dependencies. Formatting uses nightly rustfmt witherror_on_line_overflow, grouped and granular imports, and comment wrapping.Performance, beyond the rewrite itself (details and numbers in the benchmark comment):
synchronous=normal, checkpointing every 10000 pages..cargo/config.toml).substr; at most two queries per lookup.copy_file_range), and a first download reads the Parts directly.Range, and never merged.MIMALLOC_PURGE_DELAY=0 MIMALLOC_ALLOW_THP=0.Against Node 9.9.0 on SQLite + filesystem: uploads are 6.7× faster, first downloads 22×, lookups 17×, and small save+restore cycles go from 4 to 1143 per second.
Fixed along the way:
DELETE /management-api/cache-entriesignored therepoIdfilter.MANAGEMENT_API_KEYenabled the API without auth; it now disables it, as before.1.073741824e+11) are accepted.CACHE_CLEANUP_OLDER_THAN_DAYSstops startup; the TypeScript server deleted every entry daily with it.✅ Verification
Test suite: 68 Rust tests: those ported from the vitest suite, the fix(storage): stop concurrent CreateCacheEntry calls from reserving the same entry twice #283/fix(upload): fail a block with 404 instead of silently dropping it #285/fix(storage): match cache keys exactly on every database, like GitHub does #286/fix(download): answer Range on an empty cache entry the same way on every storage driver #291 tests, and a test pinning the match order. They pass locally on these backend combinations:
CI runs the full 3 × 4 matrix through
scripts/test-services.sh.Real client:
@actions/cache6 save and restore against the release binary, for 1 B, 2 MiB, 64 MiB and 300 MiB, including the restore-key fallback, with both lazy and eager merge: all byte-identical.In-place upgrade: entries saved by the 9.8.0 TypeScript server (one merged, one unmerged) restore byte-identically through the Rust server on the same SQLite database and storage, with no migration rerun. A test also upgrades a
$6database dumped from the TypeScript server through$8.Docker: the image builds for linux/amd64 and linux/arm64 by cross-compiling (no QEMU). The final image is about 29 MB, versus 243 MB.
Checks:
cargo +nightly fmt --check,cargo clippy --all-targets --locked(0 diagnostics) andnode --test scripts/*.test.mjsall pass.A benchmark comparing Node 9.9.0, this branch before the performance commits, and the current head (startup, memory, CPU, transfer speed, lookup latency) is in this comment.
🔍 Reviews
After merging
dev(#291), a second review pass ran with GPT-6.1 Sol:The unfixed ones are the two DST and retention differences above; one round-3 spec finding was already fixed.
Unfixed by decision:
migratereturns a plainbool.nodejs_*metrics are gone, and theprocess_*set differs slightly.Out of scope because the TypeScript server behaves the same (see Needs attention):
/management-api/cache-entries/with a trailing slashNot adopted, as judgement calls: newtype ids, a merge-state enum, splitting the cleanup summary, and moving one upload test.
💥 Merge danger
Door: two-way
The schema, migration bookkeeping and storage layout are shared with the TypeScript server, so rolling back to the previous image works on the same database and volume.
Blast Radius: everything
Every deployment runs the new binary. Removed:
/management-api/_rpcand the TypeScript SDK-cagedimage tags and Helm'simage.variantBENCHMARK,NITRO_PORTandNITRO_HOSTDELETE /management-api/storage-locations/{id}answers 409 while the location is being downloaded.Behaviour that changed:
synchronous=normal. A power loss or OS crash can drop the last commits, but never corrupts the database. The mode is stored in the file, and the TypeScript server opens it as is.mergedobject; the TypeScript server would merge them on first read after a rollback.-cagedvariant and the Node runtime.Cargo.tomlthroughscripts/release-metadata.mjs(tested withnode --test). It runs for real only at the next release.delete_foldermisses stray objects at the top level.🤖 Generated with Claude Code