effect-cf 0.55.0 → 0.56.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 +6 -0
- package/dist/DurableObjectAlarm.d.mts +11 -5
- package/dist/DurableObjectAlarm.mjs +72 -41
- package/package.json +1 -1
- package/src/DurableObjectAlarm.ts +149 -80
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# effect-cf
|
|
2
2
|
|
|
3
|
+
## 0.56.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#213](https://github.com/danieljvdm/effect-cf/pull/213) [`b0e1201`](https://github.com/danieljvdm/effect-cf/commit/b0e1201663c3769fa2e63daad6f46a8a8b0d9b05) Thanks [@danieljvdm](https://github.com/danieljvdm)! - Allow alarm transactions to share a Durable Object SQL client's connection and commit boundary. Reduce manual alarm checkpoint writes while preserving recovery guards and stale-dispatch fencing.
|
|
8
|
+
|
|
3
9
|
## 0.55.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -6,8 +6,9 @@ import * as S from "effect/Schema";
|
|
|
6
6
|
import * as Layer from "effect/Layer";
|
|
7
7
|
import * as Duration from "effect/Duration";
|
|
8
8
|
import * as DateTime from "effect/DateTime";
|
|
9
|
+
import { SqlClient } from "effect/sql/SqlClient";
|
|
9
10
|
declare namespace DurableObjectAlarm_d_exports {
|
|
10
|
-
export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
|
|
11
|
+
export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, AlarmTransactionOptions, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
|
|
11
12
|
}
|
|
12
13
|
declare const MIN_RETRY_DELAY_MS = 1000;
|
|
13
14
|
/** Default unchanged-attempt budget. */
|
|
@@ -161,6 +162,11 @@ type ProcessDueAlarmsHandler<R = never, E = never> = (event: DurableObjectAlarmE
|
|
|
161
162
|
* fiber; forked work and use after the callback ends fail with StorageOperationError.
|
|
162
163
|
*/
|
|
163
164
|
type AlarmTransaction = Pick<AlarmScheduler, "scheduleAlarm" | "scheduleAlarmEarlier" | "cancelAlarm">;
|
|
165
|
+
/** Share the same native commit and connection permit as the caller's SQL writes. */
|
|
166
|
+
interface AlarmTransactionOptions {
|
|
167
|
+
/** Must be a storage-backed @effect/sql-sqlite-do client for this Durable Object. */
|
|
168
|
+
readonly sqlClient: SqlClient;
|
|
169
|
+
}
|
|
164
170
|
interface AlarmStatus {
|
|
165
171
|
readonly attempts: number;
|
|
166
172
|
readonly parked: boolean;
|
|
@@ -193,7 +199,7 @@ type AlarmScheduler = {
|
|
|
193
199
|
/**
|
|
194
200
|
* Commits local application storage and logical alarms in one native SQLite
|
|
195
201
|
* Durable Object transaction, reconciling the native alarm before commit.
|
|
196
|
-
* Use the supplied mutations
|
|
202
|
+
* Use the supplied mutations inside the callback instead of standalone alarm methods.
|
|
197
203
|
* SqlClient queries must use this same Durable Object's storage.
|
|
198
204
|
*
|
|
199
205
|
* Failure, defects and interruption before commit roll back. A lost reply or
|
|
@@ -202,7 +208,7 @@ type AlarmScheduler = {
|
|
|
202
208
|
* Cloudflare's native retries are bounded; composition does not remove the
|
|
203
209
|
* pre-arm requirement or promise infinite retry liveness.
|
|
204
210
|
*/
|
|
205
|
-
readonly transaction: <A, E, R>(closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R
|
|
211
|
+
readonly transaction: <A, E, R>(closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>, options?: AlarmTransactionOptions) => Effect.Effect<A, E | StorageOperationError, R>;
|
|
206
212
|
readonly cancelAlarm: (input: AlarmRef) => Effect.Effect<void, InvalidAlarmRefError | StorageOperationError>;
|
|
207
213
|
/** Inspect retained work, including hourly recovery deadlines. */
|
|
208
214
|
readonly getAlarmStatus: (input: AlarmRef) => Effect.Effect<AlarmStatus | undefined, InvalidAlarmRefError | StorageOperationError>;
|
|
@@ -267,7 +273,7 @@ interface DefinedAlarmScheduler<Definitions extends AlarmDefinitions> extends De
|
|
|
267
273
|
readonly [TypedAlarmSchedulerTypeId]: typeof TypedAlarmSchedulerTypeId;
|
|
268
274
|
readonly deferWakes: AlarmScheduler["deferWakes"];
|
|
269
275
|
readonly getAlarmStatus: (input: AlarmRef<keyof Definitions & string>) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
|
|
270
|
-
readonly transaction: <A, E, R>(closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R
|
|
276
|
+
readonly transaction: <A, E, R>(closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>, options?: AlarmTransactionOptions) => Effect.Effect<A, E | StorageOperationError, R>;
|
|
271
277
|
}
|
|
272
278
|
/** Registers a dispatcher and the service authorized to schedule its alarms. */
|
|
273
279
|
interface AlarmRegistration<Self, R = never, E = never> {
|
|
@@ -304,4 +310,4 @@ declare class DurableObjectAlarm extends DurableObjectAlarm_base {
|
|
|
304
310
|
static readonly layer: Layer.Layer<DurableObjectAlarm, never, DurableObjectState>;
|
|
305
311
|
}
|
|
306
312
|
//#endregion
|
|
307
|
-
export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, DurableObjectAlarm_d_exports, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
|
|
313
|
+
export { AlarmDefinitionConfig, AlarmDefinitionEntry, AlarmDefinitionPayload, AlarmDefinitions, AlarmFailurePolicy, AlarmLifecycle, AlarmParked, AlarmPayload, AlarmPayloadSchema, AlarmRef, AlarmRegistration, AlarmReporter, AlarmRetryPolicy, AlarmScheduler, AlarmService, AlarmStatus, AlarmTagClass, AlarmTransaction, AlarmTransactionOptions, DEFAULT_MIN_REPEAT_INTERVAL_MS, DefinedAlarmEvent, DefinedAlarmHandlers, DefinedAlarmScheduler, DefinedAlarmTransaction, DefinedScheduleAlarmInput, DispatchAfterEvent, DurableObjectAlarm, DurableObjectAlarmError, DurableObjectAlarmEvent, DurableObjectAlarm_d_exports, HasDueAlarms, InvalidAlarmPayloadError, InvalidAlarmRefError, InvalidAlarmRegistrationError, InvalidProcessDueAlarmsOptionsError, InvalidRepeatEveryError, InvalidScheduleConfigurationError, MIN_REPEAT_INTERVAL_MS, MIN_RETRY_DELAY_MS, PARKED_RETRY_DELAY_MS, ProcessDueAlarmsFailure, ProcessDueAlarmsFailureAction, ProcessDueAlarmsHandler, ProcessDueAlarmsOptions, ProcessDueAlarmsResult, RunAlarm, ScheduleAlarmInput, ScheduleConfiguration, StoredAlarmDecodeError, Tag, UNCHANGED_ATTEMPT_BUDGET, define, dispatchRawAlarm, mergeAll, processDue };
|
|
@@ -11,9 +11,11 @@ import * as Predicate from "effect/Predicate";
|
|
|
11
11
|
import * as Exit from "effect/Exit";
|
|
12
12
|
import * as Duration from "effect/Duration";
|
|
13
13
|
import * as Clock from "effect/Clock";
|
|
14
|
+
import { SqliteClient } from "@effect/sql-sqlite-do";
|
|
14
15
|
import * as DateTime from "effect/DateTime";
|
|
15
16
|
import * as Deferred from "effect/Deferred";
|
|
16
17
|
import * as Semaphore from "effect/Semaphore";
|
|
18
|
+
import { SqlError } from "effect/sql/SqlError";
|
|
17
19
|
//#region src/DurableObjectAlarm.ts
|
|
18
20
|
var DurableObjectAlarm_exports = /* @__PURE__ */ __exportAll({
|
|
19
21
|
AlarmParked: () => AlarmParked,
|
|
@@ -51,19 +53,16 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
|
|
|
51
53
|
run_at INTEGER NOT NULL,
|
|
52
54
|
wake_at INTEGER NOT NULL DEFAULT 0,
|
|
53
55
|
repeat_every_ms INTEGER,
|
|
54
|
-
payload TEXT NOT NULL
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
revision TEXT NOT NULL,
|
|
59
|
-
attempts INTEGER NOT NULL,
|
|
60
|
-
parked INTEGER NOT NULL,
|
|
56
|
+
payload TEXT NOT NULL,
|
|
57
|
+
revision TEXT NOT NULL DEFAULT '',
|
|
58
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
parked INTEGER NOT NULL DEFAULT 0,
|
|
61
60
|
retry_at INTEGER,
|
|
62
|
-
progress INTEGER NOT NULL
|
|
61
|
+
progress INTEGER NOT NULL DEFAULT -1
|
|
63
62
|
);
|
|
64
63
|
`;
|
|
65
64
|
const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
|
|
66
|
-
const LIFECYCLE_INDEX = "
|
|
65
|
+
const LIFECYCLE_INDEX = "idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id_v2";
|
|
67
66
|
const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
|
|
68
67
|
const MIN_RETRY_DELAY_MS = 1e3;
|
|
69
68
|
/** Minimum and default parked recovery interval. */
|
|
@@ -87,14 +86,8 @@ const ScheduleConfigurationSchema = S.Struct({
|
|
|
87
86
|
}).check(S.makeFilter((configuration) => configuration.parkedRetryDelay >= configuration.minimumRetryDelay ? void 0 : "parkedRetryDelay must be at least minimumRetryDelay"));
|
|
88
87
|
const retryDelay = (attempts, configuration, parked, initialDelay = configuration.minimumRetryDelay) => parked ? configuration.parkedRetryDelay : Math.min(configuration.parkedRetryDelay, initialDelay * 2 ** (attempts - 1));
|
|
89
88
|
const getScheduledEventId = (input) => `effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`;
|
|
90
|
-
const alarmRowsSql =
|
|
91
|
-
|
|
92
|
-
COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
|
|
93
|
-
s.retry_at, COALESCE(s.progress, -1) AS progress
|
|
94
|
-
FROM effect_cf_scheduled_alarms a
|
|
95
|
-
LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
|
|
96
|
-
const sameRevisionSql = `storage_id = ? AND COALESCE(
|
|
97
|
-
(SELECT revision FROM effect_cf_alarm_attempts WHERE storage_id = ?), '') = ?`;
|
|
89
|
+
const alarmRowsSql = "SELECT a.* FROM effect_cf_scheduled_alarms a";
|
|
90
|
+
const sameRevisionSql = "storage_id = ? AND revision = ?";
|
|
98
91
|
/** Content-free: no alarm identifiers, payload, error text or consumer state. */
|
|
99
92
|
const AlarmParked = S.TaggedStruct("AlarmParked", {
|
|
100
93
|
attempts: S.Int,
|
|
@@ -170,6 +163,7 @@ const AlarmRefSchema = S.Struct({
|
|
|
170
163
|
tag: S.NonEmptyString
|
|
171
164
|
});
|
|
172
165
|
const decodeAlarmRef = (input) => S.decodeUnknownEffect(AlarmRefSchema)(input).pipe(Effect.mapError((cause) => new InvalidAlarmRefError({ cause })));
|
|
166
|
+
const StorageSqlClient = S.declare((value) => Predicate.hasProperty(value, SqliteClient.TypeId));
|
|
173
167
|
/** @internal */
|
|
174
168
|
const RunAlarm = Symbol("effect-cf/DurableObjectAlarm/RunAlarm");
|
|
175
169
|
/** @internal */
|
|
@@ -184,15 +178,30 @@ const decodeStoredPayload = (row) => S.decodeUnknownEffect(StoredPayloadString)(
|
|
|
184
178
|
})));
|
|
185
179
|
const encodeStoredPayload = (payload) => S.encodeEffect(StoredPayloadString)(payload).pipe(Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })));
|
|
186
180
|
const ensureTable = Effect.fnUntraced(function* (state) {
|
|
187
|
-
yield* state.storage.sql.exec(INIT_TABLE_SQL);
|
|
188
181
|
if ((yield* (yield* state.storage.sql.exec("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?", LIFECYCLE_INDEX)).toArray()).length > 0) return;
|
|
182
|
+
yield* state.storage.sql.exec(INIT_TABLE_SQL);
|
|
189
183
|
const columns = yield* state.storage.sql.exec("SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')");
|
|
190
184
|
const names = new Set((yield* columns.toArray()).map((column) => column.name));
|
|
191
185
|
if (!names.has("lifecycle")) yield* state.storage.sql.exec("ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'automatic'");
|
|
192
186
|
if (!names.has("wake_at")) yield* state.storage.sql.exec("ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0");
|
|
187
|
+
for (const [column, definition] of Object.entries({
|
|
188
|
+
revision: "TEXT NOT NULL DEFAULT ''",
|
|
189
|
+
attempts: "INTEGER NOT NULL DEFAULT 0",
|
|
190
|
+
parked: "INTEGER NOT NULL DEFAULT 0",
|
|
191
|
+
retry_at: "INTEGER",
|
|
192
|
+
progress: "INTEGER NOT NULL DEFAULT -1"
|
|
193
|
+
})) if (!names.has(column)) yield* state.storage.sql.exec(`ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN ${column} ${definition}`);
|
|
194
|
+
if ((yield* (yield* state.storage.sql.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_cf_alarm_attempts'")).toArray()).length > 0) {
|
|
195
|
+
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
|
|
196
|
+
(revision, attempts, parked, retry_at, progress) =
|
|
197
|
+
(SELECT revision, attempts, parked, retry_at, progress FROM effect_cf_alarm_attempts s
|
|
198
|
+
WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id)
|
|
199
|
+
WHERE storage_id IN (SELECT storage_id FROM effect_cf_alarm_attempts)`);
|
|
200
|
+
yield* state.storage.sql.exec("DROP TABLE effect_cf_alarm_attempts");
|
|
201
|
+
}
|
|
193
202
|
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
|
|
194
|
-
wake_at = MAX(run_at, COALESCE(
|
|
195
|
-
|
|
203
|
+
wake_at = MAX(run_at, COALESCE(retry_at, run_at))`);
|
|
204
|
+
yield* state.storage.sql.exec("DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id");
|
|
196
205
|
yield* state.storage.sql.exec("DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id");
|
|
197
206
|
yield* state.storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${WAKE_INDEX}
|
|
198
207
|
ON effect_cf_scheduled_alarms (wake_at, storage_id)`);
|
|
@@ -346,7 +355,7 @@ const makeDefinition = (definitions) => {
|
|
|
346
355
|
...bind(alarms),
|
|
347
356
|
deferWakes: alarms.deferWakes,
|
|
348
357
|
getAlarmStatus: (input) => alarms.getAlarmStatus(input),
|
|
349
|
-
transaction: (closure) => alarms.transaction((tx) => closure(bind(tx)))
|
|
358
|
+
transaction: (closure, options) => alarms.transaction((tx) => closure(bind(tx)), options)
|
|
350
359
|
}),
|
|
351
360
|
handlers: (handlers, options) => processDue((event) => Effect.gen(function* () {
|
|
352
361
|
const definition = definitionFor(event.tag);
|
|
@@ -443,18 +452,14 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
443
452
|
yield* writeNativeAlarm(next?.run_at ?? null);
|
|
444
453
|
});
|
|
445
454
|
const writeAttempts = Effect.fnUntraced(function* (storageId, attempts, parked, retryAt, progress) {
|
|
446
|
-
yield* state.storage.sql.exec(`
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms
|
|
450
|
-
SET wake_at = MAX(run_at, COALESCE(?, run_at))
|
|
451
|
-
WHERE storage_id = ?`, retryAt, storageId);
|
|
455
|
+
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET revision = ?, attempts = ?, parked = ?,
|
|
456
|
+
retry_at = ?, progress = ?, wake_at = MAX(run_at, COALESCE(?, run_at))
|
|
457
|
+
WHERE storage_id = ?`, crypto.randomUUID(), attempts, parked, retryAt, progress, retryAt, storageId);
|
|
452
458
|
});
|
|
453
459
|
const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input) {
|
|
454
460
|
const ref = yield* decodeAlarmRef(input);
|
|
455
461
|
const storageId = getScheduledEventId(ref);
|
|
456
462
|
yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, storageId);
|
|
457
|
-
yield* state.storage.sql.exec(`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`, storageId);
|
|
458
463
|
});
|
|
459
464
|
const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (input, earlier, reports) {
|
|
460
465
|
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
@@ -464,6 +469,19 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
464
469
|
const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery, configuration.minimumRepeatInterval);
|
|
465
470
|
const payload = yield* encodeStoredPayload(input.payload);
|
|
466
471
|
const storageId = getScheduledEventId(ref);
|
|
472
|
+
const runAt = DateTime.toEpochMillis(input.runAt);
|
|
473
|
+
if (lifecycle === "manual") {
|
|
474
|
+
const deadline = earlier ? "MIN(run_at, excluded.run_at)" : "excluded.run_at";
|
|
475
|
+
const nextPayload = earlier ? "CASE WHEN run_at <= excluded.run_at THEN payload ELSE excluded.payload END" : "excluded.payload";
|
|
476
|
+
yield* state.storage.sql.exec(`INSERT INTO effect_cf_scheduled_alarms
|
|
477
|
+
(storage_id, alarm_id, tag, lifecycle, run_at, wake_at, payload, revision)
|
|
478
|
+
VALUES (?, ?, ?, 'manual', ?, ?, ?, ?)
|
|
479
|
+
ON CONFLICT(storage_id) DO UPDATE SET
|
|
480
|
+
lifecycle = 'manual', run_at = ${deadline}, wake_at = ${deadline},
|
|
481
|
+
payload = ${nextPayload}, revision = excluded.revision,
|
|
482
|
+
repeat_every_ms = NULL, attempts = 0, parked = 0, retry_at = NULL, progress = -1`, storageId, ref.id, ref.tag, runAt, runAt, payload, crypto.randomUUID());
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
467
485
|
const existing = yield* readRow(storageId);
|
|
468
486
|
const pass = yield* CurrentAlarmPass;
|
|
469
487
|
const source = lifecycle === "automatic" && pass?.row.lifecycle === "automatic" ? pass.row : void 0;
|
|
@@ -476,16 +494,15 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
476
494
|
const parked = attempts > 0 && (attempts >= configuration.unchangedAttemptBudget || source?.parked === 1 || existing?.parked === 1) ? 1 : 0;
|
|
477
495
|
const now = yield* Clock.currentTimeMillis;
|
|
478
496
|
const retryAt = attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
|
|
479
|
-
const runAt = DateTime.toEpochMillis(input.runAt);
|
|
480
497
|
const keepEarlier = earlier && existing !== void 0 && existing.run_at <= runAt;
|
|
481
498
|
const scheduledAt = keepEarlier ? existing.run_at : runAt;
|
|
482
499
|
const scheduledPayload = keepEarlier ? existing.payload : payload;
|
|
483
|
-
const scheduledRepeat =
|
|
484
|
-
const nextProgress =
|
|
500
|
+
const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
|
|
501
|
+
const nextProgress = Math.max(progress, input.progress ?? -1);
|
|
485
502
|
yield* state.storage.sql.exec(`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
|
|
486
|
-
(storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload
|
|
487
|
-
|
|
488
|
-
|
|
503
|
+
(storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload,
|
|
504
|
+
wake_at, revision, attempts, parked, retry_at, progress)
|
|
505
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, storageId, ref.id, ref.tag, lifecycle, scheduledAt, scheduledRepeat, scheduledPayload, Math.max(scheduledAt, retryAt ?? scheduledAt), crypto.randomUUID(), attempts, parked, retryAt, nextProgress);
|
|
489
506
|
if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) reports.push(storageId);
|
|
490
507
|
});
|
|
491
508
|
const reportParked = Effect.fnUntraced(function* (storageIds) {
|
|
@@ -503,14 +520,14 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
503
520
|
yield* Effect.exit(Effect.suspend(() => report(event)));
|
|
504
521
|
}
|
|
505
522
|
});
|
|
506
|
-
const transaction = Effect.fnUntraced(function* (closure) {
|
|
523
|
+
const transaction = Effect.fnUntraced(function* (closure, options) {
|
|
507
524
|
const pass = yield* CurrentAlarmPass;
|
|
508
525
|
if (pass !== void 0 && !pass.active) return yield* Effect.fail(new StorageOperationError({
|
|
509
526
|
operation: "alarm.transaction",
|
|
510
527
|
cause: /* @__PURE__ */ new Error("Alarm handlers cannot mutate detached work after their pass ends")
|
|
511
528
|
}));
|
|
512
529
|
const reports = [];
|
|
513
|
-
const
|
|
530
|
+
const body = Effect.withFiber((owner) => {
|
|
514
531
|
let active = true;
|
|
515
532
|
const requireActive = (effect) => Effect.withFiber((fiber) => active && fiber === owner && (pass === void 0 || pass.active) ? effect : Effect.fail(new StorageOperationError({
|
|
516
533
|
operation: "alarm.transaction",
|
|
@@ -528,7 +545,21 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
528
545
|
yield* reconcileAlarm();
|
|
529
546
|
return result;
|
|
530
547
|
});
|
|
531
|
-
})
|
|
548
|
+
});
|
|
549
|
+
const result = yield* options === void 0 ? state.storage.transaction(() => body) : Effect.gen(function* () {
|
|
550
|
+
const client = yield* S.decodeUnknownEffect(StorageSqlClient)(options.sqlClient).pipe(Effect.mapError((cause) => new StorageOperationError({
|
|
551
|
+
operation: "alarm.transaction",
|
|
552
|
+
cause
|
|
553
|
+
})));
|
|
554
|
+
if (client.config.storage !== state.raw.storage) return yield* Effect.fail(new StorageOperationError({
|
|
555
|
+
operation: "alarm.transaction",
|
|
556
|
+
cause: /* @__PURE__ */ new Error("Alarm transactions require this Durable Object's storage-backed SQL client")
|
|
557
|
+
}));
|
|
558
|
+
return yield* client.withTransaction(body).pipe(Effect.mapError((cause) => cause instanceof SqlError ? new StorageOperationError({
|
|
559
|
+
operation: "alarm.transaction",
|
|
560
|
+
cause
|
|
561
|
+
}) : cause));
|
|
562
|
+
});
|
|
532
563
|
yield* reportParked(reports);
|
|
533
564
|
return result;
|
|
534
565
|
});
|
|
@@ -597,11 +628,11 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
597
628
|
});
|
|
598
629
|
const acknowledgeAlarm = Effect.fnUntraced(function* (row, configuration) {
|
|
599
630
|
if (row.repeat_every_ms === null) {
|
|
600
|
-
|
|
631
|
+
yield* state.storage.sql.exec(`DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`, row.storage_id, row.revision);
|
|
601
632
|
return;
|
|
602
633
|
}
|
|
603
634
|
const now = yield* Clock.currentTimeMillis;
|
|
604
|
-
if ((yield* (yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`, now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms), row.storage_id, row.
|
|
635
|
+
if ((yield* (yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`, now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms), row.storage_id, row.revision)).rowsWritten) > 0) yield* writeAttempts(row.storage_id, 0, 0, null, row.progress);
|
|
605
636
|
});
|
|
606
637
|
const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* (handle, options) {
|
|
607
638
|
const configuration = yield* getScheduleConfiguration(configurationDefaults);
|
|
@@ -622,7 +653,7 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
622
653
|
if (manualRows.length > 0) yield* transaction(() => Effect.gen(function* () {
|
|
623
654
|
for (const row of manualRows) {
|
|
624
655
|
const recoveryAt = now + (options?.[ManualTags]?.has(row.tag) ? configuration.inFlightRecovery : configuration.parkedRetryDelay);
|
|
625
|
-
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.
|
|
656
|
+
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.revision);
|
|
626
657
|
}
|
|
627
658
|
}));
|
|
628
659
|
const handled = [];
|
|
@@ -683,7 +714,7 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
|
|
|
683
714
|
const exits = yield* manualRows.length === 0 ? automatic.pipe(Effect.map((exit) => [exit])) : Effect.all([automatic, Effect.forEach(manualRows, (row) => permits.withPermits(1)(handleRow(row)).pipe(Effect.exit), { concurrency: 4 }).pipe(Effect.flatMap(Exit.asVoidAll), Effect.exit)], { concurrency: 2 });
|
|
684
715
|
yield* transaction(() => Effect.gen(function* () {
|
|
685
716
|
const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
|
|
686
|
-
for (const row of manualRows) yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.
|
|
717
|
+
for (const row of manualRows) yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.revision);
|
|
687
718
|
}));
|
|
688
719
|
yield* Exit.asVoidAll(exits);
|
|
689
720
|
return {
|
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SqliteClient } from "@effect/sql-sqlite-do";
|
|
1
2
|
import * as Clock from "effect/Clock";
|
|
2
3
|
import * as Context from "effect/Context";
|
|
3
4
|
import * as Data from "effect/Data";
|
|
@@ -10,6 +11,8 @@ import * as Layer from "effect/Layer";
|
|
|
10
11
|
import * as Predicate from "effect/Predicate";
|
|
11
12
|
import * as S from "effect/Schema";
|
|
12
13
|
import * as Semaphore from "effect/Semaphore";
|
|
14
|
+
import type { SqlClient } from "effect/sql/SqlClient";
|
|
15
|
+
import { SqlError } from "effect/sql/SqlError";
|
|
13
16
|
|
|
14
17
|
import { DurableObjectState } from "./DurableObjectState";
|
|
15
18
|
import { type SqlStorageValue, StorageOperationError } from "./DurableObjectStorage";
|
|
@@ -24,20 +27,17 @@ CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms (
|
|
|
24
27
|
run_at INTEGER NOT NULL,
|
|
25
28
|
wake_at INTEGER NOT NULL DEFAULT 0,
|
|
26
29
|
repeat_every_ms INTEGER,
|
|
27
|
-
payload TEXT NOT NULL
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
revision TEXT NOT NULL,
|
|
32
|
-
attempts INTEGER NOT NULL,
|
|
33
|
-
parked INTEGER NOT NULL,
|
|
30
|
+
payload TEXT NOT NULL,
|
|
31
|
+
revision TEXT NOT NULL DEFAULT '',
|
|
32
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
parked INTEGER NOT NULL DEFAULT 0,
|
|
34
34
|
retry_at INTEGER,
|
|
35
|
-
progress INTEGER NOT NULL
|
|
35
|
+
progress INTEGER NOT NULL DEFAULT -1
|
|
36
36
|
);
|
|
37
37
|
`;
|
|
38
38
|
|
|
39
39
|
const WAKE_INDEX = "idx_effect_cf_scheduled_alarms_wake_at_storage_id";
|
|
40
|
-
const LIFECYCLE_INDEX = "
|
|
40
|
+
const LIFECYCLE_INDEX = "idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id_v2";
|
|
41
41
|
|
|
42
42
|
const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
|
|
43
43
|
|
|
@@ -130,15 +130,8 @@ interface AlarmRow extends Record<string, SqlStorageValue> {
|
|
|
130
130
|
readonly progress: number;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const alarmRowsSql =
|
|
134
|
-
|
|
135
|
-
COALESCE(s.attempts, 0) AS attempts, COALESCE(s.parked, 0) AS parked,
|
|
136
|
-
s.retry_at, COALESCE(s.progress, -1) AS progress
|
|
137
|
-
FROM effect_cf_scheduled_alarms a
|
|
138
|
-
LEFT JOIN effect_cf_alarm_attempts s USING (storage_id)`;
|
|
139
|
-
|
|
140
|
-
const sameRevisionSql = `storage_id = ? AND COALESCE(
|
|
141
|
-
(SELECT revision FROM effect_cf_alarm_attempts WHERE storage_id = ?), '') = ?`;
|
|
133
|
+
const alarmRowsSql = "SELECT a.* FROM effect_cf_scheduled_alarms a";
|
|
134
|
+
const sameRevisionSql = "storage_id = ? AND revision = ?";
|
|
142
135
|
|
|
143
136
|
/** Content-free: no alarm identifiers, payload, error text or consumer state. */
|
|
144
137
|
export const AlarmParked = S.TaggedStruct("AlarmParked", {
|
|
@@ -358,6 +351,16 @@ export type AlarmTransaction = Pick<
|
|
|
358
351
|
"scheduleAlarm" | "scheduleAlarmEarlier" | "cancelAlarm"
|
|
359
352
|
>;
|
|
360
353
|
|
|
354
|
+
/** Share the same native commit and connection permit as the caller's SQL writes. */
|
|
355
|
+
export interface AlarmTransactionOptions {
|
|
356
|
+
/** Must be a storage-backed @effect/sql-sqlite-do client for this Durable Object. */
|
|
357
|
+
readonly sqlClient: SqlClient;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const StorageSqlClient = S.declare<SqliteClient.SqliteClient>(
|
|
361
|
+
(value): value is SqliteClient.SqliteClient => Predicate.hasProperty(value, SqliteClient.TypeId),
|
|
362
|
+
);
|
|
363
|
+
|
|
361
364
|
export interface AlarmStatus {
|
|
362
365
|
readonly attempts: number;
|
|
363
366
|
readonly parked: boolean;
|
|
@@ -401,7 +404,7 @@ export type AlarmScheduler = {
|
|
|
401
404
|
/**
|
|
402
405
|
* Commits local application storage and logical alarms in one native SQLite
|
|
403
406
|
* Durable Object transaction, reconciling the native alarm before commit.
|
|
404
|
-
* Use the supplied mutations
|
|
407
|
+
* Use the supplied mutations inside the callback instead of standalone alarm methods.
|
|
405
408
|
* SqlClient queries must use this same Durable Object's storage.
|
|
406
409
|
*
|
|
407
410
|
* Failure, defects and interruption before commit roll back. A lost reply or
|
|
@@ -412,6 +415,7 @@ export type AlarmScheduler = {
|
|
|
412
415
|
*/
|
|
413
416
|
readonly transaction: <A, E, R>(
|
|
414
417
|
closure: (alarms: AlarmTransaction) => Effect.Effect<A, E, R>,
|
|
418
|
+
options?: AlarmTransactionOptions,
|
|
415
419
|
) => Effect.Effect<A, E | StorageOperationError, R>;
|
|
416
420
|
|
|
417
421
|
readonly cancelAlarm: (
|
|
@@ -465,13 +469,13 @@ const encodeStoredPayload = (payload: AlarmPayload) =>
|
|
|
465
469
|
);
|
|
466
470
|
|
|
467
471
|
const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Service"]) {
|
|
468
|
-
yield* state.storage.sql.exec(INIT_TABLE_SQL);
|
|
469
472
|
const index = yield* state.storage.sql.exec(
|
|
470
473
|
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?",
|
|
471
474
|
LIFECYCLE_INDEX,
|
|
472
475
|
);
|
|
473
476
|
|
|
474
477
|
if ((yield* index.toArray()).length > 0) return;
|
|
478
|
+
yield* state.storage.sql.exec(INIT_TABLE_SQL);
|
|
475
479
|
const columns = yield* state.storage.sql.exec<{ name: string }>(
|
|
476
480
|
"SELECT name FROM pragma_table_info('effect_cf_scheduled_alarms')",
|
|
477
481
|
);
|
|
@@ -487,11 +491,36 @@ const ensureTable = Effect.fnUntraced(function* (state: DurableObjectState["Serv
|
|
|
487
491
|
"ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN wake_at INTEGER NOT NULL DEFAULT 0",
|
|
488
492
|
);
|
|
489
493
|
}
|
|
490
|
-
|
|
491
|
-
|
|
494
|
+
for (const [column, definition] of Object.entries({
|
|
495
|
+
revision: "TEXT NOT NULL DEFAULT ''",
|
|
496
|
+
attempts: "INTEGER NOT NULL DEFAULT 0",
|
|
497
|
+
parked: "INTEGER NOT NULL DEFAULT 0",
|
|
498
|
+
retry_at: "INTEGER",
|
|
499
|
+
progress: "INTEGER NOT NULL DEFAULT -1",
|
|
500
|
+
})) {
|
|
501
|
+
if (!names.has(column))
|
|
502
|
+
yield* state.storage.sql.exec(
|
|
503
|
+
`ALTER TABLE effect_cf_scheduled_alarms ADD COLUMN ${column} ${definition}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const attempts = yield* state.storage.sql.exec(
|
|
507
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_cf_alarm_attempts'",
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
if ((yield* attempts.toArray()).length > 0) {
|
|
511
|
+
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
|
|
512
|
+
(revision, attempts, parked, retry_at, progress) =
|
|
513
|
+
(SELECT revision, attempts, parked, retry_at, progress FROM effect_cf_alarm_attempts s
|
|
514
|
+
WHERE s.storage_id = effect_cf_scheduled_alarms.storage_id)
|
|
515
|
+
WHERE storage_id IN (SELECT storage_id FROM effect_cf_alarm_attempts)`);
|
|
516
|
+
yield* state.storage.sql.exec("DROP TABLE effect_cf_alarm_attempts");
|
|
517
|
+
}
|
|
518
|
+
// The replacement lifecycle index marks the committed single-row migration.
|
|
492
519
|
yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET
|
|
493
|
-
wake_at = MAX(run_at, COALESCE(
|
|
494
|
-
|
|
520
|
+
wake_at = MAX(run_at, COALESCE(retry_at, run_at))`);
|
|
521
|
+
yield* state.storage.sql.exec(
|
|
522
|
+
"DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_lifecycle_wake_at_storage_id",
|
|
523
|
+
);
|
|
495
524
|
yield* state.storage.sql.exec(
|
|
496
525
|
"DROP INDEX IF EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id",
|
|
497
526
|
);
|
|
@@ -683,6 +712,7 @@ export interface DefinedAlarmScheduler<
|
|
|
683
712
|
) => ReturnType<AlarmScheduler["getAlarmStatus"]>;
|
|
684
713
|
readonly transaction: <A, E, R>(
|
|
685
714
|
closure: (alarms: DefinedAlarmTransaction<Definitions>) => Effect.Effect<A, E, R>,
|
|
715
|
+
options?: AlarmTransactionOptions,
|
|
686
716
|
) => Effect.Effect<A, E | StorageOperationError, R>;
|
|
687
717
|
}
|
|
688
718
|
|
|
@@ -950,7 +980,7 @@ const makeDefinition = <const Definitions extends AlarmDefinitions>(definitions:
|
|
|
950
980
|
...bind(alarms),
|
|
951
981
|
deferWakes: alarms.deferWakes,
|
|
952
982
|
getAlarmStatus: (input) => alarms.getAlarmStatus(input),
|
|
953
|
-
transaction: (closure) => alarms.transaction((tx) => closure(bind(tx))),
|
|
983
|
+
transaction: (closure, options) => alarms.transaction((tx) => closure(bind(tx)), options),
|
|
954
984
|
}),
|
|
955
985
|
handlers: <R = never, E = never>(
|
|
956
986
|
handlers: DefinedAlarmHandlers<Definitions, R, E>,
|
|
@@ -1161,21 +1191,14 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1161
1191
|
progress: number,
|
|
1162
1192
|
) {
|
|
1163
1193
|
yield* state.storage.sql.exec(
|
|
1164
|
-
`
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
storageId,
|
|
1194
|
+
`UPDATE effect_cf_scheduled_alarms SET revision = ?, attempts = ?, parked = ?,
|
|
1195
|
+
retry_at = ?, progress = ?, wake_at = MAX(run_at, COALESCE(?, run_at))
|
|
1196
|
+
WHERE storage_id = ?`,
|
|
1168
1197
|
crypto.randomUUID(),
|
|
1169
1198
|
attempts,
|
|
1170
1199
|
parked,
|
|
1171
1200
|
retryAt,
|
|
1172
1201
|
progress,
|
|
1173
|
-
);
|
|
1174
|
-
// Materialize the effective wake in the same transaction for indexed dispatch/reconciliation.
|
|
1175
|
-
yield* state.storage.sql.exec(
|
|
1176
|
-
`UPDATE effect_cf_scheduled_alarms
|
|
1177
|
-
SET wake_at = MAX(run_at, COALESCE(?, run_at))
|
|
1178
|
-
WHERE storage_id = ?`,
|
|
1179
1202
|
retryAt,
|
|
1180
1203
|
storageId,
|
|
1181
1204
|
);
|
|
@@ -1189,10 +1212,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1189
1212
|
`DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`,
|
|
1190
1213
|
storageId,
|
|
1191
1214
|
);
|
|
1192
|
-
yield* state.storage.sql.exec(
|
|
1193
|
-
`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
|
|
1194
|
-
storageId,
|
|
1195
|
-
);
|
|
1196
1215
|
});
|
|
1197
1216
|
|
|
1198
1217
|
const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* (
|
|
@@ -1222,6 +1241,34 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1222
1241
|
);
|
|
1223
1242
|
const payload = yield* encodeStoredPayload(input.payload);
|
|
1224
1243
|
const storageId = getScheduledEventId(ref);
|
|
1244
|
+
const runAt = DateTime.toEpochMillis(input.runAt);
|
|
1245
|
+
|
|
1246
|
+
if (lifecycle === "manual") {
|
|
1247
|
+
// The queue owns attempts. One row carries the aggregate deadline and dispatch fence.
|
|
1248
|
+
const deadline = earlier ? "MIN(run_at, excluded.run_at)" : "excluded.run_at";
|
|
1249
|
+
const nextPayload = earlier
|
|
1250
|
+
? "CASE WHEN run_at <= excluded.run_at THEN payload ELSE excluded.payload END"
|
|
1251
|
+
: "excluded.payload";
|
|
1252
|
+
|
|
1253
|
+
yield* state.storage.sql.exec(
|
|
1254
|
+
`INSERT INTO effect_cf_scheduled_alarms
|
|
1255
|
+
(storage_id, alarm_id, tag, lifecycle, run_at, wake_at, payload, revision)
|
|
1256
|
+
VALUES (?, ?, ?, 'manual', ?, ?, ?, ?)
|
|
1257
|
+
ON CONFLICT(storage_id) DO UPDATE SET
|
|
1258
|
+
lifecycle = 'manual', run_at = ${deadline}, wake_at = ${deadline},
|
|
1259
|
+
payload = ${nextPayload}, revision = excluded.revision,
|
|
1260
|
+
repeat_every_ms = NULL, attempts = 0, parked = 0, retry_at = NULL, progress = -1`,
|
|
1261
|
+
storageId,
|
|
1262
|
+
ref.id,
|
|
1263
|
+
ref.tag,
|
|
1264
|
+
runAt,
|
|
1265
|
+
runAt,
|
|
1266
|
+
payload,
|
|
1267
|
+
crypto.randomUUID(),
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1225
1272
|
const existing = yield* readRow(storageId);
|
|
1226
1273
|
const pass = yield* CurrentAlarmPass;
|
|
1227
1274
|
const source =
|
|
@@ -1259,22 +1306,17 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1259
1306
|
const now = yield* Clock.currentTimeMillis;
|
|
1260
1307
|
const retryAt =
|
|
1261
1308
|
attempts === 0 ? null : now + retryDelay(attempts, configuration, parked === 1);
|
|
1262
|
-
const runAt = DateTime.toEpochMillis(input.runAt);
|
|
1263
1309
|
const keepEarlier = earlier && existing !== undefined && existing.run_at <= runAt;
|
|
1264
1310
|
const scheduledAt = keepEarlier ? existing.run_at : runAt;
|
|
1265
1311
|
const scheduledPayload = keepEarlier ? existing.payload : payload;
|
|
1266
|
-
const scheduledRepeat =
|
|
1267
|
-
|
|
1268
|
-
? null
|
|
1269
|
-
: keepEarlier
|
|
1270
|
-
? existing.repeat_every_ms
|
|
1271
|
-
: repeatEveryMillis;
|
|
1272
|
-
const nextProgress = lifecycle === "manual" ? -1 : Math.max(progress, input.progress ?? -1);
|
|
1312
|
+
const scheduledRepeat = keepEarlier ? existing.repeat_every_ms : repeatEveryMillis;
|
|
1313
|
+
const nextProgress = Math.max(progress, input.progress ?? -1);
|
|
1273
1314
|
|
|
1274
1315
|
yield* state.storage.sql.exec(
|
|
1275
1316
|
`INSERT OR REPLACE INTO effect_cf_scheduled_alarms
|
|
1276
|
-
(storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload
|
|
1277
|
-
|
|
1317
|
+
(storage_id, alarm_id, tag, lifecycle, run_at, repeat_every_ms, payload,
|
|
1318
|
+
wake_at, revision, attempts, parked, retry_at, progress)
|
|
1319
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1278
1320
|
storageId,
|
|
1279
1321
|
ref.id,
|
|
1280
1322
|
ref.tag,
|
|
@@ -1282,8 +1324,13 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1282
1324
|
scheduledAt,
|
|
1283
1325
|
scheduledRepeat,
|
|
1284
1326
|
scheduledPayload,
|
|
1327
|
+
Math.max(scheduledAt, retryAt ?? scheduledAt),
|
|
1328
|
+
crypto.randomUUID(),
|
|
1329
|
+
attempts,
|
|
1330
|
+
parked,
|
|
1331
|
+
retryAt,
|
|
1332
|
+
nextProgress,
|
|
1285
1333
|
);
|
|
1286
|
-
yield* writeAttempts(storageId, attempts, parked, retryAt, nextProgress);
|
|
1287
1334
|
if (parked === 1 && retryAt !== null && (existing?.parked ?? source?.parked ?? 0) === 0) {
|
|
1288
1335
|
reports.push(storageId);
|
|
1289
1336
|
}
|
|
@@ -1310,20 +1357,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1310
1357
|
}
|
|
1311
1358
|
});
|
|
1312
1359
|
|
|
1313
|
-
const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(
|
|
1314
|
-
|
|
1360
|
+
const transaction: AlarmScheduler["transaction"] = Effect.fnUntraced(
|
|
1361
|
+
function* (closure, options) {
|
|
1362
|
+
const pass = yield* CurrentAlarmPass;
|
|
1315
1363
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1364
|
+
if (pass !== undefined && !pass.active) {
|
|
1365
|
+
return yield* Effect.fail(
|
|
1366
|
+
new StorageOperationError({
|
|
1367
|
+
operation: "alarm.transaction",
|
|
1368
|
+
cause: new Error(
|
|
1369
|
+
"Alarm handlers cannot mutate detached work after their pass ends",
|
|
1370
|
+
),
|
|
1371
|
+
}),
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
const reports: string[] = [];
|
|
1375
|
+
const body = Effect.withFiber((owner) => {
|
|
1327
1376
|
let active = true;
|
|
1328
1377
|
const requireActive = <A, E>(effect: Effect.Effect<A, E>) =>
|
|
1329
1378
|
Effect.withFiber<A, E | StorageOperationError>((fiber) =>
|
|
@@ -1361,13 +1410,44 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1361
1410
|
|
|
1362
1411
|
return result;
|
|
1363
1412
|
});
|
|
1364
|
-
})
|
|
1365
|
-
|
|
1413
|
+
});
|
|
1414
|
+
const result = yield* options === undefined
|
|
1415
|
+
? state.storage.transaction(() => body)
|
|
1416
|
+
: Effect.gen(function* () {
|
|
1417
|
+
const client = yield* S.decodeUnknownEffect(StorageSqlClient)(
|
|
1418
|
+
options.sqlClient,
|
|
1419
|
+
).pipe(
|
|
1420
|
+
Effect.mapError(
|
|
1421
|
+
(cause) => new StorageOperationError({ operation: "alarm.transaction", cause }),
|
|
1422
|
+
),
|
|
1423
|
+
);
|
|
1366
1424
|
|
|
1367
|
-
|
|
1425
|
+
if (client.config.storage !== state.raw.storage)
|
|
1426
|
+
return yield* Effect.fail(
|
|
1427
|
+
new StorageOperationError({
|
|
1428
|
+
operation: "alarm.transaction",
|
|
1429
|
+
cause: new Error(
|
|
1430
|
+
"Alarm transactions require this Durable Object's storage-backed SQL client",
|
|
1431
|
+
),
|
|
1432
|
+
}),
|
|
1433
|
+
);
|
|
1368
1434
|
|
|
1369
|
-
|
|
1370
|
-
|
|
1435
|
+
return yield* client
|
|
1436
|
+
.withTransaction(body)
|
|
1437
|
+
.pipe(
|
|
1438
|
+
Effect.mapError((cause) =>
|
|
1439
|
+
cause instanceof SqlError
|
|
1440
|
+
? new StorageOperationError({ operation: "alarm.transaction", cause })
|
|
1441
|
+
: cause,
|
|
1442
|
+
),
|
|
1443
|
+
);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
yield* reportParked(reports);
|
|
1447
|
+
|
|
1448
|
+
return result;
|
|
1449
|
+
},
|
|
1450
|
+
);
|
|
1371
1451
|
|
|
1372
1452
|
const deferWakes: AlarmScheduler["deferWakes"] = (body) =>
|
|
1373
1453
|
Effect.acquireUseRelease(
|
|
@@ -1516,20 +1596,12 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1516
1596
|
configuration: ResolvedScheduleConfiguration,
|
|
1517
1597
|
) {
|
|
1518
1598
|
if (row.repeat_every_ms === null) {
|
|
1519
|
-
|
|
1599
|
+
yield* state.storage.sql.exec(
|
|
1520
1600
|
`DELETE FROM effect_cf_scheduled_alarms WHERE ${sameRevisionSql}`,
|
|
1521
1601
|
row.storage_id,
|
|
1522
|
-
row.storage_id,
|
|
1523
1602
|
row.revision,
|
|
1524
1603
|
);
|
|
1525
1604
|
|
|
1526
|
-
if ((yield* cursor.rowsWritten) > 0) {
|
|
1527
|
-
yield* state.storage.sql.exec(
|
|
1528
|
-
`DELETE FROM effect_cf_alarm_attempts WHERE storage_id = ?`,
|
|
1529
|
-
row.storage_id,
|
|
1530
|
-
);
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
1605
|
return;
|
|
1534
1606
|
}
|
|
1535
1607
|
const now = yield* Clock.currentTimeMillis;
|
|
@@ -1537,7 +1609,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1537
1609
|
`UPDATE effect_cf_scheduled_alarms SET run_at = ? WHERE ${sameRevisionSql}`,
|
|
1538
1610
|
now + Math.max(configuration.minimumRepeatInterval, row.repeat_every_ms),
|
|
1539
1611
|
row.storage_id,
|
|
1540
|
-
row.storage_id,
|
|
1541
1612
|
row.revision,
|
|
1542
1613
|
);
|
|
1543
1614
|
|
|
@@ -1605,7 +1676,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1605
1676
|
recoveryAt,
|
|
1606
1677
|
recoveryAt,
|
|
1607
1678
|
row.storage_id,
|
|
1608
|
-
row.storage_id,
|
|
1609
1679
|
row.revision,
|
|
1610
1680
|
);
|
|
1611
1681
|
}
|
|
@@ -1726,7 +1796,6 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
|
|
|
1726
1796
|
recoveryAt,
|
|
1727
1797
|
recoveryAt,
|
|
1728
1798
|
row.storage_id,
|
|
1729
|
-
row.storage_id,
|
|
1730
1799
|
row.revision,
|
|
1731
1800
|
);
|
|
1732
1801
|
}
|