effect-cf 0.0.3 → 0.1.0
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/LICENSE +21 -0
- package/README.md +36 -46
- package/dist/index.d.mts +353 -28
- package/dist/index.mjs +333 -6
- package/package.json +1 -1
- package/src/DurableObject.ts +26 -2
- package/src/DurableObjectAlarm.ts +832 -0
- package/src/Worker.ts +11 -45
- package/src/WorkerDefinition.ts +4 -9
- package/src/index.ts +1 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { Clock, Context, Data, DateTime, Duration, Effect, Exit, Layer, Schema as S } from "effect";
|
|
2
|
+
|
|
3
|
+
import { DurableObjectState } from "./DurableObjectState";
|
|
4
|
+
import type { SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
|
|
5
|
+
|
|
6
|
+
const INIT_TABLE_SQL = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
|
|
8
|
+
storage_id TEXT PRIMARY KEY,
|
|
9
|
+
alarm_id TEXT NOT NULL,
|
|
10
|
+
tag TEXT NOT NULL,
|
|
11
|
+
run_at INTEGER NOT NULL,
|
|
12
|
+
repeat_every_ms INTEGER,
|
|
13
|
+
payload TEXT NOT NULL
|
|
14
|
+
);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id
|
|
16
|
+
ON effect_cf_scheduled_alarms (run_at, storage_id);
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
|
|
20
|
+
const DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER = "30 seconds" satisfies Duration.Input;
|
|
21
|
+
|
|
22
|
+
const getScheduledEventId = (input: { readonly id: string; readonly tag: string }) =>
|
|
23
|
+
`effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* JSON-serializable alarm payload stored with each scheduled alarm.
|
|
27
|
+
*
|
|
28
|
+
* Payloads are intentionally opaque to `DurableObjectAlarm`. Consumers should use
|
|
29
|
+
* stable `tag` values to route due alarms and then decode the payload for that
|
|
30
|
+
* tag in their own domain layer.
|
|
31
|
+
*/
|
|
32
|
+
export type AlarmPayload = S.Json;
|
|
33
|
+
|
|
34
|
+
interface AlarmRow extends Record<string, SqlStorageValue> {
|
|
35
|
+
readonly alarm_id: string;
|
|
36
|
+
readonly payload: string;
|
|
37
|
+
readonly repeat_every_ms: number | null;
|
|
38
|
+
readonly run_at: number;
|
|
39
|
+
readonly storage_id: string;
|
|
40
|
+
readonly tag: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface NextAlarmRow extends Record<string, SqlStorageValue> {
|
|
44
|
+
readonly run_at: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class InvalidAlarmRefError extends Data.TaggedError("InvalidAlarmRefError")<{
|
|
48
|
+
readonly cause: unknown;
|
|
49
|
+
}> {}
|
|
50
|
+
|
|
51
|
+
export class InvalidAlarmPayloadError extends Data.TaggedError("InvalidAlarmPayloadError")<{
|
|
52
|
+
readonly cause: unknown;
|
|
53
|
+
}> {}
|
|
54
|
+
|
|
55
|
+
export class InvalidRepeatEveryError extends Data.TaggedError("InvalidRepeatEveryError")<{
|
|
56
|
+
readonly cause: unknown;
|
|
57
|
+
}> {}
|
|
58
|
+
|
|
59
|
+
export class InvalidProcessDueAlarmsOptionsError extends Data.TaggedError(
|
|
60
|
+
"InvalidProcessDueAlarmsOptionsError",
|
|
61
|
+
)<{
|
|
62
|
+
readonly cause: unknown;
|
|
63
|
+
}> {}
|
|
64
|
+
|
|
65
|
+
export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeError")<{
|
|
66
|
+
readonly cause: unknown;
|
|
67
|
+
readonly storageId: string;
|
|
68
|
+
}> {}
|
|
69
|
+
|
|
70
|
+
export type DurableObjectAlarmError =
|
|
71
|
+
| InvalidAlarmPayloadError
|
|
72
|
+
| InvalidAlarmRefError
|
|
73
|
+
| InvalidProcessDueAlarmsOptionsError
|
|
74
|
+
| InvalidRepeatEveryError
|
|
75
|
+
| StorageOperationError
|
|
76
|
+
| StoredAlarmDecodeError;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Events handled by `processDueAlarms`.
|
|
80
|
+
*
|
|
81
|
+
* `AlarmDue` represents one durable alarm whose scheduled time is now due. The
|
|
82
|
+
* `tag` is the consumer-owned routing key, while `id` is the stable alarm id
|
|
83
|
+
* within that tag.
|
|
84
|
+
*
|
|
85
|
+
* `scheduledAt` is the logical due-after timestamp from durable storage. It is
|
|
86
|
+
* not expected to match the current Durable Object alarm invocation time,
|
|
87
|
+
* especially during retries. Retries re-scan durable rows with `run_at <= now`
|
|
88
|
+
* and identify logical events by `{ tag, id }`.
|
|
89
|
+
*
|
|
90
|
+
* @example Handling events by tag
|
|
91
|
+
* ```ts
|
|
92
|
+
* yield* durableObjectAlarm.processDueAlarms((event) =>
|
|
93
|
+
* event.tag === "heartbeat"
|
|
94
|
+
* ? heartbeatManager.handleAlarmEvent(event).pipe(Effect.asVoid)
|
|
95
|
+
* : Effect.void
|
|
96
|
+
* );
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export const DurableObjectAlarmEvent = S.TaggedUnion({
|
|
100
|
+
AlarmDue: {
|
|
101
|
+
id: S.NonEmptyString,
|
|
102
|
+
payload: S.Json,
|
|
103
|
+
scheduledAt: S.DateTimeUtc,
|
|
104
|
+
tag: S.NonEmptyString,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
export type DurableObjectAlarmEvent = typeof DurableObjectAlarmEvent.Type;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stable reference for a scheduled alarm.
|
|
111
|
+
*
|
|
112
|
+
* The pair of `tag` and `id` is the durable identity. Scheduling another alarm
|
|
113
|
+
* with the same pair replaces the previous alarm, and cancellation uses the
|
|
114
|
+
* same pair.
|
|
115
|
+
*
|
|
116
|
+
* @example Cancel an alarm
|
|
117
|
+
* ```ts
|
|
118
|
+
* yield* durableObjectAlarm.cancelAlarm({
|
|
119
|
+
* tag: "connection-reconnect-grace",
|
|
120
|
+
* id: "reconnect-grace-check",
|
|
121
|
+
* });
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export type AlarmRef<Tag extends string = string> = {
|
|
125
|
+
readonly id: string;
|
|
126
|
+
readonly tag: Tag;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const AlarmRefSchema = S.Struct({
|
|
130
|
+
id: S.NonEmptyString,
|
|
131
|
+
tag: S.NonEmptyString,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const decodeAlarmRef = (input: AlarmRef) =>
|
|
135
|
+
S.decodeUnknownEffect(AlarmRefSchema)(input).pipe(
|
|
136
|
+
Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Input for scheduling or replacing an alarm.
|
|
141
|
+
*
|
|
142
|
+
* `runAt` is the absolute first fire time. `repeatEvery` can be any Effect
|
|
143
|
+
* `Duration.Input`, including strings like `"5 seconds"`, numbers interpreted
|
|
144
|
+
* as milliseconds, or `Duration` values.
|
|
145
|
+
*
|
|
146
|
+
* @example One-shot alarm
|
|
147
|
+
* ```ts
|
|
148
|
+
* const now = yield* DateTime.now;
|
|
149
|
+
*
|
|
150
|
+
* yield* durableObjectAlarm.scheduleAlarm({
|
|
151
|
+
* tag: "connection-reconnect-grace",
|
|
152
|
+
* id: "reconnect-grace-check",
|
|
153
|
+
* runAt: DateTime.add(now, { seconds: 15 }),
|
|
154
|
+
* payload: { reason: "socket-closed" },
|
|
155
|
+
* });
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* @example Repeating alarm
|
|
159
|
+
* ```ts
|
|
160
|
+
* const now = yield* DateTime.now;
|
|
161
|
+
*
|
|
162
|
+
* yield* durableObjectAlarm.scheduleAlarm({
|
|
163
|
+
* tag: "heartbeat",
|
|
164
|
+
* id: "heartbeat-check",
|
|
165
|
+
* runAt: DateTime.add(now, { seconds: 5 }),
|
|
166
|
+
* repeatEvery: "5 seconds",
|
|
167
|
+
* payload: null,
|
|
168
|
+
* });
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
|
|
172
|
+
readonly payload: AlarmPayload;
|
|
173
|
+
readonly repeatEvery?: Duration.Input;
|
|
174
|
+
readonly runAt: DateTime.Utc;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export type ProcessDueAlarmsMode = "isolated" | "ordered";
|
|
178
|
+
|
|
179
|
+
export interface ProcessDueAlarmsFailure {
|
|
180
|
+
readonly cause: unknown;
|
|
181
|
+
readonly event?: DurableObjectAlarmEvent;
|
|
182
|
+
readonly id: string;
|
|
183
|
+
readonly storageId: string;
|
|
184
|
+
readonly tag: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ProcessDueAlarmsResult {
|
|
188
|
+
readonly failed: readonly ProcessDueAlarmsFailure[];
|
|
189
|
+
readonly handled: readonly DurableObjectAlarmEvent[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type ProcessDueAlarmsFailureAction =
|
|
193
|
+
| "ordered"
|
|
194
|
+
| "retry"
|
|
195
|
+
| "skip-and-advance-repeat"
|
|
196
|
+
| {
|
|
197
|
+
readonly mode: "ordered";
|
|
198
|
+
}
|
|
199
|
+
| {
|
|
200
|
+
readonly mode: "retry";
|
|
201
|
+
readonly retryFailedAfter?: Duration.Input;
|
|
202
|
+
}
|
|
203
|
+
| {
|
|
204
|
+
readonly mode: "skip-and-advance-repeat";
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
|
|
208
|
+
/** Maximum due rows to load and process in one invocation. Defaults to 100. */
|
|
209
|
+
readonly limit?: number;
|
|
210
|
+
/**
|
|
211
|
+
* Failure isolation mode. Defaults to `isolated` so one poison logical alarm
|
|
212
|
+
* does not block unrelated maintenance work.
|
|
213
|
+
*/
|
|
214
|
+
readonly mode?: ProcessDueAlarmsMode;
|
|
215
|
+
/**
|
|
216
|
+
* Callback for logical handler/decode failures. Return a failure action to
|
|
217
|
+
* override the global failure behavior for this row.
|
|
218
|
+
*/
|
|
219
|
+
readonly onFailure?: (
|
|
220
|
+
failure: ProcessDueAlarmsFailure,
|
|
221
|
+
) => Effect.Effect<ProcessDueAlarmsFailureAction | void, OnFailureE, OnFailureR>;
|
|
222
|
+
/**
|
|
223
|
+
* Delay before retrying a failed logical row in isolated mode. Defaults to 30
|
|
224
|
+
* seconds. Ignored by ordered mode unless supplied explicitly.
|
|
225
|
+
*
|
|
226
|
+
* Retrying updates only the selected occurrence, so replacements scheduled by
|
|
227
|
+
* a long-running handler are not clobbered by failure handling.
|
|
228
|
+
*/
|
|
229
|
+
readonly retryFailedAfter?: Duration.Input;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Handler invoked for each due alarm before that alarm is acknowledged.
|
|
234
|
+
*
|
|
235
|
+
* If the handler fails, `DurableObjectAlarm` leaves the row due and fails the alarm
|
|
236
|
+
* processing effect. Cloudflare can then retry the Durable Object alarm handler,
|
|
237
|
+
* and the scheduler will find the same logical row again because `run_at` is
|
|
238
|
+
* still less than or equal to the retry time.
|
|
239
|
+
*/
|
|
240
|
+
export type ProcessDueAlarmsHandler<R = never, E = never> = (
|
|
241
|
+
event: DurableObjectAlarmEvent,
|
|
242
|
+
) => Effect.Effect<void, E, R>;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Durable alarm scheduler API.
|
|
246
|
+
*
|
|
247
|
+
* This service only owns scheduling semantics. It does not publish, subscribe,
|
|
248
|
+
* or dispatch events to consumers. Durable Object alarm handlers should pass a
|
|
249
|
+
* domain handler to `processDueAlarms`; rows are acknowledged only after that
|
|
250
|
+
* handler succeeds.
|
|
251
|
+
*
|
|
252
|
+
* This service must be the only code path that owns `storage.setAlarm()` inside
|
|
253
|
+
* a Durable Object instance. Cloudflare exposes one platform alarm timestamp per
|
|
254
|
+
* object, so another scheduler in the same object can clobber this service's
|
|
255
|
+
* reconciled alarm timestamp.
|
|
256
|
+
*
|
|
257
|
+
* @example Durable Object alarm handler
|
|
258
|
+
* ```ts
|
|
259
|
+
* const onAlarm = Effect.gen(function* () {
|
|
260
|
+
* const durableObjectAlarm = yield* DurableObjectAlarm;
|
|
261
|
+
*
|
|
262
|
+
* yield* durableObjectAlarm.processDueAlarms((event) =>
|
|
263
|
+
* Effect.gen(function* () {
|
|
264
|
+
* if (event.tag === "heartbeat") {
|
|
265
|
+
* yield* heartbeatManager.handleAlarmEvent(event);
|
|
266
|
+
* return;
|
|
267
|
+
* }
|
|
268
|
+
*
|
|
269
|
+
* if (event.tag === "connection-reconnect-grace") {
|
|
270
|
+
* yield* connectionManager.expireReconnectGracePeriods();
|
|
271
|
+
* }
|
|
272
|
+
* })
|
|
273
|
+
* );
|
|
274
|
+
* });
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
export type AlarmScheduler = {
|
|
278
|
+
/**
|
|
279
|
+
* Cancel a scheduled alarm by `{ tag, id }`.
|
|
280
|
+
*
|
|
281
|
+
* This is idempotent. Cancelling a missing alarm is a no-op. The underlying
|
|
282
|
+
* Durable Object alarm is reconciled afterward.
|
|
283
|
+
*/
|
|
284
|
+
readonly cancelAlarm: (
|
|
285
|
+
input: AlarmRef,
|
|
286
|
+
) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Process due alarms according to `Clock.currentTimeMillis`.
|
|
290
|
+
*
|
|
291
|
+
* Each row is acknowledged only after `handle` succeeds. One-shot alarms are
|
|
292
|
+
* then deleted. Repeating alarms are rescheduled to `acknowledgedAt +
|
|
293
|
+
* repeatEvery`, which intentionally behaves as delay-after-success rather
|
|
294
|
+
* than fixed-cadence catch-up. Acknowledgements are conditional on the stored
|
|
295
|
+
* row still matching the selected occurrence, so a handler that reschedules
|
|
296
|
+
* the same `{ tag, id }` will not have its replacement clobbered by the old
|
|
297
|
+
* acknowledgement.
|
|
298
|
+
*
|
|
299
|
+
* Processing is ordered by `runAt` and stable alarm id. By default, logical
|
|
300
|
+
* handler/decode failures are isolated to the failing row: the row is retried
|
|
301
|
+
* after `retryFailedAfter`, later due rows still run, and the result reports
|
|
302
|
+
* both handled and failed rows. Use `mode: "ordered"` when a workflow
|
|
303
|
+
* intentionally requires strict head-of-line blocking.
|
|
304
|
+
*
|
|
305
|
+
* Handlers must still be idempotent: Cloudflare alarms are at-least-once, and
|
|
306
|
+
* if a handler succeeds but the acknowledgement write fails, the logical event
|
|
307
|
+
* can be delivered again.
|
|
308
|
+
*/
|
|
309
|
+
readonly processDueAlarms: <R = never, E = never, OnFailureR = never, OnFailureE = never>(
|
|
310
|
+
handle: ProcessDueAlarmsHandler<R, E>,
|
|
311
|
+
options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
|
|
312
|
+
) => Effect.Effect<
|
|
313
|
+
ProcessDueAlarmsResult,
|
|
314
|
+
E | OnFailureE | DurableObjectAlarmError,
|
|
315
|
+
R | OnFailureR
|
|
316
|
+
>;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Schedule or replace an alarm by `{ tag, id }`.
|
|
320
|
+
*
|
|
321
|
+
* The durable row write and underlying platform alarm reconciliation are run
|
|
322
|
+
* in one Durable Object storage transaction, so a failed `setAlarm` rolls back
|
|
323
|
+
* the logical schedule instead of leaving a stored-but-unarmed alarm.
|
|
324
|
+
*/
|
|
325
|
+
readonly scheduleAlarm: (
|
|
326
|
+
input: ScheduleAlarmInput,
|
|
327
|
+
) => Effect.Effect<
|
|
328
|
+
void,
|
|
329
|
+
| InvalidAlarmPayloadError
|
|
330
|
+
| InvalidAlarmRefError
|
|
331
|
+
| InvalidRepeatEveryError
|
|
332
|
+
| StorageOperationError
|
|
333
|
+
>;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const StoredPayloadString = S.fromJsonString(S.Json);
|
|
337
|
+
|
|
338
|
+
const decodeStoredPayload = (row: AlarmRow) =>
|
|
339
|
+
S.decodeUnknownEffect(StoredPayloadString)(row.payload).pipe(
|
|
340
|
+
Effect.mapError((cause) => new StoredAlarmDecodeError({ cause, storageId: row.storage_id })),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const encodeStoredPayload = (payload: AlarmPayload) =>
|
|
344
|
+
S.encodeEffect(StoredPayloadString)(payload).pipe(
|
|
345
|
+
Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const ensureTable = (state: DurableObjectState["Service"]) =>
|
|
349
|
+
state.storage.sql.exec(INIT_TABLE_SQL).pipe(Effect.asVoid);
|
|
350
|
+
|
|
351
|
+
const toRepeatEveryMillis = (input: Duration.Input | undefined) => {
|
|
352
|
+
if (input === undefined) {
|
|
353
|
+
return Effect.succeed(null);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return Effect.try({
|
|
357
|
+
try: () => {
|
|
358
|
+
const millis = Duration.toMillis(Duration.fromInputUnsafe(input));
|
|
359
|
+
if (!Number.isFinite(millis) || millis <= 0) {
|
|
360
|
+
throw new Error("Alarm repeatEvery must be a positive finite duration");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return Math.ceil(millis);
|
|
364
|
+
},
|
|
365
|
+
catch: (cause) => new InvalidRepeatEveryError({ cause }),
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const toAlarmDue = (row: AlarmRow) =>
|
|
370
|
+
Effect.gen(function* () {
|
|
371
|
+
if (!Number.isFinite(row.run_at)) {
|
|
372
|
+
return yield* Effect.fail(
|
|
373
|
+
new StoredAlarmDecodeError({
|
|
374
|
+
cause: new Error("Stored alarm run_at must be a finite number"),
|
|
375
|
+
storageId: row.storage_id,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return DurableObjectAlarmEvent.make({
|
|
381
|
+
_tag: "AlarmDue",
|
|
382
|
+
id: row.alarm_id,
|
|
383
|
+
payload: yield* decodeStoredPayload(row),
|
|
384
|
+
scheduledAt: DateTime.toUtc(DateTime.makeUnsafe(row.run_at)),
|
|
385
|
+
tag: row.tag,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const getProcessLimit = (options: ProcessDueAlarmsOptions<unknown, unknown> | undefined) => {
|
|
390
|
+
const limit = options?.limit ?? DEFAULT_PROCESS_DUE_ALARMS_LIMIT;
|
|
391
|
+
|
|
392
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
393
|
+
return Effect.fail(
|
|
394
|
+
new InvalidProcessDueAlarmsOptionsError({
|
|
395
|
+
cause: new Error("processDueAlarms limit must be a positive safe integer"),
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return Effect.succeed(limit);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const toFailureRescheduleMillis = (input: Duration.Input) =>
|
|
404
|
+
Effect.try({
|
|
405
|
+
try: () => {
|
|
406
|
+
const millis = Duration.toMillis(Duration.fromInputUnsafe(input));
|
|
407
|
+
if (!Number.isFinite(millis) || millis <= 0) {
|
|
408
|
+
throw new Error("Alarm failure rescheduleAfter must be a positive finite duration");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return Math.ceil(millis);
|
|
412
|
+
},
|
|
413
|
+
catch: (cause) => new InvalidProcessDueAlarmsOptionsError({ cause }),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const getFailureRetryDelay = (options: ProcessDueAlarmsOptions<unknown, unknown> | undefined) =>
|
|
417
|
+
toFailureRescheduleMillis(
|
|
418
|
+
options?.retryFailedAfter ?? DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const getFailureActionMode = (action: ProcessDueAlarmsFailureAction) =>
|
|
422
|
+
typeof action === "string" ? action : action.mode;
|
|
423
|
+
|
|
424
|
+
const getFailureActionRetryDelay = (action: ProcessDueAlarmsFailureAction) =>
|
|
425
|
+
typeof action === "string" || action.mode !== "retry" ? undefined : action.retryFailedAfter;
|
|
426
|
+
|
|
427
|
+
export const processDue = <R = never, E = never, OnFailureR = never, OnFailureE = never>(
|
|
428
|
+
handle: ProcessDueAlarmsHandler<R, E>,
|
|
429
|
+
options: ProcessDueAlarmsOptions<OnFailureR, OnFailureE> = {},
|
|
430
|
+
) =>
|
|
431
|
+
Effect.gen(function* () {
|
|
432
|
+
const durableObjectAlarm = yield* DurableObjectAlarm;
|
|
433
|
+
return yield* durableObjectAlarm.processDueAlarms(handle, options);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
export const process = processDue;
|
|
437
|
+
|
|
438
|
+
export type AlarmPayloadSchema = S.Codec<any, any, never, never>;
|
|
439
|
+
|
|
440
|
+
export type AlarmFailurePolicy = "ordered" | "retry" | "skip-and-advance-repeat";
|
|
441
|
+
|
|
442
|
+
export interface AlarmRetryPolicy {
|
|
443
|
+
readonly initialDelay?: Duration.Input;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export interface AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> {
|
|
447
|
+
readonly failure?: AlarmFailurePolicy;
|
|
448
|
+
readonly payload: Payload;
|
|
449
|
+
readonly retry?: AlarmRetryPolicy;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export type AlarmDefinitionEntry = AlarmDefinitionConfig | AlarmPayloadSchema;
|
|
453
|
+
|
|
454
|
+
export type AlarmDefinitions = Readonly<Record<string, AlarmDefinitionEntry>>;
|
|
455
|
+
|
|
456
|
+
type AlarmDefinitionSchema<Definition> =
|
|
457
|
+
Definition extends AlarmDefinitionConfig<infer Payload> ? Payload : Definition;
|
|
458
|
+
|
|
459
|
+
export type AlarmDefinitionPayload<Definition> =
|
|
460
|
+
AlarmDefinitionSchema<Definition> extends S.Codec<infer A, any, never, never> ? A : never;
|
|
461
|
+
|
|
462
|
+
export type DefinedAlarmEvent<Tag extends string, Payload> = Omit<
|
|
463
|
+
DurableObjectAlarmEvent,
|
|
464
|
+
"payload" | "tag"
|
|
465
|
+
> & {
|
|
466
|
+
readonly payload: Payload;
|
|
467
|
+
readonly tag: Tag;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
export type DefinedAlarmHandlers<Definitions extends AlarmDefinitions, R = never, E = never> = {
|
|
471
|
+
readonly [Tag in keyof Definitions & string]: (
|
|
472
|
+
event: DefinedAlarmEvent<Tag, AlarmDefinitionPayload<Definitions[Tag]>>,
|
|
473
|
+
) => Effect.Effect<void, E, R>;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const isAlarmDefinitionConfig = (
|
|
477
|
+
definition: AlarmDefinitionEntry,
|
|
478
|
+
): definition is AlarmDefinitionConfig =>
|
|
479
|
+
typeof definition === "object" && definition !== null && "payload" in definition;
|
|
480
|
+
|
|
481
|
+
const getAlarmDefinitionSchema = (definition: AlarmDefinitionEntry) =>
|
|
482
|
+
isAlarmDefinitionConfig(definition) ? definition.payload : definition;
|
|
483
|
+
|
|
484
|
+
const getAlarmDefinitionFailureAction = (
|
|
485
|
+
definition: AlarmDefinitionEntry | undefined,
|
|
486
|
+
): ProcessDueAlarmsFailureAction | undefined => {
|
|
487
|
+
if (definition === undefined || !isAlarmDefinitionConfig(definition)) {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (definition.failure === undefined) {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return definition.failure === "retry"
|
|
496
|
+
? { mode: "retry", retryFailedAfter: definition.retry?.initialDelay }
|
|
497
|
+
: { mode: definition.failure };
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
export const define = <const Definitions extends AlarmDefinitions>(definitions: Definitions) => ({
|
|
501
|
+
handlers: <R = never, E = never>(
|
|
502
|
+
handlers: DefinedAlarmHandlers<Definitions, R, E>,
|
|
503
|
+
options?: ProcessDueAlarmsOptions<R, E>,
|
|
504
|
+
) =>
|
|
505
|
+
processDue(
|
|
506
|
+
(event) =>
|
|
507
|
+
Effect.gen(function* () {
|
|
508
|
+
const definition = definitions[event.tag];
|
|
509
|
+
if (definition === undefined) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const schema = getAlarmDefinitionSchema(definition);
|
|
514
|
+
const payload = yield* (
|
|
515
|
+
S.decodeUnknownEffect(schema)(event.payload) as Effect.Effect<
|
|
516
|
+
AlarmDefinitionPayload<Definitions[keyof Definitions & string]>,
|
|
517
|
+
unknown
|
|
518
|
+
>
|
|
519
|
+
).pipe(
|
|
520
|
+
Effect.mapError(
|
|
521
|
+
(cause) =>
|
|
522
|
+
new StoredAlarmDecodeError({
|
|
523
|
+
cause,
|
|
524
|
+
storageId: getScheduledEventId(event),
|
|
525
|
+
}),
|
|
526
|
+
),
|
|
527
|
+
);
|
|
528
|
+
const handler = handlers[event.tag];
|
|
529
|
+
|
|
530
|
+
yield* handler({ ...event, payload } as never);
|
|
531
|
+
}),
|
|
532
|
+
{
|
|
533
|
+
...options,
|
|
534
|
+
onFailure: (failure) =>
|
|
535
|
+
Effect.gen(function* () {
|
|
536
|
+
const action = getAlarmDefinitionFailureAction(definitions[failure.tag]);
|
|
537
|
+
const optionAction =
|
|
538
|
+
options?.onFailure === undefined ? undefined : yield* options.onFailure(failure);
|
|
539
|
+
return action ?? optionAction;
|
|
540
|
+
}),
|
|
541
|
+
},
|
|
542
|
+
),
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* SQLite-backed Durable Object alarm scheduler.
|
|
547
|
+
*
|
|
548
|
+
* `DurableObjectAlarm` stores alarms in the current Durable Object's SQLite storage
|
|
549
|
+
* and keeps the platform alarm set to the earliest pending scheduled alarm. The
|
|
550
|
+
* platform alarm timestamp is only a wake-up hint; logical event identity comes
|
|
551
|
+
* from persisted `{ tag, id }` rows.
|
|
552
|
+
*
|
|
553
|
+
* Retry safety depends on acknowledgement ordering. `processDueAlarms` runs the
|
|
554
|
+
* caller's handler first and only then conditionally deletes or advances the
|
|
555
|
+
* same selected row. By default, handler failures are isolated: the failed row
|
|
556
|
+
* is retried after a delay, later due rows continue, and the platform alarm is
|
|
557
|
+
* reconciled once at the end. Use `mode: "ordered"` for strict workflows where
|
|
558
|
+
* one failed logical alarm should block later due alarms.
|
|
559
|
+
*
|
|
560
|
+
* Provide `DurableObjectAlarm.layer` anywhere `DurableObjectState` is available.
|
|
561
|
+
*
|
|
562
|
+
* @example Providing the service
|
|
563
|
+
* ```ts
|
|
564
|
+
* const program = Effect.gen(function* () {
|
|
565
|
+
* const durableObjectAlarm = yield* DurableObjectAlarm;
|
|
566
|
+
* const now = yield* DateTime.now;
|
|
567
|
+
*
|
|
568
|
+
* yield* durableObjectAlarm.scheduleAlarm({
|
|
569
|
+
* tag: "heartbeat",
|
|
570
|
+
* id: "heartbeat-check",
|
|
571
|
+
* runAt: DateTime.add(now, { seconds: 5 }),
|
|
572
|
+
* repeatEvery: Duration.seconds(5),
|
|
573
|
+
* payload: null,
|
|
574
|
+
* });
|
|
575
|
+
* }).pipe(Effect.provide(DurableObjectAlarm.layer));
|
|
576
|
+
* ```
|
|
577
|
+
*
|
|
578
|
+
* @example Processing due alarms
|
|
579
|
+
* ```ts
|
|
580
|
+
* const handledEvents = yield* durableObjectAlarm.processDueAlarms((event) =>
|
|
581
|
+
* Effect.gen(function* () {
|
|
582
|
+
* if (event.tag === "heartbeat") {
|
|
583
|
+
* yield* heartbeatManager.handleAlarmEvent(event);
|
|
584
|
+
* }
|
|
585
|
+
* })
|
|
586
|
+
* );
|
|
587
|
+
* ```
|
|
588
|
+
*/
|
|
589
|
+
export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, AlarmScheduler>()(
|
|
590
|
+
"effect-cf/DurableObjectAlarm",
|
|
591
|
+
) {
|
|
592
|
+
static readonly layer = Layer.effect(
|
|
593
|
+
DurableObjectAlarm,
|
|
594
|
+
Effect.gen(function* () {
|
|
595
|
+
const state = yield* DurableObjectState;
|
|
596
|
+
|
|
597
|
+
const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
|
|
598
|
+
const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
|
|
599
|
+
`SELECT run_at FROM effect_cf_scheduled_alarms ORDER BY run_at ASC, storage_id ASC LIMIT 1`,
|
|
600
|
+
);
|
|
601
|
+
const next = (yield* cursor.toArray())[0];
|
|
602
|
+
|
|
603
|
+
if (next === undefined) {
|
|
604
|
+
yield* state.storage.deleteAlarm();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
yield* state.storage.setAlarm(next.run_at);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const rescheduleFailedAlarm = Effect.fn("DurableObjectAlarm.rescheduleFailedAlarm")(
|
|
612
|
+
function* (row: AlarmRow, retryDelayMillis: number) {
|
|
613
|
+
const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMillis;
|
|
614
|
+
|
|
615
|
+
if (row.repeat_every_ms === null) {
|
|
616
|
+
const cursor = yield* state.storage.sql.exec(
|
|
617
|
+
`UPDATE effect_cf_scheduled_alarms
|
|
618
|
+
SET run_at = ?
|
|
619
|
+
WHERE storage_id = ?
|
|
620
|
+
AND run_at = ?
|
|
621
|
+
AND repeat_every_ms IS NULL
|
|
622
|
+
AND payload = ?`,
|
|
623
|
+
retryAt,
|
|
624
|
+
row.storage_id,
|
|
625
|
+
row.run_at,
|
|
626
|
+
row.payload,
|
|
627
|
+
);
|
|
628
|
+
yield* cursor.rowsWritten;
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const cursor = yield* state.storage.sql.exec(
|
|
633
|
+
`UPDATE effect_cf_scheduled_alarms
|
|
634
|
+
SET run_at = ?
|
|
635
|
+
WHERE storage_id = ?
|
|
636
|
+
AND run_at = ?
|
|
637
|
+
AND repeat_every_ms = ?
|
|
638
|
+
AND payload = ?`,
|
|
639
|
+
retryAt,
|
|
640
|
+
row.storage_id,
|
|
641
|
+
row.run_at,
|
|
642
|
+
row.repeat_every_ms,
|
|
643
|
+
row.payload,
|
|
644
|
+
);
|
|
645
|
+
yield* cursor.rowsWritten;
|
|
646
|
+
},
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
const acknowledgeAlarm = Effect.fn("DurableObjectAlarm.acknowledgeAlarm")(function* (
|
|
650
|
+
row: AlarmRow,
|
|
651
|
+
) {
|
|
652
|
+
if (row.repeat_every_ms === null) {
|
|
653
|
+
const cursor = yield* state.storage.sql.exec(
|
|
654
|
+
`DELETE FROM effect_cf_scheduled_alarms
|
|
655
|
+
WHERE storage_id = ?
|
|
656
|
+
AND run_at = ?
|
|
657
|
+
AND repeat_every_ms IS NULL
|
|
658
|
+
AND payload = ?`,
|
|
659
|
+
row.storage_id,
|
|
660
|
+
row.run_at,
|
|
661
|
+
row.payload,
|
|
662
|
+
);
|
|
663
|
+
yield* cursor.rowsWritten;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const acknowledgedAt = yield* Clock.currentTimeMillis;
|
|
668
|
+
const cursor = yield* state.storage.sql.exec(
|
|
669
|
+
`UPDATE effect_cf_scheduled_alarms
|
|
670
|
+
SET run_at = ?
|
|
671
|
+
WHERE storage_id = ?
|
|
672
|
+
AND run_at = ?
|
|
673
|
+
AND repeat_every_ms = ?
|
|
674
|
+
AND payload = ?`,
|
|
675
|
+
acknowledgedAt + row.repeat_every_ms,
|
|
676
|
+
row.storage_id,
|
|
677
|
+
row.run_at,
|
|
678
|
+
row.repeat_every_ms,
|
|
679
|
+
row.payload,
|
|
680
|
+
);
|
|
681
|
+
yield* cursor.rowsWritten;
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input: AlarmRef) {
|
|
685
|
+
const ref = yield* decodeAlarmRef(input);
|
|
686
|
+
yield* state.storage.transaction(() =>
|
|
687
|
+
Effect.gen(function* () {
|
|
688
|
+
yield* ensureTable(state);
|
|
689
|
+
yield* state.storage.sql
|
|
690
|
+
.exec(
|
|
691
|
+
`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
|
|
692
|
+
getScheduledEventId(ref),
|
|
693
|
+
)
|
|
694
|
+
.pipe(Effect.asVoid);
|
|
695
|
+
yield* reconcileAlarm();
|
|
696
|
+
}),
|
|
697
|
+
);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (
|
|
701
|
+
input: ScheduleAlarmInput,
|
|
702
|
+
) {
|
|
703
|
+
const ref = yield* decodeAlarmRef(input);
|
|
704
|
+
const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery);
|
|
705
|
+
const payload = yield* encodeStoredPayload(input.payload);
|
|
706
|
+
const runAt = DateTime.toEpochMillis(input.runAt);
|
|
707
|
+
|
|
708
|
+
yield* state.storage.transaction(() =>
|
|
709
|
+
Effect.gen(function* () {
|
|
710
|
+
yield* ensureTable(state);
|
|
711
|
+
yield* state.storage.sql
|
|
712
|
+
.exec(
|
|
713
|
+
`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
|
|
714
|
+
(storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
|
|
715
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
716
|
+
getScheduledEventId(ref),
|
|
717
|
+
ref.id,
|
|
718
|
+
ref.tag,
|
|
719
|
+
runAt,
|
|
720
|
+
repeatEveryMillis,
|
|
721
|
+
payload,
|
|
722
|
+
)
|
|
723
|
+
.pipe(Effect.asVoid);
|
|
724
|
+
yield* reconcileAlarm();
|
|
725
|
+
}),
|
|
726
|
+
);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* <
|
|
730
|
+
R,
|
|
731
|
+
E,
|
|
732
|
+
OnFailureR,
|
|
733
|
+
OnFailureE,
|
|
734
|
+
>(
|
|
735
|
+
handle: ProcessDueAlarmsHandler<R, E>,
|
|
736
|
+
options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
|
|
737
|
+
) {
|
|
738
|
+
yield* ensureTable(state);
|
|
739
|
+
const mode = options?.mode ?? "isolated";
|
|
740
|
+
const limit = yield* getProcessLimit(options);
|
|
741
|
+
const now = yield* Clock.currentTimeMillis;
|
|
742
|
+
const cursor = yield* state.storage.sql.exec<AlarmRow>(
|
|
743
|
+
`SELECT storage_id, alarm_id, tag, run_at, repeat_every_ms, payload
|
|
744
|
+
FROM effect_cf_scheduled_alarms
|
|
745
|
+
WHERE run_at <= ?
|
|
746
|
+
ORDER BY run_at ASC, storage_id ASC
|
|
747
|
+
LIMIT ?`,
|
|
748
|
+
now,
|
|
749
|
+
limit,
|
|
750
|
+
);
|
|
751
|
+
const dueRows = yield* cursor.toArray();
|
|
752
|
+
const handled: DurableObjectAlarmEvent[] = [];
|
|
753
|
+
const failed: ProcessDueAlarmsFailure[] = [];
|
|
754
|
+
|
|
755
|
+
const handleFailure = function* (
|
|
756
|
+
row: AlarmRow,
|
|
757
|
+
event: DurableObjectAlarmEvent | undefined,
|
|
758
|
+
cause: unknown,
|
|
759
|
+
) {
|
|
760
|
+
const failure: ProcessDueAlarmsFailure = {
|
|
761
|
+
cause,
|
|
762
|
+
event,
|
|
763
|
+
id: row.alarm_id,
|
|
764
|
+
storageId: row.storage_id,
|
|
765
|
+
tag: row.tag,
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
failed.push(failure);
|
|
769
|
+
const failureAction =
|
|
770
|
+
options?.onFailure === undefined ? undefined : yield* options.onFailure(failure);
|
|
771
|
+
const actionMode =
|
|
772
|
+
failureAction === undefined ? mode : getFailureActionMode(failureAction);
|
|
773
|
+
|
|
774
|
+
if (actionMode === "retry" || actionMode === "isolated") {
|
|
775
|
+
const actionRetryDelay =
|
|
776
|
+
failureAction === undefined ? undefined : getFailureActionRetryDelay(failureAction);
|
|
777
|
+
const retryDelay =
|
|
778
|
+
actionRetryDelay === undefined
|
|
779
|
+
? yield* getFailureRetryDelay(options)
|
|
780
|
+
: yield* toFailureRescheduleMillis(actionRetryDelay);
|
|
781
|
+
|
|
782
|
+
yield* rescheduleFailedAlarm(row, retryDelay);
|
|
783
|
+
return "continue" as const;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (actionMode === "skip-and-advance-repeat") {
|
|
787
|
+
yield* acknowledgeAlarm(row);
|
|
788
|
+
return "continue" as const;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
yield* reconcileAlarm();
|
|
792
|
+
return "stop" as const;
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
for (const row of dueRows) {
|
|
796
|
+
const eventExit = yield* Effect.exit(toAlarmDue(row));
|
|
797
|
+
|
|
798
|
+
if (Exit.isFailure(eventExit)) {
|
|
799
|
+
const action = yield* handleFailure(row, undefined, eventExit.cause);
|
|
800
|
+
if (action === "stop") {
|
|
801
|
+
return yield* Effect.failCause(eventExit.cause);
|
|
802
|
+
}
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const event = eventExit.value;
|
|
807
|
+
const handleExit = yield* Effect.exit(handle(event));
|
|
808
|
+
|
|
809
|
+
if (Exit.isFailure(handleExit)) {
|
|
810
|
+
const action = yield* handleFailure(row, event, handleExit.cause);
|
|
811
|
+
if (action === "stop") {
|
|
812
|
+
return yield* Effect.failCause(handleExit.cause);
|
|
813
|
+
}
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
yield* acknowledgeAlarm(row);
|
|
818
|
+
handled.push(event);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
yield* reconcileAlarm();
|
|
822
|
+
return { failed, handled };
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
return DurableObjectAlarm.of({
|
|
826
|
+
cancelAlarm,
|
|
827
|
+
processDueAlarms,
|
|
828
|
+
scheduleAlarm,
|
|
829
|
+
});
|
|
830
|
+
}).pipe(Effect.withSpan("DurableObjectAlarm.layer")),
|
|
831
|
+
);
|
|
832
|
+
}
|