effect-cf 0.52.0 → 0.54.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 +7 -1
- package/dist/AnalyticsEngine.d.mts +2 -2
- package/dist/AnalyticsEngine.mjs +3 -3
- package/dist/CloudflareOtlp.d.mts +3 -3
- package/dist/CloudflareOtlp.mjs +7 -7
- package/dist/D1.d.mts +1 -1
- package/dist/DurableObject.d.mts +1 -0
- package/dist/DurableObject.mjs +33 -11
- package/dist/DurableObjectAlarm.d.mts +74 -9
- package/dist/DurableObjectAlarm.mjs +245 -35
- package/dist/DurableObjectRpcWebSocket.d.mts +3 -3
- package/dist/DurableObjectRpcWebSocket.mjs +3 -3
- package/dist/DurableObjectSqlite.d.mts +1 -1
- package/dist/DurableObjectSqlite.mjs +2 -2
- package/dist/HyperdrivePg.d.mts +1 -1
- package/dist/Mcp.d.mts +3 -3
- package/dist/Mcp.mjs +3 -3
- package/dist/RateLimit.d.mts +50 -0
- package/dist/RateLimit.mjs +46 -0
- package/dist/RpcWebSocketClient.d.mts +6 -6
- package/dist/RpcWebSocketClient.mjs +3 -3
- package/dist/Worker.d.mts +2 -2
- package/dist/Worker.mjs +4 -4
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/internal/Runtime.mjs +5 -3
- package/dist/internal/Telemetry.mjs +1 -1
- package/package.json +5 -5
- package/src/AnalyticsEngine.ts +4 -4
- package/src/CloudflareOtlp.ts +8 -8
- package/src/D1.ts +1 -1
- package/src/DurableObject.ts +93 -30
- package/src/DurableObjectAlarm.ts +601 -59
- package/src/DurableObjectRpcWebSocket.ts +3 -3
- package/src/DurableObjectSqlite.ts +2 -2
- package/src/HyperdrivePg.ts +1 -1
- package/src/Mcp.ts +4 -4
- package/src/RateLimit.ts +110 -0
- package/src/RpcWebSocketClient.ts +6 -6
- package/src/Worker.ts +4 -4
- package/src/index.ts +1 -0
- package/src/internal/Entrypoint.ts +8 -4
- package/src/internal/Runtime.ts +12 -7
- package/src/internal/Telemetry.ts +1 -1
|
@@ -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
|
|
|
@@ -49,6 +53,8 @@ export const MIN_REPEAT_INTERVAL_MS = 1_000;
|
|
|
49
53
|
export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
|
|
50
54
|
|
|
51
55
|
export interface ScheduleConfiguration {
|
|
56
|
+
/** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
|
|
57
|
+
readonly dispatchAfterEvent?: boolean;
|
|
52
58
|
/** Retry floor, at least one second. Defaults to one second. */
|
|
53
59
|
readonly minimumRetryDelay?: Duration.Input;
|
|
54
60
|
/** Positive safe integer. Defaults to eight unchanged attempts. */
|
|
@@ -68,7 +74,13 @@ export const ScheduleConfiguration = Context.Reference<ScheduleConfiguration>(
|
|
|
68
74
|
const safeIntegerAtLeast = (minimum: number) =>
|
|
69
75
|
S.Int.check(S.isGreaterThanOrEqualTo(minimum), S.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
70
76
|
|
|
77
|
+
/** Automatic alarms acknowledge/retry on handler exit; manual alarms retain consumer control. */
|
|
78
|
+
export type AlarmLifecycle = "automatic" | "manual";
|
|
79
|
+
|
|
80
|
+
const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
|
|
81
|
+
|
|
71
82
|
const ScheduleConfigurationSchema = S.Struct({
|
|
83
|
+
dispatchAfterEvent: S.Boolean,
|
|
72
84
|
minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
|
|
73
85
|
unchangedAttemptBudget: safeIntegerAtLeast(1),
|
|
74
86
|
parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
|
|
@@ -99,6 +111,7 @@ const getScheduledEventId = (input: { readonly id: string; readonly tag: string
|
|
|
99
111
|
export type AlarmPayload = S.Json;
|
|
100
112
|
|
|
101
113
|
interface AlarmRow extends Record<string, SqlStorageValue> {
|
|
114
|
+
readonly lifecycle: AlarmLifecycle;
|
|
102
115
|
readonly attempts: number;
|
|
103
116
|
readonly alarm_id: string;
|
|
104
117
|
readonly payload: string;
|
|
@@ -140,6 +153,18 @@ const CurrentAlarmPass = Context.Reference<
|
|
|
140
153
|
{ readonly row: AlarmRow; readonly parked: AlarmParked[]; active: boolean } | undefined
|
|
141
154
|
>("effect-cf/DurableObjectAlarm/CurrentAlarmPass", { defaultValue: () => undefined });
|
|
142
155
|
|
|
156
|
+
/** One dispatcher's view of a registration pass: its tags, shared manual permits, raw tracking. */
|
|
157
|
+
const CurrentDispatch = Context.Reference<
|
|
158
|
+
| {
|
|
159
|
+
readonly tags: readonly string[];
|
|
160
|
+
readonly exclude: boolean;
|
|
161
|
+
readonly permits: Semaphore.Semaphore;
|
|
162
|
+
readonly raw?: { processed: boolean };
|
|
163
|
+
}
|
|
164
|
+
| undefined
|
|
165
|
+
>("effect-cf/DurableObjectAlarm/CurrentDispatch", { defaultValue: () => undefined });
|
|
166
|
+
const ManualTags: unique symbol = Symbol("effect-cf/DurableObjectAlarm/ManualTags");
|
|
167
|
+
|
|
143
168
|
interface NextAlarmRow extends Record<string, SqlStorageValue> {
|
|
144
169
|
readonly run_at: number;
|
|
145
170
|
}
|
|
@@ -188,12 +213,23 @@ export class InvalidScheduleConfigurationError extends Data.TaggedError(
|
|
|
188
213
|
}
|
|
189
214
|
}
|
|
190
215
|
|
|
216
|
+
export class InvalidAlarmRegistrationError extends Data.TaggedError(
|
|
217
|
+
"InvalidAlarmRegistrationError",
|
|
218
|
+
)<{
|
|
219
|
+
readonly cause: unknown;
|
|
220
|
+
}> {
|
|
221
|
+
override get message(): string {
|
|
222
|
+
return `Invalid Durable Object alarm registration: ${ErrorMessage.causeMessage(this.cause)}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
191
226
|
const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: ScheduleConfiguration) {
|
|
192
227
|
const input = { ...defaults, ...(yield* ScheduleConfiguration) };
|
|
193
228
|
|
|
194
229
|
return yield* Effect.try({
|
|
195
230
|
try: () =>
|
|
196
231
|
S.decodeUnknownSync(ScheduleConfigurationSchema)({
|
|
232
|
+
dispatchAfterEvent: input.dispatchAfterEvent ?? false,
|
|
197
233
|
minimumRetryDelay: Math.ceil(
|
|
198
234
|
Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
|
|
199
235
|
),
|
|
@@ -219,6 +255,7 @@ export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeE
|
|
|
219
255
|
}
|
|
220
256
|
|
|
221
257
|
export type DurableObjectAlarmError =
|
|
258
|
+
| InvalidAlarmRegistrationError
|
|
222
259
|
| InvalidAlarmPayloadError
|
|
223
260
|
| InvalidAlarmRefError
|
|
224
261
|
| InvalidProcessDueAlarmsOptionsError
|
|
@@ -258,6 +295,8 @@ const decodeAlarmRef = (input: AlarmRef) =>
|
|
|
258
295
|
* Reusing `{tag, id}` replaces an alarm. Product repeats use the configured floor and run after completion.
|
|
259
296
|
*/
|
|
260
297
|
export type ScheduleAlarmInput<Tag extends string = string> = AlarmRef<Tag> & {
|
|
298
|
+
/** Prefer selecting the lifecycle in a typed Tag definition. Defaults to automatic. */
|
|
299
|
+
readonly lifecycle?: AlarmLifecycle;
|
|
261
300
|
readonly payload: AlarmPayload;
|
|
262
301
|
readonly repeatEvery?: Duration.Input;
|
|
263
302
|
readonly runAt: DateTime.Utc;
|
|
@@ -291,6 +330,7 @@ export type ProcessDueAlarmsFailureAction =
|
|
|
291
330
|
};
|
|
292
331
|
|
|
293
332
|
export interface ProcessDueAlarmsOptions<OnFailureR = never, OnFailureE = never> {
|
|
333
|
+
readonly [ManualTags]?: ReadonlySet<string>;
|
|
294
334
|
readonly limit?: number;
|
|
295
335
|
readonly onFailure?: (
|
|
296
336
|
failure: ProcessDueAlarmsFailure,
|
|
@@ -318,8 +358,39 @@ export interface AlarmStatus {
|
|
|
318
358
|
readonly retryAt: DateTime.Utc | undefined;
|
|
319
359
|
}
|
|
320
360
|
|
|
361
|
+
/** @internal */
|
|
362
|
+
export const RunAlarm: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RunAlarm");
|
|
363
|
+
/** @internal */
|
|
364
|
+
export const DispatchAfterEvent: unique symbol = Symbol(
|
|
365
|
+
"effect-cf/DurableObjectAlarm/DispatchAfterEvent",
|
|
366
|
+
);
|
|
367
|
+
/** @internal */
|
|
368
|
+
export const HasDueAlarms: unique symbol = Symbol("effect-cf/DurableObjectAlarm/HasDueAlarms");
|
|
369
|
+
|
|
321
370
|
/** Own `storage.setAlarm()` exclusively: a Durable Object has one platform alarm timestamp. */
|
|
322
371
|
export type AlarmScheduler = {
|
|
372
|
+
/** @internal DurableObject.make shares this scoped event across native and post-event dispatch. */
|
|
373
|
+
readonly [RunAlarm]: <R>(
|
|
374
|
+
event: (
|
|
375
|
+
beginDispatch: Effect.Effect<void, InvalidScheduleConfigurationError>,
|
|
376
|
+
) => Effect.Effect<void, unknown, R>,
|
|
377
|
+
native: boolean,
|
|
378
|
+
) => Effect.Effect<void, unknown, R>;
|
|
379
|
+
/** @internal Read the current event's opt-in without acquiring another event layer. */
|
|
380
|
+
readonly [DispatchAfterEvent]: Effect.Effect<boolean>;
|
|
381
|
+
/** @internal Check inside the alarm event so its clock and configuration apply. */
|
|
382
|
+
readonly [HasDueAlarms]: Effect.Effect<boolean, StorageOperationError>;
|
|
383
|
+
/**
|
|
384
|
+
* Keep a native recovery alarm armed while coalescing checkpoint changes across this
|
|
385
|
+
* object's concurrent and nested scopes. Source/checkpoint transactions still commit;
|
|
386
|
+
* the last scope reconciles their current deadlines on success, failure or interruption.
|
|
387
|
+
* Run outside transactions, around bounded maintenance or inline work. The first scope's
|
|
388
|
+
* parkedRetryDelay bounds the shared guard; renew it if it expires and is consumed while
|
|
389
|
+
* scopes remain active. A process loss before exit recovers at that retained alarm.
|
|
390
|
+
*/
|
|
391
|
+
readonly deferWakes: <A, E, R>(
|
|
392
|
+
body: Effect.Effect<A, E, R>,
|
|
393
|
+
) => Effect.Effect<A, E | InvalidScheduleConfigurationError | StorageOperationError, R>;
|
|
323
394
|
/**
|
|
324
395
|
* Commits local application storage and logical alarms in one native SQLite
|
|
325
396
|
* Durable Object transaction, reconciling the native alarm before commit.
|
|
@@ -374,6 +445,7 @@ export type AlarmScheduler = {
|
|
|
374
445
|
};
|
|
375
446
|
|
|
376
447
|
const StoredPayloadString = S.fromJsonString(S.Json);
|
|
448
|
+
const StoredTags = S.fromJsonString(S.Array(S.String));
|
|
377
449
|
|
|
378
450
|
const decodeStoredPayload = (row: AlarmRow) =>
|
|
379
451
|
S.decodeUnknownEffect(StoredPayloadString)(row.payload).pipe(
|
|
@@ -389,7 +461,7 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
|
|
|
389
461
|
yield* state.storage.sql.exec(INIT_TABLE_SQL);
|
|
390
462
|
const index = yield* state.storage.sql.exec(
|
|
391
463
|
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
|
|
392
|
-
|
|
464
|
+
LIFECYCLE_INDEX,
|
|
393
465
|
);
|
|
394
466
|
|
|
395
467
|
if ((yield* index.toArray()).length > 0) return;
|
|
@@ -398,6 +470,11 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
|
|
|
398
470
|
);
|
|
399
471
|
const names = new Set((yield* columns.toArray()).map((column) => column.name));
|
|
400
472
|
|
|
473
|
+
if (!names.has("lifecycle")) {
|
|
474
|
+
yield* state.storage.sql.exec(
|
|
475
|
+
"ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'automatic'",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
401
478
|
if (!names.has("wake_at")) {
|
|
402
479
|
yield* state.storage.sql.exec(
|
|
403
480
|
"ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
|
|
@@ -413,6 +490,8 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
|
|
|
413
490
|
);
|
|
414
491
|
yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
|
|
415
492
|
ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
|
|
493
|
+
yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${LIFECYCLE_INDEX}
|
|
494
|
+
ON effect_cf_scheduled_alarms (lifecycle, wake_at, storage_id)`);
|
|
416
495
|
});
|
|
417
496
|
|
|
418
497
|
const toRepeatEveryMillis = (input: Duration.Input | undefined, minimum: number) => {
|
|
@@ -515,11 +594,20 @@ export interface AlarmRetryPolicy {
|
|
|
515
594
|
readonly initialDelay?: Duration.Input;
|
|
516
595
|
}
|
|
517
596
|
|
|
518
|
-
export
|
|
519
|
-
readonly failure?: AlarmFailurePolicy;
|
|
597
|
+
export type AlarmDefinitionConfig<Payload extends AlarmPayloadSchema = AlarmPayloadSchema> = {
|
|
520
598
|
readonly payload: Payload;
|
|
521
|
-
|
|
522
|
-
|
|
599
|
+
} & (
|
|
600
|
+
| {
|
|
601
|
+
readonly lifecycle?: "automatic";
|
|
602
|
+
readonly failure?: AlarmFailurePolicy;
|
|
603
|
+
readonly retry?: AlarmRetryPolicy;
|
|
604
|
+
}
|
|
605
|
+
| {
|
|
606
|
+
readonly lifecycle: "manual";
|
|
607
|
+
readonly failure?: never;
|
|
608
|
+
readonly retry?: never;
|
|
609
|
+
}
|
|
610
|
+
);
|
|
523
611
|
|
|
524
612
|
export type AlarmDefinitionEntry = AlarmDefinitionConfig | AlarmPayloadSchema;
|
|
525
613
|
|
|
@@ -547,9 +635,14 @@ export type DefinedAlarmHandlers<Definitions extends AlarmDefinitions, R = never
|
|
|
547
635
|
|
|
548
636
|
/** A discriminated union keeps each tag paired with its decoded payload type. */
|
|
549
637
|
export type DefinedScheduleAlarmInput<Definitions extends AlarmDefinitions> = {
|
|
550
|
-
readonly [Tag in keyof Definitions & string]: Omit<
|
|
638
|
+
readonly [Tag in keyof Definitions & string]: Omit<
|
|
639
|
+
ScheduleAlarmInput<Tag>,
|
|
640
|
+
"payload" | "lifecycle" | "repeatEvery" | "progress"
|
|
641
|
+
> & {
|
|
551
642
|
readonly payload: AlarmDefinitionPayload<Definitions[Tag]>;
|
|
552
|
-
}
|
|
643
|
+
} & (Definitions[Tag] extends { readonly lifecycle: "manual" }
|
|
644
|
+
? { readonly repeatEvery?: never; readonly progress?: never }
|
|
645
|
+
: Pick<ScheduleAlarmInput<Tag>, "repeatEvery" | "progress">);
|
|
553
646
|
}[keyof Definitions & string];
|
|
554
647
|
|
|
555
648
|
export interface DefinedAlarmTransaction<Definitions extends AlarmDefinitions> {
|
|
@@ -577,6 +670,7 @@ export interface DefinedAlarmScheduler<
|
|
|
577
670
|
Definitions extends AlarmDefinitions,
|
|
578
671
|
> extends DefinedAlarmTransaction<Definitions> {
|
|
579
672
|
readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
|
|
673
|
+
readonly deferWakes: AlarmScheduler["deferWakes"];
|
|
580
674
|
readonly getAlarmStatus: (
|
|
581
675
|
input: AlarmRef<keyof Definitions & string>,
|
|
582
676
|
) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
|
|
@@ -587,12 +681,175 @@ export interface DefinedAlarmScheduler<
|
|
|
587
681
|
|
|
588
682
|
/** Registers a dispatcher and the service authorized to schedule its alarms. */
|
|
589
683
|
export interface AlarmRegistration<Self, R = never, E = never> {
|
|
590
|
-
readonly layer: Layer.Layer<Self,
|
|
684
|
+
readonly layer: Layer.Layer<Self, InvalidAlarmRegistrationError, DurableObjectAlarm>;
|
|
591
685
|
readonly run: Effect.Effect<
|
|
592
686
|
ProcessDueAlarmsResult,
|
|
593
687
|
E | DurableObjectAlarmError,
|
|
594
688
|
R | DurableObjectAlarm
|
|
595
689
|
>;
|
|
690
|
+
readonly [RegistrationParts]?: RegistrationParts<R, E>;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const RegistrationParts: unique symbol = Symbol("effect-cf/DurableObjectAlarm/RegistrationParts");
|
|
694
|
+
|
|
695
|
+
interface AlarmDispatcher<R, E> {
|
|
696
|
+
readonly tags: readonly string[];
|
|
697
|
+
readonly automatic: boolean;
|
|
698
|
+
readonly run: AlarmRegistration<never, R, E>["run"];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
interface RegistrationParts<R, E> {
|
|
702
|
+
readonly services: readonly string[];
|
|
703
|
+
readonly dispatchers: readonly AlarmDispatcher<R, E>[];
|
|
704
|
+
readonly invalid?: string;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const emptyResult: ProcessDueAlarmsResult = { failed: [], handled: [], parked: [] };
|
|
708
|
+
|
|
709
|
+
const missingAlarm = (tag: string) =>
|
|
710
|
+
new InvalidAlarmRegistrationError({
|
|
711
|
+
cause: new Error(`No manual handler registered for alarm tag "${tag}"`),
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const validateRegistration = <R, E>(parts: RegistrationParts<R, E>) =>
|
|
715
|
+
Effect.try({
|
|
716
|
+
try: () => {
|
|
717
|
+
if (parts.invalid !== undefined) throw new Error(parts.invalid);
|
|
718
|
+
const tags = parts.dispatchers.flatMap((dispatcher) => dispatcher.tags);
|
|
719
|
+
|
|
720
|
+
S.decodeUnknownSync(S.Array(S.NonEmptyString))([...parts.services, ...tags]);
|
|
721
|
+
if (
|
|
722
|
+
new Set(parts.services).size !== parts.services.length ||
|
|
723
|
+
new Set(tags).size !== tags.length
|
|
724
|
+
) {
|
|
725
|
+
throw new Error(
|
|
726
|
+
"Alarm service keys and definition tags must be unique within a Durable Object",
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
catch: (cause) => new InvalidAlarmRegistrationError({ cause }),
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
const runRegistrations = <R, E>(
|
|
734
|
+
parts: RegistrationParts<R, E>,
|
|
735
|
+
rawAlarm?: Effect.Effect<void, E, R>,
|
|
736
|
+
): AlarmRegistration<never, R, E>["run"] =>
|
|
737
|
+
Effect.gen(function* () {
|
|
738
|
+
yield* validateRegistration(parts);
|
|
739
|
+
const alarms = yield* DurableObjectAlarm;
|
|
740
|
+
const permits = yield* Semaphore.make(4);
|
|
741
|
+
const parentRaw = (yield* CurrentDispatch)?.raw;
|
|
742
|
+
const recoveryScope = {
|
|
743
|
+
tags: parts.dispatchers.flatMap((dispatcher) => dispatcher.tags),
|
|
744
|
+
exclude: true,
|
|
745
|
+
permits,
|
|
746
|
+
raw: parentRaw,
|
|
747
|
+
};
|
|
748
|
+
const recovery =
|
|
749
|
+
rawAlarm === undefined
|
|
750
|
+
? makeDefinition({})
|
|
751
|
+
.handlers({})
|
|
752
|
+
.pipe(Effect.provideService(CurrentDispatch, recoveryScope))
|
|
753
|
+
: Effect.suspend(() => {
|
|
754
|
+
const raw = { processed: false };
|
|
755
|
+
|
|
756
|
+
return rawAlarm.pipe(
|
|
757
|
+
Effect.onExit(() =>
|
|
758
|
+
raw.processed ? Effect.void : makeDefinition({}).handlers({}).pipe(Effect.asVoid),
|
|
759
|
+
),
|
|
760
|
+
Effect.provideService(CurrentDispatch, { ...recoveryScope, raw }),
|
|
761
|
+
Effect.as(emptyResult),
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
return yield* alarms.deferWakes(
|
|
766
|
+
Effect.gen(function* () {
|
|
767
|
+
const run = (dispatchers: RegistrationParts<R, E>["dispatchers"]) =>
|
|
768
|
+
Effect.forEach(
|
|
769
|
+
dispatchers,
|
|
770
|
+
(dispatcher) =>
|
|
771
|
+
dispatcher.run.pipe(
|
|
772
|
+
Effect.catchTag("InvalidProcessDueAlarmsOptionsError", (error) =>
|
|
773
|
+
Effect.fail(error).pipe(
|
|
774
|
+
Effect.onExit(() => makeDefinition({}).handlers({}).pipe(Effect.asVoid)),
|
|
775
|
+
),
|
|
776
|
+
),
|
|
777
|
+
Effect.provideService(CurrentDispatch, {
|
|
778
|
+
tags: dispatcher.tags,
|
|
779
|
+
exclude: false,
|
|
780
|
+
permits,
|
|
781
|
+
raw: parentRaw,
|
|
782
|
+
}),
|
|
783
|
+
Effect.exit,
|
|
784
|
+
),
|
|
785
|
+
{ concurrency: "unbounded" },
|
|
786
|
+
);
|
|
787
|
+
// Raw hooks wait only for automatic dispatchers; manual-only work stays independent.
|
|
788
|
+
const automatic = parts.dispatchers.filter(
|
|
789
|
+
(dispatcher) => rawAlarm !== undefined && dispatcher.automatic,
|
|
790
|
+
);
|
|
791
|
+
const independent = parts.dispatchers.filter(
|
|
792
|
+
(dispatcher) => rawAlarm === undefined || !dispatcher.automatic,
|
|
793
|
+
);
|
|
794
|
+
const exits = (yield* Effect.all(
|
|
795
|
+
[
|
|
796
|
+
run(independent),
|
|
797
|
+
run(automatic).pipe(
|
|
798
|
+
Effect.flatMap((exits) =>
|
|
799
|
+
Effect.map(Effect.exit(recovery), (exit) => [...exits, exit]),
|
|
800
|
+
),
|
|
801
|
+
),
|
|
802
|
+
],
|
|
803
|
+
{ concurrency: "unbounded" },
|
|
804
|
+
)).flat();
|
|
805
|
+
|
|
806
|
+
yield* Exit.asVoidAll(exits);
|
|
807
|
+
|
|
808
|
+
return {
|
|
809
|
+
failed: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.failed : [])),
|
|
810
|
+
handled: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.handled : [])),
|
|
811
|
+
parked: exits.flatMap((exit) => (Exit.isSuccess(exit) ? exit.value.parked : [])),
|
|
812
|
+
};
|
|
813
|
+
}),
|
|
814
|
+
);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
const makeRegistration = <Self, R, E>(
|
|
818
|
+
layer: AlarmRegistration<Self>["layer"],
|
|
819
|
+
parts: RegistrationParts<R, E>,
|
|
820
|
+
): AlarmRegistration<Self, R, E> => ({
|
|
821
|
+
[RegistrationParts]: parts,
|
|
822
|
+
layer: layer.pipe(Layer.provide(Layer.effectDiscard(validateRegistration(parts)))),
|
|
823
|
+
run: runRegistrations(parts),
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
/** Compose independent typed alarm registrations under one native alarm owner. */
|
|
827
|
+
export function mergeAll<
|
|
828
|
+
const Registrations extends readonly [
|
|
829
|
+
AlarmRegistration<never, unknown, unknown>,
|
|
830
|
+
...AlarmRegistration<never, unknown, unknown>[],
|
|
831
|
+
],
|
|
832
|
+
>(
|
|
833
|
+
...registrations: Registrations
|
|
834
|
+
): AlarmRegistration<
|
|
835
|
+
Layer.Success<Registrations[number]["layer"]>,
|
|
836
|
+
Exclude<Effect.Services<Registrations[number]["run"]>, DurableObjectAlarm>,
|
|
837
|
+
Exclude<Effect.Error<Registrations[number]["run"]>, DurableObjectAlarmError>
|
|
838
|
+
>;
|
|
839
|
+
export function mergeAll(
|
|
840
|
+
registration: AlarmRegistration<never, unknown, unknown>,
|
|
841
|
+
...others: readonly AlarmRegistration<never, unknown, unknown>[]
|
|
842
|
+
): AlarmRegistration<never, unknown, unknown> {
|
|
843
|
+
const registrations = [registration, ...others];
|
|
844
|
+
const parts = registrations.map((item) => item[RegistrationParts]);
|
|
845
|
+
|
|
846
|
+
return makeRegistration(Layer.mergeAll(registration.layer, ...others.map((item) => item.layer)), {
|
|
847
|
+
services: parts.flatMap((part) => part?.services ?? []),
|
|
848
|
+
dispatchers: parts.flatMap((part) => part?.dispatchers ?? []),
|
|
849
|
+
invalid: parts.some((part) => part === undefined)
|
|
850
|
+
? "mergeAll requires registrations returned by Tag.handlers"
|
|
851
|
+
: parts.find((part) => part?.invalid !== undefined)?.invalid,
|
|
852
|
+
});
|
|
596
853
|
}
|
|
597
854
|
|
|
598
855
|
export interface AlarmTagClass<
|
|
@@ -654,7 +911,11 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
654
911
|
Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })),
|
|
655
912
|
);
|
|
656
913
|
|
|
657
|
-
yield* mutations[method]({
|
|
914
|
+
yield* mutations[method]({
|
|
915
|
+
...input,
|
|
916
|
+
payload,
|
|
917
|
+
lifecycle: isAlarmDefinitionConfig(definition) ? definition.lifecycle : undefined,
|
|
918
|
+
});
|
|
658
919
|
});
|
|
659
920
|
|
|
660
921
|
return {
|
|
@@ -680,6 +941,7 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
680
941
|
make: (alarms: AlarmScheduler): DefinedAlarmScheduler<Definitions> => ({
|
|
681
942
|
[TypedAlarmSchedulerTypeId]: TypedAlarmSchedulerTypeId,
|
|
682
943
|
...bind(alarms),
|
|
944
|
+
deferWakes: alarms.deferWakes,
|
|
683
945
|
getAlarmStatus: (input) => alarms.getAlarmStatus(input),
|
|
684
946
|
transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
|
|
685
947
|
}),
|
|
@@ -724,6 +986,14 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
724
986
|
}),
|
|
725
987
|
{
|
|
726
988
|
...options,
|
|
989
|
+
[ManualTags]: new Set(
|
|
990
|
+
Object.entries(definitions)
|
|
991
|
+
.filter(
|
|
992
|
+
([, definition]) =>
|
|
993
|
+
isAlarmDefinitionConfig(definition) && definition.lifecycle === "manual",
|
|
994
|
+
)
|
|
995
|
+
.map(([tag]) => tag),
|
|
996
|
+
),
|
|
727
997
|
onFailure: (failure) =>
|
|
728
998
|
Effect.gen(function* () {
|
|
729
999
|
const action = getAlarmDefinitionFailureAction(definitionFor(failure.tag));
|
|
@@ -742,6 +1012,46 @@ export const define = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
742
1012
|
handlers: makeDefinition(definitions).handlers,
|
|
743
1013
|
});
|
|
744
1014
|
|
|
1015
|
+
const hasSchedulerTables = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
|
|
1016
|
+
return yield* Effect.gen(function* () {
|
|
1017
|
+
if (state.raw.storage.sql === undefined) return false;
|
|
1018
|
+
// Inspect without creating tables: raw-only SQLite and KV objects retain native ownership.
|
|
1019
|
+
const tables = yield* state.storage.sql.exec(
|
|
1020
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
|
1021
|
+
"effect_cf_scheduled_alarms",
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
return (yield* tables.toArray()).length > 0;
|
|
1025
|
+
}).pipe(
|
|
1026
|
+
Effect.catchIf(
|
|
1027
|
+
(error) =>
|
|
1028
|
+
error.cause instanceof Error &&
|
|
1029
|
+
(error.cause.message.startsWith("SQL is not enabled for this Durable Object class") ||
|
|
1030
|
+
error.cause.message.startsWith("This Durable Object is not backed by SQLite storage")),
|
|
1031
|
+
() => Effect.succeed(false),
|
|
1032
|
+
),
|
|
1033
|
+
);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
/** @internal Preserve custom dispatch while recovering retained alarms after deployments. */
|
|
1037
|
+
export const dispatchRawAlarm = <Self, E, R>(
|
|
1038
|
+
rawAlarm: Effect.Effect<void, E, R>,
|
|
1039
|
+
registration?: AlarmRegistration<Self, R, E>,
|
|
1040
|
+
): Effect.Effect<void, E | DurableObjectAlarmError, R | DurableObjectAlarm | DurableObjectState> =>
|
|
1041
|
+
Effect.gen(function* () {
|
|
1042
|
+
const parts = registration?.[RegistrationParts];
|
|
1043
|
+
|
|
1044
|
+
if (parts !== undefined) return yield* runRegistrations(parts, rawAlarm).pipe(Effect.asVoid);
|
|
1045
|
+
const custom =
|
|
1046
|
+
registration === undefined ? rawAlarm : registration.run.pipe(Effect.andThen(rawAlarm));
|
|
1047
|
+
const state = yield* DurableObjectState;
|
|
1048
|
+
const hasScheduler = yield* hasSchedulerTables(state);
|
|
1049
|
+
|
|
1050
|
+
if (!hasScheduler) return yield* custom;
|
|
1051
|
+
|
|
1052
|
+
return yield* runRegistrations({ services: [], dispatchers: [] }, custom).pipe(Effect.asVoid);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
745
1055
|
/** The typed scheduler becomes available when its handlers are registered on a Durable Object. */
|
|
746
1056
|
export const Tag =
|
|
747
1057
|
<Self>() =>
|
|
@@ -756,10 +1066,19 @@ export const Tag =
|
|
|
756
1066
|
handlers: <R = never, E = never>(
|
|
757
1067
|
handlers: DefinedAlarmHandlers<Definitions, R, E>,
|
|
758
1068
|
options?: ProcessDueAlarmsOptions<R, E>,
|
|
759
|
-
): AlarmRegistration<Self, R, E> =>
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1069
|
+
): AlarmRegistration<Self, R, E> =>
|
|
1070
|
+
makeRegistration(Layer.effect(tag, Effect.map(DurableObjectAlarm, definition.make)), {
|
|
1071
|
+
services: [id],
|
|
1072
|
+
dispatchers: [
|
|
1073
|
+
{
|
|
1074
|
+
tags: Object.keys(definitions),
|
|
1075
|
+
automatic: Object.values(definitions).some(
|
|
1076
|
+
(entry) => !isAlarmDefinitionConfig(entry) || entry.lifecycle !== "manual",
|
|
1077
|
+
),
|
|
1078
|
+
run: definition.handlers(handlers, options),
|
|
1079
|
+
},
|
|
1080
|
+
],
|
|
1081
|
+
}),
|
|
763
1082
|
});
|
|
764
1083
|
};
|
|
765
1084
|
|
|
@@ -771,6 +1090,13 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
771
1090
|
Effect.gen(function* () {
|
|
772
1091
|
const state = yield* DurableObjectState;
|
|
773
1092
|
const configurationDefaults = { ...(yield* ScheduleConfiguration) };
|
|
1093
|
+
let wakeDeferrals = 0;
|
|
1094
|
+
let wakesReady: Deferred.Deferred<void> | undefined;
|
|
1095
|
+
let pendingAlarm: Deferred.Deferred<void, unknown> | undefined;
|
|
1096
|
+
let dispatchActive = false;
|
|
1097
|
+
let dispatchNeedsReconcile = false;
|
|
1098
|
+
let deferredRecoveryDelay = PARKED_RETRY_DELAY_MS;
|
|
1099
|
+
let deferredRecoveryAt = 0;
|
|
774
1100
|
|
|
775
1101
|
const readRow = Effect.fnUntraced(function* (storageId: string) {
|
|
776
1102
|
const cursor = yield* state.storage.sql.exec<AlarmRow>(
|
|
@@ -781,25 +1107,43 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
781
1107
|
return (yield* cursor.toArray())[0];
|
|
782
1108
|
});
|
|
783
1109
|
|
|
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({
|
|
1110
|
+
// The scheduler is the sole alarm writer. Raw storage is confined to this boundary.
|
|
1111
|
+
const writeNativeAlarm = (runAt: number | null) =>
|
|
1112
|
+
Effect.tryPromise({
|
|
793
1113
|
try: () =>
|
|
794
|
-
|
|
795
|
-
? state.raw.storage.deleteAlarm()
|
|
796
|
-
: state.raw.storage.setAlarm(next.run_at),
|
|
1114
|
+
runAt === null ? state.raw.storage.deleteAlarm() : state.raw.storage.setAlarm(runAt),
|
|
797
1115
|
catch: (cause) =>
|
|
798
1116
|
new StorageOperationError({
|
|
799
|
-
operation:
|
|
1117
|
+
operation: runAt === null ? "deleteAlarm" : "setAlarm",
|
|
800
1118
|
cause,
|
|
801
1119
|
}),
|
|
802
1120
|
});
|
|
1121
|
+
|
|
1122
|
+
const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () {
|
|
1123
|
+
if (wakeDeferrals > 0 || dispatchActive) {
|
|
1124
|
+
if (dispatchActive) dispatchNeedsReconcile = true;
|
|
1125
|
+
const current = yield* state.storage.getAlarm();
|
|
1126
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1127
|
+
|
|
1128
|
+
if (deferredRecoveryAt <= now) {
|
|
1129
|
+
deferredRecoveryAt = now + deferredRecoveryDelay;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Commit the guard before user work. Later transactions keep it even when
|
|
1133
|
+
// logical deadlines move earlier or every checkpoint is cancelled.
|
|
1134
|
+
if (current === null || current <= now || current > deferredRecoveryAt) {
|
|
1135
|
+
yield* writeNativeAlarm(deferredRecoveryAt);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const cursor = yield* state.storage.sql.exec<NextAlarmRow>(
|
|
1141
|
+
`SELECT wake_at AS run_at FROM effect_cf_scheduled_alarms
|
|
1142
|
+
ORDER BY wake_at, storage_id LIMIT 1`,
|
|
1143
|
+
);
|
|
1144
|
+
const next = (yield* cursor.toArray())[0];
|
|
1145
|
+
|
|
1146
|
+
yield* writeNativeAlarm(next?.run_at ?? null);
|
|
803
1147
|
});
|
|
804
1148
|
|
|
805
1149
|
const writeAttempts = Effect.fnUntraced(function* (
|
|
@@ -851,6 +1195,20 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
851
1195
|
) {
|
|
852
1196
|
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
853
1197
|
const ref = yield* decodeAlarmRef(input);
|
|
1198
|
+
const lifecycle = yield* S.decodeUnknownEffect(AlarmLifecycleSchema)(
|
|
1199
|
+
input.lifecycle ?? "automatic",
|
|
1200
|
+
).pipe(Effect.mapError((cause) => new InvalidAlarmRefError({ cause })));
|
|
1201
|
+
|
|
1202
|
+
if (
|
|
1203
|
+
lifecycle === "manual" &&
|
|
1204
|
+
(input.repeatEvery !== undefined || input.progress !== undefined)
|
|
1205
|
+
) {
|
|
1206
|
+
return yield* Effect.fail(
|
|
1207
|
+
new InvalidAlarmRefError({
|
|
1208
|
+
cause: new Error("Manual alarms own their repeats and progress budgets"),
|
|
1209
|
+
}),
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
854
1212
|
const repeatEveryMillis = yield* toRepeatEveryMillis(
|
|
855
1213
|
input.repeatEvery,
|
|
856
1214
|
configuration.minimumRepeatInterval,
|
|
@@ -859,19 +1217,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
859
1217
|
const storageId = getScheduledEventId(ref);
|
|
860
1218
|
const existing = yield* readRow(storageId);
|
|
861
1219
|
const pass = yield* CurrentAlarmPass;
|
|
862
|
-
const source =
|
|
1220
|
+
const source =
|
|
1221
|
+
lifecycle === "automatic" && pass?.row.lifecycle === "automatic" ? pass.row : undefined;
|
|
863
1222
|
const progress = existing?.progress ?? source?.progress ?? -1;
|
|
864
1223
|
|
|
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
1224
|
if (input.progress !== undefined) {
|
|
876
1225
|
yield* S.decodeUnknownEffect(S.Natural)(input.progress).pipe(
|
|
877
1226
|
Effect.mapError((cause) => new InvalidAlarmRefError({ cause })),
|
|
@@ -907,16 +1256,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
907
1256
|
const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
|
|
908
1257
|
const scheduledAt = keepEarlier ? existing.run_at : runAt;
|
|
909
1258
|
const scheduledPayload = keepEarlier ? existing.payload : payload;
|
|
910
|
-
const scheduledRepeat =
|
|
911
|
-
|
|
1259
|
+
const scheduledRepeat =
|
|
1260
|
+
lifecycle === "manual"
|
|
1261
|
+
? null
|
|
1262
|
+
: keepEarlier
|
|
1263
|
+
? existing.repeat_every_ms
|
|
1264
|
+
: repeatEveryMillis;
|
|
1265
|
+
const nextProgress = lifecycle === "manual" ? -1 : Math.max(progress, input.progress ?? -1);
|
|
912
1266
|
|
|
913
1267
|
yield* state.storage.sql.exec(
|
|
914
1268
|
`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
|
|
915
|
-
(storage_id, alarm_id, tag, run_at, repeat_every_ms, payload)
|
|
916
|
-
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1269
|
+
(storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload)
|
|
1270
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
917
1271
|
storageId,
|
|
918
1272
|
ref.id,
|
|
919
1273
|
ref.tag,
|
|
1274
|
+
lifecycle,
|
|
920
1275
|
scheduledAt,
|
|
921
1276
|
scheduledRepeat,
|
|
922
1277
|
scheduledPayload,
|
|
@@ -949,13 +1304,24 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
949
1304
|
});
|
|
950
1305
|
|
|
951
1306
|
const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(function* (closure) {
|
|
1307
|
+
const pass = yield* CurrentAlarmPass;
|
|
1308
|
+
|
|
1309
|
+
if (pass !== undefined && !pass.active) {
|
|
1310
|
+
return yield* Effect.fail(
|
|
1311
|
+
new StorageOperationError({
|
|
1312
|
+
operation: "alarm.transaction",
|
|
1313
|
+
cause: new Error("Alarm handlers cannot mutate detached work after their pass ends"),
|
|
1314
|
+
}),
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
952
1317
|
const reports: string[] = [];
|
|
953
1318
|
const result = yield* state.storage.transaction(() =>
|
|
954
1319
|
Effect.withFiber((owner) => {
|
|
955
1320
|
let active = true;
|
|
956
1321
|
const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
|
|
957
1322
|
Effect.withFiber<A, E | StorageOperationError>((fiber) =>
|
|
958
|
-
|
|
1323
|
+
// A queued or suspended transaction can outlive the handler that admitted it.
|
|
1324
|
+
active && fiber === owner && (pass === undefined || pass.active)
|
|
959
1325
|
? effect
|
|
960
1326
|
: Effect.fail(
|
|
961
1327
|
new StorageOperationError({
|
|
@@ -996,6 +1362,107 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
996
1362
|
return result;
|
|
997
1363
|
});
|
|
998
1364
|
|
|
1365
|
+
const deferWakes: AlarmScheduler["deferWakes"] = (body) =>
|
|
1366
|
+
Effect.acquireUseRelease(
|
|
1367
|
+
Effect.gen(function* () {
|
|
1368
|
+
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
1369
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1370
|
+
|
|
1371
|
+
if (wakeDeferrals === 0) {
|
|
1372
|
+
deferredRecoveryDelay = configuration.parkedRetryDelay;
|
|
1373
|
+
deferredRecoveryAt = now + deferredRecoveryDelay;
|
|
1374
|
+
wakesReady ??= Deferred.makeUnsafe<void>();
|
|
1375
|
+
}
|
|
1376
|
+
wakeDeferrals++;
|
|
1377
|
+
}),
|
|
1378
|
+
() => transaction(() => Effect.void).pipe(Effect.andThen(body)),
|
|
1379
|
+
() =>
|
|
1380
|
+
Effect.gen(function* () {
|
|
1381
|
+
wakeDeferrals--;
|
|
1382
|
+
if (wakeDeferrals === 0) {
|
|
1383
|
+
yield* (dispatchActive ? Effect.void : transaction(() => Effect.void)).pipe(
|
|
1384
|
+
Effect.ensuring(
|
|
1385
|
+
Effect.sync(() => {
|
|
1386
|
+
if (wakeDeferrals === 0 && wakesReady !== undefined) {
|
|
1387
|
+
Deferred.doneUnsafe(wakesReady, Effect.void);
|
|
1388
|
+
wakesReady = undefined;
|
|
1389
|
+
}
|
|
1390
|
+
}),
|
|
1391
|
+
),
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
}),
|
|
1395
|
+
);
|
|
1396
|
+
|
|
1397
|
+
const awaitWakes: Effect.Effect<void> = Effect.suspend(() =>
|
|
1398
|
+
wakesReady === undefined
|
|
1399
|
+
? Effect.void
|
|
1400
|
+
: Deferred.await(wakesReady).pipe(Effect.andThen(awaitWakes)),
|
|
1401
|
+
);
|
|
1402
|
+
const runAlarm: AlarmScheduler[typeof RunAlarm] = (event, native) =>
|
|
1403
|
+
Effect.flatMap(hasSchedulerTables(state), (managed) =>
|
|
1404
|
+
!managed
|
|
1405
|
+
? native
|
|
1406
|
+
? event(Effect.void)
|
|
1407
|
+
: Effect.void
|
|
1408
|
+
: Effect.uninterruptibleMask((restore) =>
|
|
1409
|
+
Effect.suspend(() => {
|
|
1410
|
+
const pending = pendingAlarm;
|
|
1411
|
+
|
|
1412
|
+
if (pending !== undefined) {
|
|
1413
|
+
// A native delivery consumed its timestamp. Restore recovery before joining.
|
|
1414
|
+
return restore(
|
|
1415
|
+
(native ? transaction(() => Effect.void) : Effect.void).pipe(
|
|
1416
|
+
Effect.andThen(Deferred.await(pending)),
|
|
1417
|
+
),
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
const completed = Deferred.makeUnsafe<void, unknown>();
|
|
1421
|
+
let dispatchStarted = false;
|
|
1422
|
+
|
|
1423
|
+
pendingAlarm = completed;
|
|
1424
|
+
dispatchNeedsReconcile = native;
|
|
1425
|
+
|
|
1426
|
+
return restore(
|
|
1427
|
+
Effect.gen(function* () {
|
|
1428
|
+
// Native-first delivery can consume an inline region's guard before
|
|
1429
|
+
// any prompt pass exists to join. Restore it while waiting for exit.
|
|
1430
|
+
if (native && wakesReady !== undefined) yield* transaction(() => Effect.void);
|
|
1431
|
+
yield* awaitWakes;
|
|
1432
|
+
dispatchActive = true;
|
|
1433
|
+
yield* event(
|
|
1434
|
+
Effect.gen(function* () {
|
|
1435
|
+
yield* getScheduleConfiguration(configurationDefaults);
|
|
1436
|
+
dispatchStarted = true;
|
|
1437
|
+
}),
|
|
1438
|
+
);
|
|
1439
|
+
}),
|
|
1440
|
+
).pipe(
|
|
1441
|
+
Effect.onExit((exit) =>
|
|
1442
|
+
Effect.gen(function* () {
|
|
1443
|
+
// The event includes its scope finalizers. Late enrollments cannot be
|
|
1444
|
+
// swallowed by a native delivery that joined after the final checkpoint.
|
|
1445
|
+
// Acquisition failure never reached a dispatcher: keep a future guard
|
|
1446
|
+
// instead of rearming untouched due rows into an immediate retry loop.
|
|
1447
|
+
const reconcile =
|
|
1448
|
+
dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
|
|
1449
|
+
|
|
1450
|
+
dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
|
|
1451
|
+
const reconciled = yield* (
|
|
1452
|
+
reconcile ? transaction(() => Effect.void) : Effect.void
|
|
1453
|
+
).pipe(Effect.exit);
|
|
1454
|
+
|
|
1455
|
+
dispatchActive = false;
|
|
1456
|
+
pendingAlarm = undefined;
|
|
1457
|
+
yield* Deferred.done(completed, Exit.asVoidAll([exit, reconciled]));
|
|
1458
|
+
yield* reconciled;
|
|
1459
|
+
}),
|
|
1460
|
+
),
|
|
1461
|
+
);
|
|
1462
|
+
}),
|
|
1463
|
+
),
|
|
1464
|
+
);
|
|
1465
|
+
|
|
999
1466
|
const rescheduleFailedAlarm = Effect.fnUntraced(function* (
|
|
1000
1467
|
row: AlarmRow,
|
|
1001
1468
|
initialDelay: number,
|
|
@@ -1072,14 +1539,50 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1072
1539
|
const limit = yield* getProcessLimit(options);
|
|
1073
1540
|
const initialDelay = yield* getFailureRetryDelay(options, configuration);
|
|
1074
1541
|
const now = yield* Clock.currentTimeMillis;
|
|
1542
|
+
const dispatch = yield* CurrentDispatch;
|
|
1543
|
+
|
|
1544
|
+
// A validated dispatcher owns its remaining batch, including newly enrolled work.
|
|
1545
|
+
if (dispatch?.raw !== undefined) dispatch.raw.processed = true;
|
|
1546
|
+
|
|
1547
|
+
const tagFilter =
|
|
1548
|
+
dispatch === undefined
|
|
1549
|
+
? ""
|
|
1550
|
+
: ` AND a.tag ${dispatch.exclude ? "NOT IN" : "IN"} (SELECT value FROM json_each(?))`;
|
|
1551
|
+
const tagBindings =
|
|
1552
|
+
dispatch === undefined ? [] : [S.encodeSync(StoredTags)([...dispatch.tags])];
|
|
1553
|
+
const select = Effect.fnUntraced(function* (lifecycle: AlarmLifecycle, batchLimit: number) {
|
|
1554
|
+
const cursor = yield* state.storage.sql.exec<AlarmRow>(
|
|
1555
|
+
`${alarmRowsSql} WHERE a.lifecycle = ? AND a.wake_at <= ?${tagFilter}
|
|
1556
|
+
ORDER BY a.wake_at ASC, a.storage_id ASC LIMIT ?`,
|
|
1557
|
+
lifecycle,
|
|
1558
|
+
now,
|
|
1559
|
+
...tagBindings,
|
|
1560
|
+
batchLimit,
|
|
1561
|
+
);
|
|
1075
1562
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1563
|
+
return yield* cursor.toArray();
|
|
1564
|
+
});
|
|
1565
|
+
// Manual recovery never consumes the raw/automatic dispatcher's batch allowance.
|
|
1566
|
+
const dueRows = yield* select("automatic", limit);
|
|
1567
|
+
const manualRows = yield* select("manual", DEFAULT_PROCESS_DUE_ALARMS_LIMIT);
|
|
1568
|
+
|
|
1569
|
+
if (manualRows.length > 0) {
|
|
1570
|
+
yield* transaction(() =>
|
|
1571
|
+
Effect.gen(function* () {
|
|
1572
|
+
for (const row of manualRows) {
|
|
1573
|
+
// Keep the selected revision: a racing replacement must survive this guard.
|
|
1574
|
+
yield* state.storage.sql.exec(
|
|
1575
|
+
`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
|
|
1576
|
+
now + configuration.parkedRetryDelay,
|
|
1577
|
+
now + configuration.parkedRetryDelay,
|
|
1578
|
+
row.storage_id,
|
|
1579
|
+
row.storage_id,
|
|
1580
|
+
row.revision,
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
}),
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1083
1586
|
const handled: DurableObjectAlarmEvent[] = [];
|
|
1084
1587
|
const failed: ProcessDueAlarmsFailure[] = [];
|
|
1085
1588
|
const parked: AlarmParked[] = [];
|
|
@@ -1128,12 +1631,10 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1128
1631
|
}
|
|
1129
1632
|
});
|
|
1130
1633
|
|
|
1131
|
-
|
|
1634
|
+
const handleRow = Effect.fnUntraced(function* (row: AlarmRow) {
|
|
1132
1635
|
const current = yield* readRow(row.storage_id);
|
|
1133
1636
|
|
|
1134
|
-
if (current === undefined || current.revision !== row.revision)
|
|
1135
|
-
continue;
|
|
1136
|
-
}
|
|
1637
|
+
if (current === undefined || current.revision !== row.revision) return;
|
|
1137
1638
|
const inPass = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
|
|
1138
1639
|
const pass = { row, parked, active: true };
|
|
1139
1640
|
|
|
@@ -1146,28 +1647,69 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1146
1647
|
),
|
|
1147
1648
|
);
|
|
1148
1649
|
};
|
|
1650
|
+
const manual = row.lifecycle === "manual";
|
|
1651
|
+
|
|
1652
|
+
if (manual && !options?.[ManualTags]?.has(row.tag)) {
|
|
1653
|
+
return yield* Effect.fail(missingAlarm(row.tag));
|
|
1654
|
+
}
|
|
1149
1655
|
const eventExit = yield* Effect.exit(toAlarmDue(row));
|
|
1150
1656
|
|
|
1151
1657
|
if (Exit.isFailure(eventExit)) {
|
|
1152
|
-
yield*
|
|
1153
|
-
|
|
1658
|
+
if (manual) return yield* Effect.failCause(eventExit.cause);
|
|
1659
|
+
|
|
1660
|
+
return yield* inPass(handleFailure(row, undefined, eventExit.cause));
|
|
1154
1661
|
}
|
|
1155
1662
|
const event = eventExit.value;
|
|
1156
1663
|
const handleExit = yield* Effect.exit(inPass(Effect.suspend(() => handle(event))));
|
|
1157
1664
|
|
|
1158
1665
|
if (Exit.isFailure(handleExit)) {
|
|
1159
|
-
yield*
|
|
1160
|
-
|
|
1666
|
+
if (manual) return yield* Effect.failCause(handleExit.cause);
|
|
1667
|
+
|
|
1668
|
+
return yield* inPass(handleFailure(row, event, handleExit.cause));
|
|
1161
1669
|
}
|
|
1162
|
-
yield* transaction(() => acknowledgeAlarm(row, configuration));
|
|
1670
|
+
if (!manual) yield* transaction(() => acknowledgeAlarm(row, configuration));
|
|
1163
1671
|
handled.push(event);
|
|
1164
|
-
}
|
|
1672
|
+
});
|
|
1673
|
+
const permits = dispatch?.permits ?? (yield* Semaphore.make(4));
|
|
1674
|
+
const automatic = Effect.forEach(dueRows, handleRow, { discard: true }).pipe(Effect.exit);
|
|
1675
|
+
const exits = yield* manualRows.length === 0
|
|
1676
|
+
? automatic.pipe(Effect.map((exit) => [exit]))
|
|
1677
|
+
: Effect.all(
|
|
1678
|
+
[
|
|
1679
|
+
automatic,
|
|
1680
|
+
Effect.forEach(
|
|
1681
|
+
manualRows,
|
|
1682
|
+
(row) => permits.withPermits(1)(handleRow(row)).pipe(Effect.exit),
|
|
1683
|
+
{ concurrency: 4 },
|
|
1684
|
+
).pipe(Effect.flatMap(Exit.asVoidAll), Effect.exit),
|
|
1685
|
+
],
|
|
1686
|
+
{ concurrency: 2 },
|
|
1687
|
+
);
|
|
1688
|
+
|
|
1165
1689
|
yield* transaction(() => Effect.void);
|
|
1690
|
+
yield* Exit.asVoidAll(exits);
|
|
1166
1691
|
|
|
1167
1692
|
return { failed, handled, parked };
|
|
1168
1693
|
});
|
|
1169
1694
|
|
|
1170
1695
|
return DurableObjectAlarm.of({
|
|
1696
|
+
[RunAlarm]: runAlarm,
|
|
1697
|
+
[DispatchAfterEvent]: Effect.map(
|
|
1698
|
+
ScheduleConfiguration,
|
|
1699
|
+
(configuration) =>
|
|
1700
|
+
(configuration.dispatchAfterEvent ?? configurationDefaults.dispatchAfterEvent) === true,
|
|
1701
|
+
),
|
|
1702
|
+
[HasDueAlarms]: Effect.gen(function* () {
|
|
1703
|
+
yield* ensureTable(state);
|
|
1704
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1705
|
+
const cursor = yield* state.storage.sql.exec(
|
|
1706
|
+
"SELECT 1 FROM effect_cf_scheduled_alarms WHERE wake_at <= ? LIMIT 1",
|
|
1707
|
+
now,
|
|
1708
|
+
);
|
|
1709
|
+
|
|
1710
|
+
return (yield* cursor.toArray()).length > 0;
|
|
1711
|
+
}),
|
|
1712
|
+
deferWakes,
|
|
1171
1713
|
cancelAlarm: (input) => transaction((alarms) => alarms.cancelAlarm(input)),
|
|
1172
1714
|
getAlarmStatus: Effect.fnUntraced(function* (input) {
|
|
1173
1715
|
const ref = yield* decodeAlarmRef(input);
|