Skip to content

Commit 41e0483

Browse files
committed
docs(foundations): add scheduling and cron patterns guide
1 parent ea3254e commit 41e0483

File tree

3 files changed

+257
-1
lines changed

3 files changed

+257
-1
lines changed

docs/content/docs/foundations/index.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,7 @@ Workflow programming can be a slight shift from how you traditionally write real
3535
<Card href="/docs/foundations/idempotency" title="Idempotency">
3636
Prevent duplicate side effects when retrying operations.
3737
</Card>
38+
<Card href="/docs/foundations/scheduling" title="Scheduling & Cron">
39+
Build recurring jobs and scheduled tasks with durable sleep loops.
40+
</Card>
3841
</Cards>

docs/content/docs/foundations/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"hooks",
99
"streaming",
1010
"serialization",
11-
"idempotency"
11+
"idempotency",
12+
"scheduling"
1213
],
1314
"defaultOpen": true
1415
}
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
---
2+
title: Scheduling & Cron
3+
description: Implement recurring jobs and scheduled tasks using durable workflow patterns.
4+
type: guide
5+
summary: Build scheduled and recurring execution patterns with durable sleep loops.
6+
prerequisites:
7+
- /docs/foundations/workflows-and-steps
8+
related:
9+
- /docs/foundations/common-patterns
10+
- /docs/api-reference/workflow/sleep
11+
- /docs/foundations/hooks
12+
---
13+
14+
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.
15+
16+
## Recurring Job Execution
17+
18+
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.
19+
20+
```typescript title="workflows/recurring-job.ts" lineNumbers
21+
import { sleep } from "workflow";
22+
declare function runJob(): Promise<{ success: boolean }>; // @setup
23+
24+
export async function recurringJobWorkflow() {
25+
"use workflow";
26+
27+
while (true) { // [!code highlight]
28+
const result = await runJob();
29+
30+
if (!result.success) {
31+
break;
32+
}
33+
34+
await sleep("1 hour"); // [!code highlight]
35+
}
36+
}
37+
```
38+
39+
```typescript title="workflows/steps.ts" lineNumbers
40+
export async function runJob() {
41+
"use step";
42+
43+
// Full Node.js access - call APIs, query databases, etc.
44+
const response = await fetch("https://api.example.com/process");
45+
return { success: response.ok };
46+
}
47+
```
48+
49+
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.
50+
51+
<Callout type="info">
52+
`sleep()` consumes no compute resources while waiting. A workflow sleeping for one hour is effectively free until it wakes up.
53+
</Callout>
54+
55+
## Polling External Systems
56+
57+
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.
58+
59+
```typescript title="workflows/poll-changes.ts" lineNumbers
60+
import { sleep } from "workflow";
61+
declare function fetchCurrentState(): Promise<{ data: string; updatedAt: string }>; // @setup
62+
declare function processChanges(previous: string, current: string): Promise<void>; // @setup
63+
64+
export async function pollForChangesWorkflow() {
65+
"use workflow";
66+
67+
let previousState = "";
68+
69+
while (true) {
70+
const current = await fetchCurrentState(); // [!code highlight]
71+
72+
if (current.data !== previousState) { // [!code highlight]
73+
await processChanges(previousState, current.data);
74+
previousState = current.data;
75+
}
76+
77+
await sleep("5 minutes");
78+
}
79+
}
80+
```
81+
82+
Because workflow state persists across suspensions, `previousState` survives restarts and replays. This makes durable polling straightforward - no external state store needed.
83+
84+
## Scheduled Tasks at Specific Times
85+
86+
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.
87+
88+
```typescript title="workflows/scheduled-task.ts" lineNumbers
89+
import { sleep } from "workflow";
90+
declare function sendReport(period: string): Promise<void>; // @setup
91+
92+
export async function endOfMonthReportWorkflow(year: number, month: number) {
93+
"use workflow";
94+
95+
// Wait until midnight on the first of next month
96+
const reportDate = new Date(year, month, 1, 0, 0, 0); // [!code highlight]
97+
await sleep(reportDate); // [!code highlight]
98+
99+
await sendReport(`${year}-${String(month).padStart(2, "0")}`);
100+
}
101+
```
102+
103+
<Callout type="info">
104+
`Date` constructors are deterministic inside workflow functions - the framework ensures consistent values across replays.
105+
</Callout>
106+
107+
## Health Checks and Keep-Alive
108+
109+
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.
110+
111+
```typescript title="workflows/health-check.ts" lineNumbers
112+
import { sleep } from "workflow";
113+
declare function checkServiceHealth(serviceUrl: string): Promise<{ healthy: boolean; latencyMs: number }>; // @setup
114+
declare function sendAlert(serviceUrl: string, latencyMs: number): Promise<void>; // @setup
115+
declare function restartService(serviceUrl: string): Promise<void>; // @setup
116+
117+
export async function healthCheckWorkflow(serviceUrl: string) {
118+
"use workflow";
119+
120+
let consecutiveFailures = 0;
121+
122+
while (true) {
123+
const status = await checkServiceHealth(serviceUrl);
124+
125+
if (!status.healthy) {
126+
consecutiveFailures++;
127+
128+
await sendAlert(serviceUrl, status.latencyMs);
129+
130+
if (consecutiveFailures >= 3) { // [!code highlight]
131+
await restartService(serviceUrl); // [!code highlight]
132+
consecutiveFailures = 0;
133+
}
134+
} else {
135+
consecutiveFailures = 0;
136+
}
137+
138+
await sleep("30 seconds");
139+
}
140+
}
141+
```
142+
143+
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.
144+
145+
## Cron-Like Dispatching
146+
147+
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.
148+
149+
```typescript title="workflows/cron-dispatcher.ts" lineNumbers
150+
import { sleep } from "workflow";
151+
declare function triggerDailyReport(): Promise<string>; // @setup
152+
declare function triggerCleanup(): Promise<string>; // @setup
153+
154+
export async function cronDispatcherWorkflow() {
155+
"use workflow";
156+
157+
let iteration = 0;
158+
159+
while (true) {
160+
// Run daily report every iteration
161+
await triggerDailyReport();
162+
163+
// Run cleanup every 7 iterations (weekly)
164+
if (iteration % 7 === 0) { // [!code highlight]
165+
await triggerCleanup(); // [!code highlight]
166+
}
167+
168+
iteration++;
169+
await sleep("1 day");
170+
}
171+
}
172+
```
173+
174+
```typescript title="workflows/steps.ts" lineNumbers
175+
import { start } from "workflow/api";
176+
177+
export async function triggerDailyReport() {
178+
"use step";
179+
180+
const run = await start(dailyReportWorkflow, []); // [!code highlight]
181+
return run.runId;
182+
}
183+
184+
export async function triggerCleanup() {
185+
"use step";
186+
187+
const run = await start(cleanupWorkflow, []);
188+
return run.runId;
189+
}
190+
191+
// These are independent workflows started by the dispatcher
192+
export async function dailyReportWorkflow() {
193+
"use workflow";
194+
// Generate and send daily report
195+
}
196+
197+
export async function cleanupWorkflow() {
198+
"use workflow";
199+
// Clean up old data
200+
}
201+
```
202+
203+
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`.
204+
205+
## Graceful Shutdown
206+
207+
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.
208+
209+
```typescript title="workflows/stoppable-job.ts" lineNumbers
210+
import { sleep, createHook } from "workflow";
211+
declare function runJob(): Promise<void>; // @setup
212+
213+
export async function stoppableJobWorkflow() {
214+
"use workflow";
215+
216+
const stopHook = createHook<{ reason: string }>({ // [!code highlight]
217+
token: "stop-recurring-job", // [!code highlight]
218+
}); // [!code highlight]
219+
220+
let stopped = false;
221+
222+
while (!stopped) {
223+
await runJob();
224+
225+
// Race: either sleep completes or someone sends a stop signal
226+
await Promise.race([ // [!code highlight]
227+
sleep("1 hour"), // [!code highlight]
228+
stopHook.then(() => { stopped = true; }), // [!code highlight]
229+
]); // [!code highlight]
230+
}
231+
}
232+
```
233+
234+
To stop the workflow, call `resumeHook()` with the custom token from any external context:
235+
236+
```typescript title="app/api/stop-job/route.ts" lineNumbers
237+
import { resumeHook } from "workflow/api";
238+
239+
export async function POST() {
240+
await resumeHook("stop-recurring-job", { reason: "Manual shutdown" });
241+
return Response.json({ stopped: true });
242+
}
243+
```
244+
245+
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.
246+
247+
## Related Documentation
248+
249+
- [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full documentation on duration formats and date-based sleep
250+
- [Common Patterns](/docs/foundations/common-patterns) - Sequential, parallel, timeout, and composition patterns
251+
- [Hooks & Webhooks](/docs/foundations/hooks) - Pause workflows and resume them with external data
252+
- [Starting Workflows](/docs/foundations/starting-workflows) - Trigger workflows with `start()` and track execution

0 commit comments

Comments
 (0)