From 04654a6dc5f952921677c71ba1966f2209ec206c Mon Sep 17 00:00:00 2001 From: yanghaojia <2453883990@qq.com> Date: Tue, 19 Dec 2023 10:30:20 +0800 Subject: [PATCH] Feature: Add WHILE Task --- .../common/metadata/tasks/TaskType.java | 13 + .../common/metadata/workflow/WorkflowDef.java | 2 +- .../metadata/workflow/WorkflowTask.java | 16 +- .../core/execution/DeciderService.java | 6 +- .../core/execution/WorkflowExecutor.java | 4 +- .../execution/mapper/WhileTaskMapper.java | 104 ++ .../conductor/core/execution/tasks/While.java | 305 ++++ .../service/WorkflowTestService.java | 1 + .../WorkflowTaskTypeConstraint.java | 9 +- .../core/execution/tasks/WhileSpec.groovy | 369 +++++ .../core/execution/TestWorkflowExecutor.java | 30 +- .../execution/mapper/WhileTaskMapperTest.java | 127 ++ .../WorkflowTaskTypeConstraintTest.java | 22 + .../workflowdef/operators/index.md | 1 + .../workflowdef/operators/while-task.md | 180 +++ .../sdk/workflow/def/tasks/While.java | 97 ++ .../workflow/executor/WorkflowExecutor.java | 1 + mkdocs.yml | 1 + .../test/integration/WhileSpec.groovy | 1258 +++++++++++++++++ .../while_as_subtask_integration_test.json | 117 ++ ...while_five_loop_over_integration_test.json | 123 ++ .../resources/while_integration_test.json | 117 ++ .../resources/while_iteration_fix_test.json | 43 + .../while_multiple_integration_test.json | 151 ++ .../resources/while_set_variable_fix.json | 45 + .../while_sub_workflow_integration_test.json | 135 ++ .../test/resources/while_system_tasks.json | 93 ++ .../resources/while_with_decision_task.json | 62 + ui/cypress/fixtures/while/whileSwitch.json | 416 ++++++ ui/src/components/diagram/WorkflowDAG.js | 60 +- ui/src/components/diagram/WorkflowGraph.jsx | 16 +- .../diagram/WorkflowGraph.test.cy.js | 37 + ui/src/utils/constants.js | 1 + 33 files changed, 3904 insertions(+), 58 deletions(-) create mode 100644 core/src/main/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapper.java create mode 100644 core/src/main/java/com/netflix/conductor/core/execution/tasks/While.java create mode 100644 core/src/test/groovy/com/netflix/conductor/core/execution/tasks/WhileSpec.groovy create mode 100644 core/src/test/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapperTest.java create mode 100644 docs/documentation/configuration/workflowdef/operators/while-task.md create mode 100644 java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/While.java create mode 100644 test-harness/src/test/groovy/com/netflix/conductor/test/integration/WhileSpec.groovy create mode 100644 test-harness/src/test/resources/while_as_subtask_integration_test.json create mode 100644 test-harness/src/test/resources/while_five_loop_over_integration_test.json create mode 100644 test-harness/src/test/resources/while_integration_test.json create mode 100644 test-harness/src/test/resources/while_iteration_fix_test.json create mode 100644 test-harness/src/test/resources/while_multiple_integration_test.json create mode 100644 test-harness/src/test/resources/while_set_variable_fix.json create mode 100644 test-harness/src/test/resources/while_sub_workflow_integration_test.json create mode 100644 test-harness/src/test/resources/while_system_tasks.json create mode 100644 test-harness/src/test/resources/while_with_decision_task.json create mode 100644 ui/cypress/fixtures/while/whileSwitch.json diff --git a/common/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskType.java b/common/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskType.java index 902027b02..64f2976d8 100644 --- a/common/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskType.java +++ b/common/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskType.java @@ -13,6 +13,7 @@ package com.netflix.conductor.common.metadata.tasks; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import com.netflix.conductor.annotations.protogen.ProtoEnum; @@ -27,6 +28,7 @@ public enum TaskType { SWITCH, JOIN, DO_WHILE, + WHILE, SUB_WORKFLOW, START_WORKFLOW, EVENT, @@ -53,6 +55,7 @@ public enum TaskType { public static final String TASK_TYPE_DYNAMIC = "DYNAMIC"; public static final String TASK_TYPE_JOIN = "JOIN"; public static final String TASK_TYPE_DO_WHILE = "DO_WHILE"; + public static final String TASK_TYPE_WHILE = "WHILE"; public static final String TASK_TYPE_FORK_JOIN_DYNAMIC = "FORK_JOIN_DYNAMIC"; public static final String TASK_TYPE_EVENT = "EVENT"; public static final String TASK_TYPE_WAIT = "WAIT"; @@ -81,6 +84,7 @@ public enum TaskType { BUILT_IN_TASKS.add(TASK_TYPE_JOIN); BUILT_IN_TASKS.add(TASK_TYPE_EXCLUSIVE_JOIN); BUILT_IN_TASKS.add(TASK_TYPE_DO_WHILE); + BUILT_IN_TASKS.add(TASK_TYPE_WHILE); } /** @@ -104,4 +108,13 @@ public static TaskType of(String taskType) { public static boolean isBuiltIn(String taskType) { return BUILT_IN_TASKS.contains(taskType); } + + public static boolean isLoopTask(String taskType) { + return Objects.equals(TASK_TYPE_DO_WHILE, taskType) + || Objects.equals(TASK_TYPE_WHILE, taskType); + } + + public static boolean isLoopTask(TaskType taskType) { + return taskType == DO_WHILE || taskType == WHILE; + } } diff --git a/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowDef.java b/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowDef.java index 02c4d0149..63f751dcd 100644 --- a/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowDef.java +++ b/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowDef.java @@ -341,7 +341,7 @@ public WorkflowTask getNextTask(String taskReferenceName) { WorkflowTask nextTask = task.next(taskReferenceName, null); if (nextTask != null) { return nextTask; - } else if (TaskType.DO_WHILE.name().equals(task.getType()) + } else if (TaskType.isLoopTask(task.getType()) && !task.getTaskReferenceName().equals(taskReferenceName) && task.has(taskReferenceName)) { // If the task is child of Loop Task and at last position, return null. diff --git a/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowTask.java b/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowTask.java index 94c9c00e0..e18da3c6c 100644 --- a/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowTask.java +++ b/common/src/main/java/com/netflix/conductor/common/metadata/workflow/WorkflowTask.java @@ -560,6 +560,7 @@ private Collection> children() { workflowTaskLists.addAll(forkTasks); break; case DO_WHILE: + case WHILE: workflowTaskLists.add(loopOver); break; default: @@ -584,6 +585,7 @@ public WorkflowTask next(String taskReferenceName, WorkflowTask parent) { switch (taskType) { case DO_WHILE: + case WHILE: case DECISION: case SWITCH: for (List workflowTasks : children()) { @@ -605,13 +607,12 @@ public WorkflowTask next(String taskReferenceName, WorkflowTask parent) { return iterator.next(); } } - if (taskType == TaskType.DO_WHILE && this.has(taskReferenceName)) { - // come here means this is DO_WHILE task and `taskReferenceName` is the last - // task in - // this DO_WHILE task, because DO_WHILE task need to be executed to decide - // whether to - // schedule next iteration, so we just return the DO_WHILE task, and then ignore - // generating this task again in deciderService.getNextTask() + if (TaskType.isLoopTask(taskType) && this.has(taskReferenceName)) { + // come here means this is DO_WHILE/WHILE task and `taskReferenceName` is the + // last task in this DO_WHILE/WHILE task, because DO_WHILE/WHILE task need to be + // executed to decide whether to schedule next iteration, so we just return the + // DO_WHILE/WHILE task, and then ignore generating this task again in + // deciderService.getNextTask() return this; } break; @@ -663,6 +664,7 @@ public boolean has(String taskReferenceName) { case DECISION: case SWITCH: case DO_WHILE: + case WHILE: case FORK_JOIN: for (List childx : children()) { for (WorkflowTask child : childx) { diff --git a/core/src/main/java/com/netflix/conductor/core/execution/DeciderService.java b/core/src/main/java/com/netflix/conductor/core/execution/DeciderService.java index 6d7a42e70..e81b570ba 100644 --- a/core/src/main/java/com/netflix/conductor/core/execution/DeciderService.java +++ b/core/src/main/java/com/netflix/conductor/core/execution/DeciderService.java @@ -218,7 +218,7 @@ private DeciderOutcome decide(final WorkflowModel workflow, List preS pendingTask.setExecuted(true); List nextTasks = getNextTask(workflow, pendingTask); if (pendingTask.isLoopOverTask() - && !TaskType.DO_WHILE.name().equals(pendingTask.getTaskType()) + && !TaskType.isLoopTask(pendingTask.getTaskType()) && !nextTasks.isEmpty()) { nextTasks = filterNextLoopOverTasks(nextTasks, pendingTask, workflow); } @@ -476,8 +476,8 @@ List getNextTask(WorkflowModel workflow, TaskModel task) { while (isTaskSkipped(taskToSchedule, workflow)) { taskToSchedule = workflowDef.getNextTask(taskToSchedule.getTaskReferenceName()); } - if (taskToSchedule != null && TaskType.DO_WHILE.name().equals(taskToSchedule.getType())) { - // check if already has this DO_WHILE task, ignore it if it already exists + if (taskToSchedule != null && TaskType.isLoopTask(taskToSchedule.getType())) { + // check if already has this DO_WHILE/WHILE task, ignore it if it already exists String nextTaskReferenceName = taskToSchedule.getTaskReferenceName(); if (workflow.getTasks().stream() .anyMatch( diff --git a/core/src/main/java/com/netflix/conductor/core/execution/WorkflowExecutor.java b/core/src/main/java/com/netflix/conductor/core/execution/WorkflowExecutor.java index 84ee5e001..34eaa5163 100644 --- a/core/src/main/java/com/netflix/conductor/core/execution/WorkflowExecutor.java +++ b/core/src/main/java/com/netflix/conductor/core/execution/WorkflowExecutor.java @@ -342,7 +342,7 @@ private void retry(WorkflowModel workflow) { break; case CANCELED: if (task.getTaskType().equalsIgnoreCase(TaskType.JOIN.toString()) - || task.getTaskType().equalsIgnoreCase(TaskType.DO_WHILE.toString())) { + || TaskType.isLoopTask(task.getTaskType())) { task.setStatus(IN_PROGRESS); addTaskToQueue(task); // Task doesn't have to be updated yet. Will be updated along with other @@ -934,7 +934,7 @@ private void extendLease(TaskResult taskResult) { * Determines if a workflow can be lazily evaluated, if it meets any of these criteria * *
    - *
  • The task is NOT a loop task within DO_WHILE + *
  • The task is NOT a loop task within DO_WHILE or WHILE *
  • The task is one of the intermediate tasks in a branch within a FORK_JOIN *
  • The task is forked from a FORK_JOIN_DYNAMIC *
diff --git a/core/src/main/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapper.java b/core/src/main/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapper.java new file mode 100644 index 000000000..a6022e084 --- /dev/null +++ b/core/src/main/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapper.java @@ -0,0 +1,104 @@ +/* + * Copyright 2023 Conductor Authors. + *

+ * Licensed 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 com.netflix.conductor.core.execution.mapper; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.netflix.conductor.common.metadata.tasks.TaskDef; +import com.netflix.conductor.common.metadata.tasks.TaskType; +import com.netflix.conductor.common.metadata.workflow.WorkflowDef; +import com.netflix.conductor.common.metadata.workflow.WorkflowTask; +import com.netflix.conductor.core.utils.ParametersUtils; +import com.netflix.conductor.dao.MetadataDAO; +import com.netflix.conductor.model.TaskModel; +import com.netflix.conductor.model.WorkflowModel; + +/** + * An implementation of {@link TaskMapper} to map a {@link WorkflowTask} of type {@link + * TaskType#WHILE} to a {@link TaskModel} of type {@link TaskType#WHILE} + */ +@Component +public class WhileTaskMapper implements TaskMapper { + + private static final Logger LOGGER = LoggerFactory.getLogger(WhileTaskMapper.class); + + private final MetadataDAO metadataDAO; + private final ParametersUtils parametersUtils; + + @Autowired + public WhileTaskMapper(MetadataDAO metadataDAO, ParametersUtils parametersUtils) { + this.metadataDAO = metadataDAO; + this.parametersUtils = parametersUtils; + } + + @Override + public String getTaskType() { + return TaskType.WHILE.name(); + } + + /** + * This method maps {@link TaskMapper} to map a {@link WorkflowTask} of type {@link + * TaskType#WHILE} to a {@link TaskModel} of type {@link TaskType#WHILE} with a status of {@link + * TaskModel.Status#IN_PROGRESS} + * + * @param taskMapperContext: A wrapper class containing the {@link WorkflowTask}, {@link + * WorkflowDef}, {@link WorkflowModel} and a string representation of the TaskId + * @return: A {@link TaskModel} of type {@link TaskType#WHILE} in a List + */ + @Override + public List getMappedTasks(TaskMapperContext taskMapperContext) { + LOGGER.debug("TaskMapperContext {} in WhileTaskMapper", taskMapperContext); + + WorkflowTask workflowTask = taskMapperContext.getWorkflowTask(); + WorkflowModel workflowModel = taskMapperContext.getWorkflowModel(); + + TaskModel task = workflowModel.getTaskByRefName(workflowTask.getTaskReferenceName()); + if (task != null && task.getStatus().isTerminal()) { + // Since loopTask is already completed no need to schedule task again. + return List.of(); + } + + TaskDef taskDefinition = + Optional.ofNullable(taskMapperContext.getTaskDefinition()) + .orElseGet( + () -> + Optional.ofNullable( + metadataDAO.getTaskDef( + workflowTask.getName())) + .orElseGet(TaskDef::new)); + + TaskModel whileTask = taskMapperContext.createTaskModel(); + whileTask.setTaskType(TaskType.TASK_TYPE_WHILE); + whileTask.setStatus(TaskModel.Status.IN_PROGRESS); + whileTask.setStartTime(System.currentTimeMillis()); + whileTask.setRateLimitPerFrequency(taskDefinition.getRateLimitPerFrequency()); + whileTask.setRateLimitFrequencyInSeconds(taskDefinition.getRateLimitFrequencyInSeconds()); + whileTask.setRetryCount(taskMapperContext.getRetryCount()); + + Map taskInput = + parametersUtils.getTaskInputV2( + workflowTask.getInputParameters(), + workflowModel, + whileTask.getTaskId(), + taskDefinition); + whileTask.setInputData(taskInput); + return List.of(whileTask); + } +} diff --git a/core/src/main/java/com/netflix/conductor/core/execution/tasks/While.java b/core/src/main/java/com/netflix/conductor/core/execution/tasks/While.java new file mode 100644 index 000000000..b102f0d30 --- /dev/null +++ b/core/src/main/java/com/netflix/conductor/core/execution/tasks/While.java @@ -0,0 +1,305 @@ +/* + * Copyright 2023 Conductor Authors. + *

+ * Licensed 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 com.netflix.conductor.core.execution.tasks; + +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import javax.script.ScriptException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import com.netflix.conductor.annotations.VisibleForTesting; +import com.netflix.conductor.common.metadata.tasks.TaskDef; +import com.netflix.conductor.common.metadata.workflow.WorkflowTask; +import com.netflix.conductor.common.utils.TaskUtils; +import com.netflix.conductor.core.events.ScriptEvaluator; +import com.netflix.conductor.core.execution.WorkflowExecutor; +import com.netflix.conductor.core.utils.ParametersUtils; +import com.netflix.conductor.model.TaskModel; +import com.netflix.conductor.model.WorkflowModel; + +import static com.netflix.conductor.common.metadata.tasks.TaskType.TASK_TYPE_WHILE; + +@Component(TASK_TYPE_WHILE) +public class While extends WorkflowSystemTask { + + private static final Logger LOGGER = LoggerFactory.getLogger(While.class); + + private final ParametersUtils parametersUtils; + + public While(ParametersUtils parametersUtils) { + super(TASK_TYPE_WHILE); + this.parametersUtils = parametersUtils; + } + + @Override + public void cancel(WorkflowModel workflow, TaskModel task, WorkflowExecutor executor) { + task.setStatus(TaskModel.Status.CANCELED); + } + + @Override + public boolean execute( + WorkflowModel workflow, TaskModel whileTaskModel, WorkflowExecutor workflowExecutor) { + // It evaluates the loop condition before executing the task list, and if the condition is + // initially false, the task list will not be executed. + if (whileTaskModel.getIteration() == 0) { + try { + boolean shouldContinue = evaluateCondition(workflow, whileTaskModel); + LOGGER.debug( + "Task {} condition evaluated to {}", + whileTaskModel.getTaskId(), + shouldContinue); + if (!shouldContinue) { + LOGGER.debug( + "Task {} took {} iterations to complete", + whileTaskModel.getTaskId(), + whileTaskModel.getIteration()); + whileTaskModel.addOutput("iteration", whileTaskModel.getIteration()); + return markTaskSuccess(whileTaskModel); + } + } catch (ScriptException e) { + String message = + String.format( + "Unable to evaluate condition %s, exception %s", + whileTaskModel.getWorkflowTask().getLoopCondition(), + e.getMessage()); + LOGGER.error(message); + return markTaskFailure( + whileTaskModel, TaskModel.Status.FAILED_WITH_TERMINAL_ERROR, message); + } + } + + boolean hasFailures = false; + StringBuilder failureReason = new StringBuilder(); + Map output = new HashMap<>(); + + /* + * Get the latest set of tasks (the ones that have the highest retry count). We don't want to evaluate any tasks + * that have already failed if there is a more current one (a later retry count). + */ + Map relevantTasks = new LinkedHashMap<>(); + TaskModel relevantTask; + for (TaskModel t : workflow.getTasks()) { + if (whileTaskModel + .getWorkflowTask() + .has(TaskUtils.removeIterationFromTaskRefName(t.getReferenceTaskName())) + && !Objects.equals( + whileTaskModel.getReferenceTaskName(), t.getReferenceTaskName()) + && whileTaskModel.getIteration() == t.getIteration()) { + relevantTask = relevantTasks.get(t.getReferenceTaskName()); + if (relevantTask == null || t.getRetryCount() > relevantTask.getRetryCount()) { + relevantTasks.put(t.getReferenceTaskName(), t); + } + } + } + Collection loopOverTasks = relevantTasks.values(); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Workflow {} waiting for tasks {} to complete iteration {}", + workflow.getWorkflowId(), + loopOverTasks.stream() + .map(TaskModel::getReferenceTaskName) + .collect(Collectors.toList()), + whileTaskModel.getIteration()); + } + + // if the loopOverTasks collection is empty, no tasks inside the loop have been scheduled. + // so schedule it and exit the method. + if (loopOverTasks.isEmpty()) { + whileTaskModel.setIteration(1); + whileTaskModel.addOutput("iteration", whileTaskModel.getIteration()); + return scheduleNextIteration(whileTaskModel, workflow, workflowExecutor); + } + + for (TaskModel loopOverTask : loopOverTasks) { + TaskModel.Status taskStatus = loopOverTask.getStatus(); + hasFailures = !taskStatus.isSuccessful(); + if (hasFailures) { + failureReason.append(loopOverTask.getReasonForIncompletion()).append(" "); + } + output.put( + TaskUtils.removeIterationFromTaskRefName(loopOverTask.getReferenceTaskName()), + loopOverTask.getOutputData()); + if (hasFailures) { + break; + } + } + whileTaskModel.addOutput(String.valueOf(whileTaskModel.getIteration()), output); + + if (hasFailures) { + LOGGER.debug( + "Task {} failed in {} iteration", + whileTaskModel.getTaskId(), + whileTaskModel.getIteration() + 1); + return markTaskFailure( + whileTaskModel, TaskModel.Status.FAILED, failureReason.toString()); + } + + if (!isIterationComplete(whileTaskModel, relevantTasks)) { + // current iteration is not complete (all tasks inside the loop are not terminal) + return false; + } + + // if we are here, the iteration is complete, and we need to check if there is a next + // iteration by evaluating the loopCondition + try { + boolean shouldContinue = evaluateCondition(workflow, whileTaskModel); + LOGGER.debug( + "Task {} condition evaluated to {}", + whileTaskModel.getTaskId(), + shouldContinue); + if (shouldContinue) { + whileTaskModel.setIteration(whileTaskModel.getIteration() + 1); + whileTaskModel.addOutput("iteration", whileTaskModel.getIteration()); + return scheduleNextIteration(whileTaskModel, workflow, workflowExecutor); + } else { + LOGGER.debug( + "Task {} took {} iterations to complete", + whileTaskModel.getTaskId(), + whileTaskModel.getIteration() + 1); + return markTaskSuccess(whileTaskModel); + } + } catch (ScriptException e) { + String message = + String.format( + "Unable to evaluate condition %s, exception %s", + whileTaskModel.getWorkflowTask().getLoopCondition(), e.getMessage()); + LOGGER.error(message); + return markTaskFailure( + whileTaskModel, TaskModel.Status.FAILED_WITH_TERMINAL_ERROR, message); + } + } + + /** + * Check if all tasks in the current iteration have reached terminal state. + * + * @param whileTaskModel The {@link TaskModel} of WHILE. + * @param referenceNameToModel Map of taskReferenceName to {@link TaskModel}. + * @return true if all tasks in WHILE.loopOver are in referenceNameToModel and + * reached terminal state. + */ + private boolean isIterationComplete( + TaskModel whileTaskModel, Map referenceNameToModel) { + List workflowTasksInsideWhile = + whileTaskModel.getWorkflowTask().getLoopOver(); + int iteration = whileTaskModel.getIteration(); + boolean allTasksTerminal = true; + for (WorkflowTask workflowTaskInsideWhile : workflowTasksInsideWhile) { + String taskReferenceName = + TaskUtils.appendIteration( + workflowTaskInsideWhile.getTaskReferenceName(), iteration); + if (referenceNameToModel.containsKey(taskReferenceName)) { + TaskModel taskModel = referenceNameToModel.get(taskReferenceName); + if (!taskModel.getStatus().isTerminal()) { + allTasksTerminal = false; + break; + } + } else { + allTasksTerminal = false; + break; + } + } + + if (!allTasksTerminal) { + // Cases where tasks directly inside loop over are not completed. + // loopOver -> [task1 -> COMPLETED, task2 -> IN_PROGRESS] + return false; + } + + // Check all the tasks in referenceNameToModel are completed or not. These are set of tasks + // which are not directly inside loopOver tasks, but they are under hierarchy + // loopOver -> [decisionTask -> COMPLETED [ task1 -> COMPLETED, task2 -> IN_PROGRESS]] + return referenceNameToModel.values().stream() + .noneMatch(taskModel -> !taskModel.getStatus().isTerminal()); + } + + boolean scheduleNextIteration( + TaskModel whileTaskModel, WorkflowModel workflow, WorkflowExecutor workflowExecutor) { + LOGGER.debug( + "Scheduling loop tasks for task {} as condition {} evaluated to true", + whileTaskModel.getTaskId(), + whileTaskModel.getWorkflowTask().getLoopCondition()); + workflowExecutor.scheduleNextIteration(whileTaskModel, workflow); + return true; // Return true even though status not changed. Iteration has to be updated in + // execution DAO. + } + + boolean markTaskFailure(TaskModel taskModel, TaskModel.Status status, String failureReason) { + LOGGER.error("Marking task {} failed with error.", taskModel.getTaskId()); + taskModel.setReasonForIncompletion(failureReason); + taskModel.setStatus(status); + return true; + } + + boolean markTaskSuccess(TaskModel taskModel) { + LOGGER.debug( + "Task {} took {} iterations to complete", + taskModel.getTaskId(), + taskModel.getIteration() + 1); + taskModel.setStatus(TaskModel.Status.COMPLETED); + return true; + } + + @VisibleForTesting + boolean evaluateCondition(WorkflowModel workflow, TaskModel task) throws ScriptException { + TaskDef taskDefinition = task.getTaskDefinition().orElse(null); + // Use paramUtils to compute the task input + Map conditionInput = + parametersUtils.getTaskInputV2( + task.getWorkflowTask().getInputParameters(), + workflow, + task.getTaskId(), + taskDefinition); + Map outputData = new HashMap<>(task.getOutputData()); + outputData.putIfAbsent("iteration", task.getIteration()); + conditionInput.put(task.getReferenceTaskName(), outputData); + List loopOver = + workflow.getTasks().stream() + .filter( + t -> + (task.getWorkflowTask() + .has( + TaskUtils + .removeIterationFromTaskRefName( + t + .getReferenceTaskName())) + && !Objects.equals( + task.getReferenceTaskName(), + t.getReferenceTaskName()))) + .collect(Collectors.toList()); + + for (TaskModel loopOverTask : loopOver) { + conditionInput.put( + TaskUtils.removeIterationFromTaskRefName(loopOverTask.getReferenceTaskName()), + loopOverTask.getOutputData()); + } + + String condition = task.getWorkflowTask().getLoopCondition(); + boolean result = false; + if (condition != null) { + LOGGER.debug("Condition: {} is being evaluated", condition); + // Evaluate the expression by using the Nashorn based script evaluator + result = ScriptEvaluator.evalBool(condition, conditionInput); + } + return result; + } +} diff --git a/core/src/main/java/com/netflix/conductor/service/WorkflowTestService.java b/core/src/main/java/com/netflix/conductor/service/WorkflowTestService.java index 601c0aba8..2fdfe646d 100644 --- a/core/src/main/java/com/netflix/conductor/service/WorkflowTestService.java +++ b/core/src/main/java/com/netflix/conductor/service/WorkflowTestService.java @@ -36,6 +36,7 @@ public class WorkflowTestService { static { operators.add(TaskType.TASK_TYPE_JOIN); operators.add(TaskType.TASK_TYPE_DO_WHILE); + operators.add(TaskType.TASK_TYPE_WHILE); operators.add(TaskType.TASK_TYPE_SET_VARIABLE); operators.add(TaskType.TASK_TYPE_FORK); operators.add(TaskType.TASK_TYPE_INLINE); diff --git a/core/src/main/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraint.java b/core/src/main/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraint.java index 290d8692b..d476bd269 100644 --- a/core/src/main/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraint.java +++ b/core/src/main/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraint.java @@ -105,7 +105,8 @@ public boolean isValid(WorkflowTask workflowTask, ConstraintValidatorContext con valid = isKafkaPublishTaskValid(workflowTask, context); break; case TaskType.TASK_TYPE_DO_WHILE: - valid = isDoWhileTaskValid(workflowTask, context); + case TaskType.TASK_TYPE_WHILE: + valid = isLoopTaskValid(workflowTask, context); break; case TaskType.TASK_TYPE_SUB_WORKFLOW: valid = isSubWorkflowTaskValid(workflowTask, context); @@ -256,7 +257,7 @@ private boolean isSwitchTaskValid( return valid; } - private boolean isDoWhileTaskValid( + private boolean isLoopTaskValid( WorkflowTask workflowTask, ConstraintValidatorContext context) { boolean valid = true; if (workflowTask.getLoopCondition() == null) { @@ -264,7 +265,7 @@ private boolean isDoWhileTaskValid( String.format( PARAM_REQUIRED_STRING_FORMAT, "loopCondition", - TaskType.DO_WHILE, + workflowTask.getType(), workflowTask.getName()); context.buildConstraintViolationWithTemplate(message).addConstraintViolation(); valid = false; @@ -274,7 +275,7 @@ private boolean isDoWhileTaskValid( String.format( PARAM_REQUIRED_STRING_FORMAT, "loopOver", - TaskType.DO_WHILE, + workflowTask.getType(), workflowTask.getName()); context.buildConstraintViolationWithTemplate(message).addConstraintViolation(); valid = false; diff --git a/core/src/test/groovy/com/netflix/conductor/core/execution/tasks/WhileSpec.groovy b/core/src/test/groovy/com/netflix/conductor/core/execution/tasks/WhileSpec.groovy new file mode 100644 index 000000000..4487dcaea --- /dev/null +++ b/core/src/test/groovy/com/netflix/conductor/core/execution/tasks/WhileSpec.groovy @@ -0,0 +1,369 @@ +/* + * Copyright 2023 Conductor Authors. + *

+ * Licensed 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 com.netflix.conductor.core.execution.tasks + +import com.netflix.conductor.common.metadata.workflow.WorkflowDef +import com.netflix.conductor.common.metadata.workflow.WorkflowTask +import com.netflix.conductor.common.utils.TaskUtils +import com.netflix.conductor.core.execution.WorkflowExecutor +import com.netflix.conductor.core.utils.ParametersUtils +import com.netflix.conductor.model.TaskModel +import com.netflix.conductor.model.WorkflowModel + +import com.fasterxml.jackson.databind.ObjectMapper +import spock.lang.Specification +import spock.lang.Subject + +import static com.netflix.conductor.common.metadata.tasks.TaskType.TASK_TYPE_HTTP +import static com.netflix.conductor.common.metadata.tasks.TaskType.TASK_TYPE_WHILE + +class WhileSpec extends Specification { + + @Subject + While whileTask + + WorkflowExecutor workflowExecutor + ObjectMapper objectMapper + ParametersUtils parametersUtils + TaskModel whileTaskModel + + WorkflowTask task1, task2 + TaskModel taskModel1, taskModel2 + + def setup() { + objectMapper = new ObjectMapper(); + workflowExecutor = Mock(WorkflowExecutor.class) + parametersUtils = new ParametersUtils(objectMapper) + + task1 = new WorkflowTask(name: 'task1', taskReferenceName: 'task1') + task2 = new WorkflowTask(name: 'task2', taskReferenceName: 'task2') + + whileTask = new While(parametersUtils) + } + + def "no iteration"() { + given: + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + whileWorkflowTask.loopCondition = "if (\$.whileTask['iteration'] < 0) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + workflowModel.tasks = [whileTaskModel] + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that return value is true, iteration value is updated in WHILE TaskModel" + retVal + + and: "verify the iteration value" + whileTaskModel.iteration == 0 + whileTaskModel.outputData['iteration'] == 0 + + and: "verify whether the first task is not scheduled" + 0 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "first iteration"() { + given: + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + whileWorkflowTask.loopCondition = "if (\$.whileTask['iteration'] < 1) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + workflowModel.tasks = [whileTaskModel] + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that return value is true, iteration value is updated in WHILE TaskModel" + retVal + + and: "verify the iteration value" + whileTaskModel.iteration == 1 + whileTaskModel.outputData['iteration'] == 1 + + and: "verify whether the first task is scheduled" + 1 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "an iteration - one task is complete and other is not scheduled"() { + given: "WorkflowModel consists of one iteration of one task inside WHILE already completed" + taskModel1 = createTaskModel(task1) + + and: "loop over contains two tasks" + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + whileWorkflowTask.loopCondition = "if (\$.whileTask['iteration'] < 2) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] // two tasks + + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + whileTaskModel.iteration = 1 + whileTaskModel.outputData['iteration'] = 1 + whileTaskModel.status = TaskModel.Status.IN_PROGRESS + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + // setup the WorkflowModel + workflowModel.tasks = [whileTaskModel, taskModel1] + + // this is the expected format of iteration 1's output data + def iteration1OutputData = [:] + iteration1OutputData[task1.taskReferenceName] = taskModel1.outputData + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that the return value is false, since the iteration is not complete" + !retVal + + and: "verify that the next iteration is NOT scheduled" + 0 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "next iteration - one iteration of all tasks inside WHILE are complete"() { + given: "WorkflowModel consists of one iteration of tasks inside WHILE already completed" + taskModel1 = createTaskModel(task1) + taskModel2 = createTaskModel(task2) + + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + whileWorkflowTask.loopCondition = "if (\$.whileTask['iteration'] < 2) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] + + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + whileTaskModel.iteration = 1 + whileTaskModel.outputData['iteration'] = 1 + whileTaskModel.status = TaskModel.Status.IN_PROGRESS + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + // setup the WorkflowModel + workflowModel.tasks = [whileTaskModel, taskModel1, taskModel2] + + // this is the expected format of iteration 1's output data + def iteration1OutputData = [:] + iteration1OutputData[task1.taskReferenceName] = taskModel1.outputData + iteration1OutputData[task2.taskReferenceName] = taskModel2.outputData + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that the return value is true, since the iteration is updated" + retVal + + and: "verify that the WHILE TaskModel is correct" + whileTaskModel.iteration == 2 + whileTaskModel.outputData['iteration'] == 2 + whileTaskModel.outputData['1'] == iteration1OutputData + whileTaskModel.status == TaskModel.Status.IN_PROGRESS + + and: "verify whether the first task in the next iteration is scheduled" + 1 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "next iteration - a task failed in the previous iteration"() { + given: "WorkflowModel consists of one iteration of tasks one of which is FAILED" + taskModel1 = createTaskModel(task1) + + taskModel2 = createTaskModel(task2, TaskModel.Status.FAILED) + taskModel2.reasonForIncompletion = 'no specific reason, i am tired of success' + + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + whileWorkflowTask.loopCondition = "if (\$.whileTask['iteration'] < 2) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] + + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + whileTaskModel.iteration = 1 + whileTaskModel.outputData['iteration'] = 1 + whileTaskModel.status = TaskModel.Status.IN_PROGRESS + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + // setup the WorkflowModel + workflowModel.tasks = [whileTaskModel, taskModel1, taskModel2] + + // this is the expected format of iteration 1's output data + def iteration1OutputData = [:] + iteration1OutputData[task1.taskReferenceName] = taskModel1.outputData + iteration1OutputData[task2.taskReferenceName] = taskModel2.outputData + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that return value is true, status is updated" + retVal + + and: "verify the status and reasonForIncompletion fields" + whileTaskModel.iteration == 1 + whileTaskModel.outputData['iteration'] == 1 + whileTaskModel.outputData['1'] == iteration1OutputData + whileTaskModel.status == TaskModel.Status.FAILED + whileTaskModel.reasonForIncompletion && whileTaskModel.reasonForIncompletion.contains(taskModel2.reasonForIncompletion) + + and: "verify that next iteration is NOT scheduled" + 0 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "next iteration - a task is in progress in the previous iteration"() { + given: "WorkflowModel consists of one iteration of tasks inside WHILE already completed" + taskModel1 = createTaskModel(task1) + taskModel2 = createTaskModel(task2, TaskModel.Status.IN_PROGRESS) + taskModel2.outputData = [:] // no output data, task is in progress + + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + whileWorkflowTask.loopCondition = "if (\$.whileTask['iteration'] < 2) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] + + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + whileTaskModel.iteration = 1 + whileTaskModel.outputData['iteration'] = 1 + whileTaskModel.status = TaskModel.Status.IN_PROGRESS + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + // setup the WorkflowModel + workflowModel.tasks = [whileTaskModel, taskModel1, taskModel2] + + // this is the expected format of iteration 1's output data + def iteration1OutputData = [:] + iteration1OutputData[task1.taskReferenceName] = taskModel1.outputData + iteration1OutputData[task2.taskReferenceName] = [:] + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that return value is false, since the WHILE task model is not updated" + !retVal + + and: "verify that WHILE task model is not modified" + whileTaskModel.iteration == 1 + whileTaskModel.outputData['iteration'] == 1 + whileTaskModel.outputData['1'] == iteration1OutputData + whileTaskModel.status == TaskModel.Status.IN_PROGRESS + + and: "verify that next iteration is NOT scheduled" + 0 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "final step - all iterations are complete and all tasks in them are successful"() { + given: "WorkflowModel consists of one iteration of tasks inside WHILE already completed" + taskModel1 = createTaskModel(task1) + taskModel2 = createTaskModel(task2) + + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + whileWorkflowTask.loopCondition = "if (\$.whileTask['iteration'] < 1) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] + + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + whileTaskModel.iteration = 1 + whileTaskModel.outputData['iteration'] = 1 + whileTaskModel.status = TaskModel.Status.IN_PROGRESS + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + // setup the WorkflowModel + workflowModel.tasks = [whileTaskModel, taskModel1, taskModel2] + + // this is the expected format of iteration 1's output data + def iteration1OutputData = [:] + iteration1OutputData[task1.taskReferenceName] = taskModel1.outputData + iteration1OutputData[task2.taskReferenceName] = taskModel2.outputData + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that the return value is true, WHILE TaskModel is updated" + retVal + + and: "verify the status and other fields are set correctly" + whileTaskModel.iteration == 1 + whileTaskModel.outputData['iteration'] == 1 + whileTaskModel.outputData['1'] == iteration1OutputData + whileTaskModel.status == TaskModel.Status.COMPLETED + + and: "verify that next iteration is not scheduled" + 0 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "next iteration - one iteration of all tasks inside WHILE are complete, but the condition is incorrect"() { + given: "WorkflowModel consists of one iteration of tasks inside WHILE already completed" + taskModel1 = createTaskModel(task1) + taskModel2 = createTaskModel(task2) + + WorkflowTask whileWorkflowTask = new WorkflowTask(taskReferenceName: 'whileTask', type: TASK_TYPE_WHILE) + // condition will produce a ScriptException + whileWorkflowTask.loopCondition = "if (dollar_sign_goes_here.whileTask['iteration'] < 2) { true; } else { false; }" + whileWorkflowTask.loopOver = [task1, task2] + + whileTaskModel = new TaskModel(workflowTask: whileWorkflowTask, taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE, referenceTaskName: whileWorkflowTask.taskReferenceName) + whileTaskModel.iteration = 1 + whileTaskModel.outputData['iteration'] = 1 + whileTaskModel.status = TaskModel.Status.IN_PROGRESS + + def workflowModel = new WorkflowModel(workflowDefinition: new WorkflowDef(name: 'test_workflow')) + // setup the WorkflowModel + workflowModel.tasks = [whileTaskModel, taskModel1, taskModel2] + + // this is the expected format of iteration 1's output data + def iteration1OutputData = [:] + iteration1OutputData[task1.taskReferenceName] = taskModel1.outputData + iteration1OutputData[task2.taskReferenceName] = taskModel2.outputData + + when: + def retVal = whileTask.execute(workflowModel, whileTaskModel, workflowExecutor) + + then: "verify that the return value is true since WHILE TaskModel is updated" + retVal + + and: "verify the status of WHILE TaskModel" + whileTaskModel.iteration == 1 + whileTaskModel.outputData['iteration'] == 1 + whileTaskModel.outputData['1'] == iteration1OutputData + whileTaskModel.status == TaskModel.Status.FAILED_WITH_TERMINAL_ERROR + whileTaskModel.reasonForIncompletion != null + + and: "verify that next iteration is not scheduled" + 0 * workflowExecutor.scheduleNextIteration(whileTaskModel, workflowModel) + } + + def "cancel sets the status as CANCELED"() { + given: + whileTaskModel = new TaskModel(taskId: UUID.randomUUID().toString(), + taskType: TASK_TYPE_WHILE) + whileTaskModel.iteration = 1 + whileTaskModel.outputData['iteration'] = 1 + whileTaskModel.status = TaskModel.Status.IN_PROGRESS + + when: "cancel is called with null for WorkflowModel and WorkflowExecutor" + // null is used to note that those arguments are not intended to be used by this method + whileTask.cancel(null, whileTaskModel, null) + + then: + whileTaskModel.status == TaskModel.Status.CANCELED + } + + private static createTaskModel(WorkflowTask workflowTask, TaskModel.Status status = TaskModel.Status.COMPLETED, int iteration = 1) { + TaskModel taskModel1 = new TaskModel(workflowTask: workflowTask, taskType: TASK_TYPE_HTTP) + + taskModel1.status = status + taskModel1.outputData = ['k1': 'v1'] + taskModel1.iteration = iteration + taskModel1.referenceTaskName = TaskUtils.appendIteration(workflowTask.taskReferenceName, iteration) + + return taskModel1 + } +} diff --git a/core/src/test/java/com/netflix/conductor/core/execution/TestWorkflowExecutor.java b/core/src/test/java/com/netflix/conductor/core/execution/TestWorkflowExecutor.java index b974c5c95..4a71bda8a 100644 --- a/core/src/test/java/com/netflix/conductor/core/execution/TestWorkflowExecutor.java +++ b/core/src/test/java/com/netflix/conductor/core/execution/TestWorkflowExecutor.java @@ -2482,14 +2482,26 @@ public void testIsLazyEvaluateWorkflow() { doWhile.setName("doWhile"); doWhile.setTaskReferenceName("doWhile"); - WorkflowTask loopTask = new WorkflowTask(); - loopTask.setType(SIMPLE.name()); - loopTask.setName("loopTask"); - loopTask.setTaskReferenceName("loopTask"); + WorkflowTask loopTask1 = new WorkflowTask(); + loopTask1.setType(SIMPLE.name()); + loopTask1.setName("loopTask1"); + loopTask1.setTaskReferenceName("loopTask1"); - doWhile.setLoopOver(List.of(loopTask)); + doWhile.setLoopOver(List.of(loopTask1)); - workflowDef.getTasks().addAll(List.of(simpleTask, forkTask, joinTask, doWhile)); + WorkflowTask whileTask = new WorkflowTask(); + whileTask.setType(WHILE.name()); + whileTask.setName("while"); + whileTask.setTaskReferenceName("while"); + + WorkflowTask loopTask2 = new WorkflowTask(); + loopTask2.setType(SIMPLE.name()); + loopTask2.setName("loopTask2"); + loopTask2.setTaskReferenceName("loopTask2"); + + whileTask.setLoopOver(List.of(loopTask2)); + + workflowDef.getTasks().addAll(List.of(simpleTask, forkTask, joinTask, doWhile, whileTask)); TaskModel task = new TaskModel(); task.setStatus(TaskModel.Status.COMPLETED); @@ -2507,7 +2519,11 @@ public void testIsLazyEvaluateWorkflow() { task.setReferenceTaskName("simple"); assertFalse(workflowExecutor.isLazyEvaluateWorkflow(workflowDef, task)); - task.setReferenceTaskName("loopTask__1"); + task.setReferenceTaskName("loopTask1__1"); + task.setIteration(1); + assertFalse(workflowExecutor.isLazyEvaluateWorkflow(workflowDef, task)); + + task.setReferenceTaskName("loopTask2__1"); task.setIteration(1); assertFalse(workflowExecutor.isLazyEvaluateWorkflow(workflowDef, task)); diff --git a/core/src/test/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapperTest.java b/core/src/test/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapperTest.java new file mode 100644 index 000000000..d00d0d42f --- /dev/null +++ b/core/src/test/java/com/netflix/conductor/core/execution/mapper/WhileTaskMapperTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2023 Conductor Authors. + *

+ * Licensed 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 com.netflix.conductor.core.execution.mapper; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.netflix.conductor.common.metadata.tasks.TaskDef; +import com.netflix.conductor.common.metadata.tasks.TaskType; +import com.netflix.conductor.common.metadata.workflow.WorkflowDef; +import com.netflix.conductor.common.metadata.workflow.WorkflowTask; +import com.netflix.conductor.common.utils.TaskUtils; +import com.netflix.conductor.core.execution.DeciderService; +import com.netflix.conductor.core.utils.IDGenerator; +import com.netflix.conductor.core.utils.ParametersUtils; +import com.netflix.conductor.dao.MetadataDAO; +import com.netflix.conductor.model.TaskModel; +import com.netflix.conductor.model.WorkflowModel; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import static com.netflix.conductor.common.metadata.tasks.TaskType.TASK_TYPE_WHILE; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class WhileTaskMapperTest { + + private TaskModel task1; + private DeciderService deciderService; + private WorkflowModel workflow; + private WorkflowTask workflowTask1; + private TaskMapperContext taskMapperContext; + private MetadataDAO metadataDAO; + private ParametersUtils parametersUtils; + + @Before + public void setup() { + WorkflowTask workflowTask = new WorkflowTask(); + workflowTask.setType(TaskType.WHILE.name()); + workflowTask.setTaskReferenceName("Test"); + workflowTask.setInputParameters(Map.of("value", "${workflow.input.foo}")); + task1 = new TaskModel(); + task1.setReferenceTaskName("task1"); + TaskModel task2 = new TaskModel(); + task2.setReferenceTaskName("task2"); + workflowTask1 = new WorkflowTask(); + workflowTask1.setTaskReferenceName("task1"); + WorkflowTask workflowTask2 = new WorkflowTask(); + workflowTask2.setTaskReferenceName("task2"); + task1.setWorkflowTask(workflowTask1); + task2.setWorkflowTask(workflowTask2); + workflowTask.setLoopOver(Arrays.asList(task1.getWorkflowTask(), task2.getWorkflowTask())); + workflowTask.setLoopCondition( + "if ($.second_task + $.first_task > 10) { false; } else { true; }"); + + String taskId = new IDGenerator().generate(); + + WorkflowDef workflowDef = new WorkflowDef(); + workflow = new WorkflowModel(); + workflow.setWorkflowDefinition(workflowDef); + workflow.setInput(Map.of("foo", "bar")); + + deciderService = Mockito.mock(DeciderService.class); + metadataDAO = Mockito.mock(MetadataDAO.class); + + taskMapperContext = + TaskMapperContext.newBuilder() + .withDeciderService(deciderService) + .withWorkflowModel(workflow) + .withTaskDefinition(new TaskDef()) + .withWorkflowTask(workflowTask) + .withRetryCount(0) + .withTaskId(taskId) + .build(); + + parametersUtils = new ParametersUtils(new ObjectMapper()); + } + + @Test + public void getMappedTasks() { + Mockito.doReturn(Collections.singletonList(task1)) + .when(deciderService) + .getTasksToBeScheduled(workflow, workflowTask1, 0); + + List mappedTasks = + new WhileTaskMapper(metadataDAO, parametersUtils).getMappedTasks(taskMapperContext); + + assertNotNull(mappedTasks); + assertEquals(mappedTasks.size(), 1); + assertEquals(TASK_TYPE_WHILE, mappedTasks.get(0).getTaskType()); + assertNotNull(mappedTasks.get(0).getInputData()); + assertEquals(Map.of("value", "bar"), mappedTasks.get(0).getInputData()); + } + + @Test + public void shouldNotScheduleCompletedTask() { + task1.setStatus(TaskModel.Status.COMPLETED); + + List mappedTasks = + new WhileTaskMapper(metadataDAO, parametersUtils).getMappedTasks(taskMapperContext); + + assertNotNull(mappedTasks); + assertEquals(mappedTasks.size(), 1); + } + + @Test + public void testAppendIteration() { + assertEquals("task__1", TaskUtils.appendIteration("task", 1)); + } +} diff --git a/core/src/test/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraintTest.java b/core/src/test/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraintTest.java index eb1d88dd3..894dbc98a 100644 --- a/core/src/test/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraintTest.java +++ b/core/src/test/java/com/netflix/conductor/validations/WorkflowTaskTypeConstraintTest.java @@ -172,6 +172,28 @@ public void testWorkflowTaskTypeDoWhile() { "loopOver field is required for taskType: DO_WHILE taskName: encode")); } + @Test + public void testWorkflowTaskTypeWhile() { + WorkflowTask workflowTask = createSampleWorkflowTask(); + workflowTask.setType("WHILE"); + + when(mockMetadataDao.getTaskDef(anyString())).thenReturn(new TaskDef()); + + Set> result = validator.validate(workflowTask); + assertEquals(2, result.size()); + + List validationErrors = new ArrayList<>(); + + result.forEach(e -> validationErrors.add(e.getMessage())); + + assertTrue( + validationErrors.contains( + "loopCondition field is required for taskType: WHILE taskName: encode")); + assertTrue( + validationErrors.contains( + "loopOver field is required for taskType: WHILE taskName: encode")); + } + @Test public void testWorkflowTaskTypeWait() { WorkflowTask workflowTask = createSampleWorkflowTask(); diff --git a/docs/documentation/configuration/workflowdef/operators/index.md b/docs/documentation/configuration/workflowdef/operators/index.md index a325a3531..c90dd2963 100644 --- a/docs/documentation/configuration/workflowdef/operators/index.md +++ b/docs/documentation/configuration/workflowdef/operators/index.md @@ -9,6 +9,7 @@ Conductor supports the following programming language constructs: | Language Construct | Conductor Operator | | -------------------------- | ----------------------------------------- | | Do-While or For Loops | [Do While Task](do-while-task.md) | +| While or For Loops | [While Task](while-task.md) | | Function Pointer | [Dynamic Task](dynamic-task.md) | | Dynamic Parallel execution | [Dynamic Fork Task](dynamic-fork-task.md) | | Static Parallel execution | [Fork Task](fork-task.md) | diff --git a/docs/documentation/configuration/workflowdef/operators/while-task.md b/docs/documentation/configuration/workflowdef/operators/while-task.md new file mode 100644 index 000000000..b3af0aeb3 --- /dev/null +++ b/docs/documentation/configuration/workflowdef/operators/while-task.md @@ -0,0 +1,180 @@ +# While +```json +"type" : "WHILE" +``` + +The `WHILE` task is similar to the `DO_WHILE` task in that it sequentially executes a list of tasks as long as a condition is true. However, unlike the `DO_WHILE` task, the `WHILE` task checks the condition before executing the task list, and if the condition is initially false, the task list will not be executed. + +When scheduled, each task within the `WHILE` task will have its `taskReferenceName` concatenated with __i, where i is the iteration number starting at 1. It is important to note that `taskReferenceName` containing arithmetic operators must not be used. + +Similar to the `DO_WHILE` task, the output of each task is stored as part of the `WHILE` task, indexed by the iteration value, allowing the condition to reference the output of a task for a specific iteration. + +The `WHILE` task is set to `FAILED` as soon as one of the loopOver tasks fails. In such a case, the iteration is retried starting from 1. + +## Limitations +- Domain or isolation group execution is unsupported. +- Nested `WHILE` tasks are unsupported, however, the `WHILE` task supports using `SUB_WORKFLOW` as a loopOver task, allowing for similar functionality. +- Since loopOver tasks will be executed in a loop inside the scope of the parent `WHILE` task, branching outside of the `WHILE` task is not respected. + +Branching inside loopOver task is supported. + +## Configuration +The following fields must be specified at the top level of the task configuration. + +| name | type | description | +|---------------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| loopCondition | String | Condition to be evaluated after every iteration. This is a Javascript expression, evaluated using the Nashorn engine. If an exception occurs during evaluation, the WHILE task is set to FAILED_WITH_TERMINAL_ERROR. | +| loopOver | List\[Task] | List of tasks that needs to be executed as long as the condition is true. | + +## Output + +| name | type | description | +|-----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| iteration | Integer | Iteration number: the current one while executing; the final one once the loop is finished | +| `i` | Map[String, Any] | Iteration number as a string, mapped to the task references names and their output. | +| * | Any | Any state can be stored here if the `loopCondition` does so. For example `storage` will exist if `loopCondition` is `if ($.LoopTask['iteration'] <= 10) {$.LoopTask.storage = 3; true } else {false}` | + +## Examples +### Basic Example + +The following definition: +```json +{ + "name": "Loop Task", + "taskReferenceName": "LoopTask", + "type": "WHILE", + "inputParameters": { + "value": "${workflow.input.value}" + }, + "loopCondition": "if ( ($.LoopTask['iteration'] < $.value ) || ( $.first_task['response']['body'] > 10)) { false; } else { true; }", + "loopOver": [ + { + "name": "first task", + "taskReferenceName": "first_task", + "inputParameters": { + "http_request": { + "uri": "http://localhost:8082", + "method": "POST" + } + }, + "type": "HTTP" + },{ + "name": "second task", + "taskReferenceName": "second_task", + "inputParameters": { + "http_request": { + "uri": "http://localhost:8082", + "method": "POST" + } + }, + "type": "HTTP" + } + ], + "startDelay": 0, + "optional": false +} +``` + +will produce the following execution, assuming 3 executions occurred (alongside `first_task__1`, `first_task__2`, `first_task__3`, +`second_task__1`, `second_task__2` and `second_task__3`): + +```json +{ + "taskType": "WHILE", + "outputData": { + "iteration": 3, + "1": { + "first_task": { + "response": {}, + "headers": { + "Content-Type": "application/json" + } + }, + "second_task": { + "response": {}, + "headers": { + "Content-Type": "application/json" + } + } + }, + "2": { + "first_task": { + "response": {}, + "headers": { + "Content-Type": "application/json" + } + }, + "second_task": { + "response": {}, + "headers": { + "Content-Type": "application/json" + } + } + }, + "3": { + "first_task": { + "response": {}, + "headers": { + "Content-Type": "application/json" + } + }, + "second_task": { + "response": {}, + "headers": { + "Content-Type": "application/json" + } + } + } + } +} +``` + +### Example using iteration key + +Sometimes, you may want to use the iteration value/counter in the tasks used in the loop. In this example, an API call is made to GitHub (to the Netflix Conductor repository), but each loop increases the pagination. + +The Loop ```taskReferenceName``` is "get_all_stars_loop_ref". + +In the ```loopCondition``` the term ```$.get_all_stars_loop_ref['iteration']``` is used. + +In tasks embedded in the loop, ```${get_all_stars_loop_ref.output.iteration}``` is used. In this case, it is used to define which page of results the API should return. + +```json +{ + "name": "get_all_stars", + "taskReferenceName": "get_all_stars_loop_ref", + "inputParameters": { + "stargazers": [ + 1, + 2 + ] + }, + "type": "WHILE", + "startDelay": 0, + "optional": false, + "asyncComplete": false, + "loopCondition": "if ( $.stargazers && $.get_all_stars_loop_ref['iteration'] < $.stargazers.length ) { true; } else { false; }", + "loopOver": [ + { + "name": "100_stargazers", + "taskReferenceName": "hundred_stargazers_ref", + "inputParameters": { + "counter": "${get_all_stars_loop_ref.output.iteration}", + "http_request": { + "uri": "https://api.github.com/repos/netflix/conductor/stargazers?page=${get_all_stars_loop_ref.output.iteration}&per_page=100", + "method": "GET", + "headers": { + "Authorization": "token ${workflow.input.gh_token}", + "Accept": "application/vnd.github.v3.star+json" + } + } + }, + "type": "HTTP", + "startDelay": 0, + "optional": false, + "asyncComplete": false, + "retryCount": 3 + } + ] +} +``` diff --git a/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/While.java b/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/While.java new file mode 100644 index 000000000..2025fb411 --- /dev/null +++ b/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/While.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023 Conductor Authors. + *

+ * Licensed 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 com.netflix.conductor.sdk.workflow.def.tasks; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.netflix.conductor.common.metadata.tasks.TaskType; +import com.netflix.conductor.common.metadata.workflow.WorkflowTask; + +public class While extends Task { + + private String loopCondition; + + private List> loopTasks = new ArrayList<>(); + + /** + * Execute tasks in a loop determined by the condition set using condition parameter. The loop + * will continue till the condition is true + * + * @param taskReferenceName + * @param condition Javascript that evaluates to a boolean value + * @param tasks + */ + public While(String taskReferenceName, String condition, Task... tasks) { + super(taskReferenceName, TaskType.WHILE); + Collections.addAll(this.loopTasks, tasks); + this.loopCondition = condition; + } + + /** + * Similar to a for loop, run tasks for N times + * + * @param taskReferenceName + * @param loopCount + * @param tasks + */ + public While(String taskReferenceName, int loopCount, Task... tasks) { + super(taskReferenceName, TaskType.WHILE); + Collections.addAll(this.loopTasks, tasks); + this.loopCondition = getForLoopCondition(loopCount); + } + + While(WorkflowTask workflowTask) { + super(workflowTask); + this.loopCondition = workflowTask.getLoopCondition(); + for (WorkflowTask task : workflowTask.getLoopOver()) { + Task loopTask = TaskRegistry.getTask(task); + this.loopTasks.add(loopTask); + } + } + + public While loopOver(Task... tasks) { + for (Task task : tasks) { + this.loopTasks.add(task); + } + return this; + } + + private String getForLoopCondition(int loopCount) { + return "if ( $." + + getTaskReferenceName() + + "['iteration'] < " + + loopCount + + ") { true; } else { false; }"; + } + + public String getLoopCondition() { + return loopCondition; + } + + public List getLoopTasks() { + return loopTasks; + } + + @Override + public void updateWorkflowTask(WorkflowTask workflowTask) { + workflowTask.setLoopCondition(loopCondition); + + List loopWorkflowTasks = new ArrayList<>(); + for (Task task : this.loopTasks) { + loopWorkflowTasks.addAll(task.getWorkflowDefTasks()); + } + workflowTask.setLoopOver(loopWorkflowTasks); + } +} diff --git a/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/executor/WorkflowExecutor.java b/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/executor/WorkflowExecutor.java index 15ba64683..4af6da958 100644 --- a/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/executor/WorkflowExecutor.java +++ b/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/executor/WorkflowExecutor.java @@ -69,6 +69,7 @@ public class WorkflowExecutor { public static void initTaskImplementations() { TaskRegistry.register(TaskType.DO_WHILE.name(), DoWhile.class); + TaskRegistry.register(TaskType.WHILE.name(), While.class); TaskRegistry.register(TaskType.DYNAMIC.name(), Dynamic.class); TaskRegistry.register(TaskType.FORK_JOIN_DYNAMIC.name(), DynamicFork.class); TaskRegistry.register(TaskType.FORK_JOIN.name(), ForkJoin.class); diff --git a/mkdocs.yml b/mkdocs.yml index a5c1166ef..476ec8149 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Operators: - documentation/configuration/workflowdef/operators/index.md - documentation/configuration/workflowdef/operators/do-while-task.md + - documentation/configuration/workflowdef/operators/while-task.md - documentation/configuration/workflowdef/operators/dynamic-task.md - documentation/configuration/workflowdef/operators/dynamic-fork-task.md - documentation/configuration/workflowdef/operators/fork-task.md diff --git a/test-harness/src/test/groovy/com/netflix/conductor/test/integration/WhileSpec.groovy b/test-harness/src/test/groovy/com/netflix/conductor/test/integration/WhileSpec.groovy new file mode 100644 index 000000000..5dc5d0799 --- /dev/null +++ b/test-harness/src/test/groovy/com/netflix/conductor/test/integration/WhileSpec.groovy @@ -0,0 +1,1258 @@ +/* + * Copyright 2023 Conductor Authors. + *

+ * Licensed 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 com.netflix.conductor.test.integration + +import org.springframework.beans.factory.annotation.Autowired + +import com.netflix.conductor.common.metadata.tasks.Task +import com.netflix.conductor.common.metadata.tasks.TaskDef +import com.netflix.conductor.common.metadata.tasks.TaskResult +import com.netflix.conductor.common.run.Workflow +import com.netflix.conductor.common.utils.TaskUtils +import com.netflix.conductor.core.execution.tasks.Join +import com.netflix.conductor.core.execution.tasks.SubWorkflow +import com.netflix.conductor.test.base.AbstractSpecification + +import static com.netflix.conductor.test.util.WorkflowTestUtil.verifyPolledAndAcknowledgedTask + +class WhileSpec extends AbstractSpecification { + + @Autowired + Join joinTask + + @Autowired + SubWorkflow subWorkflowTask + + def setup() { + workflowTestUtil.registerWorkflows('while_integration_test.json', + 'while_multiple_integration_test.json', + 'while_as_subtask_integration_test.json', + 'while_iteration_fix_test.json', + 'while_sub_workflow_integration_test.json', + 'while_five_loop_over_integration_test.json', + 'while_system_tasks.json', + 'while_with_decision_task.json', + 'while_set_variable_fix.json') + } + + def "Test workflow with 2 iterations of five tasks"() { + given: "Number of iterations of the loop is set to 1" + def workflowInput = new HashMap() + workflowInput['loop'] = 2 + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("while_five_loop_over_integration_test", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 4 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'LAMBDA' + tasks[1].status == Task.Status.COMPLETED + tasks[1].iteration == 1 + tasks[2].taskType == 'JSON_JQ_TRANSFORM' + tasks[2].status == Task.Status.COMPLETED + tasks[2].iteration == 1 + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.SCHEDULED + tasks[3].iteration == 1 + } + + when: "Polling and completing first task" + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'LAMBDA' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'JSON_JQ_TRANSFORM' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'JSON_JQ_TRANSFORM' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.SCHEDULED + } + + when: "Polling and completing second task" + Tuple polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + verifyTaskIteration(polledAndCompletedTask2[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 9 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'LAMBDA' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'JSON_JQ_TRANSFORM' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'JSON_JQ_TRANSFORM' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'LAMBDA' + tasks[6].status == Task.Status.COMPLETED + tasks[6].iteration == 2 + tasks[7].taskType == 'JSON_JQ_TRANSFORM' + tasks[7].status == Task.Status.COMPLETED + tasks[7].iteration == 2 + tasks[8].taskType == 'integration_task_1' + tasks[8].status == Task.Status.SCHEDULED + tasks[8].iteration == 2 + } + + when: "Polling and completing first task" + polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 2) + + when: "Polling and completing second task" + polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + verifyTaskIteration(polledAndCompletedTask2[0] as Task, 2) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 12 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'LAMBDA' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'JSON_JQ_TRANSFORM' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'JSON_JQ_TRANSFORM' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'LAMBDA' + tasks[6].status == Task.Status.COMPLETED + tasks[6].iteration == 2 + tasks[7].taskType == 'JSON_JQ_TRANSFORM' + tasks[7].status == Task.Status.COMPLETED + tasks[7].iteration == 2 + tasks[8].taskType == 'integration_task_1' + tasks[8].status == Task.Status.COMPLETED + tasks[8].iteration == 2 + tasks[9].taskType == 'JSON_JQ_TRANSFORM' + tasks[9].status == Task.Status.COMPLETED + tasks[9].iteration == 2 + tasks[10].taskType == 'integration_task_2' + tasks[10].status == Task.Status.COMPLETED + tasks[10].iteration == 2 + tasks[11].taskType == 'integration_task_3' + tasks[11].status == Task.Status.SCHEDULED + tasks[11].iteration == 0 // this is outside WHILE + } + + when: "Polling and completing last task" + polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_3', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 12 + tasks[11].taskType == 'integration_task_3' + tasks[11].status == Task.Status.COMPLETED + tasks[11].iteration == 0 + } + } + + def "Test workflow with 2 iterations of 3 system tasks"() { + given: "Number of iterations of the loop is set to 1" + def workflowInput = new HashMap() + workflowInput['loop'] = 2 + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("while_system_tasks", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 8 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'LAMBDA' + tasks[1].status == Task.Status.COMPLETED + tasks[1].iteration == 1 + tasks[2].taskType == 'JSON_JQ_TRANSFORM' + tasks[2].status == Task.Status.COMPLETED + tasks[2].iteration == 1 + tasks[3].taskType == 'JSON_JQ_TRANSFORM' + tasks[3].status == Task.Status.COMPLETED + tasks[3].iteration == 1 + tasks[4].taskType == 'LAMBDA' + tasks[4].status == Task.Status.COMPLETED + tasks[4].iteration == 2 + tasks[5].taskType == 'JSON_JQ_TRANSFORM' + tasks[5].status == Task.Status.COMPLETED + tasks[5].iteration == 2 + tasks[6].taskType == 'JSON_JQ_TRANSFORM' + tasks[6].status == Task.Status.COMPLETED + tasks[6].iteration == 2 + tasks[7].taskType == 'integration_task_1' + tasks[7].status == Task.Status.SCHEDULED + tasks[7].iteration == 0 // outside the loop + } + + when: "Polling and completing first task" + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 8 + tasks[7].taskType == 'integration_task_1' + tasks[7].status == Task.Status.COMPLETED + tasks[7].iteration == 0 // outside the loop + } + } + + def "Test workflow with a single iteration While task"() { + given: "Number of iterations of the loop is set to 1" + def workflowInput = new HashMap() + workflowInput['loop'] = 1 + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("While_Workflow", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 2 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.SCHEDULED + } + + when: "Polling and completing first task" + Tuple polledAndCompletedTask0 = workflowTestUtil.pollAndCompleteTask('integration_task_0', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask0) + verifyTaskIteration(polledAndCompletedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.SCHEDULED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing second task" + def joinId = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).getTaskByRefName("join__1").taskId + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing third task" + Tuple polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + and: "JOIN task is executed" + asyncSystemTaskExecutor.execute(joinTask, joinId) + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + verifyTaskIteration(polledAndCompletedTask2[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + } + } + + def "Test workflow with a single iteration While task with Sub workflow"() { + given: "Number of iterations of the loop is set to 1" + def workflowInput = new HashMap() + workflowInput['loop'] = 1 + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("While_Sub_Workflow", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 2 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.SCHEDULED + } + + when: "Polling and completing first task" + Tuple polledAndCompletedTask0 = workflowTestUtil.pollAndCompleteTask('integration_task_0', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask0) + verifyTaskIteration(polledAndCompletedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.SCHEDULED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing second task" + def joinId = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).getTaskByRefName("join__1").taskId + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing third task" + Tuple polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + and: "JOIN task is executed" + asyncSystemTaskExecutor.execute(joinTask, joinId) + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + verifyTaskIteration(polledAndCompletedTask2[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'SUB_WORKFLOW' + tasks[6].status == Task.Status.SCHEDULED + } + + when: "the sub workflow is started by issuing a system task call" + def parentWorkflow = workflowExecutionService.getExecutionStatus(workflowInstanceId, true) + def subWorkflowTaskId = parentWorkflow.getTaskByRefName('st1__1').taskId + asyncSystemTaskExecutor.execute(subWorkflowTask, subWorkflowTaskId) + + then: "verify that the sub workflow task is in a IN PROGRESS state" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'SUB_WORKFLOW' + tasks[6].status == Task.Status.IN_PROGRESS + } + + when: "sub workflow is retrieved" + def workflow = workflowExecutionService.getExecutionStatus(workflowInstanceId, true) + def subWorkflowInstanceId = workflow.getTaskByRefName('st1__1').subWorkflowId + + then: "verify that the sub workflow is in a RUNNING state" + with(workflowExecutionService.getExecutionStatus(subWorkflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 1 + tasks[0].taskType == 'simple_task_in_sub_wf' + tasks[0].status == Task.Status.SCHEDULED + } + + when: "the 'simple_task_in_sub_wf' belonging to the sub workflow is polled and completed" + def polledAndCompletedSubWorkflowTask = workflowTestUtil.pollAndCompleteTask('simple_task_in_sub_wf', 'subworkflow.task.worker') + + then: "verify that the task was polled and acknowledged" + workflowTestUtil.verifyPolledAndAcknowledgedTask(polledAndCompletedSubWorkflowTask) + + and: "verify that the sub workflow is in COMPLETED state" + with(workflowExecutionService.getExecutionStatus(subWorkflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 1 + tasks[0].status == Task.Status.COMPLETED + tasks[0].taskType == 'simple_task_in_sub_wf' + } + + and: "the parent workflow is swept" + sweep(workflowInstanceId) + + and: "verify that the workflow is in COMPLETED state" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'SUB_WORKFLOW' + tasks[6].status == Task.Status.COMPLETED + } + } + + def "Test workflow with multiple While tasks with multiple iterations"() { + given: "Number of iterations of the first loop is set to 2 and second loop is set to 1" + def workflowInput = new HashMap() + workflowInput['loop'] = 2 + workflowInput['loop2'] = 1 + + when: "A workflow with multiple while tasks with multiple iterations is started" + def workflowInstanceId = startWorkflow("While_Multiple", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 2 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.SCHEDULED + } + + when: "Polling and completing first task" + Tuple polledAndCompletedTask0 = workflowTestUtil.pollAndCompleteTask('integration_task_0', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask0) + verifyTaskIteration(polledAndCompletedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.SCHEDULED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing second task" + def join1Id = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).getTaskByRefName("join__1").taskId + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing third task" + Tuple polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + and: "JOIN task is executed" + asyncSystemTaskExecutor.execute(joinTask, join1Id) + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + verifyTaskIteration(polledAndCompletedTask2[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'integration_task_0' + tasks[6].status == Task.Status.SCHEDULED + } + + when: "Polling and completing second iteration of first task" + Tuple polledAndCompletedSecondIterationTask0 = workflowTestUtil.pollAndCompleteTask('integration_task_0', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedSecondIterationTask0, [:]) + verifyTaskIteration(polledAndCompletedSecondIterationTask0[0] as Task, 2) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 11 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'integration_task_0' + tasks[6].status == Task.Status.COMPLETED + tasks[7].taskType == 'FORK' + tasks[7].status == Task.Status.COMPLETED + tasks[8].taskType == 'integration_task_1' + tasks[8].status == Task.Status.SCHEDULED + tasks[9].taskType == 'integration_task_2' + tasks[9].status == Task.Status.SCHEDULED + tasks[10].taskType == 'JOIN' + tasks[10].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing second iteration of second task" + def join2Id = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).getTaskByRefName("join__2").taskId + Tuple polledAndCompletedSecondIterationTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedSecondIterationTask1) + verifyTaskIteration(polledAndCompletedSecondIterationTask1[0] as Task, 2) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 11 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'integration_task_0' + tasks[6].status == Task.Status.COMPLETED + tasks[7].taskType == 'FORK' + tasks[7].status == Task.Status.COMPLETED + tasks[8].taskType == 'integration_task_1' + tasks[8].status == Task.Status.COMPLETED + tasks[9].taskType == 'integration_task_2' + tasks[9].status == Task.Status.SCHEDULED + tasks[10].taskType == 'JOIN' + tasks[10].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing second iteration of third task" + Tuple polledAndCompletedSecondIterationTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + and: "JOIN task is executed" + asyncSystemTaskExecutor.execute(joinTask, join2Id) + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedSecondIterationTask2) + verifyTaskIteration(polledAndCompletedSecondIterationTask2[0] as Task, 2) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 13 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'integration_task_0' + tasks[6].status == Task.Status.COMPLETED + tasks[7].taskType == 'FORK' + tasks[7].status == Task.Status.COMPLETED + tasks[8].taskType == 'integration_task_1' + tasks[8].status == Task.Status.COMPLETED + tasks[9].taskType == 'integration_task_2' + tasks[9].status == Task.Status.COMPLETED + tasks[10].taskType == 'JOIN' + tasks[10].status == Task.Status.COMPLETED + tasks[11].taskType == 'WHILE' + tasks[11].status == Task.Status.IN_PROGRESS + tasks[12].taskType == 'integration_task_3' + tasks[12].status == Task.Status.SCHEDULED + } + + when: "Polling and completing task within the second while" + Tuple polledAndCompletedIntegrationTask3 = workflowTestUtil.pollAndCompleteTask('integration_task_3', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedIntegrationTask3) + verifyTaskIteration(polledAndCompletedIntegrationTask3[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 13 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'FORK' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'integration_task_1' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_2' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'JOIN' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'integration_task_0' + tasks[6].status == Task.Status.COMPLETED + tasks[7].taskType == 'FORK' + tasks[7].status == Task.Status.COMPLETED + tasks[8].taskType == 'integration_task_1' + tasks[8].status == Task.Status.COMPLETED + tasks[9].taskType == 'integration_task_2' + tasks[9].status == Task.Status.COMPLETED + tasks[10].taskType == 'JOIN' + tasks[10].status == Task.Status.COMPLETED + tasks[11].taskType == 'WHILE' + tasks[11].status == Task.Status.COMPLETED + tasks[12].taskType == 'integration_task_3' + tasks[12].status == Task.Status.COMPLETED + } + } + + def "Test retrying a failed while workflow"() { + setup: "Update the task definition with no retries" + def taskName = 'integration_task_0' + def persistedTaskDefinition = workflowTestUtil.getPersistedTaskDefinition(taskName).get() + def modifiedTaskDefinition = new TaskDef(persistedTaskDefinition.name, persistedTaskDefinition.description, + persistedTaskDefinition.ownerEmail, 0, persistedTaskDefinition.timeoutSeconds, + persistedTaskDefinition.responseTimeoutSeconds) + metadataService.updateTaskDef(modifiedTaskDefinition) + + when: "A while workflow is started" + def workflowInput = new HashMap() + workflowInput['loop'] = 1 + def workflowInstanceId = startWorkflow("While_Workflow", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 2 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.SCHEDULED + } + + when: "Polling and failing first task" + Tuple polledAndFailedTask0 = workflowTestUtil.pollAndFailTask('integration_task_0', 'integration.test.worker', "induced..failure") + + then: "Verify that the task was polled and acknowledged and workflow is in failed state" + verifyPolledAndAcknowledgedTask(polledAndFailedTask0) + verifyTaskIteration(polledAndFailedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.FAILED + tasks.size() == 2 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.CANCELED + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + } + + when: "The workflow is retried" + workflowExecutor.retry(workflowInstanceId, false) + + then: "Verify that workflow is running" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 3 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.SCHEDULED + } + + when: "Polling and completing first task" + Tuple polledAndCompletedTask0 = workflowTestUtil.pollAndCompleteTask('integration_task_0', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask0) + verifyTaskIteration(polledAndCompletedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'FORK' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_1' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.SCHEDULED + tasks[6].taskType == 'JOIN' + tasks[6].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing second task" + def joinId = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).getTaskByRefName("join__1").taskId + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'FORK' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_1' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.SCHEDULED + tasks[6].taskType == 'JOIN' + tasks[6].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing third task" + Tuple polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + and: "JOIN task is executed" + asyncSystemTaskExecutor.execute(joinTask, joinId) + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + verifyTaskIteration(polledAndCompletedTask2[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'FORK' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_1' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'JOIN' + tasks[6].status == Task.Status.COMPLETED + } + + cleanup: "Reset the task definition" + metadataService.updateTaskDef(persistedTaskDefinition) + } + + def "Test auto retrying a failed while workflow"() { + setup: "Update the task definition with retryCount to 1 and retryDelaySeconds to 0" + def taskName = 'integration_task_0' + def persistedTaskDefinition = workflowTestUtil.getPersistedTaskDefinition(taskName).get() + def modifiedTaskDefinition = new TaskDef(persistedTaskDefinition.name, persistedTaskDefinition.description, + persistedTaskDefinition.ownerEmail, 1, persistedTaskDefinition.timeoutSeconds, + persistedTaskDefinition.responseTimeoutSeconds) + modifiedTaskDefinition.setRetryDelaySeconds(0) + metadataService.updateTaskDef(modifiedTaskDefinition) + + when: "A while workflow is started" + def workflowInput = new HashMap() + workflowInput['loop'] = 1 + def workflowInstanceId = startWorkflow("While_Workflow", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 2 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.SCHEDULED + } + + when: "Polling and failing first task" + Tuple polledAndFailedTask0 = workflowTestUtil.pollAndFailTask('integration_task_0', 'integration.test.worker', "induced..failure") + + then: "Verify that the task was polled and acknowledged and retried task was generated and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndFailedTask0) + verifyTaskIteration(polledAndFailedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 3 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[1].retried + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.SCHEDULED + tasks[2].retryCount == 1 + tasks[2].retriedTaskId == tasks[1].taskId + } + + when: "Polling and completing first task" + Tuple polledAndCompletedTask0 = workflowTestUtil.pollAndCompleteTask('integration_task_0', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask0) + verifyTaskIteration(polledAndCompletedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'FORK' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_1' + tasks[4].status == Task.Status.SCHEDULED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.SCHEDULED + tasks[6].taskType == 'JOIN' + tasks[6].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing second task" + def joinId = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).getTaskByRefName("join__1").taskId + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'FORK' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_1' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.SCHEDULED + tasks[6].taskType == 'JOIN' + tasks[6].status == Task.Status.IN_PROGRESS + } + + when: "Polling and completing third task" + Tuple polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + and: "JOIN task is executed" + asyncSystemTaskExecutor.execute(joinTask, joinId) + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + verifyTaskIteration(polledAndCompletedTask2[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 7 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'integration_task_0' + tasks[1].status == Task.Status.FAILED + tasks[2].taskType == 'integration_task_0' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'FORK' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_1' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_2' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'JOIN' + tasks[6].status == Task.Status.COMPLETED + } + + cleanup: "Reset the task definition" + metadataService.updateTaskDef(persistedTaskDefinition) + } + + def "Test workflow with a iteration While task as subtask of a forkjoin task"() { + given: "Number of iterations of the loop is set to 1" + def workflowInput = new HashMap() + workflowInput['loop'] = 1 + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("While_SubTask", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has started" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 5 + tasks[0].taskType == 'FORK' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'WHILE' + tasks[1].status == Task.Status.IN_PROGRESS + tasks[2].taskType == 'integration_task_2' + tasks[2].status == Task.Status.SCHEDULED + tasks[3].taskType == 'JOIN' + tasks[3].status == Task.Status.IN_PROGRESS + tasks[4].taskType == 'integration_task_0' + tasks[4].status == Task.Status.SCHEDULED + } + + when: "Polling and completing first task in While" + def joinId = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).getTaskByRefName("join").taskId + Tuple polledAndCompletedTask0 = workflowTestUtil.pollAndCompleteTask('integration_task_0', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask0) + verifyTaskIteration(polledAndCompletedTask0[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'FORK' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'WHILE' + tasks[1].status == Task.Status.IN_PROGRESS + tasks[2].taskType == 'integration_task_2' + tasks[2].status == Task.Status.SCHEDULED + tasks[3].taskType == 'JOIN' + tasks[3].status == Task.Status.IN_PROGRESS + tasks[4].taskType == 'integration_task_0' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_1' + tasks[5].status == Task.Status.SCHEDULED + } + + when: "Polling and completing second task in While" + Tuple polledAndCompletedTask1 = workflowTestUtil.pollAndCompleteTask('integration_task_1', 'integration.test.worker') + + then: "Verify that the task was polled and acknowledged and workflow is in running state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask1) + verifyTaskIteration(polledAndCompletedTask1[0] as Task, 1) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 6 + tasks[0].taskType == 'FORK' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'WHILE' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'integration_task_2' + tasks[2].status == Task.Status.SCHEDULED + tasks[3].taskType == 'JOIN' + tasks[3].status == Task.Status.IN_PROGRESS + tasks[4].taskType == 'integration_task_0' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_1' + tasks[5].status == Task.Status.COMPLETED + } + + when: "Polling and completing third task" + Tuple polledAndCompletedTask2 = workflowTestUtil.pollAndCompleteTask('integration_task_2', 'integration.test.worker') + + and: "the workflow is evaluated" + sweep(workflowInstanceId) + + and: "JOIN task is executed" + asyncSystemTaskExecutor.execute(joinTask, joinId) + + then: "Verify that the task was polled and acknowledged and workflow is in completed state" + verifyPolledAndAcknowledgedTask(polledAndCompletedTask2) + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 6 + tasks[0].taskType == 'FORK' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'WHILE' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'integration_task_2' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'JOIN' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'integration_task_0' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'integration_task_1' + tasks[5].status == Task.Status.COMPLETED + } + } + + def "Test workflow with While task contains loop over task that use iteration in script expression"() { + given: "Number of iterations of the loop is set to 2" + def workflowInput = new HashMap() + workflowInput['loop'] = 2 + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("While_Workflow_Iteration_Fix", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has competed" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 3 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'LAMBDA' + tasks[1].status == Task.Status.COMPLETED + tasks[1].outputData.get("result") == 0 + tasks[2].taskType == 'LAMBDA' + tasks[2].status == Task.Status.COMPLETED + tasks[2].outputData.get("result") == 1 + } + } + + def "Test workflow with While task has no iteration"() { + given: "The loop condition is set to use set variable" + def workflowInput = new HashMap() + workflowInput['value'] = 2 + workflowInput['list'] = null + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("while_Set_variable_fix", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has competed" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 1 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[0].outputData['iteration'] == 0 + } + } + + def "Test workflow with While task contains set variable task"() { + given: "The loop condition is set to use set variable" + def workflowInput = new HashMap() + workflowInput['value'] = 2 + workflowInput['list'] = [1] + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("while_Set_variable_fix", 1, "looptest", workflowInput, null) + + then: "Verify that the workflow has competed" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 2 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[1].taskType == 'SET_VARIABLE' + tasks[1].status == Task.Status.COMPLETED + tasks[1].inputData.get("value") == workflowInput['value'] + variables['value'] == workflowInput['value'] + } + } + + def "Test workflow with While task contains decision task"() { + given: "The loop condition is set to use set variable" + def workflowInput = new HashMap() + def array = new ArrayList() + array.add(1); + array.add(2); + workflowInput['list'] = array + + when: "A while workflow is started" + def workflowInstanceId = startWorkflow("While_with_Decision_task", 1, "looptest", workflowInput, null) + + then: "Verify that the loop over task is waiting for the wait task to get completed" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 4 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[1].taskType == 'INLINE' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'SWITCH' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'WAIT' + tasks[3].status == Task.Status.IN_PROGRESS + } + + when: "The wait task is completed" + def waitTask = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).tasks[3] + waitTask.status = Task.Status.COMPLETED + workflowExecutor.updateTask(new TaskResult(waitTask)) + + then: "Verify that the next iteration is scheduled and workflow is in running state" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.RUNNING + tasks.size() == 8 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.IN_PROGRESS + tasks[0].iteration == 2 + tasks[1].taskType == 'INLINE' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'SWITCH' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'WAIT' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'INLINE' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'INLINE' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'SWITCH' + tasks[6].status == Task.Status.COMPLETED + tasks[7].taskType == 'WAIT' + tasks[7].status == Task.Status.IN_PROGRESS + } + + when: "The wait task is completed" + waitTask = workflowExecutionService.getExecutionStatus(workflowInstanceId, true).tasks[7] + waitTask.status = Task.Status.COMPLETED + workflowExecutor.updateTask(new TaskResult(waitTask)) + + then: "Verify that the workflow is completed" + with(workflowExecutionService.getExecutionStatus(workflowInstanceId, true)) { + status == Workflow.WorkflowStatus.COMPLETED + tasks.size() == 9 + tasks[0].taskType == 'WHILE' + tasks[0].status == Task.Status.COMPLETED + tasks[0].iteration == 2 + tasks[1].taskType == 'INLINE' + tasks[1].status == Task.Status.COMPLETED + tasks[2].taskType == 'SWITCH' + tasks[2].status == Task.Status.COMPLETED + tasks[3].taskType == 'WAIT' + tasks[3].status == Task.Status.COMPLETED + tasks[4].taskType == 'INLINE' + tasks[4].status == Task.Status.COMPLETED + tasks[5].taskType == 'INLINE' + tasks[5].status == Task.Status.COMPLETED + tasks[6].taskType == 'SWITCH' + tasks[6].status == Task.Status.COMPLETED + tasks[7].taskType == 'WAIT' + tasks[7].status == Task.Status.COMPLETED + tasks[8].taskType == 'INLINE' + tasks[8].status == Task.Status.COMPLETED + } + } + + + void verifyTaskIteration(Task task, int iteration) { + assert task.getReferenceTaskName().endsWith(TaskUtils.getLoopOverTaskRefNameSuffix(task.getIteration())) + assert task.iteration == iteration + } +} diff --git a/test-harness/src/test/resources/while_as_subtask_integration_test.json b/test-harness/src/test/resources/while_as_subtask_integration_test.json new file mode 100644 index 000000000..e00798d5c --- /dev/null +++ b/test-harness/src/test/resources/while_as_subtask_integration_test.json @@ -0,0 +1,117 @@ +{ + "name": "While_SubTask", + "description": "While_SubTask", + "version": 1, + "tasks": [ + { + "name": "fork", + "taskReferenceName": "fork", + "inputParameters": {}, + "type": "FORK_JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [ + [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "value": "${workflow.input.loop}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask['iteration'] < $.value) { true; } else { false;} ", + "loopOver": [ + { + "name": "integration_task_0", + "taskReferenceName": "integration_task_0", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "integration_task_1", + "taskReferenceName": "integration_task_1", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + } + ], + [ + { + "name": "integration_task_2", + "taskReferenceName": "integration_task_2", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + ], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "join", + "taskReferenceName": "join", + "inputParameters": {}, + "type": "JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [ + "loopTask", + "integration_task_2" + ], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} diff --git a/test-harness/src/test/resources/while_five_loop_over_integration_test.json b/test-harness/src/test/resources/while_five_loop_over_integration_test.json new file mode 100644 index 000000000..6be0676f4 --- /dev/null +++ b/test-harness/src/test/resources/while_five_loop_over_integration_test.json @@ -0,0 +1,123 @@ +{ + "name": "while_five_loop_over_integration_test", + "description": "while with a mix of 5, simple and system tasks", + "version": 1, + "tasks": [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "value": "${workflow.input.loop}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask['iteration'] < $.value ) { true;} else {false;} ", + "loopOver": [ + { + "name": "LAMBDA_TASK", + "taskReferenceName": "lambda_locs", + "inputParameters": { + "scriptExpression": "return {locationRanId: 'some location id'}" + }, + "type": "LAMBDA", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "jq_add_location", + "taskReferenceName": "jq_add_location", + "inputParameters": { + "locationIdValue": "${lambda_locs.output.result.locationRanId}", + "queryExpression": "{ out: ({ \"locationId\": .locationIdValue }) }" + }, + "type": "JSON_JQ_TRANSFORM", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "integration_task_1", + "taskReferenceName": "integration_task_1", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "jq_create_hydrus_input", + "taskReferenceName": "jq_create_hydrus_input", + "inputParameters": { + "locationIdValue": "${lambda_locs.output.result.locationRanId}", + "queryExpression": "{ out: ({ \"locationId\": .locationIdValue }) }" + }, + "type": "JSON_JQ_TRANSFORM", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "integration_task_2", + "taskReferenceName": "integration_task_2", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + }, + { + "name": "integration_task_3", + "taskReferenceName": "integration_task_3", + "inputParameters": {}, + "type": "SIMPLE" + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} diff --git a/test-harness/src/test/resources/while_integration_test.json b/test-harness/src/test/resources/while_integration_test.json new file mode 100644 index 000000000..c18a06564 --- /dev/null +++ b/test-harness/src/test/resources/while_integration_test.json @@ -0,0 +1,117 @@ +{ + "name": "While_Workflow", + "description": "While_Workflow", + "version": 1, + "tasks": [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "value": "${workflow.input.loop}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask['iteration'] < $.value) { true; } else { false;} ", + "loopOver": [ + { + "name": "integration_task_0", + "taskReferenceName": "integration_task_0", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "fork", + "taskReferenceName": "fork", + "inputParameters": {}, + "type": "FORK_JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [ + [ + { + "name": "integration_task_1", + "taskReferenceName": "integration_task_1", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ], + [ + { + "name": "integration_task_2", + "taskReferenceName": "integration_task_2", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + ], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "join", + "taskReferenceName": "join", + "inputParameters": {}, + "type": "JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [ + "integration_task_1", + "integration_task_2" + ], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} \ No newline at end of file diff --git a/test-harness/src/test/resources/while_iteration_fix_test.json b/test-harness/src/test/resources/while_iteration_fix_test.json new file mode 100644 index 000000000..b3ac8b941 --- /dev/null +++ b/test-harness/src/test/resources/while_iteration_fix_test.json @@ -0,0 +1,43 @@ +{ + "name": "While_Workflow_Iteration_Fix", + "description": "While_Workflow_Iteration_Fix", + "version": 1, + "tasks": [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "value": "${workflow.input.loop}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask['iteration'] < $.value) { true; } else { false;} ", + "loopOver": [ + { + "name": "form_uri", + "taskReferenceName": "form_uri", + "inputParameters": { + "index" : "${loopTask['iteration']}", + "scriptExpression": "return $.index - 1;" + }, + "type": "LAMBDA" + } + ] + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} diff --git a/test-harness/src/test/resources/while_multiple_integration_test.json b/test-harness/src/test/resources/while_multiple_integration_test.json new file mode 100644 index 000000000..3c7746196 --- /dev/null +++ b/test-harness/src/test/resources/while_multiple_integration_test.json @@ -0,0 +1,151 @@ +{ + "name": "While_Multiple", + "description": "While_Multiple", + "version": 1, + "tasks": [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "value": "${workflow.input.loop}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask['iteration'] < $.value ) { true;} else {false;} ", + "loopOver": [ + { + "name": "integration_task_0", + "taskReferenceName": "integration_task_0", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "fork", + "taskReferenceName": "fork", + "inputParameters": {}, + "type": "FORK_JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [ + [ + { + "name": "integration_task_1", + "taskReferenceName": "integration_task_1", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ], + [ + { + "name": "integration_task_2", + "taskReferenceName": "integration_task_2", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + ], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "join", + "taskReferenceName": "join", + "inputParameters": {}, + "type": "JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [ + "integration_task_1", + "integration_task_2" + ], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + }, + { + "name": "loopTask2", + "taskReferenceName": "loopTask2", + "inputParameters": { + "value": "${workflow.input.loop2}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask2['iteration'] < $.value) { true; } else { false; }", + "loopOver": [ + { + "name": "integration_task_3", + "taskReferenceName": "integration_task_3", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} \ No newline at end of file diff --git a/test-harness/src/test/resources/while_set_variable_fix.json b/test-harness/src/test/resources/while_set_variable_fix.json new file mode 100644 index 000000000..b0972bca3 --- /dev/null +++ b/test-harness/src/test/resources/while_set_variable_fix.json @@ -0,0 +1,45 @@ +{ + "name": "while_Set_variable_fix", + "description": "while with set variable task fix", + "version": 1, + "tasks": [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "list": "${workflow.input.list}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ( $.list && $.loopTask['iteration'] < $.list.length ) { true; } else { false; } ", + "loopOver": [ + { + "name": "set_variable", + "taskReferenceName": "set_variable", + "inputParameters": { + "value": "${workflow.input.value}" + }, + "type": "SET_VARIABLE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + } + ] + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} diff --git a/test-harness/src/test/resources/while_sub_workflow_integration_test.json b/test-harness/src/test/resources/while_sub_workflow_integration_test.json new file mode 100644 index 000000000..09b511d06 --- /dev/null +++ b/test-harness/src/test/resources/while_sub_workflow_integration_test.json @@ -0,0 +1,135 @@ +{ + "name": "While_Sub_Workflow", + "description": "While_Sub_Workflow", + "version": 1, + "tasks": [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "value": "${workflow.input.loop}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask['iteration'] < $.value) { true; } else { false;} ", + "loopOver": [ + { + "name": "integration_task_0", + "taskReferenceName": "integration_task_0", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "fork", + "taskReferenceName": "fork", + "inputParameters": {}, + "type": "FORK_JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [ + [ + { + "name": "integration_task_1", + "taskReferenceName": "integration_task_1", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ], + [ + { + "name": "integration_task_2", + "taskReferenceName": "integration_task_2", + "inputParameters": {}, + "type": "SIMPLE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + ], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "join", + "taskReferenceName": "join", + "inputParameters": {}, + "type": "JOIN", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [ + "integration_task_1", + "integration_task_2" + ], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "sub_workflow_task", + "taskReferenceName": "st1", + "inputParameters": {}, + "type": "SUB_WORKFLOW", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "subWorkflowParam": { + "name": "sub_workflow" + }, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} \ No newline at end of file diff --git a/test-harness/src/test/resources/while_system_tasks.json b/test-harness/src/test/resources/while_system_tasks.json new file mode 100644 index 000000000..ed5ede85c --- /dev/null +++ b/test-harness/src/test/resources/while_system_tasks.json @@ -0,0 +1,93 @@ +{ + "name": "while_system_tasks", + "description": "while with a mix of 5, simple and system tasks", + "version": 1, + "tasks": [ + { + "name": "loopTask", + "taskReferenceName": "loopTask", + "inputParameters": { + "value": "${workflow.input.loop}" + }, + "type": "WHILE", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopCondition": "if ($.loopTask['iteration'] < $.value ) { true;} else {false;} ", + "loopOver": [ + { + "name": "LAMBDA_TASK", + "taskReferenceName": "lambda_locs", + "inputParameters": { + "scriptExpression": "return {locationRanId: 'some location id'}" + }, + "type": "LAMBDA", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "jq_add_location", + "taskReferenceName": "jq_add_location", + "inputParameters": { + "locationIdValue": "${lambda_locs.output.result.locationRanId}", + "queryExpression": "{ out: ({ \"locationId\": .locationIdValue }) }" + }, + "type": "JSON_JQ_TRANSFORM", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + }, + { + "name": "jq_create_hydrus_input", + "taskReferenceName": "jq_create_hydrus_input", + "inputParameters": { + "locationIdValue": "${lambda_locs.output.result.locationRanId}", + "queryExpression": "{ out: ({ \"locationId\": .locationIdValue }) }" + }, + "type": "JSON_JQ_TRANSFORM", + "decisionCases": {}, + "defaultCase": [], + "forkTasks": [], + "startDelay": 0, + "joinOn": [], + "optional": false, + "defaultExclusiveJoinTask": [], + "asyncComplete": false, + "loopOver": [] + } + ] + }, + { + "name": "integration_task_1", + "taskReferenceName": "integration_task_1", + "inputParameters": {}, + "type": "SIMPLE" + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": false, + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "ownerEmail": "test@harness.com" +} diff --git a/test-harness/src/test/resources/while_with_decision_task.json b/test-harness/src/test/resources/while_with_decision_task.json new file mode 100644 index 000000000..5ff0c3aa6 --- /dev/null +++ b/test-harness/src/test/resources/while_with_decision_task.json @@ -0,0 +1,62 @@ +{ + "name": "While_with_Decision_task", + "description": "Program for testing loop behaviour", + "version": 1, + "schemaVersion": 2, + "ownerEmail": "xyz@company.eu", + "tasks": [ + { + "name": "LoopTask", + "taskReferenceName": "LoopTask", + "type": "WHILE", + "inputParameters": { + "list": "${workflow.input.list}" + }, + "loopCondition": "$.LoopTask['iteration'] < $.list.length", + "loopOver": [ + { + "name": "GetNumberAtIndex", + "taskReferenceName": "GetNumberAtIndex", + "type": "INLINE", + "inputParameters": { + "evaluatorType": "javascript", + "list": "${workflow.input.list}", + "iterator": "${LoopTask.output.iteration}", + "expression": "function getElement() { return $.list.get($.iterator - 1); } getElement();" + } + }, + { + "name": "SwitchTask", + "taskReferenceName": "SwitchTask", + "type": "SWITCH", + "evaluatorType": "javascript", + "inputParameters": { + "param": "${GetNumberAtIndex.output.result}" + }, + "expression": "$.param > 0", + "decisionCases": { + "true": [ + { + "name": "WaitTask", + "taskReferenceName": "WaitTask", + "type": "WAIT", + "inputParameters": { + } + }, + { + "name": "ComputeNumber", + "taskReferenceName": "ComputeNumber", + "type": "INLINE", + "inputParameters": { + "evaluatorType": "javascript", + "number": "${GetNumberAtIndex.output.result.number}", + "expression": "function compute() { return $.number+10; } compute();" + } + } + ] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/ui/cypress/fixtures/while/whileSwitch.json b/ui/cypress/fixtures/while/whileSwitch.json new file mode 100644 index 000000000..323c1ca72 --- /dev/null +++ b/ui/cypress/fixtures/while/whileSwitch.json @@ -0,0 +1,416 @@ +{ + "ownerApp": "nq_mwi_conductor_ui_server", + "createTime": 1660252744369, + "status": "COMPLETED", + "endTime": 1660252745449, + "workflowId": "9aaf69a6-9c61-4460-93b5-0a657a084ba4", + "tasks": [ + { + "taskType": "INLINE", + "status": "COMPLETED", + "inputData": { + "evaluatorType": "javascript", + "expression": "1", + "value": null + }, + "referenceTaskName": "inline_task_outside", + "retryCount": 0, + "seq": 1, + "pollCount": 0, + "taskDefName": "inline_task_outside", + "scheduledTime": 1660252744439, + "startTime": 1660252744437, + "endTime": 1660252744504, + "updateTime": 1660252744446, + "startDelayInSeconds": 0, + "retried": false, + "executed": true, + "callbackFromWorker": true, + "responseTimeoutSeconds": 0, + "workflowInstanceId": "9aaf69a6-9c61-4460-93b5-0a657a084ba4", + "workflowType": "LoopTestWithSwitch", + "taskId": "07ae873e-5316-4e89-9c1e-a9cab711f1a2", + "callbackAfterSeconds": 0, + "outputData": { + "result": 1 + }, + "workflowTask": { + "name": "inline_task_outside", + "taskReferenceName": "inline_task_outside", + "inputParameters": { + "value": "${workflow.input.value}", + "evaluatorType": "javascript", + "expression": "1" + }, + "type": "INLINE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + }, + "rateLimitPerFrequency": 0, + "rateLimitFrequencyInSeconds": 0, + "workflowPriority": 0, + "iteration": 0, + "subworkflowChanged": false, + "taskDefinition": null, + "queueWaitTime": -2, + "loopOverTask": false + }, + { + "taskType": "WHILE", + "status": "COMPLETED", + "inputData": { + "value": null + }, + "referenceTaskName": "LoopTask", + "retryCount": 0, + "seq": 2, + "pollCount": 0, + "taskDefName": "Loop Task", + "scheduledTime": 1660252744620, + "startTime": 1660252744618, + "endTime": 1660252745337, + "updateTime": 1660252744808, + "startDelayInSeconds": 0, + "retried": false, + "executed": false, + "callbackFromWorker": true, + "responseTimeoutSeconds": 0, + "workflowInstanceId": "9aaf69a6-9c61-4460-93b5-0a657a084ba4", + "workflowType": "LoopTestWithSwitch", + "taskId": "790126b0-81e8-4286-ac65-d1f4c8eca271", + "callbackAfterSeconds": 0, + "outputData": { + "1": { + "inline_task": { + "result": { + "result": "NODE_2" + } + }, + "switch_task": { + "evaluationResult": ["null"] + } + }, + "iteration": 1 + }, + "workflowTask": { + "name": "Loop Task", + "taskReferenceName": "LoopTask", + "inputParameters": { + "value": "${workflow.input.value}" + }, + "type": "WHILE", + "startDelay": 0, + "optional": false, + "asyncComplete": false, + "loopCondition": "false", + "loopOver": [ + { + "name": "inline_task", + "taskReferenceName": "inline_task", + "inputParameters": { + "value": "${workflow.input.value}", + "evaluatorType": "javascript", + "expression": "function e() { if ($.value == 1){return {\"result\": 'NODE_1'}} else { return {\"result\": 'NODE_2'}}} e();" + }, + "type": "INLINE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + }, + { + "name": "switch_task", + "taskReferenceName": "switch_task", + "inputParameters": { + "switchCaseValue": "${inline_task_1.output.result.result}" + }, + "type": "SWITCH", + "decisionCases": { + "NODE_1": [ + { + "name": "Set_NODE_1", + "taskReferenceName": "Set_NODE_1", + "inputParameters": { + "node": "NODE_1" + }, + "type": "SET_VARIABLE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + } + ], + "NODE_2": [ + { + "name": "Set_NODE_2", + "taskReferenceName": "Set_NODE_2", + "inputParameters": { + "node": "NODE_2" + }, + "type": "SET_VARIABLE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + } + ] + }, + "startDelay": 0, + "optional": false, + "asyncComplete": false, + "evaluatorType": "value-param", + "expression": "switchCaseValue" + } + ] + }, + "rateLimitPerFrequency": 0, + "rateLimitFrequencyInSeconds": 1, + "workflowPriority": 0, + "iteration": 1, + "subworkflowChanged": false, + "taskDefinition": null, + "queueWaitTime": -2, + "loopOverTask": true + }, + { + "taskType": "INLINE", + "status": "COMPLETED", + "inputData": { + "evaluatorType": "javascript", + "expression": "function e() { if ($.value == 1){return {\"result\": 'NODE_1'}} else { return {\"result\": 'NODE_2'}}} e();", + "value": null + }, + "referenceTaskName": "inline_task__1", + "retryCount": 0, + "seq": 3, + "pollCount": 0, + "taskDefName": "inline_task", + "scheduledTime": 1660252744696, + "startTime": 1660252744693, + "endTime": 1660252744931, + "updateTime": 1660252744702, + "startDelayInSeconds": 0, + "retried": false, + "executed": true, + "callbackFromWorker": true, + "responseTimeoutSeconds": 0, + "workflowInstanceId": "9aaf69a6-9c61-4460-93b5-0a657a084ba4", + "workflowType": "LoopTestWithSwitch", + "taskId": "27f7fbc4-325b-43c4-872f-37dc64c9dab0", + "callbackAfterSeconds": 0, + "outputData": { + "result": { + "result": "NODE_2" + } + }, + "workflowTask": { + "name": "inline_task", + "taskReferenceName": "inline_task", + "inputParameters": { + "value": "${workflow.input.value}", + "evaluatorType": "javascript", + "expression": "function e() { if ($.value == 1){return {\"result\": 'NODE_1'}} else { return {\"result\": 'NODE_2'}}} e();" + }, + "type": "INLINE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + }, + "rateLimitPerFrequency": 0, + "rateLimitFrequencyInSeconds": 0, + "workflowPriority": 0, + "iteration": 1, + "subworkflowChanged": false, + "taskDefinition": null, + "queueWaitTime": -3, + "loopOverTask": true + }, + { + "taskType": "SWITCH", + "status": "COMPLETED", + "inputData": { + "case": "null" + }, + "referenceTaskName": "switch_task__1", + "retryCount": 0, + "seq": 4, + "pollCount": 0, + "taskDefName": "SWITCH", + "scheduledTime": 1660252745049, + "startTime": 1660252745047, + "endTime": 1660252745163, + "updateTime": 1660252745056, + "startDelayInSeconds": 0, + "retried": false, + "executed": true, + "callbackFromWorker": true, + "responseTimeoutSeconds": 0, + "workflowInstanceId": "9aaf69a6-9c61-4460-93b5-0a657a084ba4", + "workflowType": "LoopTestWithSwitch", + "taskId": "2e2a0836-a2e6-4902-9e41-9bbc2c75e0ed", + "callbackAfterSeconds": 0, + "outputData": { + "evaluationResult": ["null"] + }, + "workflowTask": { + "name": "switch_task", + "taskReferenceName": "switch_task", + "inputParameters": { + "switchCaseValue": "${inline_task_1.output.result.result}" + }, + "type": "SWITCH", + "decisionCases": { + "NODE_1": [ + { + "name": "Set_NODE_1", + "taskReferenceName": "Set_NODE_1", + "inputParameters": { + "node": "NODE_1" + }, + "type": "SET_VARIABLE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + } + ], + "NODE_2": [ + { + "name": "Set_NODE_2", + "taskReferenceName": "Set_NODE_2", + "inputParameters": { + "node": "NODE_2" + }, + "type": "SET_VARIABLE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + } + ] + }, + "startDelay": 0, + "optional": false, + "asyncComplete": false, + "evaluatorType": "value-param", + "expression": "switchCaseValue" + }, + "rateLimitPerFrequency": 0, + "rateLimitFrequencyInSeconds": 0, + "workflowPriority": 0, + "iteration": 1, + "subworkflowChanged": false, + "taskDefinition": null, + "queueWaitTime": -2, + "loopOverTask": true + } + ], + "input": {}, + "output": { + "evaluationResult": ["null"] + }, + "taskToDomain": {}, + "failedReferenceTaskNames": [], + "workflowDefinition": { + "createTime": 1660244498873, + "updateTime": 1660252731854, + "name": "LoopTestWithSwitch", + "description": "Loop Test With Switch WF", + "version": 3, + "tasks": [ + { + "name": "inline_task_outside", + "taskReferenceName": "inline_task_outside", + "inputParameters": { + "value": "${workflow.input.value}", + "evaluatorType": "javascript", + "expression": "1" + }, + "type": "INLINE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + }, + { + "name": "Loop Task", + "taskReferenceName": "LoopTask", + "inputParameters": { + "value": "${workflow.input.value}" + }, + "type": "WHILE", + "startDelay": 0, + "optional": false, + "asyncComplete": false, + "loopCondition": "false", + "loopOver": [ + { + "name": "inline_task", + "taskReferenceName": "inline_task", + "inputParameters": { + "value": "${workflow.input.value}", + "evaluatorType": "javascript", + "expression": "function e() { if ($.value == 1){return {\"result\": 'NODE_1'}} else { return {\"result\": 'NODE_2'}}} e();" + }, + "type": "INLINE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + }, + { + "name": "switch_task", + "taskReferenceName": "switch_task", + "inputParameters": { + "switchCaseValue": "${inline_task_1.output.result.result}" + }, + "type": "SWITCH", + "decisionCases": { + "NODE_1": [ + { + "name": "Set_NODE_1", + "taskReferenceName": "Set_NODE_1", + "inputParameters": { + "node": "NODE_1" + }, + "type": "SET_VARIABLE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + } + ], + "NODE_2": [ + { + "name": "Set_NODE_2", + "taskReferenceName": "Set_NODE_2", + "inputParameters": { + "node": "NODE_2" + }, + "type": "SET_VARIABLE", + "startDelay": 0, + "optional": false, + "asyncComplete": false + } + ] + }, + "startDelay": 0, + "optional": false, + "asyncComplete": false, + "evaluatorType": "value-param", + "expression": "switchCaseValue" + } + ] + } + ], + "inputParameters": [], + "outputParameters": {}, + "schemaVersion": 2, + "restartable": true, + "workflowStatusListenerEnabled": true, + "ownerEmail": "abc@example.com", + "timeoutPolicy": "ALERT_ONLY", + "timeoutSeconds": 0, + "variables": {}, + "inputTemplate": {} + }, + "priority": 0, + "variables": {}, + "lastRetriedTime": 0, + "startTime": 1660252744369, + "workflowName": "LoopTestWithSwitch", + "workflowVersion": 3 +} diff --git a/ui/src/components/diagram/WorkflowDAG.js b/ui/src/components/diagram/WorkflowDAG.js index cabf35fa0..ecc7f77cc 100644 --- a/ui/src/components/diagram/WorkflowDAG.js +++ b/ui/src/components/diagram/WorkflowDAG.js @@ -146,10 +146,10 @@ export default class WorkflowDAG { // // SWITCH is the newer version of DECISION and DECISION is deprecated // - // Skip this if current type is DO_WHILE_END - which means the SWITCH is one of the bundled + // Skip this if current type is DO_WHILE_END or WHILE_END - which means the SWITCH is one of the bundled // loop tasks and the current task is not the result of a decision if ( - taskConfig.type !== "DO_WHILE_END" && + taskConfig.type !== "DO_WHILE_END" && taskConfig.type !== "WHILE_END" && (antecedent.type === "SWITCH" || antecedent.type === "DECISION") ) { edgeParams.caseValue = getCaseValue( @@ -300,6 +300,7 @@ export default class WorkflowDAG { return retval; } + case "WHILE": case "DO_WHILE": { return task.loopOver; } @@ -329,35 +330,35 @@ export default class WorkflowDAG { return [task].concat(taskRefs); } - processDoWhileTask(doWhileTask, antecedents) { + processLoopTask(loopTask, antecedents) { console.assert(Array.isArray(antecedents)); - const hasDoWhileExecuted = !!this.getExecutionStatus( - doWhileTask.taskReferenceName + const hasLoopTaskExecuted = !!this.getExecutionStatus( + loopTask.taskReferenceName ); - this.addVertex(doWhileTask, antecedents); + this.addVertex(loopTask, antecedents); // Bottom bar // aliasForRef indicates when the bottom bar is clicked one we should highlight the ref - let endDoWhileTask = { - type: "DO_WHILE_END", - name: doWhileTask.name, - taskReferenceName: doWhileTask.taskReferenceName + "-END", - aliasForRef: doWhileTask.taskReferenceName, + let endLoopTask = { + type: loopTask.type + "_END", + name: loopTask.name, + taskReferenceName: loopTask.taskReferenceName + "-END", + aliasForRef: loopTask.taskReferenceName, }; - const loopOverRefPrefixes = this.getRefTask(doWhileTask).map( + const loopOverRefPrefixes = this.getRefTask(loopTask).map( (t) => t.taskReferenceName ); - if (hasDoWhileExecuted) { + if (hasLoopTaskExecuted) { // Create cosmetic LOOP edges between top and bottom bars this.graph.setEdge( - doWhileTask.taskReferenceName, - doWhileTask.taskReferenceName + "-END", + loopTask.taskReferenceName, + loopTask.taskReferenceName + "-END", { type: "loop", - executed: hasDoWhileExecuted, + executed: hasLoopTaskExecuted, } ); @@ -383,16 +384,16 @@ export default class WorkflowDAG { })); for (let task of loopTasks) { - this.addVertex(task, [doWhileTask]); + this.addVertex(task, [loopTask]); } - this.addVertex(endDoWhileTask, [...loopTasks]); + this.addVertex(endLoopTask, [...loopTasks]); } else { // Definition view (or not executed) - this.processTaskList(doWhileTask.loopOver, [doWhileTask]); + this.processTaskList(loopTask.loopOver, [loopTask]); - const lastLoopTask = _.last(doWhileTask.loopOver); + const lastLoopTask = _.last(loopTask.loopOver); // Connect the end of each case to the loop end if ( @@ -402,26 +403,26 @@ export default class WorkflowDAG { Object.entries(lastLoopTask.decisionCases).forEach( ([caseValue, tasks]) => { const lastTaskInCase = _.last(tasks); - this.addVertex(endDoWhileTask, [lastTaskInCase]); + this.addVertex(endLoopTask, [lastTaskInCase]); } ); } // Default case - this.addVertex(endDoWhileTask, [lastLoopTask]); + this.addVertex(endLoopTask, [lastLoopTask]); } // Create reverse loop edge this.graph.setEdge( - doWhileTask.taskReferenceName, - doWhileTask.taskReferenceName + "-END", + loopTask.taskReferenceName, + loopTask.taskReferenceName + "-END", { type: "loop", - executed: hasDoWhileExecuted, + executed: hasLoopTaskExecuted, } ); - return [endDoWhileTask]; + return [endLoopTask]; } processForkJoin(forkJoinTask, antecedents) { @@ -503,8 +504,9 @@ export default class WorkflowDAG { return []; } + case "WHILE": case "DO_WHILE": { - return this.processDoWhileTask(task, antecedents); + return this.processLoopTask(task, antecedents); } case "JOIN": { @@ -546,11 +548,11 @@ export default class WorkflowDAG { return this.graph .successors(parent.ref) .map((ref) => this.graph.node(ref)); - } else if (parent.type === "DO_WHILE") { + } else if (parent.type === "DO_WHILE" || parent.type === "WHILE") { return this.graph .successors(parent.ref) .map((ref) => this.graph.node(ref)) - .filter((node) => node.type !== "DO_WHILE_END"); + .filter((node) => node.type !== "DO_WHILE_END" && node.type !== "WHILE_END"); } } } diff --git a/ui/src/components/diagram/WorkflowGraph.jsx b/ui/src/components/diagram/WorkflowGraph.jsx index 7553970ef..68fb570d9 100644 --- a/ui/src/components/diagram/WorkflowGraph.jsx +++ b/ui/src/components/diagram/WorkflowGraph.jsx @@ -87,7 +87,7 @@ class WorkflowGraph extends React.Component { const parentRef = _.first(dagGraph.predecessors(selectedRef)); const parentType = dagGraph.node(parentRef).type; console.assert( - parentType === "FORK_JOIN_DYNAMIC" || parentType === "DO_WHILE" + parentType === "FORK_JOIN_DYNAMIC" || parentType === "DO_WHILE" || parentType === "WHILE" ); resolvedRef = this.graph @@ -257,20 +257,20 @@ class WorkflowGraph extends React.Component { } } - // Collapse Do_while children + // Collapse DO_WHILE or WHILE children const doWhileNodes = dagGraph .nodes() - .filter((nodeId) => dagGraph.node(nodeId).type === "DO_WHILE"); + .filter((nodeId) => dagGraph.node(nodeId).type === "DO_WHILE" || dagGraph.node(nodeId).type === "WHILE"); for (const parentRef of doWhileNodes) { const parentNode = dagGraph.node(parentRef); - // Only collapse executed DO_WHILE loops + // Only collapse executed DO_WHILE or WHILE loops if (_.get(parentNode, "status")) { const childRefs = dagGraph .successors(parentRef) .map((ref) => dagGraph.node(ref)) - .filter((node) => node.type !== "DO_WHILE_END") + .filter((node) => node.type !== "DO_WHILE_END" && node.type !== "WHILE_END") .map((node) => node.ref); if (childRefs.length > 0) { @@ -544,6 +544,12 @@ class WorkflowGraph extends React.Component { retval.label = `${retval.label} [DO_WHILE]`; this.barNodes.push(v.ref); break; + case "WHILE": + case "WHILE_END": + retval = composeBarNode(v, "down"); + retval.label = `${retval.label} [WHILE]`; + this.barNodes.push(v.ref); + break; default: retval.label = `${v.ref}\n(${v.name})`; retval.shape = "rect"; diff --git a/ui/src/components/diagram/WorkflowGraph.test.cy.js b/ui/src/components/diagram/WorkflowGraph.test.cy.js index 30cd4236e..e7794e02a 100644 --- a/ui/src/components/diagram/WorkflowGraph.test.cy.js +++ b/ui/src/components/diagram/WorkflowGraph.test.cy.js @@ -113,4 +113,41 @@ describe("", () => { cy.get(".edgeLabels").should("contain", "LOOP"); }); }); + + it("while containing switch (definition)", () => { + const onClickSpy = cy.spy().as("onClickSpy"); + + cy.fixture("while/whileSwitch").then((data) => { + const dag = new WorkflowDAG(null, data.workflowDefinition); + mount( + + ); + + cy.get(".edgePaths .edgePath.reverse").should("exist"); + cy.get(".edgePaths").find(".edgePath").should("have.length", 11); + cy.get(".edgeLabels").should("contain", "LOOP"); + }); + }); + + // Note: The addition of task 'inline_task_outside' tests prefix-based loop content detection. + // Will succeed only when filtering via 'prefix + "__"'; + it("while containing switch (execution)", () => { + const onClickSpy = cy.spy().as("onClickSpy"); + + cy.fixture("while/whileSwitch").then((data) => { + const dag = new WorkflowDAG(data); + mount( + + ); + + cy.get("#LoopTask_DF_TASK_PLACEHOLDER") + .should("contain", "2 of 2 tasks succeeded") + .click(); + + cy.get("@onClickSpy").should("be.calledWith", { ref: "inline_task__1" }); + cy.get(".edgePaths").find(".edgePath").should("have.length", 6); + cy.get(".edgePaths .edgePath.reverse").should("exist"); + cy.get(".edgeLabels").should("contain", "LOOP"); + }); + }); }); diff --git a/ui/src/utils/constants.js b/ui/src/utils/constants.js index f0ccd99d8..547fb8aa5 100644 --- a/ui/src/utils/constants.js +++ b/ui/src/utils/constants.js @@ -23,6 +23,7 @@ export const TASK_TYPES = [ "ARCHER", "DECISION", "DO_WHILE", + "WHILE", "DYNAMIC", "DYNIMO", "EAAS",