@gjermundgaraba/effect-actions-cloudflare 0.21.0 → 0.22.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.
@@ -86,7 +86,7 @@ export interface Options<S = never, SE = never, SR = never, A = never, E = never
86
86
  *
87
87
  * - A failure is logged and retried 2 seconds later, doubling, six times, as the runtime retries a plain object's alarm, then hourly until a run succeeds, never dropped: the object's `alarm` resolves, so the runtime records no failure.
88
88
  * - A run an eviction, a deploy or a crash cuts off runs again at its retry's time, counted in `retryCount` as a failure, so a run may repeat: make what it sends idempotent.
89
- * - It runs once per `Alarm.set`: setting a time again replaces it, so a store setting it for its next expiry sets the earliest, and the option sets its next one itself.
89
+ * - It runs once for every time `Alarm.set` gave before its run, at the earliest: the option sets its next one itself.
90
90
  */
91
91
  readonly alarm?: ((info: AlarmInvocationInfo) => Effect.Effect<void, AE, AR>) | undefined;
92
92
  /**
package/dist/alarm.d.mts CHANGED
@@ -1,22 +1,31 @@
1
1
  import { State } from "./state.mjs";
2
2
  import { Context, DateTime, Effect, Layer } from "effect";
3
3
  declare const Alarm_base: Context.ServiceClass<Alarm, "effect-actions-cloudflare/ActionDurableObject/Alarm", {
4
- /** Runs the object's `alarm` option at `at`, in place of any time set before. */
4
+ /**
5
+ * Runs the object's `alarm` option no later than `at`: the earliest time set since it last
6
+ * ran, or a failed run's retry if earlier, so each deadline sets its own. A failed run's
7
+ * retry stays unless it was cleared as it ran with no time set afterwards, or a time set as
8
+ * it ran is earlier.
9
+ *
10
+ * **Gotchas**
11
+ *
12
+ * - A later time leaves an earlier one in place, so the option runs early: postpone with `clear`, then `set`.
13
+ */
5
14
  readonly set: (at: DateTime.DateTime) => Effect.Effect<void>;
6
15
  /** Cancels the time set, if any. */
7
16
  readonly clear: Effect.Effect<void>;
8
17
  }>;
9
18
  /**
10
- * When the object's `alarm` option runs: `set(at)` schedules it, replacing an earlier time, and
11
- * `clear` cancels it. A startup service of the object's services, routes, calls and `run`, as
12
- * `State` is. The object's one runtime alarm is the package's, set for the earliest of the
13
- * times it keeps, the package's own work among them, so an application never sets it itself.
19
+ * When the object's `alarm` option runs: `set(at)` has it run no later than `at`, and `clear`
20
+ * cancels it. A startup service of the object's services, routes, calls and `run`, as `State`
21
+ * is. The object's one runtime alarm is the package's, set for the earliest of the times it
22
+ * keeps, the package's own work among them, so an application never sets it itself.
14
23
  *
15
24
  * **Gotchas**
16
25
  *
17
26
  * - `State`'s `storage.setAlarm` replaces the package's alarm: the times it keeps then run at that alarm, early or late, or never, and the `alarm` option runs only for a time `Alarm.set` gave, so schedule with `Alarm`.
18
27
  * - A firing before every time it keeps, as a repeated one or `runDurableObjectAlarm` in a test, runs none and sets the alarm again: a test runs a time once it has come.
19
- * - A time already past runs at once; the option runs once per `set`, and sets its next itself.
28
+ * - A time already past runs at once; the option runs once for every time set before its run, and sets its next itself.
20
29
  * - The times live in the object's storage, under `effect-actions/alarm/`, beside the application's data: a store deleting every key deletes them too.
21
30
  */
22
31
  export declare class Alarm extends Alarm_base {
package/dist/alarm.mjs CHANGED
@@ -3,10 +3,10 @@ import { Clock, Context, DateTime, Effect, Exit, Layer, Option, Schema, Semaphor
3
3
  // src/durable/alarm.ts
4
4
  /** Where the object's alarm times are kept, one key per owner. */
5
5
  const alarmPrefix = "effect-actions/alarm/";
6
+ const keyOf = (owner) => `${alarmPrefix}${owner}`;
6
7
  const Kept = Schema.Struct({
7
8
  at: Schema.Finite,
8
- failures: Schema.Int,
9
- running: Schema.optional(Schema.Boolean)
9
+ failures: Schema.Int
10
10
  });
11
11
  const keptOf = Schema.decodeUnknownOption(Kept);
12
12
  const backoffRetries = 6;
@@ -20,6 +20,8 @@ const retryDelay = (failures) => failures > backoffRetries ? hour : 2e3 * 2 ** (
20
20
  const makeSchedule = (state) => {
21
21
  const { storage } = state;
22
22
  const lock = Semaphore.makeUnsafe(1);
23
+ const running = /* @__PURE__ */ new Set();
24
+ const read = (owner) => Effect.map(Effect.promise(async () => await storage.get(keyOf(owner))), keptOf);
23
25
  const times = Effect.promise(async () => await storage.list({ prefix: alarmPrefix })).pipe(Effect.map((listed) => [...listed].flatMap(([key, value]) => Option.match(keptOf(value), {
24
26
  onNone: () => [],
25
27
  onSome: (kept) => [{
@@ -34,48 +36,61 @@ const makeSchedule = (state) => {
34
36
  });
35
37
  });
36
38
  const keep = (owner, kept) => Effect.promise(async () => {
37
- await storage.put(`${alarmPrefix}${owner}`, kept);
39
+ await storage.put(keyOf(owner), kept);
38
40
  });
39
41
  return {
40
42
  rearm,
41
43
  restore: lock.withPermits(1)(Effect.flatMap(Effect.promise(async () => await storage.getAlarm()), (alarm) => alarm === null ? Effect.flatMap(times, (kept) => kept.length === 0 ? Effect.void : rearm) : Effect.void)),
42
- set: (owner, at) => lock.withPermits(1)(Effect.flatMap(Effect.promise(async () => await storage.get(`${alarmPrefix}${owner}`)), (stored) => Option.exists(keptOf(stored), (kept) => kept.at === at && kept.failures === 0 && kept.running !== true) ? Effect.void : Effect.andThen(keep(owner, {
44
+ /**
45
+ * Keeps `at` as the owner's time, in place of any: set as the owner's run fails, it gives way
46
+ * to that run's retry where the retry is earlier.
47
+ */
48
+ set: (owner, at) => lock.withPermits(1)(Effect.flatMap(read(owner), (kept) => Option.exists(kept, (held) => held.at === at && held.failures === 0) ? Effect.void : Effect.andThen(keep(owner, {
49
+ at,
50
+ failures: 0
51
+ }), rearm))),
52
+ /**
53
+ * Keeps `at` as the owner's time unless an earlier one is kept, a retry due included: the
54
+ * mark of the owner's run under way is no time, so a time set from the run is kept.
55
+ */
56
+ setBy: (owner, at) => lock.withPermits(1)(Effect.flatMap(read(owner), (kept) => Option.exists(kept, (held) => held.at <= at && (held.failures === 0 || !running.has(owner))) ? Effect.void : Effect.andThen(keep(owner, {
43
57
  at,
44
58
  failures: 0
45
59
  }), rearm))),
46
- clear: (owner) => lock.withPermits(1)(Effect.andThen(Effect.promise(async () => await storage.delete(`${alarmPrefix}${owner}`)), rearm)),
60
+ clear: (owner) => lock.withPermits(1)(Effect.andThen(Effect.promise(async () => await storage.delete(keyOf(owner))), rearm)),
47
61
  /**
48
- * Runs each owner whose time has come as its turn comes, removing its time once its run ends
49
- * unless one was set or cleared as it ran: one that fails is logged and retried, 2 seconds
50
- * later, doubling, six times, then hourly.
62
+ * Runs each owner whose time has come as its turn comes, removing its time once its run
63
+ * succeeds unless one was set or cleared as it ran: one that fails is logged and retried, 2
64
+ * seconds later, doubling, six times, then hourly, unless cleared as it ran or a time set as
65
+ * it ran is earlier.
51
66
  */
52
67
  runDue: (owners) => Effect.gen(function* () {
53
68
  const now = yield* Clock.currentTimeMillis;
54
69
  const kept = yield* times;
55
70
  yield* Effect.forEach(kept.filter(({ at }) => at <= now), ({ owner }) => Effect.gen(function* () {
56
- const key = `${alarmPrefix}${owner}`;
57
71
  const run = owners[owner];
58
- const stored = Effect.map(Effect.promise(async () => await storage.get(key)), (value) => Option.getOrUndefined(keptOf(value)));
59
- const drop = Effect.promise(async () => await storage.delete(key));
72
+ const stored = Effect.map(read(owner), Option.getOrUndefined);
73
+ const drop = Effect.promise(async () => await storage.delete(keyOf(owner)));
60
74
  const due = yield* lock.withPermits(1)(Effect.gen(function* () {
61
75
  const current = yield* stored;
62
76
  if (current === void 0 || current.at > now) return;
63
77
  if (run === void 0) return yield* Effect.as(drop, void 0);
64
- const startedAt = yield* Clock.currentTimeMillis;
65
- yield* keep(owner, {
66
- at: startedAt + retryDelay(current.failures + 1),
67
- failures: current.failures + 1,
68
- running: true
69
- }).pipe(Effect.andThen(rearm), Effect.andThen(Effect.promise(async () => {
78
+ const mark = {
79
+ at: (yield* Clock.currentTimeMillis) + retryDelay(current.failures + 1),
80
+ failures: current.failures + 1
81
+ };
82
+ yield* keep(owner, mark).pipe(Effect.andThen(rearm), Effect.andThen(Effect.promise(async () => {
70
83
  await storage.sync();
71
84
  })));
85
+ running.add(owner);
72
86
  return {
73
87
  ...current,
88
+ mark,
74
89
  run
75
90
  };
76
91
  }));
77
92
  if (due === void 0) return;
78
- const { at, failures } = due;
93
+ const { at, failures, mark } = due;
79
94
  const attempt = failures + 1;
80
95
  const exit = yield* Effect.exit(due.run({
81
96
  retryCount: failures,
@@ -84,14 +99,24 @@ const makeSchedule = (state) => {
84
99
  }));
85
100
  if (Exit.isFailure(exit)) yield* Effect.logError(`The alarm of ${owner} failed: retried in ${retryDelay(attempt)} ms`, exit.cause);
86
101
  const endedAt = yield* Clock.currentTimeMillis;
87
- yield* lock.withPermits(1)(Effect.flatMap(stored, (current) => {
88
- if (current?.running !== true) return Effect.void;
89
- return Exit.isSuccess(exit) ? drop : keep(owner, {
102
+ yield* lock.withPermits(1)(Effect.gen(function* () {
103
+ running.delete(owner);
104
+ const current = yield* stored;
105
+ const isMark = current?.at === mark.at && current.failures === mark.failures;
106
+ if (Exit.isSuccess(exit)) {
107
+ if (isMark) yield* drop;
108
+ return;
109
+ }
110
+ if (current === void 0) return;
111
+ const retry = {
90
112
  at: endedAt + retryDelay(attempt),
91
113
  failures: attempt
92
- });
114
+ };
115
+ if (isMark || retry.at < current.at) yield* keep(owner, retry);
93
116
  }));
94
- }), { discard: true });
117
+ }).pipe(Effect.ensuring(Effect.sync(() => {
118
+ running.delete(owner);
119
+ }))), { discard: true });
95
120
  yield* lock.withPermits(1)(rearm);
96
121
  })
97
122
  };
@@ -100,20 +125,20 @@ const makeSchedule = (state) => {
100
125
  const applicationOwner = "application";
101
126
  /** The application's alarm over `schedule`, the instance's. */
102
127
  const alarmOf = (schedule) => Alarm.of({
103
- set: (at) => schedule.set(applicationOwner, DateTime.toEpochMillis(at)),
128
+ set: (at) => schedule.setBy(applicationOwner, DateTime.toEpochMillis(at)),
104
129
  clear: schedule.clear(applicationOwner)
105
130
  });
106
131
  /**
107
- * When the object's `alarm` option runs: `set(at)` schedules it, replacing an earlier time, and
108
- * `clear` cancels it. A startup service of the object's services, routes, calls and `run`, as
109
- * `State` is. The object's one runtime alarm is the package's, set for the earliest of the
110
- * times it keeps, the package's own work among them, so an application never sets it itself.
132
+ * When the object's `alarm` option runs: `set(at)` has it run no later than `at`, and `clear`
133
+ * cancels it. A startup service of the object's services, routes, calls and `run`, as `State`
134
+ * is. The object's one runtime alarm is the package's, set for the earliest of the times it
135
+ * keeps, the package's own work among them, so an application never sets it itself.
111
136
  *
112
137
  * **Gotchas**
113
138
  *
114
139
  * - `State`'s `storage.setAlarm` replaces the package's alarm: the times it keeps then run at that alarm, early or late, or never, and the `alarm` option runs only for a time `Alarm.set` gave, so schedule with `Alarm`.
115
140
  * - A firing before every time it keeps, as a repeated one or `runDurableObjectAlarm` in a test, runs none and sets the alarm again: a test runs a time once it has come.
116
- * - A time already past runs at once; the option runs once per `set`, and sets its next itself.
141
+ * - A time already past runs at once; the option runs once for every time set before its run, and sets its next itself.
117
142
  * - The times live in the object's storage, under `effect-actions/alarm/`, beside the application's data: a store deleting every key deletes them too.
118
143
  */
119
144
  var Alarm = class Alarm extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Alarm") {
@@ -224,7 +224,7 @@ Give each of a binding's contracts a key of its input, `rooms` in `routes.ts` ab
224
224
 
225
225
  ## Run an alarm
226
226
 
227
- Give `make` an `alarm`, and set when it runs from a handler or a store with the `Alarm` service: the room's store sets it for the oldest message's expiry and, each run, for the next ([chat-rooms/src/platform/sql-store.ts](../examples/chat-rooms/src/platform/sql-store.ts)), and `worker.ts` above gives the alarm the store the routes read. How it runs, and how a failure is retried, are on `ActionDurableObject.Options`' `alarm`; why `State`'s `storage.setAlarm` is not the way, on `ActionDurableObject.Alarm`.
227
+ Give `make` an `alarm`, and set when it runs from a handler or a store with the `Alarm` service, which keeps the earliest time it is given: the room's store sets it for each message's expiry as it is posted and, each run, for the oldest left ([chat-rooms/src/platform/sql-store.ts](../examples/chat-rooms/src/platform/sql-store.ts)), and `worker.ts` above gives the alarm the store the routes read. How it runs, and how a failure is retried, are on `ActionDurableObject.Options`' `alarm`; how to postpone it, and why `State`'s `storage.setAlarm` is not the way, on `ActionDurableObject.Alarm`.
228
228
 
229
229
  ## Call an object from a Worker
230
230
 
@@ -30,17 +30,16 @@ export const layerSql = Layer.effect(MessageStore)(
30
30
  "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)"
31
31
  );
32
32
 
33
- // Every post looks up the oldest message: indexed, it reads one row, where a scan would read
33
+ // Every expiry looks up the oldest message: indexed, it reads one row, where a scan would read
34
34
  // the whole table, and SQLite storage is billed by rows read.
35
35
  storage.sql.exec(
36
36
  "CREATE INDEX IF NOT EXISTS messages_by_at ON messages (at)"
37
37
  );
38
38
 
39
- // The room's alarm is due no later than the oldest message's expiry: `post` and `expire` set
40
- // it for the oldest message, read from the table and set in one step, so posts running at
41
- // once each set it for the oldest they see, and the last to set it has seen them all; reading
42
- // the alarm first would let two posts both see none and the later one win. Removing the
43
- // oldest message leaves its alarm, which then finds nothing due and moves on.
39
+ // The room's alarm is due no later than the oldest message's expiry: `Alarm.set` keeps the
40
+ // earliest time it is given, so each post sets its own message's, and `expire`, which runs on
41
+ // the alarm, sets the next from the oldest message left. Removing the oldest message leaves
42
+ // its alarm, which then finds nothing due and moves on.
44
43
  const alarmForOldest = Effect.suspend(() => {
45
44
  const [oldest] = query("SELECT * FROM messages ORDER BY at LIMIT 1");
46
45
 
@@ -82,7 +81,7 @@ export const layerSql = Layer.effect(MessageStore)(
82
81
  .one()
83
82
  );
84
83
 
85
- yield* alarmForOldest;
84
+ yield* alarm.set(DateTime.makeUnsafe(message.at + day));
86
85
 
87
86
  return message;
88
87
  }),
@@ -7,7 +7,7 @@ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
7
7
  import { evictDurableObject, runInDurableObject } from "cloudflare:test";
8
8
  import { env, exports } from "cloudflare:workers";
9
9
  import type { Schema } from "effect";
10
- import { Effect, Schedule, Stream } from "effect";
10
+ import { DateTime, Effect, Schedule, Stream } from "effect";
11
11
  import { HttpClient, HttpClientRequest } from "effect/http";
12
12
  import { TestClock } from "effect/testing";
13
13
 
@@ -536,21 +536,28 @@ describe("the Worker and its rooms", () => {
536
536
  text: "From yesterday.",
537
537
  });
538
538
 
539
+ const recent = yield* json.postMessage({
540
+ room: "design",
541
+ text: "From today.",
542
+ });
543
+
544
+ // A day passes for the first message alone: it is backdated, and the alarm set for its
545
+ // expiry, as its post would have set it a day ago. That time is past, so the runtime runs
546
+ // it at once: the test waits until it has set the next, for the recent one's.
539
547
  yield* Effect.promise(async () => {
540
- await runInDurableObject(stub, (_, state) => {
548
+ await runInDurableObject(stub, async (room, state) => {
541
549
  state.storage.sql.exec(
542
550
  "UPDATE messages SET at = at - ? WHERE id = ?",
543
551
  day,
544
552
  old.id
545
553
  );
546
- });
547
- });
548
554
 
549
- // The next post sets the alarm for the day-old message's expiry, already past, so the
550
- // runtime runs it at once: the test waits until it has set the next, for the recent one's.
551
- const recent = yield* json.postMessage({
552
- room: "design",
553
- text: "From today.",
555
+ await room.run(
556
+ ActionDurableObject.Alarm.use((alarm) =>
557
+ alarm.set(DateTime.makeUnsafe(old.at))
558
+ )
559
+ );
560
+ });
554
561
  });
555
562
 
556
563
  yield* eventually(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions-cloudflare",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Serve effect-actions from Cloudflare Durable Objects, with Effect RPC over hibernating WebSockets.",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -39,11 +39,11 @@
39
39
  "@cloudflare/vitest-plugin": "1.4.7",
40
40
  "@cloudflare/workers-types": "5.20261025.1",
41
41
  "@effect/vitest": "4.0.6",
42
- "@gjermundgaraba/effect-actions": "0.21.0",
42
+ "@gjermundgaraba/effect-actions": "0.22.0",
43
43
  "effect": "4.0.6"
44
44
  },
45
45
  "peerDependencies": {
46
- "@gjermundgaraba/effect-actions": "^0.21.0",
46
+ "@gjermundgaraba/effect-actions": "^0.22.0",
47
47
  "effect": "~4.0.5"
48
48
  },
49
49
  "scripts": {
@@ -1,3 +0,0 @@
1
- Signature: 8a477f597d28d172789f06886806bc55
2
- # This file is a cache directory tag created by Vitest.
3
- # For information about cache directory tags see https://bford.info/cachedir/
@@ -1 +0,0 @@
1
- {"version":1,"projects":{"":{"files":{"test/worker.test.ts":{"failed":false,"duration":3218,"lastRun":1793050877213},"test/platform/sql-store.test.ts":{"failed":false,"duration":405,"lastRun":1793050877213}}}}}