Skip to content

Commit 08e3d21

Browse files
committed
perf(avro): buffer records into size-bounded blocks
1 parent 0d58407 commit 08e3d21

3 files changed

Lines changed: 221 additions & 9 deletions

File tree

‎pyiceberg/avro/file.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
MAGIC = bytes(b"Obj" + bytearray([VERSION]))
5555
MAGIC_SIZE = len(MAGIC)
5656
SYNC_SIZE = 16
57+
# Approximate uncompressed bytes buffered per block, matching the default sync interval of the Avro
58+
# Java writer that Iceberg Java uses (DataFileConstants.DEFAULT_SYNC_INTERVAL).
59+
DEFAULT_SYNC_INTERVAL = 4000 * SYNC_SIZE
5760
META_SCHEMA = StructType(
5861
NestedField(name="magic", field_id=100, field_type=FixedType(length=MAGIC_SIZE), required=True),
5962
NestedField(
@@ -238,6 +241,7 @@ def __init__(
238241
schema_name: str,
239242
record_schema: Schema | None = None,
240243
metadata: dict[str, str] = EMPTY_DICT,
244+
sync_interval: int = DEFAULT_SYNC_INTERVAL,
241245
) -> None:
242246
self.output_file = output_file
243247
self.file_schema = file_schema
@@ -249,6 +253,12 @@ def __init__(
249253
else resolve_writer(record_schema=record_schema, file_schema=self.file_schema)
250254
)
251255
self.metadata = metadata
256+
if sync_interval <= 0:
257+
raise ValueError(f"Sync interval must be positive: {sync_interval}")
258+
self.sync_interval = sync_interval
259+
self._block = io.BytesIO()
260+
self._block_encoder = BinaryEncoder(output_stream=self._block)
261+
self._block_records = 0
252262

253263
def __enter__(self) -> AvroOutputFile[D]:
254264
"""
@@ -266,6 +276,7 @@ def __enter__(self) -> AvroOutputFile[D]:
266276

267277
def __exit__(self, exctype: type[BaseException] | None, excinst: BaseException | None, exctb: TracebackType | None) -> None:
268278
"""Perform cleanup when exiting the scope of a 'with' statement."""
279+
self._flush_block()
269280
self.output_stream.close()
270281

271282
def _write_header(self) -> None:
@@ -300,13 +311,18 @@ def compression_codec(self) -> type[Codec] | None:
300311
return KNOWN_CODECS[codec_name] # type: ignore
301312

302313
def write_block(self, objects: list[D]) -> None:
303-
in_memory = io.BytesIO()
304-
block_content_encoder = BinaryEncoder(output_stream=in_memory)
305314
for obj in objects:
306-
self.writer.write(block_content_encoder, obj)
307-
block_content = in_memory.getvalue()
315+
self.writer.write(self._block_encoder, obj)
316+
self._block_records += 1
317+
if self._block.tell() >= self.sync_interval:
318+
self._flush_block()
308319

309-
self.encoder.write_int(len(objects))
320+
def _flush_block(self) -> None:
321+
if self._block_records == 0:
322+
return
323+
324+
block_content = self._block.getvalue()
325+
self.encoder.write_int(self._block_records)
310326

311327
if codec := self.compression_codec():
312328
content, content_length = codec.compress(block_content)
@@ -318,5 +334,9 @@ def write_block(self, objects: list[D]) -> None:
318334

319335
self.encoder.write(self.sync_bytes)
320336

337+
self._block.seek(0)
338+
self._block.truncate()
339+
self._block_records = 0
340+
321341
def tell(self) -> int:
322-
return self.output_stream.tell()
342+
return self.output_stream.tell() + self._block.tell()

‎tests/avro/test_file.py‎

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323
from uuid import UUID
2424

2525
import pytest
26-
from fastavro import reader, writer
26+
from fastavro import block_reader, reader, writer
2727

2828
import pyiceberg.avro.file as avro
2929
from pyiceberg.avro.codecs.deflate import DeflateCodec
30-
from pyiceberg.avro.file import AvroFileHeader
30+
from pyiceberg.avro.file import DEFAULT_SYNC_INTERVAL, AvroFileHeader
3131
from pyiceberg.io.pyarrow import PyArrowFileIO
3232
from pyiceberg.manifest import (
3333
DEFAULT_BLOCK_SIZE,
@@ -453,3 +453,94 @@ def field_uuid(self) -> UUID:
453453
for idx, field in enumerate(all_primitives_schema.as_struct()):
454454
assert record[idx] == avro_entry[idx], f"Invalid {field}"
455455
assert record[idx] == avro_entry_read_with_fastavro[idx], f"Invalid {field} read with fastavro"
456+
457+
458+
def manifest_entry(index: int) -> ManifestEntry:
459+
return ManifestEntry.from_args(
460+
status=ManifestEntryStatus.ADDED,
461+
snapshot_id=8638475580105682862,
462+
sequence_number=0,
463+
file_sequence_number=0,
464+
data_file=DataFile.from_args(
465+
content=DataFileContent.DATA,
466+
file_path=f"s3://some-path/some-file-{index}.parquet",
467+
file_format=FileFormat.PARQUET,
468+
partition=Record(),
469+
record_count=131327,
470+
file_size_in_bytes=220669226,
471+
),
472+
)
473+
474+
475+
@pytest.mark.parametrize("sync_interval", [0, -1])
476+
def test_avro_output_file_rejects_non_positive_sync_interval(sync_interval: int) -> None:
477+
with TemporaryDirectory() as tmpdir:
478+
with pytest.raises(ValueError, match="Sync interval must be positive"):
479+
avro.AvroOutputFile[ManifestEntry](
480+
output_file=PyArrowFileIO().new_output(tmpdir + "/manifest_entry.avro"),
481+
file_schema=MANIFEST_ENTRY_SCHEMAS[2],
482+
schema_name="manifest_entry",
483+
sync_interval=sync_interval,
484+
)
485+
486+
487+
def test_avro_output_file_writes_no_block_without_records() -> None:
488+
with TemporaryDirectory() as tmpdir:
489+
tmp_avro_file = tmpdir + "/manifest_entry.avro"
490+
491+
with avro.AvroOutputFile[ManifestEntry](
492+
output_file=PyArrowFileIO().new_output(tmp_avro_file),
493+
file_schema=MANIFEST_ENTRY_SCHEMAS[2],
494+
schema_name="manifest_entry",
495+
):
496+
pass
497+
498+
with open(tmp_avro_file, "rb") as fo:
499+
assert list(block_reader(fo)) == []
500+
501+
502+
@pytest.mark.parametrize("sync_interval", [1, DEFAULT_SYNC_INTERVAL])
503+
def test_avro_output_file_round_trips_across_blocks(sync_interval: int) -> None:
504+
entries = [manifest_entry(index) for index in range(10)]
505+
506+
with TemporaryDirectory() as tmpdir:
507+
tmp_avro_file = tmpdir + "/manifest_entry.avro"
508+
509+
with avro.AvroOutputFile[ManifestEntry](
510+
output_file=PyArrowFileIO().new_output(tmp_avro_file),
511+
file_schema=MANIFEST_ENTRY_SCHEMAS[2],
512+
schema_name="manifest_entry",
513+
sync_interval=sync_interval,
514+
) as out:
515+
for entry in entries:
516+
out.write_block([entry])
517+
518+
with open(tmp_avro_file, "rb") as fo:
519+
blocks = [block.num_records for block in block_reader(fo)]
520+
521+
with avro.AvroFile[ManifestEntry](
522+
input_file=PyArrowFileIO().new_input(tmp_avro_file),
523+
read_schema=MANIFEST_ENTRY_SCHEMAS[2],
524+
read_types={-1: ManifestEntry, 2: DataFile},
525+
) as avro_reader:
526+
read_entries = list(avro_reader)
527+
528+
if sync_interval == 1:
529+
# A record that on its own reaches the sync interval is flushed as its own block
530+
assert blocks == [1] * len(entries)
531+
else:
532+
assert blocks == [len(entries)]
533+
assert [entry.data_file.file_path for entry in read_entries] == [entry.data_file.file_path for entry in entries]
534+
535+
536+
def test_avro_output_file_tell_includes_buffered_records() -> None:
537+
with TemporaryDirectory() as tmpdir:
538+
with avro.AvroOutputFile[ManifestEntry](
539+
output_file=PyArrowFileIO().new_output(tmpdir + "/manifest_entry.avro"),
540+
file_schema=MANIFEST_ENTRY_SCHEMAS[2],
541+
schema_name="manifest_entry",
542+
) as out:
543+
after_header = out.tell()
544+
out.write_block([manifest_entry(0)])
545+
546+
assert out.tell() > after_header

‎tests/utils/test_manifest.py‎

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
import pyiceberg.manifest as manifest_module
2727
from pyiceberg.avro.codecs import AvroCompressionCodec
28-
from pyiceberg.avro.file import AvroFile, AvroOutputFile
28+
from pyiceberg.avro.file import DEFAULT_SYNC_INTERVAL, AvroFile, AvroOutputFile
2929
from pyiceberg.io import load_file_io
3030
from pyiceberg.io.pyarrow import PyArrowFileIO
3131
from pyiceberg.manifest import (
@@ -1126,6 +1126,107 @@ def test_manifest_writer_tell(format_version: TableVersion) -> None:
11261126
assert after_entry_bytes > initial_bytes, "Bytes should increase after adding entry"
11271127

11281128

1129+
@pytest.mark.parametrize("format_version", [1, 2])
1130+
@pytest.mark.parametrize("compression", ["null", "deflate"])
1131+
def test_write_manifest_fills_blocks(format_version: TableVersion, compression: AvroCompressionCodec) -> None:
1132+
io = load_file_io()
1133+
test_schema = Schema(NestedField(1, "foo", IntegerType(), False))
1134+
entry_count = 2000
1135+
1136+
with TemporaryDirectory() as tmpdir:
1137+
manifest_path = f"{tmpdir}/test-manifest.avro"
1138+
with write_manifest(
1139+
format_version=format_version,
1140+
spec=UNPARTITIONED_PARTITION_SPEC,
1141+
schema=test_schema,
1142+
output_file=io.new_output(manifest_path),
1143+
snapshot_id=1,
1144+
avro_compression=compression,
1145+
) as writer:
1146+
for i in range(entry_count):
1147+
writer.add_entry(
1148+
ManifestEntry.from_args(
1149+
status=ManifestEntryStatus.ADDED,
1150+
snapshot_id=1,
1151+
data_file=DataFile.from_args(
1152+
content=DataFileContent.DATA,
1153+
file_path=f"{tmpdir}/data-{i}.parquet",
1154+
file_format=FileFormat.PARQUET,
1155+
partition=Record(),
1156+
record_count=100,
1157+
file_size_in_bytes=1000,
1158+
),
1159+
)
1160+
)
1161+
1162+
with open(manifest_path, "rb") as f:
1163+
blocks = [(block.num_records, block.size) for block in fastavro.block_reader(f)]
1164+
1165+
manifest = ManifestFile.from_args(
1166+
manifest_path=manifest_path,
1167+
manifest_length=0,
1168+
partition_spec_id=0,
1169+
added_snapshot_id=1,
1170+
sequence_number=0,
1171+
partitions=[],
1172+
)
1173+
read_entries = manifest.fetch_manifest_entry(io)
1174+
1175+
assert sum(num_records for num_records, _ in blocks) == entry_count
1176+
assert len(blocks) < entry_count
1177+
if compression == "null":
1178+
# Only an uncompressed block reports the buffered size that triggers a flush
1179+
assert all(size >= DEFAULT_SYNC_INTERVAL for _, size in blocks[:-1])
1180+
assert [entry.data_file.file_path for entry in read_entries] == [f"{tmpdir}/data-{i}.parquet" for i in range(entry_count)]
1181+
1182+
1183+
@pytest.mark.parametrize("format_version", [1, 2])
1184+
def test_write_manifest_list_fills_blocks(format_version: TableVersion) -> None:
1185+
io = load_file_io()
1186+
manifest_count = 2000
1187+
1188+
with TemporaryDirectory() as tmpdir:
1189+
manifest_list_path = f"{tmpdir}/manifest-list.avro"
1190+
manifests = [
1191+
ManifestFile.from_args(
1192+
manifest_path=f"{tmpdir}/manifest-{i}.avro",
1193+
manifest_length=100,
1194+
partition_spec_id=0,
1195+
content=ManifestContent.DATA,
1196+
sequence_number=0,
1197+
min_sequence_number=0,
1198+
added_snapshot_id=1,
1199+
added_files_count=1,
1200+
existing_files_count=0,
1201+
deleted_files_count=0,
1202+
added_rows_count=1,
1203+
existing_rows_count=0,
1204+
deleted_rows_count=0,
1205+
partitions=[],
1206+
)
1207+
for i in range(manifest_count)
1208+
]
1209+
with write_manifest_list(
1210+
format_version=format_version,
1211+
output_file=io.new_output(manifest_list_path),
1212+
snapshot_id=1,
1213+
parent_snapshot_id=None,
1214+
sequence_number=0,
1215+
avro_compression="null",
1216+
) as writer:
1217+
writer.add_manifests(manifests)
1218+
1219+
with open(manifest_list_path, "rb") as f:
1220+
blocks = [(block.num_records, block.size) for block in fastavro.block_reader(f)]
1221+
1222+
read_manifests = list(read_manifest_list(io.new_input(manifest_list_path)))
1223+
1224+
assert sum(num_records for num_records, _ in blocks) == manifest_count
1225+
assert all(size >= DEFAULT_SYNC_INTERVAL for _, size in blocks[:-1])
1226+
assert len(blocks) > 1
1227+
assert [manifest.manifest_path for manifest in read_manifests] == [manifest.manifest_path for manifest in manifests]
1228+
1229+
11291230
@pytest.mark.parametrize("format_version", [1, 2])
11301231
def test_write_manifest_min_sequence_number_zero(format_version: TableVersion) -> None:
11311232
# A data sequence number of 0 is a legitimate min for a live file (e.g. files from a

0 commit comments

Comments
 (0)