Repository navigation
feat: charge native write buffers to the task's memory pool - #6247
Conversation
Neither native writer reserved what it buffers, so a native Parquet or Iceberg write held its row groups in memory that no budget accounted for. A fanout Iceberg write multiplies that by the number of partitions a task writes, and when it outgrew the executor's memory overhead the container was killed. Both writers now register one memory consumer per task and resize it after every batch. ParquetWriterExec reserves parquet-rs's estimate of its in-progress row group. The Iceberg writer wraps iceberg-rust's ParquetWriterBuilder so that every open file reports what it holds, capped at the row-group size because iceberg-rust exposes only the bytes written plus the in-progress row group, and adds the rows its pacers hold back. Neither writer can spill, so a resize the pool refuses fails the task with a native out-of-memory error that Spark can retry. Closes apache#5648. Closes apache#6115.
sunchao
left a comment
There was a problem hiding this comment.
Summary
- Prior state and problem: Both native writers retained buffers outside Comet’s memory accounting.
- Design approach: Add one task-level reservation covering writer buffers and Iceberg’s pending rows.
- Correctness / compatibility analysis: Found one introduced P2 issue: flushed Iceberg data remains charged as live memory, causing false out-of-memory failures. Compared Spark’s memory and task-failure handling across supported versions and Iceberg’s Java row-group lifecycle.
- Key design decisions: One consumer avoids multiplying fair-pool participants with partition count. File rolling and cleanup ownership remain unchanged.
- Implementation sketch: Parquet uses
ArrowWriter::memory_size(). Iceberg adds a metered writer wrapper, per-file shares, and pending-row accounting. Accounting runs after each input batch, including a walk over fanout pacers. - Behavioral changes worth calling out: Pool refusals now fail the task. Neither writer spills. Failure cleanup worked in focused validation.
- Suggested improvements: Charge live Iceberg buffers and release reservations after row-group flushes, with the regression described below.
Reviewed the entire 10-file diff from 14f0f59f74dbcf273ff3cbdb1721a5ee535bbe84 to d077400f307cad8e0df66d4ac6850a7433332639. PR remains open and non-draft. No existing reviews, comments, or threads were present.
Routed skills: review-comet-pr, review-comet-memory-pr, and review-comet-iceberg-write-pr.
Exact-head CI: 24 successful checks, 14 skipped, no failures or pending checks. Logs confirm both new JVM failure tests passed. Spark SQL suites, upstream Iceberg suites, and macOS were skipped.
Local validation: 50 Iceberg writer tests, six Parquet writer tests, and the disposable task-path reproduction passed using --no-default-features. JVM and HDFS integration tests were not run locally. Disposable repository changes were removed, and the checkout is clean.
| async fn write(&mut self, batch: &RecordBatch) -> iceberg::Result<()> { | ||
| self.inner.write(batch).await?; | ||
| self.share | ||
| .set(self.inner.current_written_size().min(self.row_group_bytes)); |
There was a problem hiding this comment.
[P2] Release the row-group charge after a flush. current_written_size() includes bytes already flushed to storage, so this cap leaves every flushed fanout file permanently charged for a full row group until it closes. With 128 KiB row groups, uncompressed 1,000-row batches containing distinct 256-byte strings, a 512 MiB file target, and a 1 MiB pool, the ninth partition requests 1,179,648 bytes and fails with ResourcesExhausted, although the equivalent parquet-rs writers report zero live row-group bytes after every batch. Flushed buffers should release their reservation so subsequent partitions can reuse the budget. This introduces repeatable task failures for writes whose row-group buffers do not accumulate. Please use a live-buffer accessor from iceberg-rust and add a regression covering reservation reuse after flushing.
Evidence: Reproduced through exact-head run_write_task with disposable test review_flushed_fanout_files_false_oom. The unbounded control wrote all 12 partitions. The 1 MiB pool failed requesting another 128 KiB after reserving 1 MiB. Parallel ArrowWriter controls asserted one flushed row group and memory_size() == 0 for every batch. Failure cleanup deleted all files and returned the reservation. Reproduction patch and output are preserved at /tmp/comet-6247-current-validation/reproduction.patch and /tmp/comet-6247-current-validation/task-probe.log. Pinned iceberg-rust implements current_written_size() as bytes_written() + in_progress_size().
There was a problem hiding this comment.
Fixed in e238121, though not with an accessor. iceberg-rust's ParquetWriter has no in-progress size accessor, at the pinned rev or on main; it only has current_written_size(). So each file's OutputFile is now wrapped (CountedOutput), its writer counts the bytes that leave memory on their way to storage, and a file's share is current_written_size() less those. For a local file that is the in-progress row group plus whatever is still in parquet-rs's 8 KiB BufWriter.
Releasing on flush is only right for local files, though. On S3 and GCS the flushed bytes stay in memory. OpenDAL collects what a file hands it into parts of at least 5 MiB (write_multi_min_size), and its MultipartWriter keeps each part until the next one is complete or the file closes, because only then does it know which part is the last (opendal-core 0.58.2, raw/oio/write/multipart_write.rs). I checked this against MinIO through iceberg-storage-opendal's S3 FileWrite at the pinned rev. Writing 6, 6, 1, 1 and 4 MiB, part 1 was uploaded only during the second write, part 2 only during the fifth, and part 3 at close. So at the default 128 MiB row group, a fanout file on S3 holds the last row group it flushed until it flushes another or closes, and releasing on flush would undercount S3 by up to a row group per open file. The share now follows the storage (StorageWrites, HeldByStorage): file and memory release a row group as it is handed on, and s3, s3a and gs release a part only once the next one is complete.
Regression tests:
files_that_flush_give_back_what_they_reservedis your scenario: 12 fanout partitions, 128 KiB row groups, 1,000 distinct 256-byte strings per partition and a 1 MiB pool. It failed on the ninth partition before the change and now passes, with a peak reservation of 0 bytes.a_files_share_is_what_it_still_holdschecks both storage kinds at the file level.storage_taken_in_parts_holds_the_last_part_until_the_nextpins the part model.
sunchao
left a comment
There was a problem hiding this comment.
Summary
- Prior state and problem: Both native writers buffered data outside Comet’s memory accounting, allowing writes to exceed the task’s budget.
- Design approach: Register one task-level consumer and resize its reservation after each batch.
- Correctness / compatibility analysis: No additional introduced P1/P2 issues found within this review. The existing P2 about charging flushed Iceberg data remains unresolved and reproduces at this head. Checked Spark’s memory and task-failure handling across supported versions and Iceberg Java’s row-group lifecycle.
- Key design decisions: One consumer avoids reducing fair-pool shares for every open file. Per-file charges use drop-based cleanup. Eligibility and configuration defaults remain unchanged.
- Implementation sketch: Parquet uses
ArrowWriter::memory_size()plus remote staging capacity. Iceberg wraps its file writers and includes pending rows. Accounting also scans the fanout feeds after each input batch. No additional substantiated performance or abstraction issue found. - Behavioral changes worth calling out: Compared with
branch-1.1, pool refusals now intentionally fail native writes. Neither writer spills. The existing Iceberg overcharge also causes unintended failures when row-group buffers have already been flushed. - Suggested improvements: Address the existing thread by charging live Iceberg buffers and releasing their charge after flushes, preserving the demonstrated regression as a test.
Reviewed the entire 10-file diff from 9dc8c3ca89962506078831f7064a25c75da83883 to 91839920807e42e4a7d3ad0fbc23eb2fff1251d1. The PR remains open and non-draft. Read the existing review, inline comment and unresolved thread. Routed skills: review-comet-pr, review-comet-memory-pr, and review-comet-iceberg-write-pr.
Existing blocker: discussion_r4124645199, at native/core/src/execution/operators/iceberg_write.rs:293. With 128 KiB row groups, the unlimited-pool control writes all 12 fanout partitions. A 1 MiB pool fails on the ninth because flushed files remain charged. Equivalent parquet-rs controls report zero live row-group bytes after each batch. Failure cleanup removes the files and releases the reservation. This is existing feedback, so it is not duplicated in findings.
Exact-head CI: 7 successful checks, 14 skipped and 11 in progress, with no reported failures. Native build, Rust tests and lint checks remain pending. Spark SQL, upstream Iceberg and macOS suites were skipped.
Local validation: 65 Iceberg writer tests, six Parquet writer tests and the existing-blocker reproduction passed with --no-default-features. JVM, HDFS, real object-store and full upstream suites were not run locally. Evidence is in /tmp/comet-6247-918399-validation/. Disposable source changes were removed and the checkout is clean.
…memory A data file's share was its written size capped at one row group, so every fanout file that had flushed stayed charged a whole row group until it closed. A local file keeps nothing it has flushed, so a pool of eight 128 KiB row groups failed a twelve-partition write whose files held almost nothing. Each file's OutputFile is now wrapped so that its writer counts the bytes that leave memory on their way to storage, and the file reports what it has written less those. A local file releases a row group as it is handed on. S3 and GCS do not: OpenDAL collects what a file flushes into parts of at least 5 MiB and holds each part until the next one is complete or the file closes, so a file there keeps what it has flushed since its last upload charged.
sunchao
left a comment
There was a problem hiding this comment.
Summary
- Prior state and problem: Both native writers buffered data outside Comet’s memory accounting.
- Design approach: Register one task-level consumer and resize its reservation after each batch.
- Correctness / compatibility analysis: No introduced P1/P2 issues found within this review. The previously reported Iceberg flush overcharge is fixed: the original 12-partition reproduction now succeeds with a 1 MiB pool. Checked Spark’s memory and task-failure handling across supported versions and Iceberg Java’s row-group lifecycle.
- Key design decisions: One consumer preserves task-wide fair-pool accounting. Per-file shares release on close or drop. The storage adapter leaves encoding, file rolling and cleanup ownership unchanged.
- Implementation sketch: Parquet charges
ArrowWriter::memory_size()plus remote staging capacity. Iceberg subtracts bytes released to storage and adds pending rows. Accounting scans fanout feeds after each batch. No substantiated P1/P2 performance or abstraction issue found. - Behavioral changes worth calling out: Compared with
branch-1.1, pool refusals now intentionally fail native writes. Neither writer spills. Iceberg’s estimate still excludes dictionary overhead and excess buffer capacity, as documented. - Suggested improvements: No further P1/P2 changes requested.
Reviewed the full 11-file diff from 9dc8c3ca89962506078831f7064a25c75da83883 to e23812130021a658d749488f3867f1a68bdb9bf0. Confirmed the PR is open and non-draft. Read both existing reviews, the inline comment and its thread.
Routed skills: review-comet-pr, review-comet-memory-pr, review-comet-iceberg-write-pr.
Exact-head CI: 17 successful checks, 14 skipped and 7 running, with no reported failures. Native build and Java lint passed. Rust tests, JVM suites and query-result checks remain pending. Spark SQL, upstream Iceberg and macOS suites were skipped.
Local validation: 69 Iceberg writer tests, six Parquet writer tests and two disposable probes passed using --no-default-features. The multipart probe exercised pinned OpenDAL against a local S3 stub and matched released bytes to observed uploads. JVM, HDFS, real object-store and full upstream suites were not run locally. No throughput benchmark was run. Evidence is preserved in /tmp/comet-6247-e238121-validation/. Disposable source changes were removed and the checkout is clean.
….0 upgrade guide With spark.comet.write.iceberg.enabled on by default, every eligible Iceberg write draws its buffers from Comet's off-heap memory pool (apache#6247) instead of the JVM heap. The 1.2.0 upgrade-guide entry now says so, names the error a task fails with when a fanout write to many partitions outgrows the pool, and lists what lets such a write fit.
The merge with main brings in two callers written against the old constructor. apache#5957's RevertNativeForTransitionHeavyStagesSuite relied on the removed committer and jobTrackerID defaults, and apache#6247's write_reserving_from helper still passed the removed work_dir, job_id and task_attempt_id arguments to ParquetWriterExec::try_new. CleanupFailingNativeWriteExec also needs apache#5957's originalPlan argument.
…#4746) * fix: honor Spark’s commit protocol in Spark 3.x native writes * ci: register legacy native write commit protocol tests * refactor: drop the native Parquet writer's work_dir file naming Both native writers now take the exact file name from Spark's commit protocol, so nothing sets work_dir, job_id or task_attempt_id any more. Remove the Some(work_dir) arm from ParquetWriterExec::execute, reserve the three proto fields, and correct the comments that still described the Spark 3.x writer naming its own files. * fix: handle empty inputs in the Spark 3.x native writer Mirror CometWriteFilesExec and FileFormatWriter: swap a zero-partition child for a single empty partition so the output still carries a schema (SPARK-23271), and only write a file from partition 0 or a partition that has rows. Move createTaskContext to the companion object so the task closure no longer captures the exec node and its child subtree. The SPARK-23271 and empty-partition tests now run on Spark 3.x too. * fix: match Spark 3.x write admission and cover empty scans * docs: say native writes over an empty relation stay native With the empty-relation guard gone from CometDataWritingCommand, the user guide and the CometWriteFilesExec comment still described Parquet writes over an empty relation falling back to Spark's writer. Both native write paths now swap a zero-partition input for one empty partition, so such a write stays native and still leaves a readable schema-only file. * fix: take the Spark 3.x native write codec from the job configuration The Spark 3.x task named its file after the codec in the job configuration, because getFileExtension reads it from there. The native writer used the codec that convert resolved at planning time. The two agree today only because both copy the precedence rule of ParquetOptions. Read the codec with CodecConfig.from(taskContext) next to setOutputPath, as CometWriteFilesExec.executeTask does, so the file name and the footer agree by construction. A new CometNativeWriteSuite test sets a job codec that differs from the planned one and checks the name and the footer. The mixed-case codec test now checks the file name on Spark 3.x too. * test: make the injected cleanup failure throw from SQLMetric.add too Since apache#6416, CometMetricNode reports a sum metric with add instead of set. The test metric threw only from set, so the two cleanup-failure tests in CometNativeWriteSuite stopped injecting their failure and failed on Spark 3.4 and 3.5. * ci: pre-compile Spark SQL test classes in the manual writer workflow The Parquet writer test step caps the sbt heap at 1 GB for the forked test JVM, but this workflow compiles Spark first, and sbt ran out of heap while it compiled sql/core. Compile the SQL test classes in an earlier step with a 3 GB heap. This branch had the same step in July, before a history rewrite dropped it. * test: build CometNativeWriteExec with the merged constructor The merge with main brings in two callers written against the old constructor. apache#5957's RevertNativeForTransitionHeavyStagesSuite relied on the removed committer and jobTrackerID defaults, and apache#6247's write_reserving_from helper still passed the removed work_dir, job_id and task_attempt_id arguments to ParquetWriterExec::try_new. CleanupFailingNativeWriteExec also needs apache#5957's originalPlan argument. --------- Co-authored-by: Andy Grove <agrove@apache.org>
* feat: enable the Iceberg split-operator write plan by default spark.comet.write.iceberg.splitOperator.enabled now defaults to true, so Comet plans an Iceberg append, overwrite, or copy-on-write DELETE, UPDATE or MERGE as IcebergWrite under IcebergCommit instead of Spark's single V2 write operator. iceberg-java still writes and commits the data files, and the native writer (spark.comet.write.iceberg.enabled) stays off. The setting moves from the testing config category to query execution, since it is now the switch that restores Spark's own operator. The user guide, the upgrade guide, the operators table, the Iceberg contributor guide and the Iceberg write review skill describe the new default. This is step 1 of apache#5644. * feat: write eligible Iceberg data files natively by default spark.comet.write.iceberg.enabled now defaults to true, so an eligible Iceberg write is written by iceberg-rust and every other write still falls back to iceberg-java. The setting moves to the query execution category, and the docs and the 1.2.0 upgrade-guide entry describe the new default. CometIcebergWriteActionSuite pins the flag off, so the tables it writes outside withNativeEnabled stay an iceberg-java baseline, and withNativeEnabled now restores the previous value instead of unsetting it, which would turn the native writer back on. * docs: note the native Iceberg writer's off-heap memory use in the 1.2.0 upgrade guide With spark.comet.write.iceberg.enabled on by default, every eligible Iceberg write draws its buffers from Comet's off-heap memory pool (apache#6247) instead of the JVM heap. The 1.2.0 upgrade-guide entry now says so, names the error a task fails with when a fanout write to many partitions outgrows the pool, and lists what lets such a write fit. * feat: make spark.comet.write.iceberg.enabled the only Iceberg write switch spark.comet.write.iceberg.enabled now plans the split operator by itself, so one setting turns Comet's Iceberg write path on or off: on plans IcebergCommit over IcebergWrite and writes eligible data files natively, off plans Spark's own V2 write operator. Separate flags for the two layers gave four combinations but only three behaviours: the native writer flag did nothing without the split flag, and the split plan with the native writer off gives a user nothing over Spark's own operator. spark.comet.write.iceberg.splitOperator.enabled goes back to the testing category, off by default. It plans the split operator with the native writer off, which CometIcebergWriteActionSuite uses for its iceberg-java baselines. The Iceberg Spark test diffs set both flags to true, so they run the same plan as before. * fix: keep Spark's Iceberg write operator when native execution is off With the new default, an application that turns off spark.comet.exec.enabled and uses Comet only for scans or shuffle got IcebergCommit over a JVM IcebergWrite. The strategy now plans Spark's own operator in that case too. Also brings the remaining docs in line with the single, default-on setting, and notes the 1.1.0 name of the setting in the migration guide. * test: keep the Iceberg write when a transition-heavy stage reverts with default flags With no write flag set, an eligible write becomes a CometIcebergWriteExec, so transition reversion has to restore a JVM writer (apache#5719, fixed by apache#5957). * test: show the transition-heavy write runs natively before its stage is reverted Run the same write under the default threshold first and require a CometIcebergWriteExec, so the reverted run cannot pass because the write was never eligible. * docs: describe the footer reads native Iceberg writes make on object stores The JVM reads each natively written file's footer back to compute its Iceberg metrics. Say that this is two reads per file, made one after another, which on S3 or GCS are GET requests, so a write of many small files pays two round trips for each. * fix: keep Spark's operator for merge-on-read Iceberg writes unless the testing split flag is on apache#6693 plans Spark 3.5+ WriteDelta through the split plan, with Iceberg's JVM DeltaWriter still writing the rows. With spark.comet.write.iceberg.enabled on by default, every merge-on-read write would take that path although the plan gives it nothing over Spark's own operator. Only the testing split flag plans it now, so users keep Spark's operator until a native delta writer exists (apache#6240), while the Iceberg Spark tests, which set both flags, still cover it. * docs: describe native Iceberg writes in the FAQ as on by default The FAQ added on main called Comet's native Iceberg writes experimental. They are now on by default for eligible writes. * docs: describe fanout writes closing partitions in the 1.2.0 upgrade guide * docs: explain the 1.1.0 split flag in the Iceberg writes upgrade guide A deployment that still sets spark.comet.write.iceberg.splitOperator.enabled=true keeps the split plan when spark.comet.write.iceberg.enabled=false. Say so in the upgrade entry and the fallback list, add a test that pins the combination, and note that the flag still plans merge-on-read writes in the Iceberg Spark tests.
Which issue does this PR close?
Closes #5648.
Closes #6115.
Rationale for this change
Neither native writer reserved the memory it buffers, so none of it was visible to Comet's memory pool or to Spark.
ParquetWriterExecholds its in-progress row group. It uses parquet-rs's default limit of 1Mi rows and no byte limit.A wide fanout write therefore grew in memory that no budget accounted for, until the container was killed. The JVM writers hold the same buffers on the JVM heap, which executors are sized for.
#5648 asked for an audit of the Iceberg writer's buffers, and a test that a fanout write over many partitions with a small memory budget fails with a Comet out-of-memory error rather than a process-level one. #6115 asked the same of both writers, with the buffers charged to the task's pool. This PR does both.
What changes are included in this PR?
MemoryConsumerper task,ParquetWriterExec[N]andIcebergWriteExec[N], andtry_resizeit after every batch. It is one per task rather than one per file because every consumer registered withfair_unifiedlowers the share of every other consumer in the task.ParquetWriterExecreservesArrowWriter::memory_size(), which counts encoded pages, encoder buffers, dictionaries and Bloom filters. On the remote (HDFS) path it adds the staging buffer's capacity.ParquetWriterBuilderin aMeteredParquetWriterBuilder. That is the only hook, because the rolling and partitioning writers keep their file writers private. iceberg-rust'sParquetWriterexposescurrent_written_size(), which is every byte the file has written, the in-progress row group's encoded size included. It does not expose parquet-rs'smemory_size(). So the builder handsParquetWritereach file'sOutputFilebehind aCountedOutput, whose writer counts the bytes that leave memory on their way to storage, and each open file reports what it has written less those. When bytes leave depends on the storage:file,memory) writes a flushed row group out at once, so it reports its in-progress row group and the few KiB parquet-rs buffers in front of storage.RowPacerholds back.CometNativeException: Additional allocation failed for IcebergWriteExec[0] ..., a task failure Spark retries. The Iceberg writer's abort guard deletes the files the task had opened, as on any other task failure.memory_management.mdhas a new "Native writers" section.iceberg-writes.mddescribes the metered builder.What this does not do:
ParquetWriterExec, or closing the largest open file in a fanout write, would let the write continue with a different file layout. Those can be follow-ups.ParquetWriter. Native Iceberg writes decline Bloom filters today, and feat: support bloom filters in native Iceberg writes #5724 would need to reserve them itself.HeldByStoragehas to follow.FanoutDataWriter, which also keeps every partition's file open.ParquetWriterExecstill writes 1Mi-row row groups with no byte limit (Native Parquet writer ignores Spark's Parquet writer properties (block size, page size, dictionary, writer version, statistics) #5304), so a wide write needs a correspondingly large share of the pool.Behavior change: both native writers are off by default. A write whose buffers exceed the task's share of the pool now fails the task. Before, it succeeded in untracked memory, or got the executor killed.
How are these changes tested?
iceberg_write.rs:a_fanout_write_reserves_the_rows_its_partitions_hold_back: 900 rows held back in each of 1 and 8 partitions reserve 9.5 KB and 74 KB.the_reservation_follows_the_files_that_are_open: eight one-unit partitions reserve 32 KB fanout, 4.1 KB clustered, and 4.5 KB when an unpartitioned write rolls through eight files.a_file_reserves_no_more_than_one_row_group: a 225 KB file written with 16 KiB row groups reserves at most 17.8 KB, its in-progress row group and parquet-rs's write buffer.files_that_flush_give_back_what_they_reserved: twelve fanout partitions, each a unit of 1,000 distinct 256-byte strings that flushes as soon as it is written into 128 KiB row groups, fit in a pool of eight row groups (1 MiB), with a peak reservation of 0. Charging each flushed file a whole row group failed this write on its ninth partition.a_files_share_is_what_it_still_holds: a local file's share falls back each time a row group flushes and stays under a row group plus parquet-rs's 8 KiB write buffer. A file taken in parts reports everything it has written until that makes a part.storage_taken_in_parts_holds_the_last_part_until_the_nextandonly_local_storage_writes_through: the part model and which schemes write through.a_write_the_pool_cannot_hold_fails_and_deletes_its_files: fails withResourcesExhaustednaming the consumer, leaves no files, and gives back its reservation.an_open_file_gives_back_its_share_when_it_closes_or_is_droppedandpacer_counts_the_memory_of_the_rows_it_holds_back.parquet_writer.rs: the row group is reserved until the file closes, and a pool too small for it fails the write.spark.comet.exec.memoryPool.fraction=0.002.CometIcebergWriteActionSuite"native acceleration: a fanout write that outgrows the memory pool fails its task" writes 64 partitions from one task. It checks the error, that nothing was committed, and that no data files were left (the writer deleted the 12 it had opened). Then the same write with the whole pool succeeds natively.CometParquetWriterSuite"a row group the pool cannot hold fails its task with a native out-of-memory error" does the same for a single 10 MB row group.FileWriteat the pinned rev. Writing 6, 6, 1, 1 and 4 MiB, MinIO received part 1 only during the second write, part 2 only during the fifth, and part 3 at close.cargo test -p datafusion-comet --libpasses (707 tests), and clippy with-D warningsandcargo fmtare clean.CometIcebergWriteActionSuite,CometIcebergRewriteActionSuiteandCometIcebergSystemFunctionSuitepass (110 tests), and so doesCometParquetWriterSuite(50 tests).