Skip to content

atelet: simplify the file cache internals per review - #1605

Closed
Dmitry Berkovich (dberkov) wants to merge 7 commits into
agent-substrate:mainfrom
dberkov:filecache-m1-cleanup
Closed

atelet: simplify the file cache internals per review#1605
Dmitry Berkovich (dberkov) wants to merge 7 commits into
agent-substrate:mainfrom
dberkov:filecache-m1-cleanup

Conversation

@dberkov

Copy link
Copy Markdown
Collaborator

Follow-up to the readability review on #1517, kept as its own PR so that branch stays exactly as reviewed. Stacked on #1517: review only the top commit (atelet: simplify the file cache internals per review).

What it does, mapped to the review comments:

  • createDestFile seam — removed entirely, along with the sparseDest interface and the Fd() type-assertion it forced: CopyFile opens with os.Create, copySparse writes real *os.Files. The userspace fallback is now tested through the public API: CopyFile from disk to /dev/shm, where copy_file_range genuinely fails with EXDEV (hard-link probe verifies the mounts differ; skips where no second filesystem exists — platforms without a kernel copy path exercise userspace in the plain holes test already). The close-error test is deleted with the seam; it only existed because the seam made it possible.
  • getTo's parameters — clarified rather than restructured, because neither is a test seam: fetch is the caller's downloader (the production API; M2 binds the GCS fetcher through it), and the second parameter is the link-vs-copy serving strategy shared by the two public methods. It now has a named, documented type (serveHit) so the loop reads as that split.
  • Debris schedule — eviction passes now retry .rm-* leftovers a previous pass failed to remove (race-free under evictMu; skipped in dry-run; test included), so stranded bytes no longer wait for a restart. tmp/ stays startup-swept only — a later sweep would delete in-flight fetches' working directories — and SweepDebris's doc now says both things explicitly.
  • File namingfilecache.go split into doc.go (package contract), store.go (store lifecycle), sweep.go; the sizing helpers moved next to their consumer in evict.go.
  • Comment language — trimmed to shorter declarative sentences throughout; same technical content.

Kept as-is: FileFetcher remains a func type — it is the production injection point (call sites bind a GCS client and object URI via closure), and single-method func types are the stdlib idiom for exactly this (http.HandlerFunc, filepath.WalkFunc).

Tested: full atelet suite; filecache and sparsefile under -race -count=3; gofmt, golangci-lint, boilerplate 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.
Remove the createDestFile test seam and the sparseDest indirection:
CopyFile creates its destination directly, copySparse writes real
files, and the userspace fallback is tested through the public API by
copying across filesystems (copy_file_range fails with EXDEV on a
/dev/shm destination). Name getTo's serving-mode parameter (serveHit)
so the shared loop reads as the strategy split it is, not a test seam.

Eviction passes now retry .rm-* leftovers a previous pass failed to
remove, so those bytes no longer wait for the next restart's sweep;
tmp/ stays startup-swept only, since a later sweep would delete
in-flight fetches' working directories.

Split filecache.go into doc.go, store.go, and sweep.go, moving the
sizing helpers next to their consumer in evict.go, and shorten the
package's comments.
@dberkov

Copy link
Copy Markdown
Collaborator Author

Closing: per discussion the cleanup lands as the top commit on #1517 directly (same commit, 5a6b6ba) instead of a stacked PR.

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