Cancellation & compensation
Cancellation in effect-temporal is Effect interruption, wired to Temporal's cancellation machinery so that both sides always agree.
Cancelling a run
const wf = yield* WorkflowClient;
yield* wf.interrupt(workflowId); // app code: the graceful stop
// yield* OrderFlow.interrupt(executionId); // engine-level (Effect's own API)
// await handle.cancel() // any Temporal client/UIInterrupting a closed or unknown execution is a no-op. For a hard stop that skips the unwind entirely — no finalizers, no compensation — wf.terminate maps to Temporal termination.
What happens on cancel
- In-flight activity and Nexus call scopes are cancelled server-side — the work is told to stop, not abandoned.
- The handler fiber is interrupted: Effect finalizers and
Workflow.withCompensationsteps run during the unwind — and their own activity calls still work. - The run waits (bounded, 30s) for its cancelled calls to settle — a Nexus operation's cancellation handshake only proceeds while the caller is open.
- The run records as Cancelled in Temporal; readers see an interrupted exit.
Compensation
Workflow.withCompensation registers an undo step that runs if the workflow later fails or is cancelled — the saga pattern, in ordinary Effect:
const reservation = yield* callActivity(Reserve, { sku }).pipe(
Workflow.withCompensation((value) =>
callActivity(Release, { reservation: value }).pipe(Effect.asVoid),
),
);On a typed failure or an interrupt downstream, Release runs during the unwind. On success, it never does. Compensation activities started during an unwind are fresh calls — nothing cancels them; they run to completion.
Workflow-internal interruption
Interruption inside the body composes the same way. When Effect.timeout fires or an Effect.race is lost, the interrupted fiber's in-flight call is cancelled server-side while the run itself continues:
const fast = yield* callActivity(Slow, payload).pipe(Effect.timeoutOption("30 seconds"));
// None on timeout — and the server-side activity received a cancel request,
// visible as ActivityTaskCancelRequested in history.This matters for activities with side effects mid-flight and for anything holding resources: a timed-out call is stopped, not left running blind.
The one escape from this: a plain Effect.promise(() => acts.foo()) has no cancellation scope — on interrupt its activity runs to completion server-side, abandoned. The lint rules flag that form.
Interaction with the message primitives
During the unwind:
- pending updates that never get a
respondfail to their callers when the run completes as Cancelled; - mailbox messages still buffered are lost with the run;
- state cells keep serving their last published snapshot after the run closes — publish a terminal status in a finalizer if observers need to see "cancelled".
Children
Awaited child workflows are cancelled with the parent (engine-side best-effort plus REQUEST_CANCEL parent-close policy), and their own compensation runs. Discarded children are unaffected by design.
The reverse also holds: a child or attached execution cancelled from outside surfaces to the awaiting parent as fiber interruption — the parent unwinds (compensation runs) and its own run records as Failed with a CancelledFailure cause rather than Cancelled, since the parent itself was never cancel-requested. Callers of the parent still decode an interrupted exit.