UNPKG

@dudousxd/nestjs-durable

Version:

Durable workflows for NestJS — module, decorators, discovery and boot recovery

198 lines (153 loc) 7.12 kB
--- name: durable-signals-and-timers description: > Pause and resume @dudousxd/nestjs-durable workflows — ctx.waitForSignal(token) resumed by WorkflowService.signal(token, payload), ctx.waitForEvent(name, { match }) resumed by publishEvent(name, payload), @OnEvent / @Workflow({ onEvent }) event-triggered starts, durable ctx.sleep(duration) / ctx.sleepUntil(date), ctx.webhook() for third-party callbacks, and signalWithStart for the durable-entity/accumulator pattern. Covers the timeoutMs determinism caveat. license: MIT metadata: type: core library: "@dudousxd/nestjs-durable" library_version: 0.22.0 framework: nestjs --- # Signals, events & durable timers Workflows pause on durable primitives and resume when an external event arrives — a signal, a named event, a timer, or a webhook callback. The run is `suspended` (consuming nothing) while it waits and survives restarts. ## Setup Inside a workflow body you await the wait primitive; outside (a controller, a webhook) you deliver via `WorkflowService`: ```ts title="approval.workflow.ts" import { Workflow } from '@dudousxd/nestjs-durable'; import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core'; import { NotificationsService } from './notifications.service'; @Workflow({ name: 'approval', version: '1' }) export class ApprovalWorkflow { constructor(private readonly notifications: NotificationsService) {} async run(ctx: WorkflowCtx, order: { id: string }) { await ctx.step(this.notifications.requestApproval, order.id); // Pause until someone signals this token (or 24h passes). const decision = await ctx.waitForSignal<{ approved: boolean }>( `approve:${order.id}`, { timeoutMs: 24 * 60 * 60 * 1000 }, ); return decision.approved ? 'shipped' : 'rejected'; } } ``` ```ts title="notifications.service.ts" import { Step } from '@dudousxd/nestjs-durable'; import { Injectable } from '@nestjs/common'; @Injectable() export class NotificationsService { @Step() async requestApproval(orderId: string) { // email/Slack the approver } } ``` ```ts title="approval.controller.ts" import { WorkflowService } from '@dudousxd/nestjs-durable'; @Controller('approvals') export class ApprovalController { constructor(private readonly workflows: WorkflowService) {} @Post(':orderId') approve(@Param('orderId') orderId: string, @Body() body: { approved: boolean }) { return this.workflows.signal(`approve:${orderId}`, body); // resumes the suspended run } } ``` ## Core patterns ### Signals (point-to-point) vs events (named pub/sub) - `ctx.waitForSignal<T>(token, { timeoutMs? })` waits on an exact `token`; deliver with `workflows.signal(token, payload)`. One token, one waiting run. - `ctx.waitForEvent<T>(name, { match?, timeoutMs? })` waits on a named event with optional `match` filtering; `workflows.publishEvent(name, payload)` fans out to every run whose `match` the payload satisfies (and starts any `@OnEvent` workflows). Returns how many runs it touched. ```ts const order = await ctx.waitForEvent<Order>('order.paid', { match: { orderId: id } }); // elsewhere: await workflows.publishEvent('order.paid', { orderId: id, amount }); ``` ### Event-triggered workflow starts `@Workflow({ onEvent })` or `@OnEvent(...)` start a **fresh run** whenever the event is published — the payload becomes the run's input. `debounce` / `batch` coalesce bursts. ```ts import { Workflow, OnEvent } from '@dudousxd/nestjs-durable'; @OnEvent('user.registered', 'user.invited') @Workflow({ name: 'welcome', version: '1', debounce: '30s' }) export class WelcomeWorkflow { async run(ctx: WorkflowCtx, user: { id: string }) { /* ... */ } } ``` ### Durable timers `ctx.sleep(duration)` and `ctx.sleepUntil(date)` suspend the run durably — a 7-day sleep survives any number of restarts; the timer poller resumes it when due. `duration` accepts `ms`-style strings. ```ts await ctx.step(this.reports.draft, order.id); await ctx.sleep('7 days'); // durable — not setTimeout await ctx.step(this.reports.send, order.id); ``` ### Webhooks (third-party callbacks) `ctx.webhook()` mints a stable token + url **before** suspending, so you can hand the url to a third party — inside a dispatched `ctx.step` — then `await wh.wait()` for the callback (delivered as `signal(token, body)`). Configure `webhookUrl` on the module to populate `wh.url`. ```ts const wh = ctx.webhook<{ status: string }>(); await ctx.step(this.provider.register, { callbackUrl: wh.url }); // url is ready now const result = await wh.wait(); // resumes on the callback ``` ### signalWithStart — the accumulator pattern `signalWithStart` ensures a run exists, then delivers a signal race-free — one long-lived run per key fed by many calls. ```ts await this.workflows.signalWithStart( AggregatorWorkflow, { key }, `aggregator:${key}`, // stable runId { token: `event:${key}`, payload: event }, ); ``` ## Common mistakes ### 1. Using setTimeout / a cron instead of ctx.sleep ```ts // ✗ Wrong — a real timer is lost on restart; the delay is not durable async run(ctx, input) { await new Promise((r) => setTimeout(r, 7 * 86_400_000)); // gone if the process restarts await ctx.step(this.reports.send, input); } // ✓ Correct — a durable timer the engine persists and resumes when due async run(ctx, input) { await ctx.sleep('7 days'); await ctx.step(this.reports.send, input); } ``` `ctx.sleep`/`sleepUntil` record a durable timer checkpoint and suspend; the timer poller resumes the run — a raw `setTimeout` dies with the process. Source: packages/core/src/workflow-ctx.ts (`sleep`, `sleepUntil`, `suspendUntil`). ### 2. Adding `{ timeoutMs }` to a live waitForSignal ```ts // A run started under this (unbounded wait = 1 logical position): await ctx.waitForSignal('approve'); // ✗ Wrong — adding a timeout makes it consume TWO positions, shifting every later step's seq await ctx.waitForSignal('approve', { timeoutMs: 60_000 }); // in-flight runs corrupt // ✓ Correct — treat the timeout change as a new @Workflow version @Workflow({ name: 'approval', version: '2' }) ``` A bounded wait consumes two logical positions (deadline + wait), an unbounded one consumes one, so toggling `timeoutMs` is a positional (versioned) change. Source: packages/core/src/workflow-ctx.ts (determinism note above `consumeBuffered`). ### 3. Signalling a token that doesn't match the waiting run ```ts // Workflow waits on a per-order token: await ctx.waitForSignal(`approve:${order.id}`); // ✗ Wrong — signalling a different token leaves the run suspended forever await this.workflows.signal('approve', body); // ✓ Correct — the signal token must match the waited token exactly await this.workflows.signal(`approve:${order.id}`, body); ``` `signal(token, payload)` resolves only the run waiting on that exact token; a mismatched token is buffered and the run never wakes. Source: packages/nestjs/src/workflow.service.ts (`signal`), packages/core/src/workflow-ctx.ts (`waitForSignal`).