From 41e04838f6debe2c43122c269eebb75c435f49c7 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Wed, 18 Feb 2026 11:10:59 -0700 Subject: [PATCH 1/4] docs(foundations): add scheduling and cron patterns guide --- docs/content/docs/foundations/index.mdx | 3 + docs/content/docs/foundations/meta.json | 3 +- docs/content/docs/foundations/scheduling.mdx | 252 +++++++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/foundations/scheduling.mdx diff --git a/docs/content/docs/foundations/index.mdx b/docs/content/docs/foundations/index.mdx index c11fa163cd..3500f8d122 100644 --- a/docs/content/docs/foundations/index.mdx +++ b/docs/content/docs/foundations/index.mdx @@ -35,4 +35,7 @@ Workflow programming can be a slight shift from how you traditionally write real Prevent duplicate side effects when retrying operations. + + Build recurring jobs and scheduled tasks with durable sleep loops. + diff --git a/docs/content/docs/foundations/meta.json b/docs/content/docs/foundations/meta.json index 69c338d929..be5f04af0a 100644 --- a/docs/content/docs/foundations/meta.json +++ b/docs/content/docs/foundations/meta.json @@ -8,7 +8,8 @@ "hooks", "streaming", "serialization", - "idempotency" + "idempotency", + "scheduling" ], "defaultOpen": true } diff --git a/docs/content/docs/foundations/scheduling.mdx b/docs/content/docs/foundations/scheduling.mdx new file mode 100644 index 0000000000..a20598b1b8 --- /dev/null +++ b/docs/content/docs/foundations/scheduling.mdx @@ -0,0 +1,252 @@ +--- +title: Scheduling & Cron +description: Implement recurring jobs and scheduled tasks using durable workflow patterns. +type: guide +summary: Build scheduled and recurring execution patterns with durable sleep loops. +prerequisites: + - /docs/foundations/workflows-and-steps +related: + - /docs/foundations/common-patterns + - /docs/api-reference/workflow/sleep + - /docs/foundations/hooks +--- + +Workflows naturally support scheduling through [`sleep()`](/docs/api-reference/workflow/sleep). Unlike traditional cron systems that require external infrastructure, workflow-based scheduling is durable - if the server restarts, the schedule resumes without missing a beat. And because `sleep()` suspends the workflow without consuming compute resources, a workflow sleeping for one minute costs the same as one sleeping for a month. + +## Recurring Job Execution + +The simplest scheduling pattern is an infinite loop that runs a job and sleeps between iterations. This replaces traditional cron jobs with a single workflow function. + +```typescript title="workflows/recurring-job.ts" lineNumbers +import { sleep } from "workflow"; +declare function runJob(): Promise<{ success: boolean }>; // @setup + +export async function recurringJobWorkflow() { + "use workflow"; + + while (true) { // [!code highlight] + const result = await runJob(); + + if (!result.success) { + break; + } + + await sleep("1 hour"); // [!code highlight] + } +} +``` + +```typescript title="workflows/steps.ts" lineNumbers +export async function runJob() { + "use step"; + + // Full Node.js access - call APIs, query databases, etc. + const response = await fetch("https://api.example.com/process"); + return { success: response.ok }; +} +``` + +The workflow runs the job, sleeps for one hour, and repeats. If the server restarts during the sleep, the workflow resumes at the correct time. The `break` condition lets you stop the loop based on the job's result. + + +`sleep()` consumes no compute resources while waiting. A workflow sleeping for one hour is effectively free until it wakes up. + + +## Polling External Systems + +A common use case is periodically checking an external system for changes. The workflow maintains state between polls, so it can detect what changed since the last check. + +```typescript title="workflows/poll-changes.ts" lineNumbers +import { sleep } from "workflow"; +declare function fetchCurrentState(): Promise<{ data: string; updatedAt: string }>; // @setup +declare function processChanges(previous: string, current: string): Promise; // @setup + +export async function pollForChangesWorkflow() { + "use workflow"; + + let previousState = ""; + + while (true) { + const current = await fetchCurrentState(); // [!code highlight] + + if (current.data !== previousState) { // [!code highlight] + await processChanges(previousState, current.data); + previousState = current.data; + } + + await sleep("5 minutes"); + } +} +``` + +Because workflow state persists across suspensions, `previousState` survives restarts and replays. This makes durable polling straightforward - no external state store needed. + +## Scheduled Tasks at Specific Times + +Pass a `Date` object to `sleep()` to wait until a specific point in time. This is useful for tasks that must run at exact times rather than fixed intervals. + +```typescript title="workflows/scheduled-task.ts" lineNumbers +import { sleep } from "workflow"; +declare function sendReport(period: string): Promise; // @setup + +export async function endOfMonthReportWorkflow(year: number, month: number) { + "use workflow"; + + // Wait until midnight on the first of next month + const reportDate = new Date(year, month, 1, 0, 0, 0); // [!code highlight] + await sleep(reportDate); // [!code highlight] + + await sendReport(`${year}-${String(month).padStart(2, "0")}`); +} +``` + + +`Date` constructors are deterministic inside workflow functions - the framework ensures consistent values across replays. + + +## Health Checks and Keep-Alive + +Periodic health monitoring is a natural fit for durable workflows. The workflow checks a service, takes action if something is wrong, and sleeps before checking again. + +```typescript title="workflows/health-check.ts" lineNumbers +import { sleep } from "workflow"; +declare function checkServiceHealth(serviceUrl: string): Promise<{ healthy: boolean; latencyMs: number }>; // @setup +declare function sendAlert(serviceUrl: string, latencyMs: number): Promise; // @setup +declare function restartService(serviceUrl: string): Promise; // @setup + +export async function healthCheckWorkflow(serviceUrl: string) { + "use workflow"; + + let consecutiveFailures = 0; + + while (true) { + const status = await checkServiceHealth(serviceUrl); + + if (!status.healthy) { + consecutiveFailures++; + + await sendAlert(serviceUrl, status.latencyMs); + + if (consecutiveFailures >= 3) { // [!code highlight] + await restartService(serviceUrl); // [!code highlight] + consecutiveFailures = 0; + } + } else { + consecutiveFailures = 0; + } + + await sleep("30 seconds"); + } +} +``` + +The workflow tracks `consecutiveFailures` across iterations. After three consecutive failures, it escalates to a service restart. All of this state survives server restarts because the workflow is durable. + +## Cron-Like Dispatching + +For more complex scheduling, a long-running "dispatcher" workflow can start independent child workflows on a schedule. Each scheduled task runs as its own workflow with its own event log. + +```typescript title="workflows/cron-dispatcher.ts" lineNumbers +import { sleep } from "workflow"; +declare function triggerDailyReport(): Promise; // @setup +declare function triggerCleanup(): Promise; // @setup + +export async function cronDispatcherWorkflow() { + "use workflow"; + + let iteration = 0; + + while (true) { + // Run daily report every iteration + await triggerDailyReport(); + + // Run cleanup every 7 iterations (weekly) + if (iteration % 7 === 0) { // [!code highlight] + await triggerCleanup(); // [!code highlight] + } + + iteration++; + await sleep("1 day"); + } +} +``` + +```typescript title="workflows/steps.ts" lineNumbers +import { start } from "workflow/api"; + +export async function triggerDailyReport() { + "use step"; + + const run = await start(dailyReportWorkflow, []); // [!code highlight] + return run.runId; +} + +export async function triggerCleanup() { + "use step"; + + const run = await start(cleanupWorkflow, []); + return run.runId; +} + +// These are independent workflows started by the dispatcher +export async function dailyReportWorkflow() { + "use workflow"; + // Generate and send daily report +} + +export async function cleanupWorkflow() { + "use workflow"; + // Clean up old data +} +``` + +Each child workflow runs independently. If a daily report fails, it does not affect the dispatcher or the cleanup workflow. You can monitor each run separately using its `runId`. + +## Graceful Shutdown + +To stop a recurring workflow from the outside, use a [hook](/docs/foundations/hooks). Race the sleep against the hook in each iteration - when external code sends data to the hook, the loop breaks. + +```typescript title="workflows/stoppable-job.ts" lineNumbers +import { sleep, createHook } from "workflow"; +declare function runJob(): Promise; // @setup + +export async function stoppableJobWorkflow() { + "use workflow"; + + const stopHook = createHook<{ reason: string }>({ // [!code highlight] + token: "stop-recurring-job", // [!code highlight] + }); // [!code highlight] + + let stopped = false; + + while (!stopped) { + await runJob(); + + // Race: either sleep completes or someone sends a stop signal + await Promise.race([ // [!code highlight] + sleep("1 hour"), // [!code highlight] + stopHook.then(() => { stopped = true; }), // [!code highlight] + ]); // [!code highlight] + } +} +``` + +To stop the workflow, call `resumeHook()` with the custom token from any external context: + +```typescript title="app/api/stop-job/route.ts" lineNumbers +import { resumeHook } from "workflow/api"; + +export async function POST() { + await resumeHook("stop-recurring-job", { reason: "Manual shutdown" }); + return Response.json({ stopped: true }); +} +``` + +The custom token `"stop-recurring-job"` lets external code address the hook without needing to know the workflow's run ID. See [Custom Tokens for Deterministic Hooks](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) for more on this pattern. + +## Related Documentation + +- [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full documentation on duration formats and date-based sleep +- [Common Patterns](/docs/foundations/common-patterns) - Sequential, parallel, timeout, and composition patterns +- [Hooks & Webhooks](/docs/foundations/hooks) - Pause workflows and resume them with external data +- [Starting Workflows](/docs/foundations/starting-workflows) - Trigger workflows with `start()` and track execution From 40da1fd7a40d17ff1d25cdd5e076bafa50023d4f Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Wed, 18 Feb 2026 12:24:33 -0700 Subject: [PATCH 2/4] docs(foundations): fix scheduling date math and weekly dispatch - Clarify Date constructor month indexing (monthNumber 1-12, constructor uses 0-based) with inline comments - Fix weekly dispatch condition to not fire on iteration 0 --- docs/content/docs/foundations/scheduling.mdx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/foundations/scheduling.mdx b/docs/content/docs/foundations/scheduling.mdx index a20598b1b8..a86b6261a3 100644 --- a/docs/content/docs/foundations/scheduling.mdx +++ b/docs/content/docs/foundations/scheduling.mdx @@ -89,14 +89,15 @@ Pass a `Date` object to `sleep()` to wait until a specific point in time. This i import { sleep } from "workflow"; declare function sendReport(period: string): Promise; // @setup -export async function endOfMonthReportWorkflow(year: number, month: number) { +export async function endOfMonthReportWorkflow(year: number, monthNumber: number) { "use workflow"; - // Wait until midnight on the first of next month - const reportDate = new Date(year, month, 1, 0, 0, 0); // [!code highlight] + // monthNumber is 1-12; Date constructor uses 0-based months + // new Date(2025, 6, 1) = July 1st 2025 (month index 6 = July) + const reportDate = new Date(year, monthNumber, 1, 0, 0, 0); // [!code highlight] await sleep(reportDate); // [!code highlight] - await sendReport(`${year}-${String(month).padStart(2, "0")}`); + await sendReport(`${year}-${String(monthNumber).padStart(2, "0")}`); } ``` @@ -160,12 +161,12 @@ export async function cronDispatcherWorkflow() { // Run daily report every iteration await triggerDailyReport(); - // Run cleanup every 7 iterations (weekly) + // Run cleanup every 7 iterations (weekly), skipping the first run + iteration++; if (iteration % 7 === 0) { // [!code highlight] await triggerCleanup(); // [!code highlight] } - iteration++; await sleep("1 day"); } } From 80bf5acafaa2647e590f3f85874cec1920c25ed8 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Wed, 18 Feb 2026 13:40:38 -0700 Subject: [PATCH 3/4] docs(foundations): add FatalError escape hatch and Run.cancel() to scheduling --- docs/content/docs/foundations/scheduling.mdx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/foundations/scheduling.mdx b/docs/content/docs/foundations/scheduling.mdx index a86b6261a3..0dcf11048b 100644 --- a/docs/content/docs/foundations/scheduling.mdx +++ b/docs/content/docs/foundations/scheduling.mdx @@ -110,7 +110,7 @@ export async function endOfMonthReportWorkflow(year: number, monthNumber: number Periodic health monitoring is a natural fit for durable workflows. The workflow checks a service, takes action if something is wrong, and sleeps before checking again. ```typescript title="workflows/health-check.ts" lineNumbers -import { sleep } from "workflow"; +import { sleep, FatalError } from "workflow"; declare function checkServiceHealth(serviceUrl: string): Promise<{ healthy: boolean; latencyMs: number }>; // @setup declare function sendAlert(serviceUrl: string, latencyMs: number): Promise; // @setup declare function restartService(serviceUrl: string): Promise; // @setup @@ -128,9 +128,12 @@ export async function healthCheckWorkflow(serviceUrl: string) { await sendAlert(serviceUrl, status.latencyMs); + if (consecutiveFailures >= 10) { // [!code highlight] + throw new FatalError(`${serviceUrl} unrecoverable after 10 consecutive failures`); // [!code highlight] + } + if (consecutiveFailures >= 3) { // [!code highlight] await restartService(serviceUrl); // [!code highlight] - consecutiveFailures = 0; } } else { consecutiveFailures = 0; @@ -141,7 +144,7 @@ export async function healthCheckWorkflow(serviceUrl: string) { } ``` -The workflow tracks `consecutiveFailures` across iterations. After three consecutive failures, it escalates to a service restart. All of this state survives server restarts because the workflow is durable. +The workflow tracks `consecutiveFailures` across iterations. After three consecutive failures, it escalates to a service restart. After ten consecutive failures, a `FatalError` permanently stops the workflow to prevent infinite restart loops. All of this state survives server restarts because the workflow is durable. ## Cron-Like Dispatching @@ -178,6 +181,8 @@ import { start } from "workflow/api"; export async function triggerDailyReport() { "use step"; + // stepId is stable across retries — use it if the child workflow + // needs deduplication (e.g., as an idempotency key for external APIs) const run = await start(dailyReportWorkflow, []); // [!code highlight] return run.runId; } @@ -185,6 +190,8 @@ export async function triggerDailyReport() { export async function triggerCleanup() { "use step"; + // stepId is stable across retries — use it if the child workflow + // needs deduplication (e.g., as an idempotency key for external APIs) const run = await start(cleanupWorkflow, []); return run.runId; } @@ -245,6 +252,10 @@ export async function POST() { The custom token `"stop-recurring-job"` lets external code address the hook without needing to know the workflow's run ID. See [Custom Tokens for Deterministic Hooks](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) for more on this pattern. + +If you don't need to pass data when stopping the workflow, you can cancel a run directly using the `Run` object from [`start()`](/docs/api-reference/workflow-api/start): `await run.cancel()`. The hook-based approach above is more flexible since it lets the workflow receive a reason or other data before stopping. + + ## Related Documentation - [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full documentation on duration formats and date-based sleep From e376cd7f56b3253bf2b8bd4c3d1df2b835d929b3 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Wed, 25 Feb 2026 11:20:32 -0700 Subject: [PATCH 4/4] Address PR review feedback on scheduling docs - Merge recurring job and polling sections into unified "Recurring Execution Patterns" with three distinct patterns: loop with steps, dispatcher loop, and daisy-chaining (for infinite cron jobs with event log reset) - Add event log growth warning for unbounded while(true) loops - Fix graceful shutdown: move stopHook.then() outside loop, remove Promise.race, use if (stopped) break pattern - Replace FatalError with standard Error at workflow level (FatalError is step-only) - Fix consecutiveFailures reset after restartService() - Add inline comment and link to workflow composition docs --- docs/content/docs/foundations/scheduling.mdx | 209 ++++++++++--------- 1 file changed, 107 insertions(+), 102 deletions(-) diff --git a/docs/content/docs/foundations/scheduling.mdx b/docs/content/docs/foundations/scheduling.mdx index 0dcf11048b..503242c3a9 100644 --- a/docs/content/docs/foundations/scheduling.mdx +++ b/docs/content/docs/foundations/scheduling.mdx @@ -13,9 +13,13 @@ related: Workflows naturally support scheduling through [`sleep()`](/docs/api-reference/workflow/sleep). Unlike traditional cron systems that require external infrastructure, workflow-based scheduling is durable - if the server restarts, the schedule resumes without missing a beat. And because `sleep()` suspends the workflow without consuming compute resources, a workflow sleeping for one minute costs the same as one sleeping for a month. -## Recurring Job Execution +## Recurring Execution Patterns -The simplest scheduling pattern is an infinite loop that runs a job and sleeps between iterations. This replaces traditional cron jobs with a single workflow function. +There are three patterns for recurring jobs, each suited to different lifetimes and workloads. + +### Loop with Steps + +The simplest pattern is a `while(true)` loop that runs a job and sleeps between iterations. Use this for **short-lived or bounded** work that eventually exits — polling that resolves, health checks with a failure threshold, or jobs with a known end condition. ```typescript title="workflows/recurring-job.ts" lineNumbers import { sleep } from "workflow"; @@ -25,7 +29,9 @@ export async function recurringJobWorkflow() { "use workflow"; while (true) { // [!code highlight] - const result = await runJob(); + // runJob can be a step function or an entire composed workflow + // See: /docs/foundations/common-patterns#workflow-composition + const result = await runJob(); // [!code highlight] if (!result.success) { break; @@ -36,50 +42,110 @@ export async function recurringJobWorkflow() { } ``` + +Every iteration appends to the workflow's event log. For workflows that run indefinitely, the log grows without bound, causing replay performance to degrade over time. For true infinite cron jobs, use **daisy-chaining** instead. + + +### Dispatcher Loop + +A long-running dispatcher loop that uses a step function to call `start()` and kick off **background child workflows** without awaiting their completion. Each child workflow runs with its own event log, so the dispatcher's log stays small. + +```typescript title="workflows/cron-dispatcher.ts" lineNumbers +import { sleep } from "workflow"; +declare function triggerDailyReport(): Promise; // @setup +declare function triggerCleanup(): Promise; // @setup + +export async function cronDispatcherWorkflow() { + "use workflow"; + + let iteration = 0; + + while (true) { + // Run daily report every iteration + await triggerDailyReport(); + + // Run cleanup every 7 iterations (weekly), skipping the first run + iteration++; + if (iteration % 7 === 0) { // [!code highlight] + await triggerCleanup(); // [!code highlight] + } + + await sleep("1 day"); + } +} +``` + ```typescript title="workflows/steps.ts" lineNumbers -export async function runJob() { +import { start } from "workflow/api"; + +export async function triggerDailyReport() { "use step"; - // Full Node.js access - call APIs, query databases, etc. - const response = await fetch("https://api.example.com/process"); - return { success: response.ok }; + // stepId is stable across retries — use it if the child workflow + // needs deduplication (e.g., as an idempotency key for external APIs) + const run = await start(dailyReportWorkflow, []); // [!code highlight] + return run.runId; } -``` -The workflow runs the job, sleeps for one hour, and repeats. If the server restarts during the sleep, the workflow resumes at the correct time. The `break` condition lets you stop the loop based on the job's result. +export async function triggerCleanup() { + "use step"; - -`sleep()` consumes no compute resources while waiting. A workflow sleeping for one hour is effectively free until it wakes up. - + // stepId is stable across retries — use it if the child workflow + // needs deduplication (e.g., as an idempotency key for external APIs) + const run = await start(cleanupWorkflow, []); + return run.runId; +} + +// These are independent workflows started by the dispatcher +export async function dailyReportWorkflow() { + "use workflow"; + // Generate and send daily report +} -## Polling External Systems +export async function cleanupWorkflow() { + "use workflow"; + // Clean up old data +} +``` -A common use case is periodically checking an external system for changes. The workflow maintains state between polls, so it can detect what changed since the last check. +Each child workflow runs independently. If a daily report fails, it does not affect the dispatcher or the cleanup workflow. You can monitor each run separately using its `runId`. + +### Daisy-Chaining -```typescript title="workflows/poll-changes.ts" lineNumbers +The recommended pattern for **infinite, true cron jobs**. Instead of looping, the workflow executes its logic, sleeps for the desired duration, and as its final action triggers a brand new execution of itself before exiting. This ensures the event log resets with each run, keeping replay performance constant regardless of how long the job has been running. + +```typescript title="workflows/daisy-chain.ts" lineNumbers import { sleep } from "workflow"; -declare function fetchCurrentState(): Promise<{ data: string; updatedAt: string }>; // @setup -declare function processChanges(previous: string, current: string): Promise; // @setup +declare function runJob(): Promise; // @setup +declare function startNextRun(): Promise; // @setup -export async function pollForChangesWorkflow() { +export async function cronWorkflow() { "use workflow"; - let previousState = ""; + await runJob(); + await sleep("1 hour"); - while (true) { - const current = await fetchCurrentState(); // [!code highlight] + // Start the next execution before exiting — event log resets + await startNextRun(); // [!code highlight] +} +``` - if (current.data !== previousState) { // [!code highlight] - await processChanges(previousState, current.data); - previousState = current.data; - } +```typescript title="workflows/steps.ts" lineNumbers +import { start } from "workflow/api"; - await sleep("5 minutes"); - } +export async function startNextRun() { + "use step"; + + const run = await start(cronWorkflow, []); + return run.runId; } ``` -Because workflow state persists across suspensions, `previousState` survives restarts and replays. This makes durable polling straightforward - no external state store needed. + +`sleep()` consumes no compute resources while waiting. A workflow sleeping for one hour is effectively free until it wakes up. + + +For more on triggering workflows from within workflows, see [Workflow Composition](/docs/foundations/common-patterns#workflow-composition). ## Scheduled Tasks at Specific Times @@ -110,7 +176,7 @@ export async function endOfMonthReportWorkflow(year: number, monthNumber: number Periodic health monitoring is a natural fit for durable workflows. The workflow checks a service, takes action if something is wrong, and sleeps before checking again. ```typescript title="workflows/health-check.ts" lineNumbers -import { sleep, FatalError } from "workflow"; +import { sleep } from "workflow"; declare function checkServiceHealth(serviceUrl: string): Promise<{ healthy: boolean; latencyMs: number }>; // @setup declare function sendAlert(serviceUrl: string, latencyMs: number): Promise; // @setup declare function restartService(serviceUrl: string): Promise; // @setup @@ -129,11 +195,12 @@ export async function healthCheckWorkflow(serviceUrl: string) { await sendAlert(serviceUrl, status.latencyMs); if (consecutiveFailures >= 10) { // [!code highlight] - throw new FatalError(`${serviceUrl} unrecoverable after 10 consecutive failures`); // [!code highlight] + throw new Error(`${serviceUrl} unrecoverable after 10 consecutive failures`); // [!code highlight] } if (consecutiveFailures >= 3) { // [!code highlight] await restartService(serviceUrl); // [!code highlight] + consecutiveFailures = 0; // [!code highlight] } } else { consecutiveFailures = 0; @@ -144,75 +211,15 @@ export async function healthCheckWorkflow(serviceUrl: string) { } ``` -The workflow tracks `consecutiveFailures` across iterations. After three consecutive failures, it escalates to a service restart. After ten consecutive failures, a `FatalError` permanently stops the workflow to prevent infinite restart loops. All of this state survives server restarts because the workflow is durable. - -## Cron-Like Dispatching - -For more complex scheduling, a long-running "dispatcher" workflow can start independent child workflows on a schedule. Each scheduled task runs as its own workflow with its own event log. - -```typescript title="workflows/cron-dispatcher.ts" lineNumbers -import { sleep } from "workflow"; -declare function triggerDailyReport(): Promise; // @setup -declare function triggerCleanup(): Promise; // @setup - -export async function cronDispatcherWorkflow() { - "use workflow"; - - let iteration = 0; - - while (true) { - // Run daily report every iteration - await triggerDailyReport(); - - // Run cleanup every 7 iterations (weekly), skipping the first run - iteration++; - if (iteration % 7 === 0) { // [!code highlight] - await triggerCleanup(); // [!code highlight] - } - - await sleep("1 day"); - } -} -``` - -```typescript title="workflows/steps.ts" lineNumbers -import { start } from "workflow/api"; - -export async function triggerDailyReport() { - "use step"; - - // stepId is stable across retries — use it if the child workflow - // needs deduplication (e.g., as an idempotency key for external APIs) - const run = await start(dailyReportWorkflow, []); // [!code highlight] - return run.runId; -} - -export async function triggerCleanup() { - "use step"; - - // stepId is stable across retries — use it if the child workflow - // needs deduplication (e.g., as an idempotency key for external APIs) - const run = await start(cleanupWorkflow, []); - return run.runId; -} - -// These are independent workflows started by the dispatcher -export async function dailyReportWorkflow() { - "use workflow"; - // Generate and send daily report -} - -export async function cleanupWorkflow() { - "use workflow"; - // Clean up old data -} -``` +The workflow tracks `consecutiveFailures` across iterations. After three consecutive failures, it escalates to a service restart and resets the counter to give the service a recovery window. After ten consecutive failures, a standard error permanently stops the workflow to prevent infinite restart loops. All of this state survives server restarts because the workflow is durable. -Each child workflow runs independently. If a daily report fails, it does not affect the dispatcher or the cleanup workflow. You can monitor each run separately using its `runId`. + +`FatalError` is designed for use inside `"use step"` functions to stop step retries. At the workflow level, a standard `throw new Error(...)` produces the same workflow failure state. + ## Graceful Shutdown -To stop a recurring workflow from the outside, use a [hook](/docs/foundations/hooks). Race the sleep against the hook in each iteration - when external code sends data to the hook, the loop breaks. +To stop a recurring workflow from the outside, use a [hook](/docs/foundations/hooks). Attach the listener once before the loop — when external code sends data to the hook, the loop breaks on the next iteration. ```typescript title="workflows/stoppable-job.ts" lineNumbers import { sleep, createHook } from "workflow"; @@ -226,15 +233,13 @@ export async function stoppableJobWorkflow() { }); // [!code highlight] let stopped = false; + stopHook.then(() => { stopped = true; }); // [!code highlight] - while (!stopped) { - await runJob(); + while (true) { + if (stopped) { break; } // [!code highlight] - // Race: either sleep completes or someone sends a stop signal - await Promise.race([ // [!code highlight] - sleep("1 hour"), // [!code highlight] - stopHook.then(() => { stopped = true; }), // [!code highlight] - ]); // [!code highlight] + await runJob(); + await sleep("1 hour"); } } ```