Skip to content

Commit e8d4828

Browse files
committed
Address review: skip the commit when no manifests need merging
Committing a replace snapshot that merely re-lists the same manifests has no value; return no updates instead, and assert in the no-op test that the current snapshot is unchanged. Document that rewrites carry live entries only, matching the reference implementation, and add the Spark interop integration test (data, snapshot history, file and manifest counts, and pre-rewrite time travel verified from Spark).
1 parent d5122f8 commit e8d4828

3 files changed

Lines changed: 103 additions & 15 deletions

File tree

pyiceberg/table/update/snapshot.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,10 +1138,14 @@ class RewriteManifests(_SnapshotProducer["RewriteManifests"]):
11381138
11391139
Live entries from the rewritten data manifests are regrouped into new
11401140
manifests sized by `commit.manifest.target-size-bytes`, written as EXISTING
1141-
entries that keep their sequence numbers. Delete manifests are kept as-is.
1142-
The result is committed as a `replace` snapshot.
1141+
entries that keep their sequence numbers. Entries with status DELETED are
1142+
dropped, matching the reference implementation, which rewrites live entries
1143+
only. Delete manifests are kept as-is. The result is committed as a
1144+
`replace` snapshot; if no manifests need merging, no snapshot is committed.
11431145
"""
11441146

1147+
_computed_manifests: list[ManifestFile] | None
1148+
11451149
_rewritten_count: int
11461150
_created_count: int
11471151
_kept_count: int
@@ -1166,6 +1170,7 @@ def __init__(
11661170
self._created_count = 0
11671171
self._kept_count = 0
11681172
self._entries_processed = 0
1173+
self._computed_manifests = None
11691174

11701175
def _deleted_entries(self) -> list[ManifestEntry]:
11711176
return []
@@ -1197,9 +1202,13 @@ def _group_by_target_size(self, manifests: list[ManifestFile]) -> list[list[Mani
11971202
return groups
11981203

11991204
def _existing_manifests(self) -> list[ManifestFile]:
1205+
if self._computed_manifests is not None:
1206+
return self._computed_manifests
1207+
12001208
snapshot = self._transaction.table_metadata.snapshot_by_name(self._target_branch or MAIN_BRANCH)
12011209
if snapshot is None:
1202-
return []
1210+
self._computed_manifests = []
1211+
return self._computed_manifests
12031212

12041213
data_manifests_by_spec: defaultdict[int, list[ManifestFile]] = defaultdict(list)
12051214
kept_manifests: list[ManifestFile] = []
@@ -1233,7 +1242,15 @@ def _existing_manifests(self) -> list[ManifestFile]:
12331242
"manifests-replaced": str(self._rewritten_count),
12341243
"entries-processed": str(self._entries_processed),
12351244
}
1236-
return new_manifests + kept_manifests
1245+
self._computed_manifests = new_manifests + kept_manifests
1246+
return self._computed_manifests
1247+
1248+
def _commit(self) -> UpdatesAndRequirements:
1249+
self._existing_manifests()
1250+
if self._created_count == 0:
1251+
# nothing was merged; committing would only produce a pointless replace snapshot
1252+
return (), ()
1253+
return super()._commit()
12371254

12381255
def rewrites_needed(self) -> bool:
12391256
"""Return whether the current snapshot has more than one data manifest to merge."""
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
# pylint:disable=redefined-outer-name
18+
from typing import TYPE_CHECKING
19+
20+
import pyarrow as pa
21+
import pytest
22+
23+
from pyiceberg.catalog import Catalog
24+
from pyiceberg.exceptions import NoSuchTableError
25+
from pyiceberg.manifest import ManifestContent
26+
27+
if TYPE_CHECKING:
28+
from pyspark.sql import SparkSession
29+
30+
31+
@pytest.mark.integration
32+
def test_spark_reads_table_after_rewrite_manifests(session_catalog: Catalog, spark: "SparkSession") -> None:
33+
identifier = "default.test_rewrite_manifests_interop"
34+
try:
35+
session_catalog.drop_table(identifier)
36+
except NoSuchTableError:
37+
pass
38+
39+
table = session_catalog.create_table(identifier, schema=pa.schema([pa.field("id", pa.int64())]))
40+
for i in range(3):
41+
table.append(pa.table({"id": pa.array([i * 3 + 1, i * 3 + 2, i * 3 + 3], type=pa.int64())}))
42+
43+
table = session_catalog.load_table(identifier)
44+
snapshot = table.current_snapshot()
45+
assert snapshot is not None
46+
assert len([m for m in snapshot.manifests(table.io) if m.content == ManifestContent.DATA]) == 3
47+
48+
table.maintenance.rewrite_manifests().commit()
49+
50+
table = session_catalog.load_table(identifier)
51+
snapshot = table.current_snapshot()
52+
assert snapshot is not None
53+
assert len([m for m in snapshot.manifests(table.io) if m.content == ManifestContent.DATA]) == 1
54+
55+
# Spark must read the rewritten table with the same data
56+
spark_rows = spark.table(f"integration.{identifier}").collect()
57+
assert sorted(row.id for row in spark_rows) == list(range(1, 10))
58+
59+
# Spark must see the replace snapshot and the preserved data files
60+
snapshots = spark.sql(f"SELECT operation FROM integration.{identifier}.snapshots ORDER BY committed_at").collect()
61+
assert [row.operation for row in snapshots] == ["append", "append", "append", "replace"]
62+
63+
files = spark.sql(f"SELECT file_path FROM integration.{identifier}.files").collect()
64+
assert len(files) == 3
65+
66+
# Spark sees the same manifest consolidation
67+
manifests = spark.sql(f"SELECT path FROM integration.{identifier}.manifests").collect()
68+
assert len(manifests) == 1
69+
70+
# time travel to the pre-rewrite snapshot still works from Spark
71+
previous_snapshot_id = snapshot.parent_snapshot_id
72+
assert previous_snapshot_id is not None
73+
previous_rows = spark.sql(f"SELECT id FROM integration.{identifier} VERSION AS OF {previous_snapshot_id}").collect()
74+
assert sorted(row.id for row in previous_rows) == list(range(1, 10))

tests/table/test_rewrite_manifests.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from pyiceberg.catalog import Catalog
2323
from pyiceberg.catalog.memory import InMemoryCatalog
24-
from pyiceberg.manifest import ManifestContent
24+
from pyiceberg.manifest import ManifestContent, ManifestFile
2525
from pyiceberg.table import Table
2626
from pyiceberg.table.snapshots import Operation
2727

@@ -44,7 +44,7 @@ def _create_table_with_appends(catalog: Catalog, appends: int = 3) -> Table:
4444
return table
4545

4646

47-
def _data_manifests(table: Table) -> list:
47+
def _data_manifests(table: Table) -> list[ManifestFile]:
4848
snapshot = table.current_snapshot()
4949
assert snapshot is not None
5050
return [m for m in snapshot.manifests(table.io) if m.content == ManifestContent.DATA]
@@ -101,23 +101,20 @@ def test_rewrite_manifests_preserves_sequence_numbers(catalog: Catalog) -> None:
101101
assert manifests[0].min_sequence_number == min(entries_before.values())
102102

103103

104-
def test_rewrite_manifests_single_manifest_is_noop_kept(catalog: Catalog) -> None:
104+
def test_rewrite_manifests_single_manifest_is_noop(catalog: Catalog) -> None:
105105
table = _create_table_with_appends(catalog, appends=1)
106+
snapshot_before = table.current_snapshot()
107+
assert snapshot_before is not None
106108
manifest_path_before = _data_manifests(table)[0].manifest_path
107109

108110
table.maintenance.rewrite_manifests().commit()
109111

110112
table = catalog.load_table("default.test_rewrite")
111-
manifests = _data_manifests(table)
112-
assert len(manifests) == 1
113-
# a single manifest is kept as-is, not rewritten
114-
assert manifests[0].manifest_path == manifest_path_before
115-
113+
# nothing to merge: no new snapshot is committed and the manifest is untouched
116114
snapshot = table.current_snapshot()
117115
assert snapshot is not None
118-
assert snapshot.summary is not None
119-
assert snapshot.summary["manifests-created"] == "0"
120-
assert snapshot.summary["manifests-replaced"] == "0"
116+
assert snapshot.snapshot_id == snapshot_before.snapshot_id
117+
assert _data_manifests(table)[0].manifest_path == manifest_path_before
121118

122119

123120
def test_rewrite_manifests_respects_target_size(catalog: Catalog) -> None:

0 commit comments

Comments
 (0)