Skip to content

feat(table): remove orphan files and add sys.remove_orphan_files - #968

Open
zhuxiangyi wants to merge 5 commits into
apache:mainfrom
zhuxiangyi:feat/remove-orphan-files
Open

zhuxiangyi wants to merge 5 commits into
apache:mainfrom
zhuxiangyi:feat/remove-orphan-files

Conversation

@zhuxiangyi

@zhuxiangyi zhuxiangyi commented Sep 26, 2026 •

Copy link
Copy Markdown

Purpose

Linked issue: #964 (part 4 of 4)

Stacked on #965, which provides the shared helpers (try_get_snapshot, parse_older_than,
reassign-plan detection). Only the last three commits are new here: "feat(table): remove orphan
files and add sys.remove_orphan_files" and two test follow-ups.

Failed or interrupted writes leave files that no snapshot references, and nothing in paimon-rust
removes them. This PR ports Java's LocalOrphanFilesClean and adds CALL sys.remove_orphan_files.

Brief change log

  • Table::new_remove_orphan_files() / RemoveOrphanFiles (table/orphan_files_clean.rs):
    • Candidates: files last modified before older_than in these places:

      • the manifest, index and statistics directories;
      • every bucket directory, walking partition_keys levels of key=value directories, as
        Java's listFileDirs does;
      • bucket directories under data-file.external-paths.
    • Non-snapshot files in each branch's snapshot and changelog directories (temporary files of
      interrupted commits) are removed too, as in Java's cleanBranchSnapshotDir.

    • Used files: everything referenced by the snapshots, tags, and long-lived changelogs
      (changelog/changelog-*, the snapshot JSON format) of every branch:

      • manifest lists, manifests and their extra files;
      • index manifests and index files;
      • statistics files and reassign plans;
      • data files and their extra files from all manifest entries.

      Files are matched by name, as in Java.

    • Safety:

      • older_than defaults to one day ago and must be in the past;
      • a store that reports no modification time never exposes a file;
      • a branch without a schema aborts the run (Java);
      • managed BLOB packs (*.managed.blob) are skipped (Java);
      • directories are never deleted.
    • Unlike Java, which reads a missing manifest as empty, a missing metadata file of a snapshot or
      changelog that still exists aborts the run. Otherwise that snapshot's data files would look
      unreferenced and be deleted. If the owning snapshot vanished at the same time (concurrent
      expiration), it is simply skipped.

    • dry_run reports without deleting; parallelism bounds concurrent reads and deletions.

  • DataFusion CALL sys.remove_orphan_files(table, older_than, dry_run, parallelism, mode) returns
    deletedFileCount and deletedFileTotalLenInBytes, like Java. It supports mode => 'local' and
    one table per call; db.* returns a clear "not supported yet" error.
  • Not included: removing empty directories. It is left for later, because on object stores
    "check empty, then delete" races with writers.

Tests

  • table::orphan_files_clean::tests (13 tests) run on a real local file system, because they need
    modification times. Like the repository's other local-listing tests, they are skipped on Windows,
    where CI's 8.3 short temporary paths make opendal's local lister panic:
    • planted orphans (a data file, a manifest, an index file, a statistics file, and a temporary
      snapshot file) are found by a dry run, then removed. Afterwards the directory holds exactly
      the referenced files, the table reads correctly, and a second run finds nothing;
    • recent files are kept, and a future older_than is rejected;
    • after a snapshot is removed without cleanup, only its own manifest lists become orphans. Its
      data file is still named by the next delta's DELETE entry, as in Java;
    • a tag, a branch, and a long-lived changelog each protect their files; once the changelog is
      removed, its files become orphans;
    • partitioned bucket directories are scanned, and managed BLOB packs are skipped;
    • a missing manifest of a live snapshot aborts the run with nothing deleted;
    • a branch without a schema aborts the run;
    • deletion vectors in bucket directories and global index files that snapshots reference are
      kept, while orphans planted next to them are removed;
    • data-file.external-paths directories are scanned, keeping referenced files and removing
      orphans;
    • two levels of partition directories are walked;
    • temporary changelog files are removed while hint files stay;
    • a snapshot that vanished concurrently is skipped, while a live snapshot or a tag with a missing
      manifest aborts the run.
  • Each of these changes makes a test fail: ignoring branches, not counting referenced index files
    as used, not scanning external paths, and walking only one partition level.
  • DataFusion tests/procedures.rs:
    • an end-to-end test: the default cut-off keeps a fresh orphan; a dry run reports it; a real run
      with parallelism and mode => 'local' removes it and the table stays readable;
    • argument validation: db.*, mode => 'distributed', a future older_than, a bad dry_run,
      and parallelism => 0.
  • cargo test -p paimon --all-targets --features fulltext,vortex passes (3835 tests), and so does
    clippy with -D warnings.

API and Format

New public API: Table::new_remove_orphan_files(), RemoveOrphanFiles, OrphanFilesCleanResult;
new sys.remove_orphan_files procedure. No format change.

Documentation

docs/src/sql.md documents remove_orphan_files: its arguments, what counts as an orphan, and
the safety rules.

Port Java's ExpireSnapshotsImpl and SnapshotDeletion. Snapshots are
chosen with snapshot.num-retained.min/max, snapshot.time-retained (a
snapshot expires once its successor is older than the cut-off),
consumer protection, and snapshot.expire.limit. Expiring [earliest,
end) deletes data files removed by the deltas of (earliest, end] that
the closest earlier tag does not read, changelog files, and manifest
lists, manifests, index files, statistics and reassign plans that
neither end nor a tag in the range references, then the snapshot
files and finally moves the EARLIEST hint. Read failures only ever
make a run delete less.

Expose it as Table::new_expire_snapshots() and the DataFusion
procedure sys.expire_snapshots with Java's arguments.
…ation

Extend the file-set invariant to changelog files and index files kept
in bucket directories, and add cases for deletion-vector files in both
index layouts, changelog and hash index files of a primary-key table,
external data files, unreadable deltas, tags and skipping sets (each
must delete less, never more), the closest earlier tag among several,
a snapshot missing from the range, the slowest of several consumers,
and older_than given as a timestamp string.
Port Java's LocalOrphanFilesClean. Files older than older_than (one
day ago by default, and never in the future) in the manifest, index
and statistics directories, bucket directories and data file external
paths are deleted when no snapshot, tag or long-lived changelog of any
branch references them; non-snapshot files in snapshot and changelog
directories are removed as well. A branch without a schema aborts the
run, and unlike Java a missing metadata file of a live snapshot aborts
it too instead of exposing that snapshot's files to deletion.

Expose it as Table::new_remove_orphan_files() and the DataFusion
procedure sys.remove_orphan_files with Java's arguments and result
columns (local mode, one table per call).
…in orphan cleanup

Check that deletion vectors in bucket directories and global index
files that snapshots reference are kept while planted orphans next to
them go, that data file external paths are scanned and their
referenced files kept, that two levels of partition directories are
walked, that temporary changelog files are removed while hint files
stay, and that a vanished snapshot is skipped while a live snapshot
or a tag with a missing manifest aborts the run.
@zhuxiangyi
zhuxiangyi force-pushed the feat/remove-orphan-files branch from 545f312 to dd20d38 Compare September 26, 2026 13:11
Windows CI hands out 8.3 short temporary paths, from which opendal's
local lister cannot strip its canonical root, so listing a temporary
table directory panics there. Skip these local file system tests on
Windows, like the repository's other local-listing tests.
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.

1 participant