Repository navigation
performance: cleanup opens every table version before deleting any, so a run cut short reclaims nothing #1095
Description
Problem
In our measurements on S3, cleanup time tracks the number of table versions present, not the number it removes, and a run that stops before planning finishes deletes nothing. On our larger graphs the plan does not fit the time available, so those graphs are never cleaned and their backlog grows.
We run 0.13.0 in cluster mode on an S3 root (us-east-1): 18 graphs, one server. Direct maintenance cannot start while the server holds the cluster lock (#1082), so it runs in a nightly 25-minute stop window. It scales the server to 0, runs optimize and omnigraph cleanup --cluster s3://<root> --graph <id> --older-than 30d --confirm --yes per graph with a 600 s timeout, and scales back up.
From the 2026-10-18 window (manifest counts from an S3 listing of _versions/ on 2026-10-19):
- QA code-bridge (3 datasets, 3,522 table manifests): 488 s, 12 versions removed.
- Production council (28 datasets, 4,684 manifests), code-bridge (6,869) and a code graph (4,333): each passed 600 s and was killed.
- Production lehrer (75 manifests): 16 s, 0 removed.
That is about 0.14 s per present table version. Every version on these roots was written after 2026-10-14, so a 30-day policy had almost nothing to remove. The time is planning, not deletion.
The path at v0.13.0:
cleanup_detached_onlyruns the wholeplan_collection(optimize.rs#L891) before the firstsweep_table(#L927).plan_collectiontraces table locations one at a time (collector.rs#L986). The sweep runs up toOMNIGRAPH_MAINTENANCE_CONCURRENCYtables at once (optimize.rs#L952).- Per location,
discover_publishedopens every published version in each chain (collector.rs#L1293). Each root is opened again for its paths (#L1125), andplan_sweepopens each sweep candidate again (#L1637). So every published version is opened twice, whatever the policy retains. CollectorCost.table_openscounts this (collector.rs#L75), but the--jsonrows leave it out (main.rs#L1927), and cleanup without--confirmdoes not plan (maintenance.md#L177).
Proposed change
- Can a run that is cut short keep its progress? The capture is validated after all table inventories (collector.rs#L1019), so sweeping each table as its trace finishes may not fit that model. If not, a per-run bound (e.g.
--max-tables, or table selection like Feature request: table scoping foroptimize(--table / --exclude-table) #432 asks for optimize) would let repeated short runs converge. - Could locations be traced concurrently, under the sweep's concurrency limit?
trace_tablesharesviewsandtable_tagsacross locations, so I expect this needs some restructuring. - Could
--jsonreport the run'sCollectorCost, and the CLI expose a plan-only run throughOmnigraph::cleanup_plan(omnigraph.rs#L2785), which takes no writer gate? Operators could then size a window.
What we want most is to run cleanup beside the server, since the stop window is downtime. #646 and #1082 track that, so this issue does not ask for it. Writes and merges may already overlap cleanup in the engine (maintenance.md#L240); the exclusion comes from cluster admission.
Activity
feat(omnigraph): run the window's cleanup on a list of weekdays (#519)
fix(omnigraph): clean up every night at 2d so the window can finish (#…
ragnorc commentedon Oct 20, 2026
Summary
cleanupdecides every delete before it deletes anything. On currentmainthat decision opens every published table version about twice, including versions the policy keeps. A run that stops during the decision deletes nothing. A graph whose plan does not fit the maintenance window is never cleaned, and the backlog grows.The safety rule is right: one capture, then deletes. The cost is wrong. The cost follows versions that exist, not versions this run will remove.
Reproduction
Checked out
7fd9cc9037c634e2f3284f1ede55a85a6f7b28e8(7fd9cc90,fix(storage): keep a bound handle's re-reads on its own session). Issue #1095 was open, with no comments and no pull request that implements a fix. The same was true immediately before this comment.Environment: Linux,
rustc 1.97.1, debug (unoptimized) test profile, one localfile://graph, onePersontable. This is not S3, not a cluster, and not 0.13.0. The harness built the graph withOmnigraph::initand N inserts, then calledOmnigraph::cleanup_planandOmnigraph::cleanup. The harness was local only. It is not in the tree.older-than 30dkeeps every new graph commit.keep 1keeps the head.table_opensmanifest_snapshotscleanupwith the 30-day policy removed 1 version at both sizes (33 ms and 125 ms). That matcheswould_remove. One published detached version is not a root. I did not name that version's write.A second test armed
cleanup.collector_post_snapshotsoplan_collectionreturns an error before the table trace:The failing check on current
main: a 30-day plan may open only the versions it will remove, plus a small fixed budget. The run opened 67 versions to remove 1. The budget was 10.Claim by claim
Time tracks versions present, not versions removed. Reproduced for
table_opensand for the 30-day series. Present versions went from 9 to 33 (3.7 times).table_openswent from 19 to 67 (3.5 times). Plan time went from 31 ms to 127 ms (4.1 times). Removals stayed 1. The same 33 versions produce 67 opens under both policies, while removals are 1 or 32. Local cold time does not followtable_opensalone. The 30-day plan also opens every retained__manifestversion (manifest_snapshots33 versus 1) and took 127 ms. The freshkeep 1plan took 28 ms with the same 67 table opens. The 0.14 s per table version on S3 was not reproduced. Local opens are about 0.4 ms to 3 ms.cleanup_detached_onlyfinishesplan_collectionbefore the firstsweep_table. Reproduced.optimize.rsline 801 plans.line 837sweeps. The v0.13.0 shape is the same (optimize.rsaround the lines the report cites).Locations are traced one at a time. The sweep runs up to
OMNIGRAPH_MAINTENANCE_CONCURRENCY. Reproduced. The trace is a sequential loop atcollector.rsline 1201. The sweep usesbuffer_unorderedatoptimize.rsline 863. The default concurrency is 8 (optimize.rsline 39).discover_publishedopens every published version. Each root is opened again.plan_sweepopens each sweep candidate again. Reproduced. Chain walk:collector.rsline 1507. Root paths:line 1339. Sweep candidates:line 1852. For 33 published versions the count is2 * 33 + 1linear root = 67. A kept version is opened in the chain and again as a root. A removed version is opened in the chain and again inplan_sweep.sweep_tablethen opens one root per location (line 300). That extra open is per table, not per version. The v0.13.0discover_publishedhas this same chain open.CollectorCost.table_openscounts this.--jsonomits it. A run without--confirmdoes not plan. Reproduced. The counter isCollectorCost.table_opens. JSON rows are built inoutput.rsline 196 and have no cost field. Without--confirm,maintenance/mod.rsline 146 writes the policy and returns before open.Omnigraph::cleanup_planalready plans with no writer gate (omnigraph.rsline 3586). The CLI does not call it. The CLI text has moved since v0.13.0 (main.rsto these files). The behavior matches the report andmaintenance.md.A run cut short during planning deletes nothing. Reproduced for a failure after the graph capture and before the table trace. I did not kill a process inside the per-version loop. That loop has no seam.
sweep_tableruns only afterplan_collectionreturns success, so a kill inside the loop has the same result: no sweep has started.What differed
S3 latency, the 18-graph cluster, the server stop window, and the 600 s kill were not run. Debug
file://time is not 0.14 s per version. Since v0.13.0 the same double open remains. Extra work before the table loop now includes unpublished commits and unattached branch tags.manifest_snapshotsis a second history term the report did not separate. On this one-table graph it was the larger share of local 30-day plan time.The existing ceiling
collector_cost_is_bounded_by_retained_versions_and_garbage_examinedallowspublished + roots + sweep. That is about two opens per present version. The test passes while this bug is present.Root cause
A retained root is already known from the
__manifestversions the policy keeps (collector.rsline 1041). The_versions/listing already names every present manifest (line 1459).listingsstayed 4 at both sizes.discover_publishedstill opens each chain member to read a predecessor that is already on the manifest.StagedTransactionIdentity::recorded_byreads{read_version}-{uuid}.txnfrom the manifest alone (staging.rsline 40,table_store.rsline 4036). The handle is then dropped. The root loop andplan_sweepopen the same version again for fragment, deletion, transaction, and index paths.open_atis a fullDatasetopen (promotion.rsline 38). Lance resolves a detached version by path with no latest-version lookup. The open still builds a dataset. The shared session can make the second open cheap. The counter still records both. One uncached GET per present version is enough to explain about 0.14 s on S3 (3522 * 0.14 s ≈ 493 s, next to the reported 488 s).The capture check runs after every table inventory (
collector.rsline 1234). Borrowed-file protection also waits for every table (protect_borrowed_files). Both are why a partial trace does not delete. They are also why a timeout deletes nothing.Recommended design
Keep one capture, and check it immediately before deletes. Change what that capture has to read.
The delete set is the listing minus the known roots, after staging judgment. Roots come from retained
__manifestpins. Open a table manifest only when that version can change the delete set: a non-root candidate, or a root whose file list is required because this run will delete files. When every listed version is a root and no orphan is old enough, do not open table versions. That is the nightly case where the policy keeps almost everything.Parse each manifest once. Keep paths and the predecessor from that parse. Do not
Dataset::openthe same version again. Prefer the manifest bytes the listing already named (read_manifestin Lance), not a dataset session. The second open is pure waste even when the session cache hides its S3 cost.A short run should delete unpinned manifests after the capture check, and leave their files for a later mark. Deleting a manifest that no retained pin names does not require every root's file list. Deleting its files does, because a kept version may share them. File bytes then wait for a run that has read the mark set, or for the 7-day orphan rule. Version count drops, so the next plan is cheaper and can finish the mark. Do this only for versions the capture has classed as unpinned. A live or undecidable staging stays. Re-check branch head and tag inventory immediately before those deletes.
Do not sweep a table as soon as its trace finishes. A publication during the remaining tables can pin a version this table already dropped.
protect_borrowed_filesis also global. Per-table sweep during the trace fights both. A bound fits the capture model:--max-tablesor an explicit table list, the same shape Feature request: table scoping foroptimize(--table / --exclude-table) #432 asks foroptimize. One capture covers the subset. Delete a file only when borrowed-origin closure says it is local to finished tables. A killed run still deletes nothing. A finished subset shrinks the backlog.Trace independent locations concurrently, up to the sweep limit. Capture branch views and tag inventories first.
judge_stagingwalks a view's lineage and mutates that cursor, so that walk stays before the parallel section, or each view has its own lock.table_tagsis already keyed by dataset path.Put
CollectorCostoncleanup --json, and add a plan-only CLI path throughOmnigraph::cleanup_plan. The engine method exists and takes no writer gate. Say in the output that the plan deleted nothing and that a later publication changes the next plan. Without--confirm, today's command still returns before open. A plan flag is a different command from that preview.Caching the second open, or parallel traces alone, cuts a constant factor. The plan still grows with retained versions. That does not fix a 600 s window on thousands of kept versions.
Tradeoff. One run no longer promises to reclaim every unpinned file in the whole graph. It promises a checked subset, and manifest removal for versions that are not roots. File bytes can outlive those manifests until a mark completes. That is the orphan rule's job. It is safer than today's file-then-manifest order, which leaves a readable manifest whose files are already gone (#1094). Robustness of the capture and the orphan rule stays ahead of "one run cleans every table inside the window."
Liability removed. Full-history double opens. A timeout that never reclaims. Operators with no cost in the report.
Liability added if the subset is wrong. A file delete before borrowed-origin closure can remove a file a kept snapshot still uses. Fail closed: no file delete until that closure is known. A plan-only command must not be read as a delete.
The detached-only RFC acceptance text bounds discovery by present detached manifests. That text matches today's ceiling. A change that stops opening kept versions when they cannot affect the delete set should update
collector_cost_is_bounded_by_retained_versions_and_garbage_examinedand that threshold text in the same change. Safety (every kept pin still opens, every path it names still exists) stays.Lance
Lance does not fix this. OmniGraph pins unmodified Lance 13.0.0. No open OmniGraph pull request bumps Lance.
Stock
cleanup_old_versionslists attached manifests throughlist_manifest_locations.parse_versiondoes not acceptd*.manifest. Detached manifests have a separate list,list_detached_manifests.process_manifestsuses only the attached list. Lancemain(checked 2026-10-20) still does that. lance#8944 (merged, in this pin) makes old data files eligible once they passmax(retained manifest floor, now - 7 days), including files that only a detached version references. That is why graph tables must not use stock cleanup.Open Lance work does not take this cost over:
Dataset::referenced_files, open) refuses a dataset that has detached versions, because their files would look like orphans. It also still reads every present manifest.retain_n_versionson the attached chain. It does not apply to detached pins.The useful Lance pattern is the one stock cleanup already uses for attached manifests: read each manifest once, concurrently, then delete. OmniGraph needs its own notion of which versions the graph keeps. Lance does not have that.
Related work
This issue stays open. Nothing else has the same scope.
--table/--exclude-tableonoptimize. The same selection is the right bound forcleanup, with the borrowed-file rule above.__manifestversion. It is not a fix for this issue, and it is still draft. Until then, the retained-commit scan (manifest_snapshots) remains.Test plan for a fix
collector_cost_is_bounded_by_retained_versions_and_garbage_examinedso a 30-day plan'stable_opensstays bounded by versions that can be removed, not by versions kept. Cover two sizes. Recordmanifest_snapshotsbesidetable_opens.keep 1andolder-than: opens for kept versions are not repeated.keep 1may still open versions it will remove.cleanup.collector_post_snapshot: manifest set unchanged. A later run deletes. Add a subset run that deletes only its tables.maintenance.rs.--jsonincludesCollectorCost. Plan-only deletes nothing and does not take the writer gate. Owner: CLI maintenance tests.cleanupchanges, updatecrates/omnigraph-dst/cost_table.txtand explain every changed line.cleanup_old_versions.Limitations
No S3 timing, no cluster lock, no 18-graph window. The interrupt is a failpoint before the table loop, not a kill on the Nth manifest GET. The single unpinned version in the 30-day plan was counted, not attributed to a specific write. Local
manifest_snapshotsdominated 30-day wall time. On S3, table-manifest GETs may dominate instead. Both terms scale with retained history.fix(omnigraph): only optimize in Production's nightly window (#6520)