effect-cf 0.53.0 → 0.55.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 +16 -0
- package/README.md +6 -0
- package/dist/DurableObject.d.mts +1 -0
- package/dist/DurableObject.mjs +33 -11
- package/dist/DurableObjectAlarm.d.mts +76 -9
- package/dist/DurableObjectAlarm.mjs +265 -36
- package/dist/RateLimit.d.mts +50 -0
- package/dist/RateLimit.mjs +46 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/internal/Runtime.mjs +5 -3
- package/package.json +1 -1
- package/src/DurableObject.ts +93 -30
- package/src/DurableObjectAlarm.ts +647 -60
- package/src/RateLimit.ts +110 -0
- package/src/index.ts +1 -0
- package/src/internal/Entrypoint.ts +8 -4
- package/src/internal/Runtime.ts +12 -7
|
@@ -2,12 +2,14 @@ import * as Clock from "effect/Clock";
|
|
|
2
2
|
import * as Context from "effect/Context";
|
|
3
3
|
import * as Data from "effect/Data";
|
|
4
4
|
import * as DateTime from "effect/DateTime";
|
|
5
|
+
import * as Deferred from "effect/Deferred";
|
|
5
6
|
import * as Duration from "effect/Duration";
|
|
6
7
|
import * as Effect from "effect/Effect";
|
|
7
8
|
import * as Exit from "effect/Exit";
|
|
8
9
|
import * as Layer from "effect/Layer";
|
|
9
10
|
import * as Predicate from "effect/Predicate";
|
|
10
11
|
import * as S from "effect/Schema";
|
|
12
|
+
import * as Semaphore from "effect/Semaphore";
|
|
11
13
|
|
|
12
14
|
import { DurableObjectState } from "./DurableObjectState";
|
|
13
15
|
import { type SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
|
|
@@ -18,6 +20,7 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
|
|
|
18
20
|
storage_id TEXT PRIMARY KEY,
|
|
19
21
|
alarm_id TEXT NOT NULL,
|
|
20
22
|
tag TEXT NOT NULL,
|
|
23
|
+
lifecycle TEXT NOT NULL DEFAULT 'automatic',
|
|
21
24
|
run_at INTEGER NOT NULL,
|
|
22
25
|
wake_at INTEGER NOT NULL DEFAULT 0,
|
|
23
26
|
repeat_every_ms INTEGER,
|
|
@@ -34,6 +37,7 @@ CREATE TABLE IF NOT EXISTS effect_cf_alarm_attempts (
|
|
|
34
37
|
`;
|
|
35
38
|
|
|
36
39
|
const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
|
|
40
|
+
const LIFECYCLE_INDEX = "idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id";
|
|
37
41
|
|
|
38
42
|
const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
|
|
39
43
|
|
|
@@ -42,6 +46,7 @@ export const MIN_RETRY_DELAY_MS = 1_000;
|
|
|
42
46
|
export const UNCHANGED_ATTEMPT_BUDGET = 8;
|
|
43
47
|
/** Minimum and default parked recovery interval. */
|
|
44
48
|
export const PARKED_RETRY_DELAY_MS = 3_600_000;
|
|
49
|
+
const DEFAULT_IN_FLIGHT_RECOVERY_MS = 1_000;
|
|
45
50
|
|
|
46
51
|
/** Absolute product repeat floor. */
|
|
47
52
|
export const MIN_REPEAT_INTERVAL_MS = 1_000;
|
|
@@ -49,6 +54,10 @@ export const MIN_REPEAT_INTERVAL_MS = 1_000;
|
|
|
49
54
|
export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
|
|
50
55
|
|
|
51
56
|
export interface ScheduleConfiguration {
|
|
57
|
+
/** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
|
|
58
|
+
readonly dispatchAfterEvent?: boolean;
|
|
59
|
+
/** Positive recovery deadline while dispatch or deferWakes is active. Defaults to one second. */
|
|
60
|
+
readonly inFlightRecovery?: Duration.Input;
|
|
52
61
|
/** Retry floor, at least one second. Defaults to one second. */
|
|
53
62
|
readonly minimumRetryDelay?: Duration.Input;
|
|
54
63
|
/** Positive safe integer. Defaults to eight unchanged attempts. */
|
|
@@ -68,7 +77,14 @@ export const ScheduleConfiguration = Context.Reference<ScheduleConfiguration>(
|
|
|
68
77
|
const safeIntegerAtLeast = (minimum: number) =>
|
|
69
78
|
S.Int.check(S.isGreaterThanOrEqualTo(minimum), S.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
70
79
|
|
|
80
|
+
/** Automatic alarms acknowledge/retry on handler exit; manual alarms retain consumer control. */
|
|
81
|
+
export type AlarmLifecycle = "automatic" | "manual";
|
|
82
|
+
|
|
83
|
+
const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
|
|
84
|
+
|
|
71
85
|
const ScheduleConfigurationSchema = S.Struct({
|
|
86
|
+
dispatchAfterEvent: S.Boolean,
|
|
87
|
+
inFlightRecovery: safeIntegerAtLeast(1),
|
|
72
88
|
minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
|
|
73
89
|
unchangedAttemptBudget: safeIntegerAtLeast(1),
|
|
74
90
|
parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
|
|
@@ -99,6 +115,7 @@ const getScheduledEventId = (input: { readonly id: string; readonly tag: string
|
|
|
99
115
|
export type AlarmPayload = S.Json;
|
|
100
116
|
|
|
101
117
|
interface AlarmRow extends Record<string, SqlStorageValue> {
|
|
118
|
+
readonly lifecycle: AlarmLifecycle;
|
|
102
119
|
readonly attempts: number;
|
|
103
120
|
readonly alarm_id: string;
|
|
104
121
|
readonly payload: string;
|
|
@@ -140,6 +157,18 @@ const CurrentAlarmPass = Context.Reference<
|
|
|
140
157
|
{ readonly row: AlarmRow; readonly parked: AlarmParked[]; active: boolean } | undefined
|
|
141
158
|
>("effect-cf/DurableObjectAlarm/CurrentAlarmPass", { defaultValue: () => undefined });
|
|
142
159
|
|
|
160
|
+
/** One dispatcher's view of a registration pass: its tags, shared manual permits, raw tracking. */
|
|
161
|
+
const CurrentDispatch = Context.Reference<
|
|
162
|
+
| {
|
|
163
|
+
readonly tags: readonly string[];
|
|
164
|
+
readonly exclude: boolean;
|
|
165
|
+
readonly permits: Semaphore.Semaphore;
|
|
166
|
+
readonly raw?: { processed: boolean };
|
|
167
|
+
}
|
|
168
|
+
| undefined
|
|
169
|
+
>("effect-cf/DurableObjectAlarm/CurrentDispatch", { defaultValue: () => undefined });
|
|
170
|
+
const ManualTags: unique symbol = Symbol("effect-cf/DurableObjectAlarm/ManualTags");
|
|
171
|
+
|
|
143
172
|
interface NextAlarmRow extends Record<string, SqlStorageValue> {
|
|
144
173
|
readonly run_at: number;
|
|
145
174
|
}
|
|
@@ -188,12 +217,26 @@ export class InvalidScheduleConfigurationError extends Data.TaggedError(
|
|
|
188
217
|
}
|
|
189
218
|
}
|
|
190
219
|
|
|
220
|
+
export class InvalidAlarmRegistrationError extends Data.TaggedError(
|
|
221
|
+
"InvalidAlarmRegistrationError",
|
|
222
|
+
)<{
|
|
223
|
+
readonly cause: unknown;
|
|
224
|
+
}> {
|
|
225
|
+
override get message(): string {
|
|
226
|
+
return `Invalid Durable Object alarm registration: ${ErrorMessage.causeMessage(this.cause)}`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
191
230
|
const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: ScheduleConfiguration) {
|
|
192
231
|
const input = { ...defaults, ...(yield* ScheduleConfiguration) };
|
|
193
232
|
|
|
194
233
|
return yield* Effect.try({
|
|
195
234
|
try: () =>
|
|
196
235
|
S.decodeUnknownSync(ScheduleConfigurationSchema)({
|
|
236
|
+
dispatchAfterEvent: input.dispatchAfterEvent ?? false,
|
|
237
|
+
inFlightRecovery: Math.ceil(
|
|
238
|
+
Duration.toMillis(input.inFlightRecovery ?? DEFAULT_IN_FLIGHT_RECOVERY_MS),
|
|
239
|
+
),
|
|
197
240
|
minimumRetryDelay: Math.ceil(
|
|
198
241
|
Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
|
|
199
242
|
),
|
|
@@ -219,6 +262,7 @@ export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeE
|
|
|
219
262
|
}
|
|
220
263
|
|
|
221
264
|
export type DurableObjectAlarmError =
|
|
265
|
+
| InvalidAlarmRegistrationError
|
|
222
266
|
| InvalidAlarmPayloadError
|
|
223
267
|
| InvalidAlarmRefError
|
|
224
268
|
| InvalidProcessDueAlarmsOptionsError
|
|
@@ -258,6 +302,8 @@ const decodeAlarmRef = (input: AlarmRef) =>
|
|
|
258
302
|
* Reusing `{tag, id}` replaces an alarm. Product repeats use the configured floor and run after completion.
|
|
259
303
|
*/
|
|
260
304
|
export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
|
|
305
|
+
/** Prefer selecting the lifecycle in a typed Tag definition. Defaults to automatic. */
|
|
306
|
+
readonly lifecycle?: AlarmLifecycle;
|
|
261
307
|
readonly payload: AlarmPayload;
|
|
262
308
|
readonly repeatEvery?: Duration.Input;
|
|
263
309
|
readonly runAt: DateTime.Utc;
|
|
@@ -291,6 +337,7 @@ export type ProcessDueAlarmsFailureAction =
|
|
|
291
337
|
};
|
|
292
338
|
|
|
293
339
|
export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
|
|
340
|
+
readonly [ManualTags]?: ReadonlySet<string>;
|
|
294
341
|
readonly limit?: number;
|
|
295
342
|
readonly onFailure?: (
|
|
296
343
|
failure: ProcessDueAlarmsFailure,
|
|
@@ -318,8 +365,39 @@ export interface AlarmStatus {
|
|
|
318
365
|
readonly retryAt: DateTime.Utc | undefined;
|
|
319
366
|
}
|
|
320
367
|
|
|
368
|
+
/** @internal */
|
|
369
|
+
export const RunAlarm: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RunAlarm");
|
|
370
|
+
/** @internal */
|
|
371
|
+
export const DispatchAfterEvent: unique symbol = Symbol(
|
|
372
|
+
"effect-cf/DurableObjectAlarm/DispatchAfterEvent",
|
|
373
|
+
);
|
|
374
|
+
/** @internal */
|
|
375
|
+
export const HasDueAlarms: unique symbol = Symbol("effect-cf/DurableObjectAlarm/HasDueAlarms");
|
|
376
|
+
|
|
321
377
|
/** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
|
|
322
378
|
export type AlarmScheduler = {
|
|
379
|
+
/** @internal DurableObject.make shares this scoped event across native and post-event dispatch. */
|
|
380
|
+
readonly [RunAlarm]: <R>(
|
|
381
|
+
event: (
|
|
382
|
+
beginDispatch: Effect.Effect<void, InvalidScheduleConfigurationError>,
|
|
383
|
+
) => Effect.Effect<void, unknown, R>,
|
|
384
|
+
native: boolean,
|
|
385
|
+
) => Effect.Effect<void, unknown, R>;
|
|
386
|
+
/** @internal Read the current event's opt-in without acquiring another event layer. */
|
|
387
|
+
readonly [DispatchAfterEvent]: Effect.Effect<boolean>;
|
|
388
|
+
/** @internal Check inside the alarm event so its clock and configuration apply. */
|
|
389
|
+
readonly [HasDueAlarms]: Effect.Effect<boolean, StorageOperationError>;
|
|
390
|
+
/**
|
|
391
|
+
* Keep a native recovery alarm armed while coalescing checkpoint changes across this
|
|
392
|
+
* object's concurrent and nested scopes. Source/checkpoint transactions still commit;
|
|
393
|
+
* the last scope reconciles their current deadlines on success, failure or interruption.
|
|
394
|
+
* Run outside transactions, around bounded maintenance or inline work. The first scope's
|
|
395
|
+
* inFlightRecovery bounds the shared guard; renew it if it expires and is consumed while
|
|
396
|
+
* scopes remain active. A process loss before exit recovers at that retained alarm.
|
|
397
|
+
*/
|
|
398
|
+
readonly deferWakes: <A, E, R>(
|
|
399
|
+
body: Effect.Effect<A, E, R>,
|
|
400
|
+
) => Effect.Effect<A, E | InvalidScheduleConfigurationError | StorageOperationError, R>;
|
|
323
401
|
/**
|
|
324
402
|
* Commits local application storage and logical alarms in one native SQLite
|
|
325
403
|
* Durable Object transaction, reconciling the native alarm before commit.
|
|
@@ -374,6 +452,7 @@ export type AlarmScheduler = {
|
|
|
374
452
|
};
|
|
375
453
|
|
|
376
454
|
const StoredPayloadString = S.fromJsonString(S.Json);
|
|
455
|
+
const StoredTags = S.fromJsonString(S.Array(S.String));
|
|
377
456
|
|
|
378
457
|
const decodeStoredPayload = (row: AlarmRow) =>
|
|
379
458
|
S.decodeUnknownEffect(StoredPayloadString)(row.payload).pipe(
|
|
@@ -389,7 +468,7 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
|
|
|
389
468
|
yield* state.storage.sql.exec(INIT_TABLE_SQL);
|
|
390
469
|
const index = yield* state.storage.sql.exec(
|
|
391
470
|
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
|
|
392
|
-
|
|
471
|
+
LIFECYCLE_INDEX,
|
|
393
472
|
);
|
|
394
473
|
|
|
395
474
|
if ((yield* index.toArray()).length > 0) return;
|
|
@@ -398,6 +477,11 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
|
|
|
398
477
|
);
|
|
399
478
|
const names = new Set((yield* columns.toArray()).map((column) => column.name));
|
|
400
479
|
|
|
480
|
+
if (!names.has("lifecycle")) {
|
|
481
|
+
yield* state.storage.sql.exec(
|
|
482
|
+
"ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'automatic'",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
401
485
|
if (!names.has("wake_at")) {
|
|
402
486
|
yield* state.storage.sql.exec(
|
|
403
487
|
"ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
|
|
@@ -413,6 +497,8 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
|
|
|
413
497
|
);
|
|
414
498
|
yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
|
|
415
499
|
ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
|
|
500
|
+
yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${LIFECYCLE_INDEX}
|
|
501
|
+
ON effect_cf_scheduled_alarms (lifecycle, wake_at, storage_id)`);
|
|
416
502
|
});
|
|
417
503
|
|
|
418
504
|
const toRepeatEveryMillis = (input: Duration.Input | undefined, minimum: number) => {
|
|
@@ -515,11 +601,20 @@ export interface AlarmRetryPolicy {
|
|
|
515
601
|
readonly initialDelay?: Duration.Input;
|
|
516
602
|
}
|
|
517
603
|
|
|
518
|
-
export
|
|
519
|
-
readonly failure?: AlarmFailurePolicy;
|
|
604
|
+
export type AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> = {
|
|
520
605
|
readonly payload: Payload;
|
|
521
|
-
|
|
522
|
-
|
|
606
|
+
} & (
|
|
607
|
+
| {
|
|
608
|
+
readonly lifecycle?: "automatic";
|
|
609
|
+
readonly failure?: AlarmFailurePolicy;
|
|
610
|
+
readonly retry?: AlarmRetryPolicy;
|
|
611
|
+
}
|
|
612
|
+
| {
|
|
613
|
+
readonly lifecycle: "manual";
|
|
614
|
+
readonly failure?: never;
|
|
615
|
+
readonly retry?: never;
|
|
616
|
+
}
|
|
617
|
+
);
|
|
523
618
|
|
|
524
619
|
export type AlarmDefinitionEntry = AlarmDefinitionConfig | AlarmPayloadSchema;
|
|
525
620
|
|
|
@@ -547,9 +642,14 @@ export type DefinedAlarmHandlers<Definitions extends AlarmDefinitions, R = never
|
|
|
547
642
|
|
|
548
643
|
/** A discriminated union keeps each tag paired with its decoded payload type. */
|
|
549
644
|
export type DefinedScheduleAlarmInput<Definitions extends AlarmDefinitions> = {
|
|
550
|
-
readonly [Tag in keyof Definitions & string]: Omit<
|
|
645
|
+
readonly [Tag in keyof Definitions & string]: Omit<
|
|
646
|
+
ScheduleAlarmInput<Tag>,
|
|
647
|
+
"payload" | "lifecycle" | "repeatEvery" | "progress"
|
|
648
|
+
> & {
|
|
551
649
|
readonly payload: AlarmDefinitionPayload<Definitions[Tag]>;
|
|
552
|
-
}
|
|
650
|
+
} & (Definitions[Tag] extends { readonly lifecycle: "manual" }
|
|
651
|
+
? { readonly repeatEvery?: never; readonly progress?: never }
|
|
652
|
+
: Pick<ScheduleAlarmInput<Tag>, "repeatEvery" | "progress">);
|
|
553
653
|
}[keyof Definitions & string];
|
|
554
654
|
|
|
555
655
|
export interface DefinedAlarmTransaction<Definitions extends AlarmDefinitions> {
|
|
@@ -577,6 +677,7 @@ export interface DefinedAlarmScheduler<
|
|
|
577
677
|
Definitions extends AlarmDefinitions,
|
|
578
678
|
> extends DefinedAlarmTransaction<Definitions> {
|
|
579
679
|
readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
|
|
680
|
+
readonly deferWakes: AlarmScheduler["deferWakes"];
|
|
580
681
|
readonly getAlarmStatus: (
|
|
581
682
|
input: AlarmRef<keyof Definitions & string>,
|
|
582
683
|
) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
|
|
@@ -587,12 +688,175 @@ export interface DefinedAlarmScheduler<
|
|
|
587
688
|
|
|
588
689
|
/** Registers a dispatcher and the service authorized to schedule its alarms. */
|
|
589
690
|
export interface AlarmRegistration<Self, R = never, E = never> {
|
|
590
|
-
readonly layer: Layer.Layer<Self,
|
|
691
|
+
readonly layer: Layer.Layer<Self, InvalidAlarmRegistrationError, DurableObjectAlarm>;
|
|
591
692
|
readonly run: Effect.Effect<
|
|
592
693
|
ProcessDueAlarmsResult,
|
|
593
694
|
E | DurableObjectAlarmError,
|
|
594
695
|
R | DurableObjectAlarm
|
|
595
696
|
>;
|
|
697
|
+
readonly [RegistrationParts]?: RegistrationParts<R, E>;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const RegistrationParts: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RegistrationParts");
|
|
701
|
+
|
|
702
|
+
interface AlarmDispatcher<R, E> {
|
|
703
|
+
readonly tags: readonly string[];
|
|
704
|
+
readonly automatic: boolean;
|
|
705
|
+
readonly run: AlarmRegistration<never, R, E>["run"];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
interface RegistrationParts<R, E> {
|
|
709
|
+
readonly services: readonly string[];
|
|
710
|
+
readonly dispatchers: readonly AlarmDispatcher<R, E>[];
|
|
711
|
+
readonly invalid?: string;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const emptyResult: ProcessDueAlarmsResult = { failed: [], handled: [], parked: [] };
|
|
715
|
+
|
|
716
|
+
const missingAlarm = (tag: string) =>
|
|
717
|
+
new InvalidAlarmRegistrationError({
|
|
718
|
+
cause: new Error(`No manual handler registered for alarm tag "${tag}"`),
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const validateRegistration = <R, E>(parts: RegistrationParts<R, E>) =>
|
|
722
|
+
Effect.try({
|
|
723
|
+
try: () => {
|
|
724
|
+
if (parts.invalid !== undefined) throw new Error(parts.invalid);
|
|
725
|
+
const tags = parts.dispatchers.flatMap((dispatcher) => dispatcher.tags);
|
|
726
|
+
|
|
727
|
+
S.decodeUnknownSync(S.Array(S.NonEmptyString))([...parts.services, ...tags]);
|
|
728
|
+
if (
|
|
729
|
+
new Set(parts.services).size !== parts.services.length ||
|
|
730
|
+
new Set(tags).size !== tags.length
|
|
731
|
+
) {
|
|
732
|
+
throw new Error(
|
|
733
|
+
"Alarm service keys and definition tags must be unique within a Durable Object",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
catch: (cause) => new InvalidAlarmRegistrationError({ cause }),
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
const runRegistrations = <R, E>(
|
|
741
|
+
parts: RegistrationParts<R, E>,
|
|
742
|
+
rawAlarm?: Effect.Effect<void, E, R>,
|
|
743
|
+
): AlarmRegistration<never, R, E>["run"] =>
|
|
744
|
+
Effect.gen(function* () {
|
|
745
|
+
yield* validateRegistration(parts);
|
|
746
|
+
const alarms = yield* DurableObjectAlarm;
|
|
747
|
+
const permits = yield* Semaphore.make(4);
|
|
748
|
+
const parentRaw = (yield* CurrentDispatch)?.raw;
|
|
749
|
+
const recoveryScope = {
|
|
750
|
+
tags: parts.dispatchers.flatMap((dispatcher) => dispatcher.tags),
|
|
751
|
+
exclude: true,
|
|
752
|
+
permits,
|
|
753
|
+
raw: parentRaw,
|
|
754
|
+
};
|
|
755
|
+
const recovery =
|
|
756
|
+
rawAlarm === undefined
|
|
757
|
+
? makeDefinition({})
|
|
758
|
+
.handlers({})
|
|
759
|
+
.pipe(Effect.provideService(CurrentDispatch, recoveryScope))
|
|
760
|
+
: Effect.suspend(() => {
|
|
761
|
+
const raw = { processed: false };
|
|
762
|
+
|
|
763
|
+
return rawAlarm.pipe(
|
|
764
|
+
Effect.onExit(() =>
|
|
765
|
+
raw.processed ? Effect.void : makeDefinition({}).handlers({}).pipe(Effect.asVoid),
|
|
766
|
+
),
|
|
767
|
+
Effect.provideService(CurrentDispatch, { ...recoveryScope, raw }),
|
|
768
|
+
Effect.as(emptyResult),
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
return yield* alarms.deferWakes(
|
|
773
|
+
Effect.gen(function* () {
|
|
774
|
+
const run = (dispatchers: RegistrationParts<R, E>["dispatchers"]) =>
|
|
775
|
+
Effect.forEach(
|
|
776
|
+
dispatchers,
|
|
777
|
+
(dispatcher) =>
|
|
778
|
+
dispatcher.run.pipe(
|
|
779
|
+
Effect.catchTag("InvalidProcessDueAlarmsOptionsError", (error) =>
|
|
780
|
+
Effect.fail(error).pipe(
|
|
781
|
+
Effect.onExit(() => makeDefinition({}).handlers({}).pipe(Effect.asVoid)),
|
|
782
|
+
),
|
|
783
|
+
),
|
|
784
|
+
Effect.provideService(CurrentDispatch, {
|
|
785
|
+
tags: dispatcher.tags,
|
|
786
|
+
exclude: false,
|
|
787
|
+
permits,
|
|
788
|
+
raw: parentRaw,
|
|
789
|
+
}),
|
|
790
|
+
Effect.exit,
|
|
791
|
+
),
|
|
792
|
+
{ concurrency: "unbounded" },
|
|
793
|
+
);
|
|
794
|
+
// Raw hooks wait only for automatic dispatchers; manual-only work stays independent.
|
|
795
|
+
const automatic = parts.dispatchers.filter(
|
|
796
|
+
(dispatcher) => rawAlarm !== undefined && dispatcher.automatic,
|
|
797
|
+
);
|
|
798
|
+
const independent = parts.dispatchers.filter(
|
|
799
|
+
(dispatcher) => rawAlarm === undefined || !dispatcher.automatic,
|
|
800
|
+
);
|
|
801
|
+
const exits = (yield* Effect.all(
|
|
802
|
+
[
|
|
803
|
+
run(independent),
|
|
804
|
+
run(automatic).pipe(
|
|
805
|
+
Effect.flatMap((exits) =>
|
|
806
|
+
Effect.map(Effect.exit(recovery), (exit) => [...exits, exit]),
|
|
807
|
+
),
|
|
808
|
+
),
|
|
809
|
+
],
|
|
810
|
+
{ concurrency: "unbounded" },
|
|
811
|
+
)).flat();
|
|
812
|
+
|
|
813
|
+
yield* Exit.asVoidAll(exits);
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
failed: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.failed : [])),
|
|
817
|
+
handled: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.handled : [])),
|
|
818
|
+
parked: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.parked : [])),
|
|
819
|
+
};
|
|
820
|
+
}),
|
|
821
|
+
);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const makeRegistration = <Self, R, E>(
|
|
825
|
+
layer: AlarmRegistration<Self>["layer"],
|
|
826
|
+
parts: RegistrationParts<R, E>,
|
|
827
|
+
): AlarmRegistration<Self, R, E> => ({
|
|
828
|
+
[RegistrationParts]: parts,
|
|
829
|
+
layer: layer.pipe(Layer.provide(Layer.effectDiscard(validateRegistration(parts)))),
|
|
830
|
+
run: runRegistrations(parts),
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
/** Compose independent typed alarm registrations under one native alarm owner. */
|
|
834
|
+
export function mergeAll<
|
|
835
|
+
const Registrations extends readonly [
|
|
836
|
+
AlarmRegistration<never, unknown, unknown>,
|
|
837
|
+
...AlarmRegistration<never, unknown, unknown>[],
|
|
838
|
+
],
|
|
839
|
+
>(
|
|
840
|
+
...registrations: Registrations
|
|
841
|
+
): AlarmRegistration<
|
|
842
|
+
Layer.Success<Registrations[number]["layer"]>,
|
|
843
|
+
Exclude<Effect.Services<Registrations[number]["run"]>, DurableObjectAlarm>,
|
|
844
|
+
Exclude<Effect.Error<Registrations[number]["run"]>, DurableObjectAlarmError>
|
|
845
|
+
>;
|
|
846
|
+
export function mergeAll(
|
|
847
|
+
registration: AlarmRegistration<never, unknown, unknown>,
|
|
848
|
+
...others: readonly AlarmRegistration<never, unknown, unknown>[]
|
|
849
|
+
): AlarmRegistration<never, unknown, unknown> {
|
|
850
|
+
const registrations = [registration, ...others];
|
|
851
|
+
const parts = registrations.map((item) => item[RegistrationParts]);
|
|
852
|
+
|
|
853
|
+
return makeRegistration(Layer.mergeAll(registration.layer, ...others.map((item) => item.layer)), {
|
|
854
|
+
services: parts.flatMap((part) => part?.services ?? []),
|
|
855
|
+
dispatchers: parts.flatMap((part) => part?.dispatchers ?? []),
|
|
856
|
+
invalid: parts.some((part) => part === undefined)
|
|
857
|
+
? "mergeAll requires registrations returned by Tag.handlers"
|
|
858
|
+
: parts.find((part) => part?.invalid !== undefined)?.invalid,
|
|
859
|
+
});
|
|
596
860
|
}
|
|
597
861
|
|
|
598
862
|
export interface AlarmTagClass<
|
|
@@ -654,7 +918,11 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
654
918
|
Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
|
|
655
919
|
);
|
|
656
920
|
|
|
657
|
-
yield* mutations[method]({
|
|
921
|
+
yield* mutations[method]({
|
|
922
|
+
...input,
|
|
923
|
+
payload,
|
|
924
|
+
lifecycle: isAlarmDefinitionConfig(definition) ? definition.lifecycle : undefined,
|
|
925
|
+
});
|
|
658
926
|
});
|
|
659
927
|
|
|
660
928
|
return {
|
|
@@ -680,6 +948,7 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
680
948
|
make: (alarms: AlarmScheduler): DefinedAlarmScheduler<Definitions> => ({
|
|
681
949
|
[TypedAlarmSchedulerTypeId]: TypedAlarmSchedulerTypeId,
|
|
682
950
|
...bind(alarms),
|
|
951
|
+
deferWakes: alarms.deferWakes,
|
|
683
952
|
getAlarmStatus: (input) => alarms.getAlarmStatus(input),
|
|
684
953
|
transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
|
|
685
954
|
}),
|
|
@@ -724,6 +993,14 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
724
993
|
}),
|
|
725
994
|
{
|
|
726
995
|
...options,
|
|
996
|
+
[ManualTags]: new Set(
|
|
997
|
+
Object.entries(definitions)
|
|
998
|
+
.filter(
|
|
999
|
+
([, definition]) =>
|
|
1000
|
+
isAlarmDefinitionConfig(definition) && definition.lifecycle === "manual",
|
|
1001
|
+
)
|
|
1002
|
+
.map(([tag]) => tag),
|
|
1003
|
+
),
|
|
727
1004
|
onFailure: (failure) =>
|
|
728
1005
|
Effect.gen(function* () {
|
|
729
1006
|
const action = getAlarmDefinitionFailureAction(definitionFor(failure.tag));
|
|
@@ -742,6 +1019,46 @@ export const define = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
742
1019
|
handlers: makeDefinition(definitions).handlers,
|
|
743
1020
|
});
|
|
744
1021
|
|
|
1022
|
+
const hasSchedulerTables = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
|
|
1023
|
+
return yield* Effect.gen(function* () {
|
|
1024
|
+
if (state.raw.storage.sql === undefined) return false;
|
|
1025
|
+
// Inspect without creating tables: raw-only SQLite and KV objects retain native ownership.
|
|
1026
|
+
const tables = yield* state.storage.sql.exec(
|
|
1027
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
|
1028
|
+
"effect_cf_scheduled_alarms",
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
return (yield* tables.toArray()).length > 0;
|
|
1032
|
+
}).pipe(
|
|
1033
|
+
Effect.catchIf(
|
|
1034
|
+
(error) =>
|
|
1035
|
+
error.cause instanceof Error &&
|
|
1036
|
+
(error.cause.message.startsWith("SQL is not enabled for this Durable Object class") ||
|
|
1037
|
+
error.cause.message.startsWith("This Durable Object is not backed by SQLite storage")),
|
|
1038
|
+
() => Effect.succeed(false),
|
|
1039
|
+
),
|
|
1040
|
+
);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
/** @internal Preserve custom dispatch while recovering retained alarms after deployments. */
|
|
1044
|
+
export const dispatchRawAlarm = <Self, E, R>(
|
|
1045
|
+
rawAlarm: Effect.Effect<void, E, R>,
|
|
1046
|
+
registration?: AlarmRegistration<Self, R, E>,
|
|
1047
|
+
): Effect.Effect<void, E | DurableObjectAlarmError, R | DurableObjectAlarm | DurableObjectState> =>
|
|
1048
|
+
Effect.gen(function* () {
|
|
1049
|
+
const parts = registration?.[RegistrationParts];
|
|
1050
|
+
|
|
1051
|
+
if (parts !== undefined) return yield* runRegistrations(parts, rawAlarm).pipe(Effect.asVoid);
|
|
1052
|
+
const custom =
|
|
1053
|
+
registration === undefined ? rawAlarm : registration.run.pipe(Effect.andThen(rawAlarm));
|
|
1054
|
+
const state = yield* DurableObjectState;
|
|
1055
|
+
const hasScheduler = yield* hasSchedulerTables(state);
|
|
1056
|
+
|
|
1057
|
+
if (!hasScheduler) return yield* custom;
|
|
1058
|
+
|
|
1059
|
+
return yield* runRegistrations({ services: [], dispatchers: [] }, custom).pipe(Effect.asVoid);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
745
1062
|
/** The typed scheduler becomes available when its handlers are registered on a Durable Object. */
|
|
746
1063
|
export const Tag =
|
|
747
1064
|
<Self>() =>
|
|
@@ -756,10 +1073,19 @@ export const Tag =
|
|
|
756
1073
|
handlers: <R = never, E = never>(
|
|
757
1074
|
handlers: DefinedAlarmHandlers<Definitions, R, E>,
|
|
758
1075
|
options?: ProcessDueAlarmsOptions<R, E>,
|
|
759
|
-
): AlarmRegistration<Self, R, E> =>
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1076
|
+
): AlarmRegistration<Self, R, E> =>
|
|
1077
|
+
makeRegistration(Layer.effect(tag, Effect.map(DurableObjectAlarm, definition.make)), {
|
|
1078
|
+
services: [id],
|
|
1079
|
+
dispatchers: [
|
|
1080
|
+
{
|
|
1081
|
+
tags: Object.keys(definitions),
|
|
1082
|
+
automatic: Object.values(definitions).some(
|
|
1083
|
+
(entry) => !isAlarmDefinitionConfig(entry) || entry.lifecycle !== "manual",
|
|
1084
|
+
),
|
|
1085
|
+
run: definition.handlers(handlers, options),
|
|
1086
|
+
},
|
|
1087
|
+
],
|
|
1088
|
+
}),
|
|
763
1089
|
});
|
|
764
1090
|
};
|
|
765
1091
|
|
|
@@ -771,6 +1097,13 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
771
1097
|
Effect.gen(function* () {
|
|
772
1098
|
const state = yield* DurableObjectState;
|
|
773
1099
|
const configurationDefaults = { ...(yield* ScheduleConfiguration) };
|
|
1100
|
+
let wakeDeferrals = 0;
|
|
1101
|
+
let wakesReady: Deferred.Deferred<void> | undefined;
|
|
1102
|
+
let pendingAlarm: Deferred.Deferred<void, unknown> | undefined;
|
|
1103
|
+
let dispatchActive = false;
|
|
1104
|
+
let dispatchNeedsReconcile = false;
|
|
1105
|
+
let deferredRecoveryDelay = PARKED_RETRY_DELAY_MS;
|
|
1106
|
+
let deferredRecoveryAt = 0;
|
|
774
1107
|
|
|
775
1108
|
const readRow = Effect.fnUntraced(function* (storageId: string) {
|
|
776
1109
|
const cursor = yield* state.storage.sql.exec<AlarmRow>(
|
|
@@ -781,25 +1114,43 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
781
1114
|
return (yield* cursor.toArray())[0];
|
|
782
1115
|
});
|
|
783
1116
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
ORDER BY wake_at, storage_id LIMIT 1`,
|
|
788
|
-
);
|
|
789
|
-
const next = (yield* cursor.toArray())[0];
|
|
790
|
-
|
|
791
|
-
// The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
|
|
792
|
-
yield* Effect.tryPromise({
|
|
1117
|
+
// The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
|
|
1118
|
+
const writeNativeAlarm = (runAt: number | null) =>
|
|
1119
|
+
Effect.tryPromise({
|
|
793
1120
|
try: () =>
|
|
794
|
-
|
|
795
|
-
? state.raw.storage.deleteAlarm()
|
|
796
|
-
: state.raw.storage.setAlarm(next.run_at),
|
|
1121
|
+
runAt === null ? state.raw.storage.deleteAlarm() : state.raw.storage.setAlarm(runAt),
|
|
797
1122
|
catch: (cause) =>
|
|
798
1123
|
new StorageOperationError({
|
|
799
|
-
operation:
|
|
1124
|
+
operation: runAt === null ? "deleteAlarm" : "setAlarm",
|
|
800
1125
|
cause,
|
|
801
1126
|
}),
|
|
802
1127
|
});
|
|
1128
|
+
|
|
1129
|
+
const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
|
|
1130
|
+
if (wakeDeferrals > 0 || dispatchActive) {
|
|
1131
|
+
if (dispatchActive) dispatchNeedsReconcile = true;
|
|
1132
|
+
const current = yield* state.storage.getAlarm();
|
|
1133
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1134
|
+
|
|
1135
|
+
if (deferredRecoveryAt <= now) {
|
|
1136
|
+
deferredRecoveryAt = now + deferredRecoveryDelay;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Commit the guard before user work. Later transactions keep it even when
|
|
1140
|
+
// logical deadlines move earlier or every checkpoint is cancelled.
|
|
1141
|
+
if (current === null || current <= now || current > deferredRecoveryAt) {
|
|
1142
|
+
yield* writeNativeAlarm(deferredRecoveryAt);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
|
|
1148
|
+
`SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
|
|
1149
|
+
ORDER BY wake_at, storage_id LIMIT 1`,
|
|
1150
|
+
);
|
|
1151
|
+
const next = (yield* cursor.toArray())[0];
|
|
1152
|
+
|
|
1153
|
+
yield* writeNativeAlarm(next?.run_at ?? null);
|
|
803
1154
|
});
|
|
804
1155
|
|
|
805
1156
|
const writeAttempts = Effect.fnUntraced(function* (
|
|
@@ -851,6 +1202,20 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
851
1202
|
) {
|
|
852
1203
|
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
853
1204
|
const ref = yield* decodeAlarmRef(input);
|
|
1205
|
+
const lifecycle = yield* S.decodeUnknownEffect(AlarmLifecycleSchema)(
|
|
1206
|
+
input.lifecycle ?? "automatic",
|
|
1207
|
+
).pipe(Effect.mapError((cause) => new InvalidAlarmRefError({ cause })));
|
|
1208
|
+
|
|
1209
|
+
if (
|
|
1210
|
+
lifecycle === "manual" &&
|
|
1211
|
+
(input.repeatEvery !== undefined || input.progress !== undefined)
|
|
1212
|
+
) {
|
|
1213
|
+
return yield* Effect.fail(
|
|
1214
|
+
new InvalidAlarmRefError({
|
|
1215
|
+
cause: new Error("Manual alarms own their repeats and progress budgets"),
|
|
1216
|
+
}),
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
854
1219
|
const repeatEveryMillis = yield* toRepeatEveryMillis(
|
|
855
1220
|
input.repeatEvery,
|
|
856
1221
|
configuration.minimumRepeatInterval,
|
|
@@ -859,19 +1224,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
859
1224
|
const storageId = getScheduledEventId(ref);
|
|
860
1225
|
const existing = yield* readRow(storageId);
|
|
861
1226
|
const pass = yield* CurrentAlarmPass;
|
|
862
|
-
const source =
|
|
1227
|
+
const source =
|
|
1228
|
+
lifecycle === "automatic" && pass?.row.lifecycle === "automatic" ? pass.row : undefined;
|
|
863
1229
|
const progress = existing?.progress ?? source?.progress ?? -1;
|
|
864
1230
|
|
|
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
1231
|
if (input.progress !== undefined) {
|
|
876
1232
|
yield* S.decodeUnknownEffect(S.Natural)(input.progress).pipe(
|
|
877
1233
|
Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
|
|
@@ -907,16 +1263,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
907
1263
|
const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
|
|
908
1264
|
const scheduledAt = keepEarlier ? existing.run_at : runAt;
|
|
909
1265
|
const scheduledPayload = keepEarlier ? existing.payload : payload;
|
|
910
|
-
const scheduledRepeat =
|
|
911
|
-
|
|
1266
|
+
const scheduledRepeat =
|
|
1267
|
+
lifecycle === "manual"
|
|
1268
|
+
? null
|
|
1269
|
+
: keepEarlier
|
|
1270
|
+
? existing.repeat_every_ms
|
|
1271
|
+
: repeatEveryMillis;
|
|
1272
|
+
const nextProgress = lifecycle === "manual" ? -1 : Math.max(progress, input.progress ?? -1);
|
|
912
1273
|
|
|
913
1274
|
yield* state.storage.sql.exec(
|
|
914
1275
|
`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
|
|
915
|
-
(storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
|
|
916
|
-
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1276
|
+
(storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
|
|
1277
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
917
1278
|
storageId,
|
|
918
1279
|
ref.id,
|
|
919
1280
|
ref.tag,
|
|
1281
|
+
lifecycle,
|
|
920
1282
|
scheduledAt,
|
|
921
1283
|
scheduledRepeat,
|
|
922
1284
|
scheduledPayload,
|
|
@@ -949,13 +1311,24 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
949
1311
|
});
|
|
950
1312
|
|
|
951
1313
|
const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
|
|
1314
|
+
const pass = yield* CurrentAlarmPass;
|
|
1315
|
+
|
|
1316
|
+
if (pass !== undefined && !pass.active) {
|
|
1317
|
+
return yield* Effect.fail(
|
|
1318
|
+
new StorageOperationError({
|
|
1319
|
+
operation: "alarm.transaction",
|
|
1320
|
+
cause: new Error("Alarm handlers cannot mutate detached work after their pass ends"),
|
|
1321
|
+
}),
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
952
1324
|
const reports: string[] = [];
|
|
953
1325
|
const result = yield* state.storage.transaction(() =>
|
|
954
1326
|
Effect.withFiber((owner) => {
|
|
955
1327
|
let active = true;
|
|
956
1328
|
const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
|
|
957
1329
|
Effect.withFiber<A, E | StorageOperationError>((fiber) =>
|
|
958
|
-
|
|
1330
|
+
// A queued or suspended transaction can outlive the handler that admitted it.
|
|
1331
|
+
active && fiber === owner && (pass === undefined || pass.active)
|
|
959
1332
|
? effect
|
|
960
1333
|
: Effect.fail(
|
|
961
1334
|
new StorageOperationError({
|
|
@@ -996,6 +1369,123 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
996
1369
|
return result;
|
|
997
1370
|
});
|
|
998
1371
|
|
|
1372
|
+
const deferWakes: AlarmScheduler["deferWakes"] = (body) =>
|
|
1373
|
+
Effect.acquireUseRelease(
|
|
1374
|
+
Effect.gen(function* () {
|
|
1375
|
+
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
1376
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1377
|
+
|
|
1378
|
+
if (wakeDeferrals === 0) {
|
|
1379
|
+
deferredRecoveryDelay = configuration.inFlightRecovery;
|
|
1380
|
+
deferredRecoveryAt = now + deferredRecoveryDelay;
|
|
1381
|
+
wakesReady ??= Deferred.makeUnsafe<void>();
|
|
1382
|
+
}
|
|
1383
|
+
wakeDeferrals++;
|
|
1384
|
+
}),
|
|
1385
|
+
() => transaction(() => Effect.void).pipe(Effect.andThen(body)),
|
|
1386
|
+
() =>
|
|
1387
|
+
Effect.gen(function* () {
|
|
1388
|
+
wakeDeferrals--;
|
|
1389
|
+
if (wakeDeferrals === 0) {
|
|
1390
|
+
yield* (dispatchActive ? Effect.void : transaction(() => Effect.void)).pipe(
|
|
1391
|
+
Effect.ensuring(
|
|
1392
|
+
Effect.sync(() => {
|
|
1393
|
+
if (wakeDeferrals === 0 && wakesReady !== undefined) {
|
|
1394
|
+
Deferred.doneUnsafe(wakesReady, Effect.void);
|
|
1395
|
+
wakesReady = undefined;
|
|
1396
|
+
}
|
|
1397
|
+
}),
|
|
1398
|
+
),
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
}),
|
|
1402
|
+
);
|
|
1403
|
+
|
|
1404
|
+
const awaitWakes: Effect.Effect<void> = Effect.suspend(() =>
|
|
1405
|
+
wakesReady === undefined
|
|
1406
|
+
? Effect.void
|
|
1407
|
+
: Deferred.await(wakesReady).pipe(Effect.andThen(awaitWakes)),
|
|
1408
|
+
);
|
|
1409
|
+
const runAlarm: AlarmScheduler[typeof RunAlarm] = (event, native) =>
|
|
1410
|
+
Effect.flatMap(hasSchedulerTables(state), (managed) =>
|
|
1411
|
+
!managed
|
|
1412
|
+
? native
|
|
1413
|
+
? event(Effect.void)
|
|
1414
|
+
: Effect.void
|
|
1415
|
+
: Effect.uninterruptibleMask((restore) =>
|
|
1416
|
+
Effect.suspend(() => {
|
|
1417
|
+
const pending = pendingAlarm;
|
|
1418
|
+
|
|
1419
|
+
if (pending !== undefined) {
|
|
1420
|
+
// A native delivery consumed its timestamp. Restore recovery before joining.
|
|
1421
|
+
return restore(
|
|
1422
|
+
(native ? transaction(() => Effect.void) : Effect.void).pipe(
|
|
1423
|
+
Effect.andThen(Deferred.await(pending)),
|
|
1424
|
+
),
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
const completed = Deferred.makeUnsafe<void, unknown>();
|
|
1428
|
+
let dispatchStarted = false;
|
|
1429
|
+
|
|
1430
|
+
pendingAlarm = completed;
|
|
1431
|
+
dispatchNeedsReconcile = native;
|
|
1432
|
+
|
|
1433
|
+
return restore(
|
|
1434
|
+
Effect.gen(function* () {
|
|
1435
|
+
// Native-first delivery can consume an inline region's guard before
|
|
1436
|
+
// any prompt pass exists to join. Restore it while waiting for exit.
|
|
1437
|
+
if (native && wakesReady !== undefined) yield* transaction(() => Effect.void);
|
|
1438
|
+
yield* awaitWakes;
|
|
1439
|
+
dispatchActive = true;
|
|
1440
|
+
yield* event(
|
|
1441
|
+
Effect.gen(function* () {
|
|
1442
|
+
yield* getScheduleConfiguration(configurationDefaults);
|
|
1443
|
+
dispatchStarted = true;
|
|
1444
|
+
}),
|
|
1445
|
+
);
|
|
1446
|
+
}),
|
|
1447
|
+
).pipe(
|
|
1448
|
+
Effect.onExit((exit) =>
|
|
1449
|
+
Effect.gen(function* () {
|
|
1450
|
+
// The event includes its scope finalizers. Late enrollments cannot be
|
|
1451
|
+
// swallowed by a native delivery that joined after the final checkpoint.
|
|
1452
|
+
// Acquisition failure never reached a dispatcher: keep a future guard
|
|
1453
|
+
// instead of rearming untouched due rows into an immediate retry loop.
|
|
1454
|
+
const reconcile =
|
|
1455
|
+
dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
|
|
1456
|
+
|
|
1457
|
+
dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
|
|
1458
|
+
if (dispatchActive) {
|
|
1459
|
+
const recoveryDelay = yield* getScheduleConfiguration(
|
|
1460
|
+
configurationDefaults,
|
|
1461
|
+
).pipe(
|
|
1462
|
+
Effect.match({
|
|
1463
|
+
onFailure: () => PARKED_RETRY_DELAY_MS,
|
|
1464
|
+
onSuccess: (configuration) => configuration.parkedRetryDelay,
|
|
1465
|
+
}),
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
// A concurrent deferred scope retains ownership of its short guard.
|
|
1469
|
+
if (wakeDeferrals === 0) {
|
|
1470
|
+
deferredRecoveryDelay = recoveryDelay;
|
|
1471
|
+
deferredRecoveryAt = 0;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
const reconciled = yield* (
|
|
1475
|
+
reconcile ? transaction(() => Effect.void) : Effect.void
|
|
1476
|
+
).pipe(Effect.exit);
|
|
1477
|
+
|
|
1478
|
+
dispatchActive = false;
|
|
1479
|
+
pendingAlarm = undefined;
|
|
1480
|
+
yield* Deferred.done(completed, Exit.asVoidAll([exit, reconciled]));
|
|
1481
|
+
yield* reconciled;
|
|
1482
|
+
}),
|
|
1483
|
+
),
|
|
1484
|
+
);
|
|
1485
|
+
}),
|
|
1486
|
+
),
|
|
1487
|
+
);
|
|
1488
|
+
|
|
999
1489
|
const rescheduleFailedAlarm = Effect.fnUntraced(function* (
|
|
1000
1490
|
row: AlarmRow,
|
|
1001
1491
|
initialDelay: number,
|
|
@@ -1072,14 +1562,56 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1072
1562
|
const limit = yield* getProcessLimit(options);
|
|
1073
1563
|
const initialDelay = yield* getFailureRetryDelay(options, configuration);
|
|
1074
1564
|
const now = yield* Clock.currentTimeMillis;
|
|
1565
|
+
const dispatch = yield* CurrentDispatch;
|
|
1566
|
+
|
|
1567
|
+
// A validated dispatcher owns its remaining batch, including newly enrolled work.
|
|
1568
|
+
if (dispatch?.raw !== undefined) dispatch.raw.processed = true;
|
|
1569
|
+
|
|
1570
|
+
const tagFilter =
|
|
1571
|
+
dispatch === undefined
|
|
1572
|
+
? ""
|
|
1573
|
+
: ` AND a.tag ${dispatch.exclude ? "NOT IN" : "IN"} (SELECT value FROM json_each(?))`;
|
|
1574
|
+
const tagBindings =
|
|
1575
|
+
dispatch === undefined ? [] : [S.encodeSync(StoredTags)([...dispatch.tags])];
|
|
1576
|
+
const select = Effect.fnUntraced(function* (lifecycle: AlarmLifecycle, batchLimit: number) {
|
|
1577
|
+
const cursor = yield* state.storage.sql.exec<AlarmRow>(
|
|
1578
|
+
`${alarmRowsSql} WHERE a.lifecycle = ? AND a.wake_at <= ?${tagFilter}
|
|
1579
|
+
ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`,
|
|
1580
|
+
lifecycle,
|
|
1581
|
+
now,
|
|
1582
|
+
...tagBindings,
|
|
1583
|
+
batchLimit,
|
|
1584
|
+
);
|
|
1075
1585
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1586
|
+
return yield* cursor.toArray();
|
|
1587
|
+
});
|
|
1588
|
+
// Manual recovery never consumes the raw/automatic dispatcher's batch allowance.
|
|
1589
|
+
const dueRows = yield* select("automatic", limit);
|
|
1590
|
+
const manualRows = yield* select("manual", DEFAULT_PROCESS_DUE_ALARMS_LIMIT);
|
|
1591
|
+
|
|
1592
|
+
if (manualRows.length > 0) {
|
|
1593
|
+
yield* transaction(() =>
|
|
1594
|
+
Effect.gen(function* () {
|
|
1595
|
+
for (const row of manualRows) {
|
|
1596
|
+
// Keep work eligible after eviction; unknown registrations stay parked.
|
|
1597
|
+
const recoveryAt =
|
|
1598
|
+
now +
|
|
1599
|
+
(options?.[ManualTags]?.has(row.tag)
|
|
1600
|
+
? configuration.inFlightRecovery
|
|
1601
|
+
: configuration.parkedRetryDelay);
|
|
1602
|
+
|
|
1603
|
+
yield* state.storage.sql.exec(
|
|
1604
|
+
`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
|
|
1605
|
+
recoveryAt,
|
|
1606
|
+
recoveryAt,
|
|
1607
|
+
row.storage_id,
|
|
1608
|
+
row.storage_id,
|
|
1609
|
+
row.revision,
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
}),
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1083
1615
|
const handled: DurableObjectAlarmEvent[] = [];
|
|
1084
1616
|
const failed: ProcessDueAlarmsFailure[] = [];
|
|
1085
1617
|
const parked: AlarmParked[] = [];
|
|
@@ -1128,12 +1660,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1128
1660
|
}
|
|
1129
1661
|
});
|
|
1130
1662
|
|
|
1131
|
-
|
|
1663
|
+
const handleRow = Effect.fnUntraced(function* (row: AlarmRow) {
|
|
1132
1664
|
const current = yield* readRow(row.storage_id);
|
|
1133
1665
|
|
|
1134
|
-
if (current === undefined || current.revision !== row.revision)
|
|
1135
|
-
continue;
|
|
1136
|
-
}
|
|
1666
|
+
if (current === undefined || current.revision !== row.revision) return;
|
|
1137
1667
|
const inPass = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
|
|
1138
1668
|
const pass = { row, parked, active: true };
|
|
1139
1669
|
|
|
@@ -1146,28 +1676,85 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1146
1676
|
),
|
|
1147
1677
|
);
|
|
1148
1678
|
};
|
|
1679
|
+
const manual = row.lifecycle === "manual";
|
|
1680
|
+
|
|
1681
|
+
if (manual && !options?.[ManualTags]?.has(row.tag)) {
|
|
1682
|
+
return yield* Effect.fail(missingAlarm(row.tag));
|
|
1683
|
+
}
|
|
1149
1684
|
const eventExit = yield* Effect.exit(toAlarmDue(row));
|
|
1150
1685
|
|
|
1151
1686
|
if (Exit.isFailure(eventExit)) {
|
|
1152
|
-
yield*
|
|
1153
|
-
|
|
1687
|
+
if (manual) return yield* Effect.failCause(eventExit.cause);
|
|
1688
|
+
|
|
1689
|
+
return yield* inPass(handleFailure(row, undefined, eventExit.cause));
|
|
1154
1690
|
}
|
|
1155
1691
|
const event = eventExit.value;
|
|
1156
1692
|
const handleExit = yield* Effect.exit(inPass(Effect.suspend(() => handle(event))));
|
|
1157
1693
|
|
|
1158
1694
|
if (Exit.isFailure(handleExit)) {
|
|
1159
|
-
yield*
|
|
1160
|
-
|
|
1695
|
+
if (manual) return yield* Effect.failCause(handleExit.cause);
|
|
1696
|
+
|
|
1697
|
+
return yield* inPass(handleFailure(row, event, handleExit.cause));
|
|
1161
1698
|
}
|
|
1162
|
-
yield* transaction(() => acknowledgeAlarm(row, configuration));
|
|
1699
|
+
if (!manual) yield* transaction(() => acknowledgeAlarm(row, configuration));
|
|
1163
1700
|
handled.push(event);
|
|
1164
|
-
}
|
|
1165
|
-
yield*
|
|
1701
|
+
});
|
|
1702
|
+
const permits = dispatch?.permits ?? (yield* Semaphore.make(4));
|
|
1703
|
+
const automatic = Effect.forEach(dueRows, handleRow, { discard: true }).pipe(Effect.exit);
|
|
1704
|
+
const exits = yield* manualRows.length === 0
|
|
1705
|
+
? automatic.pipe(Effect.map((exit) => [exit]))
|
|
1706
|
+
: Effect.all(
|
|
1707
|
+
[
|
|
1708
|
+
automatic,
|
|
1709
|
+
Effect.forEach(
|
|
1710
|
+
manualRows,
|
|
1711
|
+
(row) => permits.withPermits(1)(handleRow(row)).pipe(Effect.exit),
|
|
1712
|
+
{ concurrency: 4 },
|
|
1713
|
+
).pipe(Effect.flatMap(Exit.asVoidAll), Effect.exit),
|
|
1714
|
+
],
|
|
1715
|
+
{ concurrency: 2 },
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
yield* transaction(() =>
|
|
1719
|
+
Effect.gen(function* () {
|
|
1720
|
+
const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
|
|
1721
|
+
|
|
1722
|
+
for (const row of manualRows) {
|
|
1723
|
+
// Only a returned handler with no newer checkpoint falls back to parked recovery.
|
|
1724
|
+
yield* state.storage.sql.exec(
|
|
1725
|
+
`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
|
|
1726
|
+
recoveryAt,
|
|
1727
|
+
recoveryAt,
|
|
1728
|
+
row.storage_id,
|
|
1729
|
+
row.storage_id,
|
|
1730
|
+
row.revision,
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
}),
|
|
1734
|
+
);
|
|
1735
|
+
yield* Exit.asVoidAll(exits);
|
|
1166
1736
|
|
|
1167
1737
|
return { failed, handled, parked };
|
|
1168
1738
|
});
|
|
1169
1739
|
|
|
1170
1740
|
return DurableObjectAlarm.of({
|
|
1741
|
+
[RunAlarm]: runAlarm,
|
|
1742
|
+
[DispatchAfterEvent]: Effect.map(
|
|
1743
|
+
ScheduleConfiguration,
|
|
1744
|
+
(configuration) =>
|
|
1745
|
+
(configuration.dispatchAfterEvent ?? configurationDefaults.dispatchAfterEvent) === true,
|
|
1746
|
+
),
|
|
1747
|
+
[HasDueAlarms]: Effect.gen(function* () {
|
|
1748
|
+
yield* ensureTable(state);
|
|
1749
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1750
|
+
const cursor = yield* state.storage.sql.exec(
|
|
1751
|
+
"SELECT 1 FROM effect_cf_scheduled_alarms WHERE wake_at <= ? LIMIT 1",
|
|
1752
|
+
now,
|
|
1753
|
+
);
|
|
1754
|
+
|
|
1755
|
+
return (yield* cursor.toArray()).length > 0;
|
|
1756
|
+
}),
|
|
1757
|
+
deferWakes,
|
|
1171
1758
|
cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
|
|
1172
1759
|
getAlarmStatus: Effect.fnUntraced(function* (input) {
|
|
1173
1760
|
const ref = yield* decodeAlarmRef(input);
|