effect-cf 0.50.0 → 0.52.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/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/dist/DurableObjectAlarm.d.mts +68 -15
- package/dist/DurableObjectAlarm.mjs +261 -110
- package/dist/DurableObjectContainer.d.mts +3 -1
- package/dist/DurableObjectContainer.mjs +4 -2
- package/dist/DurableObjectRpcWebSocket.d.mts +2 -2
- package/dist/DurableObjectRpcWebSocket.mjs +2 -6
- package/dist/DurableObjectStorage.d.mts +2 -9
- package/dist/DurableObjectStorage.mjs +1 -14
- package/dist/DurableObjectWebSocket.d.mts +10 -2
- package/dist/DurableObjectWebSocket.mjs +18 -2
- package/dist/RpcTracing.mjs +1 -1
- package/package.json +1 -1
- package/src/DurableObjectAlarm.ts +536 -202
- package/src/DurableObjectContainer.ts +12 -1
- package/src/DurableObjectRpcWebSocket.ts +4 -14
- package/src/DurableObjectStorage.ts +2 -59
- package/src/DurableObjectWebSocket.ts +24 -0
|
@@ -19,15 +19,79 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
|
|
|
19
19
|
alarm_id TEXT NOT NULL,
|
|
20
20
|
tag TEXT NOT NULL,
|
|
21
21
|
run_at INTEGER NOT NULL,
|
|
22
|
+
wake_at INTEGER NOT NULL DEFAULT 0,
|
|
22
23
|
repeat_every_ms INTEGER,
|
|
23
24
|
payload TEXT NOT NULL
|
|
24
25
|
);
|
|
25
|
-
CREATE
|
|
26
|
-
|
|
26
|
+
CREATE TABLE IF NOT EXISTS effect_cf_alarm_attempts (
|
|
27
|
+
storage_id TEXT PRIMARY KEY,
|
|
28
|
+
revision TEXT NOT NULL,
|
|
29
|
+
attempts INTEGER NOT NULL,
|
|
30
|
+
parked INTEGER NOT NULL,
|
|
31
|
+
retry_at INTEGER,
|
|
32
|
+
progress INTEGER NOT NULL
|
|
33
|
+
);
|
|
27
34
|
`;
|
|
28
35
|
|
|
36
|
+
const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
|
|
37
|
+
|
|
29
38
|
const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
|
|
30
|
-
|
|
39
|
+
|
|
40
|
+
export const MIN_RETRY_DELAY_MS = 1_000;
|
|
41
|
+
/** Default unchanged-attempt budget. */
|
|
42
|
+
export const UNCHANGED_ATTEMPT_BUDGET = 8;
|
|
43
|
+
/** Minimum and default parked recovery interval. */
|
|
44
|
+
export const PARKED_RETRY_DELAY_MS = 3_600_000;
|
|
45
|
+
|
|
46
|
+
/** Absolute product repeat floor. */
|
|
47
|
+
export const MIN_REPEAT_INTERVAL_MS = 1_000;
|
|
48
|
+
/** Default product schedule floor. Never use repeats to poll state. */
|
|
49
|
+
export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
|
|
50
|
+
|
|
51
|
+
export interface ScheduleConfiguration {
|
|
52
|
+
/** Retry floor, at least one second. Defaults to one second. */
|
|
53
|
+
readonly minimumRetryDelay?: Duration.Input;
|
|
54
|
+
/** Positive safe integer. Defaults to eight unchanged attempts. */
|
|
55
|
+
readonly unchangedAttemptBudget?: number;
|
|
56
|
+
/** Recovery interval, at least one hour and the retry floor. Defaults to one hour. */
|
|
57
|
+
readonly parkedRetryDelay?: Duration.Input;
|
|
58
|
+
/** Product repeat floor, at least one second. Defaults to one minute. */
|
|
59
|
+
readonly minimumRepeatInterval?: Duration.Input;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Optional policy overrides. Provide with Layer.succeed in the Durable Object's application layer. */
|
|
63
|
+
export const ScheduleConfiguration = Context.Reference<ScheduleConfiguration>(
|
|
64
|
+
"effect-cf/DurableObjectAlarm/ScheduleConfiguration",
|
|
65
|
+
{ defaultValue: () => ({}) },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const safeIntegerAtLeast = (minimum: number) =>
|
|
69
|
+
S.Int.check(S.isGreaterThanOrEqualTo(minimum), S.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
70
|
+
|
|
71
|
+
const ScheduleConfigurationSchema = S.Struct({
|
|
72
|
+
minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
|
|
73
|
+
unchangedAttemptBudget: safeIntegerAtLeast(1),
|
|
74
|
+
parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
|
|
75
|
+
minimumRepeatInterval: safeIntegerAtLeast(MIN_REPEAT_INTERVAL_MS),
|
|
76
|
+
}).check(
|
|
77
|
+
S.makeFilter((configuration) =>
|
|
78
|
+
configuration.parkedRetryDelay >= configuration.minimumRetryDelay
|
|
79
|
+
? undefined
|
|
80
|
+
: "parkedRetryDelay must be at least minimumRetryDelay",
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
type ResolvedScheduleConfiguration = typeof ScheduleConfigurationSchema.Type;
|
|
85
|
+
|
|
86
|
+
const retryDelay = (
|
|
87
|
+
attempts: number,
|
|
88
|
+
configuration: ResolvedScheduleConfiguration,
|
|
89
|
+
parked: boolean,
|
|
90
|
+
initialDelay = configuration.minimumRetryDelay,
|
|
91
|
+
) =>
|
|
92
|
+
parked
|
|
93
|
+
? configuration.parkedRetryDelay
|
|
94
|
+
: Math.min(configuration.parkedRetryDelay, initialDelay * 2 ** (attempts - 1));
|
|
31
95
|
|
|
32
96
|
const getScheduledEventId = (input: { readonly id: string; readonly tag: string }) =>
|
|
33
97
|
`effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
|
|
@@ -35,14 +99,47 @@ const getScheduledEventId = (input: { readonly id: string; readonly tag: string
|
|
|
35
99
|
export type AlarmPayload = S.Json;
|
|
36
100
|
|
|
37
101
|
interface AlarmRow extends Record<string, SqlStorageValue> {
|
|
102
|
+
readonly attempts: number;
|
|
38
103
|
readonly alarm_id: string;
|
|
39
104
|
readonly payload: string;
|
|
40
105
|
readonly repeat_every_ms: number | null;
|
|
41
106
|
readonly run_at: number;
|
|
107
|
+
readonly wake_at: number;
|
|
42
108
|
readonly storage_id: string;
|
|
43
109
|
readonly tag: string;
|
|
110
|
+
readonly revision: string;
|
|
111
|
+
readonly parked: number;
|
|
112
|
+
readonly retry_at: number | null;
|
|
113
|
+
readonly progress: number;
|
|
44
114
|
}
|
|
45
115
|
|
|
116
|
+
const alarmRowsSql = `
|
|
117
|
+
SELECT a.*, COALESCE(s.revision, '') AS revision,
|
|
118
|
+
COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
|
|
119
|
+
s.retry_at, COALESCE(s.progress, -1) AS progress
|
|
120
|
+
FROM effect_cf_scheduled_alarms a
|
|
121
|
+
LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
|
|
122
|
+
|
|
123
|
+
const sameRevisionSql = `storage_id = ? AND COALESCE(
|
|
124
|
+
(SELECT revision FROM effect_cf_alarm_attempts WHERE storage_id = ?), '') = ?`;
|
|
125
|
+
|
|
126
|
+
/** Content-free: no alarm identifiers, payload, error text or consumer state. */
|
|
127
|
+
export const AlarmParked = S.TaggedStruct("AlarmParked", {
|
|
128
|
+
attempts: S.Int,
|
|
129
|
+
retryAt: S.DateTimeUtc,
|
|
130
|
+
});
|
|
131
|
+
export type AlarmParked = typeof AlarmParked.Type;
|
|
132
|
+
|
|
133
|
+
/** Install with Layer.succeed / Effect.provideService. Reporting cannot undo a committed guard. */
|
|
134
|
+
export const AlarmReporter = Context.Reference<(event: AlarmParked) => Effect.Effect<void>>(
|
|
135
|
+
"effect-cf/DurableObjectAlarm/AlarmReporter",
|
|
136
|
+
{ defaultValue: () => (event) => Effect.logWarning(event) },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const CurrentAlarmPass = Context.Reference<
|
|
140
|
+
{ readonly row: AlarmRow; readonly parked: AlarmParked[]; active: boolean } | undefined
|
|
141
|
+
>("effect-cf/DurableObjectAlarm/CurrentAlarmPass", { defaultValue: () => undefined });
|
|
142
|
+
|
|
46
143
|
interface NextAlarmRow extends Record<string, SqlStorageValue> {
|
|
47
144
|
readonly run_at: number;
|
|
48
145
|
}
|
|
@@ -81,6 +178,37 @@ export class InvalidProcessDueAlarmsOptionsError extends Data.TaggedError(
|
|
|
81
178
|
}
|
|
82
179
|
}
|
|
83
180
|
|
|
181
|
+
export class InvalidScheduleConfigurationError extends Data.TaggedError(
|
|
182
|
+
"InvalidScheduleConfigurationError",
|
|
183
|
+
)<{
|
|
184
|
+
readonly cause: unknown;
|
|
185
|
+
}> {
|
|
186
|
+
override get message(): string {
|
|
187
|
+
return `Invalid Durable Object alarm configuration: ${ErrorMessage.causeMessage(this.cause)}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: ScheduleConfiguration) {
|
|
192
|
+
const input = { ...defaults, ...(yield* ScheduleConfiguration) };
|
|
193
|
+
|
|
194
|
+
return yield* Effect.try({
|
|
195
|
+
try: () =>
|
|
196
|
+
S.decodeUnknownSync(ScheduleConfigurationSchema)({
|
|
197
|
+
minimumRetryDelay: Math.ceil(
|
|
198
|
+
Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
|
|
199
|
+
),
|
|
200
|
+
unchangedAttemptBudget: input.unchangedAttemptBudget ?? UNCHANGED_ATTEMPT_BUDGET,
|
|
201
|
+
parkedRetryDelay: Math.ceil(
|
|
202
|
+
Duration.toMillis(input.parkedRetryDelay ?? PARKED_RETRY_DELAY_MS),
|
|
203
|
+
),
|
|
204
|
+
minimumRepeatInterval: Math.ceil(
|
|
205
|
+
Duration.toMillis(input.minimumRepeatInterval ?? DEFAULT_MIN_REPEAT_INTERVAL_MS),
|
|
206
|
+
),
|
|
207
|
+
}),
|
|
208
|
+
catch: (cause) => new InvalidScheduleConfigurationError({ cause }),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
84
212
|
export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeError")<{
|
|
85
213
|
readonly cause: unknown;
|
|
86
214
|
readonly storageId: string;
|
|
@@ -95,6 +223,7 @@ export type DurableObjectAlarmError =
|
|
|
95
223
|
| InvalidAlarmRefError
|
|
96
224
|
| InvalidProcessDueAlarmsOptionsError
|
|
97
225
|
| InvalidRepeatEveryError
|
|
226
|
+
| InvalidScheduleConfigurationError
|
|
98
227
|
| StorageOperationError
|
|
99
228
|
| StoredAlarmDecodeError;
|
|
100
229
|
|
|
@@ -124,15 +253,18 @@ const decodeAlarmRef = (input: AlarmRef) =>
|
|
|
124
253
|
Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
|
|
125
254
|
);
|
|
126
255
|
|
|
127
|
-
/**
|
|
256
|
+
/**
|
|
257
|
+
* Arm a logical deadline. External enrollment is progress; handler self-rearms consume a budget.
|
|
258
|
+
* Reusing `{tag, id}` replaces an alarm. Product repeats use the configured floor and run after completion.
|
|
259
|
+
*/
|
|
128
260
|
export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
|
|
129
261
|
readonly payload: AlarmPayload;
|
|
130
262
|
readonly repeatEvery?: Duration.Input;
|
|
131
263
|
readonly runAt: DateTime.Utc;
|
|
264
|
+
/** Optional monotonic source cursor. Only a strictly increasing cursor resets a live budget. */
|
|
265
|
+
readonly progress?: number;
|
|
132
266
|
};
|
|
133
267
|
|
|
134
|
-
export type ProcessDueAlarmsMode = "isolated" | "ordered";
|
|
135
|
-
|
|
136
268
|
export interface ProcessDueAlarmsFailure {
|
|
137
269
|
readonly cause: unknown;
|
|
138
270
|
readonly event?: DurableObjectAlarmEvent;
|
|
@@ -144,15 +276,12 @@ export interface ProcessDueAlarmsFailure {
|
|
|
144
276
|
export interface ProcessDueAlarmsResult {
|
|
145
277
|
readonly failed: readonly ProcessDueAlarmsFailure[];
|
|
146
278
|
readonly handled: readonly DurableObjectAlarmEvent[];
|
|
279
|
+
readonly parked: readonly AlarmParked[];
|
|
147
280
|
}
|
|
148
281
|
|
|
149
282
|
export type ProcessDueAlarmsFailureAction =
|
|
150
|
-
| "ordered"
|
|
151
283
|
| "retry"
|
|
152
284
|
| "skip-and-advance-repeat"
|
|
153
|
-
| {
|
|
154
|
-
readonly mode: "ordered";
|
|
155
|
-
}
|
|
156
285
|
| {
|
|
157
286
|
readonly mode: "retry";
|
|
158
287
|
readonly retryFailedAfter?: Duration.Input;
|
|
@@ -163,8 +292,6 @@ export type ProcessDueAlarmsFailureAction =
|
|
|
163
292
|
|
|
164
293
|
export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
|
|
165
294
|
readonly limit?: number;
|
|
166
|
-
/** `isolated` retries only the failed row; `ordered` stops before later rows. */
|
|
167
|
-
readonly mode?: ProcessDueAlarmsMode;
|
|
168
295
|
readonly onFailure?: (
|
|
169
296
|
failure: ProcessDueAlarmsFailure,
|
|
170
297
|
) => Effect.Effect<ProcessDueAlarmsFailureAction | void, OnFailureE, OnFailureR>;
|
|
@@ -179,7 +306,17 @@ export type ProcessDueAlarmsHandler<R = never, E = never> = (
|
|
|
179
306
|
* Alarm mutations owned by one transaction callback. Run them in the callback's
|
|
180
307
|
* fiber; forked work and use after the callback ends fail with StorageOperationError.
|
|
181
308
|
*/
|
|
182
|
-
export type AlarmTransaction = Pick<
|
|
309
|
+
export type AlarmTransaction = Pick<
|
|
310
|
+
AlarmScheduler,
|
|
311
|
+
"scheduleAlarm" | "scheduleAlarmEarlier" | "cancelAlarm"
|
|
312
|
+
>;
|
|
313
|
+
|
|
314
|
+
export interface AlarmStatus {
|
|
315
|
+
readonly attempts: number;
|
|
316
|
+
readonly parked: boolean;
|
|
317
|
+
readonly runAt: DateTime.Utc;
|
|
318
|
+
readonly retryAt: DateTime.Utc | undefined;
|
|
319
|
+
}
|
|
183
320
|
|
|
184
321
|
/** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
|
|
185
322
|
export type AlarmScheduler = {
|
|
@@ -203,7 +340,15 @@ export type AlarmScheduler = {
|
|
|
203
340
|
input: AlarmRef,
|
|
204
341
|
) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
|
|
205
342
|
|
|
206
|
-
/**
|
|
343
|
+
/** Inspect retained work, including hourly recovery deadlines. */
|
|
344
|
+
readonly getAlarmStatus: (
|
|
345
|
+
input: AlarmRef,
|
|
346
|
+
) => Effect.Effect<AlarmStatus | undefined, InvalidAlarmRefError | StorageOperationError>;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Dispatch by effective wake deadline, then storage key. Failures retry independently.
|
|
350
|
+
* Conditional acknowledgements preserve handler replacements; alarms are at-least-once.
|
|
351
|
+
*/
|
|
207
352
|
readonly processDueAlarms: <R = never, E = never, OnFailureR = never, OnFailureE = never>(
|
|
208
353
|
handle: ProcessDueAlarmsHandler<R, E>,
|
|
209
354
|
options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
|
|
@@ -221,8 +366,11 @@ export type AlarmScheduler = {
|
|
|
221
366
|
| InvalidAlarmPayloadError
|
|
222
367
|
| InvalidAlarmRefError
|
|
223
368
|
| InvalidRepeatEveryError
|
|
369
|
+
| InvalidScheduleConfigurationError
|
|
224
370
|
| StorageOperationError
|
|
225
371
|
>;
|
|
372
|
+
/** Atomically min-merge a logical deadline. Preserves an earlier alarm's payload and repeat. */
|
|
373
|
+
readonly scheduleAlarmEarlier: AlarmScheduler["scheduleAlarm"];
|
|
226
374
|
};
|
|
227
375
|
|
|
228
376
|
const StoredPayloadString = S.fromJsonString(S.Json);
|
|
@@ -237,10 +385,37 @@ const encodeStoredPayload = (payload: AlarmPayload) =>
|
|
|
237
385
|
Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
|
|
238
386
|
);
|
|
239
387
|
|
|
240
|
-
const ensureTable = (state: DurableObjectState["Service"])
|
|
241
|
-
state.storage.sql.exec(INIT_TABLE_SQL)
|
|
388
|
+
const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
|
|
389
|
+
yield* state.storage.sql.exec(INIT_TABLE_SQL);
|
|
390
|
+
const index = yield* state.storage.sql.exec(
|
|
391
|
+
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
|
|
392
|
+
WAKE_INDEX,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
if ((yield* index.toArray()).length > 0) return;
|
|
396
|
+
const columns = yield* state.storage.sql.exec<{ name: string }>(
|
|
397
|
+
"SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')",
|
|
398
|
+
);
|
|
399
|
+
const names = new Set((yield* columns.toArray()).map((column) => column.name));
|
|
242
400
|
|
|
243
|
-
|
|
401
|
+
if (!names.has("wake_at")) {
|
|
402
|
+
yield* state.storage.sql.exec(
|
|
403
|
+
"ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
// Backfill once, including retained schedules from before attempt tracking existed.
|
|
407
|
+
// The wake index is the durable migration marker and is created only after the backfill.
|
|
408
|
+
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
|
|
409
|
+
wake_at = MAX(run_at, COALESCE((SELECT retry_at FROM effect_cf_alarm_attempts s
|
|
410
|
+
WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id), run_at))`);
|
|
411
|
+
yield* state.storage.sql.exec(
|
|
412
|
+
"DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id",
|
|
413
|
+
);
|
|
414
|
+
yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
|
|
415
|
+
ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const toRepeatEveryMillis = (input: Duration.Input | undefined, minimum: number) => {
|
|
244
419
|
if (input === undefined) {
|
|
245
420
|
return Effect.succeed(null);
|
|
246
421
|
}
|
|
@@ -249,8 +424,8 @@ const toRepeatEveryMillis = (input: Duration.Input | undefined) => {
|
|
|
249
424
|
try: () => {
|
|
250
425
|
const millis = Duration.toMillis(input);
|
|
251
426
|
|
|
252
|
-
if (!Number.isFinite(millis) || millis
|
|
253
|
-
throw new Error(
|
|
427
|
+
if (!Number.isFinite(millis) || millis < minimum) {
|
|
428
|
+
throw new Error(`Alarm repeatEvery must be at least ${minimum} milliseconds and finite`);
|
|
254
429
|
}
|
|
255
430
|
|
|
256
431
|
return Math.ceil(millis);
|
|
@@ -293,7 +468,7 @@ const getProcessLimit = (options: ProcessDueAlarmsOptions<unknown, unknown> | un
|
|
|
293
468
|
return Effect.succeed(limit);
|
|
294
469
|
};
|
|
295
470
|
|
|
296
|
-
const toFailureRescheduleMillis = (input: Duration.Input) =>
|
|
471
|
+
const toFailureRescheduleMillis = (input: Duration.Input, minimum: number) =>
|
|
297
472
|
Effect.try({
|
|
298
473
|
try: () => {
|
|
299
474
|
const millis = Duration.toMillis(input);
|
|
@@ -302,14 +477,18 @@ const toFailureRescheduleMillis = (input: Duration.Input) =>
|
|
|
302
477
|
throw new Error("Alarm failure rescheduleAfter must be a positive finite duration");
|
|
303
478
|
}
|
|
304
479
|
|
|
305
|
-
return Math.ceil(millis);
|
|
480
|
+
return Math.max(minimum, Math.ceil(millis));
|
|
306
481
|
},
|
|
307
482
|
catch: (cause) => new InvalidProcessDueAlarmsOptionsError({ cause }),
|
|
308
483
|
});
|
|
309
484
|
|
|
310
|
-
const getFailureRetryDelay = (
|
|
485
|
+
const getFailureRetryDelay = (
|
|
486
|
+
options: ProcessDueAlarmsOptions<unknown, unknown> | undefined,
|
|
487
|
+
configuration: ResolvedScheduleConfiguration,
|
|
488
|
+
) =>
|
|
311
489
|
toFailureRescheduleMillis(
|
|
312
|
-
options?.retryFailedAfter ??
|
|
490
|
+
options?.retryFailedAfter ?? configuration.minimumRetryDelay,
|
|
491
|
+
configuration.minimumRetryDelay,
|
|
313
492
|
);
|
|
314
493
|
|
|
315
494
|
const getFailureActionMode = (action: ProcessDueAlarmsFailureAction) =>
|
|
@@ -330,7 +509,7 @@ export const processDue = <R = never, E = never, OnFailureR = never, OnFailureE
|
|
|
330
509
|
|
|
331
510
|
export type AlarmPayloadSchema = S.Codec<any, any, never, never>;
|
|
332
511
|
|
|
333
|
-
export type AlarmFailurePolicy = "
|
|
512
|
+
export type AlarmFailurePolicy = "retry" | "skip-and-advance-repeat";
|
|
334
513
|
|
|
335
514
|
export interface AlarmRetryPolicy {
|
|
336
515
|
readonly initialDelay?: Duration.Input;
|
|
@@ -377,6 +556,7 @@ export interface DefinedAlarmTransaction<Definitions extends AlarmDefinitions> {
|
|
|
377
556
|
readonly scheduleAlarm: (
|
|
378
557
|
input: DefinedScheduleAlarmInput<Definitions>,
|
|
379
558
|
) => ReturnType<AlarmScheduler["scheduleAlarm"]>;
|
|
559
|
+
readonly scheduleAlarmEarlier: DefinedAlarmTransaction<Definitions>["scheduleAlarm"];
|
|
380
560
|
readonly cancelAlarm: (
|
|
381
561
|
input: AlarmRef<keyof Definitions & string>,
|
|
382
562
|
) => ReturnType<AlarmScheduler["cancelAlarm"]>;
|
|
@@ -397,6 +577,9 @@ export interface DefinedAlarmScheduler<
|
|
|
397
577
|
Definitions extends AlarmDefinitions,
|
|
398
578
|
> extends DefinedAlarmTransaction<Definitions> {
|
|
399
579
|
readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
|
|
580
|
+
readonly getAlarmStatus: (
|
|
581
|
+
input: AlarmRef<keyof Definitions & string>,
|
|
582
|
+
) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
|
|
400
583
|
readonly transaction: <A, E, R>(
|
|
401
584
|
closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>,
|
|
402
585
|
) => Effect.Effect<A, E | StorageOperationError, R>;
|
|
@@ -450,47 +633,54 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
450
633
|
const definitionFor = (tag: string) =>
|
|
451
634
|
Object.hasOwn(definitions, tag) ? definitions[tag] : undefined;
|
|
452
635
|
|
|
453
|
-
const bind = (mutations: AlarmTransaction): DefinedAlarmTransaction<Definitions> =>
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
636
|
+
const bind = (mutations: AlarmTransaction): DefinedAlarmTransaction<Definitions> => {
|
|
637
|
+
const schedule = (method: "scheduleAlarm" | "scheduleAlarmEarlier") =>
|
|
638
|
+
Effect.fn("DefinedAlarms.scheduleAlarm")(function* (
|
|
639
|
+
input: DefinedScheduleAlarmInput<Definitions>,
|
|
640
|
+
) {
|
|
641
|
+
const definition = definitionFor(input.tag);
|
|
458
642
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
643
|
+
if (definition === undefined) {
|
|
644
|
+
return yield* Effect.fail(
|
|
645
|
+
new InvalidAlarmRefError({
|
|
646
|
+
cause: new Error(`Unknown alarm tag "${input.tag}"`),
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const payload = yield* S.encodeEffect(getAlarmDefinitionSchema(definition))(
|
|
651
|
+
input.payload,
|
|
652
|
+
).pipe(
|
|
653
|
+
Effect.flatMap(S.decodeUnknownEffect(S.Json)),
|
|
654
|
+
Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
|
|
464
655
|
);
|
|
465
|
-
}
|
|
466
|
-
const payload = yield* S.encodeEffect(getAlarmDefinitionSchema(definition))(
|
|
467
|
-
input.payload,
|
|
468
|
-
).pipe(
|
|
469
|
-
Effect.flatMap(S.decodeUnknownEffect(S.Json)),
|
|
470
|
-
Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
|
|
471
|
-
);
|
|
472
656
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
cancelAlarm: Effect.fn("DefinedAlarms.cancelAlarm")(function* (
|
|
476
|
-
input: AlarmRef<keyof Definitions & string>,
|
|
477
|
-
) {
|
|
478
|
-
if (definitionFor(input.tag) === undefined) {
|
|
479
|
-
return yield* Effect.fail(
|
|
480
|
-
new InvalidAlarmRefError({
|
|
481
|
-
cause: new Error(`Unknown alarm tag "${input.tag}"`),
|
|
482
|
-
}),
|
|
483
|
-
);
|
|
484
|
-
}
|
|
657
|
+
yield* mutations[method]({ ...input, payload });
|
|
658
|
+
});
|
|
485
659
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
660
|
+
return {
|
|
661
|
+
scheduleAlarm: schedule("scheduleAlarm"),
|
|
662
|
+
scheduleAlarmEarlier: schedule("scheduleAlarmEarlier"),
|
|
663
|
+
cancelAlarm: Effect.fn("DefinedAlarms.cancelAlarm")(function* (
|
|
664
|
+
input: AlarmRef<keyof Definitions & string>,
|
|
665
|
+
) {
|
|
666
|
+
if (definitionFor(input.tag) === undefined) {
|
|
667
|
+
return yield* Effect.fail(
|
|
668
|
+
new InvalidAlarmRefError({
|
|
669
|
+
cause: new Error(`Unknown alarm tag "${input.tag}"`),
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
yield* mutations.cancelAlarm(input);
|
|
675
|
+
}),
|
|
676
|
+
};
|
|
677
|
+
};
|
|
489
678
|
|
|
490
679
|
return {
|
|
491
680
|
make: (alarms: AlarmScheduler): DefinedAlarmScheduler<Definitions> => ({
|
|
492
681
|
[TypedAlarmSchedulerTypeId]: TypedAlarmSchedulerTypeId,
|
|
493
682
|
...bind(alarms),
|
|
683
|
+
getAlarmStatus: (input) => alarms.getAlarmStatus(input),
|
|
494
684
|
transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
|
|
495
685
|
}),
|
|
496
686
|
handlers: <R = never, E = never>(
|
|
@@ -580,138 +770,189 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
580
770
|
DurableObjectAlarm,
|
|
581
771
|
Effect.gen(function* () {
|
|
582
772
|
const state = yield* DurableObjectState;
|
|
773
|
+
const configurationDefaults = { ...(yield* ScheduleConfiguration) };
|
|
774
|
+
|
|
775
|
+
const readRow = Effect.fnUntraced(function* (storageId: string) {
|
|
776
|
+
const cursor = yield* state.storage.sql.exec<AlarmRow>(
|
|
777
|
+
`${alarmRowsSql} WHERE a.storage_id = ?`,
|
|
778
|
+
storageId,
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
return (yield* cursor.toArray())[0];
|
|
782
|
+
});
|
|
583
783
|
|
|
584
784
|
const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
|
|
585
785
|
const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
|
|
586
|
-
`SELECT run_at FROM effect_cf_scheduled_alarms
|
|
786
|
+
`SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
|
|
787
|
+
ORDER BY wake_at, storage_id LIMIT 1`,
|
|
587
788
|
);
|
|
588
789
|
const next = (yield* cursor.toArray())[0];
|
|
589
790
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
791
|
+
// The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
|
|
792
|
+
yield* Effect.tryPromise({
|
|
793
|
+
try: () =>
|
|
794
|
+
next === undefined
|
|
795
|
+
? state.raw.storage.deleteAlarm()
|
|
796
|
+
: state.raw.storage.setAlarm(next.run_at),
|
|
797
|
+
catch: (cause) =>
|
|
798
|
+
new StorageOperationError({
|
|
799
|
+
operation: next === undefined ? "deleteAlarm" : "setAlarm",
|
|
800
|
+
cause,
|
|
801
|
+
}),
|
|
802
|
+
});
|
|
597
803
|
});
|
|
598
804
|
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
`UPDATE effect_cf_scheduled_alarms
|
|
606
|
-
SET run_at = ?
|
|
607
|
-
WHERE storage_id = ?
|
|
608
|
-
AND run_at = ?
|
|
609
|
-
AND repeat_every_ms IS NULL
|
|
610
|
-
AND payload = ?`,
|
|
611
|
-
retryAt,
|
|
612
|
-
row.storage_id,
|
|
613
|
-
row.run_at,
|
|
614
|
-
row.payload,
|
|
615
|
-
);
|
|
616
|
-
|
|
617
|
-
yield* cursor.rowsWritten;
|
|
618
|
-
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
const cursor = yield* state.storage.sql.exec(
|
|
623
|
-
`UPDATE effect_cf_scheduled_alarms
|
|
624
|
-
SET run_at = ?
|
|
625
|
-
WHERE storage_id = ?
|
|
626
|
-
AND run_at = ?
|
|
627
|
-
AND repeat_every_ms = ?
|
|
628
|
-
AND payload = ?`,
|
|
629
|
-
retryAt,
|
|
630
|
-
row.storage_id,
|
|
631
|
-
row.run_at,
|
|
632
|
-
row.repeat_every_ms,
|
|
633
|
-
row.payload,
|
|
634
|
-
);
|
|
635
|
-
|
|
636
|
-
yield* cursor.rowsWritten;
|
|
637
|
-
},
|
|
638
|
-
);
|
|
639
|
-
|
|
640
|
-
const acknowledgeAlarm = Effect.fn("DurableObjectAlarm.acknowledgeAlarm")(function* (
|
|
641
|
-
row: AlarmRow,
|
|
805
|
+
const writeAttempts = Effect.fnUntraced(function* (
|
|
806
|
+
storageId: string,
|
|
807
|
+
attempts: number,
|
|
808
|
+
parked: number,
|
|
809
|
+
retryAt: number | null,
|
|
810
|
+
progress: number,
|
|
642
811
|
) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const acknowledgedAt = yield* Clock.currentTimeMillis;
|
|
661
|
-
const cursor = yield* state.storage.sql.exec(
|
|
812
|
+
yield* state.storage.sql.exec(
|
|
813
|
+
`INSERT OR REPLACE INTO effect_cf_alarm_attempts
|
|
814
|
+
(storage_id, revision, attempts, parked, retry_at, progress)
|
|
815
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
816
|
+
storageId,
|
|
817
|
+
crypto.randomUUID(),
|
|
818
|
+
attempts,
|
|
819
|
+
parked,
|
|
820
|
+
retryAt,
|
|
821
|
+
progress,
|
|
822
|
+
);
|
|
823
|
+
// Materialize the effective wake in the same transaction for indexed dispatch/reconciliation.
|
|
824
|
+
yield* state.storage.sql.exec(
|
|
662
825
|
`UPDATE effect_cf_scheduled_alarms
|
|
663
|
-
|
|
664
|
-
WHERE storage_id =
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
AND payload = ?`,
|
|
668
|
-
acknowledgedAt + row.repeat_every_ms,
|
|
669
|
-
row.storage_id,
|
|
670
|
-
row.run_at,
|
|
671
|
-
row.repeat_every_ms,
|
|
672
|
-
row.payload,
|
|
826
|
+
SET wake_at = MAX(run_at, COALESCE(?, run_at))
|
|
827
|
+
WHERE storage_id = ?`,
|
|
828
|
+
retryAt,
|
|
829
|
+
storageId,
|
|
673
830
|
);
|
|
674
|
-
|
|
675
|
-
yield* cursor.rowsWritten;
|
|
676
831
|
});
|
|
677
832
|
|
|
678
833
|
const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input: AlarmRef) {
|
|
679
834
|
const ref = yield* decodeAlarmRef(input);
|
|
835
|
+
const storageId = getScheduledEventId(ref);
|
|
680
836
|
|
|
681
837
|
yield* state.storage.sql.exec(
|
|
682
838
|
`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
|
|
683
|
-
|
|
839
|
+
storageId,
|
|
840
|
+
);
|
|
841
|
+
yield* state.storage.sql.exec(
|
|
842
|
+
`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
|
|
843
|
+
storageId,
|
|
684
844
|
);
|
|
685
845
|
});
|
|
686
846
|
|
|
687
847
|
const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (
|
|
688
848
|
input: ScheduleAlarmInput,
|
|
849
|
+
earlier: boolean,
|
|
850
|
+
reports: string[],
|
|
689
851
|
) {
|
|
852
|
+
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
690
853
|
const ref = yield* decodeAlarmRef(input);
|
|
691
|
-
const repeatEveryMillis = yield* toRepeatEveryMillis(
|
|
854
|
+
const repeatEveryMillis = yield* toRepeatEveryMillis(
|
|
855
|
+
input.repeatEvery,
|
|
856
|
+
configuration.minimumRepeatInterval,
|
|
857
|
+
);
|
|
692
858
|
const payload = yield* encodeStoredPayload(input.payload);
|
|
859
|
+
const storageId = getScheduledEventId(ref);
|
|
860
|
+
const existing = yield* readRow(storageId);
|
|
861
|
+
const pass = yield* CurrentAlarmPass;
|
|
862
|
+
const source = pass?.row;
|
|
863
|
+
const progress = existing?.progress ?? source?.progress ?? -1;
|
|
864
|
+
|
|
865
|
+
if (pass !== undefined && !pass.active) {
|
|
866
|
+
return yield* Effect.fail(
|
|
867
|
+
new StorageOperationError({
|
|
868
|
+
operation: "alarm.schedule",
|
|
869
|
+
cause: new Error(
|
|
870
|
+
"Alarm handlers cannot schedule detached work after their pass ends",
|
|
871
|
+
),
|
|
872
|
+
}),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (input.progress !== undefined) {
|
|
876
|
+
yield* S.decodeUnknownEffect(S.Natural)(input.progress).pipe(
|
|
877
|
+
Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
// Replayed source notices cannot refill a live lane's budget or replace newer state.
|
|
881
|
+
if (source === undefined && input.progress !== undefined && input.progress <= progress) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const progressed = input.progress !== undefined && input.progress > progress;
|
|
885
|
+
const sourceProgressed =
|
|
886
|
+
source !== undefined && storageId === source.storage_id && progress > source.progress;
|
|
887
|
+
const attempts =
|
|
888
|
+
source === undefined || progressed || sourceProgressed
|
|
889
|
+
? 0
|
|
890
|
+
: source.parked === 1 || existing?.parked === 1
|
|
891
|
+
? Math.max(source.attempts, existing?.attempts ?? 0)
|
|
892
|
+
: Math.min(
|
|
893
|
+
configuration.unchangedAttemptBudget,
|
|
894
|
+
Math.max(source.attempts + 1, existing?.attempts ?? 0),
|
|
895
|
+
);
|
|
896
|
+
const parked =
|
|
897
|
+
attempts > 0 &&
|
|
898
|
+
(attempts >= configuration.unchangedAttemptBudget ||
|
|
899
|
+
source?.parked === 1 ||
|
|
900
|
+
existing?.parked === 1)
|
|
901
|
+
? 1
|
|
902
|
+
: 0;
|
|
903
|
+
const now = yield* Clock.currentTimeMillis;
|
|
904
|
+
const retryAt =
|
|
905
|
+
attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
|
|
693
906
|
const runAt = DateTime.toEpochMillis(input.runAt);
|
|
907
|
+
const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
|
|
908
|
+
const scheduledAt = keepEarlier ? existing.run_at : runAt;
|
|
909
|
+
const scheduledPayload = keepEarlier ? existing.payload : payload;
|
|
910
|
+
const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
|
|
911
|
+
const nextProgress = Math.max(progress, input.progress ?? -1);
|
|
694
912
|
|
|
695
913
|
yield* state.storage.sql.exec(
|
|
696
914
|
`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
915
|
+
(storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
|
|
916
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
917
|
+
storageId,
|
|
700
918
|
ref.id,
|
|
701
919
|
ref.tag,
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
920
|
+
scheduledAt,
|
|
921
|
+
scheduledRepeat,
|
|
922
|
+
scheduledPayload,
|
|
705
923
|
);
|
|
924
|
+
yield* writeAttempts(storageId, attempts, parked, retryAt, nextProgress);
|
|
925
|
+
if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) {
|
|
926
|
+
reports.push(storageId);
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const reportParked = Effect.fnUntraced(function* (storageIds: readonly string[]) {
|
|
931
|
+
if (storageIds.length === 0) return;
|
|
932
|
+
const pass = yield* CurrentAlarmPass;
|
|
933
|
+
const report = yield* AlarmReporter;
|
|
934
|
+
|
|
935
|
+
for (const storageId of new Set(storageIds)) {
|
|
936
|
+
const row = yield* readRow(storageId);
|
|
937
|
+
|
|
938
|
+
// Cancellation or progress later in the same transaction removes tentative parking.
|
|
939
|
+
if (row === undefined || row.parked === 0 || row.retry_at === null) continue;
|
|
940
|
+
const event = AlarmParked.make({
|
|
941
|
+
attempts: row.attempts,
|
|
942
|
+
retryAt: DateTime.makeUnsafe(Math.max(row.run_at, row.retry_at)),
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
pass?.parked.push(event);
|
|
946
|
+
// Reporting is at-most-once per parked episode, after the native commit.
|
|
947
|
+
yield* Effect.exit(Effect.suspend(() => report(event)));
|
|
948
|
+
}
|
|
706
949
|
});
|
|
707
950
|
|
|
708
|
-
const transaction: AlarmScheduler["transaction"] = (closure)
|
|
709
|
-
|
|
951
|
+
const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
|
|
952
|
+
const reports: string[] = [];
|
|
953
|
+
const result = yield* state.storage.transaction(() =>
|
|
710
954
|
Effect.withFiber((owner) => {
|
|
711
955
|
let active = true;
|
|
712
|
-
|
|
713
|
-
// A callback-owned handle cannot escape via a returned Effect or a
|
|
714
|
-
// detached fiber and write after reconciliation or rollback.
|
|
715
956
|
const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
|
|
716
957
|
Effect.withFiber<A, E | StorageOperationError>((fiber) =>
|
|
717
958
|
active && fiber === owner
|
|
@@ -731,7 +972,9 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
731
972
|
const result = yield* Effect.suspend(() =>
|
|
732
973
|
closure({
|
|
733
974
|
cancelAlarm: (input) => requireActive(cancelAlarm(input)),
|
|
734
|
-
scheduleAlarm: (input) => requireActive(scheduleAlarm(input)),
|
|
975
|
+
scheduleAlarm: (input) => requireActive(scheduleAlarm(input, false, reports)),
|
|
976
|
+
scheduleAlarmEarlier: (input) =>
|
|
977
|
+
requireActive(scheduleAlarm(input, true, reports)),
|
|
735
978
|
}),
|
|
736
979
|
).pipe(
|
|
737
980
|
Effect.ensuring(
|
|
@@ -748,6 +991,72 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
748
991
|
}),
|
|
749
992
|
);
|
|
750
993
|
|
|
994
|
+
yield* reportParked(reports);
|
|
995
|
+
|
|
996
|
+
return result;
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const rescheduleFailedAlarm = Effect.fnUntraced(function* (
|
|
1000
|
+
row: AlarmRow,
|
|
1001
|
+
initialDelay: number,
|
|
1002
|
+
configuration: ResolvedScheduleConfiguration,
|
|
1003
|
+
reports: string[],
|
|
1004
|
+
) {
|
|
1005
|
+
const current = yield* readRow(row.storage_id);
|
|
1006
|
+
|
|
1007
|
+
// A self-rearm already charged this pass; a newer schedule must survive a stale failure.
|
|
1008
|
+
if (current === undefined || current.revision !== row.revision) return;
|
|
1009
|
+
const attempts =
|
|
1010
|
+
row.parked === 1
|
|
1011
|
+
? row.attempts
|
|
1012
|
+
: Math.min(configuration.unchangedAttemptBudget, row.attempts + 1);
|
|
1013
|
+
const parked = row.parked === 1 || attempts >= configuration.unchangedAttemptBudget ? 1 : 0;
|
|
1014
|
+
const retryAt =
|
|
1015
|
+
(yield* Clock.currentTimeMillis) +
|
|
1016
|
+
retryDelay(attempts, configuration, parked === 1, initialDelay);
|
|
1017
|
+
|
|
1018
|
+
yield* writeAttempts(row.storage_id, attempts, parked, retryAt, row.progress);
|
|
1019
|
+
if (parked === 1 && row.parked === 0) {
|
|
1020
|
+
reports.push(row.storage_id);
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const acknowledgeAlarm = Effect.fnUntraced(function* (
|
|
1025
|
+
row: AlarmRow,
|
|
1026
|
+
configuration: ResolvedScheduleConfiguration,
|
|
1027
|
+
) {
|
|
1028
|
+
if (row.repeat_every_ms === null) {
|
|
1029
|
+
const cursor = yield* state.storage.sql.exec(
|
|
1030
|
+
`DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`,
|
|
1031
|
+
row.storage_id,
|
|
1032
|
+
row.storage_id,
|
|
1033
|
+
row.revision,
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
if ((yield* cursor.rowsWritten) > 0) {
|
|
1037
|
+
yield* state.storage.sql.exec(
|
|
1038
|
+
`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
|
|
1039
|
+
row.storage_id,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1046
|
+
const cursor = yield* state.storage.sql.exec(
|
|
1047
|
+
`UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`,
|
|
1048
|
+
now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms),
|
|
1049
|
+
row.storage_id,
|
|
1050
|
+
row.storage_id,
|
|
1051
|
+
row.revision,
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
if ((yield* cursor.rowsWritten) > 0) {
|
|
1055
|
+
// A completed product occurrence is progress. Missed occurrences are never replayed.
|
|
1056
|
+
yield* writeAttempts(row.storage_id, 0, 0, null, row.progress);
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
751
1060
|
const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* <
|
|
752
1061
|
R,
|
|
753
1062
|
E,
|
|
@@ -757,24 +1066,25 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
757
1066
|
handle: ProcessDueAlarmsHandler<R, E>,
|
|
758
1067
|
options?: ProcessDueAlarmsOptions<OnFailureR, OnFailureE>,
|
|
759
1068
|
) {
|
|
1069
|
+
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
1070
|
+
|
|
760
1071
|
yield* ensureTable(state);
|
|
761
|
-
const mode = options?.mode ?? "isolated";
|
|
762
1072
|
const limit = yield* getProcessLimit(options);
|
|
1073
|
+
const initialDelay = yield* getFailureRetryDelay(options, configuration);
|
|
763
1074
|
const now = yield* Clock.currentTimeMillis;
|
|
1075
|
+
|
|
764
1076
|
const cursor = yield* state.storage.sql.exec<AlarmRow>(
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
WHERE run_at <= ?
|
|
768
|
-
ORDER BY run_at ASC, storage_id ASC
|
|
769
|
-
LIMIT ?`,
|
|
1077
|
+
`${alarmRowsSql}
|
|
1078
|
+
WHERE a.wake_at <= ? ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`,
|
|
770
1079
|
now,
|
|
771
1080
|
limit,
|
|
772
1081
|
);
|
|
773
1082
|
const dueRows = yield* cursor.toArray();
|
|
774
1083
|
const handled: DurableObjectAlarmEvent[] = [];
|
|
775
1084
|
const failed: ProcessDueAlarmsFailure[] = [];
|
|
1085
|
+
const parked: AlarmParked[] = [];
|
|
776
1086
|
|
|
777
|
-
const handleFailure = function* (
|
|
1087
|
+
const handleFailure = Effect.fnUntraced(function* (
|
|
778
1088
|
row: AlarmRow,
|
|
779
1089
|
event: DurableObjectAlarmEvent | undefined,
|
|
780
1090
|
cause: unknown,
|
|
@@ -788,72 +1098,96 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
788
1098
|
};
|
|
789
1099
|
|
|
790
1100
|
failed.push(failure);
|
|
791
|
-
const
|
|
792
|
-
options?.onFailure === undefined
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
return "continue" as const;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
if (actionMode === "skip-and-advance-repeat") {
|
|
810
|
-
yield* acknowledgeAlarm(row);
|
|
1101
|
+
const actionExit = yield* Effect.exit(
|
|
1102
|
+
options?.onFailure === undefined
|
|
1103
|
+
? Effect.void
|
|
1104
|
+
: Effect.suspend(() => options.onFailure!(failure)),
|
|
1105
|
+
);
|
|
1106
|
+
const action = Exit.isSuccess(actionExit) ? actionExit.value : undefined;
|
|
1107
|
+
const actionMode = action === undefined ? "retry" : getFailureActionMode(action);
|
|
1108
|
+
const delayInput = action === undefined ? undefined : getFailureActionRetryDelay(action);
|
|
1109
|
+
const delayExit = yield* Effect.exit(
|
|
1110
|
+
delayInput === undefined
|
|
1111
|
+
? Effect.succeed(initialDelay)
|
|
1112
|
+
: toFailureRescheduleMillis(delayInput, configuration.minimumRetryDelay),
|
|
1113
|
+
);
|
|
1114
|
+
const delay = Exit.isSuccess(delayExit) ? delayExit.value : initialDelay;
|
|
1115
|
+
const reports: string[] = [];
|
|
811
1116
|
|
|
812
|
-
|
|
1117
|
+
yield* transaction(() =>
|
|
1118
|
+
actionMode === "skip-and-advance-repeat"
|
|
1119
|
+
? acknowledgeAlarm(row, configuration)
|
|
1120
|
+
: rescheduleFailedAlarm(row, delay, configuration, reports),
|
|
1121
|
+
);
|
|
1122
|
+
yield* reportParked(reports);
|
|
1123
|
+
if (Exit.isFailure(actionExit)) {
|
|
1124
|
+
return yield* Effect.failCause(actionExit.cause);
|
|
813
1125
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
};
|
|
1126
|
+
if (Exit.isFailure(delayExit)) {
|
|
1127
|
+
return yield* Effect.failCause(delayExit.cause);
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
819
1130
|
|
|
820
1131
|
for (const row of dueRows) {
|
|
1132
|
+
const current = yield* readRow(row.storage_id);
|
|
1133
|
+
|
|
1134
|
+
if (current === undefined || current.revision !== row.revision) {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const inPass = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
|
|
1138
|
+
const pass = { row, parked, active: true };
|
|
1139
|
+
|
|
1140
|
+
return effect.pipe(
|
|
1141
|
+
Effect.provideService(CurrentAlarmPass, pass),
|
|
1142
|
+
Effect.ensuring(
|
|
1143
|
+
Effect.sync(() => {
|
|
1144
|
+
pass.active = false;
|
|
1145
|
+
}),
|
|
1146
|
+
),
|
|
1147
|
+
);
|
|
1148
|
+
};
|
|
821
1149
|
const eventExit = yield* Effect.exit(toAlarmDue(row));
|
|
822
1150
|
|
|
823
1151
|
if (Exit.isFailure(eventExit)) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
if (action === "stop") {
|
|
827
|
-
return yield* Effect.failCause(eventExit.cause);
|
|
828
|
-
}
|
|
1152
|
+
yield* inPass(handleFailure(row, undefined, eventExit.cause));
|
|
829
1153
|
continue;
|
|
830
1154
|
}
|
|
831
|
-
|
|
832
1155
|
const event = eventExit.value;
|
|
833
|
-
const handleExit = yield* Effect.exit(handle(event));
|
|
1156
|
+
const handleExit = yield* Effect.exit(inPass(Effect.suspend(() => handle(event))));
|
|
834
1157
|
|
|
835
1158
|
if (Exit.isFailure(handleExit)) {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
if (action === "stop") {
|
|
839
|
-
return yield* Effect.failCause(handleExit.cause);
|
|
840
|
-
}
|
|
1159
|
+
yield* inPass(handleFailure(row, event, handleExit.cause));
|
|
841
1160
|
continue;
|
|
842
1161
|
}
|
|
843
|
-
|
|
844
|
-
yield* acknowledgeAlarm(row);
|
|
1162
|
+
yield* transaction(() => acknowledgeAlarm(row, configuration));
|
|
845
1163
|
handled.push(event);
|
|
846
1164
|
}
|
|
1165
|
+
yield* transaction(() => Effect.void);
|
|
847
1166
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
return { failed, handled };
|
|
1167
|
+
return { failed, handled, parked };
|
|
851
1168
|
});
|
|
852
1169
|
|
|
853
1170
|
return DurableObjectAlarm.of({
|
|
854
1171
|
cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
|
|
1172
|
+
getAlarmStatus: Effect.fnUntraced(function* (input) {
|
|
1173
|
+
const ref = yield* decodeAlarmRef(input);
|
|
1174
|
+
|
|
1175
|
+
yield* ensureTable(state);
|
|
1176
|
+
const row = yield* readRow(getScheduledEventId(ref));
|
|
1177
|
+
|
|
1178
|
+
return row === undefined
|
|
1179
|
+
? undefined
|
|
1180
|
+
: {
|
|
1181
|
+
attempts: row.attempts,
|
|
1182
|
+
parked: row.parked === 1,
|
|
1183
|
+
runAt: DateTime.makeUnsafe(row.run_at),
|
|
1184
|
+
retryAt: row.retry_at === null ? undefined : DateTime.makeUnsafe(row.retry_at),
|
|
1185
|
+
};
|
|
1186
|
+
}),
|
|
855
1187
|
processDueAlarms,
|
|
856
1188
|
scheduleAlarm: (input) => transaction((alarms) => alarms.scheduleAlarm(input)),
|
|
1189
|
+
scheduleAlarmEarlier: (input) =>
|
|
1190
|
+
transaction((alarms) => alarms.scheduleAlarmEarlier(input)),
|
|
857
1191
|
transaction,
|
|
858
1192
|
});
|
|
859
1193
|
}),
|