Skip to content

atelet: node-local file cache library (filecache, M1) - #1517

Open
Dmitry Berkovich (dberkov) wants to merge 6 commits into
agent-substrate:mainfrom
dberkov:filecache-m1
Open

atelet: node-local file cache library (filecache, M1)#1517
Dmitry Berkovich (dberkov) wants to merge 6 commits into
agent-substrate:mainfrom
dberkov:filecache-m1

Conversation

@dberkov

@dberkov Dmitry Berkovich (dberkov) commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Implements milestone M1 of the node-local artifact cache proposed in #690 (design in the issue comment): a generic cmd/atelet/internal/filecache package that will back golden-snapshot restores (today re-downloaded per actor on every start/resume) and later the sandbox-asset fetches. it reads well commit by commit:

  1. atelet: add filecache store skeleton — constructor-only Keys (SHA256Key content-addressed, URIKey for immutable sources; prefix-disjoint canonical forms, entry dir = sha256(key)), the entries/ + tmp/ + .rm-* layout, SweepDebris (startup crash-debris reaper), TotalBytes (GC budget measure), debug-only meta.json.
  2. atelet: add filecache singleflight retrieval (GetFileTo) — atomic get-and-link: per-key singleflight on context.WithoutCancel + fetch timeout (a canceled caller never aborts the download others wait on; no negative caching); fetch into tmp/, validate, chmod 0444 (in-place writes fail loudly instead of poisoning shared bytes), publish by one atomic rename; hit = hard link + LRU touch under hitMu.RLock. Path-based FileFetcher so ategcs's sparse zstd download plugs in unchanged; %w wrapping end-to-end for ateerrors classification.
  3. atelet: move the sparse file copy helpers into internal/sparsefile — mechanical move of copyFile/copySparse/kernelCopyRange (and their tests) out of package main so filecache can reuse them; adds Copy(src, dst *os.File) for caller-owned handles (source opened before its name can vanish, destination created O_EXCL).
  4. filecache: add GetFileCopyTo for consumers that mutate staged files — the second serving mode: a private, hole-preserving copy (mode 0600) instead of a read-only hard link, for consumers that rewrite staged files in place (ateom-microvm rewrites config.json at restore and merges deltas into memory-ranges at suspend — a shared inode would be corrupted). The copy reads a handle opened under the hit lock, so an eviction racing the copy retires only the entry's name; a copy needs no same-mount constraint.
  5. atelet: add filecache eviction (EvictUnused) — pressure-driven only: min-age gate, unlinked-first then LRU ordering, stop at target. Two-phase retire inside the key's singleflight + hitMu exclusive (moved last-use clock or in-flight fetch vetoes; rename to .rm-*), slow RemoveAll after all retires outside the hot-path locks. Stats distinguish Retired (namespace removal, irreversible at rename) from FreedBytes (credited only after physical removal succeeds) and PendingBytes (retired but consumer-linked; kernel frees later). Copied-out entries carry no links, so eviction is free to take them — existing copies are private inodes and unaffected.
  6. atelet: document filecache contracts and stress the get/evict races — package-doc contracts (link-out immunity, copy-out privacy, min-age sizing rule, read-only shared bytes, key immutability) plus a race-detector stress test: concurrent getters and evictors on shared keys; every get must succeed with intact content.

The core safety property throughout: eviction can only ever cost a refetch — never break a consumer. Hard-linked files are protected by the link itself (the consumer's inode survives eviction); copies are private inodes; the min age covers the publish-to-use window.

Follow-ups per the design: M2 wires a golden store into Restore (downloadExternalCheckpoint/downloadCombinedCheckpoint) with a GC driver loop — gVisor restores get hard links, micro-VM restores get copies; M3 adds GetFile/GetDir + the sandbox-record root set and migrates fetchAsset/fetchGVisorRelease.

Tested: go test -race -count=3 ./cmd/atelet/internal/filecache/; every commit builds and passes tests individually; golangci-lint, gofmt, and boilerplate checks clean.

🤖 Generated with Claude Code

Introduce cmd/atelet/internal/filecache, the foundation of a node-local
artifact cache: opaque entry keys (content-addressed sha256 and
immutable-URI forms), the entries/tmp on-disk layout, a startup sweep
for crash debris (unfinished fetches, interrupted evictions), and byte
accounting for a GC budget.

Golden snapshot restores download their files per actor with no reuse,
and sandbox-asset fetches race concurrent downloads of the same asset;
this package is the shared cache that will back both paths. Retrieval
(singleflight fetch, atomic publication, hardlink-out) and eviction
build on this skeleton in follow-up changes.
GetFileTo materializes a cached artifact at a destination path via hard
link, fetching it on a miss. Concurrent callers for one key share a
single fetch (singleflight), and the fetch runs detached from the
callers' contexts bounded by the store's fetch timeout, so one canceled
caller never aborts a download other callers are waiting on. There is
no negative caching: a failed fetch reaches every waiting caller and
the next call starts fresh.

A fetch lands in tmp/, must produce a regular file, is made read-only
(0444) so a consumer's in-place write fails loudly instead of
corrupting the shared copy, and is published with one atomic rename. A
hit links out and touches the entry's last-use clock under a shared
lock that eviction will hold exclusively, closing the hit-vs-evict
window. Destinations must not exist and must be absolute paths on the
cache's mount; cross-filesystem destinations fail with a dedicated
error rather than a silent copy.
copyFile and its hole-preserving machinery lived in package main, usable
only by atelet's own checkpoint staging. The filecache package is about
to need the same copy (its copy-out mode hands consumers a private,
hole-preserved copy of a cached artifact), so move the code where both
can import it.

Mechanical move, with one seam added: Copy(src, dst *os.File) exposes
the engine on caller-owned handles, for callers that must open the
source before its name can vanish or create the destination with
O_EXCL. CopyFile keeps its os.Create semantics for the existing caller.
GetFileTo serves hits as read-only hard links, which is only safe for
consumers that never write the staged file in place. GetFileCopyTo
serves the same read-through cache as a private copy instead: the
caller owns the resulting inode outright (mode 0600) and may mutate it
freely, holes are preserved, and the destination may live on any
filesystem. The copy reads a handle opened under the hit lock, so an
eviction racing the copy retires only the entry's name — the bytes
survive until the copy completes. Fetch dedup is unchanged: concurrent
calls for one key share a single flight.
EvictUnused frees cache space least-recently-used first until a byte
target is met, with a two-phase retire: inside the key's singleflight
and the hit lock, a victim is re-verified (a moved last-use clock or an
in-flight fetch vetoes) and renamed to a .rm-* dir, making it invisible
to lookups; the slow physical deletion runs after all retires, outside
the locks the hot path contends, so hits and fetches never wait on it.

Entries younger than the store's min age are never touched, covering
the window between publication and a consumer's first link. Entries
whose data a consumer still hard-links may be retired but count as
pending rather than freed bytes - the kernel returns that space when
the last consumer link goes - so eviction can only ever cost a
re-download, never break a consumer. FreedBytes is credited per entry
only after its physical removal succeeds; a failed removal leaves the
bytes in a .rm-* dir for the startup sweep and out of the freed count.
State the package's consumer-protection contracts in the package doc
(link-out immunity, min-age sizing, the read-only shared-bytes rule,
and key immutability), and pin them with a race-detector stress test:
getters and evictors hammer the same keys concurrently, and every get
must succeed with intact content - eviction may force refetches but can
never fail a caller, corrupt a served file, or leave half-states in the
store.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant