Repository navigation
feat(iceberg): stream Iceberg writes with constant memory - #3753
ajinzrathod wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
this is really cool! and I think solves a lot of practical problems before pyiceberg is fixed. two things before we can merge this
- some of the methods do garbage collection. could you parametrize how often it is done (which will also allow to disable it)?
- this PR does not include tests. if you are using claude code (and probably any other decent agent) you should be able to ask it to add tests in the correct place and with compatible parametrization
- we do not need to test on bucket. here IMO tests on local filesystem are sufficient
ie.test_open_table_pipelinemodule is a good start and
@pytest.mark.parametrize(
"destination_config",
destinations_configs(
table_format_local_configs=True,
with_table_format="iceberg",
),
ids=lambda x: x.name,
)
def test_iceberg_table_properties(
destination_config: DestinationTestConfiguration,
) -> None:
``` is good example parametrization
|
Updated with both changes: GC parametrization: Added iceberg_gc_collect_interval config option (default 0 = disabled). Any positive integer triggers gc.collect() every N batches. Threaded through from FilesystemDestinationClientConfiguration -> IcebergLoadFilesystemJob -> streaming functions. Progress logging is decoupled from GC and always runs every 10 batches. Tests: Added 3 tests in test_open_table_pipeline.py using table_format_local_configs=True with with_table_format="iceberg":
All passing locally on filesystem. Let me know if this looks good or if any other changes are needed. |
- Add store_decimal_as_integer=True to manual pq.write_table() calls to fix FIXED_LEN_BYTE_ARRAY vs INT64 mismatch - Use txn.append() instead of add_files for partitioned tables since batches can span multiple partition values - Separate update and insert transactions for partitioned upserts to work around pyiceberg 0.9.x SIGILL crash when mixing overwrite + append in one transaction - Extract _upload_parquet_to_remote and _process_upsert_batch helpers
|
I was looking at the failed test cases on CI, all the 1. Decimal encoding mismatchPyArrow defaults to encoding decimals as Fix: Included 2. Partitioned tables failing on streamed writesWhile fixing the above, I noticed the streaming path used Fix: Split into two strategies:
3. SIGILL crash during upsert on partitioned tablesAfter fixing the decimal encoding, I re-ran the tests locally. The tests that were failing earlier now got past the load step but the upsert test ( Fix: Separated updates ( Cleanup
|
|
@ajinzrathod I reviewed the code again. the fundamental change - to stream data via batch reader - makes total sense. but it seems to me that if we bump pyiceberg to |
Review & Suggested SimplificationThanks for the PR — it solves a real OOM problem and the streaming approach is the right direction. After investigation, we believe the implementation can be significantly simplified by bumping the pyiceberg minimum to Context
With pyiceberg Key findings
SIGILL crash doesn't reproduce: We tested the single-transaction approach (mixing
Memory is constant: Proposed changes1. Bump pyiceberg minimum to
|
| Function/Config | Reason |
|---|---|
_upload_parquet_to_remote() |
pyiceberg writes files internally |
_write_iceberg_table_streamed() |
Replaced by txn.append() loop |
_write_streamed_partitioned() |
pyiceberg handles partitions |
_write_streamed_add_files() |
pyiceberg handles file registration |
_process_upsert_batch() |
Replaced by txn.upsert() |
_upsert_iceberg_table() |
Replaced by txn.upsert() loop |
_UPLOAD_CHUNK_SIZE |
No manual uploads |
_iter_parquet_batches() |
Scanner does this |
iceberg_gc_collect_interval |
Not needed with pyiceberg's writer |
recommended_file_size = 128MB |
Unrelated global change |
store_decimal_as_integer patch |
Native in 0.10+ |
1. Scanner OOMs on large datasets
The scanner works fine on small data, our earlier tests with Fix: Streaming via |
2. Schema evolution breaks
|
3. Trade-off in replace operation: txn.append() files are deletable but O(N²), fast_append() is O(N) but files can't be deletedFor streaming replace, we need to delete existing data first, then append new batches. We tested Problem: Files written by What we verified:
|
|
Summary:
FWIW, it would be valuable to validate append, replace, and merge operations on datasets larger than 10 GB (with container RAM smaller than the data size) to confirm the streaming approach at scale. I tested the proposed approach on Apple Silicon using a 4 GB Docker container with datasets ranging from 2 GB to 55 GB. The test I'm not pushing code to this repo yet, will do after we have proper sign-off on the approach. My current working code is on my personal fork in the feat/3752-v2-iceberg-streaming-atomic-commit branch for reference against the findings above. |
Sounds good, happy to look at the implementation plan. Given the findings above (especially the O(N²) txn.append(), performance, SIGILL, and the fast_append() delete issue), interested to hear your thoughts. Open to collaborating on this or letting you take it forward, whatever works best. |
# Rationale for this change Closes #2152, addresses the long-standing memory problem reported in #1004 and re-discovered by dlt-hub#3753. `Table.append(df)` and `Table.overwrite(df)` currently require a fully materialised `pa.Table`. For large or unbounded inputs this means loading the entire dataset into memory before writing — fatal at any non-trivial scale and a recurring complaint going back to #1004 (Aug 2024). The reference Java implementation has streaming append; iceberg-go shipped it in iceberg-go#369 (Apr 2025). Python is the last major SDK without it. This PR adds `pa.RecordBatchReader` as a valid input to `Table.append/overwrite` (and `Transaction.append/overwrite`). The reader is consumed lazily, microbatched into Parquet files via the new `bin_pack_record_batches` helper, and committed in a single snapshot via the existing `fast_append` pipeline. ```python reader = pa.RecordBatchReader.from_batches(schema, batch_iter) tbl.append(reader) # ← streams, doesn't materialise tbl.overwrite(reader) # ← also supported ``` ## Scope (unpartitioned only) Streaming into a partitioned table raises `NotImplementedError` pointing back to #2152. Partitioned support is genuinely the harder case — it needs design discussion around partition cardinality bounds, per-partition rolling writers, and idempotency on retry — so I'm proposing to land in three reviewable PRs: 1. **This PR** — API + unpartitioned + buffered byte-budget bin-packing. 2. **PR2 (next)** — switch internals to a rolling `pq.ParquetWriter` + `OutputStream.tell()` for constant-memory streaming. No API change. Detailed plan below. 3. **PR3 (later)** — partitioned streaming, after design discussion on #2152. This mirrors iceberg-go#369's staging: ship the unpartitioned API first, iterate from there. ## Implementation The streaming path reuses the existing `WriteTask` → `write_file` → `fast_append` pipeline. The only new primitive is `bin_pack_record_batches` (sibling of the existing `bin_pack_arrow_table`): - Accumulates incoming `RecordBatch`es into an in-memory buffer. - Flushes when `sum(batch.nbytes) >= write.target-file-size-bytes`. - Each flushed buffer becomes one parquet file via the existing `write_parquet` task. - Schema check (`_check_pyarrow_schema_compatible`) runs against `reader.schema` before the snapshot producer opens — schema mismatches fail before any data file is written, so no orphans. ## Acknowledged trade-offs **Memory**: peak memory is bounded by `N_workers × write.target-file-size-bytes` (default 8 × 512 MiB ≈ 4 GiB), not constant. This is materially better than today's "materialise everything" but isn't yet "constant memory streaming". PR2 fixes this. **Byte semantics**: `write.target-file-size-bytes` is currently interpreted as **uncompressed in-memory Arrow bytes** (`RecordBatch.nbytes` — the bin-packing weight), not compressed on-disk Parquet bytes. The resulting files are typically 3-10× smaller than the property suggests after zstd / dictionary / RLE encoding. This matches the existing `pa.Table` write path (`bin_pack_arrow_table` uses the same accounting) — this PR doesn't change pyiceberg's existing semantics, it only documents them in the docstrings of both helpers and the `Transaction.append/overwrite` `Note:` blocks. PR2 fixes this too. **Retry**: `pa.RecordBatchReader` is single-pass, so a failed catalog commit leaves the reader drained and a naive retry writes zero rows. Documented in the `Note:` block — callers needing at-least-once semantics should reconstruct the reader on each attempt via a factory callable, or use the two-stage `add_files` pattern (whose input is a replayable list of paths). ## PR2 — proposed scope (FYI, not in this PR) Drop the buffer-and-flush approach and use a rolling `pq.ParquetWriter` driven by `OutputStream.tell()` (added in #2998 specifically for this kind of use case): ```python # sketch writer = pq.ParquetWriter(fos, schema, **kwargs) for batch in reader: writer.write_batch(batch) if fos.tell() >= target_file_size: # compressed on-disk bytes writer.close() finalize_data_file(...) # open next file fos = io.new_output(next_path).create(overwrite=True) writer = pq.ParquetWriter(fos, schema, **kwargs) writer.close() ``` What this delivers: - **Constant memory**: `O(1 batch)` per worker (~10s of MB) regardless of `target_file_size`. The 4 GiB peak in this PR drops to ~50-100 MB. - **Spec-correct byte semantics**: `write.target-file-size-bytes` becomes actual on-disk compressed bytes, matching the Java/Spark/Flink writers and the spec. - **No public API change**: same `tx.append(reader)` / `tx.overwrite(reader)` — internals only. Open design questions for PR2 (will surface on the issue thread before coding): - **Parallelism**: a single rolling writer is serial. Either accept that for streaming (memory-vs-throughput trade), or add a hybrid (N rolling writers fed via a queue) and pick a default that matches today's `executor.map(write_parquet, tasks)` parallelism. - **Backwards compat**: switching `bin_pack_arrow_table` to the same rolling-writer mechanism would also tighten the `pa.Table` path's byte semantics. That changes file-size characteristics for every existing pyiceberg writer. Probably worth a separate change with a deprecation note, or a feature flag. - **`add_files` interaction**: rolling writes produce data files we know about directly; we shouldn't go through the parquet-footer round-trip in `_dataframe_to_data_files`. Means a small refactor in the streaming-only path. ## Are these changes tested? Yes, comprehensively at four layers. **1. Unit tests** (`tests/io/test_pyarrow.py`) — 4 new tests for `bin_pack_record_batches` covering single-bin, microbatched, empty input, and lazy generator consumption. **2. End-to-end behaviour tests** (`tests/catalog/test_catalog_behaviors.py`) — 8 new tests parametrised across all three in-process catalog backends (`memory`, `sql`, `sql_without_rowcount`) → 24 test runs covering append, overwrite, microbatch verification (multiple files in one snapshot), empty reader, partitioned-table-raises, invalid-input-rejected, reader-consumed-exactly-once, and schema-mismatch-writes-no-files. **3. Integration tests** (`tests/integration/test_writes/test_writes.py`) — 6 new Spark-readback tests for v1 + v2 format versions covering append, overwrite, and multi-file microbatch. Proves Spark can read tables written via the streaming path against the docker-compose stack. **4. Smoke test on a real production stack** — verified end-to-end against AWS Glue + S3 in our staging account: 100 k-row streaming append in 17 s, 20-file microbatched commit, Athena read-back (`COUNT(*)` and `MAX(id)` matched the input exactly), schema-mismatch rejection leaving no orphan files. Full unit suite: 3 647 passed. Full integration suite: 122 passed, 1 skipped. ## Are there any user-facing changes? Yes, intentionally: - `Transaction.append(df)`, `Transaction.overwrite(df)`, `Table.append(df)`, `Table.overwrite(df)` accept `pa.Table | pa.RecordBatchReader`. - The `ValueError` raised on bad input changes from `"Expected PyArrow table, got: ..."` to `"Expected pa.Table or pa.RecordBatchReader, got: ..."`. Updated `test_invalid_arguments` accordingly. - New module-level helper `bin_pack_record_batches` in `pyiceberg.io.pyarrow` (sibling of `bin_pack_arrow_table`). - `bin_pack_arrow_table` gained its first docstring, documenting the existing uncompressed-Arrow-bytes accounting. - Docs: new "Streaming writes from a RecordBatchReader" subsection in `mkdocs/docs/api.md`. - Docstrings on `Transaction.append/overwrite` document retry semantics and the byte-semantics caveat. ## Related - Closes #2152 - Addresses #1004 (closed by reporter without a fix) - Reference implementation: iceberg-go#369 - Downstream consumer hitting the same problem: dlt-hub/dlt#3753 (independent rediscovery of the same approach) - Builds on the maintainer-blessed pattern from #1742's review (`_dataframe_to_data_files` + `fast_append.append_data_file()`, no separate `write_parquet` API) - Companion fix (already merged separately): test-state isolation in `test_write_optional_list` - PR2 will build on `OutputStream.tell()` from #2998 --------- Co-authored-by: Paul Mathew <[email protected]>
|
Really looking forward to this — we've been hitting OOMs on Iceberg loads as well, so a streaming write path would be a huge win for us. Any rough estimate when this could land in a released version of dlt? |
|
@tderk I'm afraid we won't get far here.
ducklabs released pretty good support for iceberg via iceberg extensions. I'd probably spend time there. it requires REST catalog though so this nice catalog-less iceberg we have here will be hard to reproduce |
|
Thanks @rudolfix — that makes sense. On the partitioned-data problem specifically: would From what I understand it just registers already-written Parquet via footer reads — no data rewrite, no per-partition writer buffers — so wouldn't that sidestep the memory/SIGILL issues that come from pyiceberg actually writing the partitioned data? The only limit I see is that it's per-file (each Parquet would need to hold a single partition value), not per-load. Since dlt stages Parquet rotated by size, would the files just need to be split by partition key before registering — or is there a reason that wouldn't work here? |
|
Really looking forward on this, if you need any help also |


Description
Closes #3752
Previously, Iceberg loads materialised the entire Arrow dataset in memory (
self.arrow_dataset.to_table()) and upserts created one snapshot per batch. This caused OOM risk on large datasets and snapshot bloat that slowed metadata operations.This PR streams all Iceberg write paths batch-by-batch with constant memory and produces minimal Iceberg snapshots — bringing Iceberg to parity with the Delta Lake code path which already uses
RecordBatchReader.Changes
dlt/common/libs/pyiceberg.pywrite_iceberg_tablenow acceptsUnion[pa.Table, pa.RecordBatchReader]. When given aRecordBatchReader, it dispatches to the new_write_iceberg_table_streamedfunction._write_iceberg_table_streamed(new): Streams Arrow batches one at a time — each batch is written to a temp parquet file, uploaded to remote storage via Iceberg's IO, then discarded. All files are registered atomically withtable.add_files()(append) ordelete+add_filesin a single transaction (replace). Memory stays constant: only one batch + one parquet file in memory at a time.merge_iceberg_tablenow acceptsUnion[pa.Table, pa.RecordBatchReader]and delegates to the new_upsert_iceberg_tablefunction._upsert_iceberg_table(new): Streams batches within a singletable.transaction(). Updates go viatxn.overwrite()only when rows actually changed (and only forupsertstrategy, notinsert-only). Inserts are collected as remote parquet files and registered viatxn.add_files()at the end of the transaction. For pure-insert loads (e.g. first load into an empty table) this produces exactly one Iceberg snapshot.dlt/destinations/impl/filesystem/filesystem.pyIcebergLoadFilesystemJob.run(): Reads schema cheaply from the first parquet file header (pq.read_schema) instead of materialising the full dataset. For merge path: streams viascanner(batch_readahead=0, fragment_readahead=0, use_threads=False).to_reader(). For append/replace: uses_iter_parquet_batchesgenerator wrapped inRecordBatchReader.from_batches(). Never calls.to_table()on the full dataset. Explicitgc.collect()after loads and periodically during batching._iter_parquet_batches(new static method): Yields Arrow batches from parquet files one at a time for constant memory.dlt/destinations/impl/filesystem/factory.pyrecommended_file_size = 128MBfor the filesystem destination so upstream produces reasonably-sized parquet files for streaming.Key invariants
.to_table()on the full dataset — data streams batch-by-batchRecordBatch+ one temp parquet file in memory at a timeinsert-onlystrategy support (PR (Feat) insert only merge strategy #3741)time.monotonic()throughout (aligned with PR (fix) implements monotonic wall clock #3695)propertiesparameter increate_tablecalls (PR (feat) adds iceberg table properties #3699)pa.Tableinputs still work as beforeRelated upstream issues
This addresses a known limitation in pyiceberg itself:
table.append()due to full materializationRecordBatchReaderwrite support. A pyiceberg maintainer (@kevinjqliu) pointed to iceberg-go's approach: materialize arrow streams as individual parquet files, then register them viaadd_files— which is exactly the approach this PR takes.Testing
Tested locally with Docker (MinIO + Iceberg REST catalog, 4GB memory limit) across varying dataset sizes (up to 100 parquet files, 1M+ rows × 400 columns). Behavior is consistent at all scales:
A ready-to-use test harness with Docker Compose (MinIO + Iceberg REST catalog), data generators, and test scripts is available on the
github.com/ajinzrathod/dlt-ajinzrathod's(me)feat/iceberg-streaming-testingbranch if you'd like to reproduce locally.