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.

@EItanya Eitan Yarmush (EItanya) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the comment-only review. The change request has been dismissed.

@EItanya Eitan Yarmush (EItanya) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI-generated review. I looked through them to make sure they weren't crazy and they all seem worth looking at.

  1. Use allocated bytes for sparse-file accountingevict.go:213
    sizeEntry and TotalBytes count logical file lengths. In a local reproduction, an entry whose regular files occupied 8 KiB was reported as freeing over 1 GiB. This can trigger unnecessary eviction and stop reclamation before the requested disk space is freed. Please use allocated bytes consistently for budgeting and eviction, such as Stat_t.Blocks * 512, and add a sparse-file accounting test.

  2. Synchronize the failed-fetch test deterministicallygetfileto_test.go:197
    started.Done() runs before callers enter GetFileTo, so the fetch can finish before everyone joins. Late callers correctly retry, violating the test’s one-fetch assertion. A focused race-enabled run failed 16 times out of 100. Please wait until the callers are actually waiting on the flight before releasing the fetch, using testing/synctest or an equivalent deterministic barrier.

  3. Report filesystem errors during eviction enumerationevict.go:180
    The Stat and sizeEntry error branches assume an entry disappeared during enumeration, swallowing permission and I/O errors too. An unreadable entry reproduced a nil error and entirely zero eviction statistics. This makes broken cache access indistinguishable from having no eligible entries. Please ignore only fs.ErrNotExist and return other errors with the affected path.

  4. Clarify the copy helper’s destination requirementssparsefile.go:78
    Copy accepts open handles without requiring an empty destination. Copying an all-hole source over an existing file returns success while retaining the old destination bytes; this was reproduced locally. The current caller creates an empty file and is safe. The smallest fix is to document the empty, zero-offset destination requirement and add a contract check, or explicitly support overwriting.

  5. Make eviction-race coverage deterministicstress_test.go:113
    The stress test discards eviction statistics and exercises only hard-link retrieval. One passing run performed just the eight initial fetches. Please add deterministic overlap tests for eviction versus both serving modes and verify that eviction actually occurred. Fetch-timeout behavior also needs a test beyond checking the configured value.

  6. Move test-only metadata reading out of production codefilecache.go:197
    readEntryMeta is used only by tests. It can move into the test file, or the test can decode the metadata directly.

Validation: scoped golangci-lint and go vet passed. Atelet tests passed, and filecache/sparsefile passed three race-enabled runs. Focused repetitions reproduced the flaky test; isolated probes reproduced the accounting, filesystem-error, and existing-destination issues. Full repository verification and infrastructure E2E were not run.

Test cases, reproduction commands, and observed results.

@EItanya
Eitan Yarmush (EItanya) dismissed their stale review September 5, 2026 22:57

Replaced with a comment-only review at the reviewer’s request: #1517 (review)

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.

2 participants