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 45d652eaf6 Run PipelineCompleted before releasing waitUntilFinished(),
fixes #9005 (#9035)
45d652eaf6 is described below
commit 45d652eaf63451028feedd9617a17dd312c4e113
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Oct 24 09:40:50 2026 +0200
Run PipelineCompleted before releasing waitUntilFinished(), fixes #9005
(#9035)
* Run PipelineCompleted before releasing waitUntilFinished(), fixes #9005
- call the PipelineCompleted extension point before releasing waiters, and
release them in a finally
- register the TransformFinished extension point ahead of the pipeline's
completion detection
- fire TransformBeforeStart/TransformFinished for the SingleThreaded
pipeline type
- flush the lineage hub in HopRun before System.exit
* Count completion failures before releasing waiters, flush lineage on
hop-run error exits, #9005
---
.../java/org/apache/hop/pipeline/Pipeline.java | 133 +++++++----
.../src/main/java/org/apache/hop/run/HopRun.java | 22 ++
.../java/org/apache/hop/pipeline/PipelineTest.java | 243 +++++++++++++++++++++
3 files changed, 353 insertions(+), 45 deletions(-)
diff --git a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
index ee4a3de3e7..737fd560b2 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
@@ -1376,7 +1376,7 @@ public abstract class Pipeline
try {
fireExecutionFinishedListeners();
} catch (Exception e) {
- transform.setErrors(transform.getErrors() + 1L);
+ // fireExecutionFinishedListeners() already counted this on
the pipeline
log.logError(
getName()
+ " : "
@@ -1406,7 +1406,11 @@ public abstract class Pipeline
}
};
- // Make sure this is called first!
+ // Register the completion detection before the other listeners, so the
pipeline's own
+ // bookkeeping (finished flag, end date, error handling) does not wait
on them. The
+ // TransformFinished extension point is inserted ahead of it further
down, before the
+ // transform threads start: a transform has to finish announcing itself
before the pipeline
+ // can conclude that the last one is done.
//
if (sid.transform instanceof BaseTransform baseTransform) {
baseTransform.getTransformFinishedListeners().add(0, finishedListener);
@@ -1499,29 +1503,19 @@ public abstract class Pipeline
RunThread runThread = new RunThread(combi);
Thread thread = new Thread(runThread);
thread.setName(getName() + " - " + combi.transformName);
- ExtensionPointHandler.callExtensionPoint(
- log, this, HopExtensionPoint.TransformBeforeStart.id, combi);
- // Call an extension point at the end of the transform
- //
- combi.transform.addTransformFinishedListener(
- (pipeline, transformMeta, transform) -> {
- try {
- ExtensionPointHandler.callExtensionPoint(
- log, this, HopExtensionPoint.TransformFinished.id,
combi);
- } catch (HopException e) {
- throw new HopRuntimeException(
- "Unexpected error in calling extension point upon
transform finish", e);
- }
- });
-
+ registerTransformExtensionPoints(combi);
thread.start();
}
break;
case SingleThreaded:
- // Don't do anything, this needs to be handled by the pipeline
- // executor!
+ // The executor runs these transforms one at a time, so there are no
threads to start here,
+ // but they announce themselves like any other transform. The executor
only stops them when
+ // it disposes them, so their finished announcement lands at the end
of the run.
//
+ for (final TransformMetaDataCombi combi : transforms) {
+ registerTransformExtensionPoints(combi);
+ }
break;
default:
break;
@@ -1546,6 +1540,43 @@ public abstract class Pipeline
}
}
+ /**
+ * Announce a transform that is about to start, and register the extension
point that announces it
+ * when it finishes.
+ *
+ * <p>The finished listener is inserted ahead of the completion detection
registered above: the
+ * pipeline fires its completion, and with it whatever that triggers
(flushing queued lineage, for
+ * example), the moment the last transform stops, so a transform has to
finish announcing itself
+ * before that. Called before execution starts, so no transform can beat the
registration.
+ */
+ private void registerTransformExtensionPoints(TransformMetaDataCombi combi)
throws HopException {
+ ExtensionPointHandler.callExtensionPoint(
+ log, this, HopExtensionPoint.TransformBeforeStart.id, combi);
+
+ // This listener runs ahead of the completion detection, so it must never
throw: a failure here
+ // would skip the detection for this transform and leave
waitUntilFinished() waiting forever.
+ ITransformFinishedListener transformFinishedExtensionPoint =
+ (pipeline, transformMeta, transform) -> {
+ try {
+ ExtensionPointHandler.callExtensionPoint(
+ log, this, HopExtensionPoint.TransformFinished.id, combi);
+ } catch (Exception e) {
+ log.logError(
+ "Unexpected error in calling extension point upon transform
finish of '"
+ + combi.transformName
+ + "'",
+ e);
+ }
+ };
+ if (combi.transform instanceof BaseTransform baseTransform) {
+ baseTransform.getTransformFinishedListeners().add(0,
transformFinishedExtensionPoint);
+ } else {
+ // The interface has no insert, so an implementation that does not
extend BaseTransform keeps
+ // the old order and with it a late last event.
+
combi.transform.addTransformFinishedListener(transformFinishedExtensionPoint);
+ }
+ }
+
/**
* @deprecated Make attempt to fire all registered finished listeners if
possible.
* @throws HopException if any errors occur during notification
@@ -1558,44 +1589,56 @@ public abstract class Pipeline
@Override
public void fireExecutionFinishedListeners() throws HopException {
- HopException listenerException = null;
- synchronized (executionFinishedListeners) {
- if (!executionFinishedListeners.isEmpty()) {
- // prevent Exception from one listener to block others execution
- List<HopException> badGuys = new
ArrayList<>(executionFinishedListeners.size());
- for (IExecutionFinishedListener<IPipelineEngine<PipelineMeta>>
listener :
- executionFinishedListeners) {
- try {
- listener.finished(this);
- } catch (HopException e) {
- badGuys.add(e);
+ try {
+ HopException listenerException = null;
+ synchronized (executionFinishedListeners) {
+ if (!executionFinishedListeners.isEmpty()) {
+ // prevent Exception from one listener to block others execution
+ List<HopException> badGuys = new
ArrayList<>(executionFinishedListeners.size());
+ for (IExecutionFinishedListener<IPipelineEngine<PipelineMeta>>
listener :
+ executionFinishedListeners) {
+ try {
+ listener.finished(this);
+ } catch (HopException e) {
+ badGuys.add(e);
+ }
+ }
+ if (!badGuys.isEmpty()) {
+ // FIFO
+ listenerException = badGuys.get(0);
}
- }
- if (!badGuys.isEmpty()) {
- // FIFO
- listenerException = badGuys.get(0);
}
}
- }
- try {
- // Now the status and everything else is set correctly. We've completed
the pipeline.
- //
- pipelineCompleted();
-
- // Also call an extension point in case plugins want to play along
+ // Call the completion extension point before releasing anything blocked
in
+ // waitUntilFinished(): plugins here may still have work to do -
flushing lineage, for
+ // example - while a waiting caller (hop-run, for one) can leave the JVM
the moment it is
+ // released.
//
ExtensionPointHandler.callExtensionPoint(
log, this, HopExtensionPoint.PipelineCompleted.id, this);
+
+ if (listenerException != null) {
+ throw listenerException;
+ }
+ } catch (Exception e) {
+ // Count the failure before the waiters are released: a released caller
reads the result
+ // straight away, hop-run its exit code for one.
+ //
+ errors.incrementAndGet();
+ throw e;
} finally {
+ // Now the status and everything else is set correctly. We've completed
the pipeline.
+ //
+ // Always release the waiters, even when a listener or extension point
failed: that must never
+ // be able to deadlock a caller. The failure itself still propagates.
+ //
+ pipelineCompleted();
+
// Only now: everything above can still touch files of this namespace,
and closing it
// invalidates every file object resolved through it - the result files
carry those.
releaseVfsNamespace();
}
-
- if (listenerException != null) {
- throw listenerException;
- }
}
public void pipelineCompleted() throws HopException {
diff --git a/engine/src/main/java/org/apache/hop/run/HopRun.java
b/engine/src/main/java/org/apache/hop/run/HopRun.java
index e0655d6a20..a45003a9f3 100644
--- a/engine/src/main/java/org/apache/hop/run/HopRun.java
+++ b/engine/src/main/java/org/apache/hop/run/HopRun.java
@@ -27,6 +27,7 @@ import org.apache.hop.core.config.plugin.ConfigPlugin;
import org.apache.hop.core.logging.HopLogStore;
import org.apache.hop.core.plugins.JarCache;
import org.apache.hop.hop.Hop;
+import org.apache.hop.lineage.hub.LineageHub;
import org.apache.hop.metadata.api.IHasHopMetadataProvider;
import org.apache.hop.metadata.util.HopMetadataInstance;
import org.apache.hop.metadata.util.HopMetadataUtil;
@@ -92,6 +93,9 @@ public class HopRun extends HopRunBase implements Runnable,
IHasHopMetadataProvi
System.exit(1);
} else {
hopRun.run();
+
+ shutdownLineage();
+
System.out.println("HopRun exit.");
if (hopRun.isFinishedWithoutError()) {
System.exit(0);
@@ -102,20 +106,38 @@ public class HopRun extends HopRunBase implements
Runnable, IHasHopMetadataProvi
} catch (ParameterException e) {
System.err.println(e.getMessage());
hopRun.cmd.usage(System.err);
+ shutdownLineage();
System.exit(9);
} catch (ExecutionException e) {
System.err.println("Error found during execution!");
System.err.println(Const.getStackTracker(e));
+ shutdownLineage();
System.exit(1);
} catch (Exception e) {
System.err.println("General error found, something went horribly
wrong!");
System.err.println(Const.getStackTracker(e));
+ shutdownLineage();
System.exit(2);
}
}
+ /**
+ * Deliver whatever lineage is still queued before the JVM goes away: the
hub hands its queue to
+ * the sinks and the sinks drain theirs (bounded by their drain timeout).
Without it a short run
+ * loses its terminal events, which the sinks send last, and a failed run
loses the events emitted
+ * before it failed. Call it before every System.exit() once a run may have
started. Safe to call
+ * when lineage is disabled, and a failure here never changes the exit code.
+ */
+ private static void shutdownLineage() {
+ try {
+ LineageHub.getInstance().shutdown();
+ } catch (Exception e) {
+ System.err.println("Error delivering the remaining lineage events: " +
e.getMessage());
+ }
+ }
+
public void applySystemProperties() {
// Set some System properties if there were any
//
diff --git a/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
b/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
index 4027a1fe51..84ac02e274 100644
--- a/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
+++ b/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
@@ -27,19 +27,33 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
import org.apache.hop.core.Const;
import org.apache.hop.core.HopEnvironment;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.exception.HopRuntimeException;
+import org.apache.hop.core.extension.ExtensionPointMap;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LoggingObject;
+import org.apache.hop.core.plugins.IClassLoadingPlugin;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.variables.Variables;
import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
import org.apache.hop.pipeline.engine.IPipelineEngine;
import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
import org.apache.hop.pipeline.transform.ITransform;
import org.apache.hop.pipeline.transform.ITransformData;
import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.dummy.DummyMeta;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -150,6 +164,229 @@ class PipelineTest {
assertThrows(HopException.class, line::fireExecutionFinishedListeners);
}
+ /**
+ * A caller blocked in {@link Pipeline#waitUntilFinished()} must not be
released before the
+ * PipelineCompleted extension point has run. Extension points there still
have work to flush -
+ * lineage for one - and a released caller (hop-run, for example) can leave
the JVM immediately.
+ */
+ @Test
+ void pipelineCompletedExtensionPointRunsBeforeWaitersAreReleased() throws
Exception {
+ List<String> order = Collections.synchronizedList(new ArrayList<>());
+ CountDownLatch waiterReleased = new CountDownLatch(1);
+
+ TestExtensionPointPlugin plugin =
+ registerExtensionPoint(
+ HopExtensionPoint.PipelineCompleted.id,
+ () -> {
+ order.add("extension-point");
+ // If the waiter was released first (the bug) it records itself
while this runs.
+ // Waiting for that here keeps the assertion deterministic
instead of a race.
+ try {
+ waiterReleased.await(400, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+
+ try {
+ Pipeline line = new LocalPipelineEngine(new PipelineMeta());
+ line.setLogChannel(mock(ILogChannel.class));
+ line.prepareExecution();
+
+ Thread waiter =
+ new Thread(
+ () -> {
+ // waitUntilFinished() returns at once while startThreads()
has not created its
+ // queue yet. The queue exists once the pipeline is flagged as
running, and a late
+ // waiter still finds the signal already on it.
+ while (!line.isRunning() && !line.isFinished()) {
+ Thread.onSpinWait();
+ }
+ line.waitUntilFinished();
+ order.add("waiter");
+ waiterReleased.countDown();
+ });
+ waiter.start();
+
+ // A pipeline without transforms completes in startThreads(), on this
thread.
+ line.startThreads();
+
+ waiter.join(10_000);
+ assertEquals(List.of("extension-point", "waiter"), order);
+ } finally {
+ ExtensionPointMap.getInstance().removeExtensionPoint(plugin);
+ }
+ }
+
+ /**
+ * A transform has to finish announcing itself before the pipeline concludes
that the last
+ * transform is done. A plugin emitting its final events on
TransformFinished - lineage does -
+ * otherwise queues them after the pipeline has already fired its completion
and flushed.
+ */
+ @Test
+ void transformFinishedExtensionPointRunsBeforePipelineCompleted() throws
Exception {
+ List<String> order = Collections.synchronizedList(new ArrayList<>());
+
+ TestExtensionPointPlugin transformFinished =
+ registerExtensionPoint(
+ HopExtensionPoint.TransformFinished.id, () ->
order.add("transform-finished"));
+ TestExtensionPointPlugin pipelineCompleted =
+ registerExtensionPoint(
+ HopExtensionPoint.PipelineCompleted.id, () ->
order.add("pipeline-completed"));
+
+ try {
+ PipelineMeta meta = new PipelineMeta();
+ meta.setName("ordering test");
+ meta.addTransform(new TransformMeta("A", new DummyMeta()));
+
+ LocalPipelineEngine run =
+ new LocalPipelineEngine(meta, new Variables(), new
LoggingObject("test"));
+ run.prepareExecution();
+ run.startThreads();
+ run.waitUntilFinished();
+
+ assertEquals(List.of("transform-finished", "pipeline-completed"), order);
+ } finally {
+ ExtensionPointMap.getInstance().removeExtensionPoint(transformFinished);
+ ExtensionPointMap.getInstance().removeExtensionPoint(pipelineCompleted);
+ }
+ }
+
+ /**
+ * The TransformFinished extension point runs ahead of the pipeline's
completion detection, so a
+ * plugin that fails there must not keep the pipeline from finishing.
+ */
+ @Test
+ void failingTransformFinishedExtensionPointDoesNotHangThePipeline() throws
Exception {
+ TestExtensionPointPlugin transformFinished =
+ registerExtensionPoint(
+ HopExtensionPoint.TransformFinished.id,
+ () -> {
+ throw new IllegalStateException("broken plugin");
+ });
+
+ try {
+ PipelineMeta meta = new PipelineMeta();
+ meta.setName("failing extension point test");
+ meta.addTransform(new TransformMeta("A", new DummyMeta()));
+
+ LocalPipelineEngine run =
+ new LocalPipelineEngine(meta, new Variables(), new
LoggingObject("test"));
+ run.prepareExecution();
+ run.startThreads();
+
+ Thread waiter = new Thread(run::waitUntilFinished);
+ waiter.start();
+ waiter.join(10_000);
+
+ assertFalse(waiter.isAlive(), "waitUntilFinished() did not return");
+ assertTrue(run.isFinished());
+ } finally {
+ ExtensionPointMap.getInstance().removeExtensionPoint(transformFinished);
+ }
+ }
+
+ /**
+ * A failing PipelineCompleted extension point has to show up in the result
by the time the
+ * waiters are released: hop-run reads the error count, and with it its exit
code, straight away.
+ * The failure is counted once.
+ */
+ @Test
+ void
failingPipelineCompletedExtensionPointIsCountedBeforeWaitersAreReleased()
throws Exception {
+ TestExtensionPointPlugin pipelineCompleted =
+ registerExtensionPoint(
+ HopExtensionPoint.PipelineCompleted.id,
+ () -> {
+ throw new IllegalStateException("broken plugin");
+ });
+
+ try {
+ PipelineMeta meta = new PipelineMeta();
+ meta.setName("failing completion test");
+ meta.addTransform(new TransformMeta("A", new DummyMeta()));
+
+ // Look at the result the moment the waiters are released: a waiter
itself only polls every
+ // 50ms, so it would nearly always see a count that is added just after.
+ AtomicLong errorsAtRelease = new AtomicLong(-1);
+ LocalPipelineEngine run =
+ new LocalPipelineEngine(meta, new Variables(), new
LoggingObject("test")) {
+ @Override
+ public void pipelineCompleted() throws HopException {
+ errorsAtRelease.set(getResult().getNrErrors());
+ super.pipelineCompleted();
+ }
+ };
+ run.prepareExecution();
+ run.startThreads();
+ run.waitUntilFinished();
+
+ assertEquals(1, errorsAtRelease.get());
+ assertEquals(1, run.getResult().getNrErrors());
+ } finally {
+ ExtensionPointMap.getInstance().removeExtensionPoint(pipelineCompleted);
+ }
+ }
+
+ /**
+ * A single threaded run has to announce its transforms like any other.
Those transforms are run
+ * by the executor instead of by threads of their own, and that pipeline
type used to fire neither
+ * TransformBeforeStart nor TransformFinished - so a single threaded run
produced no transform
+ * level lineage at all.
+ */
+ @Test
+ void singleThreadedPipelineAnnouncesItsTransforms() throws Exception {
+ List<String> order = Collections.synchronizedList(new ArrayList<>());
+
+ TestExtensionPointPlugin transformStart =
+ registerExtensionPoint(
+ HopExtensionPoint.TransformBeforeStart.id, () ->
order.add("transform-start"));
+ TestExtensionPointPlugin transformFinished =
+ registerExtensionPoint(
+ HopExtensionPoint.TransformFinished.id, () ->
order.add("transform-finished"));
+
+ try {
+ PipelineMeta meta = new PipelineMeta();
+ meta.setName("single threaded test");
+ meta.addTransform(new TransformMeta("A", new DummyMeta()));
+
+ LocalPipelineEngine run =
+ new LocalPipelineEngine(meta, new Variables(), new
LoggingObject("test"));
+ run.setPipelineType(PipelineMeta.PipelineType.SingleThreaded);
+ run.prepareExecution();
+ run.startThreads();
+
+ // What SingleThreadedPipelineEngine does with the executor...
+ SingleThreadedPipelineExecutor executor = new
SingleThreadedPipelineExecutor(run);
+ assertTrue(executor.init());
+ executor.oneIteration();
+ executor.dispose();
+
+ assertEquals(List.of("transform-start", "transform-finished"), order);
+ } finally {
+ ExtensionPointMap.getInstance().removeExtensionPoint(transformStart);
+ ExtensionPointMap.getInstance().removeExtensionPoint(transformFinished);
+ }
+ }
+
+ /**
+ * Hand the extension point map a fake extension point, so a test can watch
for it. The caller
+ * removes the returned plugin again.
+ */
+ private TestExtensionPointPlugin registerExtensionPoint(
+ String extensionPointId, Runnable action) {
+ IExtensionPoint<Object> extensionPoint = (logChannel, variables, object)
-> action.run();
+
+ TestExtensionPointPlugin plugin = mock(TestExtensionPointPlugin.class);
+ // For an extension point plugin the name is the extension point it
listens to, and the ids are
+ // the plugin's own ids: ExtensionPointPluginType extracts them that way.
+ when(plugin.getName()).thenReturn(extensionPointId);
+ when(plugin.getIds()).thenReturn(new String[] {"test-" +
extensionPointId});
+ when(plugin.getMainType()).thenReturn((Class) IExtensionPoint.class);
+ when(plugin.loadClass(IExtensionPoint.class)).thenReturn(extensionPoint);
+ ExtensionPointMap.getInstance().addExtensionPoint(plugin);
+ return plugin;
+ }
+
@Test
void testFinishStatus() throws Exception {
while (pipeline.isRunning()) {
@@ -291,6 +528,12 @@ class PipelineTest {
private final IExecutionFinishedListener<IPipelineEngine<PipelineMeta>>
listener = p -> {};
+ /**
+ * A plugin that loads its own classes, so a test can hand the extension
point map a fake
+ * extension point without going through the plugin registry.
+ */
+ private interface TestExtensionPointPlugin extends IClassLoadingPlugin,
IPlugin {}
+
private final IExecutionStoppedListener<IPipelineEngine<PipelineMeta>>
pipelineStoppedListener =
p -> {};