Skip to content

Commit 40a20c8

Browse files
perf: cache Transaction.table_metadata between reads
`Transaction.table_metadata` replays every staged update through `update_table_metadata`, whose last step is `model_copy(deep=True)`. The cost of a single read therefore scales with the size of the metadata -- the snapshot list in particular -- and callers read the property many times per operation. Cache the result keyed on the identity of its two inputs. `_updates` is a tuple, so every `+=` rebinds it to a new object, and `Table.metadata` is replaced wholesale on refresh and commit; identity equality on both is therefore sufficient for invalidation without any explicit cache-clearing at mutation sites. Carries forward #3302 by Ruiyang Wang, which was approved and then closed by the stale bot. That PR predates #3301, whose `test_snapshot_producer_bounded_metadata_access` pins the hoisted access count with an equality assertion; the cache absorbs that access too, so the assertion is relaxed to an upper bound. Co-authored-by: Ruiyang Wang <rynewang@users.noreply.github.com>
1 parent ddd8309 commit 40a20c8

3 files changed

Lines changed: 72 additions & 3 deletions

File tree

pyiceberg/table/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ class Transaction:
241241
_autocommit: bool
242242
_updates: tuple[TableUpdate, ...]
243243
_requirements: tuple[TableRequirement, ...]
244+
_table_metadata_cache: tuple[TableMetadata, tuple[TableUpdate, ...], TableMetadata] | None
244245

245246
def __init__(self, table: Table, autocommit: bool = False):
246247
"""Open a transaction to stage and commit changes to a table.
@@ -255,10 +256,21 @@ def __init__(self, table: Table, autocommit: bool = False):
255256
self._requirements = ()
256257
self._snapshot_producers: list[_SnapshotProducer[Any]] = []
257258
self._failed = False
259+
self._table_metadata_cache = None
258260

259261
@property
260262
def table_metadata(self) -> TableMetadata:
261-
return update_table_metadata(self._table.metadata, self._updates)
263+
base, updates = self._table.metadata, self._updates
264+
# update_table_metadata replays every staged update via model_copy(deep=True);
265+
# the cache is keyed on the identity of its inputs so it self-invalidates
266+
# whenever _updates is reassigned (tuple += creates a new object) or the
267+
# underlying table metadata is refreshed.
268+
cached = self._table_metadata_cache
269+
if cached is not None and cached[0] is base and cached[1] is updates:
270+
return cached[2]
271+
result = update_table_metadata(base, updates)
272+
self._table_metadata_cache = (base, updates, result)
273+
return result
262274

263275
def __enter__(self) -> Transaction:
264276
"""Start a transaction to update the table."""

tests/table/test_init.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,3 +2010,54 @@ def _spy(*args: Any, **kwargs: Any) -> FileIO:
20102010

20112011
assert seen_locations, "expected at least one load_file_io call"
20122012
assert all(loc is not None for loc in seen_locations), f"load_file_io called without a location: {seen_locations}"
2013+
2014+
2015+
def test_transaction_table_metadata_cached(table_v2: Table) -> None:
2016+
"""Repeated reads of an unchanged transaction state recompute at most once.
2017+
2018+
`Transaction.table_metadata` replays every staged update through
2019+
`model_copy(deep=True)`, so the cost of a read scales with the size of the
2020+
metadata (the snapshot list in particular), not with the work being done.
2021+
"""
2022+
from unittest import mock
2023+
2024+
from pyiceberg.table.update import SetPropertiesUpdate, update_table_metadata
2025+
2026+
with mock.patch("pyiceberg.table.update_table_metadata", wraps=update_table_metadata) as spy:
2027+
txn = table_v2.transaction()
2028+
2029+
first = txn.table_metadata
2030+
for _ in range(10):
2031+
assert txn.table_metadata is first
2032+
assert spy.call_count == 1, f"expected 1 recompute for repeated reads, got {spy.call_count}"
2033+
2034+
txn._stage((SetPropertiesUpdate(updates={"k": "v"}),))
2035+
second = txn.table_metadata
2036+
assert second is not first
2037+
assert second.properties["k"] == "v"
2038+
for _ in range(10):
2039+
assert txn.table_metadata is second
2040+
assert spy.call_count == 2, f"expected 2 recomputes after one staged update, got {spy.call_count}"
2041+
2042+
2043+
def test_transaction_table_metadata_cached_with_updates_already_staged(table_v2: Table) -> None:
2044+
"""The cache must still hold once `_updates` is non-empty.
2045+
2046+
An empty-`_updates` short circuit (`return self._table.metadata` when nothing
2047+
is staged) would leave this case uncovered, and it is the expensive one:
2048+
`CreateTableTransaction` seeds `_updates` with ~10 entries before any write,
2049+
so a create-then-append transaction replays all of them on every read.
2050+
"""
2051+
from unittest import mock
2052+
2053+
from pyiceberg.table.update import SetPropertiesUpdate, update_table_metadata
2054+
2055+
txn = table_v2.transaction()
2056+
txn._stage((SetPropertiesUpdate(updates={"staged": "before"}),))
2057+
2058+
with mock.patch("pyiceberg.table.update_table_metadata", wraps=update_table_metadata) as spy:
2059+
first = txn.table_metadata
2060+
for _ in range(10):
2061+
assert txn.table_metadata is first
2062+
assert first.properties["staged"] == "before"
2063+
assert spy.call_count == 1, f"expected 1 recompute with updates already staged, got {spy.call_count}"

tests/table/test_snapshots.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,13 @@ def summary_calls(n_files: int) -> int:
645645
spy.reset_mock()
646646
_MergeAppendFiles(operation=Operation.APPEND, transaction=txn, io=table_v2.io)
647647
merge_init = spy.call_count
648-
assert merge_init - fast_init == 1, (
648+
# Upper bound, not equality: `Transaction.table_metadata` caches on the identity of
649+
# its inputs, so the second construction reads the same staged state and adds 0 calls.
650+
# The trade-off is that this assertion no longer catches an un-hoisting of
651+
# `_MergeAppendFiles.__init__` on its own — repeated reads of an unchanged state are
652+
# free either way. What it still pins is that constructing the producer cannot start
653+
# replaying updates per access again.
654+
assert merge_init - fast_init <= 1, (
649655
f"_MergeAppendFiles.__init__ made {merge_init - fast_init} extra update_table_metadata "
650-
"calls over its superclass; expected 1 (hoisted)"
656+
"calls over its superclass; expected at most 1"
651657
)

0 commit comments

Comments
 (0)