Skip to content

Share unchanged models in update_table_metadata instead of deep-copying - #4303

Open
ASRodrigo1 wants to merge 1 commit into
apache:mainfrom
ASRodrigo1:perf/table-metadata-structural-sharing
Open

ASRodrigo1 wants to merge 1 commit into
apache:mainfrom
ASRodrigo1:perf/table-metadata-structural-sharing

Conversation

@ASRodrigo1

Copy link
Copy Markdown

Closes #4294

Rationale for this change

The problem

update_table_metadata ends in new_metadata.model_copy(deep=True). Every call therefore copies the entire table history: each snapshot with its summary, each snapshot log and metadata log entry, and each schema, partition spec and sort order. The cost of one call grows linearly with the number of snapshots the table keeps, regardless of how small the update is.

The function sits on the hot path of every write:

Each commit also adds a snapshot, so a table that receives frequent small commits pays a little more on each one. #4294 calls this quadratic in total.

How we ran into it

On a production table with a few thousand snapshots retained, a single fast_append commit took close to 10 seconds. Profiling the commit with cProfile and py-spy attributed 71% of its time to update_table_metadata → model_copy(deep=True) → copy.deepcopy, with about 4 million deepcopy calls per commit.

The cost tracks retention: in a reproduction with a larger history, the same commit took 18 s with 10,000 snapshots and 68 s with 40,000. A writer that commits frequently cannot keep up at that depth.

Why the deep copy exists, and why a shallow container copy is enough

The deep copy was introduced in #182 so that the metadata returned by update_table_metadata cannot change the base metadata. test_metadata_isolation_from_illegal_updates pins that guarantee. It appends to new_metadata.partition_specs and asserts that base_metadata.partition_specs is unchanged. As the test's own comment notes, Pydantic's frozen config cannot prevent mutations such as a list append.

That guarantee only requires the list and dict fields of TableMetadata to be distinct objects. The models they hold (Snapshot, Schema, PartitionSpec, SortOrder, SnapshotRef, the log entries) are themselves frozen, so they can be shared safely.

This is also how the Java implementation works. TableMetadata.Builder copies each collection (Lists.newArrayList(base.snapshots()), Maps.newHashMap(base.refs), and so on) and shares the immutable elements.

The handlers behind _apply_table_update were already required not to mutate their input in place: the first handler receives the base metadata itself, and the isolation test checks that the base is unchanged after legal updates. We audited all 23 handlers on main and found that each one builds new collections or returns its input unchanged. This change adds no new requirement on them.

The change

The final model_copy(deep=True) is replaced by _copy_containers, which copies every list and dict field and shares everything else:

def _copy_containers(metadata: TableMetadata) -> TableMetadata:
    return metadata.model_copy(update={name: value.copy() for name, value in metadata if isinstance(value, (list, dict))})

There is no cache and no change to the API, and the enforce_validation path is untouched. Fixing the function rather than its callers makes every path cheap: reads with or without staged updates, and the commit inside each catalog.

Relation to earlier work

Approach Status What it leaves
#2674 Do not copy the metadata for each data file in the snapshot summary (fixes #2673) Merged The other reads still deep-copy
#3301 Hoist repeated reads of table_metadata in snapshot.py Merged, in 0.12.0 About 20 reads per append remain, each a full deep copy
#3302, #3784 Cache Transaction.table_metadata between reads Both closed by the stale bot. On #3302, reviewers raised the complexity of a cache. The catalog commit still deep-copies, and so does every new transaction state
#4302 Return the table's metadata when nothing is staged, as suggested in the #3302 review Open Reads while updates are staged (CreateTableTransaction, set_properties before an append), and the catalog commit
This PR Remove the cost from update_table_metadata itself Parsing and serializing the metadata JSON, which every commit needs anyway

This PR does not conflict with #4302. With both applied, #4302 adds a small further gain (table below).

Measurements

One append of one row to a table registered in a SqlCatalog on SQLite, with a local warehouse and no network. Each time is the median of 3 appends. To set the depth, the metadata was rewritten to keep N snapshots that point at the table's real manifest list. All four variants ran on the same machine against the same code: main restores the deep copy, and #4302 applies its change to Transaction.table_metadata.

Snapshots Metadata JSON main #4302 This PR Both
1,000 0.6 MB 470 ms 97 ms 27 ms 26 ms
3,500 2.3 MB 1,930 ms 204 ms 74 ms 70 ms
10,000 6.5 MB 6,591 ms 748 ms 182 ms 188 ms
40,000 25.9 MB 30,589 ms 4,081 ms 1,230 ms 1,052 ms

update_table_metadata alone, adding one snapshot (median of 5):

Snapshots Before After
1,000 16.9 ms 0.13 ms
3,500 62.6 ms 0.36 ms
10,000 182.7 ms 0.91 ms
40,000 1,151.7 ms 10.5 ms

What remains at 40,000 snapshots is mostly reading and writing the 26 MB metadata file, which no change to this function can avoid.

Are these changes tested?

Yes.

  • New test. test_update_metadata_shares_unchanged_models checks three things after an update: the list and dict fields of the result are new objects, the snapshots and schemas are the same objects as in the base, and the base properties are unchanged. It fails on main and passes with this change.
  • Existing isolation test. test_metadata_isolation_from_illegal_updates passes unchanged.
  • Unit suite. pytest tests/ -m unit --ignore=tests/integration on Python 3.13: 4,521 passed, 5 skipped, 4 failed. The 4 failures are the Kerberos tests in tests/catalog/test_hive.py, which fail with No module named 'puresasl' because the hive-kerberos extra was not installed. They fail the same way on main (1c800eb) without this change.
  • Lint. ruff check and ruff format are clean. mypy reports no new errors in the changed module.

Are there any user-facing changes?

The public API does not change, and commits produce the same metadata as before.

One behavior changes. The models inside the returned metadata are now shared with the base metadata, so mutating one of them in place also changes the base. An example is writing into a snapshot's summary with metadata.snapshots[0].summary[...] = .... These models are frozen and are not meant to be mutated after construction. Appending to or assigning into the metadata's own lists and dicts remains isolated, as before.

🤖 Generated with Claude Code

update_table_metadata ended in model_copy(deep=True), so every call copied the
whole table history (snapshots, logs, schemas) and its cost grew with the number
of snapshots the table keeps. Transaction.table_metadata calls it on every read
and catalogs call it once more on commit, which made one append to a table with
10,000 snapshots take seconds.

Copy only the list and dict fields of the metadata and share the frozen models
they hold, like TableMetadata.Builder in the Java implementation. The base
metadata stays isolated from a list append or dict assignment on the result.

Closes apache#4294

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant