Skip to content

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

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

imagecache: eviction engine — root set, EvictUnused, startup orphan recovery#735
igooch wants to merge 6 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.
@igooch

igooch commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

For reviewers:

How a pass decides, and specifically how running actors are protected. Protection is based on use, not age: an image is protected while any actor on this node references it (i.e., while that actor's bundle spec exists on disk). mtime only orders images that nothing references.

flowchart TD
    subgraph inuse ["Store.InUse() — LOOP over every container bundle of every actor on this node"]
        SPEC["read actors/<uid>/bundles/<container>/rootfs-overlay.json"]
        SPEC --> MAPS["ImageDigests: spec.imageDigest<br/>LayerHexes: each layer dir the spec names<br/>LayerSets: exact layer-set signature"]
    end

    inuse --> listing

    subgraph listing ["Store.listEviction() — LOOP over every image record (image-cache/manifests/sha256/*.json)"]
        REC["read + decode record;<br/>refcount++ each of its layers<br/>(rooted and fresh records count too)"]
        REC --> G1{"this record rooted?<br/>ImageDigests[digest] OR<br/>LayerSets[signature(its diffIDs)]"}
        G1 -- "yes — running actors exit here;<br/>mtime never consulted" --> NXT1["next record ↩"]
        G1 -- "no" --> G2{"record mtime younger<br/>than min-age?"}
        G2 -- "yes" --> NXT1
        G2 -- "no" --> CAND["→ candidate list"]
    end

    listing --> COMPLETE{"was every record read + decoded?"}
    COMPLETE -- "no" --> SKIP["⛔ skip entire pass (ERROR) —<br/>partial refcounts would retire layers<br/>unread records still name"]
    COMPLETE -- "yes" --> SORT["sort candidates by mtime, oldest first<br/>(order among evictables — never permission)"]

    SORT --> evict

    subgraph evict ["Store.EvictUnused() — LOOP over candidates until targetBytes freed"]
        RM["removeStaleRecord: re-check under hitMu<br/>(gone? fresh again?) then delete record"]
        RM --> layers

        subgraph layers ["retireCandidateLayers — LOOP over the deleted record's layers"]
            L1{"refcount-- still &gt; 0?<br/>(another image uses it)"}
            L1 -- "yes" --> KEEP["layer kept"]
            L1 -- "no" --> L2{"LayerHexes[hex]?<br/>(a bundle spec names this layer)"}
            L2 -- "yes" --> KEEP
            L2 -- "no" --> L3{"layer dir mtime fresh?"}
            L3 -- "yes" --> KEEP
            L3 -- "no" --> MARK["retireLayer: rename → .rm-* (the mark)"]
        end

        layers --> KEPT{"anything kept?"}
        KEPT -- "yes" --> RESTORE["restoreRecord — record rewritten,<br/>image NOT evicted; next candidate ↩"]
        KEPT -- "no" --> EVICTED["image evicted ✓; next candidate ↩"]
    end

    evict --> RRD["removeRetiredDirs — slow RemoveAll of .rm-* dirs,<br/>outside all locks; crash debris swept at next startup"]
Loading
  • A referenced image never reaches the mtime sort — its layers can be months old with
    zero risk; protection ends only when its bundle is removed (post-checkpoint), never by aging.
  • "Marked for deletion" = renamed to .rm-*, possible only for a layer no record
    references and no bundle names.
  • Every failure path lands on retention (an unreadable bundle spec is the one WARN-and-root-nothing exception).

igooch added 2 commits August 5, 2026 13:26
…oped

The hit block in EnsureImage released hitMu.RUnlock explicitly because a
defer would have held the read lock across the pull path. Extracting the
block into cachedImageHit makes lock scope equal function scope, so defer
works - and a panic between lock and unlock can no longer leak a read
lock. That leak was free before this PR (hitMu had no exclusive holder);
now it would wedge removeStaleRecord, and with it every future eviction
pass, in a process whose RPC layer survives panics.

Mirrors removeStaleRecord on the write side: both parties to the hitMu
contract are now named functions whose body is their critical section,
and the field comment points at each.
The hitMu field comment picked up a 123-column line when the function
names were inserted; rather than reflowing, shrink it to the invariant -
the two named functions (cachedImageHit, removeStaleRecord) now carry
the mechanics, so the field comment restating them was duplication.

retireCandidateLayers takes dbg as a parameter again instead of
recomputing Enabled per candidate, restoring the once-per-pass hoist the
extraction had quietly undone.
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