fix(cli): execute confirmed orphan cleanup plans#1452
Conversation
zeroshade
left a comment
There was a problem hiding this comment.
Reusing a scanned plan for the confirmed delete is a nice improvement. Two small non-blocking notes on the CLI output (inline).
| OrphanFileLocations: plan.Files(), | ||
| TotalSizeBytes: plan.TotalSizeBytes(), | ||
| } | ||
| cliResult := buildCleanOrphanFilesResult(tbl, result, true) |
There was a problem hiding this comment.
Non-blocking: this hardcodes dryRun=true for the pre-confirmation result, so a real (non-dry-run) run that finds zero orphans still reports "dry_run": true in JSON. Consider using cmd.DryRun for the final/no-op result.
| return | ||
| } | ||
|
|
||
| output.CleanOrphanFilesResult(cliResult) |
There was a problem hiding this comment.
Non-blocking: with --output json, emitting this preview and then the post-deletion result later produces two top-level JSON objects on stdout for a single invocation (and an aborted confirmation leaves a success-looking dry-run object behind). Consider keeping the pre-confirmation preview out of the structured output, or emitting a single JSON result at the end.
laskoviymishka
left a comment
There was a problem hiding this comment.
The plan/execute split is the right shape here. Planning once and then deleting exactly that set closes the window between the confirm prompt and the delete, so files that show up after the user confirms can't get swept in — and capturing the cutoff at plan time is the correct instinct.
I'd hold this before merging though.
The blocker is the hand-built OrphanCleanupResult in the CLI: it never populates OrphanFiles, so buildCleanOrphanFilesResult intersects a nil slice and comes up empty. The result is that the text preview prints "No orphan files found." right before we prompt to delete real files, and the final summary reports 0 files deleted even on a successful cleanup (the deleteResult from ExecuteOrphanCleanup gets discarded). That defeats the "show the exact plan before confirmation" goal the PR is built around. The clean fix is a public OrphanFiles() accessor on the plan so the CLI stops reconstructing a lossy result by hand — details inline.
The other thing I'd want addressed is ExecuteOrphanCleanup accepting the full option set but silently honoring only deleteFunc/maxConcurrency/dryRun. Passing WithFilesOlderThan there does nothing, with no error — a real footgun when the plan is supposed to be immutable.
A few things I'd want before merge:
- populate
OrphanFilesand rebuild the CLI result from the delete outcome - narrow (or reject) the options
ExecuteOrphanCleanupcan't honor - a test for the text preview content, and a real-mtime case so the age filter is actually exercised
- drop or document
Cutoff(), which nothing enforces at execution
Once those are in, happy to take another pass and approve. I've stayed off the CLI output details since zeroshade's already looking there.
| os.Exit(1) | ||
| } | ||
|
|
||
| result := table.OrphanCleanupResult{ |
There was a problem hiding this comment.
This hand-built result never populates OrphanFiles, and that quietly breaks both the preview and the final output.
buildCleanOrphanFilesResult with dryRun=false intersects result.OrphanFiles against DeletedFiles — but OrphanFiles is nil here, so the intersection is empty. The text preview prints "No orphan files found." right before we prompt to delete real files, and the final output reuses this same cliResult (deleteResult from ExecuteOrphanCleanup is discarded), so the tool reports OrphanFileCount: 0 even after it deletes the full set.
The plan already carries the per-file entries privately. I'd add a public accessor — func (p OrphanCleanupPlan) OrphanFiles() []OrphanFile { return slices.Clone(p.orphanFiles) } — populate result.OrphanFiles from it for the preview, and rebuild cliResult from deleteResult after ExecuteOrphanCleanup before the final output. wdyt?
| } | ||
|
|
||
| if len(result.OrphanFileLocations) == 0 { | ||
| if len(plan.Files()) == 0 { |
There was a problem hiding this comment.
plan.Files() clones the slice on every call, and we hit it three times in this function (this guard, the prompt, the result build). Minor, but I'd pull it into a local once — or add a Len() int on the plan for the count-only checks.
|
|
||
| func shouldPrintCleanOrphanPreview(output Output) bool { | ||
| switch output.(type) { | ||
| case jsonOutput, *jsonOutput: |
There was a problem hiding this comment.
jsonOutput is only ever constructed as a value, so the *jsonOutput arm is dead outside the test — I'd drop it. Separately, isMachineReadable reads a touch clearer than shouldPrintCleanOrphanPreview, since that's really the property we're keying on.
| assert.Equal(t, int64(2048), result.OrphanFiles[0].SizeBytes) | ||
| } | ||
|
|
||
| func TestShouldPrintCleanOrphanPreview(t *testing.T) { |
There was a problem hiding this comment.
This covers the predicate, but the behavior the PR is actually selling — the text preview listing the exact files before we prompt — has no test. An end-to-end runCleanOrphanFiles case that captures text output and asserts the preview contains the orphan paths would have caught the empty-preview bug I flagged in clean_orphan_files.go; right now nothing does. Could we add one?
| } | ||
|
|
||
| // Cutoff returns the age cutoff used to create the cleanup plan. | ||
| func (p OrphanCleanupPlan) Cutoff() time.Time { |
There was a problem hiding this comment.
Cutoff() is only read by the test — no execution path uses it, and ExecuteOrphanCleanup doesn't re-check age against it. As a public accessor it reads like an execute-time guard that doesn't exist. I'd either drop it, or godoc it as informational: the plan-time filter, not enforced at execution.
|
|
||
| // ExecuteOrphanCleanup deletes exactly the files in plan. It does not perform | ||
| // another orphan scan, so files appearing after planning are not included. | ||
| func (t Table) ExecuteOrphanCleanup(ctx context.Context, plan OrphanCleanupPlan, opts ...OrphanCleanupOption) (OrphanCleanupResult, error) { |
There was a problem hiding this comment.
executeOrphanCleanup only reads deleteFunc and maxConcurrency off cfg, but ExecuteOrphanCleanup accepts the full OrphanCleanupOption set — so WithFilesOlderThan, WithLocation, WithPrefixMismatchMode etc. are silently dropped here. Someone calling ExecuteOrphanCleanup(ctx, plan, WithFilesOlderThan(24*time.Hour)) gets no error and no effect, which is a nasty footgun given the plan's whole point is that its set is already fixed.
I'd narrow this to a smaller option type (just WithDeleteFunc/WithMaxConcurrency/WithDryRun), so the scan-time options aren't even expressible on the execute path. wdyt?
| nil, | ||
| ) | ||
|
|
||
| plan, err := tbl.PlanOrphanFiles(ctx, WithFilesOlderThan(time.Hour)) |
There was a problem hiding this comment.
The don't-expand-after-planning behavior this asserts is good and worth having. But WithFilesOlderThan(time.Hour) isn't exercising the age filter — MemFS reports a zero ModTime, so every file is "older than" any cutoff and the option is a no-op here. If we want real coverage of the age boundary, a separate case with actual mtimes that asserts a recent file gets excluded would do it.
I'd also assert result.OrphanFiles here, not just DeletedFiles — the path/size pairs are exactly what broke in the CLI.
zeroshade
left a comment
There was a problem hiding this comment.
(Medium) The pre-confirmation preview for a non-dry-run cleanup renders zero files.
In runCleanOrphanFiles (cmd/iceberg/clean_orphan_files.go:61), cliResult is built with dryRun = cmd.DryRun (i.e. false for a real run) from the plan result. The "exact plan before confirmation" preview (clean_orphan_files.go:77-79) then reuses that result, but buildCleanOrphanFilesResult with dryRun=false filters entries by result.DeletedFiles (:118-127) — which is empty before deletion has happened. So the preview always shows 0 orphan files even though planFiles is non-empty (guaranteed by the early return at :69).
Fix: build the pre-confirmation preview with dryRun=true semantics (from result.OrphanFiles), and keep the dryRun=false/DeletedFiles build only for the post-deletion output at :94.
e905660 to
3bba143
Compare
zeroshade
left a comment
There was a problem hiding this comment.
Re-reviewed — my earlier concern is resolved. The pre-confirmation preview now builds with dry-run semantics (the plan's orphan set) instead of the empty post-deletion set, so it shows the real count, and JSON output emits a single document. Thanks @fallintoplace!
|
@laskoviymishka mind giving this another look when you have time? The plan/execute split is in place, and the pre-confirmation preview now uses dry-run semantics (it shows the real planned orphan set, and JSON output emits a single document), so the concern I'd raised is resolved. I've approved on my side — a review update from you would unblock the merge. Thanks! |
3bba143 to
c68c90e
Compare
Summary
Split orphan cleanup into an explicit plan and execution phase so confirmation applies to the exact files shown to the user.
The existing
DeleteOrphanFilespath uses the same planning and execution flow.Testing
go test ./table ./cmd/iceberg