Skip to content

Add --dry-run to tart prune - #1334

Open
akim136 wants to merge 4 commits into
openai:mainfrom
akim136:feat/prune-dry-run
Open

akim136 wants to merge 4 commits into
openai:mainfrom
akim136:feat/prune-dry-run

Conversation

@akim136

@akim136 akim136 commented Sep 16, 2026 •

Copy link
Copy Markdown

tart prune deletes straight away, with no way to check what it's about to remove first.
Since it has twice taken more than intended (#271, #549), a preview seemed worth having.

--dry-run lists what would go, in the order it would go, with sizes and a total, and
touches nothing on disk.

$ tart prune --entries caches --space-budget 10 --dry-run
Dry run: no changes will be made.
Would remove /Users/me/.tart/cache/.../ghcr.io/org/image/tag (4.2 GB)
Would remove /Users/me/.tart/cache/IPSWs/UniversalMac_15.2_24C101.ipsw (8 KB)
Total estimated space reclaimed: 4.2 GB (allocated size).

How it works

Preview and real runs share one selection path: a PruningOperation passed into
pruneOlderThan and pruneSpaceBudget either deletes or records what it picked. A separate
preview implementation would drift from the real one and end up lying, so there isn't one.

Two things needed care.

The budget loop only makes progress because each delete() shrinks the list it rebuilds.
Take the delete away and it never finishes. Preview mode therefore remembers what it picked
and feeds that back in on the next rebuild.

Simply filtering those out afterwards isn't enough. Deleting a stacked cache record hands
its shared content to another record, pushing that record's charged size up — which can take
it over budget, so a real run deletes it too. Ownership is computed from the records on disk,
so a preview that filtered its own results never saw this and could list fewer entries than
the real run deletes. That's the dangerous direction for a preview, so the simulated removals
go into the storage instead, via PrunableStorage.prunables(simulatingRemovalOf:), and
ownership and content-reference scans recompute exactly as they would after a real delete.
The protocol default filters by URL, which is right for IPSW and local VM storage;
VMStorageOCI overrides it. Real runs pass an empty set and behave as before.

PruneTests and the existing stacked-content test both pin this: preview and real deletion
must select the same records in the same order.

Reading the numbers

Per-entry sizes are allocated usage when each entry is selected; the total is the net drop in
usage across the simulation. So content that only changes owner isn't double-counted, content
swept by garbage collection is, and where images share content the entries can add up to more
than the total. Sizes use allocatedSizeBytes(), since sizeBytes() overstates sparse disk
images — either way an estimate, not a promise of free space.

Keeping the dry run dry

Three paths would otherwise have written to disk: --gc, the garbage collection Root runs
before every command (so a dry run mutated state before Prune.run() was even reached), and
building the storages, since Config.validateTartHome() creates directories despite its name.
The first two are skipped, the storages take an optional readOnly flag, and existing callers
plus automatic low-disk pruning are untouched.

A fourth was subtler and came out of review. VMStorageOCI.list() recursively enumerated
each cached-image record, and on filesystems that update directory atimes during enumeration
that advances the very timestamp age and LRU selection read back — so a preview could make old
entries look recently accessed and change what the real prune then removed. Cache records are
leaves in the namespace and their contents are already addressed by known path, so the scan now
skips their descendants. Record selection timestamps are preserved exactly, to the nanosecond,
with no restoration writes; the real scan stops paying for the traversal too. Enumerated
ancestor directories and manifest files still acquire atimes, but those are not selection
inputs.

Those skips are the only things a dry run doesn't model, and it says so after the results when
they apply: --gc can remove untagged records the preview didn't list, and temporary cleanup
can release content pinned by stale temporary manifests, pushing more images over budget.

Tests and checks

New PruneTests.swift covers both criteria, ordering, termination, shared ownership, content
GC, failure paths and that a real run still deletes. The existing stacked-image test now asserts
preview and deletion agree rather than differ. Its fixtures originally set future access times
so enumeration could not disturb their ordering — which encoded the atime defect above as a
workaround — and now use explicit past ordering and assert the timestamps are untouched.

swift build and swift test pass (143 tests, 4 skipped, 0 failures) and SwiftFormat needs no
changes, run against a throwaway TART_HOME with /usr/sbin on PATH as CI does. The 4 skips
are the existing Docker-dependent tests skipping themselves.

I couldn't run the Go integration suite locally since it boots real VMs, so that's still
outstanding.

Closes #1295

tart prune deletes immediately, with no way to preview what it will remove.
Pruning has previously selected more data than intended (openai#271, openai#549).

Introduce a PruningOperation that both the real and preview paths run through,
so selection logic is shared rather than duplicated. In preview mode it records
each selection instead of deleting, and excludes already-selected entries when
pruneSpaceBudget rebuilds its candidate list -- without that, the loop's only
source of progress is the delete it no longer performs, and it never terminates.

Preview avoids mutating state: storages initialise read-only so they do not
create the TART_HOME tree, --gc is skipped, and Root's pre-command garbage
collection is bypassed for a dry run.

Preview cannot observe stacked-image content being reattributed when an owner
is deleted, so it may select different entries than a real run. The command
prints that caveat rather than implying the estimate is exact.

Closes openai#1295
The first version of --dry-run could list fewer entries than a real prune
deletes. Deleting a stacked cache record hands its shared content to another
record, pushing that record's charged size up; the preview never saw this
because ownership was computed from the records on disk and simulated removals
were filtered out afterwards. Under-reporting is the dangerous direction for a
preview, and reproduces the problem openai#1295 exists to prevent.

Push the simulated removals into the storage instead, via
PrunableStorage.prunables(simulatingRemovalOf:), so ownership and content
reference scans recompute exactly as they would after a real delete. The
protocol default filters by URL, which is correct for IPSW and local VM
storage; VMStorageOCI overrides it. Real runs pass an empty set and behave as
before.

The reclaimed total is now the net drop in usage across the simulation rather
than the sum of successive ownerships, so content that only changes owner is
not counted twice and content swept by a deletion's garbage collection is.

Drop the blanket stacked-image caveat, which no longer applies, and move the
remaining notices after the results so an empty preview does not warn about
nothing. Those notices now name the two things still outside a dry run:
explicit --gc, and Root's temporary-directory cleanup.
Root's temporary-directory cleanup is skipped for every dry run, not just
budget ones, so an age-only preview can also omit content that a real prune
releases and then collects. The notice was gated on --space-budget.

Also stop accumulating estimatedReclaimedBytes in remove(); updateEstimate()
overwrites it from the before/after totals and is the only correct source,
since summing per-entry sizes double-counts content that changes owner.
@akim136
akim136 marked this pull request as ready for review September 16, 2026 21:54
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-16T22:44:29.060998Z e2d446c New commits
🔒 Security Review ✅ Completed 2026-09-16T22:45:25.074264Z e2d446c New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6349d30c81

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date,
operation: PruningOperation = PruningOperation()) throws {
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables(simulatingRemovalOf: operation.removedURLs) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve access dates during dry-run scans

On filesystems that update directory atimes during enumeration, this dry-run path reaches VMStorageOCI.list() and recursively traverses cached-image directories, advancing the same access dates used for age and LRU selection. The new test at Tests/TartTests/VMStorageOCITests.swift:623-626 explicitly assigns future atimes to work around this behavior rather than verifying they remain unchanged. Consequently, running a preview before the real prune can make previously old entries appear recently accessed and cause the real --older-than or --space-budget run to select a different set, despite the dry-run promise that no changes are made.

Useful? React with 👍 / 👎.

VMStorageOCI.list() recursively enumerated each cached-image record, and on
filesystems that update directory atimes during enumeration that advances the
very timestamp age and LRU selection read back. A dry run could therefore make
old entries look recently accessed and change what the real prune went on to
remove -- while promising it had changed nothing.

Cache records are leaves in the namespace, so skip their descendants once one is
recognised. Recognition, manifest reads and sizing already address record
contents by known path rather than by enumeration, so nothing else needs the
traversal, and the real scan stops paying for it too.

Record selection timestamps are now preserved exactly, to the nanosecond, with
no restoration writes. Enumerated ancestor directories and manifest files still
acquire atimes; those are not selection inputs.

The preview fixtures previously set future access times so enumeration could not
disturb their ordering, which encoded the defect as a workaround. They now use
explicit past ordering and assert the timestamps are untouched. Those assertions
fail against the previous commit.
@akim136

akim136 commented Sep 16, 2026

Copy link
Copy Markdown
Author

Good catch — this was real, and the future-atime fixtures were me papering over it rather than noticing it. Fixed in e2d446c.

I confirmed the mechanism before changing anything: on APFS, enumerating a directory advances that directory's atime (reading a file inside advances the file's, not the directory's), and CachedImagePrunable.accessDate() → VMDirectory.accessDate() reads contentAccessDateKey on exactly that record directory. So list() recursing into records was re-dating the selection inputs, and a preview could change what the real prune went on to remove.

Cache records are leaves in the namespace, and recognition, manifest reads and sizing already address their contents by known path rather than by enumeration, so list() now calls skipDescendants() once it recognises one. Record selection timestamps are preserved exactly, to the nanosecond, with no restoration writes — and the real scan stops paying for the traversal too.

To be precise about the limit of the claim: enumerated ancestor directories and manifest files still acquire atimes. Those are not selection inputs, and avoiding them would need read-without-atime support or a separate metadata index.

The fixtures now use explicit past ordering and assert the timestamps are untouched. Those assertions fail against 6349d30 with the expected signature — a record's atime coming back as "now" instead of its fixed past value.

Separately, while looking at this I think URL.updateAccessDate() has a pre-existing bug: it reads .contentAccessDateKey but passes that value to utimes as the modification time, so each call overwrites mtime with the old atime. It has four callers (VMStorageLocal.swift:23, VMStorageOCI.swift:138, VM.swift:99, ContentStore.swift:105). I've left it alone since it predates this PR and nothing here depends on it — happy to fix it here or file it separately, whichever you prefer.

This branch has not been deployed

No deployments
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.

Add --dry-run to tart prune

2 participants