mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-13 10:43:28 +08:00
Phase 2 of #463 (image-cache GC + observability): this is the eviction engine itself — the root set, the eviction pass, and startup orphan recovery — assembled from the foundations merged in #650 (sizes/last-use), #656 (spec digest), #658 (retirement primitives), and #717 (record-first pull). What remains after this is the watermark loop + flags that turn it on, then metrics and e2e. **Inert in production**: nothing calls `EvictUnused` yet — the watermark GC loop and its flags come in the next PR. The only behavior `New` gains is the startup orphan scan. ## What - **`Store.InUse()`** — the root set: scans bundle overlay specs under the actors dir (opt-in via `WithActorsDir`) for image digests, layer dirs, and exact layer-set signatures. The exact-set rule roots the multi-arch twin record and digestless (pre-`imageDigest`) specs' records without making every subset image unevictable. Returns `(RootSet, error)`: a non-nil error means the root set may be incomplete, and deleting callers must do nothing. - **`Store.EvictUnused(ctx, targetBytes, dryRun)`** — LRU eviction of unprotected images and the layers their removal leaves unreferenced. Min-age vetoes; per-victim re-checks under `hitMu` held exclusive against the cache-hit touch; two-phase deletion via `retireLayer`, with the slow `RemoveAll` outside all locks. If any layer of a deleted record must be kept (still referenced, rooted, fresh, or failed to retire), the record is atomically restored from bytes captured at listing time — a kept layer is never left unreachable. - **`Store.RecoverOrphans`**, called once from `New` — reclaims layers no record references. Startup is the one moment this scan is race-free (no pull can be in flight); a failure never fails `New`. - **Everything fails toward retention.** Both passes skip entirely, at ERROR naming the culprit, when their inputs can't be fully enumerated: an unreadable/undecodable image record, a garbled diffID inside a valid record, or an unenumerable root set (actors dir, bundles dir, or bundle spec). Refcounts and roots from partial data would retire layers that unread records still reference or running actors still mount — and for a long-running actor, the bundle spec is the only protection (its record mtime can be arbitrarily old, so min-age is no backstop). Missing dirs/specs remain legitimate emptiness, not errors. - **Eviction never writes into the live pool.** Retired layers are credited from the size file that rides along with the rename-aside (O(1), read-only, consistent with `CacheSize`); the size-file backfill exists only on the `CacheSize` path, where it bumps the dir mtime rather than restoring it (restoring could rewind a concurrent reuse-touch). - Per-item log lines are Debug behind an `Enabled` gate (level control via #677); pass summaries, evictions, and restores are Info. - `internal/imagecache/README.md` now documents the GC: pass structure, protection rules, failure direction, startup recovery, and two-phase deletion, plus the on-disk layout entries (`size`, `.tmp-*`, `.rm-*`) that shipped with #650/#658. ## Notes for reviewers - The gc tests reuse the helpers merged with #650 (`backdate`, `mustEnsure`) and #717's gated-registry harness — no duplicated symbols. The slow-pull gated test gains the eviction-veto assertion deferred from #717. - Dry-run mutates nothing, including size-file backfills (`layerSizeReadOnly`). - A `Store` built without `WithActorsDir` has an empty root set (deliberate for tools/tests). - Carried to the wiring PR: pass `WithActorsDir` in atelet; handle `EvictUnused`'s error before any shortfall accounting (a gated pass returns zero stats plus an error and must not be logged as a shortfall); and surface skipped passes as a counter in the observability PR so a wedged node alerts rather than scrolling ERROR lines. ## Testing - `go test -race ./internal/imagecache/`: every veto, shared-layer survival, both restore-on-keep shapes, orphan reclaimed at startup and ignored by the periodic pass, all enumeration gates — unreadable record (shared-layer scenario), undecodable record, garbled diffID, and the three unreadable root-set shapes (actors dir / bundles dir / spec, each against a rooted image with an arbitrarily old record) — dry-run purity, 25-iteration ensure-vs-evict race loop. - Kind, via the integration branch that stacks the watermark loop on this PR (the loop and flags are **not** part of this PR): full gVisor e2e suite green; a planted backdated orphan layer (no size file) was reclaimed at atelet startup with the expected log lines, its size credited read-only from the retired dir. - GKE (6 nodes), same integration branch: demo lifecycle + snapshot-continuity suites green; under aggressive watermarks the idle pool was evicted and subsequent actor resumes cold-pulled cleanly. No enumeration-gate false positives on either cluster.
148 lines
4.7 KiB
Go
148 lines
4.7 KiB
Go
// 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 imagecache
|
|
|
|
// Size accounting and last-use recording: the data foundations for cache
|
|
// eviction. Both facts live in the filesystem — a per-layer "size" file
|
|
// and the image record's mtime — so they survive atelet restarts without
|
|
// in-memory state.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
)
|
|
|
|
// countingReader counts bytes read through it.
|
|
type countingReader struct {
|
|
r io.Reader
|
|
n int64
|
|
}
|
|
|
|
func (c *countingReader) Read(p []byte) (int, error) {
|
|
n, err := c.r.Read(p)
|
|
c.n += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
// touchRecord refreshes the record's mtime (the persisted last-use
|
|
// timestamp). Best-effort: a failed touch only costs LRU accuracy.
|
|
func (s *Store) touchRecord(digest v1.Hash) {
|
|
now := time.Now()
|
|
if err := os.Chtimes(s.recordPath(digest), now, now); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
slog.Warn("Failed to touch image record", slog.String("digest", digest.String()), slog.Any("err", err))
|
|
}
|
|
}
|
|
|
|
// layerSize returns the layer's recorded byte count, backfilling the size
|
|
// file for layers unpacked before sizes were recorded.
|
|
func (s *Store) layerSize(layerDir string) (int64, error) {
|
|
if n, ok, err := recordedLayerSize(layerDir); err != nil || ok {
|
|
return n, err
|
|
}
|
|
total := walkLayerSize(layerDir)
|
|
// Best-effort write; the walked total is valid regardless. The write
|
|
// bumps the dir mtime and deliberately does NOT restore it: restoring
|
|
// could rewind a concurrent ensureLayer reuse-touch and defeat
|
|
// retireLayer's freshness veto. The bump just makes a freshly
|
|
// backfilled layer read as recently used for one min-age window.
|
|
_ = os.WriteFile(filepath.Join(layerDir, layerSizeFileName), []byte(strconv.FormatInt(total, 10)+"\n"), 0o600)
|
|
return total, nil
|
|
}
|
|
|
|
// layerSizeReadOnly is layerSize without the backfill write, for dry-run
|
|
// eviction: "dry run mutates nothing" includes derived metadata.
|
|
func (s *Store) layerSizeReadOnly(layerDir string) (int64, error) {
|
|
if n, ok, err := recordedLayerSize(layerDir); err != nil || ok {
|
|
return n, err
|
|
}
|
|
return walkLayerSize(layerDir), nil
|
|
}
|
|
|
|
// recordedLayerSize reads the layer's size file. ok is false when the file
|
|
// is absent or corrupt (the caller falls back to walking the tree).
|
|
func recordedLayerSize(layerDir string) (n int64, ok bool, err error) {
|
|
b, err := os.ReadFile(filepath.Join(layerDir, layerSizeFileName))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return 0, false, nil
|
|
}
|
|
return 0, false, fmt.Errorf("while reading layer size: %w", err)
|
|
}
|
|
n, perr := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
|
if perr != nil {
|
|
return 0, false, nil
|
|
}
|
|
return n, true, nil
|
|
}
|
|
|
|
// walkLayerSize sums regular-file sizes under the layer's fs tree.
|
|
func walkLayerSize(layerDir string) int64 {
|
|
var total int64
|
|
fsRoot := filepath.Join(layerDir, layerFSDirName)
|
|
// The callback swallows every error (skip-and-under-count contract),
|
|
// so WalkDir cannot return one.
|
|
_ = filepath.WalkDir(fsRoot, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
// Images legitimately ship unreadable dirs (0111, 0000) and
|
|
// atelet lacks CAP_DAC_READ_SEARCH, so skip and under-count
|
|
// rather than abort.
|
|
if p != fsRoot && d != nil && d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.Type().IsRegular() {
|
|
if info, err := d.Info(); err == nil {
|
|
total += info.Size()
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return total
|
|
}
|
|
|
|
// CacheSize returns the sum of recorded sizes of every layer in the pool
|
|
// (the accounting behind --image-cache-max-bytes, distinct from volume
|
|
// usage).
|
|
func (s *Store) CacheSize() (int64, error) {
|
|
entries, err := os.ReadDir(s.layersDir())
|
|
if err != nil {
|
|
return 0, fmt.Errorf("while listing layer pool: %w", err)
|
|
}
|
|
var total int64
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), ".") || !e.IsDir() {
|
|
continue
|
|
}
|
|
n, err := s.layerSize(filepath.Join(s.layersDir(), e.Name()))
|
|
if err != nil {
|
|
slog.Warn("Failed to size layer", slog.String("layer", e.Name()), slog.Any("err", err))
|
|
continue
|
|
}
|
|
total += n
|
|
}
|
|
return total, nil
|
|
}
|