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

hansva 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 958c4f48e1 Issue #8667 : Cancel Table Input and Database Join queries 
when a pipeline stops (#8846)
958c4f48e1 is described below

commit 958c4f48e195f7f1e5df26673e557a1d0dfec63d
Author: Matt Casters <[email protected]>
AuthorDate: Thu Oct 15 16:53:08 2026 +0200

    Issue #8667 : Cancel Table Input and Database Join queries when a pipeline 
stops (#8846)
    
    * Issue #8667 : Cancel Table Input and Database Join queries when a 
pipeline stops
    
    * Issue #8667 : Cancel a running query without deadlocking and without 
reporting a failure
    
    Database Join stopRunning() cancelled the statement while holding dbLock, 
the
    same lock lookupValues holds across the query and putRow. stopAll holds the
    transform list while it calls stopRunning, and a stopped putRow calls 
stopAll,
    so the two threads deadlocked.
    
    Cancel the statement without dbLock. A query that fails because the pipeline
    is already stopped is a clean stop in both Database Join and Table Input: no
    error is logged and the error count stays at 0.
    
    Co-authored-by: Matt Casters <[email protected]>
    
    ---------
    
    Co-authored-by: Cursor Agent <[email protected]>
---
 .../transforms/databasejoin/DatabaseJoin.java      |  44 +++--
 .../transforms/databasejoin/DatabaseJoinTest.java  | 179 ++++++++++++++++--
 .../pipeline/transforms/tableinput/TableInput.java |  35 +++-
 .../transforms/tableinput/TableInputTest.java      | 210 ++++++++++++++++++++-
 4 files changed, 435 insertions(+), 33 deletions(-)

diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
index 7db91d2ac7..8af6a0e870 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
+++ 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.pipeline.transforms.databasejoin;
 
+import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.util.ArrayList;
 import java.util.List;
@@ -244,6 +245,12 @@ public class DatabaseJoin extends 
BaseTransform<DatabaseJoinMeta, DatabaseJoinDa
         logBasic(BaseMessages.getString(PKG, "DatabaseJoin.Log.LineNumber") + 
getLinesRead());
       }
     } catch (HopException e) {
+      if (isStopped()) {
+        // The lookup was canceled because the pipeline stopped. The driver 
reports that as a
+        // failed query, but this is a clean stop: no error, and no further 
rows.
+        logDebug(e.getMessage());
+        return false;
+      }
 
       if (getTransformMeta().isDoingErrorHandling()) {
         sendToErrorRow = true;
@@ -269,28 +276,35 @@ public class DatabaseJoin extends 
BaseTransform<DatabaseJoinMeta, DatabaseJoinDa
   }
 
   /**
-   * Stop the running query In the Database Join transform data.isCancelled is 
checked before
-   * synchronization and set after synchronization is completed.
+   * Cancel the running prepared statement.
+   *
+   * <p>{@code Pipeline.stopTransform} marks this transform stopped before it 
calls this method, so
+   * the stopped flag must not skip the cancel. {@code data.isCanceled} 
prevents a second cancel.
+   * Cancel is skipped once the transform is disposed, because dispose has 
already closed the
+   * statement and dropped the connection.
    *
-   * <p>To cancel a prepared statement we need a valid database connection 
which we do not have if
-   * disposed has already been called
+   * <p>Do not hold {@code dbLock} across {@link 
Database#cancelStatement(java.sql.Statement)}.
+   * {@code lookupValues} keeps that lock for the whole lookup, including 
{@code putRow}. A stopped
+   * {@code putRow} calls {@code stopAll}, and {@code stopAll} holds the 
pipeline transform list
+   * while it calls this method. Waiting for {@code dbLock} here deadlocks 
those two threads. JDBC
+   * {@code Statement.cancel()} is safe to call from this stopping thread 
while the lookup thread is
+   * blocked in the driver.
    */
   @Override
   public void stopRunning() throws HopException {
-    if (this.isStopped() || data.isDisposed()) {
+    if (data.isDisposed()) {
       return;
     }
 
-    dbLock.lock();
-
-    try {
-      if (data.db != null && data.db.getConnection() != null && 
!data.isCanceled) {
-        data.db.cancelStatement(data.pstmt);
-        setStopped(true);
-        data.isCanceled = true;
-      }
-    } finally {
-      dbLock.unlock();
+    // Read the connection and statement without dbLock, then cancel outside 
any lock the lookup
+    // thread holds. dispose() may clear these fields; a cancel of a statement 
that is already
+    // closing is harmless compared with deadlocking the pipeline.
+    Database database = data.db;
+    PreparedStatement statement = data.pstmt;
+    if (database != null && database.getConnection() != null && 
!data.isCanceled) {
+      database.cancelStatement(statement);
+      setStopped(true);
+      data.isCanceled = true;
     }
   }
 
diff --git 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
index 3566da85ac..f0c59658d8 100644
--- 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
+++ 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
@@ -17,12 +17,17 @@
 
 package org.apache.hop.pipeline.transforms.databasejoin;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -31,8 +36,16 @@ import static org.mockito.Mockito.withSettings;
 
 import java.sql.Connection;
 import java.sql.PreparedStatement;
+import java.sql.SQLException;
+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.database.Database;
+import org.apache.hop.core.exception.HopDatabaseException;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.pipeline.Pipeline;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
@@ -83,38 +96,41 @@ class DatabaseJoinTest {
                 mockPipeline));
   }
 
+  /**
+   * Pipeline.stopTransform marks the transform stopped before stopRunning(). 
That flag must not
+   * skip the cancel.
+   */
   @Test
-  void testStopRunningWhenTransformIsStopped() throws HopException {
+  void testStopRunningCancelsWhenTransformIsAlreadyStopped() throws 
HopException {
     doReturn(true).when(mockDatabaseJoin).isStopped();
+    doReturn(false).when(mockTransformDataInterface).isDisposed();
+    
when(mockTransformDataInterface.db.getConnection()).thenReturn(mock(Connection.class));
 
     mockDatabaseJoin.stopRunning();
 
-    verify(mockDatabaseJoin, times(1)).isStopped();
-    verify(mockTransformDataInterface, times(0)).isDisposed();
+    verify(mockTransformDataInterface.db, 
times(1)).cancelStatement(any(PreparedStatement.class));
+    assertTrue(mockTransformDataInterface.isCanceled);
+    verify(mockDatabaseJoin, times(1)).setStopped(true);
   }
 
   @Test
   void testStopRunningWhenTransformDataInterfaceIsDisposed() throws 
HopException {
-    doReturn(false).when(mockDatabaseJoin).isStopped();
     doReturn(true).when(mockTransformDataInterface).isDisposed();
 
     mockDatabaseJoin.stopRunning();
 
-    verify(mockDatabaseJoin, times(1)).isStopped();
     verify(mockTransformDataInterface, times(1)).isDisposed();
+    verify(mockTransformDataInterface.db, 
times(0)).cancelStatement(any(PreparedStatement.class));
+    assertFalse(mockTransformDataInterface.isCanceled);
   }
 
   @Test
-  void
-      
testStopRunningWhenTransformIsNotStoppedNorTransformDataInterfaceIsDisposedAndDatabaseConnectionIsValid()
-          throws HopException {
-    doReturn(false).when(mockDatabaseJoin).isStopped();
+  void testStopRunningCancelsWhenDatabaseConnectionIsValid() throws 
HopException {
     doReturn(false).when(mockTransformDataInterface).isDisposed();
     
when(mockTransformDataInterface.db.getConnection()).thenReturn(mock(Connection.class));
 
     mockDatabaseJoin.stopRunning();
 
-    verify(mockDatabaseJoin, times(1)).isStopped();
     verify(mockTransformDataInterface, times(1)).isDisposed();
     verify(mockTransformDataInterface.db, times(1)).getConnection();
     verify(mockTransformDataInterface.db, 
times(1)).cancelStatement(any(PreparedStatement.class));
@@ -122,19 +138,152 @@ class DatabaseJoinTest {
   }
 
   @Test
-  void
-      
testStopRunningWhenTransformIsNotStoppedNorTransformDataInterfaceIsDisposedAndDatabaseConnectionIsNotValid()
-          throws HopException {
-    doReturn(false).when(mockDatabaseJoin).isStopped();
+  void testStopRunningDoesNotCancelTwice() throws HopException {
+    doReturn(false).when(mockTransformDataInterface).isDisposed();
+    
when(mockTransformDataInterface.db.getConnection()).thenReturn(mock(Connection.class));
+
+    mockDatabaseJoin.stopRunning();
+    mockDatabaseJoin.stopRunning();
+
+    verify(mockTransformDataInterface.db, 
times(1)).cancelStatement(any(PreparedStatement.class));
+    assertTrue(mockTransformDataInterface.isCanceled);
+  }
+
+  @Test
+  void testStopRunningWhenDatabaseConnectionIsNotValid() throws HopException {
     doReturn(false).when(mockTransformDataInterface).isDisposed();
     when(mockTransformDataInterface.db.getConnection()).thenReturn(null);
 
     mockDatabaseJoin.stopRunning();
 
-    verify(mockDatabaseJoin, times(1)).isStopped();
     verify(mockTransformDataInterface, times(1)).isDisposed();
     verify(mockTransformDataInterface.db, times(1)).getConnection();
     verify(mockTransformDataInterface.db, 
times(0)).cancelStatement(any(PreparedStatement.class));
     assertFalse(mockTransformDataInterface.isCanceled);
   }
+
+  /**
+   * lookupValues holds {@code dbLock} for the whole lookup, including the 
query and {@code putRow}.
+   * stopAll holds the pipeline transform list while it calls stopRunning. 
Cancel has to run without
+   * that lock, or the stop never returns.
+   */
+  @Test
+  void stopDuringRunningQueryDoesNotDeadlockAndEndsWithNoErrors() throws 
Exception {
+    stubRunningLookup();
+    doReturn(false).when(mockTransformDataInterface).isDisposed();
+    
when(mockTransformDataInterface.db.getConnection()).thenReturn(mock(Connection.class));
+
+    CountDownLatch queryStarted = new CountDownLatch(1);
+    CountDownLatch cancelCalled = new CountDownLatch(1);
+    when(mockTransformDataInterface.db.openQuery(
+            any(PreparedStatement.class), any(IRowMeta.class), 
any(Object[].class)))
+        .thenAnswer(
+            invocation -> {
+              // Still inside lookupValues, so dbLock is held by this thread.
+              queryStarted.countDown();
+              assertTrue(
+                  cancelCalled.await(5, TimeUnit.SECONDS),
+                  "stopRunning did not cancel while the lookup held dbLock");
+              throw new HopDatabaseException(
+                  "The query was canceled", new SQLException("The query was 
canceled"));
+            });
+    doAnswer(
+            invocation -> {
+              cancelCalled.countDown();
+              return null;
+            })
+        .when(mockTransformDataInterface.db)
+        .cancelStatement(any(PreparedStatement.class));
+
+    AtomicReference<Throwable> queryFailure = new AtomicReference<>();
+    AtomicBoolean keepGoing = new AtomicBoolean(true);
+    Thread queryThread =
+        new Thread(
+            () -> {
+              try {
+                keepGoing.set(mockDatabaseJoin.processRow());
+              } catch (Throwable t) {
+                queryFailure.set(t);
+              }
+            },
+            "database-join-lookup");
+    queryThread.setDaemon(true);
+    queryThread.start();
+    assertTrue(queryStarted.await(5, TimeUnit.SECONDS), "lookup did not reach 
the query");
+
+    AtomicReference<Throwable> stopFailure = new AtomicReference<>();
+    Thread stopThread =
+        new Thread(
+            () -> {
+              try {
+                // Pipeline.stopTransform sets the flag before stopRunning().
+                mockDatabaseJoin.setStopped(true);
+                mockDatabaseJoin.stopRunning();
+              } catch (Throwable t) {
+                stopFailure.set(t);
+              }
+            },
+            "database-join-stop");
+    stopThread.setDaemon(true);
+    stopThread.start();
+    stopThread.join(3_000);
+
+    assertFalse(stopThread.isAlive(), "stopRunning deadlocked while the lookup 
held dbLock");
+    assertNull(stopFailure.get());
+    queryThread.join(3_000);
+    assertFalse(queryThread.isAlive(), "lookup did not finish after the query 
was cancelled");
+    assertNull(queryFailure.get());
+    assertFalse(keepGoing.get());
+    assertEquals(0, mockDatabaseJoin.getErrors());
+    verify(mockDatabaseJoin, never()).stopAll();
+    verify(mockDatabaseJoin, never()).logError(anyString(), 
any(Throwable.class));
+    verify(mockTransformDataInterface.db, 
times(1)).cancelStatement(any(PreparedStatement.class));
+  }
+
+  /** A cancel that surfaces from the running lookup is a clean stop, not a 
failed SQL query. */
+  @Test
+  void cancelDuringQueryEndsWithNoErrors() throws Exception {
+    stubRunningLookup();
+    when(mockTransformDataInterface.db.openQuery(
+            any(PreparedStatement.class), any(IRowMeta.class), 
any(Object[].class)))
+        .thenThrow(
+            new HopDatabaseException(
+                "The query was canceled", new SQLException("The query was 
canceled")));
+    mockDatabaseJoin.setStopped(true);
+
+    assertFalse(mockDatabaseJoin.processRow());
+
+    assertEquals(0, mockDatabaseJoin.getErrors());
+    verify(mockDatabaseJoin, never()).stopAll();
+    verify(mockDatabaseJoin, never()).logError(anyString(), 
any(Throwable.class));
+    verify(mockDatabaseJoin).logDebug(anyString());
+  }
+
+  /** A query error while the pipeline is still running is still a failure. */
+  @Test
+  void queryFailureWhileRunningSetsAnError() throws Exception {
+    stubRunningLookup();
+    doNothing().when(mockDatabaseJoin).stopAll();
+    doNothing().when(mockDatabaseJoin).setOutputDone();
+    when(mockTransformDataInterface.db.openQuery(
+            any(PreparedStatement.class), any(IRowMeta.class), 
any(Object[].class)))
+        .thenThrow(new HopDatabaseException("syntax error", new 
SQLException("bad sql")));
+
+    assertFalse(mockDatabaseJoin.processRow());
+
+    assertEquals(1, mockDatabaseJoin.getErrors());
+    verify(mockDatabaseJoin).stopAll();
+    verify(mockDatabaseJoin).logError(anyString(), any(Throwable.class));
+  }
+
+  /** Skip the first-row metadata setup and block in the lookup query itself. 
*/
+  private void stubRunningLookup() throws HopException {
+    mockDatabaseJoin.first = false;
+    mockTransformDataInterface.lookupRowMeta = new RowMeta();
+    mockTransformDataInterface.keynrs = new int[0];
+    doReturn(new Object[] {"in"}).when(mockDatabaseJoin).getRow();
+    doReturn(new RowMeta()).when(mockDatabaseJoin).getInputRowMeta();
+    doNothing().when(mockDatabaseJoin).logDebug(anyString());
+    doNothing().when(mockDatabaseJoin).logError(anyString(), 
any(Throwable.class));
+  }
 }
diff --git 
a/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java
 
b/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java
index 6320edcbf8..cb10b4e819 100644
--- 
a/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java
+++ 
b/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java
@@ -282,6 +282,9 @@ public class TableInput extends 
BaseTransform<TableInputMeta, TableInputData> {
         data.rs = data.db.openQuery(sql, boundMeta, boundData, 
ResultSet.FETCH_FORWARD, false);
       }
     } catch (HopDatabaseException ex) {
+      if (stoppedDuringQuery(ex)) {
+        return false;
+      }
       Throwable root = ex.getCause();
       if (root instanceof SQLException) {
         logError(
@@ -346,6 +349,9 @@ public class TableInput extends 
BaseTransform<TableInputMeta, TableInputData> {
           }
         }
       } catch (HopDatabaseException ex) {
+        if (stoppedDuringQuery(ex)) {
+          return false;
+        }
         logError("Error reading rows from query result: " + ex.getMessage());
         if (isDebug()) {
           logDebug(Const.getStackTracker(ex));
@@ -389,10 +395,35 @@ public class TableInput extends 
BaseTransform<TableInputMeta, TableInputData> {
     super.dispose();
   }
 
-  /** Stop the running query */
+  /**
+   * A cancel while the statement is executing surfaces as a database 
exception. The pipeline has
+   * already been stopped, so this is not a failed query.
+   *
+   * @return {@code true} when the exception should end the transform without 
an error
+   */
+  private boolean stoppedDuringQuery(HopDatabaseException ex) {
+    if (!isStopped()) {
+      return false;
+    }
+    // Same handling as a cancel caught while reading later rows in 
processRow().
+    logDebug(ex.getMessage());
+    return true;
+  }
+
+  /**
+   * Cancel the open query.
+   *
+   * <p>{@code Pipeline.stopTransform} marks this transform stopped before it 
calls this method, so
+   * the stopped flag must not skip the cancel. {@code data.isCanceled} 
prevents a second cancel. A
+   * disposed transform has already closed the connection.
+   *
+   * <p>{@code doQuery} does not hold this transform's monitor, so the cancel 
below is not waiting
+   * on a lock the query thread holds. Do not add one: {@code stopAll} keeps 
the pipeline transform
+   * list locked while it calls this method, and a stopped {@code putRow} 
calls {@code stopAll}.
+   */
   @Override
   public synchronized void stopRunning() throws HopException {
-    if (this.isStopped() || data.isDisposed()) {
+    if (data.isDisposed()) {
       return;
     }
 
diff --git 
a/plugins/transforms/tableinput/src/test/java/org/apache/hop/pipeline/transforms/tableinput/TableInputTest.java
 
b/plugins/transforms/tableinput/src/test/java/org/apache/hop/pipeline/transforms/tableinput/TableInputTest.java
index ec9607c991..ad37476514 100644
--- 
a/plugins/transforms/tableinput/src/test/java/org/apache/hop/pipeline/transforms/tableinput/TableInputTest.java
+++ 
b/plugins/transforms/tableinput/src/test/java/org/apache/hop/pipeline/transforms/tableinput/TableInputTest.java
@@ -17,6 +17,10 @@
 
 package org.apache.hop.pipeline.transforms.tableinput;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.ArgumentMatchers.notNull;
@@ -24,8 +28,10 @@ import static org.mockito.ArgumentMatchers.nullable;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
@@ -33,12 +39,19 @@ import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.sql.SQLException;
 import java.util.UUID;
+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.HopClientEnvironment;
 import org.apache.hop.core.database.Database;
 import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.exception.HopDatabaseException;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.core.row.value.ValueMetaString;
@@ -46,6 +59,7 @@ import org.apache.hop.core.variables.Variables;
 import org.apache.hop.databases.h2.H2DatabaseMeta;
 import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
 import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engine.EngineComponent.ComponentExecutionStatus;
 import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.junit.jupiter.api.BeforeAll;
@@ -173,7 +187,7 @@ class TableInputTest {
    * stopped.
    */
   private static void assertSucceeded(TableInput tableInput) {
-    org.junit.jupiter.api.Assertions.assertEquals(0, tableInput.getErrors());
+    assertEquals(0, tableInput.getErrors());
     verify(tableInput, never()).stopAll();
   }
 
@@ -258,4 +272,198 @@ class TableInputTest {
     assertQueryWithoutBind(db, "SELECT '?' AS Q FROM T WHERE X = 1");
     assertSucceeded(tableInput);
   }
+
+  /**
+   * Pipeline.stopTransform marks the transform stopped before stopRunning(). 
The open statement
+   * must still be cancelled, or drivers such as SQL Server keep streaming the 
rest of the result
+   * set while the pipeline stays in Halting.
+   */
+  @Test
+  void stopRunningCancelsQueryWhenTransformIsAlreadyStopped() throws Exception 
{
+    doReturn(mock(Connection.class)).when(db).getConnection();
+    tableInput.setStopped(true);
+
+    tableInput.stopRunning();
+
+    verify(db, times(1)).cancelQuery();
+    assertTrue(data.isCanceled);
+    assertTrue(tableInput.isStopped());
+  }
+
+  @Test
+  void stopRunningDoesNotCancelTwice() throws Exception {
+    doReturn(mock(Connection.class)).when(db).getConnection();
+
+    tableInput.stopRunning();
+    tableInput.stopRunning();
+
+    verify(db, times(1)).cancelQuery();
+    assertTrue(data.isCanceled);
+  }
+
+  @Test
+  void stopRunningDoesNothingWhenDisposed() throws Exception {
+    doReturn(mock(Connection.class)).when(db).getConnection();
+    data.setStatus(ComponentExecutionStatus.STATUS_DISPOSED);
+
+    tableInput.stopRunning();
+
+    verify(db, never()).cancelQuery();
+    assertFalse(data.isCanceled);
+  }
+
+  @Test
+  void stopRunningDoesNothingWhenConnectionIsMissing() throws Exception {
+    doReturn(null).when(db).getConnection();
+
+    tableInput.stopRunning();
+
+    verify(db, never()).cancelQuery();
+    assertFalse(data.isCanceled);
+    assertTrue(tableInput.isStopped());
+  }
+
+  /**
+   * doQuery does not hold a lock stopRunning waits on. A stop during 
openQuery must cancel and
+   * return, then the canceled query ends the transform with no errors.
+   */
+  @Test
+  void stopDuringRunningQueryDoesNotBlockAndEndsWithNoErrors() throws 
Exception {
+    stubHeaderRow();
+    doReturn(mock(Connection.class)).when(db).getConnection();
+    CountDownLatch queryStarted = new CountDownLatch(1);
+    CountDownLatch cancelCalled = new CountDownLatch(1);
+    doAnswer(
+            invocation -> {
+              queryStarted.countDown();
+              assertTrue(cancelCalled.await(5, TimeUnit.SECONDS), "query was 
not cancelled");
+              throw new HopDatabaseException(
+                  "The query was canceled", new SQLException("The query was 
canceled"));
+            })
+        .when(db)
+        .openQuery(
+            anyString(),
+            nullable(IRowMeta.class),
+            nullable(Object[].class),
+            anyInt(),
+            anyBoolean());
+    doAnswer(
+            invocation -> {
+              cancelCalled.countDown();
+              return null;
+            })
+        .when(db)
+        .cancelQuery();
+
+    AtomicReference<Throwable> queryFailure = new AtomicReference<>();
+    AtomicBoolean keepGoing = new AtomicBoolean(true);
+    Thread queryThread =
+        new Thread(
+            () -> {
+              try {
+                keepGoing.set(tableInput.processRow());
+              } catch (Throwable t) {
+                queryFailure.set(t);
+              }
+            },
+            "table-input-query");
+    queryThread.setDaemon(true);
+    queryThread.start();
+    assertTrue(queryStarted.await(5, TimeUnit.SECONDS), "query did not start");
+
+    AtomicReference<Throwable> stopFailure = new AtomicReference<>();
+    Thread stopThread =
+        new Thread(
+            () -> {
+              try {
+                tableInput.setStopped(true);
+                tableInput.stopRunning();
+              } catch (Throwable t) {
+                stopFailure.set(t);
+              }
+            },
+            "table-input-stop");
+    stopThread.setDaemon(true);
+    stopThread.start();
+    stopThread.join(3_000);
+
+    assertFalse(stopThread.isAlive(), "stopRunning blocked while the query was 
running");
+    assertNull(stopFailure.get());
+    queryThread.join(3_000);
+    assertFalse(queryThread.isAlive(), "query did not finish after it was 
cancelled");
+    assertNull(queryFailure.get());
+    assertFalse(keepGoing.get());
+    assertCleanStop();
+    verify(db).cancelQuery();
+  }
+
+  /**
+   * Stop before the first row: openQuery fails with "query was canceled", and 
that is not an error.
+   */
+  @Test
+  void cancelDuringQueryEndsWithNoErrors() throws Exception {
+    stubHeaderRow();
+    tableInput.setStopped(true);
+    doThrow(
+            new HopDatabaseException(
+                "The query was canceled", new SQLException("The query was 
canceled")))
+        .when(db)
+        .openQuery(
+            anyString(),
+            nullable(IRowMeta.class),
+            nullable(Object[].class),
+            anyInt(),
+            anyBoolean());
+
+    assertFalse(tableInput.processRow());
+
+    assertCleanStop();
+    verify(tableInput).logDebug(any());
+  }
+
+  /**
+   * Some drivers return from execute and block on the first fetch. Cancel 
there is a clean stop.
+   */
+  @Test
+  void cancelWhileReadingFirstRowEndsWithNoErrors() throws Exception {
+    stubHeaderRow();
+    tableInput.setStopped(true);
+    doThrow(
+            new HopDatabaseException(
+                "The query was canceled", new SQLException("The query was 
canceled")))
+        .when(db)
+        .getRow(any(ResultSet.class));
+
+    assertFalse(tableInput.processRow());
+
+    assertCleanStop();
+    verify(tableInput, never())
+        .logError(org.mockito.ArgumentMatchers.contains("Error reading rows"));
+  }
+
+  /** A real SQL failure while the pipeline is still running is still 
reported. */
+  @Test
+  void queryFailureWhileRunningSetsAnError() throws Exception {
+    stubHeaderRow();
+    doThrow(new HopDatabaseException("syntax error", new SQLException("bad 
sql")))
+        .when(db)
+        .openQuery(
+            anyString(),
+            nullable(IRowMeta.class),
+            nullable(Object[].class),
+            anyInt(),
+            anyBoolean());
+
+    assertFalse(tableInput.processRow());
+
+    assertEquals(1, tableInput.getErrors());
+    verify(tableInput).stopAll();
+    verify(tableInput).logError(org.mockito.ArgumentMatchers.contains("SQL 
query failed"));
+  }
+
+  private void assertCleanStop() {
+    assertEquals(0, tableInput.getErrors());
+    verify(tableInput, never()).stopAll();
+    verify(tableInput, never()).logError(any());
+  }
 }

Reply via email to