Skip to content

Commit a340d93

Browse files
committed
Align V3 manifest writers with the Java implementation
Carry an optional first_row_id through ManifestWriterV3 into the produced manifest file (used when rewriting manifests whose first_row_id is known), matching ManifestFiles.newWriter in Java. Expand tests for parity with TestManifestWriterVersions and TestManifestListVersions: reader compatibility of V3-written files, metrics field round-trips, null first_row_id when reading V2 manifest lists with the V3 layout, and preservation of writer-carried first_row_id without advancing next_row_id.
1 parent d161e78 commit a340d93

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

pyiceberg/manifest.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1301,12 +1301,40 @@ class ManifestWriterV3(ManifestWriterV2):
13011301
The writer inherits the V2 sequence-number semantics; the V3 manifest entry
13021302
schema additionally carries `first_row_id`, `referenced_data_file`,
13031303
`content_offset` and `content_size_in_bytes` on the data file struct.
1304+
1305+
An optional `first_row_id` can be provided when rewriting a manifest whose
1306+
`first_row_id` is already known; it is carried into the produced manifest file
1307+
so the manifest list writer preserves it instead of assigning a new one. For
1308+
new manifests it is None and assigned when writing the manifest list.
13041309
"""
13051310

1311+
_first_row_id: int | None
1312+
1313+
def __init__(
1314+
self,
1315+
spec: PartitionSpec,
1316+
schema: Schema,
1317+
output_file: OutputFile,
1318+
snapshot_id: int,
1319+
avro_compression: AvroCompressionCodec,
1320+
first_row_id: int | None = None,
1321+
):
1322+
super().__init__(spec, schema, output_file, snapshot_id, avro_compression)
1323+
self._first_row_id = first_row_id
1324+
13061325
@property
13071326
def version(self) -> TableVersion:
13081327
return 3
13091328

1329+
def to_manifest_file(self) -> ManifestFile:
1330+
"""Return the manifest file, bound to the V3 layout and carrying `first_row_id`."""
1331+
manifest_file = super().to_manifest_file()
1332+
args = {
1333+
field.name: value
1334+
for field, value in zip(MANIFEST_LIST_FILE_SCHEMAS[DEFAULT_READ_VERSION].fields, manifest_file._data, strict=True)
1335+
}
1336+
return ManifestFile.from_args(_table_format_version=3, first_row_id=self._first_row_id, **args)
1337+
13101338
def new_writer(self) -> AvroOutputFile[ManifestEntry]:
13111339
# Use the V3 record layout so the V3-only data file fields are written
13121340
return AvroOutputFile[ManifestEntry](
@@ -1347,13 +1375,16 @@ def write_manifest(
13471375
output_file: OutputFile,
13481376
snapshot_id: int,
13491377
avro_compression: AvroCompressionCodec,
1378+
first_row_id: int | None = None,
13501379
) -> ManifestWriter:
1380+
if first_row_id is not None and format_version < 3:
1381+
raise ValueError(f"First-row-id is only supported for V3 tables: {format_version}")
13511382
if format_version == 1:
13521383
return ManifestWriterV1(spec, schema, output_file, snapshot_id, avro_compression)
13531384
elif format_version == 2:
13541385
return ManifestWriterV2(spec, schema, output_file, snapshot_id, avro_compression)
13551386
elif format_version == 3:
1356-
return ManifestWriterV3(spec, schema, output_file, snapshot_id, avro_compression)
1387+
return ManifestWriterV3(spec, schema, output_file, snapshot_id, avro_compression, first_row_id)
13571388
else:
13581389
raise ValueError(f"Cannot write manifest for table version: {format_version}")
13591390

tests/utils/test_manifest.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,11 @@ def test_write_manifest_v3(compression: AvroCompressionCodec) -> None:
11611161
partition=Record(),
11621162
record_count=100,
11631163
file_size_in_bytes=1024,
1164+
column_sizes={1: 10},
1165+
value_counts={1: 100},
1166+
null_value_counts={1: 0},
1167+
split_offsets=[4],
1168+
sort_order_id=1,
11641169
first_row_id=1000,
11651170
)
11661171
v2_data_file = DataFile.from_args(
@@ -1197,6 +1202,19 @@ def test_write_manifest_v3(compression: AvroCompressionCodec) -> None:
11971202
for entry in entries:
11981203
for v3_field in ("first_row_id", "referenced_data_file", "content_offset", "content_size_in_bytes"):
11991204
assert v3_field in entry["data_file"]
1205+
assert entries[0]["data_file"]["sort_order_id"] == 1
1206+
assert entries[0]["data_file"]["split_offsets"] == [4]
1207+
1208+
# the V3 manifest must remain readable by the current reader
1209+
read_entries = writer.to_manifest_file().fetch_manifest_entry(io, discard_deleted=False)
1210+
assert len(read_entries) == 2
1211+
assert read_entries[0].status == ManifestEntryStatus.ADDED
1212+
assert read_entries[0].data_file.file_path == "/data/file-v3.parquet"
1213+
assert read_entries[0].data_file.record_count == 100
1214+
assert read_entries[0].data_file.column_sizes == {1: 10}
1215+
assert read_entries[0].data_file.value_counts == {1: 100}
1216+
assert read_entries[0].data_file.sort_order_id == 1
1217+
assert read_entries[1].data_file.file_path == "/data/file-v2.parquet"
12001218

12011219

12021220
@pytest.mark.parametrize("compression", ["null", "deflate"])
@@ -1261,6 +1279,13 @@ def manifest(path: str, content: ManifestContent, first_row_id: int | None = Non
12611279

12621280
assert [r["first_row_id"] for r in records] == [1000, 77, None, 1125]
12631281

1282+
# the V3 manifest list must remain readable by the current reader
1283+
read_back = list(read_manifest_list(io.new_input(path)))
1284+
assert [m.manifest_path for m in read_back] == ["/m1.avro", "/m2.avro", "/m3.avro", "/m4.avro"]
1285+
assert read_back[0].added_rows_count == 100
1286+
assert read_back[0].existing_rows_count == 25
1287+
assert read_back[2].content == ManifestContent.DELETES
1288+
12641289

12651290
def test_write_manifest_list_v3_requires_first_row_id() -> None:
12661291
io = load_file_io()
@@ -1275,3 +1300,112 @@ def test_write_manifest_list_v3_requires_first_row_id() -> None:
12751300
avro_compression="null",
12761301
first_row_id=None,
12771302
)
1303+
1304+
1305+
@pytest.mark.parametrize("compression", ["null", "deflate"])
1306+
def test_write_manifest_v3_carries_first_row_id(compression: AvroCompressionCodec) -> None:
1307+
io = load_file_io()
1308+
test_schema = Schema(NestedField(1, "foo", IntegerType(), False))
1309+
data_file = DataFile.from_args(
1310+
content=DataFileContent.DATA,
1311+
file_path="/data/file.parquet",
1312+
file_format=FileFormat.PARQUET,
1313+
partition=Record(),
1314+
record_count=100,
1315+
file_size_in_bytes=1024,
1316+
)
1317+
1318+
with TemporaryDirectory() as tmp_dir:
1319+
with write_manifest(
1320+
format_version=3,
1321+
spec=UNPARTITIONED_PARTITION_SPEC,
1322+
schema=test_schema,
1323+
output_file=io.new_output(tmp_dir + "/manifest-rewrite.avro"),
1324+
snapshot_id=25,
1325+
avro_compression=compression,
1326+
first_row_id=100,
1327+
) as writer:
1328+
writer.add(ManifestEntry.from_args(status=ManifestEntryStatus.ADDED, snapshot_id=25, data_file=data_file))
1329+
1330+
manifest_file = writer.to_manifest_file()
1331+
assert manifest_file.first_row_id == 100
1332+
1333+
# a manifest list writer must preserve the carried first_row_id and not advance next_row_id for it
1334+
path = tmp_dir + "/manifest-list.avro"
1335+
with write_manifest_list(
1336+
format_version=3,
1337+
output_file=io.new_output(path),
1338+
snapshot_id=25,
1339+
parent_snapshot_id=19,
1340+
sequence_number=2,
1341+
avro_compression=compression,
1342+
first_row_id=1000,
1343+
) as list_writer:
1344+
list_writer.add_manifests([manifest_file])
1345+
assert list_writer.next_row_id == 1000 # type: ignore[attr-defined]
1346+
1347+
with open(path, "rb") as f:
1348+
records = list(fastavro.reader(f))
1349+
assert [r["first_row_id"] for r in records] == [100]
1350+
1351+
1352+
def test_write_manifest_first_row_id_requires_v3() -> None:
1353+
io = load_file_io()
1354+
test_schema = Schema(NestedField(1, "foo", IntegerType(), False))
1355+
with TemporaryDirectory() as tmp_dir:
1356+
with pytest.raises(ValueError, match="First-row-id is only supported for V3 tables"):
1357+
write_manifest(
1358+
format_version=2,
1359+
spec=UNPARTITIONED_PARTITION_SPEC,
1360+
schema=test_schema,
1361+
output_file=io.new_output(tmp_dir + "/manifest.avro"),
1362+
snapshot_id=25,
1363+
avro_compression="null",
1364+
first_row_id=100,
1365+
)
1366+
1367+
1368+
def test_read_v2_manifest_list_with_v3_layout() -> None:
1369+
from pyiceberg.avro.file import AvroFile
1370+
from pyiceberg.manifest import MANIFEST_LIST_FILE_SCHEMAS
1371+
1372+
io = load_file_io()
1373+
manifest = ManifestFile.from_args(
1374+
manifest_path="/m1.avro",
1375+
manifest_length=100,
1376+
partition_spec_id=0,
1377+
content=ManifestContent.DATA,
1378+
sequence_number=1,
1379+
min_sequence_number=1,
1380+
added_snapshot_id=25,
1381+
added_files_count=1,
1382+
existing_files_count=0,
1383+
deleted_files_count=0,
1384+
added_rows_count=100,
1385+
existing_rows_count=0,
1386+
deleted_rows_count=0,
1387+
)
1388+
1389+
with TemporaryDirectory() as tmp_dir:
1390+
path = tmp_dir + "/manifest-list-v2.avro"
1391+
with write_manifest_list(
1392+
format_version=2,
1393+
output_file=io.new_output(path),
1394+
snapshot_id=25,
1395+
parent_snapshot_id=19,
1396+
sequence_number=2,
1397+
avro_compression="null",
1398+
) as writer:
1399+
writer.add_manifests([manifest])
1400+
1401+
# reading a V2 manifest list with the V3 layout yields a null first_row_id
1402+
with AvroFile[ManifestFile](
1403+
io.new_input(path),
1404+
MANIFEST_LIST_FILE_SCHEMAS[3],
1405+
read_types={-1: ManifestFile},
1406+
read_enums={517: ManifestContent},
1407+
) as reader:
1408+
entries = list(reader)
1409+
assert len(entries) == 1
1410+
assert entries[0].first_row_id is None
1411+
assert entries[0].manifest_path == "/m1.avro"

0 commit comments

Comments
 (0)