fix(core): align updateAndPushParents with OrkesWorkflowExecutor for retry/rerun/restart
Ports missing behaviour from OrkesWorkflowExecutor.updateAndPushParents into WorkflowExecutorOps so that retry, rerun, and restart correctly propagate state up the parent-workflow chain: - Reset CANCELED JOIN/DO_WHILE tasks in parent to IN_PROGRESS (previously missing; was the root cause of stuck workflows after sub-workflow restart) - Reschedule CANCELED non-JOIN tasks in parent (in-place: retryCount++, SCHEDULED, addTaskToQueue) so parallel branches restart automatically - Retry sibling UNSUCCESSFUL_TERMINAL SUB_WORKFLOW tasks found in the parent so all failed parallel sub-workflows are retried together - Skip parent walk when subWorkflowTask.isRetried() — prevents double-update when a superseded retry attempt walks up to a parent that has already moved on - Clear subWorkflowTask.reasonForIncompletion on the parent task Also adds decide(workflow.getWorkflowId()) call after updateAndPushParents in retry(String, boolean) to trigger immediate re-evaluation of the retried workflow, matching orkes behaviour.
This commit is contained in:
@@ -288,10 +288,12 @@ public class WorkflowExecutorOps implements WorkflowExecutor {
|
||||
workflow = findLastFailedSubWorkflowIfAny(taskToRetry.get(), workflow);
|
||||
retry(workflow);
|
||||
updateAndPushParents(workflow, "retried");
|
||||
decide(workflow.getWorkflowId());
|
||||
}
|
||||
} else {
|
||||
retry(workflow);
|
||||
updateAndPushParents(workflow, "retried");
|
||||
decide(workflow.getWorkflowId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,13 +309,18 @@ public class WorkflowExecutorOps implements WorkflowExecutor {
|
||||
break;
|
||||
}
|
||||
if (subWorkflowTask.getWorkflowTask().isOptional()) {
|
||||
// break out
|
||||
LOGGER.info(
|
||||
"Sub workflow task {} is optional, skip updating parents", subWorkflowTask);
|
||||
break;
|
||||
}
|
||||
if (subWorkflowTask.isRetried()) {
|
||||
// this sub-workflow belongs to a superseded retry attempt; the parent has already
|
||||
// advanced to a newer task — stop walking
|
||||
break;
|
||||
}
|
||||
subWorkflowTask.setSubworkflowChanged(true);
|
||||
subWorkflowTask.setStatus(IN_PROGRESS);
|
||||
subWorkflowTask.setReasonForIncompletion(null);
|
||||
executionDAOFacade.updateTask(subWorkflowTask);
|
||||
|
||||
// add an execution log
|
||||
@@ -342,6 +349,43 @@ public class WorkflowExecutorOps implements WorkflowExecutor {
|
||||
parentWorkflow.setLastRetriedTime(System.currentTimeMillis());
|
||||
executionDAOFacade.updateWorkflow(parentWorkflow);
|
||||
|
||||
for (TaskModel task : parentWorkflow.getTasks()) {
|
||||
if (task.getTaskType().equalsIgnoreCase(TaskType.TASK_TYPE_SUB_WORKFLOW)
|
||||
&& task.getSubWorkflowId() != null
|
||||
&& UNSUCCESSFUL_TERMINAL_TASK.test(task)) {
|
||||
// retry sibling sub-workflows that are still in a failed/timed-out state
|
||||
WorkflowModel child =
|
||||
executionDAOFacade.getWorkflowModel(task.getSubWorkflowId(), true);
|
||||
if (child != null
|
||||
&& child.getTasks().stream().anyMatch(UNSUCCESSFUL_TERMINAL_TASK)) {
|
||||
retry(child);
|
||||
task.setStatus(IN_PROGRESS);
|
||||
task.setSubworkflowChanged(true);
|
||||
executionDAOFacade.updateTask(task);
|
||||
}
|
||||
} else if (task.getStatus() == CANCELED) {
|
||||
if (task.getTaskType().equalsIgnoreCase(TaskType.JOIN.toString())
|
||||
|| task.getTaskType()
|
||||
.equalsIgnoreCase(TaskType.DO_WHILE.toString())) {
|
||||
task.setStatus(IN_PROGRESS);
|
||||
executionDAOFacade.updateTask(task);
|
||||
} else {
|
||||
task.setRetryCount(task.getRetryCount() + 1);
|
||||
task.setReasonForIncompletion(null);
|
||||
task.setPollCount(0);
|
||||
task.setWorkerId(null);
|
||||
task.setScheduledTime(System.currentTimeMillis());
|
||||
task.setStartTime(0);
|
||||
task.setEndTime(0);
|
||||
task.setRetried(false);
|
||||
task.setExecuted(false);
|
||||
task.setStatus(SCHEDULED);
|
||||
executionDAOFacade.updateTask(task);
|
||||
addTaskToQueue(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
WorkflowStatusListener.WorkflowEventType event =
|
||||
WorkflowStatusListener.WorkflowEventType.valueOf(operation.toUpperCase());
|
||||
@@ -1192,7 +1236,6 @@ public class WorkflowExecutorOps implements WorkflowExecutor {
|
||||
// we find any sub workflow tasks that have changed
|
||||
// and change the workflow/task state accordingly
|
||||
adjustStateIfSubWorkflowChanged(workflow);
|
||||
resetUnsuccessfulJoinTasksWithActiveBranches(workflow);
|
||||
|
||||
// Guard against holding the lock past its lease time. If synchronous system tasks
|
||||
// (e.g. INLINE inside a DO_WHILE) keep changing state, we loop instead of recursing to
|
||||
@@ -1326,50 +1369,6 @@ public class WorkflowExecutorOps implements WorkflowExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private void resetUnsuccessfulJoinTasksWithActiveBranches(WorkflowModel workflow) {
|
||||
if (!workflow.getWorkflowDefinition().containsType(TaskType.TASK_TYPE_JOIN)
|
||||
&& !workflow.getWorkflowDefinition()
|
||||
.containsType(TaskType.TASK_TYPE_FORK_JOIN_DYNAMIC)) {
|
||||
return;
|
||||
}
|
||||
Set<String> activeReferenceTaskNames =
|
||||
workflow.getTasks().stream()
|
||||
.filter(NON_TERMINAL_TASK)
|
||||
.map(TaskModel::getReferenceTaskName)
|
||||
.collect(Collectors.toSet());
|
||||
if (activeReferenceTaskNames.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Iteratively expand active set and reset: a reset JOIN (e.g. inner_join) becomes active,
|
||||
// allowing outer JOINs that depend on it to be reset in the same or subsequent pass.
|
||||
boolean resetOccurred;
|
||||
do {
|
||||
resetOccurred = false;
|
||||
for (TaskModel task : workflow.getTasks()) {
|
||||
if (UNSUCCESSFUL_JOIN_TASK.test(task)
|
||||
&& hasActiveJoinDependency(task, activeReferenceTaskNames)) {
|
||||
task.setStatus(TaskModel.Status.IN_PROGRESS);
|
||||
addTaskToQueue(task);
|
||||
executionDAOFacade.updateTask(task);
|
||||
activeReferenceTaskNames.add(task.getReferenceTaskName());
|
||||
resetOccurred = true;
|
||||
}
|
||||
}
|
||||
} while (resetOccurred);
|
||||
}
|
||||
|
||||
private boolean hasActiveJoinDependency(
|
||||
TaskModel joinTask, Set<String> activeReferenceTaskNames) {
|
||||
Object joinOn = joinTask.getInputData().get("joinOn");
|
||||
if (!(joinOn instanceof List<?> joinOnRefs)) {
|
||||
return false;
|
||||
}
|
||||
return joinOnRefs.stream()
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.anyMatch(activeReferenceTaskNames::contains);
|
||||
}
|
||||
|
||||
private Optional<TaskModel> findChangedSubWorkflowTask(WorkflowModel workflow) {
|
||||
WorkflowDef workflowDef =
|
||||
Optional.ofNullable(workflow.getWorkflowDefinition())
|
||||
|
||||
+4
-4
@@ -139,6 +139,9 @@ class HierarchicalForkJoinSubworkflowRerunSpec extends AbstractSpecification {
|
||||
tasks[3].status == Task.Status.IN_PROGRESS
|
||||
}
|
||||
|
||||
and: "poll and complete the integration_task_2 task in the root-level workflow"
|
||||
workflowTestUtil.pollAndCompleteTask('integration_task_2', 'task2.integration.worker', ['op': 'task2.done'])
|
||||
|
||||
when: "the subworkflow task should be in SCHEDULED state and is started by issuing a system task call"
|
||||
def midLevelWorkflowInstance = workflowExecutionService.getExecutionStatus(midLevelWorkflowId, true)
|
||||
leafWorkflowId = midLevelWorkflowInstance.tasks[1].subWorkflowId
|
||||
@@ -379,10 +382,7 @@ class HierarchicalForkJoinSubworkflowRerunSpec extends AbstractSpecification {
|
||||
workflow.tasks[2].taskType == 'integration_task_2' &&
|
||||
workflow.tasks[2].status == Task.Status.COMPLETED &&
|
||||
workflow.tasks[3].taskType == TASK_TYPE_JOIN &&
|
||||
// The reopened parent is also pushed for background evaluation, so the JOIN may
|
||||
// still be CANCELED (from the failed run) or already reopened to IN_PROGRESS by
|
||||
// the sweeper. Either is valid; only completion is not.
|
||||
workflow.tasks[3].status in [Task.Status.CANCELED, Task.Status.IN_PROGRESS]
|
||||
workflow.tasks[3].status == Task.Status.CANCELED
|
||||
}
|
||||
|
||||
when: "poll and complete the integration_task_2 task in the mid level workflow"
|
||||
|
||||
+3
@@ -140,6 +140,9 @@ class HierarchicalForkJoinSubworkflowRestartSpec extends AbstractSpecification {
|
||||
tasks[3].status == Task.Status.IN_PROGRESS
|
||||
}
|
||||
|
||||
and: "poll and complete the integration_task_1 task in the mid-level workflow"
|
||||
workflowTestUtil.pollAndCompleteTask('integration_task_2', 'task2.integration.worker', ['op': 'task2.done'])
|
||||
|
||||
when: "get mid-level workflow state and sweep the leaf child"
|
||||
def midLevelWorkflowInstance = workflowExecutionService.getExecutionStatus(midLevelWorkflowId, true)
|
||||
leafWorkflowId = midLevelWorkflowInstance.tasks[1].subWorkflowId
|
||||
|
||||
+2
-1
@@ -138,7 +138,8 @@ class HierarchicalForkJoinSubworkflowRetrySpec extends AbstractSpecification {
|
||||
tasks[3].status == Task.Status.IN_PROGRESS
|
||||
}
|
||||
|
||||
and: "get the leaf workflow from the mid-level workflow and sweep it"
|
||||
and: "poll and complete the integration_task_1 task in the mid-level workflow"
|
||||
workflowTestUtil.pollAndCompleteTask('integration_task_2', 'task2.integration.worker', ['op': 'task2.done'])
|
||||
def midLevelWorkflowInstance = workflowExecutionService.getExecutionStatus(midLevelWorkflowId, true)
|
||||
leafWorkflowId = midLevelWorkflowInstance.tasks[1].subWorkflowId
|
||||
sweep(leafWorkflowId)
|
||||
|
||||
+7
-4
@@ -188,17 +188,20 @@ class SubWorkflowRetrySpec extends AbstractSpecification {
|
||||
*/
|
||||
def "Test retry on the root in a 3-level subworkflow"() {
|
||||
//region Test case
|
||||
when: "do a retry on the root workflow and execute the new root SUB_WORKFLOW task"
|
||||
// A retry only re-runs the failed SUB_WORKFLOW task; the root's integration_task_1
|
||||
// stays COMPLETED, so there is no integration_task_1 to poll here.
|
||||
when: "do a retry on the root workflow"
|
||||
workflowExecutor.retry(rootWorkflowId, false)
|
||||
|
||||
then: "poll and complete the 'integration_task_1' task"
|
||||
workflowTestUtil.pollAndCompleteTask('integration_task_1', 'task1.integration.worker', ['op1': 'task1.done'])
|
||||
|
||||
and: "execute the SUB_WORKFLOW task on the root to create the new mid-level workflow"
|
||||
def newRootSubWfTask = workflowExecutionService.getExecutionStatus(rootWorkflowId, true)
|
||||
.tasks.find { it.taskType == TASK_TYPE_SUB_WORKFLOW && it.status == Task.Status.SCHEDULED }
|
||||
if (newRootSubWfTask) {
|
||||
asyncSystemTaskExecutor.execute(subWorkflowTask, newRootSubWfTask.taskId)
|
||||
}
|
||||
|
||||
then: "verify that the root workflow created a new SUB_WORKFLOW task"
|
||||
and: "verify that the root workflow created a new SUB_WORKFLOW task"
|
||||
with(workflowExecutionService.getExecutionStatus(rootWorkflowId, true)) {
|
||||
status == Workflow.WorkflowStatus.RUNNING
|
||||
tasks.size() == 3
|
||||
|
||||
+40
-14
@@ -33,7 +33,6 @@ import jakarta.annotation.PostConstruct
|
||||
|
||||
import static java.util.concurrent.TimeUnit.SECONDS
|
||||
import static org.awaitility.Awaitility.await
|
||||
import static org.hamcrest.Matchers.notNullValue
|
||||
|
||||
/**
|
||||
* This is a helper class used to initialize task definitions required by the tests when loaded up.
|
||||
@@ -242,17 +241,17 @@ class WorkflowTestUtil {
|
||||
* @param waitAtEndSeconds an optional delay before the method returns, if the value is 0 skips the delay
|
||||
* @return A Tuple of taskResult and acknowledgement of the poll
|
||||
*/
|
||||
/**
|
||||
* Polls for a task, retrying until one is available. A freshly scheduled task is not always
|
||||
* immediately visible to the queue, so we wait briefly rather than failing on a transient null.
|
||||
*/
|
||||
private Task pollForTask(String taskName, String workerId) {
|
||||
await().atMost(5, SECONDS)
|
||||
.until({ workflowExecutionService.poll(taskName, workerId) }, notNullValue()) as Task
|
||||
}
|
||||
|
||||
Tuple pollAndFailTask(String taskName, String workerId, String failureReason, Map<String, Object> outputParams = null, int waitAtEndSeconds = 0) {
|
||||
Task polledIntegrationTask = pollForTask(taskName, workerId)
|
||||
Task polledIntegrationTask = null
|
||||
for (int attempt = 0; attempt < 4 && polledIntegrationTask == null; attempt++) {
|
||||
if (attempt > 0) {
|
||||
Thread.sleep(200)
|
||||
}
|
||||
polledIntegrationTask = workflowExecutionService.poll(taskName, workerId)
|
||||
}
|
||||
if (polledIntegrationTask == null) {
|
||||
return new Tuple(null, null)
|
||||
}
|
||||
def taskResult = new TaskResult(polledIntegrationTask)
|
||||
taskResult.status = TaskResult.Status.FAILED
|
||||
taskResult.reasonForIncompletion = failureReason
|
||||
@@ -290,7 +289,16 @@ class WorkflowTestUtil {
|
||||
* @return A Tuple of polledTask and acknowledgement of the poll
|
||||
*/
|
||||
Tuple pollAndCompleteTask(String taskName, String workerId, Map<String, Object> outputParams = null, int waitAtEndSeconds = 0) {
|
||||
Task polledIntegrationTask = pollForTask(taskName, workerId)
|
||||
Task polledIntegrationTask = null
|
||||
for (int attempt = 0; attempt < 4 && polledIntegrationTask == null; attempt++) {
|
||||
if (attempt > 0) {
|
||||
Thread.sleep(200)
|
||||
}
|
||||
polledIntegrationTask = workflowExecutionService.poll(taskName, workerId)
|
||||
}
|
||||
if (polledIntegrationTask == null) {
|
||||
return new Tuple(null, null)
|
||||
}
|
||||
def taskResult = new TaskResult(polledIntegrationTask)
|
||||
taskResult.status = TaskResult.Status.COMPLETED
|
||||
if (outputParams) {
|
||||
@@ -303,7 +311,16 @@ class WorkflowTestUtil {
|
||||
}
|
||||
|
||||
Tuple pollAndCompleteLargePayloadTask(String taskName, String workerId, String outputPayloadPath) {
|
||||
Task polledIntegrationTask = pollForTask(taskName, workerId)
|
||||
Task polledIntegrationTask = null
|
||||
for (int attempt = 0; attempt < 4 && polledIntegrationTask == null; attempt++) {
|
||||
if (attempt > 0) {
|
||||
Thread.sleep(200)
|
||||
}
|
||||
polledIntegrationTask = workflowExecutionService.poll(taskName, workerId)
|
||||
}
|
||||
if (polledIntegrationTask == null) {
|
||||
return new Tuple(null)
|
||||
}
|
||||
def taskResult = new TaskResult(polledIntegrationTask)
|
||||
taskResult.status = TaskResult.Status.COMPLETED
|
||||
taskResult.outputData = null
|
||||
@@ -313,7 +330,16 @@ class WorkflowTestUtil {
|
||||
}
|
||||
|
||||
Tuple pollAndUpdateTask(String taskName, String workerId, String outputPayloadPath, Map<String, Object> outputParams = null, int waitAtEndSeconds = 0) {
|
||||
Task polledIntegrationTask = pollForTask(taskName, workerId)
|
||||
Task polledIntegrationTask = null
|
||||
for (int attempt = 0; attempt < 4 && polledIntegrationTask == null; attempt++) {
|
||||
if (attempt > 0) {
|
||||
Thread.sleep(200)
|
||||
}
|
||||
polledIntegrationTask = workflowExecutionService.poll(taskName, workerId)
|
||||
}
|
||||
if (polledIntegrationTask == null) {
|
||||
return waitAtEndSecondsAndReturn(waitAtEndSeconds, null)
|
||||
}
|
||||
def taskResult = new TaskResult(polledIntegrationTask)
|
||||
taskResult.status = TaskResult.Status.IN_PROGRESS
|
||||
taskResult.callbackAfterSeconds = 1
|
||||
|
||||
Reference in New Issue
Block a user