imagecache: eviction engine — root set, EvictUnused, startup orphan recovery - #735
Open
igooch wants to merge 6 commits into
Open
imagecache: eviction engine — root set, EvictUnused, startup orphan recovery#735igooch wants to merge 6 commits into
igooch wants to merge 6 commits into
Conversation
…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.
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 > 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"]
|
…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.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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
EvictUnusedyet — the watermark GC loop and its flags come in the next PR. The only behaviorNewgains is the startup orphan scan.What
Store.InUse()— the root set: scans bundle overlay specs under the actors dir (opt-in viaWithActorsDir) 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 underhitMuheld exclusive against the cache-hit touch; two-phase deletion viaretireLayer, with the slowRemoveAlloutside 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 fromNew— reclaims layers no record references. Startup is the one moment this scan is race-free (no pull can be in flight); a failure never failsNew.Enabledgate (level control via serverboot: dynamic --log-level flag for the server binaries #677); pass summaries, evictions, and restores are Info.Notes for reviewers
backdate,mustEnsure) and imagecache: write the image record before unpacking layers #717's gated-registry harness — no duplicated symbols. The slow-pull gated test gains the eviction-veto assertion deferred from imagecache: write the image record before unpacking layers #717.layerSizesplit into read/walk/backfill parts; dry-run uses the read-only path).Storebuilt withoutWithActorsDirhas an empty root set (deliberate for tools/tests); the wiring PR must pass it in atelet.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.