Files
substrate/internal/imagecache/sizes.go
T
igooch 596cfb8ab0 imagecache: eviction engine — root set, EvictUnused, startup orphan recovery (#735)
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.
2026-08-10 14:46:27 -07:00

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
}