Repository navigation
feat: close fanout partitions early when the memory pool refuses the native Iceberg writer - #6773
Conversation
…native Iceberg writer A native fanout write keeps a file open for every partition a task writes to, and its buffers count against the task's memory pool, so a task over many partitions could fail where iceberg-java's on-heap writer succeeded. When the pool refuses the writer's reservation, it now writes out and closes the partitions holding the most memory, in their open file or in rows not yet handed to it, until what is left fits. A closed partition's next rows open a new file with the same writer properties, so the write finishes with more, smaller files instead of failing. A files_closed_early metric counts them. Writes that keep one file open still fail when that file outgrows the pool.
…r memory pressure
|
Why this copies iceberg-rust's The fix needs two things that So iceberg-rust |
The control write is native too, so it could lose the same rows. Also fix a stale FanoutWriter mention in the user guide and a missing noun in the FanoutFiles doc comment.
… record FanoutPartitions holds every partition in first-seen order, each with its feed, its open writer, the OpenFileMemory child its files report to, and the properties its files are written with. That replaces FanoutFiles and FanoutFeeds and their join by key, the seen counter, PartitionProperties::get and the partition field threaded through the metered builder. InnerWriter::reserve now owns the close-until-it-fits loop, ranking partitions by index without cloning keys and tracking pending bytes without rescanning. WriteMetrics gets a constructor, the Rust memory tests share their setup, and the Scala test captures the write with captureWrite.
Could there be too many small files when the rows' partitions are interleaved (e.g. p0, p1, ... pN, p0, p1, ... pN, p0, p1, ...)? |
sunchao
left a comment
There was a problem hiding this comment.
Reviewed all seven changed files across all four commits against base f80042f78715e5a009352f1582f6c246a5f96131. The PR is not a draft. No introduced P1/P2 issues found within this review.
Read AGENTS.md, existing reviews, conversation comments and review threads. Applied review-comet-pr, review-comet-iceberg-write-pr and review-comet-memory-pr from .ai/skills/.
Summary
- Prior state and problem: Native fanout writes retained a writer for every partition. When their buffers exceeded the task pool, reservation failure failed the task, and retrying could encounter the same limit.
- Design approach:
FanoutPartitionsowns each partition’s feed, writer, memory counter and selected properties. After reservation refusal, it closes the largest partitions until the remaining reservation fits. - Correctness: Traced held-row flushing, repeated close/reopen, partition metadata, shared filename generation and cleanup ownership.
PartitionFeed.finish()drains pending rows before closing, and reopened writers retain the selected properties. Compared Spark’s write/commit/abort lifecycle across supported source versions and iceberg-java’s fanout implementation. No introduced correctness issue met the P1/P2 bar. - Compatibility analysis: Checked Spark sources for 3.4.3, 3.5.9, 4.0.4, 4.1.3 and 4.2.0, plus Iceberg fanout contracts across 1.5.2–1.11.0. This change preserves planning gates, partition transforms, commit payloads and Java reconstruction of manifest metrics. It adds no version-dependent API calls or configuration defaults.
- Key design decisions: One memory consumer remains responsible for the task. Per-partition counters include both open-file buffers and pending rows. Equal-size candidates use first-seen order, and final files remain sorted by path.
- Implementation sketch:
OpenFileMemorygains child counters,PartitionWriterBuilder.build_with()supports reopening with retained properties, andInnerWriter.reserve()performs close-and-retry.files_closed_earlyis exposed through the JVM SQL metrics. Rust tests, Spark tests and documentation cover the changed behavior. - Performance: Closing files releases writer buffers and lets constrained writes finish. The cost is additional files, writes and footer reads. Candidate sorting occurs only after reservation refusal and costs
O(P log P)forPpartitions. Existing per-batch pending-memory accounting remains linear. No measured throughput regression was established. - Design: The approach fits the required selective-close operation. The pinned iceberg-rust
FanoutWriterexposes only whole-writer close and keeps its partition writers private, so a wrapper alone cannot implement this behavior. - Abstraction & complexity:
FanoutPartitionkeeps related lifecycle state together, while the existing builder and feed implementations remain shared. The additional state has a clear role in accounting, reopening and collecting completed files. No concrete simplification meeting the reporting bar was identified. - Behavioral changes worth calling out: Fanout reservation refusal now produces more, smaller files instead of failing. Early close can end a file off the normal 1000-row grid and choose dictionary settings from fewer initial rows. These differences are documented. Compared with
branch-1.1ate9efd9f764ee0a59b7898ff028d6985d4a7a28e1, pressure-driven closing is an intended change from keeping fanout writers open. Unpartitioned and clustered refusal behavior remains unchanged relative to this PR’s base. - Suggested improvements: No additional change met the P1/P2 reporting bar. The existing small-file discussion describes a real, documented tradeoff, but the evidence does not establish an unresolved P1/P2 defect. The other existing threads concern documentation and clarification.
Exact-head CI: run 37793476358 explicitly targets the reviewed SHA. It reports 58 successful checks and 11 skipped checks, with no failures. Logs confirm all 66 iceberg_write tests passed, including both early-close regressions and refusal cleanup. Spark 4.1 logs confirm the constrained-pool fanout row-parity test and unpartitioned failure/cleanup test passed. Iceberg 1.8, 1.9, 1.10 and 1.11 matrix jobs passed. Standalone Spark SQL matrices, macOS and benchmark checks were skipped.
Validation limits: The local focused Rust test command failed during dependency compilation because the shared filesystem ran out of space. No local tests executed. Its build artifacts were removed and the checkout remains clean. No local JVM suite, throughput benchmark or live S3/GCS write was run. Runtime conclusions rely on the verified exact-head CI results alongside source inspection.
Everything a fanout write reserves belongs to one of its partitions, so once every partition holding memory is closed the resize is down to nothing, which the pool cannot refuse. The contributor guide said a fanout write could still fail with nothing left to close; only an unpartitioned or clustered write can. FanoutPartition's key is used to open every file of the partition, not only to write held rows out at close.
… holding the most No test pinned the order in which a write the pool refuses closes its partitions: closing every partition at once, or closing them smallest first or in the order they were first seen, passed every test. The new test has 16 or 64 partitions take turns through 32 batches in an eighth of the memory the write needs, and asserts fewer than six files per partition. Closing the largest first writes between four and five, closing all at once seven, and the other two orders seventeen. The user guide now says how many files to expect, and that Iceberg's rewrite_data_files compacts them.
Yes, that's the case where it happens, once the pool can't hold the write: every partition keeps getting rows, so a partition closed early opens a new file on its next rows. When the pool holds the write, nothing closes early and each partition gets one file, as before. How many files per partition depends on how far short the pool falls, not on how many partitions there are. I measured it with rows taking turns across partitions as in your example (40 batches of 4096 rows with 100-byte payloads, about 18 MB), and the pool set to a fraction of the most the same write reserves with room to spare. Files per partition:
So a pool 1/k of what the write needs gives roughly (k + 1) / 2 files per partition. Closing the partition holding the most is what keeps it that low, since that frees as much as one close can. At 1/8, closing every partition at once gives 7 files per partition, and closing them in first-seen order, which is smallest first here, gives 17 to 25. Nothing tested that order, so bb1788f adds Before this PR the same write failed its task, and the retry failed the same way. iceberg-java's fanout writer keeps every file open until the task ends, so it writes one file per partition as long as the JVM heap holds all of them. Where the extra files matter, Avoiding the small files without more memory would take what Spark's own file writer does past |
Closing partitions smallest first or in first-seen order writes 17 files per partition with 16 partitions and 20 with 64; the comment only gave the first.
comphead
left a comment
There was a problem hiding this comment.
1. Drop the expect in FanoutPartition::write — let the type system prove the invariant
Avoiding unwrap()/expect() in favor of making the state unrepresentable: Option::insert sets the value and returns &mut T, so the "opened above" invariant becomes structural instead of a documented panic:
async fn write(
&mut self,
builder: &PartitionWriterBuilder,
unit: RecordBatch,
) -> iceberg::Result<()> {
let writer = match self.writer.as_mut() {
Some(writer) => writer,
None => {
let properties = self
.properties
.get_or_insert_with(|| builder.properties.take(Some(self.key.data())))
.clone();
self.writer.insert(
builder
.build_with(Some(self.key.clone()), properties, self.memory.clone())
.await?,
)
}
};
writer.write(unit).await
}2. Guard OpenFileMemory::child() against a second level of nesting
The parent chain in OpenFileShare::set only propagates one level: a child() of a child would add to the middle counter but silently never reach the task-level total, under-counting the reservation. Today nobody does this, but nothing stops a future caller. Either a debug assertion:
/// A counter for one fanout partition's files, which also counts towards this one.
fn child(&self) -> OpenFileMemory {
debug_assert!(
self.parent.is_none(),
"OpenFileMemory propagates one level; a child's child would not reach the task total"
);
OpenFileMemory {
bytes: Arc::default(),
parent: Some(Arc::clone(&self.bytes)),
}
}…or, more thoroughly, split the type: OpenFileMemory (task total, has child()) → PartitionMemory (has share() only), so the nesting rule is enforced at compile time.
3. Centralize the held_bytes diff bookkeeping
The held_before / subtract / re-add pattern is repeated at every call site and is easy to get wrong when a fourth site appears:
/// Reconciles the shared held-rows total after partition `i`'s feed changed,
/// given what it held before the change.
fn sync_held(&mut self, i: usize, held_before: usize) {
self.held_bytes = self.held_bytes - held_before + self.partitions[i].feed.held_bytes();
}used as:
let partition = &mut self.partitions[i];
let held_before = partition.feed.held_bytes();
let units = partition.feed.push(part, properties)?;
self.sync_held(i, held_before);(close_early's -= partition.feed.held_bytes() is the same invariant from the other direction; a short doc comment on held_bytes stating "always kept equal to the sum of the feeds' held_bytes()" would tie the three sites together.)
4. Document why reserve goes through indexes rather than references
by_memory() returning Vec<usize> and reserve then re-indexing fanout.partitions[i] looks like an iterator-shaped hole; it's actually required (closing mutably borrows the partition while the ranking is alive, and the index doubles as the first-seen tie-break). One line on by_memory would save the next reader from trying to "fix" it:
/// The partitions holding memory, as indexes into `partitions`, the most first. Of two holding
/// as much, the one seen first comes first, so which files a write closes early does not
/// depend on `HashMap` order.
/// Indexes rather than references: `reserve` mutates (closes) each partition it picks while
/// the rest of the ranking must stay alive.
5. Minor: skip a copy in the test helper
payload_rows_from takes &[&str] only to .to_vec() it for StringArray::from, which accepts Vec<&str> directly — taking the Vec<&str> the caller already built avoids the copy and shortens the helper:
fn payload_rows_from(first: usize, regions: Vec<&str>, payload_bytes: usize) -> RecordBatch {
let rows = regions.len();
// …
Arc::new(StringArray::from(regions)),
}…ts counters in debug builds - `FanoutPartition::write` takes the writer it opens from `Option::insert`, so it no longer needs an `expect`. - `OpenFileMemory::child` asserts that it is called on the task's total, since a share counts only one level up. - `FanoutPartitions::write` asserts that `held_bytes` is still the sum of what the feeds hold back, and the field's doc names the places that keep it so. - `by_memory` says why it ranks partitions by index. - `payload_rows_from` takes its regions by value instead of copying them.
|
Addressed in c29bb0a:
|
|
@unikdahal you have worked on the split Iceberg write path (#6693 and others). If you have time, would you be able to review this one? Thanks. |
| let mut pending = self.pending_bytes(); | ||
| let mut refused = match reservation.try_resize(open_files.bytes() + pending) { | ||
| Ok(()) => return Ok(0), | ||
| Err(refused) => refused, |
There was a problem hiding this comment.
I think this catches more than refusals. With the Spark-backed pools, try_grow() goes through SparkMemory::try_acquire() to TaskMemoryManager.acquireExecutionMemory. That call can throw, for example a SparkOutOfMemoryError when spilling another consumer fails, or a RuntimeException when the task is interrupted. The JNI bridge keeps those as DataFusionError::External, while a normal "no room" is ResourcesExhausted.
Right now an External error lands in refused and we start closing partitions. Once we've closed enough that the target drops below the current reservation, the next try_resize() is just a shrink: it never calls Spark, so it succeeds and we return Ok. The exception is gone. Spark may also have handed out part of the request before the spill threw, and the memory doc (memory_management.md, around line 205) says a caller that swallows the exception has to reconcile that grant.
Before this PR the ? failed the task. Could we only retry on ResourcesExhausted and return everything else, here and at line 1383?
Err(e) if matches!(e.find_root(), DataFusionError::ResourcesExhausted(_)) => e,
Err(e) => return Err(e),A small test with a pool that returns External would keep this from coming back.
There was a problem hiding this comment.
+1, and I think this needs to be fixed before merge. Tracing it through SparkMemory::try_acquire, a Java throwable from acquireExecutionMemory is cleared by check_exception and comes back as DataFusionError::External. Any other CometError, such as Internal, becomes Execution. So the filter should be "retry only on ResourcesExhausted" rather than "skip External", as in your snippet.
Besides a failed spill, this also swallows the InterruptedException a killed task gets while it waits in ExecutionMemoryPool.acquireMemory. A speculative loser or a cancelled task would then carry on writing files until something else notices the kill.
The same check is needed at both try_resize calls, the first one and the one inside the loop.
There was a problem hiding this comment.
Thanks, both of you. Fixed in 9270e17. Both try_resize calls now retry only when the error's root is ResourcesExhausted, through a small is_refusal helper, and pass anything else straight back, so an exception from Spark fails the task as it did before this PR. I checked that every pool we build reports a plain refusal that way: greedy_unified and fair_unified both return ResourcesExhausted, the TrackConsumersPool and SpillReplayPool wrappers keep the variant, and unbounded never refuses. So early close still happens under each of them, and the CometIcebergWriteActionSuite fanout test, which runs against the real pool, still closes partitions early.
The new a_fanout_write_fails_on_a_resize_error_that_is_not_a_refusal uses a pool that refuses twice, so two partitions close early, and then fails the third resize. It runs once with an External error and once with an Execution one, so it pins "retry only on refusals" rather than "skip External". Treating every error as a refusal, as before, fails both cases, and skipping only External fails the second.
| }; | ||
| for (closed, i) in fanout.by_memory().into_iter().enumerate() { | ||
| pending -= fanout.partitions[i].feed.pending_bytes(); | ||
| fanout.close_early(i, properties).await?; |
There was a problem hiding this comment.
Not a blocker, more of a question. After the pool says no, close_early() can still allocate. finish() writes out the pending rows first, and a partition that only has held rows opens a new Parquet writer to encode them. We don't resize again until that writer has closed, so the pool never sees that peak.
I know the writers only reserve what they hold between batches, so some memory used during a call is outside the reservation on purpose. It just feels worth a quick check since this runs right after a refusal. Do you have any allocator numbers for this case? PeakMemoryPool only sees what we reserve.
There was a problem hiding this comment.
I measured it by adding exact peak tracking to Comet's accounting allocator in a scratch build, and recording the peak allocation during each close_early above what was allocated just before it, over every partition closed in six tight-pool writes. A partition holding only held-back rows, tens to hundreds of KB, peaked at 1.4 to 2.3 times what it was charged, with a floor of about 50 KB for the Parquet writer itself, so an 11 KB partition peaked at 51 KB. A partition holding an open file, or MBs of held-back rows, peaked at 0.5 to 1.05 times what it was charged. The largest peak was 3 MB, in a write whose pool was 19 MB.
Each close then gave back about what the partition was charged, or a little more, and the closes run one partition at a time, so the extra never stacks. It's the same work the task's final close already does for every partition, just earlier. So I'd leave it outside the reservation, like the rest of what a write allocates within a batch.
| let partition = &mut self.partitions[i]; | ||
| self.held_bytes -= partition.feed.held_bytes(); | ||
| let data_files = partition.close(&self.builder, properties).await?; | ||
| self.closed.extend(data_files); |
There was a problem hiding this comment.
These DataFiles stay in memory until the task ends, and their per-column metrics and bounds aren't counted in the reservation. That was already true for rolled files, but early close can multiply the file count exactly when memory is tight. It's probably small, but a quick measurement with many partitions and wide columns would settle it. Fine as a follow-up.
There was a problem hiding this comment.
I measured it with the same scratch allocator by cloning each DataFile. It's about 1 KB for a 3-column table and 25 KB for a 101-column one, mostly the per-column sizes, counts and bounds. Next to what a close frees that's small: 25 KB kept for a median 330 KB freed with 101 columns, and for 2.3 MB freed once the partitions have files open. It does add up over a task, though. In a 101-column write of 64 partitions given a quarter of the memory it needed, the 89 files closed early kept 2.2 MB until the task ended, about a fifth of its 10 MB pool, none of it reserved. Rolled files already had the same gap, so I filed #6885 to count the kept DataFiles in the reservation.
| writers.nonEmpty, | ||
| s"the write did not run natively:\n${snapshot.plans.mkString("\n--\n")}") | ||
| assert( | ||
| writers.map(_.metrics("files_closed_early").value).sum > 0, |
There was a problem hiding this comment.
This checks that the metric is wired up and non-zero, but not that the number is right. The Rust tests pass WriteMetrics::default(), so an off-by-one in closed + 1 would still pass. One check of the actual count, for example against the number of extra files, would cover it. No need to pin the exact file count.
There was a problem hiding this comment.
One way to check the count without pinning a file count is in the Rust tests. Pass a real WriteMetrics into fanout_write_in_part_of_the_pool and assert closed_early <= tight.len() && tight.len() <= closed_early + partitions. At TARGET_FILE_SIZE no file rolls, so every file is either closed early or closed at the end, at most one per partition. Counting closed instead of closed + 1 fails that whenever a refusal closes a single partition.
There was a problem hiding this comment.
Went with viirya's version in e9d8f76. fanout_write_in_part_of_the_pool now gives its tight write a real WriteMetrics and checks closed_early <= files <= closed_early + partitions, so the three early-close tests all check the count. Counting closed instead of closed + 1 fails a_fanout_write_short_of_memory_closes_the_partitions_holding_the_most with 28 counted for 70 files over 16 partitions, and counting one too many fails it with 84. The Scala test still only checks that the metric reaches the JVM.
| partitions: Vec<FanoutPartition>, | ||
| /// Where each partition value sits in `partitions`. | ||
| index: HashMap<IcebergStruct, usize>, | ||
| /// Bytes the feeds hold back for their dictionary choice, across all partitions. Every |
There was a problem hiding this comment.
"every partition stays open" reads like it's left over from before. Partitions can close early now, so maybe say every partition stays in FanoutPartitions instead.
There was a problem hiding this comment.
Fixed in d6d4be6. It now says every partition stays in partitions until the task ends, which is what makes the held-back rows share one limit.
| /// shares (`open_files`, see [`MeteredParquetWriter`]) and the rows waiting in their | ||
| /// [`PartitionFeed`]s. When the pool refuses, a fanout write writes out and closes its | ||
| /// partitions, the one holding the most first, until what is left fits, and this returns how | ||
| /// many it closed. The unpartitioned and clustered writers hold one file open, at most a row |
There was a problem hiding this comment.
"at most a row group" isn't quite right on S3 or GCS. Per the memory doc, the file can also hold its last flushed part there. The point about having nothing to close still stands.
There was a problem hiding this comment.
Right, the size was wrong for S3 and GCS, and the comment doesn't need it. In 9270e17 it just says those writers hold one file open and cannot close it early.
|
Thanks @andygrove , this is a really nice piece of work. I spent a while on the close/reopen path and it holds up well: held rows get flushed, the writer properties carry over when a partition reopens, file names stay unique through the shared counter, cleanup still sees the reopened files, and the output order stays deterministic. There's one thing I think needs fixing before merge. |
viirya
left a comment
There was a problem hiding this comment.
Thanks @andygrove, this is a careful piece of work. I traced the close and reopen path and it holds up. finish() leaves the feed with its choice made and the pacer empty, so a reopened file starts on the 1000-row grid without making a second dictionary choice. Early-closed files are tracked by TrackingLocationGenerator from the moment their location is generated. The path sort keeps the output deterministic, and the happy path still does one try_resize per batch. Keeping the properties on FanoutPartition is a clean way to get reopening right.
The one thing I'd like fixed before merge is the error handling in InnerWriter::reserve, which @unikdahal raised. It currently retries on any error from try_resize, so an exception coming back from Spark can be swallowed. I've added detail on that thread, plus a test that would also cover a cleanup case nothing tests today. The other inline comments are about docs that should follow the fix, and about checking the files_closed_early count.
| /// early, fails with the pool's error rather than holding memory nothing accounts for, | ||
| /// and deletes the files it had opened. An unpartitioned write keeps its one file open. | ||
| #[tokio::test] | ||
| async fn a_write_the_pool_cannot_hold_fails_and_deletes_its_files() { |
There was a problem hiding this comment.
Now that this test uses an unpartitioned write, nothing checks that files a fanout write closed early are deleted when the task fails later. The mechanism should already handle it, since TrackingLocationGenerator records each location when it is generated. Could we add a test that pins it, together with the error handling in reserve? A test pool that refuses the first few resizes and then returns a non-ResourcesExhausted error would make some partitions close early and then hit the error. The test could assert that the write returns that error and that parquet_files_under(dir) is empty afterwards.
There was a problem hiding this comment.
Added in 9270e17 as a_fanout_write_fails_on_a_resize_error_that_is_not_a_refusal, much as you describe. Its pool refuses the first two resizes, so two partitions close early and their files are complete on disk, and then fails the third with an error that is not a refusal. The test asserts that the write returns that error and that parquet_files_under(dir) is empty, and checks that both refusals were used, so the early closes did happen before the error.
| `OpenFileMemory`, which is how `reserve` finds them. That is why the fanout path uses | ||
| `FanoutPartitions` rather than iceberg-rust's `FanoutWriter`, which cannot close one partition's | ||
| writer. A closed partition's next rows open a new file with the properties its first file used, | ||
| which the partition keeps. The pool cannot fail a fanout write: everything the write reserves |
There was a problem hiding this comment.
Once reserve passes through errors that are not refusals, "The pool cannot fail a fanout write" is too strong. What holds is that a refusal cannot fail it. An exception from Spark, such as a failed spill or a killed task, still fails the task. Could this say "A refusal from the pool cannot fail a fanout write"?
There was a problem hiding this comment.
Done in 9270e17. It now says a refusal from the pool cannot fail a fanout write, and that any other error from a resize fails the task, with the failed spill and the killed task as examples.
| when the pool refuses a resize the task fails with a `CometNativeException` whose message starts | ||
| `ParquetWriterExec[N]` or `IcebergWriteExec[N]`, resized after every batch. Neither can spill. A | ||
| fanout Iceberg write gives memory back instead, by closing partitions early when the pool refuses a | ||
| resize, as described below, so the pool never fails it. When the pool refuses any other write's |
There was a problem hiding this comment.
Same as in iceberg-writes.md: "so the pool never fails it" should be limited to refusals. An error from Spark during the resize should still fail the task once reserve passes it through.
There was a problem hiding this comment.
Done in 9270e17, with the same split: a refusal never fails it, and an exception Spark throws during a resize still fails the task.
The Rust tests passed `WriteMetrics::default()` and never read it back, so a miscount in `reserve`'s return went unnoticed. `write_reserving_from` now takes the metrics, and `fanout_write_in_part_of_the_pool` gives its tight write a real `WriteMetrics` and checks `closed_early <= files <= closed_early + partitions`: no file rolls at the target size, so every file was closed either early or when the task ended, at most one per partition. Counting one file fewer or one more per refusal fails `a_fanout_write_short_of_memory_closes_the_partitions_holding_the_most`.
…writer `reserve` treated any error from `try_resize` as the pool refusing, and closed partitions until the resize fit. A pool backed by Spark can also fail a resize with an exception Spark threw, such as a spill of another consumer that failed or the `InterruptedException` of a task killed while it waited for memory. That comes back as `External` (a Java throwable) or `Execution` (any other Comet error), while every pool reports a refusal as `ResourcesExhausted`. Once enough partitions had closed, the resize was a shrink that never called Spark, so the write carried on and the exception was lost. Both `try_resize` calls now retry only on `ResourcesExhausted` and pass anything else back, which fails the task. The new test's pool refuses twice, so two partitions close early, then fails the third resize with an `External` or an `Execution` error. It asserts that the write returns that error and deletes every file it created, the two closed early included. The contributor guide now says a refusal from the pool cannot fail a fanout write, rather than the pool, and the comment on `reserve` no longer says an open file holds at most a row group, which is wrong on S3 and GCS.
…il the task ends The comment on `held_bytes` still said every partition stays open, which stopped being true once partitions could close early. What makes the held-back rows share one limit is that every partition, and its feed, stays in `partitions`.
|
@viirya @unikdahal thanks for the reviews. I've addressed your comments and replied in each thread. |
viirya
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround, @andygrove. All of my comments are addressed.
reserve now closes partitions only when the pool refuses, through is_refusal, and passes any other error from either resize straight back. a_fanout_write_fails_on_a_resize_error_that_is_not_a_refusal pins that with both an External and an Execution error. It also checks that both refusals were used before the error, so it covers the cleanup of files closed early, which nothing tested before. The bound on files_closed_early in fanout_write_in_part_of_the_pool now checks the count in all three early-close tests, and the two docs separate a refusal from an exception Spark throws.
The measurements on the transient peak during close_early and on the kept DataFiles are helpful, and #6885 is the right place for the second one. LGTM.
unikdahal
left a comment
There was a problem hiding this comment.
Thanks @andygrove for the quick turnaround on the previous review comments, and for the detailed measurements around the transient close peak and retained DataFile metadata. The follow-up changes look good to me, especially the ResourcesExhausted filtering and the regression test that exercises early closes followed by a real resize error and verifies cleanup.
I did one more pass and only noticed one small, non-blocking observability detail: write_time now wraps InnerWriter::reserve() as well as writer.write(). Before this PR, the timer stopped before reservation.try_resize(...), so "time in native Iceberg writer" excluded memory-pool acquisition. On the Spark-backed pools, reserve() can eventually block while Spark waits for execution memory, so under contention that wait can now be attributed to the Iceberg writer metric even when no partition is closed early.
Definitely not something I think should hold up the merge queue, but it may be worth either keeping the ordinary resize outside write_time as before, or documenting that the metric now also includes memory reservation/wait time.
Thanks again. this was a careful change and the added tests make the behavior much easier to reason about.
Which issue does this PR close?
Closes #6771.
Part of #5644.
Rationale for this change
Since #6247, the native Iceberg writer's buffers count against the task's memory pool. A fanout write keeps a data file open for every partition a task writes to, and the writer cannot spill, so a task that writes to enough partitions fails with
Additional allocation failed for IcebergWriteExecwhere iceberg-java's fanout writer, buffering on the JVM heap, succeeds. Spark's retry takes the same path, so the job fails. Review of #6664, which makes native writes the default, asked for this to be fixed first: on Spark 3.5 and later, Iceberg writes an unsorted partitioned table with the fanout writer by default.What changes are included in this PR?
FanoutPartitionsreplaces iceberg-rust'sFanoutWriterfor fanout writes. It keeps every partition the task has seen, in first-seen order, each with its feed, its open writer and the writer properties its files use, and can close one partition's writer before the task ends, whichFanoutWritercannot.OpenFileMemory, so the writer can rank partitions by the memory they hold.InnerWriter::reservewrites out and closes the partitions holding the most memory, in their open file and in the rows their feed has not handed over, until the reservation fits. Taking the largest first frees the most memory per file closed. Of partitions holding as much, the one seen first closes first, so which files a write produces does not depend onHashMaporder. Everything a fanout write reserves belongs to one of its partitions, so once they are all closed it reserves nothing, and a refusal from the pool cannot fail it.ResourcesExhausted, and any other error from the resize still fails the task, such as an exception Spark threw because a spill failed or because the task was killed while it waited for memory.files_closed_earlymetric onCometIcebergWriteExeccounts the files closed early.DataFiles of the files a task closes early stay in memory until the task ends, outside its reservation. Native Iceberg writes: count the DataFiles a task keeps in its memory reservation #6885 tracks counting them.How are these changes tested?
files_closed_earlycount. No file rolls at their target size, so each file closed either early or when the task ended, which closes at most one per partition. Counting one file fewer or one more per refusal fails the close-order test.Externaland onceExecution. The write fails with that error and deletes every file it created, the two closed early included. Retrying on every error, or on every error butExternal, fails it.CometIcebergWriteActionSuite: the 64-partition fanout write under a 4 MiB pool now interleaves its partitions and succeeds, with the same rows as its source, 219 files against 64 under the full pool, and a non-zerofiles_closed_early. A new test keeps the failure and cleanup coverage with an unpartitioned write whose file outgrows the pool.iceberg_writeRust tests and workspace clippy pass, and so do the Iceberg write action, detection, rewrite and system-function suites on Spark 4.1 (205 tests).