test(bpmn): compensation, targeted replay and transaction cancellation (#7960)
* test(bpmn): cover compensation, targeted replay and transaction cancellation W15 turns on compensation boundary events, reverse-order replay, targeted compensation, transaction subprocesses and cancel end/boundary events. The measured claim in the design holds: the command applier needs no changes, and none were made. A compensation handler arrives as an ordinary StartWork carrying cause=compensation, and the binder already binds it because the reader emits an ordinary Primary binding for an isForCompensation element. What is new is the test mass that says so, and each case pins a failure that otherwise looks like success: - three registrations replayed in reverse, asserted as an ordered log rather than as "all three ran" - a compensate throw naming an activityRef, where the two unselected handlers are bound work that must stay unrun -- which is also where "a handler is never scheduled from flow" becomes observable - a compensation run torn down mid-replay by a cancel end event, so its claimed but unrun entry is released back to registered and the cancellation's own replay reaches it; leaving it claimed would cancel with nothing to compensate and finish looking healthy - a transaction completing Cancelled with no cancel boundary to route it, which must fault rather than take the ordinary sequence flow - two compensation logs, one per scope, in a subprocess and around it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): pin the duplicate live-work record a cancelled transaction leaves CompensationRunCancelledMidReplay asserted only log counts and Finished, so the two live ledger records/bookmarks it produces for the releaseSeat slot went unasserted. Add an explicit assertion on the scope's ledger, and correct the comment that framed the second handler start as evidence only of the release working, when it is also the symptom of the interpreter defect tracked in #7959. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
457e2a6185
commit
37c98b2a78
|
|
@ -0,0 +1,161 @@
|
|||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.IncidentStrategies;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Cancelled</c>
|
||||
/// carries that outcome to its enclosing scope.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The compensation log, the reverse ordering, and the claim/release all belong to the interpreter. What arrives here
|
||||
/// is an ordinary <c>StartWork</c>, applied like any other, so these are process-level tests rather than tests of a
|
||||
/// compensation-specific code path — there is none.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every process runs under <see cref="FaultStrategy"/>. 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -261,6 +261,217 @@ internal static class BpmnTestProcesses
|
|||
return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Three bookings, each carrying a compensation boundary event, and a compensate end event that replays the lot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same three bookings, compensated by an intermediate throw event naming one of them in its
|
||||
/// <c>activityRef</c>, and a task after the throw that its outbound flow reaches once the replay is done.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The shape is what makes the release observable. <c>chargeCard</c> and <c>reserveSeat</c> both complete and
|
||||
/// register, in that order, so the replay the intermediate throw opens claims both and runs them in reverse:
|
||||
/// <c>releaseSeat</c> first, which blocks, leaving <c>refundCard</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>fraudCheck</c> blocks so a test decides when the cancellation happens, rather than racing the replay.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A transaction subprocess that cancels itself from the inside, with nothing on the enclosing scope to route the
|
||||
/// cancellation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same shape as <see cref="CancelledTransactionSubprocess"/> 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.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A compensation log inside an embedded subprocess, and a second one in the enclosing scope that compensates the
|
||||
/// subprocess itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>An embedded subprocess with one task in it, and one task after it in the enclosing scope.</summary>
|
||||
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<string, string>(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.ActivityRef] = activityRef });
|
||||
|
||||
/// <summary>
|
||||
/// A compensation boundary event: dormant, arming nothing, and reaching its handler by association rather than by
|
||||
/// a sequence flow. <see cref="BpmnProcessBuilder.BoundaryEvent"/> cannot carry the handler association, so this
|
||||
/// one is written out.
|
||||
/// </summary>
|
||||
private static BpmnElement CompensationBoundary(string elementId, string attachedTo, string handler) =>
|
||||
new(elementId,
|
||||
BpmnElementTypes.BoundaryEvent,
|
||||
attachedToRef: attachedTo,
|
||||
eventDefinitions: [Compensation()],
|
||||
compensationHandlerElementId: handler);
|
||||
|
||||
/// <summary>
|
||||
/// A compensation handler: a task that binds work like any other, takes no sequence flows, and is invoked only by
|
||||
/// compensation replay.
|
||||
/// </summary>
|
||||
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<string, string>(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.Code] = code });
|
||||
|
||||
|
|
|
|||
|
|
@ -201,6 +201,43 @@ public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindi
|
|||
Assert.Equal("Escalated", ValueOf<string>(Assert.IsType<Event>(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<IWorkflowRunner>().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<string, string>(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.ActivityRef] = activityRef });
|
||||
|
||||
/// <summary>A compensation boundary event, which reaches its handler by association rather than by a sequence flow.</summary>
|
||||
private static BpmnElement CompensationBoundary(string elementId, string attachedTo, string handler) =>
|
||||
new(elementId,
|
||||
BpmnElementTypes.BoundaryEvent,
|
||||
attachedToRef: attachedTo,
|
||||
eventDefinitions: [Compensation()],
|
||||
compensationHandlerElementId: handler);
|
||||
|
||||
/// <summary>An unbound task marked as a compensation handler, carrying the activity binding the document declares for it.</summary>
|
||||
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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue