Skip to content

Commit c130c53

Browse files
committed
remove orphan files
Add the RemoveOrphanFiles maintenance action, mirroring the Java DeleteOrphanFiles action: list the table location, compare against every file reachable from the metadata, and delete the difference for files older than the cutoff (3 days by default). Listing requires a new FileIO.list_prefix, implemented for the PyArrow and fsspec backends.
1 parent 4e6033d commit c130c53

11 files changed

Lines changed: 1129 additions & 1 deletion

File tree

mkdocs/docs/api.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,28 @@ def cleanup_old_snapshots(table_name: str, snapshot_ids: list[int]):
15271527
cleanup_old_snapshots("analytics.user_events", [12345, 67890, 11111])
15281528
```
15291529

1530+
### Remove Orphan Files
1531+
1532+
Remove files in the table's storage location that are not reachable from any valid snapshot or metadata file. This typically happens after failed writes or aborted compactions leave residual data files behind. Table property `gc.enabled` must be set.
1533+
1534+
!!! warning
1535+
Removing orphan files is destructive and irreversible. Always start with `dry_run()` to inspect the candidates, and make sure no other table or in-flight writer is reading from the same storage location.
1536+
1537+
```python
1538+
from datetime import datetime, timedelta, timezone
1539+
1540+
# Dry run — list orphans without deleting
1541+
result = table.maintenance.remove_orphan_files() \
1542+
.older_than(timedelta(days=7)) \
1543+
.dry_run() \
1544+
.execute()
1545+
1546+
# Actually delete
1547+
table.maintenance.remove_orphan_files() \
1548+
.older_than(datetime.now(tz=timezone.utc) - timedelta(days=7)) \
1549+
.execute()
1550+
```
1551+
15301552
## Views
15311553

15321554
If PyIceberg is unable to automatically determine view support on your REST Catalog, you can manually specify, `"view-endpoints-supported": "true"`:

pyiceberg/io/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
import os
3131
import warnings
3232
from abc import ABC, abstractmethod
33+
from collections.abc import Iterator
34+
from dataclasses import dataclass
35+
from datetime import datetime
3336
from io import SEEK_SET
3437
from types import TracebackType
3538
from typing import (
@@ -269,6 +272,15 @@ def create(self, overwrite: bool = False) -> OutputStream:
269272
"""
270273

271274

275+
@dataclass(frozen=True)
276+
class FileEntry:
277+
"""Metadata only for a single file."""
278+
279+
location: str
280+
size: int
281+
last_modified: datetime | None = None
282+
283+
272284
class FileIO(ABC):
273285
"""A base class for FileIO implementations."""
274286

@@ -306,6 +318,20 @@ def delete(self, location: str | InputFile | OutputFile) -> None:
306318
FileNotFoundError: When the file at the provided location does not exist.
307319
"""
308320

321+
def list_prefix(self, location: str) -> Iterator[FileEntry]:
322+
"""Recursively list every file under the given location.
323+
324+
Args:
325+
location (str): A URI or path to recursively list.
326+
327+
Returns:
328+
Iterator[FileEntry]: The metadata of every file under the location.
329+
330+
Raises:
331+
NotImplementedError: If the FileIO implementation does not support listing.
332+
"""
333+
raise NotImplementedError(f"{type(self).__name__} does not support list_prefix")
334+
309335

310336
LOCATION = "location"
311337
WAREHOUSE = "warehouse"

pyiceberg/io/fsspec.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
import logging
2323
import os
2424
import threading
25-
from collections.abc import Callable
25+
from collections.abc import Callable, Iterator
2626
from copy import copy
27+
from datetime import datetime, timezone
2728
from functools import lru_cache
2829
from typing import (
2930
TYPE_CHECKING,
@@ -86,6 +87,7 @@
8687
S3_SIGNER_ENDPOINT_DEFAULT,
8788
S3_SIGNER_URI,
8889
S3_SSE_KMS_KEY_ID,
90+
FileEntry,
8991
FileIO,
9092
InputFile,
9193
InputStream,
@@ -491,6 +493,40 @@ def delete(self, location: str | InputFile | OutputFile) -> None:
491493
fs = self._get_fs_from_uri(uri, str_location)
492494
fs.rm(str_location)
493495

496+
@override
497+
def list_prefix(self, location: str) -> Iterator[FileEntry]:
498+
"""Recursively list every file under the given location.
499+
500+
Args:
501+
location (str): A URI or a path to recursively list.
502+
503+
Returns:
504+
Iterator[FileEntry]: The metadata of every file under the location.
505+
"""
506+
uri = urlparse(location)
507+
fs = self._get_fs_from_uri(uri, location)
508+
# On Windows a drive letter parses as a URI scheme, so local paths are reported as-is.
509+
scheme = "" if _is_local_path(location) else uri.scheme
510+
511+
for path, info in fs.find(location, detail=True).items():
512+
if info.get("type", "file") != "file":
513+
continue
514+
515+
mtime = info.get("mtime") or info.get("LastModified") or info.get("last_modified")
516+
last_modified: datetime | None
517+
if isinstance(mtime, datetime):
518+
last_modified = mtime
519+
elif isinstance(mtime, (int, float)):
520+
last_modified = datetime.fromtimestamp(mtime, tz=timezone.utc)
521+
else:
522+
last_modified = None
523+
524+
yield FileEntry(
525+
location=path if scheme in ("", "file") else f"{scheme}://{path}",
526+
size=int(info.get("size") or 0),
527+
last_modified=last_modified,
528+
)
529+
494530
def _get_fs_from_uri(self, uri: "ParseResult", location: str = "") -> AbstractFileSystem:
495531
"""Get a filesystem from a parsed URI, using hostname for ADLS account resolution."""
496532
if _is_local_path(location):

pyiceberg/io/pyarrow.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from pyarrow._s3fs import S3RetryStrategy
6262
from pyarrow.fs import (
6363
FileInfo,
64+
FileSelector,
6465
FileSystem,
6566
FileType,
6667
)
@@ -116,6 +117,7 @@
116117
S3_ROLE_SESSION_NAME,
117118
S3_SECRET_ACCESS_KEY,
118119
S3_SESSION_TOKEN,
120+
FileEntry,
119121
FileIO,
120122
InputFile,
121123
InputStream,
@@ -694,6 +696,38 @@ def delete(self, location: str | InputFile | OutputFile) -> None:
694696
raise PermissionError(f"Cannot delete file, access denied: {location}") from e
695697
raise # pragma: no cover - If some other kind of OSError, raise the raw error
696698

699+
@override
700+
def list_prefix(self, location: str) -> Iterator[FileEntry]:
701+
"""Recursively list every file under the given location.
702+
703+
Args:
704+
location (str): A URI or a path to recursively list.
705+
706+
Returns:
707+
Iterator[FileEntry]: The metadata of every file under the location.
708+
"""
709+
scheme, netloc, path = self.parse_location(location, self.properties)
710+
fs = self.fs_by_scheme(scheme, netloc)
711+
selector = FileSelector(path, recursive=True, allow_not_found=True)
712+
713+
# PyArrow reports paths without a scheme, and for object stores the bucket is part of
714+
# the path, so the prefix that reconstructs the original URI differs per scheme.
715+
original_scheme = "" if _is_local_path(location) else urlparse(location).scheme
716+
if original_scheme in ("hdfs", "viewfs"):
717+
uri_prefix = f"{original_scheme}://{netloc}"
718+
elif original_scheme:
719+
uri_prefix = f"{original_scheme}://"
720+
else:
721+
uri_prefix = ""
722+
723+
for info in fs.get_file_info(selector):
724+
if info.type == FileType.File:
725+
yield FileEntry(
726+
location=f"{uri_prefix}{info.path}",
727+
size=info.size or 0,
728+
last_modified=info.mtime,
729+
)
730+
697731
def __getstate__(self) -> dict[str, Any]:
698732
"""Create a dictionary of the PyArrowFileIO fields used when pickling."""
699733
fileio_copy = copy(self.__dict__)

pyiceberg/table/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ class TableProperties:
235235
WRITE_UPDATE_ISOLATION_LEVEL = "write.update.isolation-level"
236236
WRITE_ISOLATION_LEVEL_DEFAULT = "serializable"
237237

238+
GC_ENABLED = "gc.enabled"
239+
GC_ENABLED_DEFAULT = True
240+
238241

239242
class Transaction:
240243
_table: Table
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
if TYPE_CHECKING:
2626
from pyiceberg.table import Table
27+
from pyiceberg.table.maintenance.orphan_files import RemoveOrphanFiles
2728
from pyiceberg.table.update.snapshot import ExpireSnapshots
2829

2930

@@ -43,3 +44,13 @@ def expire_snapshots(self) -> ExpireSnapshots:
4344
from pyiceberg.table.update.snapshot import ExpireSnapshots
4445

4546
return ExpireSnapshots(transaction=Transaction(self.tbl, autocommit=True))
47+
48+
def remove_orphan_files(self) -> RemoveOrphanFiles:
49+
"""Return a RemoveOrphanFiles builder for removing files unreachable from the table.
50+
51+
Returns:
52+
RemoveOrphanFiles builder for configuring and executing orphan file removal.
53+
"""
54+
from pyiceberg.table.maintenance.orphan_files import RemoveOrphanFiles
55+
56+
return RemoveOrphanFiles(self.tbl)

0 commit comments

Comments
 (0)