Sagas and compensation
402 pro_required and the console's Pro routes are not served. Get a licence · Core and Pro · Pro features indexA step can name a compensation: the handler that undoes it. When a later step fails for good, the compensations of the steps that completed run, newest first; then the run is failed. A compensation's input is the step's output, unless the compensation names an input reference of its own (below, the reservation id).
workflow "conf-saga-#{LANG}" do
step "reserve", Reserve, input: {probe: input(:probe)}, compensate: compensation(Release, input: ref("steps.reserve.output.reservation"))
step "charge", Charge
end,wf("saga").step("reserve", reserve, input={"probe": input("probe")},
compensate=compensation(release, input=output("reserve", "reservation")))
.step("charge", charge),wf('saga')
.step('reserve', async (i: any) => ({ reservation: `res-${i.probe}` }), {
input: { probe: input('probe') },
compensate: compensation(async (i: any) => ({ released: i }), { input: output('reserve', 'reservation') }),
})
.step('charge', charge),wf("saga")
.step("reserve", ->(i, _ctx) { { reservation: "res-#{i['probe']}" } }, input: { probe: input("probe") },
compensate: compensation(->(i, _ctx) { { released: i } }, input: output("reserve", "reservation")))
.step("charge", CHARGE),wf("saga").
Step("reserve", func(in map[string]any, ec *EC) (any, error) {
return map[string]any{"reservation": fmt.Sprintf("res-%v", in["probe"])}, nil
}, joobq.WithInput[*App](map[string]any{"probe": joobq.Input("probe")}),
joobq.WithCompensate[*App](joobq.Compensation[*App](func(in map[string]any, ec *EC) (any, error) {
return map[string]any{"released": in["value"]}, nil
}, joobq.Output("reserve", "reservation")))).
Step("charge", charge),wf("saga")
.step_with("reserve", |i: Value, _ctx: &Ctx| Ok(json!({"reservation": format!("res-{}", i["probe"].as_str().unwrap_or(""))})), StepOptions {
input: json!({"probe": input(Some("probe"))}),
compensate: Some(Compensation::new(|i: Value, _ctx: &Ctx| Ok(json!({"released": i})), Some(output("reserve", Some("reservation"))))),
..Default::default()
})
.step("charge", |_i: Value, _ctx: &Ctx| Err(Error::non_retryable("card declined"))),wf("saga")
.step("reserve", (i, ctx) -> map("reservation", "res-" + m(i).get("probe")),
StepOptions.<App>input(map("probe", DSL.input("probe")))
.compensate(DSL.compensation((i, ctx) -> map("released", i), DSL.output("reserve", "reservation"))))
.step("charge", Worker::charge),wf('saga')
->step('reserve', fn ($i, $ctx) => ['reservation' => 'res-' . $i['probe']], input: ['probe' => DSL::input('probe')],
compensate: DSL::compensation(fn ($i, $ctx) => ['released' => $i], DSL::output('reserve', 'reservation')))
->step('charge', fn ($i, $ctx) => throw new NonRetryableException('card declined')),Wf("saga")
.Step("reserve", (i, ctx) => Task.FromResult<object?>(new { reservation = "res-" + i?["probe"]?.GetValue<string>() }), input: new { probe = Dsl.Input("probe") },
compensate: Dsl.Compensation<App>((i, ctx) => Task.FromResult<object?>(new { released = i }), Dsl.Output("reserve", "reservation")))
.Step("charge", (i, ctx) => throw new NonRetryableException("card declined")),Tested against a real server in every language shown.
Here charge raises a non-retryable error, so reserve's compensation (release) runs with reserve's reservation and the run fails. The engine sends a compensation to the workers as a task of its own: kind: "compensation", named <workflow>/<step>/compensate.
Cancelling a run from the console (POST /api/v1/workflows/:id/cancel) also undoes the completed steps that can be undone; terminating it (.../terminate) ends it at once. See Operating runs.
Compensating waits, timers and children
A wait (wait_for), a timer or a child node can name a compensation too, written as on a step; its input defaults to the node's own output (the signal's payload, the timer's output, the child run's output). It runs with the others, newest first, when the run fails after the node completed. A skipped node (only_if) is not compensated.
workflow "conf-waitsaga-#{LANG}" do
timer "hold", for: 300.milliseconds, compensate: compensation(ReleaseHold, input: ref("steps.hold.output"))
step "charge", Charge
end,wf("waitsaga").timer("hold", for_ms=300, compensate=compensation(release_hold, input=output("hold")))
.step("charge", charge),wf('waitsaga').timer('hold', { forMs: 300, compensate: compensation(releaseHold, { input: output('hold') }) })
.step('charge', charge),wf("waitsaga").timer("hold", for_ms: 300, compensate: compensation(RELEASE_HOLD, input: output("hold")))
.step("charge", CHARGE),wf("waitsaga").
TimerWith("hold", 300, joobq.WithCompensate[*App](joobq.Compensation[*App](func(in map[string]any, ec *EC) (any, error) {
return map[string]any{"released_hold": in}, nil
}, joobq.Output("hold")))).
Step("charge", charge),wf("waitsaga")
.timer_with("hold", 300, WaitOptions { compensate: Some(Compensation::new(release_hold, Some(output("hold", None)))), ..Default::default() })
.step("charge", charge),wf("waitsaga")
.timer("hold", 300, WaitOptions.<App>none().compensate(DSL.compensation(Worker::releaseHold, DSL.output("hold"))))
.step("charge", Worker::charge),wf('waitsaga')->timer('hold', 300, compensate: DSL::compensation('release_hold', DSL::output('hold')))
->step('charge', 'charge'),Wf("waitsaga").Timer("hold", 300, compensate: Dsl.Compensation<App>(ReleaseHold, Dsl.Output("hold"))).Step("charge", Charge),Tested against a real server in every language shown.
The timer hold completes, then charge fails for good: hold's compensation runs and the run is failed.