Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task> 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<Task> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL"
xmlns:flowable="http://flowable.org/cmmn"
targetNamespace="http://flowable.org/cmmn">
<case id="testProcessTaskFaultIfPart" name="Test ProcessTask Fault faultCode ifPart">
<casePlanModel id="casePlanModel" name="Case plan model" autoComplete="true">
<planItem id="planItemProcess" name="Process Task" definitionRef="processTask" />
<planItem id="planItemB" name="B" definitionRef="humanTaskB">
<entryCriterion id="entryB" sentryRef="sentryMatching" />
</planItem>
<planItem id="planItemC" name="C" definitionRef="humanTaskC">
<entryCriterion id="entryC" sentryRef="sentryNonMatching" />
</planItem>

<!-- Matches the thrown BpmnError code -> B activates -->
<sentry id="sentryMatching" flowable:triggerMode="onEvent">
<planItemOnPart id="onPartProcessForB" sourceRef="planItemProcess">
<standardEvent>fault</standardEvent>
</planItemOnPart>
<ifPart>
<condition><![CDATA[${faultCode == 'BPMN_ERROR'}]]></condition>
</ifPart>
</sentry>

<!-- Does NOT match -> stays pending (onPart satisfied, ifPart unsatisfied).
When B activates it triggers a re-evaluation cycle in which this ifPart
is evaluated again, but with no businessError on the lifecycle event. -->
<sentry id="sentryNonMatching" flowable:triggerMode="onEvent">
<planItemOnPart id="onPartProcessForC" sourceRef="planItemProcess">
<standardEvent>fault</standardEvent>
</planItemOnPart>
<ifPart>
<condition><![CDATA[${faultCode == 'SOME_OTHER_ERROR'}]]></condition>
</ifPart>
</sentry>

<processTask id="processTask" name="Process Task" processRef="throwErrorProcess" isBlocking="true" />
<humanTask id="humanTaskB" name="B" />
<humanTask id="humanTaskC" name="C" />
</casePlanModel>
</case>
<process id="throwErrorProcess" externalRef="throwBpmnErrorProcess" />
</definitions>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Criterion> entryCriteria = entryDependentPlanItem.getEntryCriteria();
if (!entryCriteria.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down