Skip to content

feat: add Dataset::referenced_files for external orphan cleanup - #8097

Draft
LuciferYang wants to merge 7 commits into
lance-format:mainfrom
LuciferYang:feat/dataset-referenced-files
Draft

feat: add Dataset::referenced_files for external orphan cleanup#8097
LuciferYang wants to merge 7 commits into
lance-format:mainfrom
LuciferYang:feat/dataset-referenced-files

Conversation

@LuciferYang

Copy link
Copy Markdown
Contributor

What

Adds an experimental Dataset::referenced_files() that returns the set of storage paths referenced by all of a dataset's currently-present manifests. It is meant for an external, distributed orphan-cleanup driver: the driver lists storage itself and deletes files the set does not cover, which lets orphan cleanup scale out (list + delete in parallel) instead of running entirely inside a single cleanup_old_versions call.

Unlike version cleanup, this unions references across every present version rather than just the latest, so a file referenced only by an older-but-present version is retained. It reads only manifests (concurrently) and never lists the potentially huge data/, _indices/, or _deletions/ trees, so its cost scales with the number of present versions rather than the number of files.

Why

Orphan cleanup on a large table is dominated by listing storage, which a single-process cleanup_old_versions cannot parallelize. Exposing the "keep set" as a read-only, metadata-only call lets an external engine (for example a distributed job) do the listing and deletion while relying on the core format to decide what is still referenced. Re-deriving that keep-set outside the core is a correctness hazard, because the caller has to reproduce format-specific rules (blob v2 sidecars, index layout) exactly or it deletes live data.

Design

The result is an opaque ReferencedFileSet rather than raw path lists, because a naive all_listed_files - referenced anti-join is unsafe: blob v2 sidecars, index files, tags, and staging manifests are not enumerated verbatim. The caller instead asks set.is_referenced(path) per listed file, which encapsulates the three matching rules so they cannot be reimplemented incorrectly:

  • exact match for data files (including data-overlay files), deletion files, transaction files, and manifest files;
  • an _indices/{uuid}/ directory-prefix match for index artifacts, whose individual filenames are not in the manifest;
  • the blob v2 sidecar rule: a file under data/{key}/ is referenced if and only if its parent data/{key}.lance is, since a sidecar's obfuscated filename is not recorded in the manifest and must not require listing the data tree to discover.

Paths are normalized through object_store::path::Path on both the stored and the queried side, so a caller that lists with a leading or trailing slash still matches; a mismatch here would be a false negative and delete a live file.

The call errors (rather than returning an incomplete set a caller could act on) for datasets it cannot fully represent: branches, detached versions, external multi-base fragments, and external row-id files. It also errors if it observes zero manifests, because an empty keep-set would authorize deleting the whole dataset.

How the caller uses it safely

Only files in the managed subtrees (data/, _deletions/, _transactions/, _indices/, and _versions/*.manifest) are candidates. Files under _refs/ (tags/branches), staging manifests (_versions/.tmp*), and the version-hint file must never be treated as orphans, and the set intentionally does not enumerate them. Deletion is additionally gated on a caller-enforced age threshold, because the set is a point-in-time snapshot and a file written just before its commit lands is referenced by no present manifest yet.

Testing

cargo test -p lance --lib dataset::cleanup::tests::referenced_file (12 tests), plus the doctest, cargo clippy -p lance --all-targets -- -D warnings, and RUSTDOCFLAGS="-D warnings" cargo doc -p lance --no-deps, all green. Coverage includes: a file referenced only by an older-but-present version is retained; manifests, deletion, and transaction files are reported; index prefixes cover every on-disk index file; a blob v2 sidecar's parent is reported and the sidecar itself is matched via is_referenced; overlay data files are kept (a tripwire, since they live only in fragment.overlays); multi-fragment union; deterministic sorted output with a serialize/reconstruct round-trip; and rejection of branches, detached versions, external bases, and external row-id files (asserting both the error variant and message).

Follow-ups / known limitations

This is marked experimental and its shape may change. A few items are intentionally out of scope and worth tracking:

  • The reference-collection logic now exists in three places (referenced_files, the in-crate process_manifest used by cleanup_old_versions, and collect_paths used by deep clone). They currently agree except that only referenced_files collects data-overlay files. Overlay files are an unstable, opt-in feature with no production write path today, so this is latent rather than a live bug, but before overlays are enabled by default the built-in GC and deep-clone paths should collect them too. Extracting a shared fragment-to-data-files iterator would keep a future artifact kind from silently diverging again. Best done as a separate focused change.
  • External version-metadata files are refused implicitly only because no production path creates them; if that changes, an explicit guard should be added alongside the external row-id guard.
  • Path normalization assumes the machine-generated, encoding-free filenames the format emits today. If user-controlled name fragments ever appear in a managed subtree, the normalization should be revisited to avoid double-encoding.
  • Whether this should be feature-gated or #[doc(hidden)] rather than a plain public method, given it currently refuses branches and multi-base datasets (both growing features), is a judgment call left to maintainers.
  • The returned set holds one entry per live data file, deletion file, and index, plus two per present version (manifest and transaction). Blob v2 sidecars are folded into their parent (one entry, not one per sidecar) and each index is a single prefix, so the two categories that grow fastest do not inflate it. Size therefore scales with live data files and present versions, not total file count, and stays bounded when versions are cleaned up on schedule. On a table with many uncleaned versions and heavy fragment churn it can still reach hundreds of MB at tens of millions of live files, at which point building and broadcasting the whole set in one process is the limit rather than the format. Sharding the set by version or path prefix, or returning a compact representation, is a possible direction if that ceiling is reached.

@github-actions github-actions Bot added the enhancement New feature or request label Jul 30, 2026
@LuciferYang
LuciferYang marked this pull request as draft July 30, 2026 11:51
Adds an experimental `Dataset::referenced_files()` that returns the set of
storage paths referenced by all currently-present manifests, so an external
distributed orphan-cleanup driver can list storage itself and delete files the
set does not cover. Unlike version cleanup it unions references across every
present version (not just the latest), so a file referenced only by an
older-but-present version is retained.

The result is an opaque `ReferencedFileSet` exposing `is_referenced(path)`,
which encapsulates exact matching, the `_indices/{uuid}/` prefix rule, and the
blob v2 sidecar rule (a sidecar is referenced iff its parent data file is), so
callers cannot reintroduce a data-loss bug via a naive anti-join. Reads
manifests concurrently and errors on datasets with branches, detached versions,
external bases, or external row-id files, whose files it cannot fully represent.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Serialized worker reconstruction can classify live percent-encoded objects as unreferenced. ReferencedFileSet::new applies Path::from to strings already in object-store canonical form; exact_paths() then emits that encoding and worker reconstruction encodes % once more. This violates the documented distribute/reconstruct flow, so an age-eligible live data object can be deleted. Please preserve an idempotent canonical representation across serialization.

Reproducer

Add this to referenced_file_set_matcher_rules:

let producer = ReferencedFileSet::new(
    vec!["data/live%25name.lance".to_string()],
    vec![],
);
assert!(producer.is_referenced("data/live%25name.lance"));

let worker = ReferencedFileSet::new(
    producer.exact_paths(),
    producer.index_prefixes().to_vec(),
);
assert!(worker.is_referenced("data/live%25name.lance"));

Invocation:

cargo test -p lance referenced_file_set_matcher_rules --lib -- --nocapture

The producer assertion passed, but the worker assertion failed. The producer stored data/live%2525name.lance; reconstruction changed it to data/live%252525name.lance.

External row-version metadata can also be committed under managed data/, but referenced_files() returns Ok without including or rejecting it. The collector handles RowIdMeta::External but not created_at_version_meta or last_updated_at_version_meta, so a cleanup driver can delete those live files. Please include both paths or fail closed for either external variant.

Reproducer

Using the same overwrite setup as the external-row-id test, set both fields and write the corresponding objects:

fragments[0].created_at_version_meta = Some(RowDatasetVersionMeta::External(
    ExternalFile {
        path: "data/created-at.versions".to_string(),
        offset: 0,
        size: 16,
    },
));
fragments[0].last_updated_at_version_meta = Some(RowDatasetVersionMeta::External(
    ExternalFile {
        path: "data/last-updated-at.versions".to_string(),
        offset: 0,
        size: 16,
    },
));

let refs = db.referenced_files().await?;
assert!(refs.is_referenced("data/created-at.versions"));
assert!(refs.is_referenced("data/last-updated-at.versions"));

Invocation:

cargo test -p lance referenced_files_covers_external_row_version_metadata --lib -- --nocapture

Expected either NotSupported or both assertions to pass. The call returned Ok, and the first assertion failed.

…gnature

Rebased onto upstream/main, where new_unstarted takes a ConcreteFileVersion
instead of (major, minor). Update the overlay test accordingly.
@LuciferYang
LuciferYang force-pushed the feat/dataset-referenced-files branch from ee11d32 to 9337ed6 Compare July 30, 2026 12:51
@LuciferYang
LuciferYang marked this pull request as ready for review July 30, 2026 13:11
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.06475% with 33 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/dataset/cleanup.rs 94.03% 22 Missing and 11 partials ⚠️

📢 Thoughts on this report? Let us know!

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The keep-set API is the right boundary for distributed cleanup, but a cleanup authority must be fail-closed: every live object must remain represented across collection, transport, and worker-side matching. The current implementation still violates that invariant, so it is not safe to use as a deletion predicate yet.

Comment thread rust/lance/src/dataset/cleanup.rs Outdated
Self {
exact: exact_paths
.into_iter()
.map(|p| Path::from(p.as_str()).to_string())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

exact_paths() already returns object-store canonical strings, so parsing them again here is not idempotent for % escapes. After driver serialization and worker reconstruction, a live path can be reported unreferenced and become deletion-eligible. Preserve a lossless serialized key representation, or decode exactly once at a defined boundary, so reconstruction is identity-preserving.

Reproducer

Add to referenced_file_set_matcher_rules:

let producer = ReferencedFileSet::new(
    vec!["data/live%25name.lance".to_string()],
    vec![],
);
assert!(producer.is_referenced("data/live%25name.lance"));

let worker = ReferencedFileSet::new(
    producer.exact_paths(),
    producer.index_prefixes().to_vec(),
);
assert!(worker.is_referenced("data/live%25name.lance"));

I ran cargo test -p lance referenced_file_set_matcher_rules --lib -- --nocapture at this head. The producer assertion passed; the worker assertion failed.

Comment thread rust/lance/src/dataset/cleanup.rs Outdated
// enumerate; refuse rather than under-report (matches
// `collect_paths`). Checked here so we cover every present
// version, not just the latest.
if let Some(RowIdMeta::External(external_file)) = &fragment.row_id_meta {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fail-closed guard covers row_id_meta, but external created_at_version_meta and last_updated_at_version_meta are neither retained nor rejected. A committed live metadata object under managed data/ is therefore reported unreferenced and can be deleted. Include both external paths in the exact set, or reject either external variant here.

Reproducer

Using the existing external-row-id fixture/overwrite setup, I wrote real objects at data/created-at.versions and data/last-updated-at.versions, then committed:

fragments[0].created_at_version_meta = Some(RowDatasetVersionMeta::External(
    ExternalFile { path: "data/created-at.versions".into(), offset: 0, size: 16 },
));
fragments[0].last_updated_at_version_meta = Some(RowDatasetVersionMeta::External(
    ExternalFile { path: "data/last-updated-at.versions".into(), offset: 0, size: 16 },
));
// commit Operation::Overwrite with these fragments
let refs = db.referenced_files().await.unwrap();
assert!(refs.is_referenced("data/created-at.versions"));
assert!(refs.is_referenced("data/last-updated-at.versions"));

I ran cargo test -p lance referenced_files_covers_external_row_version_metadata --lib -- --nocapture at this head. referenced_files() returned Ok; the first assertion failed.

@LuciferYang
LuciferYang marked this pull request as draft July 30, 2026 13:52
Normalizing paths with `object_store::path::Path::from` was not idempotent:
it percent-encodes `%`, so a key stored as `data/live%25name.lance` became
`data/live%2525name.lance` on ingest and grew another `%25` every time a
driver round-tripped the set through `exact_paths()` and `new()`. The worker
that reconstructed the set then failed to match a live file, and a caller
using this as a deletion predicate would delete it.

Normalize path shape only — strip leading/trailing delimiters and collapse
empty segments — and leave percent-encoding untouched, since both the
producer's keys and the paths a caller lists from storage are already in
object-store canonical form. Also accept a percent-decoded spelling of an
exact key, which can only over-retain, and skip that retry for the ordinary
alphanumeric path so the hot scan does not pay for it.

Reject external row-version metadata (`created_at_version_meta`,
`last_updated_at_version_meta`) alongside external row-id files: it is a
root-relative referenced file this set does not enumerate, so returning `Ok`
let a driver see live data as unreferenced. Reject external-base indices for
the same reason the per-fragment data and deletion guards exist — the
top-level `base_paths` check only inspects the latest manifest.

Document `_mem_wal/` and directory-marker objects as never-delete categories.
@wjones127

Copy link
Copy Markdown
Contributor

I'm wondering if we can combine this effort with tracked_files(). My intention was to evolve that API so it operated like a SQL view. Different use cases could send queries to it. For example, to get the distinct files this can be streamed into a distinct operator.

@LuciferYang

LuciferYang commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Thank you @wjones127, I think you're right, and I'd like to do this. Both APIs walk every present manifest and collect the same data, deletion, transaction, and manifest paths, and tracked_files has the better machinery for it — bounded pipeline, backpressure, min_version, progress reporting, and a Python binding that already hands back a RecordBatchReader. Since the use case driving this is a distributed Python cleanup driver, an Arrow stream that scatters to workers natively is a better transport than the exact_paths()/index_prefixes() serialization I added, which exists only because ReferencedFileSet can't cross the wire on its own. Your API also resolves base_id properly, where this PR just refuses multi-base datasets, so building on it is the path to lifting that limitation rather than enshrining it.

The part I don't think survives th@e merge is SELECT DISTINCT path as the consumer's query. Two of the things a cleanup driver must decide about are not rows in any per-file view: blob v2 sidecars, whose names are obfuscated blob ids that appear in no manifest, and index artifacts, which tracked_files only materializes by listing _indices/{uuid}/. You could emit both as prefix rows — a new FileType variant for data/{key}/ and _indices/{uuid}/ — with no extra I/O, and I'd be happy with that shape. But once a row can be a prefix, the consumer needs prefix-aware matching, so the anti-join stops being a plain distinct-plus-difference. That's the piece I'd want to keep behind a library boundary rather than re-derived per caller: an automated review on this PR caught a path-normalization bug where a percent-encoding round trip made a live file look unreferenced, which is the kind of mistake that costs data and that no caller should have to get right twice.

On the guards, you're right that they don't need a separate type — every one of them is a precondition on the same scan and would work as a for_deletion mode on TrackedFilesOptions. My concern is narrower and it's about defaults: a caller who forgets the flag gets a set that is silently incomplete rather than an error. tracked_files today is a live example, since it drops detached manifests silently (parse_version rejects d{version}.manifest), so a driver that treats its output as a keep-set would delete every file reachable only from a detached version. If the strict mode is opt-in, that failure stays reachable; if the deletion-facing entry point is its own function, it isn't. I don't feel strongly about how we express that, only that the safe reading shouldn't depend on remembering a flag.

One thing I ran into that argues for your consolidation regardless of how we shape the API: there are four independent manifest-walking collectors right now — tracked_files, process_manifest in cleanup, collect_paths for deep clone, and this PR's referenced_files — and they have already drifted. Only referenced_files collects fragment.overlays[].data_file, so the other three under-report once overlays are real. That's a reason to have one collector, which is what you're proposing.

So concretely: I'd build the keep-set on tracked_files' pipeline, add prefix rows for sidecars and index directories so it needs no listing, and keep a small library-side matcher plus the guards for the deletion use case. On sequencing I see three options and I'd rather you pick: land this PR as-is and factor out the shared collector in a follow-up, factor out the reusable part first and rebase this on top of it, or do both in this PR. I lean toward one of the first two, since the consolidation touches process_manifest and collect_paths as well and I'd rather that be reviewable on its own — but which one do you prefer?

@wjones127

Copy link
Copy Markdown
Contributor

I like the second option—"factor out the reusable part first and rebase this on top of it". I'm open to the first if you prefer that, though.

@LuciferYang

Copy link
Copy Markdown
Contributor Author

Going with the second option. #8267 is up: it is a prerequisite for the consolidation, not the consolidation itself. #8097 waits and gets rebased onto the shared collector, as you proposed.

#8267 factors out Fragment::data_files(), a fragment-level accessor yielding the base files chained with each overlay's data file, plus a mutable counterpart for the paths that rewrite base_id, and converts the six places that read fragment.files directly and therefore miss overlays. One of them is manifest_file_rows, behind tracked_files, so a keep-set built on today's pipeline would inherit the omission; that is the part bearing on the consolidation. The severe one is descendant-branch lineage retention, where the omission means a parent's cleanup deletes a file a child branch still reads. Release builds refuse overlay manifests unless the unstable flag is set, so it cannot fire today, but nothing else has to change for it to fire once the flag lifts. For #8097 itself the accessor only collapses the hand-rolled files.iter().chain(overlays…) into a single call.

The collector consolidation is what I still owe you, and two design choices decide its shape, so I would rather have your call than guess.

The first is the incompleteness contract. referenced_files refuses outright on branches, detached versions, multi-base fragments, external row-id and row-version metadata, and a zero-manifest listing anomaly, since an incomplete deletion predicate has to be an error rather than a quiet under-report. Would you rather the shared scan carry a for_deletion mode that turns those preconditions into errors, or keep a separate deletion-facing entry point over the same walk? Either way the case I want closed is the caller who ends up holding a set that silently authorizes deleting live files.

The second is independent of that one: whether prefixes belong in the stream. tracked_files resolves index artifacts by listing _indices/{uuid}/, one LIST per distinct uuid cached across versions, and emits a row per index file, where referenced_files does no listing and emits one prefix per uuid instead, avoiding both the LIST and the per-index-file rows. A blob v2 sidecar wants the same shape for a different reason: data/{key}/{blob_id}.blob lives as long as data/{key}.lance, and its name is an obfuscated blob id that appears in no manifest, so it is a predicate rather than a row. The catch is that once a row can be a prefix, SELECT DISTINCT path stops being a correct anti-join and the matching has to live somewhere; I would rather that be library-side than re-derived per caller. So: should the stream carry prefix rows for both, as a new variant on the type dictionary, or should prefix matching stay outside it?

wjones127 pushed a commit that referenced this pull request Aug 6, 2026
A fragment references its base files through `files` and its overlay
files through `overlays[].data_file`. Six paths read `files` directly
and so miss every overlay data file. Three enumerate, and three rewrite
or retain.

Enumeration:

- `process_manifest` builds the cleanup keep set, so an overlay old
enough to be a deletion candidate is irreversibly deleted from the live
dataset.
- `collect_paths` feeds deep clone's copy loop. It copies exactly the
paths returned, then commits an `Operation::Clone` whose manifest
carries the source fragments verbatim, overlays included, so the clone
references files that were never copied and reading it fails with a
not-found error.
- `manifest_file_rows` powers `tracked_files`, which under-reports its
documented "every file referenced in any manifest" contract. That
becomes a deletion risk of its own once its output drives an external
cleanup.

Rewriting and retention:

- Shallow clone stamps `base_id` on every local file so the clone
resolves it against the parent. Skipping overlays left theirs at `None`,
so the clone looked for the overlay under its own root, where it was
never written.
- Deep clone clears `base_id` on the same fields. An overlay kept a
`base_id` naming a base the new manifest no longer lists.
- Branch lineage retention promotes a path from `verified_files` into
`referenced_files` when its `base_id` resolves to the parent's own URI.
An overlay a branch inherited never got promoted, so the parent's
cleanup deleted a file the branch still reads.

The last two are inseparable. Before this change an inherited overlay
carried no `base_id` at all, so retention never examined it; fixing the
clone alone would give overlays a `base_id` while retention still
skipped them, which is what turns the omission into a deletion.

All six misbehave in any build that can open an overlay-bearing dataset:
debug builds unconditionally, release builds only with
`LANCE_ENABLE_UNSTABLE_DATA_OVERLAY_FILES` set, since a release reader
otherwise refuses the manifest at open and these paths never see one.
Within that gate none of them needs a further code change to be
reachable.

## The fix

`Fragment::data_files()` yields the base files chained with each
overlay's data file, and `data_files_mut()` is its counterpart for the
two paths that rewrite `base_id`. Both destructure `Fragment`
exhaustively, so a new field fails to compile there until someone
decides whether it references files — the prompt that was missing when
`overlays` was added in #7535. The mutable one also needs the
destructure so the disjoint field borrows are visible to the borrow
checker.

`manifest_file_rows` derives its `exact_size` precount from the same
accessor. Counting `files.len()` separately would underflow the moment
an overlay appeared, since `ExactSize::next` decrements an unsigned
counter.

`cleanup_data_fragments` deliberately stays on `files`. It deletes the
files a caller hands it, and callers decide which files belong to the
failed write: `schema_evolution` clones a live committed fragment and
narrows `files` to the newly written ones while leaving `overlays`
untouched, so including overlays there would delete live data. The
second commit records that reasoning at the loop.

Each converted caller keeps its own `base_id` handling: `collect_paths`
resolves it to a base root, `manifest_file_rows` to an external base
URI, and `process_manifest` ignores it. Those differ by design, so
unifying them is out of scope.

## Tests

Six cases, one per path, each verified to fail with the corresponding
accessor reverted to `files`-only:

- `keep_set_covers_referenced_overlay_files` — the keep set contains the
overlay path.
- `deep_clone_copies_overlay_files` — the clone returns the overlaid
values rather than failing to find the file.
- `test_manifest_file_rows_per_file_base_id` (extended) — the overlay
row appears with its own `base_id` resolved.
- `shallow_clone_stamps_base_id_on_overlay_files` — the overlay resolves
against the parent.
- `deep_clone_of_shallow_clone_clears_overlay_base_id` — deep-cloning a
shallow clone leaves the overlay with no `base_id`.
- `lineage_retention_covers_inherited_overlay_files` — the parent's keep
set promotes an overlay its branch inherited. It asserts the branch
actually inherited a `base_id` first, so it cannot pass vacuously.

The keep-set and lineage cases assert on `process_manifests` and
`retain_branch_lineage_files` output rather than driving cleanup end to
end. An end-to-end version cannot reach the deletion decision:
`build_listing_stream` passes `earliest_retained_manifest_time` to
`read_dir_all`, which lists only files whose mtime predates it, and the
test clock does not move real file mtimes, so a file written during the
test is never a candidate.

One pre-existing test helper needed a fix to support this:
`commit_overlay` wrote through a store-root-relative path that only
resolved on an in-memory store, and the clone cases need real stores.

`cargo test -p lance --lib` across `dataset::cleanup`, `dataset::files`,
`dataset::fragment`, `dataset::write`, `io::commit`, plus `cargo test -p
lance-table --lib format`, all pass. `cargo clippy -p lance -p
lance-table --all-targets -- -D warnings`, `RUSTDOCFLAGS="-D warnings"
cargo doc -p lance -p lance-table --no-deps`, and `cargo fmt --all
--check` are clean.

## Relation to #8097

This came out of the #8097 discussion, where the suggestion was to
factor out the reusable part before rebasing that PR onto it. The
overlay drift is an independent defect, so it is split out here rather
than mixed into the API discussion. It is not by itself the
consolidation asked for there — `Fragment::data_files()` is a
fragment-level accessor, and whether that is the "reusable part" or
whether `referenced_files` should be rebuilt on `tracked_files`'
pipeline is still open. I will follow up on #8097 with that question.
The accessor landed in lance-format#8267, so the hand-rolled base-plus-overlay chain
here is now a second spelling of it.
@LuciferYang

Copy link
Copy Markdown
Contributor Author

I'll be occupied with another task these days, and will resume working on this PR next week.

@LuciferYang

Copy link
Copy Markdown
Contributor Author

refactor work: #8449

wjones127 pushed a commit that referenced this pull request Aug 17, 2026
`tracked_files` walks every present manifest with a four-stage pipeline:
a lister that enumerates manifest locations and applies `min_version`, a
reader that fetches them with bounded parallelism under a memory budget,
an emitter that turns each manifest into file rows, and an index lister
that materializes index directories. Only the last two are about the
rows it emits. The first two are about walking manifests, and a second
consumer needs exactly them.

That consumer is `Dataset::referenced_files` in #8097, the keep-set an
external orphan-cleanup driver uses to decide what it may delete. In the
discussion there the suggestion was to factor out the reusable part
before rebasing that PR onto it, which is what this does. Nothing in
this PR depends on #8097; `tracked_files` is the only caller here and
its behavior is unchanged.

## What moved

The lister and reader now live in `dataset::files::scan`, which yields a
`ScannedManifest` per present manifest: the manifest, its own path, and
the index metadata read alongside it. `tracked_files` keeps its emitter
and index lister and consumes that stream. Channel capacities, the
`can_launch` predicate, the `biased` select ordering, and the
`min_version` filter are carried over unchanged.

## Why the budget accounting changed shape

Previously the reader charged bytes before sending and the emitter
released them after processing. That worked because the emitter was the
only consumer and sat in the same file, so the charge was bounded by the
reader's in-flight reads plus two channel slots.

A shared walk cannot rely on that: a second consumer that forgets to
release would silently stall the reader. The charge now lives in a
`MemoryPermit` held by `ScannedManifest` and released on drop, so
backpressure follows the manifest's lifetime rather than a convention.
Field order is load-bearing and commented: the permit drops after the
manifest it accounts for.

The bound is on the reader's prefetch, not on what a consumer retains.
One read is always allowed when nothing is in flight, which is what
keeps a manifest larger than the whole budget from deadlocking the walk,
so a consumer that holds every manifest gets serial reads rather than a
stall. That escape hatch is unchanged from before, and the module doc
now states this rather than promising a bound it does not provide.

## Tests

Six cases in `scan::tests`, covering what the previous arrangement had
no way to observe:

- the budget returns to zero once every manifest is dropped, and stays
charged while a consumer holds them;
- `min_version` really does skip manifests, which is why a keep-set must
leave it unset;
- `total` counts every manifest the walk yields;
- a failed manifest read surfaces one `Err` per manifest rather than
being skipped, asserted as `errors == 3` because `errors > 0` would also
pass on a reader that stopped at the first failure or on a listing
failure;
- dropping the stream early releases every in-flight permit.

Each fails against the corresponding mistake: a leaked permit, a
bypassed filter, a reader that aborts on first error.

`cargo clippy -p lance --all-targets -- -D warnings`, `RUSTDOCFLAGS="-D
warnings" cargo doc -p lance --no-deps`, and `cargo fmt --all --check`
are clean; `dataset::files` (18) and `dataset::cleanup` (41) pass. The
full suite is left to CI.

## Reviewing this

The second commit is the result of reviewing the first, so the two are
worth reading separately. It removes an unreachable error branch the
extraction left behind, moves the index fan-out after the row batches so
a full index channel cannot block row output while holding budget, makes
the test-only budget accessor private, and corrects the module doc
described above.
`referenced_files` had its own walk: `list_manifest_locations` fed into
`try_for_each_concurrent`, with a `Mutex` around the two path sets and an
atomic manifest counter. The shared walk from lance-format#8449 does the same listing
and reading, so consume that instead.

Two things fall out. The sets and the counter become plain locals, since
the stream is consumed sequentially and the concurrency now lives in the
walk. And the keep-set inherits the walk's memory budget, which it did not
have before: it read manifests at `io_parallelism()` with no bound on how
much manifest it held at once, on exactly the datasets this API targets.

`min_version` is deliberately left unset, with the reason at the call
site: a keep-set has to cover every present manifest, and skipping one
would authorize deleting the files it is the last to reference.
@LuciferYang

This comment was marked as spam.

Xuanwo added a commit that referenced this pull request Aug 31, 2026
…8562)

`Fragment::referenced_lance_files` documents which of a fragment's
referenced files it does *not* yield, and the list named two of the four
kinds. A fragment can also reference external row-version metadata
through `created_at_version_meta` and `last_updated_at_version_meta`,
both `RowDatasetVersionMeta::External(ExternalFile)` just like the
external row-id file.

Classify by format instead of enumerating, so the boundary stays correct
as kinds are added: a deletion file is `.arrow` or `.bin` per
`DeletionFileType::suffix`, and external row-id or row-version metadata
is an `ExternalFile`, a `(path, offset, size)` byte range rather than a
Lance file.

This matters because the omission reads as a statement about what is
referenced at all. `Dataset::referenced_files` in #8097 refuses outright
on each of those three external kinds, precisely because they are
referenced but not enumerated by any per-file walk; a reader of this doc
could reasonably conclude row-version metadata is not a referenced file.

Docs only, no code change. `cargo fmt --all --check`, `cargo clippy -p
lance-table --all-targets -- -D warnings`, and `RUSTDOCFLAGS="-D
warnings" cargo doc -p lance-table --no-deps` are clean; `lance-table`
`format` tests pass (32).

Co-authored-by: Xuanwo <github@xuanwo.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants