unikdahal commented on code in PR #6773:
URL: https://github.com/apache/datafusion-comet/pull/6773#discussion_r4243573935
##########
spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala:
##########
@@ -3109,10 +3109,69 @@ class CometIcebergWriteActionSuite
}
withNativeEnabled {
+ // withSQLConf returns Unit before Spark 4.0, so the assertions run
inside it.
withSQLConf(CometConf.COMET_OFFHEAP_MEMORY_POOL_FRACTION.key ->
"0.002") {
- val (failedPlans, error) = captureFailedPlans(spark) {
+ val snapshot = captureWrite("fanout_oom") {
spark.sql(s"INSERT INTO $catalog.$ns.fanout_oom SELECT * FROM
fanout_oom_src")
}
+ assert(
+ snapshot.snapshotDelta == 1L,
+ s"expected 1 commit, got ${snapshot.snapshotDelta}")
+ val writers = snapshot.plans.flatMap { plan =>
+ collectWithSubqueries(plan) { case w: CometIcebergWriteExec => w }
+ }
+ assert(
+ 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,
Review 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.
##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -1087,17 +1348,47 @@ impl InnerWriter {
fn pending_bytes(&self) -> usize {
match self {
InnerWriter::Unpartitioned(_, feed) => feed.pending_bytes(),
- InnerWriter::Fanout(_, _, fanout) => fanout
- .feeds
- .values()
- .map(|(_, feed)| feed.pending_bytes())
- .sum(),
+ InnerWriter::Fanout(fanout, _) => fanout.pending_bytes(),
InnerWriter::Clustered(_, _, live) => {
live.as_ref().map_or(0, |(_, feed)| feed.pending_bytes())
}
}
}
+ /// Resizes `reservation` to what the task's writers hold between batches:
the open files'
+ /// 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
+ /// group, and cannot close it early, so the pool's refusal stands.
+ async fn reserve(
+ &mut self,
+ reservation: &MemoryReservation,
+ open_files: &OpenFileMemory,
+ properties: &PartitionProperties,
+ ) -> DFResult<usize> {
+ let mut pending = self.pending_bytes();
+ let mut refused = match reservation.try_resize(open_files.bytes() +
pending) {
+ Ok(()) => return Ok(0),
+ Err(refused) => refused,
Review 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?
```rust
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.
##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -1087,17 +1348,47 @@ impl InnerWriter {
fn pending_bytes(&self) -> usize {
match self {
InnerWriter::Unpartitioned(_, feed) => feed.pending_bytes(),
- InnerWriter::Fanout(_, _, fanout) => fanout
- .feeds
- .values()
- .map(|(_, feed)| feed.pending_bytes())
- .sum(),
+ InnerWriter::Fanout(fanout, _) => fanout.pending_bytes(),
InnerWriter::Clustered(_, _, live) => {
live.as_ref().map_or(0, |(_, feed)| feed.pending_bytes())
}
}
}
+ /// Resizes `reservation` to what the task's writers hold between batches:
the open files'
+ /// 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
+ /// group, and cannot close it early, so the pool's refusal stands.
+ async fn reserve(
+ &mut self,
+ reservation: &MemoryReservation,
+ open_files: &OpenFileMemory,
+ properties: &PartitionProperties,
+ ) -> DFResult<usize> {
+ let mut pending = self.pending_bytes();
+ let mut refused = match reservation.try_resize(open_files.bytes() +
pending) {
+ Ok(()) => return Ok(0),
+ Err(refused) => refused,
+ };
+ let InnerWriter::Fanout(fanout, _) = self else {
+ return Err(refused);
+ };
+ for (closed, i) in fanout.by_memory().into_iter().enumerate() {
+ pending -= fanout.partitions[i].feed.pending_bytes();
+ fanout.close_early(i, properties).await?;
Review 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.
##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -1057,7 +1111,218 @@ async fn run_write_task(
}
}
-/// Enum-based dispatch over the three iceberg-rust partitioning writers, each
paired with the
+/// A fanout write: like iceberg-rust's `FanoutWriter`, a data file writer
open for every
+/// partition the task has written to, except that a partition can be closed
before the task ends,
+/// to give back the memory it holds. Its next rows then open a new file, with
the same properties.
+/// `FanoutWriter` keeps its writers private and closes them only all at once,
so the fanout path
+/// keeps its own.
+///
+/// iceberg-java's fanout writer keeps every file open until the task ends,
its buffers growing on
+/// the JVM heap. The native writer's buffers count against the task's memory
pool instead, so when
+/// the pool refuses them, closing partitions early lets the write finish with
more, smaller files
+/// rather than fail (see [`InnerWriter::reserve`]).
+struct FanoutPartitions {
+ builder: PartitionWriterBuilder,
+ /// Every partition the task has seen, in the order it first saw them.
+ 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
Review 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.
##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -1057,7 +1111,218 @@ async fn run_write_task(
}
}
-/// Enum-based dispatch over the three iceberg-rust partitioning writers, each
paired with the
+/// A fanout write: like iceberg-rust's `FanoutWriter`, a data file writer
open for every
+/// partition the task has written to, except that a partition can be closed
before the task ends,
+/// to give back the memory it holds. Its next rows then open a new file, with
the same properties.
+/// `FanoutWriter` keeps its writers private and closes them only all at once,
so the fanout path
+/// keeps its own.
+///
+/// iceberg-java's fanout writer keeps every file open until the task ends,
its buffers growing on
+/// the JVM heap. The native writer's buffers count against the task's memory
pool instead, so when
+/// the pool refuses them, closing partitions early lets the write finish with
more, smaller files
+/// rather than fail (see [`InnerWriter::reserve`]).
+struct FanoutPartitions {
+ builder: PartitionWriterBuilder,
+ /// Every partition the task has seen, in the order it first saw them.
+ 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
+ /// partition stays open, so the rows they hold back share one limit: once
they reach it, every
+ /// partition still holding makes its choice from the rows it has. A task
fanning out to many
+ /// partitions that each get less than a page would otherwise hold all of
its rows back,
+ /// uncompressed, until it closed.
+ ///
+ /// Always the sum of the feeds' [`PartitionFeed::held_bytes`], kept in
step wherever a feed
+ /// changes what it holds back: a push, the release once the limit is
reached, and an early
+ /// close. `write` checks this in debug builds.
+ held_bytes: usize,
+ /// The data files of the partitions closed early.
+ closed: Vec<DataFile>,
+}
+
+/// One partition of a fanout write.
+struct FanoutPartition {
+ /// What each of the partition's files is opened with: its first, and any
it opens after
+ /// closing one early.
+ key: PartitionKey,
+ feed: PartitionFeed,
+ /// The writer of the partition's open file, if it has one open.
+ writer: Option<PartitionDataFileWriter>,
+ /// What the open file holds, as part of the task's [`OpenFileMemory`].
+ memory: OpenFileMemory,
+ /// The properties the partition's files are written with, taken when its
first file opens and
+ /// kept for the files it opens after closing one early.
+ properties: Option<WriterProperties>,
+}
+
+impl FanoutPartition {
+ /// What the partition holds: its open file and the rows its feed has not
handed over.
+ fn bytes(&self) -> usize {
+ self.memory.bytes() + self.feed.pending_bytes()
+ }
+
+ /// Writes `unit` to the partition's open file, opening one first if it
has none.
+ 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
+ }
+
+ /// Writes out every row the partition still holds back and closes its
file, returning that
+ /// file's data files. A file closed at a partial unit ends off
iceberg-java's row grid, as the
+ /// last file of a task does.
+ async fn close(
+ &mut self,
+ builder: &PartitionWriterBuilder,
+ properties: &PartitionProperties,
+ ) -> DFResult<Vec<DataFile>> {
+ for unit in self.feed.finish(properties)? {
+ self.write(builder, unit).await.map_err(iceberg_err)?;
+ }
+ match self.writer.take() {
+ Some(mut writer) => writer.close().await.map_err(iceberg_err),
+ None => Ok(Vec::new()),
+ }
+ }
+}
+
+impl FanoutPartitions {
+ fn new(builder: PartitionWriterBuilder) -> Self {
+ Self {
+ builder,
+ partitions: Vec::new(),
+ index: HashMap::new(),
+ held_bytes: 0,
+ closed: Vec::new(),
+ }
+ }
+
+ /// Memory held by the rows waiting in the feeds, for every partition the
task has seen.
+ fn pending_bytes(&self) -> usize {
+ self.partitions
+ .iter()
+ .map(|partition| partition.feed.pending_bytes())
+ .sum()
+ }
+
+ /// Writes `batch`, feeding each of its partitions separately.
+ async fn write(
+ &mut self,
+ splitter: &PartitionSplitter,
+ batch: &RecordBatch,
+ properties: &PartitionProperties,
+ ) -> DFResult<()> {
+ for (key, part) in splitter.split_groups(batch)? {
+ let next = self.partitions.len();
+ let i = *self.index.entry(key.data().clone()).or_insert(next);
+ if i == next {
+ self.partitions.push(FanoutPartition {
+ feed: PartitionFeed::new(Some(key.data().clone()),
splitter.slicer),
+ key,
+ writer: None,
+ memory: self.builder.open_files.child(),
+ properties: None,
+ });
+ }
+ let partition = &mut self.partitions[i];
+ let held_before = partition.feed.held_bytes();
+ let units = partition.feed.push(part, properties)?;
+ self.held_bytes = self.held_bytes - held_before +
partition.feed.held_bytes();
+ for unit in units {
+ partition
+ .write(&self.builder, unit)
+ .await
+ .map_err(iceberg_err)?;
+ }
+ }
+ if !properties.chooser.may_hold(self.held_bytes) {
+ for partition in &mut self.partitions {
+ for unit in partition.feed.release(properties)? {
+ partition
+ .write(&self.builder, unit)
+ .await
+ .map_err(iceberg_err)?;
+ }
+ }
+ self.held_bytes = 0;
+ }
+ debug_assert_eq!(
+ self.held_bytes,
+ self.partitions
+ .iter()
+ .map(|partition| partition.feed.held_bytes())
+ .sum::<usize>(),
+ "the held-back total drifted from what the feeds hold back"
+ );
+ Ok(())
+ }
+
+ /// 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, because
`reserve` closes each
+ /// partition it picks while it walks the ranking, and a close borrows the
whole
+ /// `FanoutPartitions` mutably.
+ fn by_memory(&self) -> Vec<usize> {
+ let mut holding: Vec<(usize, usize)> = self
+ .partitions
+ .iter()
+ .enumerate()
+ .map(|(i, partition)| (partition.bytes(), i))
+ .filter(|&(bytes, _)| bytes > 0)
+ .collect();
+ holding.sort_unstable_by_key(|&(bytes, i)| (Reverse(bytes), i));
+ holding.into_iter().map(|(_, i)| i).collect()
+ }
+
+ /// Closes the partition at `i` before the task ends, keeping its data
files for the output.
+ async fn close_early(&mut self, i: usize, properties:
&PartitionProperties) -> DFResult<()> {
+ 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);
Review Comment:
These `DataFile`s 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.
##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -1087,17 +1348,47 @@ impl InnerWriter {
fn pending_bytes(&self) -> usize {
match self {
InnerWriter::Unpartitioned(_, feed) => feed.pending_bytes(),
- InnerWriter::Fanout(_, _, fanout) => fanout
- .feeds
- .values()
- .map(|(_, feed)| feed.pending_bytes())
- .sum(),
+ InnerWriter::Fanout(fanout, _) => fanout.pending_bytes(),
InnerWriter::Clustered(_, _, live) => {
live.as_ref().map_or(0, |(_, feed)| feed.pending_bytes())
}
}
}
+ /// Resizes `reservation` to what the task's writers hold between batches:
the open files'
+ /// 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
Review 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.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]