diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnCompensationTests.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnCompensationTests.cs new file mode 100644 index 000000000..57969042d --- /dev/null +++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnCompensationTests.cs @@ -0,0 +1,161 @@ +using Elsa.Workflows; +using Elsa.Workflows.IncidentStrategies; +using Xunit.Abstractions; + +namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort; + +/// +/// Compensation, transaction cancellation, and the two things about them that are the host's to get right: a +/// compensation handler binds work but is reached only by replay, and a transaction that completes Cancelled +/// carries that outcome to its enclosing scope. +/// +/// +/// +/// The compensation log, the reverse ordering, and the claim/release all belong to the interpreter. What arrives here +/// is an ordinary StartWork, applied like any other, so these are process-level tests rather than tests of a +/// compensation-specific code path — there is none. +/// +/// +/// Every process runs under . Compensation's failure mode is a quiet one: a replay that +/// claims nothing, a handler that is skipped, an unroutable cancellation treated as an ordinary completion all leave +/// a workflow that finished and reported nothing. Faulting rather than absorbing into an incident is what keeps a +/// teardown this host cannot honour from being buried under work that carried on regardless. +/// +/// +public class BpmnCompensationTests(ITestOutputHelper testOutputHelper) +{ + private readonly BpmnTestHost _host = new(testOutputHelper); + + [Fact(DisplayName = "A compensate end event replays every registered handler, in reverse registration order")] + public async Task CompensatedBookings_ReplaysEveryHandlerInReverseRegistrationOrder() + { + // The whole log, not a set of Contains assertions: "all three handlers ran" is also true of a replay that + // walked the log forwards, and of one that ran them in an order nothing decided. Three registrations are what + // makes the difference between reversed and merely permuted visible. + + // Act + var result = await _host.RunAsync(BpmnTestProcesses.CompensatedBookings(_host.Log), typeof(FaultStrategy)); + + // Assert + Assert.Equal( + [ + "executed:bookFlight", "executed:bookHotel", "executed:bookCar", + "executed:undoCar", "executed:undoHotel", "executed:undoFlight" + ], + _host.Log.Entries); + + Assert.Empty(result.WorkflowState.Incidents); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + } + + [Fact(DisplayName = "A compensate throw event naming an activityRef replays only that activity's handler")] + public async Task TargetedCompensation_ReplaysOnlyTheNamedActivitysHandler() + { + // This is also where "a compensation handler is never scheduled from flow" is observable: undoFlight and + // undoCar are bound as this scope's work exactly like undoHotel is, and the only reason they do not run is + // that nothing selected them. A container that scheduled handlers from the graph would run all three. + + // Act + var result = await _host.RunAsync(BpmnTestProcesses.TargetedCompensation(_host.Log), typeof(FaultStrategy)); + + // Assert: only the named activity's handler ran, and the throw then routed its outbound flow. + Assert.Equal( + [ + "executed:bookFlight", "executed:bookHotel", "executed:bookCar", + "executed:undoHotel", "executed:after" + ], + _host.Log.Entries); + + Assert.Empty(result.WorkflowState.Incidents); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + } + + [Fact(DisplayName = "A compensation run torn down mid-replay releases the log entries it claimed and never ran")] + public async Task CompensationRunCancelledMidReplay_ReleasesTheEntriesItNeverRan() + { + // The quiet failure this pins: an entry claimed by a run that was torn down before it ran stays Claimed, which + // makes it invisible to every later selection. The transaction would then cancel with nothing left to + // compensate, complete, route its cancel boundary, and finish looking entirely healthy -- with refundCard + // never having run and no trace that it was skipped. + + // Arrange: chargeCard and reserveSeat registered in that order, so the replay claims both and runs them in + // reverse. releaseSeat is the head and blocks; refundCard is claimed and has not started. + await _host.RunAsync(BpmnTestProcesses.CompensationRunCancelledMidReplay(_host.Log), typeof(FaultStrategy)); + + Assert.Equal(1, _host.Log.Occurrences("executed:releaseSeat")); + Assert.DoesNotContain("executed:refundCard", _host.Log.Entries); + + // Act: the other branch cancels the transaction, which stops the replay's coordinating token. + await _host.FinishWorkAsync("fraudCheck"); + + // Assert: the released entries are registered again, so the cancellation's own replay claims them -- the head + // handler starting a second time is both the first half of the release being real *and* the symptom of + // #7959: BpmnInterpreter.CancelTransaction drops the live work it is abandoning without ever producing a + // PendingTeardown/CancelWorkSubtree command for it, so the host's original ledger record and bookmark for + // the releaseSeat slot survive untouched alongside the replay's freshly started one. The scope now holds two + // live records for the same (BindingRef, IterationId) slot -- pinned below so this fails the moment #7959 + // lands, at which point it should be changed to assert a single record. + Assert.Equal(2, _host.Log.Occurrences("executed:releaseSeat")); + + var releaseSeatBindingRef = BpmnTestProcesses.BindingRef("releaseSeat"); + var releaseSeatLiveRecords = _host.LiveWorkOf("sub").Count(record => record.BindingRef == releaseSeatBindingRef); + Assert.Equal(2, releaseSeatLiveRecords); + + // And the entry that was claimed but never ran is reached once that head handler finishes: the other half. + var result = await _host.FinishWorkAsync("releaseSeat"); + + Assert.Equal(1, _host.Log.Occurrences("executed:refundCard")); + + // The transaction still completes Cancelled, so the enclosing scope takes the boundary path and not the + // ordinary sequence flow. + Assert.Contains("executed:unwind", _host.Log.Entries); + Assert.DoesNotContain("executed:after", _host.Log.Entries); + + Assert.Empty(result.WorkflowState.Incidents); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + } + + [Fact(DisplayName = "A transaction completing Cancelled with no cancel boundary attached faults, rather than completing quietly")] + public async Task CancelledTransactionWithoutCancelBoundary_Faults() + { + // The conservative direction, and the interpreter takes it: graph validation cannot see into the nested + // definition to know a cancel end event is in there, so an unroutable cancellation is only discoverable while + // running. Treating it as an ordinary completion would send the token down the sequence flow that leads to + // 'after' -- a transaction that cancelled itself, followed by the work it cancelled itself to avoid. + + // Act + var result = await _host.RunAsync(BpmnTestProcesses.CancelledTransactionWithoutCancelBoundary(_host.Log), typeof(FaultStrategy)); + + // Assert + Assert.DoesNotContain("executed:after", _host.Log.Entries); + Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus); + + var incident = Assert.Single(result.WorkflowState.Incidents); + + Assert.Contains("bpmn.transaction.cancelled-unhandled", incident.Exception!.Message); + } + + [Fact(DisplayName = "A subprocess replays its own compensation log, and the enclosing scope replays the subprocess itself")] + public async Task CompensationInsideSubprocess_ReplaysEachScopesOwnLog() + { + // Two logs, one per scope. The body's two handlers run in its own reverse order before it completes, and the + // enclosing scope's single registration -- the subprocess's own successful completion, made compensable by the + // boundary attached to it -- is what its compensate end event replays. A scope reaching into another's log + // would show up here as a handler running in the wrong scope's replay, or twice. + + // Act + var result = await _host.RunAsync(BpmnTestProcesses.CompensationInsideSubprocess(_host.Log), typeof(FaultStrategy)); + + // Assert + Assert.Equal( + [ + "executed:subCharge", "executed:subShip", + "executed:subRecall", "executed:subRefund", + "executed:undoSub" + ], + _host.Log.Entries); + + Assert.Empty(result.WorkflowState.Incidents); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + } +} diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs index c0950c93f..58f7c5d4a 100644 --- a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs +++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs @@ -261,6 +261,217 @@ internal static class BpmnTestProcesses return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log)); } + /// + /// Three bookings, each carrying a compensation boundary event, and a compensate end event that replays the lot. + /// + /// + /// Three rather than two: with two, a handler order that merely happened to be reversed is indistinguishable from + /// one that swapped a pair, and a replay that walked the log forwards would still run every handler. The three + /// handlers bind work like anything else, and nothing in the graph flows into them — the only thing that can run + /// them is the replay. + /// + public static BpmnProcess CompensatedBookings(BpmnTestLog log) + { + var definition = new BpmnProcessBuilder("compensated-bookings") + .StartEvent("start") + .Task("bookFlight", bindingRef: BindingRef("bookFlight")) + .Task("bookHotel", bindingRef: BindingRef("bookHotel")) + .Task("bookCar", bindingRef: BindingRef("bookCar")) + .EndEvent("undoEverything", null, Compensation()) + .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight")) + .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel")) + .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar")) + .Element(CompensationHandler("undoFlight")) + .Element(CompensationHandler("undoHotel")) + .Element(CompensationHandler("undoCar")) + .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoEverything") + .Build(); + + return Scope( + "scope", + definition, + Immediate("bookFlight", log), + Immediate("bookHotel", log), + Immediate("bookCar", log), + Immediate("undoFlight", log), + Immediate("undoHotel", log), + Immediate("undoCar", log)); + } + + /// + /// The same three bookings, compensated by an intermediate throw event naming one of them in its + /// activityRef, and a task after the throw that its outbound flow reaches once the replay is done. + /// + public static BpmnProcess TargetedCompensation(BpmnTestLog log) + { + var definition = new BpmnProcessBuilder("targeted-compensation") + .StartEvent("start") + .Task("bookFlight", bindingRef: BindingRef("bookFlight")) + .Task("bookHotel", bindingRef: BindingRef("bookHotel")) + .Task("bookCar", bindingRef: BindingRef("bookCar")) + .IntermediateThrowEvent("undoHotelOnly", Compensation(activityRef: "bookHotel")) + .Task("after", bindingRef: BindingRef("after")) + .EndEvent("end") + .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight")) + .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel")) + .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar")) + .Element(CompensationHandler("undoFlight")) + .Element(CompensationHandler("undoHotel")) + .Element(CompensationHandler("undoCar")) + .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoHotelOnly", "after", "end") + .Build(); + + return Scope( + "scope", + definition, + Immediate("bookFlight", log), + Immediate("bookHotel", log), + Immediate("bookCar", log), + Immediate("undoFlight", log), + Immediate("undoHotel", log), + Immediate("undoCar", log), + Immediate("after", log)); + } + + /// + /// A transaction subprocess that starts a compensation replay on one branch and cancels itself on the other while + /// that replay is still running, so the replay's claimed-but-unrun log entries are torn down mid-run. + /// + /// + /// + /// The shape is what makes the release observable. chargeCard and reserveSeat both complete and + /// register, in that order, so the replay the intermediate throw opens claims both and runs them in reverse: + /// releaseSeat first, which blocks, leaving refundCard claimed and never started. Cancelling the + /// transaction from the other branch stops the replay's coordinating token, which is the only thing in scope that + /// tears a run down mid-flight. + /// + /// + /// fraudCheck blocks so a test decides when the cancellation happens, rather than racing the replay. + /// + /// + public static BpmnProcess CompensationRunCancelledMidReplay(BpmnTestLog log) + { + var body = new BpmnProcessBuilder("mid-replay-cancel-body") + .Transaction() + .StartEvent("subStart") + .ParallelGateway("subSplit") + .Task("chargeCard", bindingRef: BindingRef("chargeCard")) + .Task("reserveSeat", bindingRef: BindingRef("reserveSeat")) + .IntermediateThrowEvent("rollBack", Compensation()) + .EndEvent("rolledBack") + .Task("fraudCheck", bindingRef: BindingRef("fraudCheck")) + .EndEvent("subCancelled", null, Cancel()) + .Element(CompensationBoundary("cardCompensated", attachedTo: "chargeCard", handler: "refundCard")) + .Element(CompensationBoundary("seatCompensated", attachedTo: "reserveSeat", handler: "releaseSeat")) + .Element(CompensationHandler("refundCard")) + .Element(CompensationHandler("releaseSeat")) + .ConnectSequence("subStart", "subSplit") + .Connect("subSplit", "chargeCard") + .ConnectSequence("chargeCard", "reserveSeat", "rollBack", "rolledBack") + .Connect("subSplit", "fraudCheck") + .ConnectSequence("fraudCheck", "subCancelled") + .Build(); + + var definition = new BpmnProcessBuilder("mid-replay-cancel") + .StartEvent("start") + .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true) + .Task("after", bindingRef: BindingRef("after")) + .EndEvent("end") + .BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel()) + .Task("unwind", bindingRef: BindingRef("unwind")) + .EndEvent("unwound") + .ConnectSequence("start", "sub", "after", "end") + .ConnectSequence("cancelled", "unwind", "unwound") + .Build(); + + var nested = Scope( + "sub", + body, + Immediate("chargeCard", log), + Immediate("reserveSeat", log), + Blocking("fraudCheck", log), + Immediate("refundCard", log), + Blocking("releaseSeat", log)); + + return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log)); + } + + /// + /// A transaction subprocess that cancels itself from the inside, with nothing on the enclosing scope to route the + /// cancellation. + /// + /// + /// The same shape as minus the cancel boundary event. Graph + /// validation cannot see into the nested definition to know a cancel end event is in there, so an unroutable + /// cancellation is an execution-time rule: the enclosing scope faults rather than treating the transaction as an + /// ordinary completion and carrying on down the sequence flow. + /// + public static BpmnProcess CancelledTransactionWithoutCancelBoundary(BpmnTestLog log) + { + var body = new BpmnProcessBuilder("unroutable-cancel-body") + .Transaction() + .StartEvent("subStart") + .Task("subWork", bindingRef: BindingRef("subWork")) + .EndEvent("subCancelled", null, Cancel()) + .ConnectSequence("subStart", "subWork", "subCancelled") + .Build(); + + var definition = new BpmnProcessBuilder("unroutable-cancel") + .StartEvent("start") + .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true) + .Task("after", bindingRef: BindingRef("after")) + .EndEvent("end") + .ConnectSequence("start", "sub", "after", "end") + .Build(); + + var nested = Scope("sub", body, Immediate("subWork", log)); + + return Scope("scope", definition, nested, Immediate("after", log)); + } + + /// + /// A compensation log inside an embedded subprocess, and a second one in the enclosing scope that compensates the + /// subprocess itself. + /// + /// + /// Two logs, one per scope, and neither can see the other: the body replays its own two handlers before it + /// completes, and the enclosing scope registers exactly one entry — the subprocess's own successful completion, + /// which its attached compensation boundary makes compensable — and replays that. + /// + public static BpmnProcess CompensationInsideSubprocess(BpmnTestLog log) + { + var body = new BpmnProcessBuilder("compensating-subprocess-body") + .StartEvent("subStart") + .Task("subCharge", bindingRef: BindingRef("subCharge")) + .Task("subShip", bindingRef: BindingRef("subShip")) + .EndEvent("subUndo", null, Compensation()) + .Element(CompensationBoundary("subChargeCompensated", attachedTo: "subCharge", handler: "subRefund")) + .Element(CompensationBoundary("subShipCompensated", attachedTo: "subShip", handler: "subRecall")) + .Element(CompensationHandler("subRefund")) + .Element(CompensationHandler("subRecall")) + .ConnectSequence("subStart", "subCharge", "subShip", "subUndo") + .Build(); + + var definition = new BpmnProcessBuilder("compensation-inside-subprocess") + .StartEvent("start") + .SubProcess("sub", bindingRef: BindingRef("sub")) + .EndEvent("undoOuter", null, Compensation()) + .Element(CompensationBoundary("subCompensated", attachedTo: "sub", handler: "undoSub")) + .Element(CompensationHandler("undoSub")) + .ConnectSequence("start", "sub", "undoOuter") + .Build(); + + var nested = Scope( + "sub", + body, + Immediate("subCharge", log), + Immediate("subShip", log), + Immediate("subRefund", log), + Immediate("subRecall", log)); + + return Scope("scope", definition, nested, Immediate("undoSub", log)); + } + /// An embedded subprocess with one task in it, and one task after it in the enclosing scope. public static BpmnProcess NestedSubprocess(BpmnTestLog log) { @@ -341,6 +552,30 @@ internal static class BpmnTestProcesses private static BpmnEventDefinition Cancel() => new(BpmnEventDefinitionTypes.Cancel); + private static BpmnEventDefinition Compensation(string? activityRef = null) => + activityRef is null + ? new(BpmnEventDefinitionTypes.Compensation) + : new(BpmnEventDefinitionTypes.Compensation, new Dictionary(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.ActivityRef] = activityRef }); + + /// + /// A compensation boundary event: dormant, arming nothing, and reaching its handler by association rather than by + /// a sequence flow. cannot carry the handler association, so this + /// one is written out. + /// + private static BpmnElement CompensationBoundary(string elementId, string attachedTo, string handler) => + new(elementId, + BpmnElementTypes.BoundaryEvent, + attachedToRef: attachedTo, + eventDefinitions: [Compensation()], + compensationHandlerElementId: handler); + + /// + /// A compensation handler: a task that binds work like any other, takes no sequence flows, and is invoked only by + /// compensation replay. + /// + private static BpmnElement CompensationHandler(string elementId) => + new(elementId, BpmnElementTypes.Task, bindingRef: BindingRef(elementId), isForCompensation: true); + private static BpmnEventDefinition Escalation(string code) => new(BpmnEventDefinitionTypes.Escalation, new Dictionary(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.Code] = code }); diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs index d371c5c7d..c926f3520 100644 --- a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs @@ -201,6 +201,43 @@ public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindi Assert.Equal("Escalated", ValueOf(Assert.IsType(WorkForRef(scope, ListenerRef("escalationHandler"))).EventName)); } + [Fact(DisplayName = "A compensation handler is bound like any other work, and only compensation replay runs it")] + public async Task CompensationHandler_IsBoundAndOnlyReachedByReplay() + { + // An isForCompensation element binds work and takes no sequence flows, and the reader emits an ordinary + // Primary binding for it — so the binder needs no case for it, which is exactly why this is worth pinning. + // A binder that skipped such a binding would still produce a scope that builds and publishes; the failure + // would surface only once a replay asked for work the scope maps to nothing. Running the bound scope is what + // turns that into an assertion. + // + // The throw names one of the two hosts, so the other handler is bound and reachable and still must not run: + // nothing in the graph flows into a compensation handler, and only the replay's own selection reaches it. + var definition = new BpmnProcessBuilder(ProcessId) + .StartEvent("start") + .Element(BoundElement("book", BpmnElementTypes.ServiceTask, new WriteLine("booked"))) + .Element(BoundElement("pay", BpmnElementTypes.ServiceTask, new WriteLine("paid"))) + .IntermediateThrowEvent("undoBookOnly", Compensation(activityRef: "book")) + .EndEvent("end") + .Element(CompensationBoundary("bookCompensated", attachedTo: "book", handler: "undoBook")) + .Element(CompensationBoundary("payCompensated", attachedTo: "pay", handler: "undoPay")) + .Element(CompensationHandler("undoBook", new WriteLine("unbooked"))) + .Element(CompensationHandler("undoPay", new WriteLine("unpaid"))) + .ConnectSequence("start", "book", "pay", "undoBookOnly", "end") + .Build(); + + var scope = Bind(definition, Unbound("book"), Unbound("pay"), Unbound("undoBook"), Unbound("undoPay")); + + // Bound: a handler is an entry in the same map as any other binding ref, under no special slot. + var undoBookActivityId = scope.WorkBindings[Ref("undoBook")]; + var undoPayActivityId = scope.WorkBindings[Ref("undoPay")]; + + var result = await Services.GetRequiredService().RunAsync(scope); + + Assert.Equal(1, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == undoBookActivityId)); + Assert.Equal(0, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == undoPayActivityId)); + Assert.Empty(result.WorkflowState.Incidents); + } + [Fact(DisplayName = "An unbound task the document does not bind is refused, naming the element")] public void UnboundTask_WithNoDeclarationIsRefused() { @@ -271,6 +308,27 @@ public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindi private static BpmnElement Element(string elementId, string elementType) => new(elementId, elementType, bindingRef: Ref(elementId)); + private static BpmnEventDefinition Compensation(string? activityRef = null) => + activityRef is null + ? new(BpmnEventDefinitionTypes.Compensation) + : new(BpmnEventDefinitionTypes.Compensation, new Dictionary(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.ActivityRef] = activityRef }); + + /// A compensation boundary event, which reaches its handler by association rather than by a sequence flow. + private static BpmnElement CompensationBoundary(string elementId, string attachedTo, string handler) => + new(elementId, + BpmnElementTypes.BoundaryEvent, + attachedToRef: attachedTo, + eventDefinitions: [Compensation()], + compensationHandlerElementId: handler); + + /// An unbound task marked as a compensation handler, carrying the activity binding the document declares for it. + private BpmnElement CompensationHandler(string elementId, IActivity activity) => + new(elementId, + BpmnElementTypes.ServiceTask, + bindingRef: Ref(elementId), + isForCompensation: true, + extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(activity))); + private static BpmnWorkBinding.TimerWait Timer(string elementId, string isoDuration) => new(ProcessId, elementId, Ref(elementId), BpmnBindingSlot.Primary, isoDuration);