Repository navigation
Cleanup leaves a table version's manifest readable after deleting its files #1094
Description
Problem
cleanup sweeps a table version in two steps. First it deletes the version's unreferenced files (data files, deletion files, index directories, transaction file), then the version's manifest. The detached-only tables RFC chose this order so that a pass that stops early "strands no file": the manifest stays behind as the list of what the next pass must still delete.
The cost is on the read side. Between the two steps, and after a pass that stops there until the next run finishes the sweep, a manifest is present but some objects it names are gone. Opening the version succeeds, so the open no longer proves that the version is readable. Today this shows up in two places:
- Branch start at a commit (feat(branch): start a branch at a graph commit #1016). Opening each pinned version is not enough proof of retention, so the start probes every object of every pin the source head does not hold: one request per object, under the exclusive schema permit that every write in the process waits on. feat(branch): start a branch at a graph commit #1016 bounds the latency (concurrent probes, stopping at the first missing object) and pins the count with a cost gate. The count still grows with the fragments of the probed pins.
- Reads at a historical commit. A read at a commit whose pinned version is half-swept fails with a raw storage
NotFoundfrom Lance when it reaches a deleted data file. A version whose manifest is gone is refused as typedHistoricalVersionReclaimedinstead. So one fact, "cleanup reclaimed this version", has two answers depending on where a pass stopped.
The property we want
A table manifest that is present names only objects that are present. With it, opening a pin proves the pin is retained. The branch-start probe becomes one open per pin and can be deleted, and a read at a reclaimed version always gets the typed refusal.
Options
- Delete the manifest first, then its files. Readers never see a half-swept version. The files of a pass that stops in between are no longer named by any manifest, so only the orphan rule reclaims them, after its seven-day age. Tradeoff: space after a crash is reclaimed up to a week later instead of on the next run, and the orphan rule becomes part of normal convergence, not only of foreign or abandoned objects.
- Move the manifest out of
_versions/before deleting its files. Copy the manifest to a collector-owned path outside the version listing, delete the original, delete the files, then delete the copy. Readers stop seeing the version at the first delete. A pass that stops early leaves the copy as the list of files still to delete, so nothing is stranded and the next run finishes promptly. Tradeoff: one more write and delete per swept version, and the collector must find and finish leftover copies at the start of each run. Object stores have no atomic rename, so it is copy then delete, and every crash window needs a case in the collector's seam matrix. - Keep the order and keep probing. Accept the per-object probe on the branch-start path, and add a probe or a typed mapping on the historical-read path. This costs no format or protocol work, but the read-side cost and the second answer stay.
Option 2 looks like the best fit: it gives readers the property without delaying reclamation. It does change the collector's on-storage protocol, so it would need an RFC amendment to the detached-only tables RFC, with crash-window evidence (failpoints.rs, detached_commit_matrix.rs, the DST collector oracle).
Pointers
- Sweep order:
collector.rssweep_table/delete_paths; RFC step 4 ("Sweep"). - The probe this would retire:
Omnigraph::prove_commit_startindb/omnigraph.rs, explained indocs/dev/writes.md(the branch-start paragraph). - Raised in the review of feat(branch): start a branch at a graph commit #1016.
Activity
ragnorc commentedon Oct 20, 2026
Summary
cleanupdeletes the files of a swept table version, and then deletes the manifest. A stop between those steps leaves the manifest in_versions/. Lance opens that manifest. Objects it names are already gone. One fact, "cleanup reclaimed this version", then has two engine answers. A missing manifest isHistoricalVersionReclaimed. A present manifest with a missing data file is a raw storageNotFound.The property we want: a manifest in
_versions/names only objects that are present. I reproduced the hole onmainatd4aa7fe9f2c55719096441a8c3aa02219ebecef4. No open pull request implements a fix. This issue stays open. No other issue supersedes it.Reproduction
Reproduced, on all three claims. Environment: Linux 6.12.94+, rustc 1.97.1, Lance 13.0.0, a local
file://temp directory.Command (a local test, not committed):
Fixture: init a Person graph, overwrite-load Alice and Bob,
ensure_indices, overwrite-load Zoe, thencleanupwithkeep_versions: Some(1). The seamcleanup.sweep_pre_manifest_deletestops the sweep afterdelete_pathsand before the manifest loop. That seam is the production line insweep_table.One run:
_indices/directories, and four_transactions/*.txnfiles. The load pin was in the sweep.manifests_removed=0,bytes_removed=3269, and errorinjected failpoint triggered: cleanup.sweep_pre_manifest_delete._versions/still heldd16171805821540867662.manifest. The data file was gone.DatasetBuilderopen of that version succeeded.NotFoundfor.../data/110001111001100000100100a8567243ba8f2f1a70e41965ea.lance. The message continues with rustc source frames from the error chain.graph_manifest_version=4).entity_atof Alice at the load commit (graph_manifest_version=2) returnedStorage(NotFound)with that same Lance message.branch_create_fromat that commit returnedBranchStartRefused { NotRetained { table_key: "node:Person", published_dataset_version: 2 } }withCompletionEvidence::BeforeEffect.branch_liststayed["main"].cleanup, with the seam clear, removed 4 manifests. The load manifest was gone. The sameentity_atreturnedHistoricalVersionReclaimed(16171805821540867662): "historical published dataset version 16171805821540867662 was reclaimed".A temporary assertion required the half-swept read to be
HistoricalVersionReclaimed. That assertion failed. The controls above passed. The test is not in the tree.Difference from the report: none on the three claims. Two extra observations:
published_dataset_version(2). The reclaimed-read error names the detached id thatopenrequested. Both mean this pin.commit_changes_pageand the change feed rewrite onlyHistoricalVersionReclaimedintoChangeFeedGap. A half-sweptNotFoundstays a storage error there. I read the match. I did not run the feed.Root cause
sweep_tabledeletessweep_paths, then the published manifests, thendead_paths, then the dead manifests, then orphans: collector.rs.The detached-only tables RFC, step 4, chose this order so a stopped pass leaves the manifest as the list for the next pass: RFC.
open_datasetmapsVersionNotFoundandDatasetNotFoundtoHistoricalVersionReclaimed. A bareNotFoundstays storage. The comment there says a live manifest that names a missing object is corruption, and must stay loud: instrumentation.rs.Branch start probes every object of every pin the source head does not hold. The head's pins are roots. The collector leaves a root's files in place. The probe runs under the exclusive schema permit: omnigraph.rs, writes.md. The cost gate is one data read per added fragment (
branch_start_at_a_commit_probes_once_per_pinned_objectinintegration/branch_control_cost.rs). I did not re-run that gate.The server maps both
HistoricalVersionReclaimedandStorageto HTTP 500, with different messages. Branch start mapsNotRetainedto 410. The change feed maps the typed reclaimed error to 410change_feed_gap.Design
Put the property on the collector. A manifest that readers can open must name only present objects. Hide every swept manifest from
_versions/before any swept file is deleted.Use a batch form of option 2:
_versions/, for example_omnigraph/sweep/<version>.manifest. Lance does not list that prefix. Stock cleanup lists_versions/,_transactions/,data/,_indices/, and_deletions/.orphan_keydoes not claim this path, so an old collector keeps the copy.sweep_paths.Use the same order for dead stagings. They use files-then-manifest today.
Resume from the listing:
A crash in step 1 or 2 leaves every still-visible manifest readable. A crash in step 3 leaves no swept manifest visible, so readers get
HistoricalVersionReclaimed. The next run finishes the files. A crash in step 4 leaves a small copy. The next run deletes it.This is an on-storage protocol. It needs an amendment to the detached-only tables RFC. Each window needs a seam in
failpoints.rs, a row indetached_commit_matrix.rs, and a case in the DST collector oracle.Alternatives
Option 1. Delete the manifest first, and let the orphan rule delete the files. Readers get the typed error at once. A crash leaves files that no manifest names. The orphan rule deletes them after 7 days (
UNVERIFIED_THRESHOLD_DAYS, Lance's unverified window). That rule exists so the collector does not delete a writer's files before the manifest that will name them. Option 1 makes the rule the normal end of a sweep. A version swept with--keep 1can be minutes old. Its files then wait for the age gate. Invariant 5's progress clause says a table version that no retained__manifestnames is gone after a run that retains nothing else. Option 1 meets that clause only after the age gate. #1095's nightly window already kills runs at 600 seconds, so the space would sit until the files age out.Option 3. Keep this order. Probe on the read path, or map
NotFoundto the typed error. This adds no storage protocol. The hole stays. A map from every historicalNotFoundtoHistoricalVersionReclaimedalso hides corruption of a retained version. The collector's root set is the authority for that split. A reader that rebuilds the root set does a second collection. The branch-start probe is already that cost, under the schema permit, and only for pins the head does not hold. The same probe on every historical read is one request per object on the read path.Tradeoff
Option 2 adds one put and one delete per swept manifest, and a larger crash matrix. #1095 shows that cleanup time tracks manifest opens during planning, not deletions. The extra requests are small beside that cost. We spend them so the reader contract and the next-run progress both hold. A wrong historical answer is a correctness failure. Saving two requests is the wrong place to give up either property. Sweep efficiency is the quality we give up, on purpose.
Liability added: a collector-owned prefix that later cleanup must finish, and that must stay outside Lance's version listing. An old binary does not finish copies. It also keeps them, because the path is an unknown layout. Files that no manifest names still fall under the 7-day orphan rule, including on an old collector. Readers are safe once the visible manifest is gone. Liability removed: the half-swept read, the second answer on the change feed, and the per-object branch-start probe.
Test plan
cleanup_plan_missing_pathsis empty; a historical read of a hidden version isHistoricalVersionReclaimed; a historical read of a still-visible swept version succeeds; the retry removes the files and the copy.historical_read_of_reclaimed_version_is_typed. Add the hidden-manifest window beside it. The error class is the same. The window is a mechanism, so the failpoint case belongs infailpoints.rs.branch_start_refuses_a_commit_an_interrupted_cleanup_emptied. After the hide seam, the manifest is absent from_versions/. The refusal staysNotRetained, fromHistoricalVersionReclaimedon open.branch_start_at_a_commit_probes_once_per_pinned_object. Extra object reads go flat. One open per probed pin remains.Cleanupincrates/omnigraph-dst/cost_table.txtchanges, the pull request explains each changed line.docs/dev/writes.md, amend the RFC, and add a changelog note. The user-visible change is the error class after a stopped cleanup. A historical HTTP read stays 500 unless we later mapHistoricalVersionReclaimedto its own status. That map is a separate decision. Branch start already returns 410.Lance
Lance does not fix this. OmniGraph pins unmodified Lance 13.0.0. No open OmniGraph pull request upgrades Lance.
Lance 13
cleanup_old_versionsdeletes unreferenced files and old manifests in one concurrent stream. The order is not manifest-first. The same stream is on Lancemain(the v14.0.0-beta.21 line). Seedelete_unreferenced_filesat v13.0.0 and the combined stream on main.lance#8944 (merged) makes old data files eligible while a detached manifest remains. That is this hole in stock cleanup. OmniGraph does not call
cleanup_old_versionson a graph table.lance#8097 (open) adds
Dataset::referenced_files()for an external orphan driver. The set covers every present manifest. It does not change delete order. A driver that deletes files of a manifest that is still present creates the same hole.Recent Lance cleanup commits (#9774, #9747, #9709) change concurrency, temporary files, and missing-object handling. They do not give this property.
Lance's 7-day unverified threshold is the age gate option 1 would use. Lance's branch guide says cleanup keeps files a branch still references. It does not promise that a present manifest's objects exist during cleanup. The collector is the right place for the property. Stock cleanup does not trace detached manifests.
Related work
sweep_table, after capture validation. It stays valid if performance: cleanup opens every table version before deleting any, so a run cut short reclaims nothing #1095 later sweeps a table when its trace finishes.delete_objectalready treatsNotFoundas success. Delete the visible manifest only after the copy put has succeeded. Delete files only after a read shows the visible manifest is absent. This issue can land before that RFC. The crash matrix should treat an ambiguous copy put as "the copy may exist".__historyextent. One direction is to fold small branch blocks. Those objects are not table data files. Folding them leaves this hole in place.__manifestversions. That is a different dataset.No open pull request changes this sweep order. No roadmap item replaces this issue. The owning decision is the detached-only tables RFC. I am not closing this issue.
Limitations
file://and the failpoint. It did not kill a process, and it did not use S3 or Azure.sweep_tableis the same function on those stores.failpoints.rsbinary or the branch-start cost gate. This run called the publicbranch_create_frompath and got the same refusal.ChangeFeedGapnote is the match inchanges/feed.rsandcommit_changes_page.