| name | durable-determinism |
| description | The one correctness rule for @dudousxd/nestjs-durable workflows — the run(ctx, input) body re-runs top-to-bottom on recovery, so it must be deterministic. Keep Date.now/Math.random/IO out of the body and inside dispatched ctx.step calls; use ctx.now() for a checkpointed timestamp and ctx.sideEffect(fn) for any other non-deterministic capture (an id, a random draw). Covers positional replay, NonDeterminismError, @Workflow version pinning + side-by-side registration for breaking changes, exactly-once vs physical retry idempotency, and self-healing recovery.
|
| license | MIT |
| metadata | {"type":"core","library":"@dudousxd/nestjs-durable","library_version":"0.22.0","framework":"nestjs"} |
Determinism & replay — the one rule
Durability comes from checkpoint + deterministic replay. On recovery the engine re-runs the
workflow body from the top; a step that already has a completed checkpoint returns its saved result
instead of executing again. For that to be safe, the body must be deterministic — all
non-determinism (clock, randomness, network, IO) lives inside a dispatched ctx.step, or one of the
two checkpointed capture helpers.
Setup
There is nothing to install for this rule — it governs how you write run(ctx, input). The two
capture helpers are checkpointed wrappers on ctx:
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
async run(ctx: WorkflowCtx, input: unknown) {
const at = await ctx.now();
const id = await ctx.sideEffect(() => crypto.randomUUID());
}
Core patterns
Side effects live in a dispatched step; the body is pure orchestration
async run(ctx: WorkflowCtx, order: Order) {
const quote = await ctx.step(this.pricing.fetch, order);
if (quote.total > 1000) await ctx.step(this.review.flag, order);
await ctx.step(this.billing.charge, quote);
}
@Injectable()
export class PricingService {
@Step()
async fetch(order: Order) { }
}
The engine guarantees each step runs exactly once logically, even across crashes and deploys,
because re-running the body short-circuits completed checkpoints.
Use ctx.now() / ctx.sideEffect(fn) for non-deterministic values
A raw Date.now() or Math.random() in the body produces a different value on each replay and
shifts later decisions, corrupting the run. ctx.now() captures the clock as a checkpoint on first
run and replays it verbatim; ctx.sideEffect(fn) does the same for anything else — fn runs ONCE
and its result is replayed thereafter (you pick the generator: () => uuidv7(), () => ulid(),
() => Math.random(), a config/env read).
Version-pinned replay for breaking changes
Replay is positional: reordering or inserting steps while runs are in flight would corrupt them.
A run resumes on the version it started on, so during a breaking change register both versions side
by side — in-flight runs drain on the old, new runs start on the new.
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflowV1 { }
@Workflow({ name: 'checkout', version: '2' })
export class CheckoutWorkflowV2 { }
A run whose version is no longer registered fails loudly rather than silently corrupting.
Recovery is automatic and self-healing
DurableModule wires engine.recoverIncomplete() — it runs on boot and periodically (the
TimerPoller), so a run orphaned by a crashed worker is reclaimed within ~leaseMs. A live worker
renews its lease while running, so a long step is not reclaimed out from under it.
Common mistakes
1. Reading the clock or randomness directly in the body
async run(ctx, order) {
if (Math.random() < 0.1) await ctx.step(this.sampler.sample, order);
const ts = Date.now();
}
async run(ctx, order) {
if ((await ctx.sideEffect(() => Math.random())) < 0.1) {
await ctx.step(this.sampler.sample, order);
}
const ts = await ctx.now();
}
The body re-runs on recovery; raw Date.now()/Math.random() are not checkpointed, so they shift
the replay. Source: packages/core/src/workflow-ctx.ts (now, sideEffect).
2. Editing a live workflow body without bumping version
@Workflow({ name: 'checkout', version: '1' })
class CheckoutWorkflow {
async run(ctx, o) {
await ctx.step(this.audit.log, o);
await ctx.step(this.billing.charge, o);
}
}
@Workflow({ name: 'checkout', version: '2' })
class CheckoutWorkflowV2 { }
Replay matches checkpoints by position; inserting/reordering steps under the same version throws a
NonDeterminismError for in-flight runs. Source: website/content/docs/concepts/durability.mdx
("Version-pinned replay"), packages/core/src/workflow-ctx.ts (NonDeterminismError).
3. Assuming a dispatched step physically runs exactly once
@Step('payments.charge-card')
async charge(input: { orderId: string; amountCents: number }) {
return { chargeId: await this.stripe.charge(input) };
}
@Step('payments.charge-card')
async charge(input: { orderId: string; amountCents: number }) {
return { chargeId: await this.stripe.charge(input, { idempotencyKey: input.orderId }) };
}
The engine guarantees logical exactly-once, but a crash after the worker ran and before its
checkpoint was written can physically re-run the step — dedupe on something stable in the input.
Source: website/content/docs/concepts/durability.mdx ("Idempotency").