UNPKG

durabull

Version:

A durable workflow engine built on top of BullMQ and Redis

391 lines (275 loc) 12.5 kB
# Durabull Documentation Durabull is a durable workflow runtime for TypeScript applications. It combines generator-based workflows, idempotent activities, and BullMQ-backed persistence to deliver replay-safe orchestrations inspired by Laravel Workflow and Temporal. - **Language:** TypeScript (ES2020 target) - **Queues:** BullMQ / Redis - **Execution model:** `async *execute()` generators that `yield` durable effects - **Persistence:** Redis-backed history/event storage (pluggable via `setStorage`) --- ## Installation ```bash npm install durabull bullmq ioredis # or pnpm add durabull bullmq ioredis ``` Durabull ships as a normal npm package. BullMQ and ioredis are required runtime dependencies. After installing, configure Durabull once at process start (worker processes, API servers, test harnesses): ```typescript import { Durabull } from 'durabull'; const myLogger = console; // Replace with your preferred logger implementation const durabull = new Durabull({ redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', queues: { workflow: 'durabull:workflow', activity: 'durabull:activity', }, serializer: 'json', // 'json' | 'base64' pruneAge: '30 days', webhooks: { route: '/webhooks', auth: { method: 'token', header: 'Authorization', token: process.env.API_TOKEN }, }, logger: { info: (...args) => myLogger.info(...args), error: (...args) => myLogger.error(...args), }, }); durabull.setActive(); ``` - `redisUrl` configures both storage and BullMQ connections. - `queues` names the workflow and activity queues. Use per-service overrides to isolate workloads. - `serializer` controls how workflow state/history is encoded. - `pruneAge` defines default workflow retention. - `webhooks` enables optional HTTP control plane helpers. - `logger` allows you to plug in your own structured logger (info/warn/error/debug). - `testMode` (boolean) disables Redis/BullMQ for fully in-memory testing. --- ## Workflows Workflows orchestrate activities via generator functions. Extend the `Workflow` base class and implement `async *execute(...)`: ```typescript import { Workflow, ActivityStub, WorkflowStub } from 'durabull'; import { ChargeCard } from '../activities/ChargeCard'; import { EmailReceipt } from '../activities/EmailReceipt'; export class CheckoutWorkflow extends Workflow<[string, number], string> { async *execute(orderId: string, amount: number) { const chargeId = yield ActivityStub.make(ChargeCard, orderId, amount); yield ActivityStub.make(EmailReceipt, orderId, chargeId); return chargeId; } } ``` Use `WorkflowStub` to start, resume, signal, or query a workflow: ```typescript const checkout = await WorkflowStub.make(CheckoutWorkflow); await checkout.start('order-123', 4999); while (await checkout.running()) { await new Promise(resolve => setTimeout(resolve, 250)); } const chargeId = await checkout.output<string>(); ``` ### Workflow Handles `WorkflowStub.make()` returns a proxy that combines: - `WorkflowHandle` methods (`start()`, `resume()`, `status()`, `running()`, `output()`, `id()`) - The workflow instance itself (signals, queries, helpers) Load existing workflows by id (provide the class for replay): ```typescript const handle = await WorkflowStub.load('wf_abc123', CheckoutWorkflow); const status = await handle.status(); ``` ### Signals & Queries Annotate methods with `@SignalMethod()` or `@QueryMethod()` to expose runtime hooks: ```typescript import { Workflow, SignalMethod, QueryMethod, WorkflowStub } from 'durabull'; export class ShipmentWorkflow extends Workflow<[string], void> { private delivered = false; @SignalMethod() markDelivered() { this.delivered = true; } @QueryMethod() isDelivered(): boolean { return this.delivered; } async *execute(orderId: string) { yield WorkflowStub.await(() => this.delivered); } } ``` Signals mutate workflow state; queries must be side-effect free. ### Timers & Await - `WorkflowStub.timer(seconds | string)` creates a durable sleep. - `WorkflowStub.await(predicate)` polls deterministically until the predicate is true. - `WorkflowStub.awaitWithTimeout(seconds | string, predicate)` races predicate evaluation against a durable timer and returns `true` if the predicate fired first. ```typescript async *execute() { yield WorkflowStub.timer(300); // 5 minute durable delay const ok = yield WorkflowStub.awaitWithTimeout('10 minutes', () => this.receivedSignal); if (!ok) { yield ActivityStub.make(SendReminderEmail); } } ``` ### Deterministic Time Use `WorkflowStub.now()` to obtain a replay-safe clock. Durabull records the first value produced during live execution and reuses it during replays. It also cooperates with `TestKit.fakeTime()`. ```typescript const startedAt = yield WorkflowStub.now(); yield WorkflowStub.timer(30); const elapsedMs = (yield WorkflowStub.now()).getTime() - startedAt.getTime(); ``` ### Continue-As-New Restart a workflow with fresh history using `WorkflowStub.continueAsNew(...)`: ```typescript async *execute(count = 0, target = 5): AsyncGenerator<unknown, string, unknown> { yield ActivityStub.make(LogProgress, count); if (count >= target) { return 'done'; } return (yield WorkflowStub.continueAsNew(count + 1, target)) as never; } ``` The current workflow is marked as `continued`, and a new workflow id is created with the supplied arguments. ### Child Workflows Spawn workflows from inside workflows via `ChildWorkflowStub.make()` or `WorkflowStub.child()`: ```typescript const child = yield ChildWorkflowStub.make(FulfillmentWorkflow, orderId); const receipt = yield child.output<string>(); ``` Children run under their own workflow ids and history while tracking parent references. ### Side Effects Wrap non-deterministic code in `WorkflowStub.sideEffect(fn)` to record the result once: ```typescript const nonce = yield WorkflowStub.sideEffect(() => crypto.randomUUID()); ``` Avoid throwing from side effects; place error-prone logic in activities. --- ## Activities Activities perform IO and other non-deterministic work. Extend `Activity` and implement `execute()`: ```typescript import { Activity } from 'durabull'; import { NonRetryableError } from 'durabull'; export class FetchInvoice extends Activity<[string], Invoice> { tries = 0; // 0 = retry forever timeout = 15; // seconds queue = 'durabull:io'; // optional queue override backoff() { return [1, 2, 5, 10, 30, 60, 120]; } async execute(invoiceId: string): Promise<Invoice> { const res = await fetchJson(`/invoices/${invoiceId}`); if (res.status === 404) { throw new NonRetryableError('invoice not found'); } return res.body; } } ``` ### Retry Policy - `tries`: maximum attempts (`0` = unlimited, default). - `timeout`: per-attempt timeout in seconds. Durabull enforces this by racing execution and rejecting when exceeded. - `backoff()`: array of seconds for each retry. Durabull clamps to the last entry for additional attempts. ### Heartbeats Long-running activities should periodically call `this.heartbeat()` to prevent timeout expiry: ```typescript export class StreamToS3 extends Activity<[string], void> { timeout = 60; async execute(url: string) { for await (const chunk of download(url)) { await uploadChunk(chunk); this.heartbeat(); } } } ``` The runtime updates Redis heartbeat keys and worker monitors them for liveness. ### Activity Context Helpers During execution, activities have access to: - `this.workflowId()`: stable workflow identifier. - `this._getLastHeartbeat()`: timestamp of the last heartbeat. - `this.connection` / `this.queue`: runtime routing hints (optional overrides). ### Parallel & Async Helpers `ActivityStub.make()` launches activities immediately (test-friendly). Combine them with: - `ActivityStub.all([...])` to await a list in parallel. - `ActivityStub.async(generatorFn)` to run lightweight in-memory subflows. --- ## Storage, History, and Persistence Durabull persists workflow records, event history, signals, and heartbeats through an abstract `Storage` interface. The default implementation (`RedisStorage`) uses Redis data structures. Key persisted fields include: - `WorkflowRecord.status`: `created | pending | running | waiting | completed | failed | continued` - `WorkflowRecord.waiting`: resume metadata for timers/awaits - `WorkflowRecord.clockEvents`: deterministic timestamps for `WorkflowStub.now()` - `History.events`: ordered log of activities, timers, signals, child workflows, exceptions, side effects Custom storage implementations can be provided via `setStorage(customStorage)`. --- ## Workers Durabull provides BullMQ-based worker helpers: ```typescript import { startWorkflowWorker, startActivityWorker } from 'durabull/worker'; import { Durabull } from 'durabull'; // Initialize Durabull first const durabull = new Durabull({ ... }); durabull.setActive(); // Register classes durabull.registerWorkflow('CheckoutWorkflow', CheckoutWorkflow); durabull.registerActivity('FetchInvoice', FetchInvoice); // Start workers const workflowWorker = startWorkflowWorker(durabull); const activityWorker = startActivityWorker(durabull); ``` Both workers: - Resolve workflow/activity constructors via the Durabull instance registry - Acquire Redis locks to ensure single execution per workflow/activity - Persist history, manage wait states, and enqueue resume jobs - Respect configured retry policies and non-retryable errors --- ## Webhooks Durabull ships an optional webhook router for HTTP-based control: ```typescript import express from 'express'; import { createWebhookRouter, TokenAuthStrategy } from 'durabull/webhooks'; import { GreetingWorkflow } from '../workflows/GreetingWorkflow'; const router = createWebhookRouter({ authStrategy: new TokenAuthStrategy('my-secret-token'), }); router.registerWebhookWorkflow('greeting-workflow', GreetingWorkflow); const app = express(); app.use(express.json()); app.post('/webhooks/*', async (req, res) => { const response = await router.handle({ method: req.method, path: req.path, headers: req.headers as Record<string, string>, body: req.body, }); res.status(response.statusCode).send(response.body); }); ``` Endpoints support: - `POST /webhooks/start/<workflow>` - `POST /webhooks/signal/<workflow>/<workflowId>/<signal>` Authentication strategies (`NoneAuthStrategy`, `TokenAuthStrategy`, `SignatureAuthStrategy`) must be explicitly configured. --- ## Determinism & Idempotency Guidelines Workflows must be deterministic: - Use `WorkflowStub.now()` instead of `Date.now()`. - Wrap randomness in `WorkflowStub.sideEffect()`. - Avoid reading mutable global state or performing IO (delegate to activities). - Store needed data in workflow fields and signals for replay. Activities must be idempotent: - Compute idempotency keys using `this.workflowId()` and activity inputs. - Ensure external side effects can tolerate retries. - Throw `NonRetryableError` for failures that should not retry. --- ## Monitoring & Maintenance - `WorkflowRecord.waiting` + timers allow durable sleeps without busy waiting. - Use `RedisStorage.refreshHeartbeat` to monitor long-running activities via Redis keys. - Invoke model pruning by deleting or archiving workflow records older than the configured `pruneAge` (Durabull exposes `closeQueues()` to clean up BullMQ connections when shutting down workers/tests). --- ## Troubleshooting - **Jest hanging:** Ensure timers are cleared (Durabull core clears activity timeouts automatically) or run with `--detectOpenHandles` to locate user-land leaks. - **Workflow not resuming:** Confirm the workflow worker is running and registered, and that Redis connections are reachable. - **Signal ignored:** Verify the workflow class is registered, signal name matches the decorator, and the workflow is currently waiting. - **Activity stuck:** Check heartbeat timestamps and confirm `this.heartbeat()` is being invoked inside long loops. --- ## Additional Resources - [`examples/`](./examples) – Executable end-to-end samples (greeting, parallelism, signals/queries, saga, timers, heartbeats) - `tests/` – Comprehensive Jest coverage mirroring Laravel Workflow fixtures Durabull continues to evolve. Follow the repository for roadmap updates and contribute via pull requests or discussions.