perf: cache Transaction.table_metadata between reads - #3784
Open
devseunggwan wants to merge 3 commits into
Open
Conversation
`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 apache#3302 by Ruiyang Wang, which was approved and then closed by the stale bot. That PR predates apache#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>
Six lines of rationale was the only multi-line comment in the file outside the license header; the surrounding style is single-line. The trade-off it described is in the PR description.
The guard asserted that _MergeAppendFiles.__init__ triggers exactly one more update_table_metadata call than its superclass. Recompute count is a proxy for read count that the cache breaks: repeated reads of an unchanged state collapse to one recompute, so an un-hoisting became invisible and the assertion had to be relaxed to an upper bound. Counting property reads directly restores the original assertion and makes the guard orthogonal to caching. Verified both ways: it passes with and without the cache, and un-hoisting __init__ back to three separate reads fails it (8 - 5). Confidence: high Not-tested: only the _MergeAppendFiles path was mutation-probed; the _summary() assertions were left as-is beyond the oracle swap.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rationale for this change
Transaction.table_metadatareplays every staged update throughupdate_table_metadata, which ends inmodel_copy(deep=True). So a single read deep-copies the whole metadata object, snapshot list included, and its cost tracks table history rather than the work being done. Callers read the property many times per operation.#2674 and #3301 hoisted repeated reads out of loops in
snapshot.py. This goes after the same cost at the source: repeated reads of an unchanged transaction state now recompute once.This carries forward #3302 by @rynewang, which @Fokko approved back in April and which the stale bot then closed for inactivity. Its branch is on a fork with
maintainerCanModifyoff so it can't be reopened from outside, hence a new PR; authorship is kept viaCo-authored-by.Numbers
We hit this in production. A writer that appends 1–6 rows at a time went from a mean of 2495 ms to 10302 ms when we upgraded 0.9.1 → 0.11.1 — same payloads, but the tables it writes to have long snapshot histories. With this cache it came back to 2856 ms. (10-minute bucket means over matched windows, n = 41–75 per bucket.)
Isolated against a local
SqlCatalog, 20 timed appends per depth, 3 runs with the arms alternated, median:update_table_metadatacalls per append drop from 22 to 2 at every depth, and the resulting table is identical (rows, snapshots, schema). The multiplier grows with depth because each remaining recompute still copies a longer snapshot list — not because the cache does more work on deep histories. For the same reason the curve doesn't flatten: 4.3 ms at depth 0 against 36.2 ms at depth 500.On the simpler alternative from #3302
@geruh suggested
if not self._updates: return self._table.metadatarather than a cache. That covers a bare append but misses the expensive case —CreateTableTransaction._initial_changes()seeds_updateswith ~10 entries before any write, so it's never empty for the snapshot producer's lifetime. There's a test for that case now (test_transaction_table_metadata_cached_with_updates_already_staged) so it doesn't have to rest on an argument.Concurrency
The property does get read from worker threads —
_SnapshotProducer._manifests()submits_write_added_manifestand_write_delete_manifestto the shared executor, and both reach it. A race there is harmless: two threads can both miss and both compute, the loser's result is dropped, and the entry is an immutable tuple assigned in a single store, so there's no half-built state to observe. If anything this narrows an existing gap, since without the cache each thread re-stampslast_updated_mson its own and concurrent readers can already see metadata that differs.Are these changes tested?
Two new tests in
tests/table/test_init.py, covering repeated reads and the already-staged case above. Both fail with the property reverted — I checked, rather than assuming they discriminate.One existing test needed rework.
test_snapshot_producer_bounded_metadata_accessfrom #3301 asserts that_MergeAppendFiles.__init__makes exactly one moreupdate_table_metadatacall than its superclass. That count is a proxy for how many times the property is read, and the cache breaks the proxy — repeated reads collapse into one recompute, so an un-hoisting would slip through unnoticed.Rather than loosen the assertion I switched the oracle to count property reads directly, which is what hoisting actually removes and which the cache doesn't affect. The original
== 1stands. It passes with the cache and againstmainwithout it, and putting__init__back to three separate reads fails it.Locally:
make testpasses (3931 passed, 3 skipped),ruff checkis clean, and mypy reports nothing onpyiceberg/table/__init__.pythat it doesn't already report onmain.Are there any user-facing changes?
One:
last_updated_mson the returned metadata is now stable across repeated reads of the same logical state instead of being re-stamped withnow()on every access. The timestamp written at commit time is unchanged. Nothing reads it expecting a fresh value per access — outsidetable/metadata.pyand the commit path, the only consumer iscli/output.py, which prints it.