Effects, retry, and reconciliation
Make external side effects recoverable when workflow steps retry or workers crash.
Choose an effect class before writing an action. The class tells the kernel what is safe after a crash.
Use the action context
Every hook receives:
| Field | Use |
|---|---|
runId, stepKey |
Correlation and stable step identity |
invocation |
Inputs, context, and frozen actor |
effectKey |
Idempotency key stable across replays |
tx |
Transaction for a transactional action |
binding() |
Stable IDs pinned during publication |
resolveReference() |
Values produced by inputs and earlier steps |
heartbeat(tx?) |
Keep the lease alive; optionally fence an application transaction |
Transactional work must use ctx.tx. An ambient database connection breaks the
atomic journal guarantee.
Idempotent external work must pass ctx.effectKey to the provider or its own
deduplication store.
A deduplication table can have more than one unique constraint, such as the
key and the run, step, and recipient it stands for. Insert into such a table
with ON CONFLICT DO NOTHING without a conflict target, then read the row by
its key. With a target, two concurrent identical inserts can still fail with a
unique violation on the other constraint. If the read finds no row, another
key holds the other constraint. Treat that, and a row whose stored values
differ from the request, as a conflict.
When an idempotent action stores its result in its own database transaction,
call await ctx.heartbeat(tx) on that transaction before writing. It checks
the execution generation and cancellation state and locks the run until commit,
so a stale worker cannot commit domain data after another worker takes over.
A heartbeat on a separate connection before opening the transaction does not
provide this guarantee. Keep fenced transactions short and do not call an
ambient heartbeat() while holding the run lock. This does not replace the
action's idempotency key or current permission checks.
Run authorize immediately before an effect when access may have changed. See
Resource authorization.
Return an action result
An action returns one state:
succeededwith output;failedwith message, optional code, and optional retryable flag;waitingwith a durable dependency;ambiguouswhen an external effect may already have happened.
Use a stable error code for operator diagnosis. retryable: true retries the
run instead of ending it. On the next claim, the journal restores completed
steps and execution returns to the failed step.
Return waiting only before any effect happens. If an effect might have
happened, return ambiguous.
Reconcile ambiguous effects
The kernel marks an ambiguous effect before calling the provider. A crash can leave it without a recorded answer.
On replay, the kernel calls the action's reconcile hook. It returns
succeeded, failed, or unknown.
An unknown effect moves the run to needs_attention. An operator must resolve
it. The kernel never repeats an effect that may already have happened.
See Workflow observability and testing for the resolution API.
Understand recovery
| Situation | Kernel behavior |
|---|---|
| Action returns a non-retryable failure | Ends the run as failed |
| Action returns a retryable failure | Releases the run, waits with backoff, and resumes from the journal |
PostgreSQL rolls back a transactional action with 40001 or 40P01 |
Retries the step with bounded backoff as WORKFLOW_TRANSACTION_CONFLICT |
| Worker crashes or loses its lease | Another worker reclaims the run after lease expiry and resumes from the journal |
Action returns waiting |
Parks the run until its dependency or deadline wakes it |
| Ambiguous effect cannot be reconciled | Ends the run as needs_attention without repeating the effect |
| Cancellation is requested | Cancels queued and waiting runs immediately; a running worker stops at a heartbeat |
Shared workflow AI tasks also observe the parent run's cancellation. Queued tasks become canceled, running provider calls receive an abort signal, and a late provider result is discarded instead of waking the run with stale output.
Connection failures and unknown database errors are not automatically classified as transaction conflicts: their commit outcome may be unknown. Deliberate application failures retain the action's own retry policy.
Repeated crashes and retryable failures are bounded. After the exported
WORKFLOW_RUN_MAX_CONSECUTIVE_FAILURES limit, the worker records
WORKFLOW_RETRY_EXHAUSTED.
Heartbeat long-running actions before the exported WORKFLOW_RUN_LEASE_MS
expires. Lease loss fences the old worker from writing a result.
import {
WORKFLOW_RUN_LEASE_MS,
WORKFLOW_RUN_MAX_CONSECUTIVE_FAILURES,
} from "@k2b/cloud/workflows/store";Plan and charge effects
Non-pure actions implement plan() for dry-run previews. Declare budget counts
separately in a cheap, side-effect-free cost() hook:
cost: () => ({ emails: 1 }),
plan: async (_ctx, input) => ({
summary: `Email ${input.to}`,
output: { messageId: "planned", planned: true },
})Execution and dry runs use the same cost() hook. Execution never calls
plan(). An omitted cost means no effect-budget charge; it does not skip
authorization, validation, or journaling. Keep those checks in run() or
authorize(). Synthetic preview outputs must say that they are planned.
Publication can set an effectBudget. The kernel charges the root run, so
fan-out cannot multiply an allowed effect count. Each step is charged once: a
step that parks on a dependency and resumes, or is retried, does not pay a
second time for the effect it already performed.
AI actions consume maxAiCalls. A replay of an already-created durable AI task
does not charge that unit again.
Wait for a dependency
return {
state: "waiting",
dependency: {
kind: "inventory.approval",
key: approvalId,
deadline,
},
};When the dependency occurs:
await wakeWorkflowRunsWaitingOn({
appId: "inventory",
kind: "inventory.approval",
key: approvalId,
});
notifyWorkflowWorker("inventory");The signal is durable and safe around the race between parking and waking. Keys must identify one occurrence inside the app.
A deadline does not fail the step. When it passes, the kernel queues the run
again and runs the waiting step under a new execution generation with the same
ctx.effectKey. The action must find the work it already started under that
key and return its current state, for example waiting again. Look up that
work first, before checks that the work itself can change. It must not fail or
start the work a second time. Work that another process carries out
for the step belongs to the step while the step waits for it. Cancel such work
only when the run is canceled or has ended, or when the step finished without
it. A new execution generation alone is not a reason to cancel it.
Fan out with child runs
Use createChildWorkflowRuns() for bounded parallel work. A child is a normal
run with its own lease, journal, and status.
Read aggregate progress with countChildWorkflowRuns(). Do not create a second
targets engine in the application.
Keep fan-out within the plan's loop and effect budgets. Large unbounded fan-out can overload every worker even when each child is valid.