-
Notifications
You must be signed in to change notification settings - Fork 229
atelet: watermark-driven image cache GC loop #836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Dmitry Berkovich (dberkov)
merged 3 commits into
agent-substrate:main
from
igooch:imagecache-gc-loop
Aug 11, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,280 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package main | ||
|
|
||
| // The image-cache GC loop. | ||
| // | ||
| // A single serialized pass on a fixed period (the kubelet's shape — the | ||
| // heavy deletion work happens outside the pull path's locks, so there is | ||
| // nothing to duty-cycle). Each pass measures the cache volume with statfs | ||
| // and the pool's own recorded size, computes how many bytes to free — | ||
| // down to the low watermark when volume usage crossed the high one, and/or | ||
| // down to --image-cache-max-bytes when the pool outgrew it — and hands the | ||
| // larger target to Store.EvictUnused. | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "log/slog" | ||
| "os" | ||
| "path/filepath" | ||
| "runtime/debug" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/agent-substrate/substrate/internal/ateompath" | ||
| "github.com/agent-substrate/substrate/internal/imagecache" | ||
| "github.com/spf13/pflag" | ||
| "golang.org/x/sys/unix" | ||
| ) | ||
|
|
||
| var ( | ||
| imageCacheGCPeriod = pflag.Duration("image-cache-gc-period", 5*time.Minute, "How often to run the image cache eviction pass. 0 disables eviction entirely.") | ||
| imageCacheHighPct = pflag.Int("image-cache-high-percent", 85, "Cache-volume usage percentage above which eviction starts.") | ||
| imageCacheLowPct = pflag.Int("image-cache-low-percent", 80, "Cache-volume usage percentage eviction frees down to. Must be lower than --image-cache-high-percent.") | ||
| imageCacheMaxBytes = pflag.Int64("image-cache-max-bytes", 0, "Absolute cap on the summed size of cached layers, evicted down to independently of the volume watermarks. 0 means no cap.") | ||
| imageCacheMinAge = pflag.Duration("image-cache-min-age", 2*time.Minute, "Layers and image records younger than this are never evicted (protects images pulled but not yet mounted).") | ||
| imageCacheGCDryRun = pflag.Bool("image-cache-gc-dry-run", false, "Compute and log eviction decisions without deleting anything.") | ||
| ) | ||
|
|
||
| const ( | ||
| // shortfallWarnLimit is how many consecutive shortfalls warn before the | ||
| // loop backs off to shortfallReminderEvery. | ||
| shortfallWarnLimit = 3 | ||
| // shortfallReminderEvery keeps a persistent shortfall visible without a | ||
| // line per tick (at the 5m default: roughly hourly). | ||
| shortfallReminderEvery = 12 | ||
| ) | ||
|
|
||
| func validateImageCacheGCFlags() error { | ||
| if *imageCacheHighPct < 1 || *imageCacheHighPct > 100 { | ||
| return fmt.Errorf("--image-cache-high-percent %d out of range [1,100]", *imageCacheHighPct) | ||
| } | ||
| if *imageCacheLowPct < 0 || *imageCacheLowPct >= *imageCacheHighPct { | ||
| return fmt.Errorf("--image-cache-low-percent %d must be in [0,%d)", *imageCacheLowPct, *imageCacheHighPct) | ||
| } | ||
| if *imageCacheMinAge < 0 { | ||
| // A negative min-age inverts the veto (the cutoff lands in the | ||
| // future), making just-pulled layers evictable. | ||
| return fmt.Errorf("--image-cache-min-age %v must be >= 0", *imageCacheMinAge) | ||
| } | ||
| // Outside BasePath the watermarks measure a different volume than | ||
| // actor state, so the pass would chase pressure the cache doesn't | ||
| // contribute to. Warn, don't fail: a separate cache volume is | ||
| // legitimate (recommended for IOPS) — it just wants its own numbers. | ||
| if !strings.HasPrefix(filepath.Clean(*imageCacheDir), ateompath.BasePath+string(os.PathSeparator)) { | ||
| slog.Warn("Image cache dir is outside the ateom base path; its volume watermarks are measured separately from actor state", | ||
| slog.String("image_cache_dir", *imageCacheDir), | ||
| slog.String("actors_dir", ateompath.ActorsDir)) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // imageCacheGCTarget computes the bytes a pass should free: the larger | ||
| // of the watermark shortfall (kubelet's formula — usage at highPct frees | ||
| // down to lowPct) and the pool's overage past maxBytes, but never more | ||
| // than the cache actually holds. | ||
| // | ||
| // The cache-size ceiling is the difference from kubelet, which owns its | ||
| // imagefs. This cache is one tenant of a shared volume, so the raw | ||
| // watermark target can dwarf it (measured: a 105 GiB volume at 98% asks | ||
| // an 11 MiB cache for 18.9 GiB), and an uncapped pass would evict | ||
| // everything every tick for a permanent 0% hit rate. Capped, the cache | ||
| // gives back all it can; the residual shortfall is someone else's disk — | ||
| // reported, not chased. | ||
| func imageCacheGCTarget(capacity, available uint64, cacheSize, maxBytes int64, highPct, lowPct int) int64 { | ||
| var target int64 | ||
| if capacity > 0 { | ||
| // Integer floor of the available fraction: usage reads up to ~1% | ||
| // high, so eviction can trigger just before the nominal watermark. | ||
| usedPct := 100 - int(available*100/capacity) | ||
|
igooch marked this conversation as resolved.
|
||
| if usedPct >= highPct { | ||
| // Free enough that available climbs back to (100-lowPct)% of | ||
| // capacity. | ||
| target = int64(capacity)*int64(100-lowPct)/100 - int64(available) | ||
| } | ||
| } | ||
| if maxBytes > 0 && cacheSize > maxBytes { | ||
| if over := cacheSize - maxBytes; over > target { | ||
| target = over | ||
| } | ||
| } | ||
| if target > cacheSize { | ||
| target = cacheSize | ||
| } | ||
| if target < 0 { | ||
| target = 0 | ||
| } | ||
| return target | ||
| } | ||
|
|
||
| // imageCacheGC is the loop's state: configuration snapshotted from the | ||
| // flags at construction (the pass logic never reads globals, so it is | ||
| // testable without flag juggling) plus the shortfall-backoff counter. | ||
| type imageCacheGC struct { | ||
| store *imagecache.Store | ||
| cacheDir string | ||
| period time.Duration | ||
| highPct int | ||
| lowPct int | ||
| maxBytes int64 | ||
| dryRun bool | ||
|
|
||
| consecutiveShortfalls int | ||
| } | ||
|
|
||
| func newImageCacheGC(store *imagecache.Store, cacheDir string) *imageCacheGC { | ||
| return &imageCacheGC{ | ||
| store: store, | ||
| cacheDir: cacheDir, | ||
| period: *imageCacheGCPeriod, | ||
| highPct: *imageCacheHighPct, | ||
| lowPct: *imageCacheLowPct, | ||
| maxBytes: *imageCacheMaxBytes, | ||
| dryRun: *imageCacheGCDryRun, | ||
| } | ||
| } | ||
|
|
||
| // Run executes eviction passes on the configured period until ctx is | ||
| // done. Passes are strictly serialized: a slow pass delays the next tick | ||
| // rather than overlapping it. | ||
| func (g *imageCacheGC) Run(ctx context.Context) { | ||
| // First pass immediately: a node booting under disk pressure must not | ||
| // wait a full period (startup recovery reclaims debris, not pressure). | ||
| g.runPass(ctx) | ||
|
|
||
| ticker := time.NewTicker(g.period) | ||
| defer ticker.Stop() | ||
| for { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return | ||
| case <-ticker.C: | ||
| } | ||
|
|
||
| g.runPass(ctx) | ||
| } | ||
| } | ||
|
|
||
| // runPass performs one pass. It recovers from panics: this is a | ||
| // background janitor, and a bug here (or a malformed directory an operator | ||
| // dropped into the pool) must not take atelet down with it and strand every | ||
| // actor on the node. | ||
| func (g *imageCacheGC) runPass(ctx context.Context) { | ||
| defer func() { | ||
| if r := recover(); r != nil { | ||
| slog.ErrorContext(ctx, "Image cache GC pass panicked; skipping this pass", | ||
| slog.Any("panic", r), slog.String("stack", string(debug.Stack()))) | ||
| } | ||
| }() | ||
|
|
||
| var st unix.Statfs_t | ||
| if err := unix.Statfs(g.cacheDir, &st); err != nil { | ||
| slog.WarnContext(ctx, "Image cache GC: statfs failed", slog.String("dir", g.cacheDir), slog.Any("err", err)) | ||
| return | ||
| } | ||
| capacity := st.Blocks * uint64(st.Bsize) | ||
| available := st.Bavail * uint64(st.Bsize) | ||
|
|
||
| // Same failure class as the enumeration gates (ReadDir of the layer | ||
| // pool): fail toward retention, retry next tick. | ||
| cacheSize, err := g.store.CacheSize() | ||
| if err != nil { | ||
| slog.WarnContext(ctx, "Image cache GC: sizing the pool failed; skipping this pass", | ||
| slog.Any("err", err)) | ||
| return | ||
| } | ||
|
|
||
| target := imageCacheGCTarget(capacity, available, cacheSize, g.maxBytes, g.highPct, g.lowPct) | ||
|
|
||
| tStart := time.Now() | ||
| // Runs even at target 0: the enumeration gates should surface a | ||
| // corrupt record or spec on the next tick, not first under disk | ||
| // pressure. Cost: the full root-set scan (a ReadDir per actor, a | ||
| // read per bundle spec) plus a read per image record — hundreds of | ||
| // small reads on a busy node. Deliberate, and cheap at this period. | ||
| stats, err := g.store.EvictUnused(ctx, target, g.dryRun) | ||
| attrs := []any{ | ||
| slog.Int64("target_bytes", target), | ||
| slog.Int64("freed_bytes", stats.FreedBytes), | ||
| slog.Int("evicted_images", stats.EvictedImages), | ||
| slog.Int("evicted_layers", stats.EvictedLayers), | ||
| slog.Int("candidates", stats.Candidates), | ||
| slog.Int("rooted_images", stats.RootedImages), | ||
| slog.Int("orphan_layers", stats.OrphanLayers), | ||
| slog.Int("skipped_rooted", stats.SkippedRooted), | ||
| slog.Int("skipped_fresh", stats.SkippedFresh), | ||
| slog.Int64("cache_size_bytes", cacheSize), | ||
| slog.Bool("dry_run", g.dryRun), | ||
| slog.Duration("took", time.Since(tStart)), | ||
| } | ||
| outcome := classifyGCPass(err, target, stats.FreedBytes) | ||
| if outcome == gcPassSkipped { | ||
| slog.ErrorContext(ctx, "Image cache GC pass skipped", append(attrs, slog.Any("err", err))...) | ||
|
igooch marked this conversation as resolved.
|
||
| return | ||
| } | ||
| if err != nil { | ||
| // Per-item failures; each retries next pass. | ||
| slog.WarnContext(ctx, "Image cache GC pass finished with errors", append(attrs, slog.Any("err", err))...) | ||
| } | ||
| switch outcome { | ||
| case gcPassShortfall: | ||
| // The capped target means a shortfall is "the cache cannot give | ||
| // more" — on a volume under foreign pressure, the steady state. | ||
| // Warn on the first few, then a periodic reminder, never | ||
| // ERROR-per-tick. | ||
| g.consecutiveShortfalls++ | ||
| switch { | ||
| case g.consecutiveShortfalls <= shortfallWarnLimit: | ||
| slog.WarnContext(ctx, "Image cache GC could not reach target", | ||
| append(attrs, slog.Int("consecutive", g.consecutiveShortfalls))...) | ||
| case g.consecutiveShortfalls%shortfallReminderEvery == 0: | ||
| slog.WarnContext(ctx, "Image cache GC still short of target; the remaining pressure is not the image cache's to free", | ||
| append(attrs, slog.Int("consecutive", g.consecutiveShortfalls))...) | ||
| } | ||
| case gcPassComplete: | ||
| g.consecutiveShortfalls = 0 | ||
| slog.InfoContext(ctx, "Image cache GC pass complete", attrs...) | ||
| default: // gcPassQuiet: no target, nothing to say. | ||
| g.consecutiveShortfalls = 0 | ||
| } | ||
| } | ||
|
|
||
| // gcPassOutcome classifies one finished pass for logging and backoff. | ||
| type gcPassOutcome int | ||
|
|
||
| const ( | ||
| gcPassSkipped gcPassOutcome = iota // gated: nothing was attempted | ||
| gcPassShortfall // ran; target not met | ||
| gcPassComplete // ran; target met | ||
| gcPassQuiet // no target | ||
| ) | ||
|
|
||
| // classifyGCPass keeps the gate-vs-shortfall distinction testable and on | ||
| // contract (the engine's sentinel), not inferred from stats: a gated pass | ||
| // means "repair the named file", never "the cache cannot give more". | ||
| func classifyGCPass(err error, target, freed int64) gcPassOutcome { | ||
| switch { | ||
| case errors.Is(err, imagecache.ErrIncompleteEnumeration): | ||
| return gcPassSkipped | ||
| case target > 0 && freed < target: | ||
| return gcPassShortfall | ||
| case target > 0: | ||
| return gcPassComplete | ||
| default: | ||
| return gcPassQuiet | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.