@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.
- package/dist/ActionDurableObject.d.mts +1 -1
- package/dist/alarm.d.mts +15 -6
- package/dist/alarm.mjs +54 -29
- package/docs/ActionDurableObject.md +1 -1
- package/examples/chat-rooms/src/platform/sql-store.ts +6 -7
- package/examples/chat-rooms/test/worker.test.ts +16 -9
- package/package.json +3 -3
- package/examples/chat-rooms/node_modules/.vite/vitest/CACHEDIR.TAG +0 -3
- package/examples/chat-rooms/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
|
@@ -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
|
|
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
|
-
/**
|
|
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)`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
49
|
-
* unless one was set or cleared as it ran: one that fails is logged and retried, 2
|
|
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(
|
|
59
|
-
const drop = Effect.promise(async () => await storage.delete(
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
})
|
|
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.
|
|
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)`
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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: `
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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*
|
|
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, (
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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.
|
|
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.
|
|
42
|
+
"@gjermundgaraba/effect-actions": "0.22.0",
|
|
43
43
|
"effect": "4.0.6"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@gjermundgaraba/effect-actions": "^0.
|
|
46
|
+
"@gjermundgaraba/effect-actions": "^0.22.0",
|
|
47
47
|
"effect": "~4.0.5"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
@@ -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}}}}}
|