From ba2b722668ca9352680ee777ce0622033e5ce571 Mon Sep 17 00:00:00 2001 From: Chase Barrett Date: Wed, 12 Aug 2026 17:25:25 -0600 Subject: [PATCH 1/4] Record a delete reason when an error event sub-process interrupts. Fixes #4257 --- .../impl/bpmn/helper/ErrorPropagation.java | 7 +- .../ErrorEventSubProcessDeleteReasonTest.java | 85 +++++++++++++++++++ .../deletereason/ThrowBpmnErrorListener.java | 26 ++++++ ...rocessDeleteReasonTest.boundary.bpmn20.xml | 40 +++++++++ ...EventSubProcessDeleteReasonTest.bpmn20.xml | 49 +++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java create mode 100644 modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java create mode 100644 modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.boundary.bpmn20.xml create mode 100644 modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml diff --git a/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/helper/ErrorPropagation.java b/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/helper/ErrorPropagation.java index 18c883ed4d0..ebf0b5e4230 100644 --- a/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/helper/ErrorPropagation.java +++ b/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/helper/ErrorPropagation.java @@ -47,6 +47,7 @@ import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.event.impl.FlowableEventBuilder; +import org.flowable.engine.history.DeleteReason; import org.flowable.common.engine.impl.callback.CallbackData; import org.flowable.common.engine.impl.callback.RuntimeInstanceStateChangeCallback; import org.flowable.engine.impl.cfg.ProcessEngineConfigurationImpl; @@ -298,14 +299,16 @@ protected static void executeEventHandler(Event event, ExecutionEntity parentExe LOGGER.debug( "Ending and deleting child executions for parent execution '{}'. Reason: Parent Execution is processIntanceType. Current Execution '{}'", parentExecution, currentExecution); - executionEntityManager.deleteChildExecutions(parentExecution, null, true); + executionEntityManager.deleteChildExecutions(parentExecution, + DeleteReason.EVENT_SUBPROCESS_INTERRUPTING + "(" + event.getId() + ")", true); } else if (!currentExecution.getParentId().equals(parentExecution.getId())) { LOGGER.debug("Planing destroyScopeOperation for execution {}. Reason: {}. Parent execution: {}", currentExecution, "Current execution parentId odes not match parentExecution id", parentExecution); CommandContextUtil.getAgenda().planDestroyScopeOperation(currentExecution); } else { LOGGER.debug("Deleting execution and related data for execution {}.", currentExecution); - executionEntityManager.deleteExecutionAndRelatedData(currentExecution, null, false); + executionEntityManager.deleteExecutionAndRelatedData(currentExecution, + DeleteReason.EVENT_SUBPROCESS_INTERRUPTING + "(" + event.getId() + ")", false); } ExecutionEntity eventSubProcessExecution = executionEntityManager.createChildExecution(parentExecution); diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java new file mode 100644 index 00000000000..6c25f23808d --- /dev/null +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java @@ -0,0 +1,85 @@ +package org.flowable.engine.test.api.deletereason; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.flowable.engine.history.DeleteReason; +import org.flowable.engine.history.HistoricActivityInstance; +import org.flowable.engine.impl.test.PluggableFlowableTestCase; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.engine.test.Deployment; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.Test; + +/** + * Companion to DeleteReasonTest#testInterruptingBoundaryEvent. + * + * An interrupting error event sub-process terminates the executions in its + * scope with a null delete reason, so an activity it killed is indistinguishable + * in history from one that completed normally. Every other interrupting event + * sub-process start type records DeleteReason.EVENT_SUBPROCESS_INTERRUPTING. + */ +public class ErrorEventSubProcessDeleteReasonTest extends PluggableFlowableTestCase { + + /** + * A forked process: one branch parks on a user task, the other throws a + * BpmnError from a start execution listener. The interrupting error event + * sub-process catches it and terminates the whole scope. + */ + @Test + @Deployment(resources = "org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml") + public void testInterruptingErrorEventSubProcess() { + ProcessInstance processInstance = runtimeService.startProcessInstanceByKey("errorEventSubProcessDeleteReason"); + + waitForHistoryJobExecutorToProcessAllJobs(7000, 100); + + // The parked branch's user task was destroyed by the interruption. + assertThat(taskService.createTaskQuery().processInstanceId(processInstance.getId()).count()).isZero(); + assertThat(runtimeService.createExecutionQuery().processInstanceId(processInstance.getId()).count()).isZero(); + + HistoricActivityInstance parkedBranch = historicActivity(processInstance.getId(), "waitingCall"); + + // It has an end time and a called process instance, exactly as a call + // activity that ran to completion does. By the documented contract of + // getDeleteReason() -- "if completed normally, no delete reason is set" + // -- a null reason here says this activity finished. It did not. + assertThat(parkedBranch.getEndTime()).isNotNull(); + assertThat(parkedBranch.getCalledProcessInstanceId()).isNotNull(); + + // FAILS: actual is null. + assertThat(parkedBranch.getDeleteReason()) + .as("delete reason on an activity terminated by the interrupting error event sub-process") + .isEqualTo(DeleteReason.EVENT_SUBPROCESS_INTERRUPTING + "(errorHandlerStart)"); + } + + /** + * The same shape with an interrupting *boundary* event rather than an event + * sub-process. This one passes: BoundaryEventActivityBehavior records + * DeleteReason.BOUNDARY_EVENT_INTERRUPTING. + */ + @Test + @Deployment(resources = "org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.boundary.bpmn20.xml") + public void testInterruptingBoundaryEventForComparison() { + ProcessInstance processInstance = runtimeService.startProcessInstanceByKey("boundaryDeleteReason"); + + Task task = taskService.createTaskQuery().processInstanceId(processInstance.getId()).singleResult(); + assertThat(task).isNotNull(); + taskService.complete(task.getId()); + + waitForHistoryJobExecutorToProcessAllJobs(7000, 100); + + HistoricActivityInstance subProcess = historicActivity(processInstance.getId(), "theSubProcess"); + assertThat(subProcess.getDeleteReason()).contains(DeleteReason.BOUNDARY_EVENT_INTERRUPTING); + } + + private HistoricActivityInstance historicActivity(String processInstanceId, String activityId) { + List instances = historyService.createHistoricActivityInstanceQuery() + .processInstanceId(processInstanceId) + .activityId(activityId) + .list(); + assertThat(instances).hasSize(1); + return instances.get(0); + } + +} diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java new file mode 100644 index 00000000000..ebc575f5c28 --- /dev/null +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java @@ -0,0 +1,26 @@ +package org.flowable.engine.test.api.deletereason; + +import org.flowable.engine.delegate.BpmnError; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.delegate.ExecutionListener; +import org.flowable.engine.delegate.JavaDelegate; + +/** Raises a BpmnError from a start execution listener. */ +public class ThrowBpmnErrorListener implements ExecutionListener { + + @Override + public void notify(DelegateExecution execution) { + throw new BpmnError("someError", "raised from a start execution listener"); + } + +} + +/** Never reached; the listener throws before the behaviour runs. */ +class NoopDelegate implements JavaDelegate { + + @Override + public void execute(DelegateExecution execution) { + // no-op + } + +} diff --git a/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.boundary.bpmn20.xml b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.boundary.bpmn20.xml new file mode 100644 index 00000000000..59629cd6d1a --- /dev/null +++ b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.boundary.bpmn20.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml new file mode 100644 index 00000000000..66921bf7483 --- /dev/null +++ b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From b7d453d2659736d872fae8f7c743ced98977d8e5 Mon Sep 17 00:00:00 2001 From: Chase Barrett Date: Wed, 12 Aug 2026 17:36:23 -0600 Subject: [PATCH 2/4] Assert the recorded delete reason and add the license headers The test was written to demonstrate the defect, so it read as a report of what was wrong rather than a check of what is now right. It asserts the same value either way; only the framing changes. Both new test classes were also missing the Apache license header. --- .../ErrorEventSubProcessDeleteReasonTest.java | 45 ++++++++++++------- .../deletereason/ThrowBpmnErrorListener.java | 12 +++++ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java index 6c25f23808d..074496c2e38 100644 --- a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.java @@ -1,3 +1,15 @@ +/* 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 org.flowable.engine.test.api.deletereason; import static org.assertj.core.api.Assertions.assertThat; @@ -13,19 +25,20 @@ import org.junit.jupiter.api.Test; /** - * Companion to DeleteReasonTest#testInterruptingBoundaryEvent. + * Companion to {@link DeleteReasonTest#testInterruptingBoundaryEvent()}, for the + * interrupting error event sub-process. * - * An interrupting error event sub-process terminates the executions in its - * scope with a null delete reason, so an activity it killed is indistinguishable - * in history from one that completed normally. Every other interrupting event - * sub-process start type records DeleteReason.EVENT_SUBPROCESS_INTERRUPTING. + * An activity terminated by an interrupting start event is recorded with + * DeleteReason.EVENT_SUBPROCESS_INTERRUPTING, so history can tell it apart from + * one that completed normally. */ public class ErrorEventSubProcessDeleteReasonTest extends PluggableFlowableTestCase { /** - * A forked process: one branch parks on a user task, the other throws a - * BpmnError from a start execution listener. The interrupting error event - * sub-process catches it and terminates the whole scope. + * A forked process: one branch parks on a user task inside a call activity, + * the other throws a BpmnError from a start execution listener. The + * interrupting error event sub-process catches it and terminates the whole + * scope, including the parked branch. */ @Test @Deployment(resources = "org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml") @@ -40,27 +53,25 @@ public void testInterruptingErrorEventSubProcess() { HistoricActivityInstance parkedBranch = historicActivity(processInstance.getId(), "waitingCall"); - // It has an end time and a called process instance, exactly as a call - // activity that ran to completion does. By the documented contract of - // getDeleteReason() -- "if completed normally, no delete reason is set" - // -- a null reason here says this activity finished. It did not. + // An end time and a called process instance are also what a call + // activity that ran to completion records, so the delete reason is the + // only thing separating the two. assertThat(parkedBranch.getEndTime()).isNotNull(); assertThat(parkedBranch.getCalledProcessInstanceId()).isNotNull(); - // FAILS: actual is null. assertThat(parkedBranch.getDeleteReason()) .as("delete reason on an activity terminated by the interrupting error event sub-process") .isEqualTo(DeleteReason.EVENT_SUBPROCESS_INTERRUPTING + "(errorHandlerStart)"); } /** - * The same shape with an interrupting *boundary* event rather than an event - * sub-process. This one passes: BoundaryEventActivityBehavior records - * DeleteReason.BOUNDARY_EVENT_INTERRUPTING. + * The same BpmnError caught by an interrupting boundary event instead, which + * records DeleteReason.BOUNDARY_EVENT_INTERRUPTING. The two paths differ only + * in which vehicle catches the error. */ @Test @Deployment(resources = "org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.boundary.bpmn20.xml") - public void testInterruptingBoundaryEventForComparison() { + public void testInterruptingBoundaryEvent() { ProcessInstance processInstance = runtimeService.startProcessInstanceByKey("boundaryDeleteReason"); Task task = taskService.createTaskQuery().processInstanceId(processInstance.getId()).singleResult(); diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java index ebc575f5c28..009fb2bba25 100644 --- a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java @@ -1,3 +1,15 @@ +/* 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 org.flowable.engine.test.api.deletereason; import org.flowable.engine.delegate.BpmnError; From eae5ec89c9f2155d36de8abc782907e823c5ade3 Mon Sep 17 00:00:00 2001 From: Chase Barrett Date: Fri, 14 Aug 2026 11:42:53 -0600 Subject: [PATCH 3/4] Raise the error from an expression task instead of a placeholder delegate The listener throws before the activity behaviour runs, so the delegate was never reached. Using an expression removes the placeholder class, leaving one top-level class in the test file. --- .../test/api/deletereason/ThrowBpmnErrorListener.java | 10 ---------- .../ErrorEventSubProcessDeleteReasonTest.bpmn20.xml | 3 +-- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java index 009fb2bba25..1280090d3b3 100644 --- a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java @@ -26,13 +26,3 @@ public void notify(DelegateExecution execution) { } } - -/** Never reached; the listener throws before the behaviour runs. */ -class NoopDelegate implements JavaDelegate { - - @Override - public void execute(DelegateExecution execution) { - // no-op - } - -} diff --git a/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml index 66921bf7483..5a377c1b3af 100644 --- a/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml +++ b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/api/deletereason/ErrorEventSubProcessDeleteReasonTest.bpmn20.xml @@ -17,8 +17,7 @@ - + From 176b4510de0955526fe98cc0dbafe81ff72124ba Mon Sep 17 00:00:00 2001 From: Chase Barrett Date: Mon, 17 Aug 2026 08:29:03 -0600 Subject: [PATCH 4/4] Drop the now-unused JavaDelegate import Left behind when the placeholder delegate was removed from this file. --- .../engine/test/api/deletereason/ThrowBpmnErrorListener.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java index 1280090d3b3..a45bb8ff7c8 100644 --- a/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/api/deletereason/ThrowBpmnErrorListener.java @@ -15,7 +15,6 @@ import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.ExecutionListener; -import org.flowable.engine.delegate.JavaDelegate; /** Raises a BpmnError from a start execution listener. */ public class ThrowBpmnErrorListener implements ExecutionListener {