feat(python): expose Table.expire_snapshots - #967
zhuxiangyi wants to merge 9 commits into
Conversation
114cc23 to
40eb555
Compare
|
Requirement fit: SUPPORTED. Python binding: CLEAN; shared expiration implementation: FINDINGS. [P1] Preserve branch references before expiration ( [P2] Preserve persisted long-lived changelog owners ( Validation at 40eb555: built and installed the actual extension with maturin; all 24 existing Python table tests passed, including the new API's write/expire/read, tag, limit and invalid-retention coverage. Two additional real-filesystem Python probes above fail. The binding's GIL release, argument ownership and exception mapping were independently checked. Current main also conflicts in table/mod.rs; rebase and rerun integration before merge. Additional shared-foundation review: #965 (comment) covers branch ownership, persisted long-lived changelog ownership, and an obsolete legacy Python DV left behind because expiry resolves only its bucket path. This PR inherits that deletion logic; the implementation differs only in the orphan cleaner helper visibility for #968. |
40eb555 to
13b76a2
Compare
|
Thanks for building and probing the extension! [P1] Branch references and [P2] persisted long-lived changelogs: both are fixed in the shared expiration code in #965 (e1d75d2). It now keeps everything that branches and long-lived changelogs reference, or fails before deleting anything. This branch is rebased onto it and onto current main. I added your probe as a Python test in 13b76a2. |
13b76a2 to
16a8c31
Compare
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Python binding: CLEAN; shared expiration: FINDINGS. Built/installed the exact-head extension with maturin on Python 3.13. All 25 table tests, 4 fork/GIL tests, 27 Rust expiry tests and the persisted-changelog Python probe passed. The previous branch/changelog/legacy-DV fixes are verified. A real Python configured-directory expiry probe reproduces the shared P2 below. Current-main merge is clean; all 15 head CI checks are successful.
| Some(_) => BinaryRow::from_serialized_bytes(partition)?, | ||
| }; | ||
| bucket_path( | ||
| &self.table_location, |
There was a problem hiding this comment.
[P2] Resolve expired data paths from the configured data root
With a real Python table configured data-file.path-directory=data, INSERT writes the old Parquet under <table>/data/bucket-0. After INSERT OVERWRITE, the new Python expire_snapshots(retain_min=1, retain_max=1) returns 1 and only snapshot 2 remains, but the expired snapshot's original Parquet still exists. Latest rows remain correct; physical reclamation silently fails because this helper attempts <table>/bucket-0 instead of the writer/read path. Use Table::data_file_location() for data and bucket-local sidecars/indexes, retaining table_location for metadata/global indexes, and add a configured-directory regression through the Python API. This is the same shared foundation defect reproduced on #968.
There was a problem hiding this comment.
Fixed by the shared change in #965 (f7ba2d3): bucket directories now come from Table::data_file_location(). Following your probe, the Python regression test_expire_snapshots_with_data_directory creates a table with data-file.path-directory = data, inserts, overwrites, and calls expire_snapshots(retain_min=1, retain_max=1). It returns 1, only one file remains under data/bucket-0, and the table reads the new row.
JingsongLi
left a comment
There was a problem hiding this comment.
Additional finding from this review sweep: the expiration implementation at this reviewed head is byte-identical to #965. A new actual global-index/branch/overwrite failure probe on #965 reproduces the P2 below; an early-metadata-preflight control passes. This supplements the previous findings; it does not assert this exact probe was run through this PR's Python/automatic/orphan entry point.
| Ok(mut skipping) => { | ||
| skipping.extend( | ||
| deletion | ||
| .manifest_skipping_set(&external_snapshots, true) |
There was a problem hiding this comment.
[P2] Preflight all external owner metadata before deleting files
external_owners only validates base/delta data manifests up front. External index/changelog metadata are first checked here, after clean_data_files and changelog deletion. I built an actual global index, branched from its indexed snapshot, dropped the main index, overwrote main twice, then corrupted only the branch-owned index manifest. Expiration returns the Avro read error, leaves every snapshot JSON (including snapshot4), but has already deleted snapshot4's Parquet. This violates the new guarantee that an unreadable external owner aborts with nothing changed and leaves retained residual history unreadable after a failed call. A control that preflights manifest_skipping_set(external_snapshots,true) immediately after external_owners returns the same error with all physical data preserved and the probe passes. Build/reuse that skipping set before any data/changelog mutation, and cover optional index/changelog metadata failures.
There was a problem hiding this comment.
16a8c31 to
ad38d25
Compare
|
Round 2 is addressed; details are in the inline replies. The shared fixes are in #965 (f7ba2d3), and ad38d25 adds the Python regression for a configured data directory. The branch is rebased onto #965 and main c41ce20. The ids in my previous reply predate two rebases: 13b76a2 is now c620c51, and e1d75d2 is now 74741ed.
|
JingsongLi
left a comment
There was a problem hiding this comment.
Requirement fit: SUPPORTED. Implementation: FINDINGS for production integration at ad38d25. The Python API itself has no new finding: I built and loaded this exact head extension, and all 26 existing table tests plus an additional argument/error/actual-expiry probe passed (27 total). All 31 core expiration tests also passed. Tags, branches, latest rows and configured data directories remain readable after expiration.
The automatic merge with current main bede665 fails the core test build with E0432 at the inherited snapshot-deletion import below. The shared foundation needs updating before this Python feature can merge or go to production.
| ManifestFileMeta, ManifestList, PartitionComputer, Snapshot, | ||
| }; | ||
| use crate::table::index_file_path::{ | ||
| committed_index_file_path, resolve_legacy_deletion_vector_entries, |
There was a problem hiding this comment.
[P1] Update snapshot deletion to the current main index-path API
The merge is textually clean, but this newly added module imports resolve_legacy_deletion_vector_entries, which current main no longer provides in table::index_file_path after #1016. On the automatic merge with bede665, the core test build fails with E0432: unresolved import crate::table::index_file_path::resolve_legacy_deletion_vector_entries. The passing head tests use the older foundation and do not establish that this PR can be merged now. Rebase/adapt the shared snapshot deletion code to current index-file resolution and rerun the merged core/SQL tests before production use.
| .and_then(|date| date.and_hms_opt(0, 0, 0)) | ||
| }) | ||
| .ok_or_else(|| DataFusionError::Plan(format!("Invalid older_than timestamp: '{value}'")))?; | ||
| Local |
There was a problem hiding this comment.
[P2] Match Java's forward resolution of DST gap timestamps
With TZ=America/New_York, CALL sys.expire_snapshots(table => 'test_db.t1', older_than => '2024-03-10 02:30:00', retain_min => 1) fails with 'does not exist in the local time zone'. Java ProcedureUtils uses DateTimeUtils.parseTimestampData with the default time zone; LocalDateTime.atZone resolves this gap forward to 03:30 EDT (1710055800000). This rejects a valid Java procedure input instead of expiring normally. Use the same gap-forward / fold-earlier resolution already implemented for timestamp options, and cover this input in an isolated time-zone test.
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.
…iring Expiration only protected the current branch's tags and the retained end snapshot, so expiring main deleted manifests and data files that a branch created from it still read, and could leave a long-lived changelog (changelog/changelog-<id>, written by Java's decoupled expiration before it removes the snapshot) without its manifest lists or changelog files. Before deleting anything, read the snapshots and tags of every branch and the long-lived changelogs of main and every branch. Their live data files join the protected set, their manifests (changelog manifests included) join the skipping set, and an expired snapshot's changelog files stay while any of them still names its changelog list. If these owners cannot be read, the run fails with nothing changed. Also resolve legacy deletion-vector locations before deleting index files: older Python writers left vectors under the table index directory even with index-file-in-data-file-dir, where reads find them but expiration did not.
…tory The skipping set of branches and long-lived changelogs, which reads their index and changelog manifests, was built only after data and changelog files had been deleted. An unreadable branch index manifest therefore failed the run after files of retained snapshots were gone. Build it right after reading the owners, before any deletion, so such a run fails with nothing changed. Resolve bucket directories from Table::data_file_location(), like writers, readers and commit cleanup do, instead of the table location: with data-file.path-directory set, expired data files, sidecars and bucket-local index files were left behind. Manifests, statistics and global index files stay under the table location.
apache#1016 removed the legacy deletion-vector location fallback (resolve_legacy_deletion_vector_entries) from reads, so expiration no longer compiled on main. Resolve index files only through committed_index_file_path, the layout reads use now, and drop the legacy-location test.
older_than rejected a local time inside a DST gap, which Java's DateTimeUtils.parseTimestampData moves forward (LocalDateTime.atZone). Generalize the Java-compatible scan.timestamp parser into spec::parse_local_timestamp_millis and use it for older_than, so a gap resolves forward and a fold to the earlier instant. Test both in a child process pinned to America/New_York.
Add PyTable.expire_snapshots(retain_max, retain_min, older_than_ms, max_deletes) on top of the core snapshot expiration. Unset arguments fall back to the table options, as in CALL sys.expire_snapshots, and the call releases the GIL while it runs.
Expire main through the Python API while a branch shares snapshot 1's files, and check both main and the branch still read their rows.
Create a table with data-file.path-directory, overwrite it and expire through the Python API; the expired file under data/bucket-0 is deleted and the table reads the new row.
ad38d25 to
0201745
Compare
Purpose
Linked issue: #964 (part 3 of 4)
Expose snapshot expiration from #965 to Python.
Brief change log
Table.expire_snapshots(retain_max=None, retain_min=None, older_than_ms=None, max_deletes=None) -> intin the Python binding. Unset arguments fall back to the table options, as inCALL sys.expire_snapshots. The call releases the GIL while it runs and returns the number of expiredsnapshots.
datafusion.pyi.Tests
tests/test_table.py::test_expire_snapshotscovers:retain_max/retain_min;older_than_mswithmax_deletes;ValueError.make install && make testfortests/test_table.pypasses (24 tests), and so does clippy with-D warnings.API and Format
New Python method
Table.expire_snapshots. No format change.Documentation
Docstring in the type stub.