Skip to content

Commit d161e78

Browse files
committed
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
1 parent 1a87aa3 commit d161e78

2 files changed

Lines changed: 259 additions & 0 deletions

File tree

pyiceberg/manifest.py

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

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

@@ -1284,6 +1295,51 @@ def prepare_entry(self, entry: ManifestEntry) -> ManifestEntry:
12841295
return entry
12851296

12861297

1298+
class ManifestWriterV3(ManifestWriterV2):
1299+
"""Writes V3 manifest files.
1300+
1301+
The writer inherits the V2 sequence-number semantics; the V3 manifest entry
1302+
schema additionally carries `first_row_id`, `referenced_data_file`,
1303+
`content_offset` and `content_size_in_bytes` on the data file struct.
1304+
"""
1305+
1306+
@property
1307+
def version(self) -> TableVersion:
1308+
return 3
1309+
1310+
def new_writer(self) -> AvroOutputFile[ManifestEntry]:
1311+
# Use the V3 record layout so the V3-only data file fields are written
1312+
return AvroOutputFile[ManifestEntry](
1313+
output_file=self._output_file,
1314+
file_schema=self._with_partition(3),
1315+
record_schema=self._with_partition(3),
1316+
schema_name="manifest_entry",
1317+
metadata=self._meta,
1318+
)
1319+
1320+
def _wrap_data_file(self, data_file: DataFile) -> DataFile:
1321+
"""Rebind a data file to the V3 record layout."""
1322+
if len(data_file._data) >= len(DATA_FILE_TYPE[3].fields):
1323+
return data_file
1324+
args = {
1325+
field.name: value for field, value in zip(DATA_FILE_TYPE[DEFAULT_READ_VERSION].fields, data_file._data, strict=True)
1326+
}
1327+
return DataFile.from_args(_table_format_version=3, **args)
1328+
1329+
def prepare_entry(self, entry: ManifestEntry) -> ManifestEntry:
1330+
entry = super().prepare_entry(entry)
1331+
if len(entry.data_file._data) < len(DATA_FILE_TYPE[3].fields):
1332+
entry = ManifestEntry.from_args(
1333+
_table_format_version=3,
1334+
status=entry.status,
1335+
snapshot_id=entry.snapshot_id,
1336+
sequence_number=entry.sequence_number,
1337+
file_sequence_number=entry.file_sequence_number,
1338+
data_file=self._wrap_data_file(entry.data_file),
1339+
)
1340+
return entry
1341+
1342+
12871343
def write_manifest(
12881344
format_version: TableVersion,
12891345
spec: PartitionSpec,
@@ -1296,6 +1352,8 @@ def write_manifest(
12961352
return ManifestWriterV1(spec, schema, output_file, snapshot_id, avro_compression)
12971353
elif format_version == 2:
12981354
return ManifestWriterV2(spec, schema, output_file, snapshot_id, avro_compression)
1355+
elif format_version == 3:
1356+
return ManifestWriterV3(spec, schema, output_file, snapshot_id, avro_compression)
12991357
else:
13001358
raise ValueError(f"Cannot write manifest for table version: {format_version}")
13011359

@@ -1419,19 +1477,91 @@ def prepare_manifest(self, manifest_file: ManifestFile) -> ManifestFile:
14191477
return wrapped_manifest_file
14201478

14211479

1480+
class ManifestListWriterV3(ManifestListWriterV2):
1481+
"""Writes V3 manifest lists, assigning `first_row_id` to data manifests.
1482+
1483+
Follows the spec's First Row ID Assignment: existing `first_row_id` values are
1484+
preserved, delete manifests are never assigned one, and data manifests without a
1485+
`first_row_id` are assigned a running value starting at the snapshot's
1486+
`first-row-id`, advanced by each assigned manifest's existing and added row counts.
1487+
"""
1488+
1489+
_next_row_id: int
1490+
1491+
def __init__(
1492+
self,
1493+
output_file: OutputFile,
1494+
snapshot_id: int,
1495+
parent_snapshot_id: int | None,
1496+
sequence_number: int,
1497+
compression: AvroCompressionCodec,
1498+
first_row_id: int,
1499+
):
1500+
super().__init__(output_file, snapshot_id, parent_snapshot_id, sequence_number, compression)
1501+
self._format_version = 3
1502+
self._meta = {
1503+
**self._meta,
1504+
"first-row-id": str(first_row_id),
1505+
"format-version": "3",
1506+
}
1507+
self._next_row_id = first_row_id
1508+
1509+
def __enter__(self) -> ManifestListWriter:
1510+
"""Open the writer for writing, using the V3 record layout so `first_row_id` is written."""
1511+
self._writer = AvroOutputFile[ManifestFile](
1512+
output_file=self._output_file,
1513+
record_schema=MANIFEST_LIST_FILE_SCHEMAS[3],
1514+
file_schema=MANIFEST_LIST_FILE_SCHEMAS[3],
1515+
schema_name="manifest_file",
1516+
metadata=self._meta,
1517+
)
1518+
self._writer.__enter__()
1519+
return self
1520+
1521+
@property
1522+
def next_row_id(self) -> int:
1523+
"""The row ID after the last assigned one; the table's `next-row-id` after this snapshot."""
1524+
return self._next_row_id
1525+
1526+
def _wrap(self, manifest_file: ManifestFile) -> ManifestFile:
1527+
"""Rebind a manifest file to the V3 record layout."""
1528+
if len(manifest_file._data) >= len(MANIFEST_LIST_FILE_SCHEMAS[3].fields):
1529+
return copy(manifest_file)
1530+
args = {
1531+
field.name: value
1532+
for field, value in zip(MANIFEST_LIST_FILE_SCHEMAS[DEFAULT_READ_VERSION].fields, manifest_file._data, strict=True)
1533+
}
1534+
return ManifestFile.from_args(_table_format_version=3, **args)
1535+
1536+
def prepare_manifest(self, manifest_file: ManifestFile) -> ManifestFile:
1537+
wrapped_manifest_file = super().prepare_manifest(self._wrap(manifest_file))
1538+
1539+
if wrapped_manifest_file.content == ManifestContent.DATA and wrapped_manifest_file.first_row_id is None:
1540+
wrapped_manifest_file.first_row_id = self._next_row_id
1541+
self._next_row_id += (wrapped_manifest_file.existing_rows_count or 0) + (wrapped_manifest_file.added_rows_count or 0)
1542+
return wrapped_manifest_file
1543+
1544+
14221545
def write_manifest_list(
14231546
format_version: TableVersion,
14241547
output_file: OutputFile,
14251548
snapshot_id: int,
14261549
parent_snapshot_id: int | None,
14271550
sequence_number: int | None,
14281551
avro_compression: AvroCompressionCodec,
1552+
first_row_id: int | None = None,
14291553
) -> ManifestListWriter:
14301554
if format_version == 1:
14311555
return ManifestListWriterV1(output_file, snapshot_id, parent_snapshot_id, avro_compression)
14321556
elif format_version == 2:
14331557
if sequence_number is None:
14341558
raise ValueError(f"Sequence-number is required for V2 tables: {sequence_number}")
14351559
return ManifestListWriterV2(output_file, snapshot_id, parent_snapshot_id, sequence_number, avro_compression)
1560+
elif format_version == 3:
1561+
if sequence_number is None:
1562+
raise ValueError(f"Sequence-number is required for V3 tables: {sequence_number}")
1563+
if first_row_id is None:
1564+
raise ValueError(f"First-row-id is required for V3 tables: {first_row_id}")
1565+
return ManifestListWriterV3(output_file, snapshot_id, parent_snapshot_id, sequence_number, avro_compression, first_row_id)
14361566
else:
14371567
raise ValueError(f"Cannot write manifest list for table version: {format_version}")

tests/utils/test_manifest.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,3 +1146,132 @@ def test_negative_manifest_cache_size_raises_value_error(monkeypatch: pytest.Mon
11461146
finally:
11471147
monkeypatch.delenv("PYICEBERG_MANIFEST_CACHE_SIZE", raising=False)
11481148
importlib.reload(manifest_module)
1149+
1150+
1151+
@pytest.mark.parametrize("compression", ["null", "deflate"])
1152+
def test_write_manifest_v3(compression: AvroCompressionCodec) -> None:
1153+
io = load_file_io()
1154+
test_schema = Schema(NestedField(1, "foo", IntegerType(), False))
1155+
1156+
v3_data_file = DataFile.from_args(
1157+
_table_format_version=3,
1158+
content=DataFileContent.DATA,
1159+
file_path="/data/file-v3.parquet",
1160+
file_format=FileFormat.PARQUET,
1161+
partition=Record(),
1162+
record_count=100,
1163+
file_size_in_bytes=1024,
1164+
first_row_id=1000,
1165+
)
1166+
v2_data_file = DataFile.from_args(
1167+
content=DataFileContent.DATA,
1168+
file_path="/data/file-v2.parquet",
1169+
file_format=FileFormat.PARQUET,
1170+
partition=Record(),
1171+
record_count=50,
1172+
file_size_in_bytes=512,
1173+
)
1174+
1175+
with TemporaryDirectory() as tmp_dir:
1176+
path = tmp_dir + "/manifest-v3.avro"
1177+
with write_manifest(
1178+
format_version=3,
1179+
spec=UNPARTITIONED_PARTITION_SPEC,
1180+
schema=test_schema,
1181+
output_file=io.new_output(path),
1182+
snapshot_id=25,
1183+
avro_compression=compression,
1184+
) as writer:
1185+
writer.add(ManifestEntry.from_args(status=ManifestEntryStatus.ADDED, snapshot_id=25, data_file=v3_data_file))
1186+
# a data file bound to the default (V2) layout is rebound to the V3 layout
1187+
writer.add(ManifestEntry.from_args(status=ManifestEntryStatus.ADDED, snapshot_id=25, data_file=v2_data_file))
1188+
1189+
_verify_metadata_with_fastavro(path, {"format-version": "3", "content": "data"})
1190+
1191+
with open(path, "rb") as f:
1192+
entries = list(fastavro.reader(f))
1193+
1194+
assert len(entries) == 2
1195+
assert entries[0]["data_file"]["first_row_id"] == 1000
1196+
assert entries[1]["data_file"]["first_row_id"] is None
1197+
for entry in entries:
1198+
for v3_field in ("first_row_id", "referenced_data_file", "content_offset", "content_size_in_bytes"):
1199+
assert v3_field in entry["data_file"]
1200+
1201+
1202+
@pytest.mark.parametrize("compression", ["null", "deflate"])
1203+
def test_write_manifest_list_v3_assigns_first_row_id(compression: AvroCompressionCodec) -> None:
1204+
io = load_file_io()
1205+
1206+
def manifest(path: str, content: ManifestContent, first_row_id: int | None = None, **counts: int) -> ManifestFile:
1207+
args: dict[str, Any] = {
1208+
"manifest_path": path,
1209+
"manifest_length": 100,
1210+
"partition_spec_id": 0,
1211+
"content": content,
1212+
"sequence_number": 1,
1213+
"min_sequence_number": 1,
1214+
"added_snapshot_id": 25,
1215+
"added_files_count": 1,
1216+
"existing_files_count": 1,
1217+
"deleted_files_count": 0,
1218+
"added_rows_count": counts.get("added", 0),
1219+
"existing_rows_count": counts.get("existing", 0),
1220+
"deleted_rows_count": 0,
1221+
}
1222+
if first_row_id is not None:
1223+
return ManifestFile.from_args(_table_format_version=3, first_row_id=first_row_id, **args)
1224+
# bound to the default (V2) layout to exercise rebinding in the writer
1225+
return ManifestFile.from_args(**args)
1226+
1227+
unassigned_data = manifest("/m1.avro", ManifestContent.DATA, added=100, existing=25)
1228+
preserved_data = manifest("/m2.avro", ManifestContent.DATA, first_row_id=77, added=10)
1229+
deletes = manifest("/m3.avro", ManifestContent.DELETES, added=10)
1230+
second_unassigned_data = manifest("/m4.avro", ManifestContent.DATA, added=5)
1231+
1232+
with TemporaryDirectory() as tmp_dir:
1233+
path = tmp_dir + "/manifest-list-v3.avro"
1234+
with write_manifest_list(
1235+
format_version=3,
1236+
output_file=io.new_output(path),
1237+
snapshot_id=25,
1238+
parent_snapshot_id=19,
1239+
sequence_number=2,
1240+
avro_compression=compression,
1241+
first_row_id=1000,
1242+
) as writer:
1243+
writer.add_manifests([unassigned_data, preserved_data, deletes, second_unassigned_data])
1244+
1245+
# 1000 + (100 + 25) + (5): assigned manifests advance by added + existing rows
1246+
assert writer.next_row_id == 1130 # type: ignore[attr-defined]
1247+
1248+
_verify_metadata_with_fastavro(
1249+
path,
1250+
{
1251+
"snapshot-id": "25",
1252+
"parent-snapshot-id": "19",
1253+
"sequence-number": "2",
1254+
"first-row-id": "1000",
1255+
"format-version": "3",
1256+
},
1257+
)
1258+
1259+
with open(path, "rb") as f:
1260+
records = list(fastavro.reader(f))
1261+
1262+
assert [r["first_row_id"] for r in records] == [1000, 77, None, 1125]
1263+
1264+
1265+
def test_write_manifest_list_v3_requires_first_row_id() -> None:
1266+
io = load_file_io()
1267+
with TemporaryDirectory() as tmp_dir:
1268+
with pytest.raises(ValueError, match="First-row-id is required for V3 tables"):
1269+
write_manifest_list(
1270+
format_version=3,
1271+
output_file=io.new_output(tmp_dir + "/manifest-list.avro"),
1272+
snapshot_id=25,
1273+
parent_snapshot_id=19,
1274+
sequence_number=2,
1275+
avro_compression="null",
1276+
first_row_id=None,
1277+
)

0 commit comments

Comments
 (0)