* docs: design for where the npm proxy keeps cached registry content * feat(npm-proxy): keep package files on disk and in the object store * fix(npm-proxy): degrade when the cache is unwritable, stream and bound it * fix(npm-proxy): keep the happy path off the heap and isolate pull scratch * fix(npm-proxy): bound the upload, verify pulled trees, keep oversized manifests * fix(npm-proxy): protect live scratch, bound uploads by parts, refuse traversals * fix: let the blocking unpack own the scratch it writes into * fix: replace a cache directory that is not a package instead of deferring to it * fix: evict by moving a package off the live path, not by deleting it in place * fix: leave a package the sweep cannot move rather than deleting it in place * fix: take one registry snapshot through a cache miss * fix: stamp a pulled package as used so the sweep does not evict it first
11 KiB
npm proxy: where cached registry content belongs
Design rationale for backend/windmill-api-npm-proxy. Read this before changing what the
proxy caches or where it keeps it.
Context
The npm proxy serves an instance's configured private registry to two browser-side
consumers: the raw app editor's package installer, and the script editor's type
acquisition (ATA). Both are chatty by nature. Opening one script asks for a package's file
tree and then for every .d.ts in it, which for @types/lodash is several hundred
requests against one archive.
Everything the proxy caches today lives in the API process's memory:
| Cache | Budget | TTL | Holds |
|---|---|---|---|
PACKAGE_JSON_CACHE |
64 MiB | 60 s | registry documents (packuments) |
TARBALL_CACHE |
256 MiB | 300 s | per package version: the compressed archive, its file list, and the retained .d.ts and manifest |
That is roughly 320 MiB of steady state, plus a transient of reads in flight x RETAINED_BYTES while a read builds its map before the cache weighs it. On a 2 GB API
container the steady state alone is 16% of the box, and the transient scales with request
concurrency rather than with any budget.
The two things being cached are not the same shape
This is the whole design.
Packuments are small (10 to 100 KB in npm's abbreviated form), hot, and mutable: a new version publishes and the document changes. They exist to save a registry round trip, so moving them to a store that itself costs a round trip buys nothing. They belong in memory, with a short TTL, and their budget can be small.
Package archives are large (10 KB to 50 MB), cold between sessions, and immutable: a published version's tarball never changes. They are re-fetchable from the registry at any time. This is blob-shaped data, and memory is the wrong tier for it.
Decision
Keep packuments in memory. Move archive-derived content to disk, backed by the instance object store when one is configured.
Store extracted files, not the archive
Storing the tarball as the cached unit is the obvious move and it is wrong: every /file
request would still download and inflate the whole archive to reach one .d.ts. For
aws-sdk@2.1692.0 that is 50 MB of inflation per declaration, and ATA asks for 442 of
them.
Store instead what the endpoints actually serve, which is already what the current code chooses to retain:
<cache root>/npm_proxy/<registry hash>/<package>/<version>/
manifest.json the file list and the resolved entry point
files/<path> each retained .d.ts and the package.json
/filetree then reads one small file and /file reads one small file. No inflation on
the request path at all, and nothing large resident.
The extraction rule stays as it is: the manifest plus .d.ts, because that is exactly what
ata/index.ts reads (treeToDTSFiles filters to .d.ts, and the caller loop fetches each
dependency's package.json). Anything else in the archive is walked past. A request for a
path outside that set still works, by walking the archive once on demand.
Tiering
Follow the order windmill-worker/src/global_cache.rs already uses for dependency caches:
- Local disk first. Fast, per replica, survives nothing but a container restart.
- Instance object store on a local miss, which then populates disk. Shared across replicas, survives restarts.
- Registry on a miss in both, which populates disk and the object store.
The object store layer stores a tar of the extracted subset rather than individual objects.
That subset is small (kilobytes to a few MB, not the 50 MB archive), one PUT and one GET
per package version instead of hundreds, and it matches build_tar_and_push /
pull_from_tar which already exist.
Keys are scoped to the registry
The cache key must include the registry identity, not just package and version. The npmrc
instance setting can be changed to point at a different registry that serves a different
artifact under the same name and version. Hash the resolved registry URL into the path.
Hashing it in is only half of it: a miss has to resolve the setting once and use that one snapshot for the key, the packument and the tarball. Resolving a second time lets the setting change in between, and since the tarball's origin is only checked by host, two repositories on one host will write the second registry's files under the first one's key, where immutability then keeps them.
Immutability means the archive tiers need no TTL. The packument cache still does, since that is what discovers new versions.
What already exists
Little of this is new machinery.
windmill_object_store::get_object_store() -> Option<Arc<dyn ObjectStore>>is the instance store accessor, and returningOptionis exactly the "no object store configured" branch.windmill_common::worker::extract_tarandatomic_publish_dirhandle unpacking and the concurrent half-extracted directory problem.atomic_publish_dirhas a thundering-herd test already.windmill_common::worker::ROOT_CACHE_DIRis the existing on-disk cache root convention.global_cache::load_cache/save_cache/build_tar_and_push/pull_from_tarare the disk-then-object-store pattern in worker form. They live inwindmill-worker, so either the shared parts move towindmill-commonor the proxy grows a small equivalent.
CE does not get the object store tier
windmill-object-store is compiled entirely under #[cfg(feature = "parquet")], and every
call site gates on #[cfg(all(feature = "enterprise", feature = "parquet"))]. Instance
object storage is therefore an EE capability.
This matters more than usual here: the users most likely to configure a private npm registry are self-hosted, and a good number of them are on CE. The disk tier is not a nicety for them, it is the whole feature. Disk has to work well on its own, and the object store has to be a pure addition on top.
Failure modes disk has and memory does not
-
Eviction. A sweep trims to 2 GiB, least recently used first, rate limited to once per ten minutes or 256 MiB written, whichever comes first. It measures allocated blocks rather than file lengths: a package of many tiny declarations costs a block each and would otherwise look nearly free.
-
Recency is
mtime, marked explicitly. Cache volumes mountrelatime, so access time does not move on a read and an LRU built on it is silently a FIFO. A cache hit touches the manifest instead. -
ENOSPC and read-only cache directories degrade, they do not fail. Every write is best effort; when one fails the archive is walked a second time into an in-memory map for that request. Only on that path, since building the map alongside a successful disk fill would put back the per-request heap this exists to remove.
-
Debris, distinguished from live writers. A killed writer leaves a
.tmp.directory that nothing will read. The sweep removes those, but only once they are an hour old, and never treats one as an eviction candidate: concurrent cold fills are exactly when a sweep is most likely to run, and deleting a scratch directory underneath its writer would publish a package missing whatever it had already written. -
The live path holds a whole package or nothing. Publishing renames a fully written temp directory into place, so a partial one never looks complete. Losing that rename is success only when the destination has a manifest, which means another writer published the same immutable content; a destination without one is debris, and it is renamed aside and replaced. Eviction is the same rename in reverse rather than a recursive delete in place.
-
Transfers stream, and everything that scales with a package stays off the runtime. Pulls go to a temp file, and unpacking, measuring and publishing all run on a blocking thread; pushes build a tar on disk and upload it with a bound on parts in flight, not bytes queued. Buffering either, or walking a tree of thousands of declarations on a runtime worker, would put back what this exists to remove.
-
A malicious archive is not a broken disk. An entry whose path escapes its directory is refused before either sink, rather than degrading the way an unwritable cache does.
-
Eviction races the reads. A package can be swept between the lookup and the manifest read, so a missing manifest is treated as a miss and repopulated rather than raised. A missing file already falls through to the on-demand walk.
-
A recursive delete has no atomic point, so nothing deletes on the live path.
remove_dir_allunlinks in readdir order, and either half of a package it can leave behind is permanent, which is why publishing and eviction both move whole directories withrename:- Files gone, manifest left reads as a complete package forever. The manifest short-circuits the lookup, so the object store is never consulted, and every declaration it names falls through to the on-demand walk, re-fetching and inflating the archive per request.
- Manifest gone, files left reads as a miss, but
renamerefuses a non-empty destination, so every tree published afterwards is discarded in favour of the debris and the path answers 500 with a valid copy sitting in the bucket.
A sweep that cannot rename a candidate aside leaves it whole rather than falling back to deleting it where it stands: the cap is a bound the next sweep rechecks, and a package half-deleted under it is not.
Still open: single flight. Concurrent misses for the same package each fetch and extract. Publishing by rename and per-pull scratch directories make that safe, just wasteful.
Alternative considered: just lower the budgets
The constants were sized for throughput without weighing a 2 GB container. Cutting
TARBALL_CACHE_BYTES to 64 MiB and PACKAGE_JSON_CACHE_BYTES to 16 MiB is a four line
change with no new failure modes, and the only cost is re-fetching large packages more
often.
It does not fix the transient, which scales with concurrency rather than with the budget, and it does not survive a restart or share across replicas. Take it if the storage work is not worth doing now; it is strictly a smaller version of the same trade.
Status
Implemented in store.rs. The archive cache is gone from memory: extraction streams each
entry straight to the package directory, so a read holds one entry rather than the whole
retained set, and PACKAGE_JSON_CACHE at 16 MiB is all that is left in the heap.
Two things settled during implementation and are worth keeping in mind before changing it:
- A warm package must not cost a round trip. The registry is resolved from settings, and the packument is fetched only on a cache miss. Fetching it first, to reach the tarball URL, made a fully cached package still hit the registry once per minute.
- The manifest is exempt from the retention budget.
nextorders itspackage.json3748th, so a budget spent on the declarations ahead of it would leave the entry point defaulted, which is a wrong answer rather than a slow one.