Repository navigation
atelet: serve shared snapshot restores from the node-local file cache - #1551
Closed
Dmitry Berkovich (dberkov) wants to merge 4 commits into
Closed
Dmitry Berkovich (dberkov) wants to merge 4 commits into
Dmitry Berkovich (dberkov) wants to merge 4 commits into
Conversation
Dmitry Berkovich (dberkov)
force-pushed
the
filecache-m2
branch
from
September 9, 2026 01:25
d0bdb37 to
240c6ce
Compare
Dmitry Berkovich (dberkov)
marked this pull request as draft
September 10, 2026 03:59
Dmitry Berkovich (dberkov)
force-pushed
the
filecache-m2
branch
from
September 10, 2026 04:04
240c6ce to
667cd76
Compare
Dmitry Berkovich (dberkov)
force-pushed
the
filecache-m2
branch
3 times, most recently
from
September 14, 2026 00:14
03ae2e4 to
251378a
Compare
Dmitry Berkovich (dberkov)
force-pushed
the
filecache-m2
branch
from
September 17, 2026 04:03
251378a to
076951e
Compare
Dmitry Berkovich (dberkov)
force-pushed
the
filecache-m2
branch
5 times, most recently
from
September 26, 2026 05:41
b691d42 to
232e494
Compare
a4-a4s1 Bot
pushed a commit
to AlexBulankou/substrate
that referenced
this pull request
Sep 26, 2026
… golden_snapshot_uri (agent-substrate#1602) Refactors the atelet Restore API so the DATA_ON_GOLDEN base snapshot travels as a typed restore source instead of a bare string, preparing for the node-local snapshot cache (agent-substrate#690, agent-substrate#1551): `RestoreRequest` gets its own external snapshot *source* message, and read-side attributes of a restore source have a home — today the URI, next (in the M2 cache work) a sharing property set by the control plane. **This change is not backward compatible.** `golden_snapshot_uri` is removed from `RestoreRequest` with no transition: an ateapi from before this change sends a DATA_ON_GOLDEN restore an updated atelet rejects (no `base_config`), and an updated ateapi sends one an old atelet rejects (no `golden_snapshot_uri`). ateapi and atelet must be rolled together. Fresh starts, FULL/DATA restores of an actor's own snapshot, and local pause restores without a golden base are unaffected. Three commits, each buildable and tested: 1. **`ateletpb: give Restore its own external snapshot source message`** — `ExternalRestoreConfiguration` replaces `ExternalCheckpointConfiguration` in `RestoreRequest`'s config oneof (same wire shape; the checkpoint-side write-destination message is untouched), and `base_config` is added for the DATA_ON_GOLDEN base. No behavior change: nothing sets or reads the new field yet. 2. **`ateapi, atelet: carry the restore base snapshot in base_config`** — the cutover: ateapi sets `base_config` on both golden-data resume paths (external data snapshot, and local pause checkpoint combining with the golden); atelet reads and validates only `base_config`. The characteristic test of the ateapi→atelet request and the golden-data functional test pin the new field. 3. **`ateletpb: remove golden_snapshot_uri from RestoreRequest`** — the old field is deleted and `base_config` takes its field number, keeping the numbering dense. Tested: full ateapi + atelet suites including the controlapi functional tests; gofmt, golangci-lint, boilerplate clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Dmitry Berkovich (dberkov)
force-pushed
the
filecache-m2
branch
3 times, most recently
from
October 3, 2026 01:46
b119745 to
f55edca
Compare
Dmitry Berkovich (dberkov)
marked this pull request as ready for review
October 3, 2026 01:51
Whether a snapshot's files may be cached node-locally is the control plane's knowledge, not the node's: atelet should not infer it from URI layout or atespace conventions, which change as tags supersede golden snapshots. Add SnapshotSharing to the restore source message — deliberately the property (PRIVATE or SHARED) rather than the snapshot's kind, so new shared snapshot kinds need no atelet change — and set it in ateapi's resume workflow: SHARED when the restore reads the template's golden snapshot, PRIVATE for the actor's own snapshot. Nothing consumes the field yet.
Shared snapshot files (a template's golden snapshot today, tag snapshots later) are immutable once published, yet every restore that needs them re-downloads them into its own per-actor dir. Introduce the node-level cache that will hold them: a filecache.Store rooted under the ateom base path (cache hits will be served as hard links into per-actor restore dirs, which requires one mounted filesystem), opened at startup behind --snapshot-cache-dir with crash debris swept before atelet serves. No restore behavior changes yet: the store is wired into the herder but nothing reads through it. The cached download path and the eviction loop follow separately.
Snapshot files were downloaded into every actor's restore dir, even when a dozen actors resume from one shared snapshot on the same node. Route the cacheable legs of the restore download paths through the snapshot cache: the DATA_ON_GOLDEN base half (shared by construction), the local-pause combine leg, and an external restore whose source the control plane declared SHARED (a fresh-from-golden start). Private snapshots and requests from callers that predate the sharing field download fresh; atelet never infers cacheability from the URI. Cache hits are hard links to a read-only shared copy, so serving is gated per sandbox class: ateom-gvisor consumes restore-state strictly read-only and gets links; ateom-microvm rewrites config.json in place at restore and merges checkpoint deltas into memory-ranges' inode at suspend, either of which would corrupt a shared inode, so its class downloads fresh until a private-copy mode serves it.
The snapshot cache served only gVisor restores: its hits are read-only hard links, and ateom-microvm mutates staged files in place (config.json is rewritten at restore, checkpoint deltas are merged into memory-ranges' inode at suspend), which would corrupt a shared inode. Add cacheModeCopy: hits are hole-preserving private copies (see filecache.GetFileCopyTo), so the micro-VM class keeps its in-place mutations — no ateom-microvm changes — while concurrent restores of one snapshot still share a single download and later restores read the node cache instead of object storage. A link-mode fetch that fails with EXDEV (cache configured on a different mount than the restore dirs) now degrades to a copy too, keeping the download savings where it previously fell back to a fresh download per restore.
Dmitry Berkovich (dberkov)
force-pushed
the
filecache-m2
branch
from
October 6, 2026 22:36
f55edca to
9046cbb
Compare
This was referenced Oct 14, 2026
Closed
Collaborator
Author
|
Superseded by #2796, the same design ported onto today's main. This branch predates the restore source becoming Snapshot.object, the move of downloads to the object-store plugin, and the module rename, so a rebase would have rewritten every commit; the port keeps the three-commit shape and adds what the new base needed. |
Haowei Cai (Roy) (roycaihw)
pushed a commit
to roycaihw/substrate
that referenced
this pull request
Oct 20, 2026
…agent-substrate#2796) Replaces agent-substrate#1551, which was four commits from September that no longer applied: the restore source message, the download path and the module path all changed underneath it. Same design, ported onto today's main, after agent-substrate#2756 and agent-substrate#2889, in four commits, plus three from review: the sharing derived from the snapshot's owner, the gVisor worker evicting a shared file's pages before unlinking it, and the manifest read through the cache. Milestone M2 of the node-local artifact cache from agent-substrate#690: an actor restoring from a shared snapshot, the one a tag holds and every actor created from it starts on, gets its files from a node-level cache instead of its own download. **Sharing is the control plane's knowledge.** `ObjectStorage` gains `SnapshotSharing`, the property rather than the snapshot's kind, so new shared snapshot kinds need no atelet change. ate-api-server sets it on Restore from the snapshot's recorded owner: SHARED for a tag's snapshot, borrowed at creation and immutable, PRIVATE for the actor's own, which its next suspend replaces, and for an unrecorded owner; the local-copy fallback object carries it too. Neither side infers it from the URI. **The cache.** A `filecache.Store` under `/var/lib/ate/snapshot-cache`, behind `--snapshot-cache-dir` (empty disables), `--snapshot-cache-min-age` and `--snapshot-cache-fetch-timeout`, opened at startup with crash debris swept before atelet serves. The directory must be under the node state dir: the object-store plugin writes only there, so a cache elsewhere could never be filled, and startup refuses it. Every flag is validated whether or not the cache is enabled, and the help text says which are read only while it is. The image cache's base-path check moved to `nodepath.UnderBasePath`, which both caches use. **Eviction.** The cache runs on the eviction loop every file cache on the node shares, `cmd/atelet/internal/filecachegc` from agent-substrate#2889: `--snapshot-cache-gc-period`, `--snapshot-cache-max-bytes` and `--snapshot-cache-gc-dry-run`, a budget with a 90% low water, least recently used first, never an entry a restore still links. Its size, evictions and passes appear under the existing `ate.filecache.*` instruments as a second value of `ate.filecache.name`, `snapshot`; no new instrument and no new loop. Every flag is validated whether or not the cache is enabled, as the other flags in atelet are, and the help text says which ones are read only while it is; a disabled cache starts no loop and reports no size. **The restore path.** A restore whose durable snapshot is declared SHARED stages each file through the cache; concurrent restores of one snapshot share a single download, later ones read disk. How a hit is served depends on the sandbox class the request's runtime resolved to. ateworker-gvisor gets hard links. Once the restore's page loading is done the worker evicts the memory and filesystem images from the page cache and unlinks them from its restore dir, keeping only the small descriptors linked: a file with another link is treated like a preserved restore dir, since unlinking it would free nothing and would leave its pages charged to the actor's cgroup, the case agent-substrate#1917 fixed. So a link protects an image's entry through the restore and the min age, not for the actor's life; an entry evicted later is still read to the end through the descriptors runsc holds open, and the next restore reads the images from local disk. ateworker-microvm gets private hole-preserving copies, not because a link would corrupt the cache, it replaces `config.json` by rename and its delta merge copies when the inode is shared, but because a link would cost more than it saves: an on-demand restore keeps `memory-ranges` open for the VM's life, which would pin the entry that long and turn every suspend's in-place merge into a full copy, and the worker's page-cache eviction would drop the cached pages for every other restore; agent-substrate#2948 tracks what links would take. EXDEV on a link, from a cache bind-mounted apart from the restore dirs, degrades to a copy, warned once per process. The manifest of a shared snapshot is read through the cache too, so a restore that finds the snapshot on the node makes no request to object storage; a private snapshot's manifest is read fresh, as the commit marker it is. The cache key is the object's address without its query, so a signed URL's credential reaches neither the key, the cache's errors, `meta.json` nor the logs; and a restore that fails after staging removes its restore-state dir, so the links do not hold the cache's entries until the next lifecycle call. The fetch goes through the object-store plugin as the direct path does, one file into a scratch dir on the cache's filesystem; because the cache then writes the restore dir by path rather than through the plugin's `os.Root`, a symlink waiting there fails the restore instead of being followed. **Not here.** Private snapshots stay uncached by design. Links for ateworker-microvm are agent-substrate#2948, a worker change. The open-and-sweep code the two file caches share in shape is still duplicated, since the snapshot cache can be disabled and degrades to copies across mounts where the asset cache refuses to start. **Tests.** ate-api functional: a golden restore declares SHARED, the actor's own snapshot PRIVATE. ate-api unit: the detach-and-resume test from agent-substrate#2852 compares the whole Restore snapshot, so its fixture now puts the snapshot under the actor's own UID, and it expects PRIVATE for the downloaded copy and nothing for a local one. atelet: flag validation, enabled or not, including the eviction flags, the fetch timeout, and a directory outside the node state dir; two shared manifest reads download once and a private one reads fresh; open disabled, creating its root, sweeping debris; the sharing gate and the class modes; through the plugin-backed test atelet, two link restores share one download and one inode and the file is read-only, copy mode gives private inodes and survives a consumer's mutation, a nil cache downloads, a missing file's error reaches the caller with nothing left behind, a plugin error through the cached modes keeps its code and leaks no credential from a signed URI, a restore that fails at the worker after staging leaves no file in restore-state and no link on the cache's entry, four concurrent restores download once, a file in the checkpoint subdirectory lands there, and symlinks in place of the file or the subdirectory fail; on Linux, a restore dir on another mount gets a copy with no second download and one warning for two restores. ateworker-gvisor, on Linux: staged images hard-linked elsewhere are evicted after the restore wait and only then unlinked, with the other links intact. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements milestone M2 of the node-local artifact cache proposed in #690, on top of the Restore source refactor from #1602: atelet restores now serve shared snapshot files (a tag's snapshot that a new actor borrows — the template's golden tag today, explicit clone tags too) from a node-local cache backed by the
filecachestore from #1517, so concurrent restores of one snapshot share a single download and later restores read from disk instead of object storage.Four commits, each buildable and tested:
ateletpb, ateapi: declare how a restore's external snapshot is shared— cacheability is the control plane's knowledge, not the node's: atelet must not infer it from URI layout or atespace conventions.SnapshotSharing(PRIVATE / SHARED) onExternalRestoreConfigurationis deliberately the property rather than the snapshot's kind, so new shared snapshot kinds need no atelet change. ateapi's resume workflow derives it from ownership: a snapshot under the actor's own prefix is PRIVATE; one the actor borrowed from a tag is SHARED. Functional tests pin SHARED on a fresh actor's first resume and on a tag clone, PRIVATE after the actor's own suspend. Nothing consumes the field yet.atelet: open a snapshot file cache at startup— lifecycle only:--snapshot-cache-dir(default<base-path>/snapshot-cacheviaateletpath.SnapshotCacheDir, empty disables; must share the actor dirs' mount because hits are hard links — the sharednodepath.UnderBasePathcheck, now also used by the image cache, warns otherwise) and--snapshot-cache-min-age; the store opens at startup andSweepDebrisclears crash debris before atelet serves. No behavior change yet.atelet: serve shared snapshot downloads from the file cache— the external restore leg goes through the cache only when the control plane declared the source SHARED; private snapshots, and requests from callers predating the field, download exactly as today (theos.Root-confined path from Snapshot path validation #1950 is kept for them; cached files are held to the same bare-file-name rule). Serving is per sandbox class: gVisor consumes restore-state read-only and gets zero-cost hard links.atelet: serve micro-VM snapshot downloads as private copies— micro-VM cannot share inodes (ateom-microvm rewritesconfig.jsonin place at restore and merges checkpoint deltas intomemory-ranges' inode at suspend), so its hits are hole-preserving private copies (GetFileCopyTo): one download per snapshot per node, mutations stay private, no ateom-microvm changes. A link-modeEXDEV(cache on a different mount) degrades to a copy too, keeping the download savings.The M2 exit criterion — N concurrent restores of one shared snapshot = one download — is pinned at the unit level by
TestDownloadExternalCheckpointSharesOneGoldenDownload(4 concurrent restores, 1 GCS get, shared inode) and its copy-mode counterpart; the sharing gate has its own matrix test.Still to come for M2 (will follow once this lands): the pressure-driven GC loop for the snapshot cache (mirroring
imagegc's watermark formula) — until then the cache grows unbounded, which matches today's behavior forstatic-filesand is bounded in practice by tag churn.Tested:
go test -race ./cmd/atelet/...plus the controlapi functional suite; every commit builds individually; the cross-mount EXDEV fallback has a dedicated test that exercises two real filesystems where available (/dev/shmon Linux CI) and skips elsewhere;golangci-lint, gofmt, and boilerplate checks clean.🤖 Generated with Claude Code