sunchao commented on code in PR #5513:
URL: https://github.com/apache/datafusion-comet/pull/5513#discussion_r3877264838


##########
native/shuffle/src/writers/rss/rss_partition_writer.rs:
##########
@@ -125,35 +132,633 @@ impl RssPartitionWriter {
     where
         I: Iterator<Item = Result<RecordBatch>>,
     {
-        for batch in batches.by_ref() {
-            let batch = batch?;
-            self.frame.clear();
+        let result = (|| {
+            for batch in batches.by_ref() {
+                self.push_batch_within_limit(partition_id, &batch?, metrics)?;
+            }
+            Ok(())
+        })();
+        if result.is_err() {
+            // Earlier frames may have been accepted remotely; a partial map 
cannot be resumed or
+            // committed after its input, encoding, reservation, or callback 
fails.
+            self.failed = true;
+        }
+        result
+    }
+
+    fn push_batch_within_limit(
+        &mut self,
+        partition_id: i32,
+        batch: &RecordBatch,
+        metrics: &ShufflePartitionerMetrics,
+    ) -> Result<()> {
+        if batch.num_rows() == 0 {
+            return Ok(());
+        }
+
+        // Estimate only live nested rows before allocation. Dictionary values 
remain charged
+        // because Arrow's dense garbage collection scans their complete value 
tables.
+        let original_size = 
Self::estimated_pre_compaction_ipc_data_size(batch)?;
+        let compaction_scratch = Self::estimated_compaction_scratch(batch)?;
+        let minimum_size = 
Self::estimated_minimum_compacted_ipc_data_size(batch)?;
+        if minimum_size > self.max_frame_size && batch.num_rows() > 1 {
+            return self.push_split_batch(partition_id, batch, metrics);
+        }
+
+        // Arrow's array estimates omit the schema, FlatBuffers messages, and 
compression-frame
+        // overhead. A small metadata allowance avoids most retries without 
charging every tiny
+        // batch for the configured maximum frame. Unusually large metadata is 
handled below by
+        // releasing the entire reservation and retrying with a larger, 
still-bounded estimate.
+        const IPC_METADATA_RESERVATION_BYTES: usize = 4 * 1024;
+        let mut frame_bound = original_size
+            .saturating_add(IPC_METADATA_RESERVATION_BYTES)
+            .min(self.max_frame_size);
 
-            let encoded_size = self.block_writer.write_batch(
-                &batch,
-                &mut Cursor::new(&mut self.frame),
+        loop {
+            // Native IPC, its JNI byte array, and Celeborn's copied transport 
request overlap.
+            // Acquire all three copies before compaction/encoding to avoid 
allocation before
+            // admission and deadlocks caused by growing another task's 
partial reservation.
+            let overlapping_copies = frame_bound.checked_mul(3).ok_or_else(|| {
+                DataFusionError::Execution(
+                    "Remote shuffle frame-copy reservation exceeds the native 
integer limit"
+                        .to_string(),
+                )
+            })?;
+            // Legacy Java callbacks retain the full JVM-soft frame limit but 
inherit a no-op
+            // reservation method whose argument is still a signed Java int. 
Concrete bounded
+            // pushers advertise a smaller frame limit, so their actual 
three-copy reservation
+            // fits; capping here preserves legacy callbacks and their failure 
propagation.
+            let reservation = overlapping_copies
+                .max(original_size.saturating_add(compaction_scratch))
+                .min(self.pusher.max_reservation_size());
+            self.pusher.reserve_partition_data(reservation)?;

Review Comment:
   [P2] Charge codec workspace and allocated capacity before encoding
   
   At exact head `be5f4af7d7876e862af9be172b95e2e704acf049`, the new 
`spark.comet.shuffle.rss.maxInFlightBytes` contract includes native encoding 
scratch, but this reservation covers estimated IPC/frame lengths and compaction 
only. The actual `ShuffleBlockWriter` also creates codec working buffers, and 
`BoundedBuffer` bounds its Vec's length rather than its allocated capacity. 
Consequently, a valid budget can be exceeded even by one admitted encoder, 
before transport completion or the separate callback-lifetime issue matters.
   
   I independently compiled the exact current/base RSS writer sources with the 
unchanged block writer and measured successful live Rust heap allocation 
requests only during encoding, excluding input/schema/metric construction. With 
a 16,384-byte budget, the real Java pusher accepts a one-Int32-row LZ4 
reservation of 12,687 bytes (12,703 including the Celeborn header; effective 
frame cap 5,456), but encoding peaks at 154,497 bytes and produces a 238-byte 
frame. The uncompressed control peaks at 1,160 bytes. This is not only a 
tiny-budget codec case: with 16,384 Int32 rows and a 262,144-byte budget, the 
accepted reservation is 215,424 + 16, while LZ4 peaks at 285,321 bytes. Without 
compression, that batch's 67,996-byte frame has 135,976 bytes of Vec capacity; 
adding its JNI and Celeborn copies requires at least 271,984 bytes, also above 
the budget. The Java reservation checks were exercised with stock Celeborn 
0.6.3 and 0.7.0.
   
   Base `eabb5d4773091b983d8fce713f0e34b1cf93f877` has the same codec 
allocation peaks but no encoding-admission protocol or byte-budget setting. The 
introduced defect is the new limit admitting work without accounting for memory 
it explicitly promises to cover; this is not a claim that the codec allocations 
themselves were introduced here. Please include codec workspace and actual 
buffer capacities in the bound, or reject budgets that cannot accommodate them, 
before allowing encoding to begin.



##########
spark/src/main/java/org/apache/comet/shuffle/CelebornShufflePartitionPusher.java:
##########
@@ -175,62 +570,449 @@ public void pushPartitionData(int partitionId, byte[] 
data, int length) throws I
     if (length < MINIMUM_COMET_FRAME_BYTES || length > data.length) {
       throw new IOException("Celeborn shuffle frame length must describe one 
complete frame");
     }
+    if (length > maxFrameBytes) {
+      throw new IOException("Celeborn shuffle frame exceeds its configured 
maximum frame size");
+    }
 
-    final long declaredBodyLength = 
ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).getLong();
+    long declaredBodyLength = 
ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).getLong();
     if (declaredBodyLength != (long) length - Long.BYTES) {
       throw new IOException(
           "Celeborn shuffle frame declares "
               + declaredBodyLength
               + " body bytes, but contains "
               + (length - Long.BYTES));
     }
+  }
 
-    final int accepted;
+  private PushReservation claimEncodingReservation(int frameBytes) throws 
IOException {
+    int required = Math.addExact(Math.multiplyExact(frameBytes, 3), 
CELEBORN_BATCH_HEADER_BYTES);
+    PushReservation reservation = encodingReservation.get();
+    if (reservation != null) {
+      if (required > reservation.bytes) {
+        throw new IOException("Celeborn shuffle push exceeds its native 
encoding reservation");
+      }
+      encodingReservation.remove();
+      synchronized (lifecycleLock) {
+        activeEncoders--;
+        lifecycleLock.notifyAll();
+      }
+      if (required < reservation.bytes) {
+        admission.release(reservation.bytes - required);
+        reservation.bytes = required;
+      }
+      return reservation;
+    }
+    admission.acquire(required, this::isAborted);
+    return new PushReservation(required);
+  }
+
+  private ObservedPushState observePushState(Object pushState) throws 
IllegalAccessException {
+    synchronized (lifecycleLock) {
+      ObservedPushState observed = observedPushStates.get(pushState);
+      if (observed != null) {
+        return observed;
+      }
+      Object tracker = inFlightRequestTracker.get(pushState);
+      AtomicReference<?> exception = (AtomicReference<?>) 
pushStateException.get(pushState);
+      ObservedPushState created =
+          new ObservedPushState((LongAdder) 
totalInFlightRequests.get(tracker), exception);
+      observedPushStates.put(pushState, created);
+      return created;
+    }
+  }
+
+  private void beginPush() throws IOException {
+    synchronized (lifecycleLock) {
+      if (asynchronousFailure != null) {
+        throw asynchronousFailure;
+      }
+      if (state != State.OPEN) {
+        throw new IOException("Celeborn shuffle map attempt no longer accepts 
partition data");
+      }
+      activePushes++;
+    }
+  }
+
+  private void beginClientPush() throws IOException {
+    synchronized (lifecycleLock) {
+      if (state == State.ABORTED) {
+        throw new IOException("Celeborn shuffle map attempt was aborted before 
its client push");
+      }
+      activeClientPushes++;
+    }
+  }
+
+  private void registerPendingPush(PushReservation reservation, 
ObservedPushState pushState)
+      throws IOException {
+    synchronized (lifecycleLock) {
+      if (state == State.ABORTED) {
+        throw new IOException("Celeborn shuffle map attempt was aborted before 
submission");
+      }
+      reservation.pushState = pushState;
+      pendingPushes.addLast(reservation);
+      if (completionReconciliation == null || 
completionReconciliation.isDone()) {
+        completionReconciliation =
+            COMPLETION_RECONCILER.scheduleWithFixedDelay(
+                this::safelyReconcileAcceptedPushes,
+                RECONCILIATION_INTERVAL_MILLIS,
+                RECONCILIATION_INTERVAL_MILLIS,
+                TimeUnit.MILLISECONDS);
+      }
+    }
+  }
+
+  private void markSubmitted(
+      PushReservation reservation, ObservedPushState pushState, int 
acceptedBytes) {
+    int released;
+    synchronized (lifecycleLock) {
+      reservation.pushState = pushState;
+      reservation.submitted = true;
+      pushState.submittedPushes++;
+      int retained = Math.min(reservation.bytes, acceptedBytes);
+      released = reservation.bytes - retained;
+      reservation.bytes = retained;

Review Comment:
   [P2] Retain native and JNI frame charges until their buffers are released
   
   At exact head `be5f4af7d7876e862af9be172b95e2e704acf049`, `markSubmitted` 
shrinks admission to the Celeborn transport byte count while the caller still 
owns both the native output Vec and the JNI byte array. A fast transport 
completion can then release the remainder. The JNI local frame is popped only 
after `JavaShufflePartitionPusher::push_partition_data` returns from its 
callback, and the native RSS writer drops its output afterward. Another map can 
therefore acquire the released permits while those two frame copies remain live.
   
   I reproduced this using the actual Rust RSS writer, real 
JavaShufflePartitionPusher JNI bridge, unmodified Java pusher, and stock 
Celeborn 0.6.3/0.7.0 request registration/removal/success callbacks. Only 
network delivery and scheduling before JNI return were controlled. Two maps 
each encode 2,048 Int32 rows without compression, producing 8,860-byte frames, 
with `maxFrameBytes=8860` and the valid shared budget `maxInFlightBytes=26596`. 
After the first production Java push returns but before JNI releases its 
array/native frame, all 26,596 permits are available. The second encoder 
enters; the two paused calls then own at least 35,440 bytes of native/JNI frame 
data alone, excluding capacity and all transport/allocator overhead. A control 
deferring semaphore release until the first native writer returns leaves zero 
permits, blocks the second encoder until those buffers are gone, and then lets 
both complete.
   
   The exact base `eabb5d4773091b983d8fce713f0e34b1cf93f877` has these same 
frame lifetimes but no shared admission protocol; the faulty release transition 
is new in this PR. This is separate from the fixed cleanup/transport-callback 
leak and from codec workspace accounting. Please keep the native/JNI portion 
charged until those owners actually finish rather than releasing it when the 
raw Celeborn call returns or its transport callback completes.



-- 
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]

Reply via email to