You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by mc...@apache.org on 2026/08/02 12:05:03 UTC

(hop) branch main updated: issue #7742: flush last batch before marking BlockingBatchingRowSet done (#7745)

This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new dcee613882 issue #7742: flush last batch before marking BlockingBatchingRowSet done (#7745)
dcee613882 is described below

commit dcee61388286a3a38c5ad6607bce11940a52110e
Author: Matt Casters <ma...@gmail.com>
AuthorDate: Sun Aug 2 14:04:57 2026 +0200

    issue #7742: flush last batch before marking BlockingBatchingRowSet done (#7745)
    
    Enqueue any remaining partial batch before setDone() sets the done flag,
    so concurrent consumers cannot observe isDone() and exit before the final
    rows are available.
---
 .../apache/hop/core/BlockingBatchingRowSet.java    |   4 +-
 .../hop/core/BlockingBatchingRowSetTest.java       | 210 +++++++++++++++++++++
 2 files changed, 213 insertions(+), 1 deletion(-)

diff --git a/core/src/main/java/org/apache/hop/core/BlockingBatchingRowSet.java b/core/src/main/java/org/apache/hop/core/BlockingBatchingRowSet.java
index 88fca37851..fda80f622c 100644
--- a/core/src/main/java/org/apache/hop/core/BlockingBatchingRowSet.java
+++ b/core/src/main/java/org/apache/hop/core/BlockingBatchingRowSet.java
@@ -162,7 +162,8 @@ public class BlockingBatchingRowSet extends BaseRowSet implements Comparable<IRo
 
   @Override
   public void setDone() {
-    super.setDone();
+    // Enqueue the last partial batch before marking done so concurrent consumers that observe
+    // isDone()==true cannot exit before the final rows are available (issue #7742).
     if (putIndex > 0 && putIndex < size && inputBuffer != null) {
       inputBuffer[putIndex] = null; // signal the end of buffer
       for (int i = putIndex + 1; i < size; i++) {
@@ -171,6 +172,7 @@ public class BlockingBatchingRowSet extends BaseRowSet implements Comparable<IRo
       getArray.offer(inputBuffer);
     }
     putArray.clear();
+    super.setDone();
   }
 
   @Override
diff --git a/core/src/test/java/org/apache/hop/core/BlockingBatchingRowSetTest.java b/core/src/test/java/org/apache/hop/core/BlockingBatchingRowSetTest.java
index 09f245aaf2..e632c544ae 100644
--- a/core/src/test/java/org/apache/hop/core/BlockingBatchingRowSetTest.java
+++ b/core/src/test/java/org/apache/hop/core/BlockingBatchingRowSetTest.java
@@ -24,8 +24,16 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.lang.reflect.Field;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.RowMeta;
@@ -118,4 +126,206 @@ class BlockingBatchingRowSetTest {
     assertNotNull(r);
     assertArrayEquals(rows.get(2), r);
   }
+
+  /**
+   * Issue #7742: the last partial batch must be enqueued before the done flag is set, otherwise a
+   * concurrent consumer can observe isDone() and exit before the last rows are available.
+   */
+  @Test
+  void testSetDoneEnqueuesPartialBatchBeforeDoneFlag() throws Exception {
+    BlockingBatchingRowSet set = new BlockingBatchingRowSet(10);
+    IRowMeta rm = createRowMetaInterface();
+
+    // 3 rows with buffer size 5 = partial batch (not flushed by putRow)
+    for (int i = 0; i < 3; i++) {
+      assertTrue(set.putRow(rm, new Object[] {(long) i}));
+    }
+    assertEquals(0, set.size());
+
+    AtomicBoolean offerSeen = new AtomicBoolean(false);
+    AtomicBoolean doneAtOffer = new AtomicBoolean(true); // fail closed if offer never checked
+    instrumentGetArrayOffers(
+        set,
+        () -> {
+          offerSeen.set(true);
+          doneAtOffer.set(set.isDone());
+        });
+
+    set.setDone();
+
+    assertTrue(offerSeen.get(), "partial batch must be offered in setDone()");
+    assertFalse(
+        doneAtOffer.get(),
+        "done flag must not be set before the last batch is offered (issue #7742)");
+    assertTrue(set.isDone());
+  }
+
+  /** When there is nothing left to flush, setDone() should only mark the rowset done. */
+  @Test
+  void testSetDoneWithNoPartialBatchOnlyMarksDone() throws Exception {
+    BlockingBatchingRowSet set = new BlockingBatchingRowSet(10);
+    AtomicBoolean offerSeen = new AtomicBoolean(false);
+    instrumentGetArrayOffers(set, () -> offerSeen.set(true));
+
+    set.setDone();
+
+    assertFalse(offerSeen.get(), "no batch should be offered when nothing was put");
+    assertTrue(set.isDone());
+  }
+
+  /**
+   * When the last put already filled and published a full batch, setDone() must not re-offer; only
+   * the done flag is set.
+   */
+  @Test
+  void testSetDoneAfterFullBatchOnlyMarksDone() throws Exception {
+    BlockingBatchingRowSet set = new BlockingBatchingRowSet(10);
+    IRowMeta rm = createRowMetaInterface();
+
+    // Exactly one full batch (size = maxSize / BATCHSIZE = 5)
+    for (int i = 0; i < 5; i++) {
+      assertTrue(set.putRow(rm, new Object[] {(long) i}));
+    }
+    assertEquals(5, set.size());
+
+    AtomicBoolean offerSeen = new AtomicBoolean(false);
+    instrumentGetArrayOffers(set, () -> offerSeen.set(true));
+
+    set.setDone();
+
+    assertFalse(offerSeen.get(), "full batch was already published; setDone must not re-offer");
+    assertTrue(set.isDone());
+  }
+
+  /**
+   * After setDone() with a partial batch, all non-null rows must be readable (sequential drain).
+   */
+  @Test
+  void testPartialBatchDrainableAfterSetDone() {
+    BlockingBatchingRowSet set = new BlockingBatchingRowSet(10);
+    IRowMeta rm = createRowMetaInterface();
+    final int rowCount = 3;
+
+    for (int i = 0; i < rowCount; i++) {
+      assertTrue(set.putRow(rm, new Object[] {(long) i}));
+    }
+
+    set.setDone();
+    assertTrue(set.isDone());
+
+    List<Object[]> received = drainNonNullRows(set);
+    assertEquals(rowCount, received.size());
+    for (int i = 0; i < rowCount; i++) {
+      assertArrayEquals(new Object[] {(long) i}, received.get(i));
+    }
+  }
+
+  /**
+   * Concurrent producer/consumer using the same done-check pattern as BaseTransform.getRow().
+   * Partial batches must never be lost across repeated runs (issue #7742).
+   */
+  @Test
+  void testNoDataLossOnConcurrentSetDone() throws Exception {
+    final int iterations = 200;
+    final int rowCount = 3;
+    IRowMeta rm = createRowMetaInterface();
+
+    for (int iter = 0; iter < iterations; iter++) {
+      BlockingBatchingRowSet set = new BlockingBatchingRowSet(10);
+      List<Object[]> received = Collections.synchronizedList(new ArrayList<>());
+      CountDownLatch consumerStarted = new CountDownLatch(1);
+      AtomicReference<Throwable> consumerError = new AtomicReference<>();
+
+      Thread consumer =
+          new Thread(
+              () -> {
+                try {
+                  consumerStarted.countDown();
+                  // Mirrors BaseTransform handling when getRowWait returns null
+                  while (true) {
+                    Object[] row = set.getRowWait(20, TimeUnit.MILLISECONDS);
+                    if (row != null) {
+                      received.add(row);
+                    } else if (set.isDone()) {
+                      row = set.getRowWait(1, TimeUnit.MILLISECONDS);
+                      if (row == null) {
+                        break;
+                      }
+                      received.add(row);
+                    }
+                  }
+                } catch (Throwable t) {
+                  consumerError.set(t);
+                }
+              },
+              "batching-rowset-consumer-" + iter);
+
+      consumer.start();
+      assertTrue(consumerStarted.await(5, TimeUnit.SECONDS));
+
+      for (int i = 0; i < rowCount; i++) {
+        assertTrue(set.putRow(rm, new Object[] {(long) i}));
+      }
+      set.setDone();
+
+      consumer.join(10_000);
+      assertFalse(consumer.isAlive(), "consumer did not finish at iteration " + iter);
+      assertNull(consumerError.get(), () -> "consumer failed: " + consumerError.get());
+      assertEquals(
+          rowCount,
+          received.size(),
+          "data loss at iteration " + iter + ", got " + received.size() + " rows");
+      for (int i = 0; i < rowCount; i++) {
+        assertArrayEquals(new Object[] {(long) i}, received.get(i));
+      }
+    }
+  }
+
+  /**
+   * Replace getArray with a queue that invokes {@code onOffer} for every subsequent offer. Existing
+   * batches are preserved without firing the callback.
+   */
+  private static void instrumentGetArrayOffers(BlockingBatchingRowSet set, Runnable onOffer)
+      throws Exception {
+    Field getArrayField = BlockingBatchingRowSet.class.getDeclaredField("getArray");
+    getArrayField.setAccessible(true);
+    @SuppressWarnings("unchecked")
+    BlockingQueue<Object[][]> originalGetArray = (BlockingQueue<Object[][]>) getArrayField.get(set);
+
+    AtomicBoolean logOffers = new AtomicBoolean(false);
+    BlockingQueue<Object[][]> loggingGetArray =
+        new ArrayBlockingQueue<>(Math.max(2, originalGetArray.size() + 2), true) {
+          @Override
+          public boolean offer(Object[][] e) {
+            if (logOffers.get()) {
+              onOffer.run();
+            }
+            return super.offer(e);
+          }
+
+          @Override
+          public boolean offer(Object[][] e, long timeout, TimeUnit unit)
+              throws InterruptedException {
+            if (logOffers.get()) {
+              onOffer.run();
+            }
+            return super.offer(e, timeout, unit);
+          }
+        };
+    // Preserve any batches already published (drainTo uses offer; logging still off)
+    originalGetArray.drainTo(loggingGetArray);
+    getArrayField.set(set, loggingGetArray);
+    logOffers.set(true);
+  }
+
+  private static List<Object[]> drainNonNullRows(BlockingBatchingRowSet set) {
+    List<Object[]> received = new ArrayList<>();
+    Object[] row;
+    // Partial batches pad with null sentinels; stop after the first null once we have started
+    // seeing data, but keep polling while empty before setDone flush visibility in sequential use.
+    while ((row = set.getRowWait(100, TimeUnit.MILLISECONDS)) != null) {
+      received.add(row);
+    }
+    return received;
+  }
 }