@durable-effect/jobs 0.0.1-next.3 → 0.0.1-next.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +467 -354
- package/dist/definitions/continuous.d.ts +31 -6
- package/dist/definitions/continuous.d.ts.map +1 -1
- package/dist/definitions/continuous.js +19 -8
- package/dist/definitions/continuous.js.map +1 -1
- package/dist/definitions/debounce.d.ts +21 -1
- package/dist/definitions/debounce.d.ts.map +1 -1
- package/dist/definitions/debounce.js +1 -0
- package/dist/definitions/debounce.js.map +1 -1
- package/dist/definitions/task.d.ts +21 -1
- package/dist/definitions/task.d.ts.map +1 -1
- package/dist/definitions/task.js +1 -0
- package/dist/definitions/task.js.map +1 -1
- package/dist/errors.d.ts +1 -14
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +0 -8
- package/dist/errors.js.map +1 -1
- package/dist/handlers/continuous/handler.d.ts.map +1 -1
- package/dist/handlers/continuous/handler.js +70 -53
- package/dist/handlers/continuous/handler.js.map +1 -1
- package/dist/handlers/continuous/types.d.ts +2 -2
- package/dist/handlers/continuous/types.d.ts.map +1 -1
- package/dist/handlers/debounce/handler.d.ts.map +1 -1
- package/dist/handlers/debounce/handler.js +63 -34
- package/dist/handlers/debounce/handler.js.map +1 -1
- package/dist/handlers/debounce/types.d.ts +2 -2
- package/dist/handlers/debounce/types.d.ts.map +1 -1
- package/dist/handlers/task/handler.d.ts.map +1 -1
- package/dist/handlers/task/handler.js +51 -26
- package/dist/handlers/task/handler.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/registry.d.ts +2 -1
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +4 -2
- package/dist/registry/registry.js.map +1 -1
- package/dist/registry/types.d.ts +32 -2
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/retry/errors.d.ts +1 -3
- package/dist/retry/errors.d.ts.map +1 -1
- package/dist/retry/errors.js +1 -3
- package/dist/retry/errors.js.map +1 -1
- package/dist/retry/executor.js +1 -1
- package/dist/retry/executor.js.map +1 -1
- package/dist/retry/types.d.ts +3 -7
- package/dist/retry/types.d.ts.map +1 -1
- package/dist/services/execution.d.ts +0 -29
- package/dist/services/execution.d.ts.map +1 -1
- package/dist/services/execution.js +9 -34
- package/dist/services/execution.js.map +1 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/job-logging.d.ts +64 -0
- package/dist/services/job-logging.d.ts.map +1 -0
- package/dist/services/job-logging.js +77 -0
- package/dist/services/job-logging.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,490 +1,603 @@
|
|
|
1
1
|
# @durable-effect/jobs
|
|
2
2
|
|
|
3
|
-
Durable
|
|
3
|
+
Durable job abstractions for Cloudflare Workers built on [Effect](https://effect.website/). Write business logic as Effect programs while the framework handles durability, retries, and scheduling.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- **Debounce** - Accumulate events and flush on schedule/threshold (coming soon)
|
|
7
|
-
- **WorkerPool** - Process events one at a time with retries (coming soon)
|
|
5
|
+
## Overview
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
This package provides three job types for different patterns:
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
The Continuous primitive executes a function on a recurring schedule. Perfect for:
|
|
9
|
+
| Job Type | Purpose | Use Cases |
|
|
10
|
+
|----------|---------|-----------|
|
|
11
|
+
| **Continuous** | Recurring work on a schedule | Token refresh, health checks, daily reports |
|
|
12
|
+
| **Debounce** | Batch rapid events before processing | Webhook coalescing, update batching |
|
|
13
|
+
| **Task** | Event-driven state machines | Order workflows, multi-step processes |
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
15
|
+
Each job instance runs in its own Durable Object, providing:
|
|
16
|
+
- Persistent state that survives restarts
|
|
17
|
+
- Durable alarms for scheduling
|
|
18
|
+
- Automatic retry with configurable backoff
|
|
19
|
+
- Type-safe client with full inference
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
## Quick Start
|
|
26
22
|
|
|
27
|
-
```
|
|
23
|
+
```typescript
|
|
28
24
|
import { Effect, Schema } from "effect";
|
|
29
|
-
import {
|
|
25
|
+
import { createDurableJobs, Continuous } from "@durable-effect/jobs";
|
|
30
26
|
|
|
31
|
-
// 1. Define
|
|
27
|
+
// 1. Define a job
|
|
32
28
|
const tokenRefresher = Continuous.make({
|
|
33
|
-
// Schema for persistent state
|
|
34
29
|
stateSchema: Schema.Struct({
|
|
35
30
|
accessToken: Schema.String,
|
|
36
31
|
refreshToken: Schema.String,
|
|
37
32
|
expiresAt: Schema.Number,
|
|
38
33
|
}),
|
|
39
|
-
|
|
40
|
-
// Execute every 30 minutes
|
|
41
34
|
schedule: Continuous.every("30 minutes"),
|
|
42
|
-
|
|
43
|
-
// The function to run
|
|
44
35
|
execute: (ctx) =>
|
|
45
36
|
Effect.gen(function* () {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const { refreshToken } = ctx.state;
|
|
50
|
-
|
|
51
|
-
// Call your refresh API
|
|
52
|
-
const newTokens = yield* refreshTokens(refreshToken);
|
|
53
|
-
|
|
54
|
-
// Update state (persisted automatically)
|
|
55
|
-
ctx.setState({
|
|
56
|
-
accessToken: newTokens.access_token,
|
|
57
|
-
refreshToken: newTokens.refresh_token,
|
|
58
|
-
expiresAt: Date.now() + newTokens.expires_in * 1000,
|
|
59
|
-
});
|
|
37
|
+
const state = yield* ctx.state;
|
|
38
|
+
const newToken = yield* refreshAccessToken(state.refreshToken);
|
|
39
|
+
yield* ctx.setState({ ...state, accessToken: newToken, expiresAt: Date.now() + 1800000 });
|
|
60
40
|
}),
|
|
61
41
|
});
|
|
62
42
|
|
|
63
|
-
// 2. Create
|
|
64
|
-
const { Jobs, JobsClient } = createDurableJobs({
|
|
65
|
-
tokenRefresher,
|
|
66
|
-
});
|
|
43
|
+
// 2. Create engine and client
|
|
44
|
+
const { Jobs, JobsClient } = createDurableJobs({ tokenRefresher });
|
|
67
45
|
|
|
68
46
|
// 3. Export the Durable Object class
|
|
69
47
|
export { Jobs };
|
|
70
48
|
|
|
71
|
-
// 4. Use in your worker
|
|
49
|
+
// 4. Use the client in your worker
|
|
72
50
|
export default {
|
|
73
|
-
async fetch(request: Request, env: Env)
|
|
74
|
-
const client = JobsClient.fromBinding(env.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
return new Response("Token refresher started!");
|
|
51
|
+
async fetch(request: Request, env: Env) {
|
|
52
|
+
const client = JobsClient.fromBinding(env.JOBS);
|
|
53
|
+
|
|
54
|
+
await Effect.runPromise(
|
|
55
|
+
client.continuous("tokenRefresher").start({
|
|
56
|
+
id: "user-123",
|
|
57
|
+
input: { accessToken: "", refreshToken: "rt_abc", expiresAt: 0 },
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return new Response("OK");
|
|
87
62
|
},
|
|
88
63
|
};
|
|
89
64
|
```
|
|
90
65
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
66
|
+
Configure your `wrangler.jsonc`:
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{
|
|
70
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
71
|
+
"name": "my-worker",
|
|
72
|
+
"main": "src/worker.ts",
|
|
73
|
+
"compatibility_date": "2024-11-27",
|
|
74
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
75
|
+
|
|
76
|
+
"durable_objects": {
|
|
77
|
+
"bindings": [
|
|
78
|
+
{
|
|
79
|
+
"name": "JOBS",
|
|
80
|
+
"class_name": "Jobs"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
97
84
|
|
|
98
|
-
[
|
|
99
|
-
|
|
100
|
-
|
|
85
|
+
"migrations": [
|
|
86
|
+
{
|
|
87
|
+
"tag": "v1",
|
|
88
|
+
"new_classes": ["Jobs"]
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
101
92
|
```
|
|
102
93
|
|
|
103
|
-
|
|
94
|
+
---
|
|
104
95
|
|
|
105
|
-
|
|
96
|
+
## Defining Jobs
|
|
106
97
|
|
|
107
|
-
|
|
108
|
-
the primitive via `createDurableJobs()` - the object key becomes the name.
|
|
98
|
+
### Continuous Jobs
|
|
109
99
|
|
|
110
|
-
|
|
111
|
-
const myPrimitive = Continuous.make({
|
|
112
|
-
stateSchema: Schema.Struct({ ... }),
|
|
113
|
-
schedule: Continuous.every("1 hour"),
|
|
114
|
-
execute: (ctx) => Effect.succeed(undefined),
|
|
115
|
-
});
|
|
100
|
+
Execute on a fixed schedule. Best for recurring background work.
|
|
116
101
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
102
|
+
```typescript
|
|
103
|
+
import { Continuous } from "@durable-effect/jobs";
|
|
104
|
+
import { Backoff } from "@durable-effect/core";
|
|
105
|
+
|
|
106
|
+
const healthChecker = Continuous.make({
|
|
107
|
+
stateSchema: Schema.Struct({
|
|
108
|
+
lastCheckAt: Schema.Number,
|
|
109
|
+
consecutiveFailures: Schema.Number,
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
// Schedule options
|
|
113
|
+
schedule: Continuous.every("5 minutes"), // or Continuous.cron("0 */5 * * *")
|
|
114
|
+
|
|
115
|
+
// Execute immediately on start (default: true)
|
|
116
|
+
startImmediately: true,
|
|
117
|
+
|
|
118
|
+
// Optional retry configuration
|
|
119
|
+
retry: {
|
|
120
|
+
maxAttempts: 3,
|
|
121
|
+
delay: Backoff.exponential({ base: "1 second", max: "30 seconds" }),
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
execute: (ctx) =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const state = yield* ctx.state;
|
|
127
|
+
|
|
128
|
+
// ctx provides rich metadata
|
|
129
|
+
console.log(`Run #${ctx.runCount}, attempt ${ctx.attempt}`);
|
|
130
|
+
|
|
131
|
+
const healthy = yield* checkHealth();
|
|
132
|
+
if (!healthy && state.consecutiveFailures > 10) {
|
|
133
|
+
// Terminate removes all state and cancels the schedule
|
|
134
|
+
return yield* ctx.terminate({ reason: "Too many failures" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
yield* ctx.updateState((s) => ({
|
|
138
|
+
lastCheckAt: Date.now(),
|
|
139
|
+
consecutiveFailures: healthy ? 0 : s.consecutiveFailures + 1,
|
|
140
|
+
}));
|
|
141
|
+
}),
|
|
120
142
|
});
|
|
121
143
|
```
|
|
122
144
|
|
|
123
|
-
|
|
145
|
+
**Context properties:**
|
|
124
146
|
|
|
125
|
-
|
|
|
126
|
-
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
147
|
+
| Property | Type | Description |
|
|
148
|
+
|----------|------|-------------|
|
|
149
|
+
| `state` | `Effect<S>` | Current state (yields the value) |
|
|
150
|
+
| `setState(s)` | `Effect<void>` | Replace entire state |
|
|
151
|
+
| `updateState(fn)` | `Effect<void>` | Transform state |
|
|
152
|
+
| `terminate(opts?)` | `Effect<never>` | Stop job and purge state |
|
|
153
|
+
| `instanceId` | `string` | Unique DO instance ID |
|
|
154
|
+
| `jobName` | `string` | Registered job name |
|
|
155
|
+
| `runCount` | `number` | Execution count (1-indexed) |
|
|
156
|
+
| `attempt` | `number` | Current retry attempt (1 = first try) |
|
|
157
|
+
| `isRetry` | `boolean` | Whether this is a retry |
|
|
132
158
|
|
|
133
|
-
|
|
159
|
+
---
|
|
134
160
|
|
|
135
|
-
|
|
161
|
+
### Debounce Jobs
|
|
136
162
|
|
|
137
|
-
|
|
163
|
+
Collect events and process them in batches. Flushes after a delay or when max events reached.
|
|
138
164
|
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Continuous.every("1 hour")
|
|
142
|
-
Continuous.every("24 hours")
|
|
143
|
-
Continuous.every(Duration.minutes(30))
|
|
144
|
-
```
|
|
165
|
+
```typescript
|
|
166
|
+
import { Debounce } from "@durable-effect/jobs";
|
|
145
167
|
|
|
146
|
-
|
|
168
|
+
const webhookBatcher = Debounce.make({
|
|
169
|
+
// Schema for incoming events
|
|
170
|
+
eventSchema: Schema.Struct({
|
|
171
|
+
type: Schema.String,
|
|
172
|
+
contactId: Schema.String,
|
|
173
|
+
data: Schema.Unknown,
|
|
174
|
+
}),
|
|
147
175
|
|
|
148
|
-
|
|
176
|
+
// Optional: separate state schema (defaults to eventSchema)
|
|
177
|
+
stateSchema: Schema.Struct({
|
|
178
|
+
events: Schema.Array(Schema.Unknown),
|
|
179
|
+
count: Schema.Number,
|
|
180
|
+
}),
|
|
149
181
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
182
|
+
// Flush timing
|
|
183
|
+
flushAfter: "5 seconds",
|
|
184
|
+
maxEvents: 100, // Optional: flush early if reached
|
|
153
185
|
|
|
154
|
-
//
|
|
155
|
-
|
|
186
|
+
// Optional: custom event reducer (default: keep latest event)
|
|
187
|
+
onEvent: (ctx) =>
|
|
188
|
+
Effect.succeed({
|
|
189
|
+
events: [...ctx.state.events, ctx.event],
|
|
190
|
+
count: ctx.state.count + 1,
|
|
191
|
+
}),
|
|
156
192
|
|
|
157
|
-
//
|
|
158
|
-
|
|
193
|
+
// Process the batch
|
|
194
|
+
execute: (ctx) =>
|
|
195
|
+
Effect.gen(function* () {
|
|
196
|
+
const state = yield* ctx.state;
|
|
197
|
+
const count = yield* ctx.eventCount;
|
|
198
|
+
|
|
199
|
+
console.log(`Flushing ${count} events, reason: ${ctx.flushReason}`);
|
|
200
|
+
yield* sendWebhookBatch(state.events);
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
159
203
|
```
|
|
160
204
|
|
|
161
|
-
|
|
205
|
+
**Execute context:**
|
|
162
206
|
|
|
163
|
-
|
|
207
|
+
| Property | Type | Description |
|
|
208
|
+
|----------|------|-------------|
|
|
209
|
+
| `state` | `Effect<S>` | Accumulated state |
|
|
210
|
+
| `eventCount` | `Effect<number>` | Total events received |
|
|
211
|
+
| `flushReason` | `string` | `"maxEvents"` \| `"flushAfter"` \| `"manual"` |
|
|
212
|
+
| `debounceStartedAt` | `Effect<number>` | When first event arrived |
|
|
213
|
+
| `attempt` | `number` | Retry attempt |
|
|
214
|
+
| `isRetry` | `boolean` | Whether this is a retry |
|
|
164
215
|
|
|
165
|
-
|
|
166
|
-
interface ContinuousContext<S> {
|
|
167
|
-
// Current state (read-only reference)
|
|
168
|
-
readonly state: S;
|
|
216
|
+
**Event context (onEvent):**
|
|
169
217
|
|
|
170
|
-
|
|
171
|
-
|
|
218
|
+
| Property | Type | Description |
|
|
219
|
+
|----------|------|-------------|
|
|
220
|
+
| `event` | `I` | The incoming event |
|
|
221
|
+
| `state` | `S` | Current accumulated state |
|
|
222
|
+
| `eventCount` | `number` | Events so far |
|
|
223
|
+
| `instanceId` | `string` | DO instance ID |
|
|
172
224
|
|
|
173
|
-
|
|
174
|
-
readonly updateState: (fn: (current: S) => S) => void;
|
|
225
|
+
---
|
|
175
226
|
|
|
176
|
-
|
|
177
|
-
readonly instanceId: string;
|
|
227
|
+
### Task Jobs
|
|
178
228
|
|
|
179
|
-
|
|
180
|
-
readonly runCount: number;
|
|
229
|
+
User-controlled state machines. You decide when to schedule execution via events.
|
|
181
230
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
```
|
|
231
|
+
```typescript
|
|
232
|
+
import { Task } from "@durable-effect/jobs";
|
|
233
|
+
import { Duration } from "effect";
|
|
186
234
|
|
|
187
|
-
|
|
235
|
+
const orderProcessor = Task.make({
|
|
236
|
+
stateSchema: Schema.Struct({
|
|
237
|
+
orderId: Schema.String,
|
|
238
|
+
status: Schema.Literal("pending", "processing", "shipped", "delivered"),
|
|
239
|
+
attempts: Schema.Number,
|
|
240
|
+
}),
|
|
188
241
|
|
|
189
|
-
|
|
242
|
+
eventSchema: Schema.Union(
|
|
243
|
+
Schema.Struct({ _tag: Schema.Literal("OrderPlaced"), orderId: Schema.String }),
|
|
244
|
+
Schema.Struct({ _tag: Schema.Literal("PaymentReceived") }),
|
|
245
|
+
Schema.Struct({ _tag: Schema.Literal("Shipped"), trackingNumber: Schema.String }),
|
|
246
|
+
),
|
|
190
247
|
|
|
191
|
-
|
|
248
|
+
// Handle incoming events
|
|
249
|
+
onEvent: (event, ctx) =>
|
|
250
|
+
Effect.gen(function* () {
|
|
251
|
+
switch (event._tag) {
|
|
252
|
+
case "OrderPlaced":
|
|
253
|
+
yield* ctx.setState({
|
|
254
|
+
orderId: event.orderId,
|
|
255
|
+
status: "pending",
|
|
256
|
+
attempts: 0,
|
|
257
|
+
});
|
|
258
|
+
yield* ctx.schedule(Duration.minutes(5)); // Check payment in 5 min
|
|
259
|
+
break;
|
|
260
|
+
|
|
261
|
+
case "PaymentReceived":
|
|
262
|
+
yield* ctx.updateState((s) => ({ ...s, status: "processing" }));
|
|
263
|
+
break;
|
|
264
|
+
|
|
265
|
+
case "Shipped":
|
|
266
|
+
yield* ctx.updateState((s) => ({ ...s, status: "shipped" }));
|
|
267
|
+
yield* ctx.schedule(Duration.hours(24)); // Check delivery tomorrow
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}),
|
|
192
271
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
272
|
+
// Execute when alarm fires
|
|
273
|
+
execute: (ctx) =>
|
|
274
|
+
Effect.gen(function* () {
|
|
275
|
+
const state = yield* ctx.state;
|
|
276
|
+
if (!state) return;
|
|
277
|
+
|
|
278
|
+
if (state.status === "pending") {
|
|
279
|
+
// Still pending after 5 min - send reminder
|
|
280
|
+
yield* sendPaymentReminder(state.orderId);
|
|
281
|
+
yield* ctx.schedule(Duration.minutes(30)); // Check again
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (state.status === "shipped") {
|
|
285
|
+
const delivered = yield* checkDelivery(state.orderId);
|
|
286
|
+
if (delivered) {
|
|
287
|
+
yield* ctx.updateState((s) => ({ ...s, status: "delivered" }));
|
|
288
|
+
yield* ctx.terminate(); // Order complete
|
|
289
|
+
} else {
|
|
290
|
+
yield* ctx.schedule(Duration.hours(24)); // Check again tomorrow
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}),
|
|
294
|
+
|
|
295
|
+
// Optional: handle idle state (no alarm scheduled)
|
|
296
|
+
onIdle: (ctx) =>
|
|
297
|
+
Effect.gen(function* () {
|
|
298
|
+
// Schedule cleanup in 1 hour
|
|
299
|
+
yield* ctx.schedule(Duration.hours(1));
|
|
300
|
+
}),
|
|
198
301
|
|
|
199
|
-
//
|
|
200
|
-
|
|
302
|
+
// Optional: handle errors
|
|
303
|
+
onError: (error, ctx) =>
|
|
304
|
+
Effect.gen(function* () {
|
|
305
|
+
yield* Effect.logError("Task failed", error);
|
|
306
|
+
yield* ctx.updateState((s) => ({ ...s, attempts: s.attempts + 1 }));
|
|
307
|
+
yield* ctx.schedule(Duration.seconds(30)); // Retry
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
201
310
|
```
|
|
202
311
|
|
|
203
|
-
|
|
312
|
+
**Event context (onEvent):**
|
|
313
|
+
|
|
314
|
+
| Property | Type | Description |
|
|
315
|
+
|----------|------|-------------|
|
|
316
|
+
| `state` | `Effect<S \| null>` | Current state (null if first event) |
|
|
317
|
+
| `setState(s)` | `Effect<void>` | Set state |
|
|
318
|
+
| `updateState(fn)` | `Effect<void>` | Transform state |
|
|
319
|
+
| `schedule(when)` | `Effect<void>` | Schedule execution |
|
|
320
|
+
| `cancelSchedule()` | `Effect<void>` | Cancel scheduled execution |
|
|
321
|
+
| `getScheduledTime()` | `Effect<number \| null>` | Get scheduled time |
|
|
322
|
+
| `terminate()` | `Effect<never>` | Terminate and purge state |
|
|
323
|
+
| `isFirstEvent` | `boolean` | True if state was null |
|
|
324
|
+
| `eventCount` | `Effect<number>` | Total events received |
|
|
325
|
+
|
|
326
|
+
**Execute context:**
|
|
327
|
+
|
|
328
|
+
| Property | Type | Description |
|
|
329
|
+
|----------|------|-------------|
|
|
330
|
+
| `state` | `Effect<S \| null>` | Current state |
|
|
331
|
+
| `setState/updateState` | `Effect<void>` | Modify state |
|
|
332
|
+
| `schedule/cancelSchedule` | `Effect<void>` | Control scheduling |
|
|
333
|
+
| `terminate()` | `Effect<never>` | Terminate task |
|
|
334
|
+
| `executeCount` | `Effect<number>` | Times execute has run |
|
|
335
|
+
| `eventCount` | `Effect<number>` | Total events received |
|
|
336
|
+
| `createdAt` | `Effect<number>` | Task creation time |
|
|
337
|
+
|
|
338
|
+
---
|
|
204
339
|
|
|
205
|
-
|
|
340
|
+
## Using the Client
|
|
206
341
|
|
|
207
|
-
|
|
342
|
+
### Setup
|
|
208
343
|
|
|
209
|
-
```
|
|
210
|
-
const
|
|
211
|
-
|
|
344
|
+
```typescript
|
|
345
|
+
const { Jobs, JobsClient } = createDurableJobs({
|
|
346
|
+
tokenRefresher,
|
|
347
|
+
webhookBatcher,
|
|
348
|
+
orderProcessor,
|
|
212
349
|
});
|
|
213
350
|
|
|
214
|
-
//
|
|
215
|
-
|
|
351
|
+
// In your worker
|
|
352
|
+
const client = JobsClient.fromBinding(env.JOBS);
|
|
216
353
|
```
|
|
217
354
|
|
|
218
|
-
|
|
355
|
+
### Continuous Client
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// Start a job instance
|
|
359
|
+
const result = yield* client.continuous("tokenRefresher").start({
|
|
360
|
+
id: "user-123", // Instance identifier
|
|
361
|
+
input: { /* initial state */ },
|
|
362
|
+
});
|
|
363
|
+
// Returns: { created: boolean, instanceId: string, status: JobStatus }
|
|
219
364
|
|
|
220
|
-
|
|
365
|
+
// Trigger immediate execution (bypass schedule)
|
|
366
|
+
yield* client.continuous("tokenRefresher").trigger("user-123");
|
|
221
367
|
|
|
222
|
-
|
|
223
|
-
const
|
|
368
|
+
// Check status
|
|
369
|
+
const status = yield* client.continuous("tokenRefresher").status("user-123");
|
|
370
|
+
// Returns: { status: "running" | "stopped" | "not_found", runCount?, nextRunAt? }
|
|
224
371
|
|
|
225
|
-
//
|
|
226
|
-
|
|
372
|
+
// Get current state
|
|
373
|
+
const { state } = yield* client.continuous("tokenRefresher").getState("user-123");
|
|
374
|
+
|
|
375
|
+
// Terminate (cancel alarm + delete all state)
|
|
376
|
+
yield* client.continuous("tokenRefresher").terminate("user-123", {
|
|
377
|
+
reason: "User requested",
|
|
378
|
+
});
|
|
227
379
|
```
|
|
228
380
|
|
|
229
|
-
|
|
381
|
+
### Debounce Client
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
// Add an event (creates instance if needed)
|
|
385
|
+
const result = yield* client.debounce("webhookBatcher").add({
|
|
386
|
+
id: "contact-456",
|
|
387
|
+
event: { type: "contact.updated", contactId: "456", data: {} },
|
|
388
|
+
});
|
|
389
|
+
// Returns: { created: boolean, eventCount: number, willFlushAt: number | null }
|
|
390
|
+
|
|
391
|
+
// Force immediate flush
|
|
392
|
+
yield* client.debounce("webhookBatcher").flush("contact-456");
|
|
230
393
|
|
|
231
|
-
|
|
394
|
+
// Clear without processing
|
|
395
|
+
yield* client.debounce("webhookBatcher").clear("contact-456");
|
|
232
396
|
|
|
233
|
-
|
|
234
|
-
const
|
|
397
|
+
// Check status
|
|
398
|
+
const status = yield* client.debounce("webhookBatcher").status("contact-456");
|
|
399
|
+
// Returns: { status: "debouncing" | "idle" | "not_found", eventCount?, willFlushAt? }
|
|
235
400
|
|
|
236
|
-
//
|
|
237
|
-
|
|
401
|
+
// Get accumulated state
|
|
402
|
+
const { state } = yield* client.debounce("webhookBatcher").getState("contact-456");
|
|
238
403
|
```
|
|
239
404
|
|
|
240
|
-
|
|
405
|
+
### Task Client
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
// Send an event (creates instance if needed)
|
|
409
|
+
const result = yield* client.task("orderProcessor").send({
|
|
410
|
+
id: "order-789",
|
|
411
|
+
event: { _tag: "OrderPlaced", orderId: "order-789" },
|
|
412
|
+
});
|
|
413
|
+
// Returns: { created: boolean, instanceId: string, scheduledAt: number | null }
|
|
241
414
|
|
|
242
|
-
|
|
415
|
+
// Trigger immediate execution
|
|
416
|
+
yield* client.task("orderProcessor").trigger("order-789");
|
|
243
417
|
|
|
244
|
-
|
|
245
|
-
const
|
|
418
|
+
// Check status
|
|
419
|
+
const status = yield* client.task("orderProcessor").status("order-789");
|
|
420
|
+
// Returns: { status: "active" | "idle" | "not_found", scheduledAt?, eventCount? }
|
|
246
421
|
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
```
|
|
422
|
+
// Get state
|
|
423
|
+
const { state, scheduledAt } = yield* client.task("orderProcessor").getState("order-789");
|
|
250
424
|
|
|
251
|
-
|
|
425
|
+
// Terminate
|
|
426
|
+
yield* client.task("orderProcessor").terminate("order-789");
|
|
427
|
+
```
|
|
252
428
|
|
|
253
|
-
|
|
429
|
+
---
|
|
254
430
|
|
|
255
|
-
|
|
431
|
+
## Common Concepts
|
|
256
432
|
|
|
257
|
-
|
|
258
|
-
const resilientPrimitive = Continuous.make({
|
|
259
|
-
stateSchema: Schema.Struct({
|
|
260
|
-
data: Schema.String,
|
|
261
|
-
errorCount: Schema.Number,
|
|
262
|
-
lastError: Schema.NullOr(Schema.String),
|
|
263
|
-
}),
|
|
264
|
-
schedule: Continuous.every("5 minutes"),
|
|
433
|
+
### Instance IDs
|
|
265
434
|
|
|
266
|
-
|
|
267
|
-
Effect.gen(function* () {
|
|
268
|
-
// This might fail
|
|
269
|
-
const data = yield* fetchExternalData();
|
|
270
|
-
ctx.updateState((s) => ({
|
|
271
|
-
...s,
|
|
272
|
-
data,
|
|
273
|
-
errorCount: 0,
|
|
274
|
-
lastError: null,
|
|
275
|
-
}));
|
|
276
|
-
}),
|
|
435
|
+
Each job instance is identified by a unique ID. The internal Durable Object instance ID follows the pattern:
|
|
277
436
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
console.error(`Execution failed: ${error}`);
|
|
281
|
-
ctx.updateState((s) => ({
|
|
282
|
-
...s,
|
|
283
|
-
errorCount: s.errorCount + 1,
|
|
284
|
-
lastError: String(error),
|
|
285
|
-
}));
|
|
286
|
-
}),
|
|
287
|
-
});
|
|
437
|
+
```
|
|
438
|
+
{jobType}:{jobName}:{userProvidedId}
|
|
288
439
|
```
|
|
289
440
|
|
|
290
|
-
|
|
441
|
+
For example: `continuous:tokenRefresher:user-123`
|
|
291
442
|
|
|
292
|
-
|
|
443
|
+
### State Schemas
|
|
293
444
|
|
|
294
|
-
|
|
295
|
-
class RefreshError extends Data.TaggedError("RefreshError")<{
|
|
296
|
-
readonly reason: string;
|
|
297
|
-
}> {}
|
|
445
|
+
All jobs use Effect Schema for state validation and serialization:
|
|
298
446
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
447
|
+
```typescript
|
|
448
|
+
const stateSchema = Schema.Struct({
|
|
449
|
+
// Basic types
|
|
450
|
+
count: Schema.Number,
|
|
451
|
+
name: Schema.String,
|
|
452
|
+
active: Schema.Boolean,
|
|
302
453
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (!result.ok) {
|
|
307
|
-
return yield* Effect.fail(new RefreshError({ reason: result.error }));
|
|
308
|
-
}
|
|
309
|
-
ctx.setState({ token: result.token });
|
|
310
|
-
}),
|
|
454
|
+
// Rich types (automatically encoded/decoded)
|
|
455
|
+
createdAt: Schema.DateFromSelf,
|
|
456
|
+
data: Schema.Unknown,
|
|
311
457
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
// error is typed as RefreshError
|
|
315
|
-
console.error(`Refresh failed: ${error.reason}`);
|
|
316
|
-
}),
|
|
458
|
+
// Optional fields
|
|
459
|
+
lastError: Schema.optional(Schema.String),
|
|
317
460
|
});
|
|
318
461
|
```
|
|
319
462
|
|
|
320
|
-
|
|
463
|
+
State is validated on read/write. Invalid state throws `ValidationError`.
|
|
321
464
|
|
|
322
|
-
|
|
465
|
+
### Terminate vs Stop
|
|
323
466
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// Create runtime from test layer
|
|
343
|
-
const runtime = createJobsRuntimeFromLayer(layer, registry);
|
|
344
|
-
|
|
345
|
-
// Start the primitive
|
|
346
|
-
await runtime.handle({
|
|
347
|
-
type: "continuous",
|
|
348
|
-
action: "start",
|
|
349
|
-
name: "tokenRefresher",
|
|
350
|
-
id: "test-user",
|
|
351
|
-
input: { accessToken: "", refreshToken: "rt_test", expiresAt: 0 },
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// Advance time past schedule
|
|
355
|
-
time.advance(30 * 60 * 1000); // 30 minutes
|
|
356
|
-
|
|
357
|
-
// Trigger alarm
|
|
358
|
-
await runtime.handleAlarm();
|
|
359
|
-
|
|
360
|
-
// Verify state was updated
|
|
361
|
-
const result = await runtime.handle({
|
|
362
|
-
type: "continuous",
|
|
363
|
-
action: "getState",
|
|
364
|
-
name: "tokenRefresher",
|
|
365
|
-
id: "test-user",
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
expect(result.state.accessToken).toBeDefined();
|
|
369
|
-
});
|
|
370
|
-
});
|
|
467
|
+
- **`terminate()`**: Removes all state and cancels alarms. The instance ID can be reused to start fresh.
|
|
468
|
+
- Jobs don't have a "pause" concept - they're either running or terminated.
|
|
469
|
+
|
|
470
|
+
### Schedule Inputs
|
|
471
|
+
|
|
472
|
+
Task's `schedule()` accepts flexible time inputs:
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
// Duration (from now)
|
|
476
|
+
yield* ctx.schedule(Duration.seconds(30));
|
|
477
|
+
yield* ctx.schedule("5 minutes");
|
|
478
|
+
|
|
479
|
+
// Absolute timestamp (ms since epoch)
|
|
480
|
+
yield* ctx.schedule(Date.now() + 60000);
|
|
481
|
+
|
|
482
|
+
// Date object
|
|
483
|
+
yield* ctx.schedule(new Date("2024-12-31"));
|
|
371
484
|
```
|
|
372
485
|
|
|
373
|
-
|
|
486
|
+
---
|
|
374
487
|
|
|
375
|
-
|
|
488
|
+
## Retry Configuration
|
|
376
489
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
totalReports: Schema.Number,
|
|
382
|
-
}),
|
|
383
|
-
schedule: Continuous.every("24 hours"),
|
|
384
|
-
startImmediately: false, // Wait for first schedule
|
|
490
|
+
Configure automatic retries for execute failures:
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
import { Backoff } from "@durable-effect/core";
|
|
385
494
|
|
|
495
|
+
const job = Continuous.make({
|
|
496
|
+
// ...
|
|
497
|
+
retry: {
|
|
498
|
+
maxAttempts: 3,
|
|
499
|
+
delay: Backoff.exponential({
|
|
500
|
+
base: "1 second",
|
|
501
|
+
max: "30 seconds",
|
|
502
|
+
}),
|
|
503
|
+
jitter: true, // Add randomness to prevent thundering herd
|
|
504
|
+
},
|
|
386
505
|
execute: (ctx) =>
|
|
387
506
|
Effect.gen(function* () {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
lastReportDate: new Date().toISOString(),
|
|
393
|
-
totalReports: s.totalReports + 1,
|
|
394
|
-
}));
|
|
507
|
+
if (ctx.isRetry) {
|
|
508
|
+
console.log(`Retry attempt ${ctx.attempt}`);
|
|
509
|
+
}
|
|
510
|
+
// ... your logic
|
|
395
511
|
}),
|
|
396
512
|
});
|
|
397
513
|
```
|
|
398
514
|
|
|
399
|
-
|
|
515
|
+
**Retry behavior:**
|
|
516
|
+
- Retries are scheduled via alarms (durable, survives restarts)
|
|
517
|
+
- When all retries exhausted, the job is terminated (state purged)
|
|
518
|
+
- A `job.retryExhausted` event is emitted for observability
|
|
400
519
|
|
|
401
|
-
|
|
402
|
-
const healthCheck = Continuous.make({
|
|
403
|
-
stateSchema: Schema.Struct({
|
|
404
|
-
status: Schema.Literal("healthy", "degraded", "down"),
|
|
405
|
-
lastCheck: Schema.Number,
|
|
406
|
-
consecutiveFailures: Schema.Number,
|
|
407
|
-
}),
|
|
408
|
-
schedule: Continuous.every("1 minute"),
|
|
520
|
+
**Backoff strategies:**
|
|
409
521
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
522
|
+
```typescript
|
|
523
|
+
// Exponential: 1s, 2s, 4s, 8s... (capped at max)
|
|
524
|
+
Backoff.exponential({ base: "1 second", max: "30 seconds" })
|
|
413
525
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
lastCheck: Date.now(),
|
|
417
|
-
consecutiveFailures: isHealthy ? 0 : s.consecutiveFailures + 1,
|
|
418
|
-
}));
|
|
526
|
+
// Linear: 1s, 2s, 3s, 4s...
|
|
527
|
+
Backoff.linear({ base: "1 second", increment: "1 second" })
|
|
419
528
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
}),
|
|
424
|
-
});
|
|
529
|
+
// Fixed: always 5s
|
|
530
|
+
Backoff.fixed("5 seconds")
|
|
425
531
|
```
|
|
426
532
|
|
|
427
|
-
|
|
533
|
+
---
|
|
428
534
|
|
|
429
|
-
|
|
430
|
-
const cacheWarmer = Continuous.make({
|
|
431
|
-
stateSchema: Schema.Struct({
|
|
432
|
-
lastWarmTime: Schema.Number,
|
|
433
|
-
itemsWarmed: Schema.Number,
|
|
434
|
-
}),
|
|
435
|
-
schedule: Continuous.every("15 minutes"),
|
|
535
|
+
## Logging
|
|
436
536
|
|
|
437
|
-
|
|
438
|
-
Effect.gen(function* () {
|
|
439
|
-
const items = yield* getPopularItems();
|
|
537
|
+
Control logging per job:
|
|
440
538
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
539
|
+
```typescript
|
|
540
|
+
import { LogLevel } from "effect";
|
|
444
541
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
542
|
+
const job = Continuous.make({
|
|
543
|
+
// ...
|
|
544
|
+
logging: true, // LogLevel.Debug (all logs)
|
|
545
|
+
logging: false, // LogLevel.Error (failures only) - DEFAULT
|
|
546
|
+
logging: LogLevel.Warning,
|
|
547
|
+
logging: LogLevel.None, // Silent
|
|
450
548
|
});
|
|
451
549
|
```
|
|
452
550
|
|
|
453
|
-
|
|
551
|
+
---
|
|
454
552
|
|
|
455
|
-
|
|
553
|
+
## Telemetry
|
|
456
554
|
|
|
555
|
+
Send job events to an external endpoint:
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
const { Jobs, JobsClient } = createDurableJobs(
|
|
559
|
+
{ tokenRefresher, webhookBatcher },
|
|
560
|
+
{
|
|
561
|
+
tracker: {
|
|
562
|
+
endpoint: "https://events.example.com/ingest",
|
|
563
|
+
env: "production",
|
|
564
|
+
serviceKey: "my-jobs-service",
|
|
565
|
+
},
|
|
566
|
+
}
|
|
567
|
+
);
|
|
457
568
|
```
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
569
|
+
|
|
570
|
+
**Emitted events:**
|
|
571
|
+
- `job.started` - Job instance created
|
|
572
|
+
- `job.executed` - Execute completed successfully
|
|
573
|
+
- `job.failed` - Execute threw an error
|
|
574
|
+
- `job.retryExhausted` - All retries failed
|
|
575
|
+
- `job.terminated` - Job instance terminated
|
|
576
|
+
- `debounce.started` - First event added
|
|
577
|
+
- `debounce.flushed` - Batch processed
|
|
578
|
+
- `task.scheduled` - Execution scheduled
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## Error Types
|
|
583
|
+
|
|
584
|
+
All errors are typed Effect errors:
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
import {
|
|
588
|
+
JobNotFoundError, // Job name not in registry
|
|
589
|
+
InstanceNotFoundError, // Instance has no metadata
|
|
590
|
+
InvalidStateError, // Invalid state transition
|
|
591
|
+
ValidationError, // Schema validation failed
|
|
592
|
+
ExecutionError, // User function threw
|
|
593
|
+
DuplicateEventError, // Idempotency check failed
|
|
594
|
+
StorageError, // Durable Object storage error
|
|
595
|
+
SchedulerError, // Alarm scheduling error
|
|
596
|
+
} from "@durable-effect/jobs";
|
|
481
597
|
```
|
|
482
598
|
|
|
483
|
-
|
|
484
|
-
- **Storage** - Isolated key-value storage for state
|
|
485
|
-
- **Alarm** - Single alarm for scheduling next execution
|
|
486
|
-
- **Instance ID** - Format: `continuous:{name}:{userProvidedId}`
|
|
599
|
+
---
|
|
487
600
|
|
|
488
|
-
##
|
|
601
|
+
## Effect Service Integration
|
|
489
602
|
|
|
490
|
-
|
|
603
|
+
TODO
|