Skip to content

imagecache: eviction engine — root set, EvictUnused, startup orphan recovery - #735

Open
igooch wants to merge 4 commits into
agent-substrate:mainfrom
igooch:imagecache-evict-engine
Open

imagecache: eviction engine — root set, EvictUnused, startup orphan recovery#735
igooch wants to merge 4 commits into
agent-substrate:mainfrom
igooch:imagecache-evict-engine

Conversation

@igooch

@igooch igooch commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

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.
  • 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 when the record enumeration is incomplete (unreadable/undecodable record): refcounts from partial data would make referenced layers look like garbage. The skip is an ERROR naming the records; an undecodable record is never itself evicted.
  • Per-item log lines are Debug behind an Enabled gate (level control via serverboot: dynamic --log-level flag for the server binaries #677); pass summaries, evictions, and restores are Info.

Notes for reviewers

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, enumeration gate (unreadable + undecodable), dry-run purity, 25-iteration ensure-vs-evict race loop.
  • Kind, branch build deployed: full gVisor e2e suite green; a planted backdated orphan layer on the node was reclaimed at atelet restart with the expected log lines.

igooch added 3 commits August 4, 2026 09:35
…ecovery

The engine that reclaims cache disk, built on the primitives from the
size/last-use, spec-digest, record-first-pull, and layer-retirement PRs.
Inert in production: nothing calls EvictUnused yet (the watermark GC
loop and its flags come separately); the only behavior New gains is the
startup orphan scan.

- Store.InUse: scans bundle overlay specs under the actors dir (opt-in
  via WithActorsDir) for the root set — image digests, layer hexes, and
  exact layer-set signatures. The exact-set rule roots the multi-arch
  twin record and digestless (pre-ImageDigest) specs' records, without
  making every superset image unevictable.
- Store.EvictUnused: LRU eviction of unprotected images and the layers
  their removal leaves unreferenced, with min-age vetoes, per-victim
  re-checks under hitMu held exclusive against the cache-hit touch, and
  a restore-on-keep commit protocol: if any layer of a deleted record
  must be kept (still referenced, rooted, fresh, or failed to retire),
  the record is rewritten from bytes captured at listing time so the
  kept layer is never stranded unreachable. Freed bytes are credited
  optimistically from recorded sizes; dry-run mutates nothing.
- Store.RecoverOrphans, called once from New: reclaims layer dirs no
  record references. Runs only at startup, the one moment the scan is
  race-free (no pull in flight), and skips itself entirely when the
  record enumeration is incomplete — refcounts from partial data make
  referenced layers look like garbage. Failure never fails New.
- Per-item log lines (root-set entries, per-layer keep/retire, skips)
  are Debug and gated on Enabled so suppressed passes don't pay for
  attr construction; pass summaries, evictions, and restores stay Info.
- pull_gated_test.go: the slow-pull progress-touch test now asserts the
  freshened record vetoes a concurrent EvictUnused pass (deferred from
  the record-first-pull PR, which had no engine to call).

Tests cover every veto, shared-layer survival, both restore-on-keep
shapes (wedged-pull-fresh-layer and rooted-subset), orphan reclaimed at
startup but ignored by the periodic pass, the enumeration completeness
gate, dry-run purity, and an ensure-vs-evict race loop under -race.
…erated

Review fixes, all in the error paths around unreadable records:

- EvictUnused now gates on complete record enumeration, like the
  startup scan already did. Refcounts from a partial listing understate
  references - a layer shared with an unreadable record hits zero and
  is retired while that record still names it - so the doc claim that a
  partial listing "only ever skips work" was inverted. The pass skips
  entirely, logs at ERROR naming the records, and returns the error;
  every later pass retries. Skipping only layer retirement instead
  would strand the evicted records' layers as orphans until restart, so
  the whole pass fails toward retention.
- An undecodable record is never an eviction candidate: deleting it
  would strand its (unknown) layers the same way. It stays in place,
  surfaced in the enumeration error for the operator.
- Dry-run no longer writes size-file backfills: dryRunRetire sizes via
  a read-only variant of layerSize (split into recordedLayerSize +
  walkLayerSize). The affected population is exactly the pre-size-file
  layers a dry-run soak on upgraded nodes exists to observe.
- restoreRecord documents the cost of its mtime bump: the record's true
  last-use is lost, accepted because preserving it would churn the same
  doomed candidate through delete-and-restore every pass.

One test per finding: gated pass with an unreadable shared-layer record
(root-gated on euid 0), undecodable record survival, and dry-run leaving
no size file behind.
The bulleted summary duplicated the per-test comments; sibling test
files carry at most a short note on what distinguishes the file.
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