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 8d494d08fe Issue #8808 : Do not hold the pipeline engine lock while 
saving execution information (#8810)
8d494d08fe is described below

commit 8d494d08fe06b17821c1f6cba458326ca1d84b89
Author: Bart Maertens <[email protected]>
AuthorDate: Fri Oct 9 15:15:41 2026 +0200

    Issue #8808 : Do not hold the pipeline engine lock while saving execution 
information (#8810)
    
    A slow save to the execution information location blocked the transforms 
from finishing and Hop GUI from stopping the pipeline. The timer tick and the 
final save now use a dedicated lock. Log data sets without row metadata at 
debug level.
---
 .../engines/local/LocalPipelineEngine.java         | 114 +++++---
 .../neo4j/execution/NeoExecutionInfoLocation.java  |   9 +-
 .../NeoExecutionInfoLocationSlowSaveIT.java        | 289 +++++++++++++++++++++
 3 files changed, 366 insertions(+), 46 deletions(-)

diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
 
b/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
index 01da5371ed..617c27ca92 100644
--- 
a/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
+++ 
b/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
@@ -78,6 +78,16 @@ public class LocalPipelineEngine extends Pipeline implements 
IPipelineEngine<Pip
     this.executionInfoLocation = executionInfoLocation;
   }
 
+  /**
+   * Held while the execution information is written: by a timer tick and by 
the final save and
+   * close. It is never the engine itself. A save can take as long as the 
location needs, and the
+   * transforms (finished listeners) and the GUI (stop) wait on the engine.
+   */
+  private final Object executionInfoLock = new Object();
+
+  /** Guards the timer fields only. It is never held while writing to the 
location. */
+  private final Object executionInfoTimerLock = new Object();
+
   private Timer transformExecutionInfoTimer;
   private TimerTask transformExecutionInfoTimerTask;
   private final AtomicInteger executionInfoLastLogLineNr = new 
AtomicInteger(0);
@@ -489,7 +499,13 @@ public class LocalPipelineEngine extends Pipeline 
implements IPipelineEngine<Pip
     }
   }
 
-  public synchronized void startTransformExecutionInfoTimer() throws 
HopException {
+  public void startTransformExecutionInfoTimer() throws HopException {
+    synchronized (executionInfoTimerLock) {
+      scheduleTransformExecutionInfoTimer();
+    }
+  }
+
+  private void scheduleTransformExecutionInfoTimer() throws HopException {
     if (executionInfoLocation == null) {
       return;
     }
@@ -522,9 +538,10 @@ public class LocalPipelineEngine extends Pipeline 
implements IPipelineEngine<Pip
         new TimerTask() {
           @Override
           public void run() {
-            // Hold the engine lock before the location lock. 
stopTransformExecutionInfoTimer()
-            // takes them in that order, and the reverse deadlocks a tick 
against shutdown.
-            synchronized (LocalPipelineEngine.this) {
+            // Not the engine lock: a slow location would block the transforms 
from finishing
+            // and the GUI from stopping the pipeline. 
stopTransformExecutionInfoTimer() waits on
+            // this lock for a running tick before the final save and close.
+            synchronized (executionInfoLock) {
               if (executionInfoLocation == null) {
                 return;
               }
@@ -611,14 +628,17 @@ public class LocalPipelineEngine extends Pipeline 
implements IPipelineEngine<Pip
     }
   }
 
-  private synchronized void cancelTransformExecutionInfoTimer() {
-    if (transformExecutionInfoTimerTask != null) {
-      transformExecutionInfoTimerTask.cancel();
-      transformExecutionInfoTimerTask = null;
-    }
-    if (transformExecutionInfoTimer != null) {
-      ExecutorUtil.cleanup(transformExecutionInfoTimer);
-      transformExecutionInfoTimer = null;
+  /** Cancels the timer without waiting for a running tick: a stop in the GUI 
must return. */
+  private void cancelTransformExecutionInfoTimer() {
+    synchronized (executionInfoTimerLock) {
+      if (transformExecutionInfoTimerTask != null) {
+        transformExecutionInfoTimerTask.cancel();
+        transformExecutionInfoTimerTask = null;
+      }
+      if (transformExecutionInfoTimer != null) {
+        ExecutorUtil.cleanup(transformExecutionInfoTimer);
+        transformExecutionInfoTimer = null;
+      }
     }
   }
 
@@ -655,46 +675,54 @@ public class LocalPipelineEngine extends Pipeline 
implements IPipelineEngine<Pip
     super.pipelineCompleted();
   }
 
-  public synchronized void stopTransformExecutionInfoTimer() {
+  public void stopTransformExecutionInfoTimer() {
     try {
       cancelTransformExecutionInfoTimer();
 
-      ExecutionInfoLocation location = executionInfoLocation;
-      executionInfoLocation = null;
-      if (location == null || location.getExecutionInfoLocation() == null) {
-        return;
+      // Wait for a running tick, then save and close. The cancelled timer 
starts no new tick.
+      //
+      synchronized (executionInfoLock) {
+        saveFinalExecutionInfoAndClose();
       }
+    } catch (Throwable e) {
+      log.logError("Error stopping transform execution info timer 
(non-fatal)", e);
+    }
+  }
 
-      IExecutionInfoLocation iLocation = location.getExecutionInfoLocation();
+  private void saveFinalExecutionInfoAndClose() {
+    ExecutionInfoLocation location = executionInfoLocation;
+    executionInfoLocation = null;
+    if (location == null || location.getExecutionInfoLocation() == null) {
+      return;
+    }
 
-      try {
-        // Register one final last state of the pipeline
-        //
-        writeExecutionInfoState(iLocation);
-
-        String dataProfileName = 
resolve(pipelineRunConfiguration.getExecutionDataProfileName());
-        if (StringUtils.isNotEmpty(dataProfileName)) {
-          // Register the collected transform data for the last time
-          //
-          ExecutionDataBuilder dataBuilder =
-              ExecutionDataBuilder.fromAllTransformData(
-                  LocalPipelineEngine.this, samplerStoresMap, true);
-          iLocation.registerData(dataBuilder.build());
-          releasePublishedSamples();
-        }
-      } catch (Throwable e) {
-        log.logError("Error handling writing final pipeline state to location 
(non-fatal)", e);
-      } finally {
-        // We're now certain all listeners fired. We can close the location.
+    IExecutionInfoLocation iLocation = location.getExecutionInfoLocation();
+
+    try {
+      // Register one final last state of the pipeline
+      //
+      writeExecutionInfoState(iLocation);
+
+      String dataProfileName = 
resolve(pipelineRunConfiguration.getExecutionDataProfileName());
+      if (StringUtils.isNotEmpty(dataProfileName)) {
+        // Register the collected transform data for the last time
         //
-        try {
-          iLocation.close();
-        } catch (Exception e) {
-          log.logError("Error closing execution information location: " + 
location.getName(), e);
-        }
+        ExecutionDataBuilder dataBuilder =
+            ExecutionDataBuilder.fromAllTransformData(
+                LocalPipelineEngine.this, samplerStoresMap, true);
+        iLocation.registerData(dataBuilder.build());
+        releasePublishedSamples();
       }
     } catch (Throwable e) {
-      log.logError("Error stopping transform execution info timer 
(non-fatal)", e);
+      log.logError("Error handling writing final pipeline state to location 
(non-fatal)", e);
+    } finally {
+      // We're now certain all listeners fired. We can close the location.
+      //
+      try {
+        iLocation.close();
+      } catch (Exception e) {
+        log.logError("Error closing execution information location: " + 
location.getName(), e);
+      }
     }
   }
 
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
index d09bce453b..0ef3a3e626 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
@@ -1249,9 +1249,12 @@ public class NeoExecutionInfoLocation implements 
IExecutionInfoLocation {
 
     try {
       if (rowBuffer == null || rowBuffer.getRowMeta() == null) {
-        log.logError(
-            "Skipping execution data set without row metadata: "
-                + (setMeta == null ? "?" : setMeta.getSetKey()));
+        // Normal for a transform that has not written a sampled row (yet), on 
every save.
+        if (log.isDebug()) {
+          log.logDebug(
+              "Skipping execution data set without row metadata: "
+                  + (setMeta == null ? "?" : setMeta.getSetKey()));
+        }
         return;
       }
       IRowMeta rowMeta = rowBuffer.getRowMeta();
diff --git 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocationSlowSaveIT.java
 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocationSlowSaveIT.java
new file mode 100644
index 0000000000..f4db0a1cfe
--- /dev/null
+++ 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocationSlowSaveIT.java
@@ -0,0 +1,289 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.neo4j.execution;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.abort;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.BooleanSupplier;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.execution.ExecutionInfoLocation;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.neo4j.shared.NeoConnection;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.RowProducer;
+import org.apache.hop.pipeline.config.PipelineRunConfiguration;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.engines.local.LocalPipelineRunConfiguration;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.dummy.DummyMeta;
+import org.apache.hop.pipeline.transforms.injector.InjectorMeta;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.AuthTokens;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.GraphDatabase;
+import org.neo4j.driver.Record;
+import org.neo4j.driver.Session;
+import org.neo4j.driver.Transaction;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.Neo4jContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Issue #8808: a pipeline must finish, and a stop must return, while a save 
to the execution
+ * information location takes long. Saves to a large Neo4j database without 
indexes can take
+ * minutes. The test makes a save wait for as long as it wants: it holds a 
write lock on the
+ * pipeline's Execution node in an open transaction, so the timer tick blocks 
inside Neo4j.
+ */
+class NeoExecutionInfoLocationSlowSaveIT {
+
+  private static final String PASSWORD = "hop-it-8808";
+  private static final String INJECTOR = "injector";
+  private static final Duration EXPECTED_WITHIN = Duration.ofSeconds(15);
+
+  private static Neo4jContainer<?> neo4j;
+  private static Driver driver;
+
+  private ExecutorService executor;
+  private LocalPipelineEngine pipeline;
+  private RowProducer rowProducer;
+  private Session blockerSession;
+  private Transaction blocker;
+  private boolean waitedUntilFinished;
+
+  @BeforeAll
+  static void setUpNeo4j() throws Exception {
+    assumeTrue(
+        DockerClientFactory.instance().isDockerAvailable(),
+        "Docker is required for NeoExecutionInfoLocationSlowSaveIT");
+    HopEnvironment.init();
+    neo4j =
+        new Neo4jContainer<>(DockerImageName.parse("neo4j:5.26"))
+            .withAdminPassword(PASSWORD)
+            .withEnv("NEO4J_server_memory_heap_initial__size", "256m")
+            .withEnv("NEO4J_server_memory_heap_max__size", "512m")
+            .withEnv("NEO4J_server_memory_pagecache_size", "64m")
+            .withStartupTimeout(Duration.ofMinutes(5));
+    try {
+      neo4j.start();
+    } catch (Exception e) {
+      abort("Neo4j container did not become ready: " + e.getMessage());
+    }
+    driver = GraphDatabase.driver(neo4j.getBoltUrl(), 
AuthTokens.basic("neo4j", PASSWORD));
+  }
+
+  @AfterAll
+  static void tearDownNeo4j() {
+    if (driver != null) {
+      driver.close();
+    }
+    if (neo4j != null) {
+      neo4j.stop();
+    }
+  }
+
+  @BeforeEach
+  void startPipelineWithAStuckSave() throws Exception {
+    executor = Executors.newCachedThreadPool();
+
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    NeoConnection connection = new NeoConnection();
+    connection.setName("neo4j");
+    connection.setServer(neo4j.getHost());
+    connection.setBoltPort(Integer.toString(neo4j.getMappedPort(7687)));
+    connection.setUsername("neo4j");
+    connection.setPassword(PASSWORD);
+    metadataProvider.getSerializer(NeoConnection.class).save(connection);
+
+    NeoExecutionInfoLocation neoLocation = new NeoExecutionInfoLocation();
+    neoLocation.setConnectionName("neo4j");
+    ExecutionInfoLocation location = new ExecutionInfoLocation();
+    location.setName("neo4j");
+    location.setExecutionInfoLocation(neoLocation);
+    // Tick right away, then not again during the test: the first save is the 
one that blocks.
+    location.setDataLoggingDelay("0");
+    location.setDataLoggingInterval("600000");
+    metadataProvider.getSerializer(ExecutionInfoLocation.class).save(location);
+
+    PipelineRunConfiguration runConfiguration =
+        new PipelineRunConfiguration(
+            "local-neo4j",
+            "",
+            "neo4j",
+            new ArrayList<>(),
+            new LocalPipelineRunConfiguration(),
+            null,
+            false);
+
+    pipeline = new LocalPipelineEngine(slowSavePipeline());
+    pipeline.setMetadataProvider(metadataProvider);
+    pipeline.setPipelineRunConfiguration(runConfiguration);
+    pipeline.prepareExecution();
+    rowProducer = pipeline.addRowProducer(INJECTOR, 0);
+
+    // prepareExecution() registered the Execution node. Lock it before the 
first tick.
+    //
+    blockerSession = driver.session();
+    blocker = blockerSession.beginTransaction();
+    blocker
+        .run(
+            "MATCH (e:Execution { id : $id }) SET e.lockedByIt8808 = true",
+            Map.of("id", pipeline.getLogChannelId()))
+        .consume();
+
+    pipeline.startThreads();
+
+    awaitTrue(
+        NeoExecutionInfoLocationSlowSaveIT::aSaveIsWaitingInNeo4j,
+        Duration.ofSeconds(30),
+        "The first execution information save never started");
+  }
+
+  @AfterEach
+  void releaseTheSaveAndFinish() throws Exception {
+    try {
+      releaseBlocker();
+      if (pipeline != null && !waitedUntilFinished) {
+        rowProducer.finished();
+        waitUntilFinished();
+      }
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test
+  void thePipelineFinishesWhileASaveIsInProgress() throws Exception {
+    rowProducer.finished();
+
+    awaitTrue(
+        pipeline::isFinished,
+        EXPECTED_WITHIN,
+        "All transforms are done but the pipeline did not finish while a save 
to the execution"
+            + " information location was in progress");
+
+    releaseBlocker();
+    waitUntilFinished();
+
+    // The final save still runs after the slow one, and stores the end of the 
execution.
+    //
+    try (Session session = driver.session()) {
+      Record record =
+          session
+              .run(
+                  "MATCH (e:Execution { id : $id }) RETURN e.executionEndDate 
AS endDate",
+                  Map.of("id", pipeline.getLogChannelId()))
+              .single();
+      assertNotNull(record.get("endDate").asObject(), "The final execution 
state was not saved");
+    }
+  }
+
+  @Test
+  void stoppingThePipelineReturnsWhileASaveIsInProgress() throws Exception {
+    // This is what the Stop button of Hop GUI calls, on the UI thread.
+    //
+    Future<?> stop = executor.submit(pipeline::stopAll);
+    try {
+      stop.get(EXPECTED_WITHIN.toMillis(), TimeUnit.MILLISECONDS);
+    } catch (TimeoutException e) {
+      fail(
+          "Stopping the pipeline did not return while a save to the execution 
information"
+              + " location was in progress");
+    }
+    assertTrue(pipeline.isStopped());
+  }
+
+  private static PipelineMeta slowSavePipeline() {
+    PluginRegistry registry = PluginRegistry.getInstance();
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("it-8808-slow-save");
+
+    InjectorMeta injectorMeta = new InjectorMeta();
+    TransformMeta injector =
+        new TransformMeta(
+            registry.getPluginId(TransformPluginType.class, injectorMeta), 
INJECTOR, injectorMeta);
+    DummyMeta dummyMeta = new DummyMeta();
+    TransformMeta dummy =
+        new TransformMeta(
+            registry.getPluginId(TransformPluginType.class, dummyMeta), 
"dummy", dummyMeta);
+    pipelineMeta.addTransform(injector);
+    pipelineMeta.addTransform(dummy);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(injector, dummy));
+    return pipelineMeta;
+  }
+
+  /** A tick of the execution information timer is waiting for the lock this 
test holds. */
+  private static boolean aSaveIsWaitingInNeo4j() {
+    for (StackTraceElement[] stack : Thread.getAllStackTraces().values()) {
+      for (StackTraceElement frame : stack) {
+        if 
(frame.getClassName().equals(NeoExecutionInfoLocation.class.getName())
+            && frame.getMethodName().equals("updateExecutionState")) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  /** Only once: waitUntilFinished() takes the single finished signal off a 
queue. */
+  private void waitUntilFinished() throws Exception {
+    waitedUntilFinished = true;
+    executor.submit(pipeline::waitUntilFinished).get(2, TimeUnit.MINUTES);
+  }
+
+  private void releaseBlocker() {
+    if (blocker != null) {
+      blocker.rollback();
+      blocker.close();
+      blocker = null;
+    }
+    if (blockerSession != null) {
+      blockerSession.close();
+      blockerSession = null;
+    }
+  }
+
+  private static void awaitTrue(BooleanSupplier condition, Duration timeout, 
String message)
+      throws InterruptedException {
+    long deadline = System.nanoTime() + timeout.toNanos();
+    while (!condition.getAsBoolean()) {
+      if (System.nanoTime() > deadline) {
+        fail(message + " (waited " + timeout.toSeconds() + "s)");
+      }
+      Thread.sleep(100);
+    }
+  }
+}

Reply via email to