Skip to content

Commit d364f62

Browse files
feat: vacuum lite mode to avoid storage listing (delta-io#4227)
# Description Vacuum Lite Mode only deletes Stale Tombstone files, but current implementation does full file listing regardless of Lite or Full mode. This change avoids listing storage for Lite mode and tries to simlify and clarify logic by segregating concerns for each mode. # Related Issue(s) - closes [#106](delta-io#4228) # Documentation Added test cases to test and clarify intend --------- Signed-off-by: Khalid Mammadov <khalidmammadov9@gmail.com> Signed-off-by: R. Tyler Croy <rtyler@brokenco.de> Co-authored-by: R. Tyler Croy <rtyler@brokenco.de>
1 parent 65b50e0 commit d364f62

3 files changed

Lines changed: 199 additions & 53 deletions

File tree

crates/core/src/kernel/snapshot/iterators/tombstones.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,13 @@ impl TombstoneView {
5555
.as_boolean()
5656
.value(self.index)
5757
}
58+
59+
pub fn size(&self) -> Option<i64> {
60+
static FIELD_INDEX: LazyLock<usize> =
61+
LazyLock::new(|| Remove::to_schema().field_with_index("size").unwrap().0);
62+
self.data
63+
.column(*FIELD_INDEX)
64+
.as_primitive_opt::<Int64Type>()
65+
.map(|a| a.value(self.index))
66+
}
5867
}

crates/core/src/operations/vacuum.rs

Lines changed: 66 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use tracing::*;
3535
use super::{CustomExecuteHandler, Operation};
3636
use crate::errors::{DeltaResult, DeltaTableError};
3737
use crate::kernel::transaction::{CommitBuilder, CommitProperties};
38-
use crate::kernel::{EagerSnapshot, resolve_snapshot};
38+
use crate::kernel::{EagerSnapshot, TombstoneView, resolve_snapshot};
3939
use crate::logstore::{LogStore, LogStoreRef};
4040
use crate::protocol::DeltaOperation;
4141
use crate::table::config::TablePropertiesExt as _;
@@ -280,6 +280,8 @@ impl VacuumBuilder {
280280
_ => HashSet::new(),
281281
};
282282

283+
let mut file_count = 0;
284+
283285
let expired_tombstones =
284286
get_stale_files(snapshot, retention_period, now_millis, &self.log_store).await?;
285287
let valid_files: HashSet<_> = snapshot
@@ -288,66 +290,62 @@ impl VacuumBuilder {
288290
.try_collect()
289291
.await?;
290292

293+
let partition_columns = snapshot.metadata().partition_columns();
294+
291295
let mut files_to_delete = vec![];
292296
let mut file_sizes = vec![];
293-
let object_store = self.log_store.object_store(None);
294-
295-
let list_span = info_span!("list_files", operation = "vacuum");
296-
let mut all_files = list_span.in_scope(|| object_store.list(None));
297-
let partition_columns = snapshot.metadata().partition_columns();
298297

299-
let mut file_count = 0;
300-
while let Some(obj_meta) = all_files.next().await {
301-
// TODO should we allow NotFound here in case we have a temporary commit file in the list
302-
let obj_meta = obj_meta.map_err(DeltaTableError::from)?;
303-
file_count += 1;
304-
// file is still being tracked in table
305-
if valid_files.contains(&obj_meta.location) {
306-
continue;
307-
}
308-
// file is associated with a version that we are keeping
309-
if keep_files.contains(&obj_meta.location.to_string()) {
310-
debug!(
311-
"The file {:?} is in a version specified to be kept by the user, skipping",
312-
&obj_meta.location
313-
);
314-
continue;
315-
}
316-
if is_hidden_directory(partition_columns, &obj_meta.location)? {
317-
continue;
298+
// VacuumMode::Lite file set
299+
// Expired tombstones are *always deleted (*unless in keep list)
300+
for tombs in expired_tombstones.iter() {
301+
let path = Path::from(tombs.path().to_string());
302+
if ok_to_delete(&path, &valid_files, &keep_files, partition_columns)? {
303+
files_to_delete.push(path);
304+
file_sizes.push(tombs.size().unwrap_or(0));
318305
}
319-
// file is not an expired tombstone _and_ this is a "Lite" vacuum
320-
// If the file is not an expired tombstone and we have gotten to here with a
321-
// VacuumMode::Full then it should be added to the deletion plan
322-
if !expired_tombstones.contains(obj_meta.location.as_ref()) {
323-
// For files without tombstones (uncommitted or orphaned files),
324-
// check their physical age to protect recently written files from deletion.
325-
// This prevents race conditions where a concurrent writer's uncommitted files
326-
// could be deleted before the transaction is committed.
327-
let file_age_millis = now_millis - obj_meta.last_modified.timestamp_millis();
328-
if file_age_millis < retention_period.num_milliseconds() {
329-
debug!(
330-
"The file {:?} is not in the log but too recent , protecting from vacuum",
306+
}
307+
308+
if self.mode == VacuumMode::Full {
309+
let object_store = self.log_store.object_store(None);
310+
311+
let list_span = info_span!("list_files", operation = "vacuum");
312+
let mut all_files = list_span.in_scope(|| object_store.list(None));
313+
314+
let already_queued: HashSet<Path> = files_to_delete.iter().cloned().collect();
315+
316+
while let Some(obj_meta) = all_files.next().await {
317+
// TODO should we allow NotFound here in case we have a temporary commit file in the list
318+
let obj_meta = obj_meta.map_err(DeltaTableError::from)?;
319+
// If the file is not an expired tombstone
320+
if !already_queued.contains(&obj_meta.location)
321+
&& ok_to_delete(
331322
&obj_meta.location,
332-
);
333-
continue;
334-
}
335-
if self.mode == VacuumMode::Lite {
336-
debug!(
337-
"The file {:?} was not referenced in a log file, but VacuumMode::Lite means it will not be vacuumed",
338-
&obj_meta.location
339-
);
340-
continue;
341-
} else {
323+
&valid_files,
324+
&keep_files,
325+
partition_columns,
326+
)?
327+
{
328+
// For files without tombstones (uncommitted or orphaned files),
329+
// check their physical age to protect recently written files from deletion.
330+
// This prevents race conditions where a concurrent writer's uncommitted files
331+
// could be deleted before the transaction is committed.
332+
let file_age_millis = now_millis - obj_meta.last_modified.timestamp_millis();
333+
if file_age_millis < retention_period.num_milliseconds() {
334+
debug!(
335+
"The file {:?} is not in the log but too recent , protecting from vacuum",
336+
&obj_meta.location,
337+
);
338+
continue;
339+
}
342340
debug!(
343341
"The file {:?} was not referenced in a log file, but VacuumMode::Full means it *will be vacuumed*",
344342
&obj_meta.location
345343
);
344+
files_to_delete.push(obj_meta.location);
345+
file_sizes.push(obj_meta.size as i64);
346+
file_count += 1;
346347
}
347348
}
348-
349-
files_to_delete.push(obj_meta.location);
350-
file_sizes.push(obj_meta.size as i64);
351349
}
352350
info!(
353351
files_scanned = file_count,
@@ -530,13 +528,29 @@ fn is_hidden_directory(partition_columns: &[String], path: &Path) -> Result<bool
530528
.any(|partition_column| path_name.starts_with(partition_column)))
531529
}
532530

531+
/// Returns true if the file at `location` is a candidate for deletion.
532+
/// A file should NOT be deleted if it is still tracked in the table,
533+
/// associated with a kept version, or is a hidden directory.
534+
fn ok_to_delete(
535+
location: &Path,
536+
valid_files: &HashSet<Path>,
537+
keep_files: &HashSet<String>,
538+
partition_columns: &[String],
539+
) -> Result<bool, DeltaTableError> {
540+
Ok(
541+
!(valid_files.contains(location) // file is still being tracked in table
542+
|| keep_files.contains(&location.to_string()) // file is associated with a version that we are keeping
543+
|| is_hidden_directory(partition_columns, location)?),
544+
)
545+
}
546+
533547
/// List files no longer referenced by a Delta table and are older than the retention threshold.
534548
async fn get_stale_files(
535549
snapshot: &EagerSnapshot,
536550
retention_period: Duration,
537551
now_timestamp_millis: i64,
538552
store: &dyn LogStore,
539-
) -> DeltaResult<HashSet<String>> {
553+
) -> DeltaResult<Vec<TombstoneView>> {
540554
let tombstone_retention_timestamp = now_timestamp_millis - retention_period.num_milliseconds();
541555
snapshot
542556
.snapshot()
@@ -546,8 +560,7 @@ async fn get_stale_files(
546560
// then it's considered as a stale file
547561
ready(tombstone.deletion_timestamp().unwrap_or(0) < tombstone_retention_timestamp)
548562
})
549-
.map_ok(|tombstone| tombstone.path().to_string())
550-
.try_collect::<HashSet<_>>()
563+
.try_collect::<Vec<_>>()
551564
.await
552565
}
553566

python/tests/test_vacuum.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,130 @@ def test_vacuum_keep_versions():
150150
}
151151

152152

153+
def test_vacuum_lite_mode_no_list_operation(
154+
tmp_path: pathlib.Path, sample_table: Table
155+
):
156+
"""Test that vacuum in lite mode (default) only removes files referenced as
157+
expired tombstones in the transaction log and does NOT perform a storage list
158+
operation. A stray parquet file that exists on disk but is never referenced by
159+
the delta log is used as a sentinel: Lite mode must not return it, while Full
160+
mode must find it via the list operation."""
161+
162+
# Write initial data then overwrite so the first batch of files becomes
163+
# expired tombstones in the transaction log.
164+
write_deltalake(tmp_path, sample_table, mode="overwrite")
165+
dt = DeltaTable(tmp_path)
166+
original_files = set(dt.file_uris())
167+
168+
write_deltalake(tmp_path, sample_table, mode="overwrite")
169+
dt.update_incremental()
170+
new_files = set(dt.file_uris())
171+
assert new_files.isdisjoint(original_files)
172+
173+
# Plant a stray file in the table directory that is NOT referenced anywhere
174+
# in the transaction log. Only a storage list operation would discover it.
175+
stray_filename = "stray_file_not_in_log.parquet"
176+
(tmp_path / stray_filename).write_bytes(b"not a real parquet file")
177+
178+
# Lite mode: Should return only the expired tombstones derived from the log.
179+
lite_tombstones = set(
180+
dt.vacuum(
181+
retention_hours=0,
182+
dry_run=True,
183+
enforce_retention_duration=False,
184+
full=False,
185+
)
186+
)
187+
188+
expected_tombstones = {f.split(os.path.sep)[-1] for f in original_files}
189+
assert lite_tombstones == expected_tombstones, (
190+
"Lite mode should return exactly the log-referenced expired tombstones"
191+
)
192+
193+
# The stray file must NOT appear: if it did, a list operation was performed.
194+
assert stray_filename not in lite_tombstones, (
195+
"Lite mode must not discover files via a storage list operation"
196+
)
197+
198+
# Full mode performs a list and must include the stray file.
199+
full_tombstones = set(
200+
dt.vacuum(
201+
retention_hours=0,
202+
dry_run=True,
203+
enforce_retention_duration=False,
204+
full=True,
205+
)
206+
)
207+
208+
assert stray_filename in full_tombstones, (
209+
"Full mode should discover the stray file via a storage list operation"
210+
)
211+
212+
213+
def test_vacuum_lite_mode_no_list_operation_partitioned(
214+
tmp_path: pathlib.Path, sample_table: Table
215+
):
216+
"""Same as test_vacuum_lite_mode_no_list_operation but for a partitioned table.
217+
Verifies that lite mode does not perform a storage list operation even when
218+
the table is partitioned a stray parquet file planted inside a partition
219+
directory must not be returned by lite mode but must be found by full mode."""
220+
221+
# Write partitioned data, then overwrite to create expired tombstones.
222+
write_deltalake(tmp_path, sample_table, partition_by=["deleted"], mode="overwrite")
223+
dt = DeltaTable(tmp_path)
224+
original_files = set(dt.file_uris())
225+
226+
write_deltalake(tmp_path, sample_table, partition_by=["deleted"], mode="overwrite")
227+
dt.update_incremental()
228+
new_files = set(dt.file_uris())
229+
assert new_files.isdisjoint(original_files)
230+
231+
# Plant a stray parquet file inside one of the existing partition directories.
232+
# Because it is never mentioned in the transaction log, only a storage list
233+
# operation would discover it.
234+
partition_dir = tmp_path / "deleted=false"
235+
partition_dir.mkdir(parents=True, exist_ok=True)
236+
stray_filename = "stray_file_not_in_log.parquet"
237+
(partition_dir / stray_filename).write_bytes(b"not a real parquet file")
238+
stray_relative = f"deleted=false/{stray_filename}"
239+
240+
# Lite mode: Should return only the expired tombstones derived from the log.
241+
lite_tombstones = set(
242+
dt.vacuum(
243+
retention_hours=0,
244+
dry_run=True,
245+
enforce_retention_duration=False,
246+
full=False,
247+
)
248+
)
249+
250+
expected_tombstones = {
251+
os.path.join(*f.split(os.path.sep)[-2:]) for f in original_files
252+
}
253+
assert lite_tombstones == expected_tombstones, (
254+
"Lite mode should return exactly the log-referenced expired tombstones"
255+
)
256+
257+
# The stray file must NOT appear: if it did, a list operation was performed.
258+
assert stray_relative not in lite_tombstones, (
259+
"Lite mode must not discover files via a storage list operation"
260+
)
261+
262+
# Full mode performs a list and must include the stray file.
263+
full_tombstones = set(
264+
dt.vacuum(
265+
retention_hours=0,
266+
dry_run=True,
267+
enforce_retention_duration=False,
268+
full=True,
269+
)
270+
)
271+
272+
assert stray_relative in full_tombstones, (
273+
"Full mode should discover the stray file via a storage list operation"
274+
)
275+
276+
153277
# https://github.com/delta-io/delta-rs/issues/3745
154278
@pytest.mark.pyarrow
155279
def test_issue_3745(tmp_path: pathlib.Path):

0 commit comments

Comments
 (0)