Skip to content

Commit 8f1d01e

Browse files
committed
fix(manifest): scope cache key by table_uuid instead of content comparison
Closes #3978. Per feedback from Fokko and rambleraptor, a manifest_path collision across genuinely different manifests should not be detected and repaired after the fact -- it should be structurally impossible. Key the process-wide cache on (table_uuid, manifest_path) instead of path alone: table_uuid is stable for a table's lifetime and unique across tables, so two tables can never share an entry regardless of what their manifest paths look like, and a genuine cache hit costs a plain O(1) lookup instead of a full record comparison. A caller that omits table_uuid (e.g. any external code still on the pre-fix Snapshot.manifests(io) signature) bypasses the cache entirely rather than falling into a shared unattributed bucket, so there is no remaining path for two tables to share an
1 parent 1312c55 commit 8f1d01e

10 files changed

Lines changed: 187 additions & 63 deletions

File tree

‎pyiceberg/catalog/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,7 @@ def purge_table(self, identifier: str | Identifier) -> None:
962962
manifest_lists_to_delete = set()
963963
manifests_to_delete: list[ManifestFile] = []
964964
for snapshot in metadata.snapshots:
965-
manifests_to_delete += snapshot.manifests(io)
965+
manifests_to_delete += snapshot.manifests(io, table_uuid=metadata.table_uuid)
966966
manifest_lists_to_delete.add(snapshot.manifest_list)
967967

968968
manifest_paths_to_delete = {manifest.manifest_path for manifest in manifests_to_delete}

‎pyiceberg/cli/output.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def files(self, table: Table, history: bool) -> None:
170170
f"Snapshot {snapshot.snapshot_id}, schema {snapshot.schema_id}: {snapshot.manifest_list}"
171171
)
172172

173-
manifest_list = snapshot.manifests(io)
173+
manifest_list = snapshot.manifests(io, table_uuid=table.metadata.table_uuid)
174174
for manifest in manifest_list:
175175
manifest_tree = list_tree.add(f"Manifest: {manifest.manifest_path}")
176176
for manifest_entry in manifest.fetch_manifest_entry(io, discard_deleted=False):

‎pyiceberg/manifest.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import math
2020
import threading
21+
import uuid
2122
from abc import ABC, abstractmethod
2223
from collections.abc import Callable, Iterator
2324
from copy import copy
@@ -937,16 +938,19 @@ def __hash__(self) -> int:
937938

938939

939940
class _ManifestCache:
940-
"""Process-wide ManifestFile cache keyed by manifest_path.
941+
"""Process-wide ManifestFile cache keyed by (table_uuid, manifest_path).
941942
942943
Consecutive snapshots often reference the same manifests after append
943944
operations, so reusing ManifestFile instances avoids retaining duplicate
944-
objects.
945+
objects. The table_uuid is part of the key so that two tables can never
946+
collide on a manifest_path, even if one happens to reuse the other's path.
947+
A caller that does not supply a table_uuid cannot be attributed to a table,
948+
so it bypasses the cache rather than sharing an unattributed entry.
945949
"""
946950

947951
DEFAULT_SIZE = 128
948952

949-
_cache: LRUCache[str, ManifestFile] | None
953+
_cache: LRUCache[tuple[uuid.UUID, str], ManifestFile] | None
950954

951955
def __init__(self) -> None:
952956
self.maxsize = self._load_configured_size()
@@ -969,16 +973,16 @@ def clear(self) -> None:
969973
if self._cache is not None:
970974
self._cache.clear()
971975

972-
def get_or_cache(self, manifest_file: ManifestFile) -> ManifestFile:
973-
if self._cache is None:
976+
def get_or_cache(self, manifest_file: ManifestFile, table_uuid: uuid.UUID | None = None) -> ManifestFile:
977+
if self._cache is None or table_uuid is None:
974978
return manifest_file
975979

976980
with self._lock:
977-
manifest_path = manifest_file.manifest_path
978-
if manifest_path in self._cache:
979-
return self._cache[manifest_path]
981+
key = (table_uuid, manifest_file.manifest_path)
982+
if key in self._cache:
983+
return self._cache[key]
980984

981-
self._cache[manifest_path] = manifest_file
985+
self._cache[key] = manifest_file
982986
return manifest_file
983987

984988
def __len__(self) -> int:
@@ -998,11 +1002,12 @@ def clear_manifest_cache() -> None:
9981002
_manifest_cache.clear()
9991003

10001004

1001-
def _manifests(io: FileIO, manifest_list: str) -> tuple[ManifestFile, ...]:
1005+
def _manifests(io: FileIO, manifest_list: str, table_uuid: uuid.UUID | None = None) -> tuple[ManifestFile, ...]:
10021006
"""Read manifests from a manifest list, reusing cached ManifestFile objects.
10031007
1004-
Caches individual ManifestFile objects by manifest_path. This is memory-efficient
1005-
because consecutive manifest lists typically share most of their manifests:
1008+
Caches individual ManifestFile objects by (table_uuid, manifest_path). This is
1009+
memory-efficient because consecutive manifest lists typically share most of
1010+
their manifests:
10061011
10071012
ManifestList1: [ManifestFile1]
10081013
ManifestList2: [ManifestFile1, ManifestFile2]
@@ -1018,14 +1023,17 @@ def _manifests(io: FileIO, manifest_list: str) -> tuple[ManifestFile, ...]:
10181023
Args:
10191024
io: FileIO instance for reading the manifest list.
10201025
manifest_list: Path to the manifest list file.
1026+
table_uuid: UUID of the table this manifest list belongs to, used to scope
1027+
the cache so that two tables can never share a cached entry. When
1028+
omitted the manifests are returned uncached.
10211029
10221030
Returns:
10231031
A tuple of ManifestFile objects.
10241032
"""
10251033
file = io.new_input(manifest_list)
10261034
manifest_files = list(read_manifest_list(file))
10271035

1028-
return tuple(_manifest_cache.get_or_cache(manifest_file) for manifest_file in manifest_files)
1036+
return tuple(_manifest_cache.get_or_cache(manifest_file, table_uuid) for manifest_file in manifest_files)
10291037

10301038

10311039
def read_manifest_list(input_file: InputFile) -> Iterator[ManifestFile]:

‎pyiceberg/table/__init__.py‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -995,13 +995,16 @@ def upsert(
995995

996996
def _find_referenced_data_files(self, file_paths: list[str]) -> list[str]:
997997
"""Return file_paths already referenced by data files in the current snapshot."""
998-
snapshot = self.table_metadata.current_snapshot()
998+
table_metadata = self.table_metadata
999+
snapshot = table_metadata.current_snapshot()
9991000
if snapshot is None:
10001001
return []
10011002

10021003
candidates = set(file_paths)
10031004
io = self._table.io
1004-
data_manifests = [m for m in snapshot.manifests(io) if m.content == ManifestContent.DATA]
1005+
data_manifests = [
1006+
m for m in snapshot.manifests(io, table_uuid=table_metadata.table_uuid) if m.content == ManifestContent.DATA
1007+
]
10051008

10061009
def path_filter(data_file: DataFile) -> bool:
10071010
return data_file.file_path in candidates
@@ -2425,7 +2428,9 @@ def _plan_manifest_entries(self) -> Iterator[list[ManifestEntry]]:
24252428
if not snapshot:
24262429
return iter([])
24272430

2428-
return self._manifest_planner.plan_manifest_entries(snapshot.manifests(self.io))
2431+
return self._manifest_planner.plan_manifest_entries(
2432+
snapshot.manifests(self.io, table_uuid=self.table_metadata.table_uuid)
2433+
)
24292434

24302435
def _should_use_server_side_planning(self) -> bool:
24312436
"""Check if server-side scan planning should be used for this scan."""
@@ -2462,7 +2467,7 @@ def _plan_files_local(self) -> Iterable[FileScanTask]:
24622467
snapshot = self.snapshot()
24632468
if not snapshot:
24642469
return []
2465-
return self._manifest_planner.plan_files(snapshot.manifests(self.io))
2470+
return self._manifest_planner.plan_files(snapshot.manifests(self.io, table_uuid=self.table_metadata.table_uuid))
24662471

24672472
def plan_files(self) -> Iterable[FileScanTask]:
24682473
"""Plans the relevant files by filtering on the PartitionSpecs.
@@ -2654,7 +2659,7 @@ def plan_files(self) -> Iterable[FileScanTask]:
26542659
{
26552660
manifest_file
26562661
for snapshot in append_snapshots
2657-
for manifest_file in snapshot.manifests(self.io)
2662+
for manifest_file in snapshot.manifests(self.io, table_uuid=self.table_metadata.table_uuid)
26582663
if manifest_file.content == ManifestContent.DATA and manifest_file.added_snapshot_id in append_snapshot_ids
26592664
}
26602665
)
@@ -2667,8 +2672,9 @@ def plan_files(self) -> Iterable[FileScanTask]:
26672672
options=self.options,
26682673
).plan_files(
26692674
manifests=manifests,
2670-
manifest_entry_filter=lambda manifest_entry: manifest_entry.snapshot_id in append_snapshot_ids
2671-
and manifest_entry.status == ManifestEntryStatus.ADDED,
2675+
manifest_entry_filter=lambda manifest_entry: (
2676+
manifest_entry.snapshot_id in append_snapshot_ids and manifest_entry.status == ManifestEntryStatus.ADDED
2677+
),
26722678
)
26732679

26742680
def to_arrow(self) -> pa.Table:

‎pyiceberg/table/inspect.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def _readable_metrics_struct(bound_type: PrimitiveType) -> pa.StructType:
232232

233233
entries = []
234234
snapshot = self._get_snapshot(snapshot_id)
235-
for manifest in snapshot.manifests(self.tbl.io):
235+
for manifest in snapshot.manifests(self.tbl.io, table_uuid=self.tbl.metadata.table_uuid):
236236
for entry in manifest.fetch_manifest_entry(io=self.tbl.io, discard_deleted=False):
237237
column_sizes = entry.data_file.column_sizes or {}
238238
value_counts = entry.data_file.value_counts or {}
@@ -383,7 +383,9 @@ def partitions(
383383
)
384384

385385
snapshot = self._get_snapshot(snapshot_id)
386-
spec_ids = {manifest.partition_spec_id for manifest in snapshot.manifests(self.tbl.io)}
386+
spec_ids = {
387+
manifest.partition_spec_id for manifest in snapshot.manifests(self.tbl.io, table_uuid=self.tbl.metadata.table_uuid)
388+
}
387389
partition_record = self.tbl.metadata.specs_struct(spec_ids=spec_ids)
388390
has_partitions = len(partition_record.fields) > 0
389391

@@ -590,7 +592,7 @@ def _partition_summaries_to_rows(
590592
specs = self.tbl.metadata.specs()
591593
manifests = []
592594
if snapshot:
593-
for manifest in snapshot.manifests(self.tbl.io):
595+
for manifest in snapshot.manifests(self.tbl.io, table_uuid=self.tbl.metadata.table_uuid):
594596
is_data_file = manifest.content == ManifestContent.DATA
595597
is_delete_file = manifest.content == ManifestContent.DELETES
596598
manifest_row = {
@@ -882,7 +884,8 @@ def _files(self, snapshot_id: int | None = None, data_file_filter: set[DataFileC
882884
executor = ExecutorFactory.get_or_create()
883885
results = list(
884886
executor.map(
885-
lambda manifest_list: self._get_files_from_manifest(manifest_list, data_file_filter), snapshot.manifests(io)
887+
lambda manifest_list: self._get_files_from_manifest(manifest_list, data_file_filter),
888+
snapshot.manifests(io, table_uuid=self.tbl.metadata.table_uuid),
886889
)
887890
)
888891
return pa.concat_tables(results)
@@ -970,7 +973,9 @@ def _all_files(self, data_file_filter: set[DataFileContent] | None = None) -> pa
970973
return pa.Table.from_pylist([], schema=self._get_files_schema())
971974

972975
executor = ExecutorFactory.get_or_create()
973-
manifest_lists = executor.map(lambda snapshot: snapshot.manifests(self.tbl.io), snapshots)
976+
manifest_lists = executor.map(
977+
lambda snapshot: snapshot.manifests(self.tbl.io, table_uuid=self.tbl.metadata.table_uuid), snapshots
978+
)
974979

975980
unique_manifests = {(manifest.manifest_path, manifest) for manifest_list in manifest_lists for manifest in manifest_list}
976981

‎pyiceberg/table/snapshots.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import time
20+
import uuid
2021
import warnings
2122
from collections import defaultdict
2223
from collections.abc import Iterable, Mapping
@@ -289,9 +290,17 @@ def __repr__(self) -> str:
289290
filtered_fields = [field for field in fields if field is not None]
290291
return f"Snapshot({', '.join(filtered_fields)})"
291292

292-
def manifests(self, io: FileIO) -> list[ManifestFile]:
293-
"""Return the manifests for the given snapshot."""
294-
return list(_manifests(io, self.manifest_list))
293+
def manifests(self, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
294+
"""Return the manifests for the given snapshot.
295+
296+
Args:
297+
io: FileIO instance for reading the manifest list.
298+
table_uuid: UUID of the table this snapshot belongs to, used to scope
299+
the process-wide manifest cache so that two tables can never
300+
share a cached entry. When omitted the manifests are returned
301+
uncached.
302+
"""
303+
return list(_manifests(io, self.manifest_list, table_uuid))
295304

296305

297306
class MetadataLogEntry(IcebergBaseModel):

‎pyiceberg/table/update/snapshot.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ def _copy_with_new_status(entry: ManifestEntry, status: ManifestEntryStatus) ->
608608
if parent_snapshot_id_for_delete_source is not None:
609609
snapshot = table_metadata.snapshot_by_id(parent_snapshot_id_for_delete_source)
610610
if snapshot: # Ensure snapshot is found
611-
for manifest_file in snapshot.manifests(io=self._io):
611+
for manifest_file in snapshot.manifests(io=self._io, table_uuid=table_metadata.table_uuid):
612612
if manifest_file.content == ManifestContent.DATA:
613613
if not manifest_evaluators[manifest_file.partition_spec_id](manifest_file):
614614
# If the manifest isn't relevant, we can just keep it in the manifest-list
@@ -699,12 +699,13 @@ def _existing_manifests(self) -> list[ManifestFile]:
699699
existing_manifests = []
700700

701701
if self._parent_snapshot_id is not None:
702-
previous_snapshot = self._transaction.table_metadata.snapshot_by_id(self._parent_snapshot_id)
702+
table_metadata = self._transaction.table_metadata
703+
previous_snapshot = table_metadata.snapshot_by_id(self._parent_snapshot_id)
703704

704705
if previous_snapshot is None:
705706
raise ValueError(f"Snapshot could not be found: {self._parent_snapshot_id}")
706707

707-
for manifest in previous_snapshot.manifests(io=self._io):
708+
for manifest in previous_snapshot.manifests(io=self._io, table_uuid=table_metadata.table_uuid):
708709
if manifest.has_added_files() or manifest.has_existing_files() or manifest.added_snapshot_id == self._snapshot_id:
709710
existing_manifests.append(manifest)
710711

@@ -782,8 +783,9 @@ def _existing_manifests(self) -> list[ManifestFile]:
782783
existing_files = []
783784

784785
manifest_evaluators: dict[int, Callable[[ManifestFile], bool]] = KeyDefaultDict(self._build_manifest_evaluator)
785-
if snapshot := self._transaction.table_metadata.snapshot_by_name(name=self._target_branch):
786-
for manifest_file in snapshot.manifests(io=self._io):
786+
table_metadata = self._transaction.table_metadata
787+
if snapshot := table_metadata.snapshot_by_name(name=self._target_branch):
788+
for manifest_file in snapshot.manifests(io=self._io, table_uuid=table_metadata.table_uuid):
787789
# Manifest does not contain rows that match the files to delete partitions
788790
if not manifest_evaluators[manifest_file.partition_spec_id](manifest_file):
789791
existing_files.append(manifest_file)
@@ -831,7 +833,8 @@ def _deleted_entries(self) -> list[ManifestEntry]:
831833
which entries are affected.
832834
"""
833835
if self._parent_snapshot_id is not None:
834-
previous_snapshot = self._transaction.table_metadata.snapshot_by_id(self._parent_snapshot_id)
836+
table_metadata = self._transaction.table_metadata
837+
previous_snapshot = table_metadata.snapshot_by_id(self._parent_snapshot_id)
835838
if previous_snapshot is None:
836839
# This should never happen since you cannot overwrite an empty table
837840
raise ValueError(f"Could not find the previous snapshot: {self._parent_snapshot_id}")
@@ -855,7 +858,9 @@ def _get_entries(manifest: ManifestFile) -> list[ManifestEntry]:
855858
if entry.data_file.content == DataFileContent.DATA and entry.data_file in self._deleted_data_files
856859
]
857860

858-
list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._io))
861+
list_of_entries = executor.map(
862+
_get_entries, previous_snapshot.manifests(self._io, table_uuid=table_metadata.table_uuid)
863+
)
859864
deleted_entries = list(itertools.chain(*list_of_entries))
860865
else:
861866
deleted_entries = []

‎pyiceberg/table/update/validate.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def _validation_history(
8787
manifests_files.extend(
8888
[
8989
manifest
90-
for manifest in snapshot.manifests(table.io)
90+
for manifest in snapshot.manifests(table.io, table_uuid=table.metadata.table_uuid)
9191
if manifest.added_snapshot_id == snapshot.snapshot_id and manifest.content == manifest_content_filter
9292
]
9393
)

‎tests/table/test_validate.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717
# pylint:disable=redefined-outer-name,eval-used
18+
import uuid
1819
from typing import cast
1920
from unittest.mock import patch
2021

@@ -77,7 +78,7 @@ def test_validation_history(table_v2_with_extensive_snapshots_and_manifests: tup
7778
]
7879
)
7980

80-
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO) -> list[ManifestFile]:
81+
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
8182
"""Mock the manifests method to use the snapshot_id for lookup."""
8283
snapshot_id = self.snapshot_id
8384
if snapshot_id in mock_manifests:
@@ -133,7 +134,7 @@ def test_validation_history_fails_on_from_snapshot_not_matching_last_snapshot(
133134
oldest_snapshot = table.snapshots()[0]
134135
newest_snapshot = cast(Snapshot, table.current_snapshot())
135136

136-
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO) -> list[ManifestFile]:
137+
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
137138
"""Mock the manifests method to use the snapshot_id for lookup."""
138139
snapshot_id = self.snapshot_id
139140
if snapshot_id in mock_manifests:
@@ -162,7 +163,7 @@ def test_deleted_data_files(
162163
oldest_snapshot = table.snapshots()[0]
163164
newest_snapshot = cast(Snapshot, table.current_snapshot())
164165

165-
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO) -> list[ManifestFile]:
166+
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
166167
"""Mock the manifests method to use the snapshot_id for lookup."""
167168
snapshot_id = self.snapshot_id
168169
if snapshot_id in mock_manifests:
@@ -257,7 +258,7 @@ def test_validate_added_data_files_conflicting_count(
257258
boundary_snapshot = table.snapshots()[-(snapshot_history + 1)]
258259
newest_snapshot = cast(Snapshot, table.current_snapshot())
259260

260-
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO) -> list[ManifestFile]:
261+
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
261262
"""Mock the manifests method to use the snapshot_id for lookup."""
262263
snapshot_id = self.snapshot_id
263264
if snapshot_id in mock_manifests:
@@ -311,7 +312,7 @@ def test_validate_added_data_files_non_conflicting_count(
311312
oldest_snapshot = table.snapshots()[-snapshot_history]
312313
newest_snapshot = cast(Snapshot, table.current_snapshot())
313314

314-
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO) -> list[ManifestFile]:
315+
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
315316
"""Mock the manifests method to use the snapshot_id for lookup."""
316317
snapshot_id = self.snapshot_id
317318
if snapshot_id in mock_manifests:
@@ -384,7 +385,7 @@ def test_added_delete_files_non_conflicting_count(
384385
oldest_snapshot = table.snapshots()[-snapshot_history]
385386
newest_snapshot = cast(Snapshot, table.current_snapshot())
386387

387-
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO) -> list[ManifestFile]:
388+
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
388389
"""Mock the manifests method to use the snapshot_id for lookup."""
389390
snapshot_id = self.snapshot_id
390391
if snapshot_id in mock_manifests:
@@ -442,7 +443,7 @@ def test_added_delete_files_conflicting_count(
442443

443444
mock_delete_file.spec_id = 0
444445

445-
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO) -> list[ManifestFile]:
446+
def mock_read_manifest_side_effect(self: Snapshot, io: FileIO, table_uuid: uuid.UUID | None = None) -> list[ManifestFile]:
446447
"""Mock the manifests method to use the snapshot_id for lookup."""
447448
snapshot_id = self.snapshot_id
448449
if snapshot_id in mock_manifests:

0 commit comments

Comments
 (0)