diff --git a/modules/flowable-cmmn-engine-configurator/src/test/java/org/flowable/cmmn/test/CrossEngineBusinessErrorTest.java b/modules/flowable-cmmn-engine-configurator/src/test/java/org/flowable/cmmn/test/CrossEngineBusinessErrorTest.java index 279cde51635..84574058672 100644 --- a/modules/flowable-cmmn-engine-configurator/src/test/java/org/flowable/cmmn/test/CrossEngineBusinessErrorTest.java +++ b/modules/flowable-cmmn-engine-configurator/src/test/java/org/flowable/cmmn/test/CrossEngineBusinessErrorTest.java @@ -482,6 +482,64 @@ public void testCaseTaskCmmnFaultAfterWaitState() { } } + /** + * CMMN Case + * ┌────────────────────────────────────────────────────────────┐ + * │ [Process Task]──fault sentry (faultCode=='BPMN_ERROR')──▶[B] │ + * │ │ └─fault sentry (faultCode=='SOME_OTHER_ERROR')──▶[C] │ + * │ ▼ starts │ + * │ BPMN: start → [Wait Task] → [Throw Error] → end │ + * │ (user task) throws BpmnError("BPMN_ERROR")│ + * └────────────────────────────────────────────────────────────┘ + * This time: complete Wait Task → BpmnError → onError callback: + * - matching sentry fires → B activates + * - B's activation triggers a re-evaluation cycle in which the still-pending + * non-matching sentry's ifPart is evaluated again — but the lifecycle event of + * that later operation no longer carries the businessError, so faultCode is + * unresolvable. This must NOT throw "Unknown property used in expression". + */ + @Test + public void testProcessTaskBpmnErrorFaultCodeMultipleSentriesWithIfPart() { + CmmnDeployment cmmnDeployment = cmmnRepositoryService.createDeployment() + .addClasspathResource("org/flowable/cmmn/test/CrossEngineBusinessErrorTest.testProcessTaskBpmnErrorFaultCodeMultipleSentriesWithIfPart.cmmn") + .deploy(); + Deployment bpmnDeployment = processEngineRepositoryService.createDeployment() + .addClasspathResource("org/flowable/cmmn/test/CrossEngineBusinessErrorTest.testProcessTaskBpmnErrorAfterWaitState.bpmn20.xml") + .deploy(); + + try { + CaseInstance caseInstance = cmmnRuntimeService.createCaseInstanceBuilder() + .caseDefinitionKey("testProcessTaskFaultIfPart") + .start(); + + List bpmnTasks = processEngineTaskService.createTaskQuery().list(); + assertThat(bpmnTasks).extracting(Task::getName).containsExactly("Wait Task"); + + // Complete the user task → next service task throws BpmnError("BPMN_ERROR") in a new transaction, + // propagating via the onError callback into the CMMN engine. + processEngineTaskService.complete(bpmnTasks.get(0).getId()); + + // ProcessTask should be FAILED (BpmnError propagated as fault via callback) + assertThat(cmmnRuntimeService.createPlanItemInstanceQuery() + .caseInstanceId(caseInstance.getId()) + .planItemInstanceState(PlanItemInstanceState.FAILED) + .includeEnded() + .list()) + .extracting(PlanItemInstance::getName) + .containsExactly("Process Task"); + + // Only the matching sentry should have fired → B active, C not created/activated + List cmmnTasks = cmmnTaskService.createTaskQuery().caseInstanceId(caseInstance.getId()).list(); + assertThat(cmmnTasks).extracting(Task::getName).containsExactly("B"); + + cmmnTaskService.complete(cmmnTasks.get(0).getId()); + assertCaseInstanceEnded(caseInstance); + + } finally { + deleteDeployments(cmmnDeployment, bpmnDeployment); + } + } + private void deleteDeployments(CmmnDeployment cmmnDeployment, Deployment bpmnDeployment) { // Clean up CMMN first (ends case instances), then BPMN. cmmnRepositoryService.deleteDeployment(cmmnDeployment.getId(), true); diff --git a/modules/flowable-cmmn-engine-configurator/src/test/resources/org/flowable/cmmn/test/CrossEngineBusinessErrorTest.testProcessTaskBpmnErrorFaultCodeMultipleSentriesWithIfPart.cmmn b/modules/flowable-cmmn-engine-configurator/src/test/resources/org/flowable/cmmn/test/CrossEngineBusinessErrorTest.testProcessTaskBpmnErrorFaultCodeMultipleSentriesWithIfPart.cmmn new file mode 100644 index 00000000000..3789e6ef63a --- /dev/null +++ b/modules/flowable-cmmn-engine-configurator/src/test/resources/org/flowable/cmmn/test/CrossEngineBusinessErrorTest.testProcessTaskBpmnErrorFaultCodeMultipleSentriesWithIfPart.cmmn @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + fault + + + + + + + + + + fault + + + + + + + + + + + + + diff --git a/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/agenda/operation/AbstractEvaluationCriteriaOperation.java b/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/agenda/operation/AbstractEvaluationCriteriaOperation.java index f301e1f3098..20ff00d2f68 100644 --- a/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/agenda/operation/AbstractEvaluationCriteriaOperation.java +++ b/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/agenda/operation/AbstractEvaluationCriteriaOperation.java @@ -41,6 +41,7 @@ import org.flowable.cmmn.engine.impl.util.CmmnLoggingSessionUtil; import org.flowable.cmmn.engine.impl.util.CmmnFaultVariableContainer; import org.flowable.cmmn.engine.impl.util.CommandContextUtil; +import org.flowable.cmmn.engine.impl.util.FaultPropagation; import org.flowable.cmmn.engine.impl.util.CompletionEvaluationResult; import org.flowable.cmmn.engine.impl.util.ExpressionUtil; import org.flowable.cmmn.engine.impl.util.PlanItemInstanceContainerUtil; @@ -54,7 +55,9 @@ import org.flowable.cmmn.model.Sentry; import org.flowable.cmmn.model.SentryIfPart; import org.flowable.cmmn.model.SentryOnPart; +import org.flowable.cmmn.model.PlanItemTransition; import org.flowable.cmmn.model.Stage; +import org.flowable.common.engine.api.delegate.BusinessError; import org.flowable.common.engine.api.delegate.Expression; import org.flowable.common.engine.api.variable.VariableContainer; import org.flowable.common.engine.impl.interceptor.CommandContext; @@ -718,12 +721,21 @@ protected SentryPartInstanceEntity createSentryPartInstanceEntity(EntityWithSent protected boolean evaluateSentryIfPart(EntityWithSentryPartInstances entityWithSentryPartInstances, Sentry sentry, VariableContainer variableContainer) { CmmnEngineConfiguration cmmnEngineConfiguration = CommandContextUtil.getCmmnEngineConfiguration(commandContext); - // When the lifecycle event carries fault data, wrap the variable container with CmmnFaultVariableContainer + // When a fault is being propagated, wrap the variable container with CmmnFaultVariableContainer // so that fault-specific variables (faultCode, faultMessage, error) are available for if-part expression // resolution without polluting the actual variable scope. This is analogous to BPMN's BpmnErrorVariableContainer. + // The error is normally carried on the current lifecycle event, but a fault sentry's if-part can be + // re-evaluated in a later agenda operation whose event no longer carries it (e.g. another matching sentry + // activated and triggered a new evaluation cycle while this fault sentry was still pending). For sentries + // with a fault on-part we therefore fall back to the command-context-scoped error so the re-evaluation + // resolves consistently instead of throwing a PropertyNotFoundException. VariableContainer resolveContainer = variableContainer; - if (planItemLifeCycleEvent != null && planItemLifeCycleEvent.getBusinessError() != null) { - resolveContainer = new CmmnFaultVariableContainer(planItemLifeCycleEvent.getBusinessError(), variableContainer); + BusinessError businessError = planItemLifeCycleEvent != null ? planItemLifeCycleEvent.getBusinessError() : null; + if (businessError == null && sentryHasFaultOnPart(sentry)) { + businessError = FaultPropagation.getCurrentBusinessError(commandContext); + } + if (businessError != null) { + resolveContainer = new CmmnFaultVariableContainer(businessError, variableContainer); } try { @@ -748,6 +760,15 @@ protected boolean evaluateSentryIfPart(EntityWithSentryPartInstances entityWithS return false; } + protected boolean sentryHasFaultOnPart(Sentry sentry) { + for (SentryOnPart onPart : sentry.getOnParts()) { + if (PlanItemTransition.FAULT.equals(onPart.getStandardEvent())) { + return true; + } + } + return false; + } + protected Criterion evaluateDependentPlanItemEntryCriteria(PlanItem entryDependentPlanItem) { List entryCriteria = entryDependentPlanItem.getEntryCriteria(); if (!entryCriteria.isEmpty()) { diff --git a/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/FaultPropagation.java b/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/FaultPropagation.java index 61ccc588a4e..68c4fa1d15e 100644 --- a/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/FaultPropagation.java +++ b/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/FaultPropagation.java @@ -45,10 +45,36 @@ */ public class FaultPropagation { + /** + * Transaction-scoped holder for the {@link BusinessError} currently being propagated. + * The error is primarily carried on the {@link org.flowable.cmmn.engine.impl.criteria.PlanItemLifeCycleEvent} + * of the fault operation, but a fault sentry's if-part can be (re-)evaluated in a later agenda operation + * whose lifecycle event no longer carries the error (e.g. when another matching sentry activates and triggers + * a fresh evaluation cycle in which a still-pending fault sentry is re-checked). Keeping the error on the + * command context lets those re-evaluations still resolve {@code faultCode}/{@code faultMessage}/{@code error} + * instead of failing with a PropertyNotFoundException. It is transient — discarded when the command completes. + */ + protected static final String CURRENT_BUSINESS_ERROR_ATTRIBUTE = "cmmnCurrentBusinessError"; + + public static void storeCurrentBusinessError(CommandContext commandContext, BusinessError error) { + if (error != null) { + commandContext.addAttribute(CURRENT_BUSINESS_ERROR_ATTRIBUTE, error); + } + } + + public static BusinessError getCurrentBusinessError(CommandContext commandContext) { + Object value = commandContext.getAttribute(CURRENT_BUSINESS_ERROR_ATTRIBUTE); + return value instanceof BusinessError ? (BusinessError) value : null; + } + public static void propagateFault(BusinessError fault, CommandContext commandContext, PlanItemInstanceEntity planItemInstanceEntity) { // The BusinessError is carried on the PlanItemLifeCycleEvent and resolved via // CmmnFaultVariableContainer during sentry if-part evaluation — similar to BPMN's BpmnErrorVariableContainer. + // Also keep it on the command context so fault sentry if-parts that are re-evaluated in a later + // agenda operation (with a different lifecycle event) can still resolve the fault variables. + storeCurrentBusinessError(commandContext, fault); + // Check if any plan item in the case has a fault sentry on this plan item. // Uses the parser-built dependency graph (entryDependentPlanItems) to avoid // walking the entire plan item instance tree at runtime.