Skip to content

feat(worker): compensation in declareWorkflow — the saga, with the machinery tags exempt by default #413

Description

@btravers

declareWorkflow hands a workflow context.activities and context.errors. It hands it nothing for the walk-back, so every saga writes its own — in btravstack/start's fulfillOrder, roughly half the workflow body is compensation plumbing.

Depends on btravstack/unthrown#268, which is where the primitive belongs: an undo stack unwound LIFO is a Result combinator, not Temporal's business.

What is Temporal's business, and the reason this is a separate issue rather than a call site of that one: which failures compensate.

A declared contract error is a permanent domain answer — compensate, then re-mint it against context.errors. ACTIVITY_ERROR_TAG and ACTIVITY_CANCELLED_TAG are not: an activity that failed unmodelled or was cancelled left state nobody can see, and un-deciding what you cannot see is a second bug. Today fulfillOrder states that in a comment and enforces it by hand, with one

.with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => ErrAsync(error))

arm per step — repeated, easy to omit, and invisible when omitted.

Effect's Workflow.withCompensation gets this wrong by default: it unwinds on any failure. Shipping the correct default is the differentiator here, so it must not be an option a caller has to go looking for.

Shape

A saga reachable from implementation (on context, or exported from @temporal-contract/worker/workflow), wrapping the unthrown primitive with this policy:

  • compensate on a declared contract error;
  • do not compensate on the two machinery tags — propagate untouched, so propagateActivityFailure still re-raises the platform's original failure;
  • an explicit opt-in for the workflow that genuinely wants to compensate on cancellation (a long-running step holding a reservation, say).

fulfillOrder then reads as three steps and two undos, and the machinery-tag arm disappears from every one of them.

Note for the implementation

Whatever this wraps must stay pure control flow — no timers, no Date.now, no randomness — or it breaks replay determinism inside the sandbox. Same constraint as unthrown#268, restated here because this is the package that knows the sandbox exists.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Should land before 1.0 — real DX cost, or a decision blocking other workenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions