Skip to content

[Bug]: vacuum reports success but deletes nothing, when a partition directory name is percent-escaped (regression in 1.5.0) #4646

Description

@JackKelly

What happened?

The investigation and write-up below were done with Claude Code. I hit the bug on our production table, I've run the repro myself, and I can defend what's here.

Thank you for delta-rs — Delta Lake is the storage layer for essentially our whole system, so your work is genuinely load-bearing for us.

The symptom. vacuum(dry_run=False) deletes nothing, returns the superseded file as deleted, and commits VACUUM START / VACUUM END with numDeletedFiles counting it.

It shows up whenever the partition directory name contains a character in object_store's INVALID set. Every character escaped when that name is built becomes a %, and % is itself in the set, so any escaped partition value triggers it — a Timestamp unavoidably, since the rendered value always contains a space and colons. A few unescaped characters trigger it directly: "a~b" gives part=a~b, with no % anywhere, and still fails. The column type is not the trigger — in the repro below a String partition fails on "a b" and succeeds on "ab".

It works in 1.4.2 and fails from 1.5.0 onward — I ran the repro against each. The difference looks like #4227, which moved the lite path from a storage listing to building the delete list from tombstone paths. Still reproduces on main at fd7e9691, so it isn't covered by #4624.

These two things look like they combine:

1. The add and remove paths reach object-store keys by different routes. Both views decode first, so both start from the real on-disk directory name — LogicalFileView::path() and TombstoneView::path() (kernel/snapshot/iterators/tombstones.rs:29) each end in percent_decode_str. They then diverge. For an add, object_store_path() (kernel/snapshot/iterators.rs:140) calls Path::parse, which validates without re-encoding, so the key matches the file on disk. That is why reads work. For a remove, operations/vacuum.rs:329 (and :620) calls Path::from, which does encode, and object_store's INVALID set contains %. The key lands back on the log's escaped form, where no object exists.

2. A NotFound while deleting is counted as a deletion. operations/vacuum.rs:529 maps Err(Error::NotFound { .. }) to Ok(path), so the miss reaches the returned list and numDeletedFiles. This one predates 1.5.0 and was harmless until the first started producing misses.

To check that the encoding really is the cause rather than something else, I planted a decoy file at the double-encoded path: vacuum deleted the decoy and left the real superseded file.

On existing issues: this looks like the same inconsistent-path-encoding root cause as #4053 (closed by #4056), at a call site I don't think #4056 touched. It's easy to mistake for #3911, so to save you checking — the repro passes dry_run=False, and the "ab" control case does delete.

Also noticed while investigating: full=True behaviour

full=True still deletes the file, since it works from a storage listing. On a table with escaped partition names it reports each file twice, once per code path, and commits numDeletedFiles: 2 for a single file. Its dedupe and recent-tombstone guards (vacuum.rs:345, :365) also compare listed paths against a set built by the same Path::from at :620, so as far as I can tell the protection added by #4300 doesn't apply there and a file with a recent tombstone falls through to the physical-age branch. Happy to split this out into its own issue if you'd rather keep them separate.

Expected behavior

A remove action's path should resolve to the same object-store key an add action's path would.

Separately, a NotFound while deleting probably shouldn't count as a deleted file — reporting it as one means an operator believes space was reclaimed and time travel severed when neither happened.

On the shape of a fix, entirely your call: a change at the call site would do it, though TombstoneView has no object_store_path() of its own, so the two paths could drift apart again.

Operating System

Linux

Binding

Python

Bindings Version

1.6.2

Steps to reproduce

import tempfile
from datetime import datetime
from pathlib import Path

import pyarrow as pa
from deltalake import DeltaTable, write_deltalake

# Only the third case avoids percent-escaping. The type is irrelevant: the
# String "a b" fails exactly as the Timestamp does.
for value in (datetime(2026, 8, 13), "a b", "ab"):
    table = Path(tempfile.mkdtemp()) / "t"
    literal = value.isoformat() if isinstance(value, datetime) else value
    for x in (1, 2):  # the second write supersedes the first in the same partition
        write_deltalake(
            table,
            pa.table({"part": [value], "x": [x]}),
            mode="overwrite",
            predicate=f"part = '{literal}'",
            partition_by=["part"],
        )

    part_dir = next(p for p in table.iterdir() if p.name.startswith("part="))
    before = len(list(part_dir.glob("*.parquet")))
    reported = DeltaTable(table).vacuum(
        retention_hours=0, dry_run=False, enforce_retention_duration=False
    )
    after = len(list(part_dir.glob("*.parquet")))

    print(f"{part_dir.name}")
    print(f"  vacuum reported deleting {len(reported)}, actually deleted {before - after}")
    print(f"  reported path: {reported[0]}")

Relevant logs

part=2026-08-13%2000%3A00%3A00.000000
  vacuum reported deleting 1, actually deleted 0
  reported path: /tmp/tmp2pfefxfg/t/part=2026-08-13%252000%253A00%253A00.000000/part-00000-9c9bd228-….parquet
part=a%20b
  vacuum reported deleting 1, actually deleted 0
  reported path: /tmp/tmpdme8wrx9/t/part=a%2520b/part-00000-2ad7e9ae-….parquet
part=ab
  vacuum reported deleting 1, actually deleted 1
  reported path: part=ab/part-00000-559362dd-….parquet

The returned path is absolute in the two failing cases and relative in the working one — the absolute form is the NotFound error's path, which is the second point above showing through.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    No status
    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions