@dudousxd/nestjs-durable
Version:
Durable workflows for NestJS — module, decorators, discovery and boot recovery
159 lines (125 loc) • 6.63 kB
Markdown
---
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.
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`:
```ts
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
async run(ctx: WorkflowCtx, input: unknown) {
const at = await ctx.now(); // checkpointed clock — same value on every replay
const id = await ctx.sideEffect(() => crypto.randomUUID()); // checkpointed capture — author picks the generator
// ...use these instead of the raw globals...
}
```
```ts
// ✓ The body only calls ctx.step and branches on results — real IO is inside a @Step handler.
async run(ctx: WorkflowCtx, order: Order) {
const quote = await ctx.step(this.pricing.fetch, order); // dispatched, checkpointed
if (quote.total > 1000) await ctx.step(this.review.flag, order);
await ctx.step(this.billing.charge, quote);
}
```
```ts
@Injectable()
export class PricingService {
@Step()
async fetch(order: Order) { /* real IO here */ }
}
```
The engine guarantees each step runs **exactly once logically**, even across crashes and deploys,
because re-running the body short-circuits completed checkpoints.
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).
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.
```ts
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflowV1 { /* old body, kept until in-flight runs drain */ }
@Workflow({ name: 'checkout', version: '2' })
export class CheckoutWorkflowV2 { /* new body */ }
```
A run whose version is no longer registered fails loudly rather than silently corrupting.
`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.
```ts
// ✗ Wrong — different value on every replay; downstream branches diverge
async run(ctx, order) {
if (Math.random() < 0.1) await ctx.step(this.sampler.sample, order); // replays differently
const ts = Date.now(); // changes each recovery
}
// ✓ Correct — checkpointed sources are stable across replays
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`).
```ts
// ✗ Wrong — insert a step into v1 while runs are in flight → positions shift, runs corrupt
@Workflow({ name: 'checkout', version: '1' })
class CheckoutWorkflow {
async run(ctx, o) {
await ctx.step(this.audit.log, o); // NEW step inserted before existing ones
await ctx.step(this.billing.charge, o);
}
}
// ✓ Correct — ship the change as a new version, keep v1 registered until its runs drain
@Workflow({ name: 'checkout', version: '2' })
class CheckoutWorkflowV2 { /* new body */ }
```
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`).
```ts
// ✗ Wrong — handler assumes it can never be called twice for the same step
@Step('payments.charge-card')
async charge(input: { orderId: string; amountCents: number }) {
return { chargeId: await this.stripe.charge(input) }; // double-charge if checkpoint write is lost
}
// ✓ Correct — derive a stable idempotency key from the input itself (a @Step handler has no
// separate stepId parameter — it only receives `(input, log)`)
@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").