Skip to content

Commit 36f5b56

Browse files
moomindaniclaude
andcommitted
Support writing V3 manifests and manifest lists
Add ManifestWriterV3 and ManifestListWriterV3. The manifest writer uses the V3 record layout so the V3-only data file fields (first_row_id, referenced_data_file, content_offset, content_size_in_bytes) are written, rebinding V2-layout records when needed. The manifest list writer implements the spec's First Row ID Assignment: preserving existing first_row_id values, never assigning one to delete manifests, and assigning a running value advanced by existing and added row counts to unassigned data manifests. It also exposes next_row_id for the commit path to update the table's next-row-id. Closes #3620 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ec1413d commit 36f5b56

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

pyiceberg/manifest.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,17 @@ def partitions(self) -> list[PartitionFieldSummary] | None:
852852
def key_metadata(self) -> bytes | None:
853853
return self._data[14]
854854

855+
@property
856+
def first_row_id(self) -> int | None:
857+
# Only present when the record is bound to the V3 manifest list schema
858+
return self._data[15] if len(self._data) > 15 else None
859+
860+
@first_row_id.setter
861+
def first_row_id(self, value: int | None) -> None:
862+
if len(self._data) <= 15:
863+
raise ValueError("Cannot set first_row_id on a manifest file not bound to the V3 schema")
864+
self._data[15] = value
865+
855866
def has_added_files(self) -> bool:
856867
return self.added_files_count is None or self.added_files_count > 0
857868

@@ -1240,6 +1251,51 @@ def prepare_entry(self, entry: ManifestEntry) -> ManifestEntry:
12401251
return entry
12411252

12421253

1254+
class ManifestWriterV3(ManifestWriterV2):
1255+
"""Writes V3 manifest files.
1256+
1257+
The writer inherits the V2 sequence-number semantics; the V3 manifest entry
1258+
schema additionally carries `first_row_id`, `referenced_data_file`,
1259+
`content_offset` and `content_size_in_bytes` on the data file struct.
1260+
"""
1261+
1262+
@property
1263+
def version(self) -> TableVersion:
1264+
return 3
1265+
1266+
def new_writer(self) -> AvroOutputFile[ManifestEntry]:
1267+
# Use the V3 record layout so the V3-only data file fields are written
1268+
return AvroOutputFile[ManifestEntry](
1269+
output_file=self._output_file,
1270+
file_schema=self._with_partition(3),
1271+
record_schema=self._with_partition(3),
1272+
schema_name="manifest_entry",
1273+
metadata=self._meta,
1274+
)
1275+
1276+
def _wrap_data_file(self, data_file: DataFile) -> DataFile:
1277+
"""Rebind a data file to the V3 record layout."""
1278+
if len(data_file._data) >= len(DATA_FILE_TYPE[3].fields):
1279+
return data_file
1280+
args = {
1281+
field.name: value for field, value in zip(DATA_FILE_TYPE[DEFAULT_READ_VERSION].fields, data_file._data, strict=True)
1282+
}
1283+
return DataFile.from_args(_table_format_version=3, **args)
1284+
1285+
def prepare_entry(self, entry: ManifestEntry) -> ManifestEntry:
1286+
entry = super().prepare_entry(entry)
1287+
if len(entry.data_file._data) < len(DATA_FILE_TYPE[3].fields):
1288+
entry = ManifestEntry.from_args(
1289+
_table_format_version=3,
1290+
status=entry.status,
1291+
snapshot_id=entry.snapshot_id,
1292+
sequence_number=entry.sequence_number,
1293+
file_sequence_number=entry.file_sequence_number,
1294+
data_file=self._wrap_data_file(entry.data_file),
1295+
)
1296+
return entry
1297+
1298+
12431299
def write_manifest(
12441300
format_version: TableVersion,
12451301
spec: PartitionSpec,
@@ -1252,6 +1308,8 @@ def write_manifest(
12521308
return ManifestWriterV1(spec, schema, output_file, snapshot_id, avro_compression)
12531309
elif format_version == 2:
12541310
return ManifestWriterV2(spec, schema, output_file, snapshot_id, avro_compression)
1311+
elif format_version == 3:
1312+
return ManifestWriterV3(spec, schema, output_file, snapshot_id, avro_compression)
12551313
else:
12561314
raise ValueError(f"Cannot write manifest for table version: {format_version}")
12571315

@@ -1375,19 +1433,91 @@ def prepare_manifest(self, manifest_file: ManifestFile) -> ManifestFile:
13751433
return wrapped_manifest_file
13761434

13771435

1436+
class ManifestListWriterV3(ManifestListWriterV2):
1437+
"""Writes V3 manifest lists, assigning `first_row_id` to data manifests.
1438+
1439+
Follows the spec's First Row ID Assignment: existing `first_row_id` values are
1440+
preserved, delete manifests are never assigned one, and data manifests without a
1441+
`first_row_id` are assigned a running value starting at the snapshot's
1442+
`first-row-id`, advanced by each assigned manifest's existing and added row counts.
1443+
"""
1444+
1445+
_next_row_id: int
1446+
1447+
def __init__(
1448+
self,
1449+
output_file: OutputFile,
1450+
snapshot_id: int,
1451+
parent_snapshot_id: int | None,
1452+
sequence_number: int,
1453+
compression: AvroCompressionCodec,
1454+
first_row_id: int,
1455+
):
1456+
super().__init__(output_file, snapshot_id, parent_snapshot_id, sequence_number, compression)
1457+
self._format_version = 3
1458+
self._meta = {
1459+
**self._meta,
1460+
"first-row-id": str(first_row_id),
1461+
"format-version": "3",
1462+
}
1463+
self._next_row_id = first_row_id
1464+
1465+
def __enter__(self) -> ManifestListWriter:
1466+
"""Open the writer for writing, using the V3 record layout so `first_row_id` is written."""
1467+
self._writer = AvroOutputFile[ManifestFile](
1468+
output_file=self._output_file,
1469+
record_schema=MANIFEST_LIST_FILE_SCHEMAS[3],
1470+
file_schema=MANIFEST_LIST_FILE_SCHEMAS[3],
1471+
schema_name="manifest_file",
1472+
metadata=self._meta,
1473+
)
1474+
self._writer.__enter__()
1475+
return self
1476+
1477+
@property
1478+
def next_row_id(self) -> int:
1479+
"""The row ID after the last assigned one; the table's `next-row-id` after this snapshot."""
1480+
return self._next_row_id
1481+
1482+
def _wrap(self, manifest_file: ManifestFile) -> ManifestFile:
1483+
"""Rebind a manifest file to the V3 record layout."""
1484+
if len(manifest_file._data) >= len(MANIFEST_LIST_FILE_SCHEMAS[3].fields):
1485+
return copy(manifest_file)
1486+
args = {
1487+
field.name: value
1488+
for field, value in zip(MANIFEST_LIST_FILE_SCHEMAS[DEFAULT_READ_VERSION].fields, manifest_file._data, strict=True)
1489+
}
1490+
return ManifestFile.from_args(_table_format_version=3, **args)
1491+
1492+
def prepare_manifest(self, manifest_file: ManifestFile) -> ManifestFile:
1493+
wrapped_manifest_file = super().prepare_manifest(self._wrap(manifest_file))
1494+
1495+
if wrapped_manifest_file.content == ManifestContent.DATA and wrapped_manifest_file.first_row_id is None:
1496+
wrapped_manifest_file.first_row_id = self._next_row_id
1497+
self._next_row_id += (wrapped_manifest_file.existing_rows_count or 0) + (wrapped_manifest_file.added_rows_count or 0)
1498+
return wrapped_manifest_file
1499+
1500+
13781501
def write_manifest_list(
13791502
format_version: TableVersion,
13801503
output_file: OutputFile,
13811504
snapshot_id: int,
13821505
parent_snapshot_id: int | None,
13831506
sequence_number: int | None,
13841507
avro_compression: AvroCompressionCodec,
1508+
first_row_id: int | None = None,
13851509
) -> ManifestListWriter:
13861510
if format_version == 1:
13871511
return ManifestListWriterV1(output_file, snapshot_id, parent_snapshot_id, avro_compression)
13881512
elif format_version == 2:
13891513
if sequence_number is None:
13901514
raise ValueError(f"Sequence-number is required for V2 tables: {sequence_number}")
13911515
return ManifestListWriterV2(output_file, snapshot_id, parent_snapshot_id, sequence_number, avro_compression)
1516+
elif format_version == 3:
1517+
if sequence_number is None:
1518+
raise ValueError(f"Sequence-number is required for V3 tables: {sequence_number}")
1519+
if first_row_id is None:
1520+
raise ValueError(f"First-row-id is required for V3 tables: {first_row_id}")
1521+
return ManifestListWriterV3(output_file, snapshot_id, parent_snapshot_id, sequence_number, avro_compression, first_row_id)
13921522
else:
13931523
raise ValueError(f"Cannot write manifest list for table version: {format_version}")

tests/utils/test_manifest.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# under the License.
1717
# pylint: disable=redefined-outer-name,arguments-renamed,fixme
1818
from tempfile import TemporaryDirectory
19+
from typing import Any
1920

2021
import fastavro
2122
import pytest
@@ -973,3 +974,132 @@ def test_inherit_from_manifest_snapshot_id() -> None:
973974
assert result.snapshot_id == 3051729675574597004
974975
assert result.sequence_number == 1
975976
assert result.file_sequence_number == 1
977+
978+
979+
@pytest.mark.parametrize("compression", ["null", "deflate"])
980+
def test_write_manifest_v3(compression: AvroCompressionCodec) -> None:
981+
io = load_file_io()
982+
test_schema = Schema(NestedField(1, "foo", IntegerType(), False))
983+
984+
v3_data_file = DataFile.from_args(
985+
_table_format_version=3,
986+
content=DataFileContent.DATA,
987+
file_path="/data/file-v3.parquet",
988+
file_format=FileFormat.PARQUET,
989+
partition=Record(),
990+
record_count=100,
991+
file_size_in_bytes=1024,
992+
first_row_id=1000,
993+
)
994+
v2_data_file = DataFile.from_args(
995+
content=DataFileContent.DATA,
996+
file_path="/data/file-v2.parquet",
997+
file_format=FileFormat.PARQUET,
998+
partition=Record(),
999+
record_count=50,
1000+
file_size_in_bytes=512,
1001+
)
1002+
1003+
with TemporaryDirectory() as tmp_dir:
1004+
path = tmp_dir + "/manifest-v3.avro"
1005+
with write_manifest(
1006+
format_version=3,
1007+
spec=UNPARTITIONED_PARTITION_SPEC,
1008+
schema=test_schema,
1009+
output_file=io.new_output(path),
1010+
snapshot_id=25,
1011+
avro_compression=compression,
1012+
) as writer:
1013+
writer.add(ManifestEntry.from_args(status=ManifestEntryStatus.ADDED, snapshot_id=25, data_file=v3_data_file))
1014+
# a data file bound to the default (V2) layout is rebound to the V3 layout
1015+
writer.add(ManifestEntry.from_args(status=ManifestEntryStatus.ADDED, snapshot_id=25, data_file=v2_data_file))
1016+
1017+
_verify_metadata_with_fastavro(path, {"format-version": "3", "content": "data"})
1018+
1019+
with open(path, "rb") as f:
1020+
entries = list(fastavro.reader(f))
1021+
1022+
assert len(entries) == 2
1023+
assert entries[0]["data_file"]["first_row_id"] == 1000
1024+
assert entries[1]["data_file"]["first_row_id"] is None
1025+
for entry in entries:
1026+
for v3_field in ("first_row_id", "referenced_data_file", "content_offset", "content_size_in_bytes"):
1027+
assert v3_field in entry["data_file"]
1028+
1029+
1030+
@pytest.mark.parametrize("compression", ["null", "deflate"])
1031+
def test_write_manifest_list_v3_assigns_first_row_id(compression: AvroCompressionCodec) -> None:
1032+
io = load_file_io()
1033+
1034+
def manifest(path: str, content: ManifestContent, first_row_id: int | None = None, **counts: int) -> ManifestFile:
1035+
args: dict[str, Any] = {
1036+
"manifest_path": path,
1037+
"manifest_length": 100,
1038+
"partition_spec_id": 0,
1039+
"content": content,
1040+
"sequence_number": 1,
1041+
"min_sequence_number": 1,
1042+
"added_snapshot_id": 25,
1043+
"added_files_count": 1,
1044+
"existing_files_count": 1,
1045+
"deleted_files_count": 0,
1046+
"added_rows_count": counts.get("added", 0),
1047+
"existing_rows_count": counts.get("existing", 0),
1048+
"deleted_rows_count": 0,
1049+
}
1050+
if first_row_id is not None:
1051+
return ManifestFile.from_args(_table_format_version=3, first_row_id=first_row_id, **args)
1052+
# bound to the default (V2) layout to exercise rebinding in the writer
1053+
return ManifestFile.from_args(**args)
1054+
1055+
unassigned_data = manifest("/m1.avro", ManifestContent.DATA, added=100, existing=25)
1056+
preserved_data = manifest("/m2.avro", ManifestContent.DATA, first_row_id=77, added=10)
1057+
deletes = manifest("/m3.avro", ManifestContent.DELETES, added=10)
1058+
second_unassigned_data = manifest("/m4.avro", ManifestContent.DATA, added=5)
1059+
1060+
with TemporaryDirectory() as tmp_dir:
1061+
path = tmp_dir + "/manifest-list-v3.avro"
1062+
with write_manifest_list(
1063+
format_version=3,
1064+
output_file=io.new_output(path),
1065+
snapshot_id=25,
1066+
parent_snapshot_id=19,
1067+
sequence_number=2,
1068+
avro_compression=compression,
1069+
first_row_id=1000,
1070+
) as writer:
1071+
writer.add_manifests([unassigned_data, preserved_data, deletes, second_unassigned_data])
1072+
1073+
# 1000 + (100 + 25) + (5): assigned manifests advance by added + existing rows
1074+
assert writer.next_row_id == 1130 # type: ignore[attr-defined]
1075+
1076+
_verify_metadata_with_fastavro(
1077+
path,
1078+
{
1079+
"snapshot-id": "25",
1080+
"parent-snapshot-id": "19",
1081+
"sequence-number": "2",
1082+
"first-row-id": "1000",
1083+
"format-version": "3",
1084+
},
1085+
)
1086+
1087+
with open(path, "rb") as f:
1088+
records = list(fastavro.reader(f))
1089+
1090+
assert [r["first_row_id"] for r in records] == [1000, 77, None, 1125]
1091+
1092+
1093+
def test_write_manifest_list_v3_requires_first_row_id() -> None:
1094+
io = load_file_io()
1095+
with TemporaryDirectory() as tmp_dir:
1096+
with pytest.raises(ValueError, match="First-row-id is required for V3 tables"):
1097+
write_manifest_list(
1098+
format_version=3,
1099+
output_file=io.new_output(tmp_dir + "/manifest-list.avro"),
1100+
snapshot_id=25,
1101+
parent_snapshot_id=19,
1102+
sequence_number=2,
1103+
avro_compression="null",
1104+
first_row_id=None,
1105+
)

0 commit comments

Comments
 (0)