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 d193e9054d Add Retry Mechanism for Failed Pipeline Executor Executions 
(#8544)
d193e9054d is described below

commit d193e9054d5955374803385259ffd7482e2e1910
Author: Happy <[email protected]>
AuthorDate: Sat Oct 10 19:33:25 2026 +0800

    Add Retry Mechanism for Failed Pipeline Executor Executions (#8544)
    
    * Enhance DatabaseJoin to support named and positional SQL parameters
    
    * Enhance DatabaseJoin to support runtime output metadata initialization 
and improve parameter handling
    
    * Add tests for mixed named and positional SQL parameters in 
DatabaseJoinMeta
    
    * Added a new core SQL parser: parseSqlParameterSpec(String) that returns
    
    * Refactor DatabaseJoin to simplify output field name handling and ensure 
parameter specification is initialized
    
    * Refactor DatabaseJoinMeta to streamline SQL parameter handling and 
enhance metadata discovery
    
    * Add tests for SQL parameter parsing and metadata handling in 
DatabaseJoinMeta
    
    * Fix SQL parameter parsing for JSONB operators and ARRAY expressions
    
    - Skip only explicit JSONB operator tokens: `?|`, `?&`, `??`, `??|`,
      and `??&`.
    - Consume complete operator tokens so `col ?? ?` still counts the trailing
      `?` as a bind parameter.
    - Stop treating all `[...]` expressions as bracket-quoted identifiers.
    - Add dialect-aware bracket handling for SQL Server.
    - Preserve PostgreSQL parameter markers in `ARRAY[?]` and `ARRAY[?, ?]`.
    - Apply the same parsing behavior to the Database Join path.
    - Expand regression coverage for JSONB operators, `LIKE`, `NOT LIKE`,
      `BETWEEN`, `SELECT ? FROM dual`, and ARRAY expressions.
    
    * Enhance parseSqlParameterSpec() call in DatabaseJoin to support 
bracket-quoted identifiers
    
    * Refactor parseSqlParameterSpec() in DatabaseJoinMeta to support 
bracket-quoted identifiers
    
    * Add tests for mixed named and positional placeholders in SQL parameter 
parsing
    
    * Refactor SQL parameter tests to improve clarity and coverage for JSONB 
operators and bracket-quoted identifiers
    
    * Add resolved parameters panel to DatabaseJoinDialog for improved SQL 
parameter handling
    
    * Refactor PipelineExecutor to improve parameter handling and add retry 
logic for pipeline execution
    
    * Add retry attempts and delay configuration to PipelineExecutorDialog for 
improved error handling
    
    * Add unit tests for retry logic in PipelineExecutor to validate execution 
attempts
    
    * Add retry configuration panel to PipelineExecutorDialog for enhanced 
error handling
    
    * Add support for retry options in PipelineExecutorMeta with XML handling
    
    * Add timeout handling to executePipelineAttempt for retry logic
    
    * Add support for additional parameters in executePipelineAttempt and 
enhance retry logic tests
    
    * Add log message for interrupted retries and update tooltip for wait 
timeout
    
    * Refactor retry logic in executeWithRetries and add cleanup for previous 
attempts
    
    * Add tests for cleanup and retry logic in PipelineExecutor
    
    * Update documentation for pipeline execution parameters to clarify retry 
behavior and timing
    
    * Refactor cleanup logic in executePipelineAttempt to use 
stopPreviousAttemptPipeline
    
    * Add tests for cleanup behavior in executeWithRetries method
    
    ---------
    
    Co-authored-by: happy.he <[email protected]>
---
 .../pipeline/transforms/pipeline-executor.adoc     |   5 +-
 .../pipelineexecutor/PipelineExecutor.java         | 119 +++++++++---
 .../pipelineexecutor/PipelineExecutorDialog.java   |  69 +++++++
 .../pipelineexecutor/PipelineExecutorMeta.java     |  24 +++
 .../messages/messages_en_US.properties             |   9 +-
 .../pipelineexecutor/PipelineExecutorMetaTest.java |   6 +
 .../pipelineexecutor/PipelineExecutorTest.java     | 206 +++++++++++++++++++++
 .../test/resources/pipeline-executor-transform.xml |   4 +
 8 files changed, 415 insertions(+), 27 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pipeline-executor.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pipeline-executor.adoc
index db8b0904e5..556b1c5cdb 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pipeline-executor.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pipeline-executor.adoc
@@ -88,7 +88,9 @@ The filename may contain variables (for example, you can use 
the built-in Intern
 
 *NOTE:* It must be considered that, by specifying the pipeline file name using 
this option, we can experience a little performance penalty because the 
pipeline that will be executed will be initialized at runtime and not during 
the initialization phase as usual. On the other side this option gives greater 
flexibility in specifying the pipeline filename dynamically and this could be 
useful for many use-cases.
 |Run configuration|Specify the Pipeline Run Configuration to be used for 
execution.
-|Stop after (milliseconds)|Maximum time to wait for the child pipeline to 
complete. Empty or 0 means wait indefinitely. When the time is reached the 
child pipeline is stopped and the execution-results stream reports a failure 
(`ExecutionResult=N`, `ExecutionNrErrors>=1`). The value may contain a variable.
+|Stop after (milliseconds)|Maximum time to wait for each child pipeline 
execution attempt. Empty or 0 means wait indefinitely. When the time is reached 
the child pipeline is stopped and the execution-results stream reports a 
failure (`ExecutionResult=N`, `ExecutionNrErrors>=1`) for that attempt. The 
value may contain a variable.
+|Retry attempts on failure|Number of retries after a failed child pipeline 
execution attempt. `0` means no retries (default), so the transform executes 
the child once. The value can use integer shorthand and variables (for example 
`1,000`, `100k`, `1.5m`, `1e3`, `'${PIPELINE_RETRY_ATTEMPTS}'`).
+|Retry delay (milliseconds)|Delay between retry attempts. Empty or `0` means 
retry immediately. The delay happens only between attempts and is not part of 
the per-attempt stop-after timeout. The value can use integer shorthand and 
variables (for example `1,000`, `100k`, `1.5m`, `1e3`, 
`'${PIPELINE_RETRY_DELAY_MS}'`).
 
 |===
 
@@ -252,4 +254,3 @@ Here you can specify where to send the result files from 
the pipeline execution.
 |Result file name field  
 |Specify the name of the field for the result files.
 |===
-
diff --git 
a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java
 
b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java
index 59021f26ab..eb2fa79b5a 100644
--- 
a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java
+++ 
b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java
@@ -232,31 +232,39 @@ public class PipelineExecutor extends 
BaseTransform<PipelineExecutorMeta, Pipeli
     }
     pipelineExecutorData.groupTimeStart = System.currentTimeMillis();
 
-    if (first) {
-      discardLogLines(pipelineExecutorData);
+    List<String> parameterValues = incomingFieldValues;
+    if (parameterValues == null) {
+      List<String> lastIncomingFieldValues = getLastIncomingFieldValues();
+      parameterValues = !Utils.isEmpty(lastIncomingFieldValues) ? 
lastIncomingFieldValues : null;
     }
 
+    // Execute the pipeline with retries if necessary
+    Result result = executeWithRetries(parameterValues);
+
+    collectPipelineResults(result);
+    collectExecutionResults(result);
+    collectExecutionResultFiles(result);
+
+    pipelineExecutorData.groupBuffer.clear();
+  }
+
+  @VisibleForTesting
+  Result executePipelineAttempt(List<String> parameterValues, long timeoutMs) 
throws HopException {
+    PipelineExecutorData pipelineExecutorData = getData();
+
     IPipelineEngine<PipelineMeta> executorPipeline = createInternalPipeline();
     pipelineExecutorData.setExecutorPipeline(executorPipeline);
-    if (incomingFieldValues != null) {
-      // Pass parameter values
-      passParametersToPipeline(incomingFieldValues);
-    } else {
-      List<String> lastIncomingFieldValues = getLastIncomingFieldValues();
-      // incomingFieldValues == null-  There are no more rows - Last Case - 
pass previous values if
-      // exists
-      // If not still pass the null parameter values
-      passParametersToPipeline(
-          !Utils.isEmpty(lastIncomingFieldValues) ? lastIncomingFieldValues : 
incomingFieldValues);
-    }
+
+    passParametersToPipeline(parameterValues);
 
     // keep track for drill down in HopGui...
     getPipeline().addActiveSubPipeline(getTransformName(), executorPipeline);
 
-    Result result = new Result();
-    result.setRows(pipelineExecutorData.groupBuffer);
-    executorPipeline.setPreviousResult(result);
+    Result previousResult = new Result();
+    previousResult.setRows(pipelineExecutorData.groupBuffer);
+    executorPipeline.setPreviousResult(previousResult);
 
+    Result result = new Result();
     try {
       executorPipeline.prepareExecution();
 
@@ -264,17 +272,16 @@ public class PipelineExecutor extends 
BaseTransform<PipelineExecutorMeta, Pipeli
       executorPipeline.startThreads();
 
       // Wait a while until we're done with the pipeline
-      long timeoutMs = ExecutionWait.parseTimeoutMs(this, 
meta.getWaitTimeout());
       boolean finishedInTime = ExecutionWait.waitForPipeline(executorPipeline, 
timeoutMs);
 
       result = executorPipeline.getResult();
+      if (result == null) {
+        result = new Result();
+      }
       if (!finishedInTime) {
         logError(
             BaseMessages.getString(
                 PKG, "PipelineExecutor.Log.WaitTimeoutReached", 
Long.toString(timeoutMs)));
-        if (result == null) {
-          result = new Result();
-        }
         result.setResult(false);
         result.setNrErrors(Math.max(1, result.getNrErrors()));
       }
@@ -282,13 +289,77 @@ public class PipelineExecutor extends 
BaseTransform<PipelineExecutorMeta, Pipeli
       logError("An error occurred executing the pipeline: ", e);
       result.setResult(false);
       result.setNrErrors(1);
+      stopPreviousAttemptPipeline(pipelineExecutorData);
     }
+    return result;
+  }
 
-    collectPipelineResults(result);
-    collectExecutionResults(result);
-    collectExecutionResultFiles(result);
+  private boolean isFailedResult(Result result) {
+    if (result == null) {
+      return true;
+    }
+    return !result.isResult() || result.getNrErrors() > 0;
+  }
 
-    pipelineExecutorData.groupBuffer.clear();
+  @VisibleForTesting
+  Result executeWithRetries(List<String> parameterValues) throws HopException {
+    int retryAttempts = Math.max(0, 
Const.toIntExpanded(resolve(meta.getRetryAttempts()), 0));
+    long retryDelayMs = Math.max(0L, 
Const.toLongExpanded(resolve(meta.getRetryDelay()), 0L));
+    long configuredTimeoutMs = ExecutionWait.parseTimeoutMs(this, 
meta.getWaitTimeout());
+
+    Result result = null;
+    for (int attempt = 0; attempt <= retryAttempts; attempt++) {
+      if (isStopped()) {
+        if (result == null) {
+          result = new Result();
+          result.setResult(false);
+          result.setNrErrors(1);
+        }
+        break;
+      }
+
+      result = executePipelineAttempt(parameterValues, configuredTimeoutMs);
+      if (!isFailedResult(result) || attempt == retryAttempts) {
+        break;
+      }
+      if (!waitRetryDelay(retryDelayMs)) {
+        break;
+      }
+      cleanupPreviousAttemptPipeline(getData());
+    }
+    return result;
+  }
+
+  private boolean waitRetryDelay(long retryDelayMs) {
+    long remainingDelayMs = retryDelayMs;
+    while (remainingDelayMs > 0) {
+      if (isStopped()) {
+        return false;
+      }
+      long sleepMs = Math.min(remainingDelayMs, 100L);
+      try {
+        Thread.sleep(sleepMs);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        logError(BaseMessages.getString(PKG, 
"PipelineExecutor.Log.RetryInterrupted"), e);
+        return false;
+      }
+      remainingDelayMs -= sleepMs;
+    }
+    return !isStopped();
+  }
+
+  @VisibleForTesting
+  void cleanupPreviousAttemptPipeline(PipelineExecutorData 
pipelineExecutorData) {
+    stopPreviousAttemptPipeline(pipelineExecutorData);
+    discardLogLines(pipelineExecutorData);
+  }
+
+  private void stopPreviousAttemptPipeline(PipelineExecutorData 
pipelineExecutorData) {
+    IPipelineEngine<PipelineMeta> executorPipeline = 
pipelineExecutorData.getExecutorPipeline();
+    if (executorPipeline != null && executorPipeline.isRunning()) {
+      executorPipeline.stopAll();
+    }
   }
 
   @VisibleForTesting
diff --git 
a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java
 
b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java
index 45ec3481b7..7811f33b26 100644
--- 
a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java
+++ 
b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java
@@ -95,6 +95,8 @@ public class PipelineExecutorDialog extends 
BaseTransformDialog {
 
   private Label wlWaitTimeout;
   private TextVar wWaitTimeout;
+  private TextVar wRetryAttempts;
+  private TextVar wRetryDelay;
 
   private Button wbPipelineNameInField;
 
@@ -287,6 +289,7 @@ public class PipelineExecutorDialog extends 
BaseTransformDialog {
     fdWaitTimeout.top = new FormAttachment(wlWaitTimeout, 0, SWT.CENTER);
     fdWaitTimeout.right = new FormAttachment(100, 0);
     wWaitTimeout.setLayoutData(fdWaitTimeout);
+    wWaitTimeout.addModifyListener(lsMod);
 
     //
     // Add a tab folder for the parameters and various input and output
@@ -308,6 +311,7 @@ public class PipelineExecutorDialog extends 
BaseTransformDialog {
     addParametersTab();
     addExecutionResultTab();
     addRowGroupTab();
+    addRetryTab(lsMod);
     addResultRowsTab();
     addResultFilesTab();
 
@@ -438,6 +442,8 @@ public class PipelineExecutorDialog extends 
BaseTransformDialog {
     }
 
     wWaitTimeout.setText(Const.NVL(pipelineExecutorMeta.getWaitTimeout(), ""));
+    wRetryAttempts.setText(Const.NVL(pipelineExecutorMeta.getRetryAttempts(), 
""));
+    wRetryDelay.setText(Const.NVL(pipelineExecutorMeta.getRetryDelay(), ""));
 
     //  throw in a separate thread.
     //
@@ -841,6 +847,67 @@ public class PipelineExecutorDialog extends 
BaseTransformDialog {
     wTabFolder.setSelection(wTab);
   }
 
+  private void addRetryTab(ModifyListener lsMod) {
+    final CTabItem wTab = new CTabItem(wTabFolder, SWT.NONE);
+    wTab.setFont(GuiResource.getInstance().getFontDefault());
+    wTab.setText(BaseMessages.getString(PKG, 
"PipelineExecutorDialog.Retry.Title"));
+    wTab.setToolTipText(BaseMessages.getString(PKG, 
"PipelineExecutorDialog.Retry.Tooltip"));
+
+    Composite wInputComposite = new Composite(wTabFolder, SWT.NONE);
+    PropsUi.setLook(wInputComposite);
+
+    FormLayout tabLayout = new FormLayout();
+    tabLayout.marginWidth = 15;
+    tabLayout.marginHeight = 15;
+    wInputComposite.setLayout(tabLayout);
+
+    Label wlRetryAttempts = new Label(wInputComposite, SWT.RIGHT);
+    PropsUi.setLook(wlRetryAttempts);
+    wlRetryAttempts.setText(
+        BaseMessages.getString(PKG, 
"PipelineExecutorDialog.RetryAttempts.Label"));
+    FormData fdlRetryAttempts = new FormData();
+    fdlRetryAttempts.left = new FormAttachment(0, 0);
+    fdlRetryAttempts.top = new FormAttachment(0, 0);
+    fdlRetryAttempts.right = new FormAttachment(middle, -margin);
+    wlRetryAttempts.setLayoutData(fdlRetryAttempts);
+
+    wRetryAttempts = new TextVar(variables, wInputComposite, SWT.SINGLE | 
SWT.LEFT | SWT.BORDER);
+    wRetryAttempts.enableExpandedInteger();
+    PropsUi.setLook(wRetryAttempts);
+    wRetryAttempts.setToolTipText(
+        BaseMessages.getString(PKG, 
"PipelineExecutorDialog.RetryAttempts.Tooltip"));
+    FormData fdRetryAttempts = new FormData();
+    fdRetryAttempts.left = new FormAttachment(middle, 0);
+    fdRetryAttempts.top = new FormAttachment(wlRetryAttempts, 0, SWT.CENTER);
+    fdRetryAttempts.right = new FormAttachment(100, 0);
+    wRetryAttempts.setLayoutData(fdRetryAttempts);
+    wRetryAttempts.addModifyListener(lsMod);
+
+    Label wlRetryDelay = new Label(wInputComposite, SWT.RIGHT);
+    PropsUi.setLook(wlRetryDelay);
+    wlRetryDelay.setText(BaseMessages.getString(PKG, 
"PipelineExecutorDialog.RetryDelay.Label"));
+    FormData fdlRetryDelay = new FormData();
+    fdlRetryDelay.left = new FormAttachment(0, 0);
+    fdlRetryDelay.top = new FormAttachment(wRetryAttempts, margin);
+    fdlRetryDelay.right = new FormAttachment(middle, -margin);
+    wlRetryDelay.setLayoutData(fdlRetryDelay);
+
+    wRetryDelay = new TextVar(variables, wInputComposite, SWT.SINGLE | 
SWT.LEFT | SWT.BORDER);
+    wRetryDelay.enableExpandedInteger();
+    PropsUi.setLook(wRetryDelay);
+    wRetryDelay.setToolTipText(
+        BaseMessages.getString(PKG, 
"PipelineExecutorDialog.RetryDelay.Tooltip"));
+    FormData fdRetryDelay = new FormData();
+    fdRetryDelay.left = new FormAttachment(middle, 0);
+    fdRetryDelay.top = new FormAttachment(wlRetryDelay, 0, SWT.CENTER);
+    fdRetryDelay.right = new FormAttachment(100, 0);
+    wRetryDelay.setLayoutData(fdRetryDelay);
+    wRetryDelay.addModifyListener(lsMod);
+
+    wTab.setControl(wInputComposite);
+    wTabFolder.setSelection(wTab);
+  }
+
   private void addExecutionResultTab() {
 
     final CTabItem wTab = new CTabItem(wTabFolder, SWT.NONE);
@@ -1235,6 +1302,8 @@ public class PipelineExecutorDialog extends 
BaseTransformDialog {
     pipelineExecutorMeta.setFilenameField(wPipelineNameField.getText());
     pipelineExecutorMeta.setRunConfigurationName(wRunConfiguration.getText());
     pipelineExecutorMeta.setWaitTimeout(wWaitTimeout.getText());
+    pipelineExecutorMeta.setRetryAttempts(wRetryAttempts.getText());
+    pipelineExecutorMeta.setRetryDelay(wRetryDelay.getText());
 
     // Load the information on the tabs, optionally do some
     // verifications...
diff --git 
a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMeta.java
 
b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMeta.java
index 492783ab80..14653badf7 100644
--- 
a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMeta.java
+++ 
b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMeta.java
@@ -87,6 +87,14 @@ public class PipelineExecutorMeta
   @HopMetadataProperty(key = "wait_timeout")
   private String waitTimeout;
 
+  /** Number of retries when child pipeline execution fails. */
+  @HopMetadataProperty(key = "retry_attempts", groupKey = "retry")
+  private String retryAttempts;
+
+  /** Delay between retries in milliseconds. */
+  @HopMetadataProperty(key = "retry_delay", groupKey = "retry")
+  private String retryDelay;
+
   /** Flag that indicate that pipeline name is specified in a stream's field */
   @HopMetadataProperty(key = "filenameInField")
   private boolean filenameInField;
@@ -257,6 +265,20 @@ public class PipelineExecutorMeta
     if (value != null) {
       setInheritingAllVariables("Y".equalsIgnoreCase(value));
     }
+
+    // Load retry options from nested <retry> group.
+    Node retryNode = XmlHandler.getSubNode(node, "retry");
+    if (retryNode == null) {
+      return;
+    }
+    String retryAttemptsValue = XmlHandler.getTagValue(retryNode, 
"retry_attempts");
+    if (retryAttemptsValue != null) {
+      setRetryAttempts(retryAttemptsValue);
+    }
+    String retryDelayValue = XmlHandler.getTagValue(retryNode, "retry_delay");
+    if (retryDelayValue != null) {
+      setRetryDelay(retryDelayValue);
+    }
   }
 
   @Override
@@ -287,6 +309,8 @@ public class PipelineExecutorMeta
     executionLogTextField = "ExecutionLogText";
     executionLogChannelIdField = "ExecutionLogChannelId";
     resultFilesFileNameField = "FileName";
+    retryAttempts = "0";
+    retryDelay = "";
   }
 
   void prepareExecutionResultsFields(IRowMeta row, TransformMeta nextTransform)
diff --git 
a/plugins/transforms/pipelineexecutor/src/main/resources/org/apache/hop/pipeline/transforms/pipelineexecutor/messages/messages_en_US.properties
 
b/plugins/transforms/pipelineexecutor/src/main/resources/org/apache/hop/pipeline/transforms/pipelineexecutor/messages/messages_en_US.properties
index 9ddb5bbc55..a8eb747dbd 100644
--- 
a/plugins/transforms/pipelineexecutor/src/main/resources/org/apache/hop/pipeline/transforms/pipelineexecutor/messages/messages_en_US.properties
+++ 
b/plugins/transforms/pipelineexecutor/src/main/resources/org/apache/hop/pipeline/transforms/pipelineexecutor/messages/messages_en_US.properties
@@ -19,6 +19,7 @@ PipelineExecutor.Description=This transform executes a Hop 
pipeline, sets parame
 PipelineExecutor.Exception.GroupFieldNotFound=Group field ''{0}'' could not be 
found in the input stream
 PipelineExecutor.Name=Pipeline executor
 PipelineExecutor.Log.WaitTimeoutReached=Maximum wait time of {0} ms reached, 
stopping the pipeline
+PipelineExecutor.Log.RetryInterrupted=Interrupted while waiting before 
retrying failed child pipeline execution
 PipelineExecutor.UnexpectedError=There was an unexpected error\:
 PipelineExecutorDialog.Browse.Label=Browse...
 PipelineExecutorDialog.ColumnInfo.Field=Field name
@@ -70,10 +71,16 @@ PipelineExecutorDialog.ResultRows.Title=Result rows
 PipelineExecutorDialog.ResultRows.Tooltip=You can specify the target and 
specification of the result rows that need to be passed from the pipeline.
 PipelineExecutorDialog.RowGroup.Title=Row grouping
 PipelineExecutorDialog.RowGroup.Tooltip=Determine how many rows of data are 
passed to the pipeline before execution
+PipelineExecutorDialog.Retry.Title=Retry
+PipelineExecutorDialog.Retry.Tooltip=Configure retries for failed child 
pipeline executions.
 PipelineExecutorDialog.RunConfiguration.Label=Run configuration
 PipelineExecutorDialog.RunConfiguration.Tooltip=The pipeline run configuration 
used to execute the specified pipeline. Use the buttons to create, edit or open 
the selected configuration.
 PipelineExecutorDialog.WaitTimeout.Label=Stop after (milliseconds)
-PipelineExecutorDialog.WaitTimeout.Tooltip=Maximum time to wait for the 
pipeline to complete, in milliseconds. Empty or 0 means wait indefinitely. When 
the time is reached the pipeline is stopped and the execution result is 
reported as failed. You can use a variable such as '${MAX_WAIT_MS}'.
+PipelineExecutorDialog.WaitTimeout.Tooltip=Maximum time to wait for each child 
pipeline execution attempt, in milliseconds. Empty or 0 means wait 
indefinitely. When the time is reached the child pipeline is stopped and that 
attempt is reported as failed. You can use a variable such as '${MAX_WAIT_MS}'.
+PipelineExecutorDialog.RetryAttempts.Label=Retry attempts on failure
+PipelineExecutorDialog.RetryAttempts.Tooltip=Number of retries for failed 
child pipeline executions. 0 means no retries. You can use a variable such as 
'${PIPELINE_RETRY_ATTEMPTS}'.
+PipelineExecutorDialog.RetryDelay.Label=Retry delay (milliseconds)
+PipelineExecutorDialog.RetryDelay.Tooltip=Delay between retry attempts in 
milliseconds. Empty or 0 retries immediately. You can use a variable such as 
'${PIPELINE_RETRY_DELAY_MS}'.
 PipelineExecutorDialog.Shell.Title=Pipeline executor
 PipelineExecutorDialog.TransformName.Label=Transform name
 PipelineExecutorMeta.CheckResult.NoInputReceived=No input is received from 
previous transforms
diff --git 
a/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMetaTest.java
 
b/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMetaTest.java
index 2f2423506a..5384592300 100644
--- 
a/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMetaTest.java
+++ 
b/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorMetaTest.java
@@ -39,6 +39,8 @@ class PipelineExecutorMetaTest {
 
     assertEquals("${PROJECT_HOME}/loops/child-loops-log-counter.hpl", 
meta.getFilename());
     assertEquals("1500", meta.getWaitTimeout());
+    assertEquals("3", meta.getRetryAttempts());
+    assertEquals("250", meta.getRetryDelay());
     assertEquals("execution results", 
meta.getExecutionResultTargetTransform());
     assertEquals("ExecutionTime", meta.getExecutionTimeField());
     assertEquals("ExecutionResult", meta.getExecutionResultField());
@@ -91,6 +93,8 @@ class PipelineExecutorMetaTest {
     assertEquals(meta.getResultFilesFileNameField(), 
clone.getResultFilesFileNameField());
     assertEquals(meta.getExecutorsOutputTransform(), 
clone.getExecutorsOutputTransform());
     assertEquals(meta.getWaitTimeout(), clone.getWaitTimeout());
+    assertEquals(meta.getRetryAttempts(), clone.getRetryAttempts());
+    assertEquals(meta.getRetryDelay(), clone.getRetryDelay());
     assertEquals(meta.getParameters().size(), clone.getParameters().size());
     assertEquals(meta.getResultRows().size(), clone.getResultRows().size());
   }
@@ -104,6 +108,8 @@ class PipelineExecutorMetaTest {
     assertEquals("1", meta.getGroupSize());
     assertEquals("", meta.getGroupField());
     assertEquals("", meta.getGroupTime());
+    assertEquals("0", meta.getRetryAttempts());
+    assertEquals("", meta.getRetryDelay());
     assertEquals("ExecutionTime", meta.getExecutionTimeField());
     assertEquals("ExecutionResult", meta.getExecutionResultField());
     assertEquals("ExecutionNrErrors", meta.getExecutionNrErrorsField());
diff --git 
a/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorTest.java
 
b/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorTest.java
index 6dd60614a4..388faf62c6 100644
--- 
a/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorTest.java
+++ 
b/plugins/transforms/pipelineexecutor/src/test/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorTest.java
@@ -23,11 +23,17 @@ 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 static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
 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;
 import static org.mockito.Mockito.when;
 
@@ -123,6 +129,23 @@ class PipelineExecutorTest {
     executor.discardLogLines(data);
   }
 
+  @Test
+  void cleanupPreviousAttemptPipelineStopsRunningChildPipeline() {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    PipelineExecutorData data = new PipelineExecutorData();
+    @SuppressWarnings("unchecked")
+    IPipelineEngine<PipelineMeta> previousChild = mock(IPipelineEngine.class);
+    when(previousChild.isRunning()).thenReturn(true);
+    when(previousChild.getLogChannelId()).thenReturn("previous-child-channel");
+    data.setExecutorPipeline(previousChild);
+    PipelineExecutor executor = newExecutor(meta, data);
+
+    executor.cleanupPreviousAttemptPipeline(data);
+
+    verify(previousChild).stopAll();
+  }
+
   @Test
   void passParametersToPipelineMapsIncomingFieldToChildParameter() throws 
HopException {
     PipelineExecutorMeta meta = new PipelineExecutorMeta();
@@ -430,4 +453,187 @@ class PipelineExecutorTest {
     assertNotNull(loaded);
     assertFalse(loaded.getTransforms().isEmpty());
   }
+
+  @Test
+  void executeWithRetriesRetriesFailedAttemptUntilSuccess() throws 
HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    meta.setRetryAttempts("2");
+    meta.setRetryDelay("0");
+
+    PipelineExecutor executor = spy(newExecutor(meta, new 
PipelineExecutorData()));
+
+    Result failed = new Result();
+    failed.setResult(false);
+    failed.setNrErrors(1);
+    Result success = new Result();
+    success.setResult(true);
+    success.setNrErrors(0);
+
+    doReturn(failed, success).when(executor).executePipelineAttempt(any(), 
anyLong());
+
+    Result result = executor.executeWithRetries(Collections.emptyList());
+
+    assertTrue(result.isResult());
+    verify(executor, times(2)).executePipelineAttempt(any(), anyLong());
+  }
+
+  @Test
+  void executeWithRetriesCleansUpPreviousAttemptOnlyWhenRetrying() throws 
HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    meta.setRetryAttempts("1");
+    meta.setRetryDelay("0");
+
+    PipelineExecutor executor = spy(newExecutor(meta, new 
PipelineExecutorData()));
+
+    Result failed = new Result();
+    failed.setResult(false);
+    failed.setNrErrors(1);
+    Result success = new Result();
+    success.setResult(true);
+    success.setNrErrors(0);
+
+    doReturn(failed, success).when(executor).executePipelineAttempt(any(), 
anyLong());
+    doNothing().when(executor).cleanupPreviousAttemptPipeline(any());
+
+    Result result = executor.executeWithRetries(Collections.emptyList());
+
+    assertTrue(result.isResult());
+    verify(executor, times(1)).cleanupPreviousAttemptPipeline(any());
+  }
+
+  @Test
+  void executeWithRetriesDoesNotCleanUpWhenNoRetryWillRun() throws 
HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    meta.setRetryAttempts("0");
+    meta.setRetryDelay("0");
+
+    PipelineExecutor executor = spy(newExecutor(meta, new 
PipelineExecutorData()));
+
+    Result failed = new Result();
+    failed.setResult(false);
+    failed.setNrErrors(1);
+
+    doReturn(failed).when(executor).executePipelineAttempt(any(), anyLong());
+    doNothing().when(executor).cleanupPreviousAttemptPipeline(any());
+
+    Result result = executor.executeWithRetries(Collections.emptyList());
+
+    assertFalse(result.isResult());
+    verify(executor, never()).cleanupPreviousAttemptPipeline(any());
+  }
+
+  @Test
+  void executePipelineAttemptStopsCurrentChildWhenStartThreadsThrows() throws 
HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+
+    PipelineExecutorData data = new PipelineExecutorData();
+    data.groupBuffer = new ArrayList<>();
+    data.groupBuffer.add(new RowMetaAndData(new RowMeta(), new Object[0]));
+
+    @SuppressWarnings("unchecked")
+    IPipelineEngine<PipelineMeta> child = mock(IPipelineEngine.class);
+    when(child.getLogChannelId()).thenReturn("failing-child-channel");
+    when(child.isRunning()).thenReturn(true);
+    doThrow(new HopException("start failed")).when(child).startThreads();
+
+    PipelineExecutor executor = spy(newExecutor(meta, data));
+    doReturn(child).when(executor).createInternalPipeline();
+    doNothing().when(executor).passParametersToPipeline(any());
+
+    Result result = executor.executePipelineAttempt(Collections.emptyList(), 
0);
+
+    assertFalse(result.isResult());
+    verify(child).stopAll();
+    verify(executor, never()).discardLogLines(any());
+  }
+
+  @Test
+  void executeWithRetriesParsesExpandedRetryAttempts() throws HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    meta.setRetryAttempts("1e0");
+    meta.setRetryDelay("0");
+
+    PipelineExecutor executor = spy(newExecutor(meta, new 
PipelineExecutorData()));
+
+    Result failed = new Result();
+    failed.setResult(false);
+    failed.setNrErrors(1);
+
+    doReturn(failed).when(executor).executePipelineAttempt(any(), anyLong());
+
+    Result result = executor.executeWithRetries(Collections.emptyList());
+
+    assertFalse(result.isResult());
+    verify(executor, times(2)).executePipelineAttempt(any(), anyLong());
+  }
+
+  @Test
+  void executeWithRetriesDoesNotRetryWhenRetriesAreDisabled() throws 
HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    meta.setRetryAttempts("0");
+    meta.setRetryDelay("0");
+
+    PipelineExecutor executor = spy(newExecutor(meta, new 
PipelineExecutorData()));
+
+    Result failed = new Result();
+    failed.setResult(false);
+    failed.setNrErrors(1);
+
+    doReturn(failed).when(executor).executePipelineAttempt(any(), anyLong());
+
+    Result result = executor.executeWithRetries(Collections.emptyList());
+
+    assertFalse(result.isResult());
+    verify(executor, times(1)).executePipelineAttempt(any(), anyLong());
+  }
+
+  @Test
+  void executeWithRetriesUsesConfiguredStopAfterForEachAttempt() throws 
HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    meta.setWaitTimeout("1");
+    meta.setRetryAttempts("5");
+    meta.setRetryDelay("0");
+
+    PipelineExecutor executor = spy(newExecutor(meta, new 
PipelineExecutorData()));
+
+    Result failed = new Result();
+    failed.setResult(false);
+    failed.setNrErrors(1);
+
+    doReturn(failed).when(executor).executePipelineAttempt(any(), anyLong());
+
+    Result result = executor.executeWithRetries(Collections.emptyList());
+
+    assertFalse(result.isResult());
+    verify(executor, times(6)).executePipelineAttempt(any(), eq(1L));
+  }
+
+  @Test
+  void executeWithRetriesDoesNotStartAnotherAttemptAfterTransformStop() throws 
HopException {
+    PipelineExecutorMeta meta = new PipelineExecutorMeta();
+    meta.setDefault();
+    meta.setRetryAttempts("5");
+    meta.setRetryDelay("0");
+
+    PipelineExecutor executor = spy(newExecutor(meta, new 
PipelineExecutorData()));
+
+    Result failed = new Result();
+    failed.setResult(false);
+    failed.setNrErrors(1);
+
+    doReturn(failed).when(executor).executePipelineAttempt(any(), anyLong());
+    doReturn(false, true).when(executor).isStopped();
+
+    Result result = executor.executeWithRetries(Collections.emptyList());
+
+    assertFalse(result.isResult());
+    verify(executor, times(1)).executePipelineAttempt(any(), anyLong());
+  }
 }
diff --git 
a/plugins/transforms/pipelineexecutor/src/test/resources/pipeline-executor-transform.xml
 
b/plugins/transforms/pipelineexecutor/src/test/resources/pipeline-executor-transform.xml
index 1c5423bb56..844a5c3b10 100644
--- 
a/plugins/transforms/pipelineexecutor/src/test/resources/pipeline-executor-transform.xml
+++ 
b/plugins/transforms/pipelineexecutor/src/test/resources/pipeline-executor-transform.xml
@@ -29,6 +29,10 @@
     </partitioning>
     <run_configuration>local</run_configuration>
     <wait_timeout>1500</wait_timeout>
+    <retry>
+        <retry_attempts>3</retry_attempts>
+        <retry_delay>250</retry_delay>
+    </retry>
     <filename>${PROJECT_HOME}/loops/child-loops-log-counter.hpl</filename>
     <filenameInField>N</filenameInField>
     <filenameField/>

Reply via email to