@worker-protocol/cloudflare 0.7.0 → 0.8.1
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/README.md +48 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/lifecycle.d.ts +77 -0
- package/dist/lifecycle.js +158 -0
- package/dist/queues.d.ts +18 -8
- package/dist/queues.js +123 -56
- package/dist/subscriptions.d.ts +3 -1
- package/dist/subscriptions.js +25 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ to watch in a first deployment.
|
|
|
34
34
|
| `withSubscriptions` | SUB-7's subscriptions, found and ensured in one step | **one** object, never one per shard |
|
|
35
35
|
| `withOutbox` | an outbox, drained when a call ends and retried by the alarm | every object whose changes raise events |
|
|
36
36
|
| `withLogs` | LOG-2's window, filtered and paged in SQL | the object `/logs` reads, and a Tail Worker writes |
|
|
37
|
+
| `withLifecycle` | EVT-15's births and endings of Tasks and Alerts, found by comparing snapshots | over `withOutbox`, in the object whose Facts the Tasks and Alerts derive from |
|
|
37
38
|
|
|
38
39
|
Mixins rather than one base class, because a Worker in production keeps one object per vehicle and
|
|
39
40
|
one for the fleet: the subscriptions belong in the one, an outbox in every other, and a class that
|
|
@@ -155,12 +156,17 @@ export default {
|
|
|
155
156
|
};
|
|
156
157
|
```
|
|
157
158
|
|
|
158
|
-
The consumer of the events Queue
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
The consumer of the events Queue routes each batch through the hub — the subscriptions are read once
|
|
160
|
+
per type for the whole batch — and **makes the first attempt at every delivery itself**, reading
|
|
161
|
+
each subscription once, then hands each event to the broker. Only what the hub asks to retry goes to
|
|
162
|
+
the deliveries Queue, with the delay it asked for: each Queue costs a batch wait, and an event that
|
|
163
|
+
crossed both before its first attempt arrived seconds after the Fact that caused it. A sink slower
|
|
164
|
+
than the hub's `attemptTimeoutMs` (ten seconds by default) holds the batch that long and is retried
|
|
165
|
+
like any failure. An event the Worker's own declaration refuses — an undeclared type (EVT-12) or
|
|
166
|
+
an undeclared extension (EVT-18) — is set aside rather than retried, since no retry would mend it,
|
|
167
|
+
and a failure to reach the store retries the batch whole, once. The consumer of the deliveries
|
|
168
|
+
Queue runs the later attempts and hands each decision back: `retry({ delaySeconds })`, or `ack()`,
|
|
169
|
+
backing off from the larger of the attempt the delivery carries and the one the Queue counted.
|
|
164
170
|
|
|
165
171
|
What is given up goes to the dead-letter queue as a `GivenUp`, tagged by its `kind` — a delivery
|
|
166
172
|
refused for good, outside EVT-8's window or abandoned with its subscription, with the reason; or an
|
|
@@ -169,6 +175,42 @@ through `/logs`. Name the same queue as each consumer's `dead_letter_queue` in
|
|
|
169
175
|
`wrangler.jsonc`, so it also receives what the platform drops after `max_retries`, and set
|
|
170
176
|
`max_retries` well above what the hub asks for: its default of 3 cuts a delivery short.
|
|
171
177
|
|
|
178
|
+
## The lifecycle of Tasks and Alerts
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
export class Intake extends withLifecycle(withOutbox(DurableObject<Env>, { events: (env) => env.EVENTS })) {
|
|
182
|
+
snapshot(at: number) {
|
|
183
|
+
return { tasks: openTasks(at, this.facts()), alerts: alerts(at, this.facts()) };
|
|
184
|
+
}
|
|
185
|
+
nextChange(after: number) {
|
|
186
|
+
return nextMinute(after); // or the next deadline, the end of an incident
|
|
187
|
+
}
|
|
188
|
+
async pause(source: string, now: number) {
|
|
189
|
+
this.changing(now, () => this.write(source, now)); // owes what the write changed
|
|
190
|
+
await this.flush();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A Worker that derives its Tasks and Alerts from Facts supplies the two methods above, and
|
|
196
|
+
`withLifecycle` owes their births and endings (EVT-15). It compares at every instant `nextChange`
|
|
197
|
+
names since the last one compared — the mark survives the object, so a missed run leaves no gap, and
|
|
198
|
+
one further behind than `catchUpMs` gives the rest up and calls `lifecycleSkipped` — and an Action's
|
|
199
|
+
`changing(now, write)` compares the instant before its write and after it, in the same transaction.
|
|
200
|
+
Every id is derived from the transition, the resource and its `since`, and the ids owed are kept for
|
|
201
|
+
`keepAnnouncedMs`, so a change seen twice is owed once; an ending whose birth was never announced
|
|
202
|
+
brings its birth with it. `heartbeat()` compares up to now and points the shared alarm at the next
|
|
203
|
+
instant; a cron that calls it starts the chain and restarts it if an alarm is lost.
|
|
204
|
+
|
|
205
|
+
**What a mixin does internally is a function of its module, never a method.** `private` is
|
|
206
|
+
TypeScript's alone: at run time it is a method on the prototype, and a Worker whose class declares
|
|
207
|
+
one of the same name replaces it silently. Only the methods each mixin's interface lists are on the
|
|
208
|
+
object.
|
|
209
|
+
|
|
210
|
+
`withSubscriptions` also answers `wantedHere(source, events)`: which events a live subscription it
|
|
211
|
+
holds would receive, synchronously, so an object that keeps an outbox and the subscriptions leaves
|
|
212
|
+
the rest out inside the transaction of the write that raised them.
|
|
213
|
+
|
|
172
214
|
## Also exported
|
|
173
215
|
|
|
174
216
|
`durableOutcomes`, `durableSubscriptions` and `durableLogs` turn a stub into the store `mount()`
|
package/dist/index.d.ts
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
*
|
|
10
10
|
* **One mixin per piece**, because a Worker in production keeps one object per vehicle and one for
|
|
11
11
|
* the fleet, and each object should carry only what it holds: the subscriptions in the one, an
|
|
12
|
-
* outbox in every other. A Worker with a single object composes all
|
|
12
|
+
* outbox in every other. A Worker with a single object composes all of them in it; one that
|
|
13
|
+
* publishes the lifecycle of its Tasks and Alerts from snapshots adds `withLifecycle` over its
|
|
14
|
+
* outbox.
|
|
13
15
|
*/
|
|
14
16
|
export type { DurableObjectClass, EnvOf, Mixed } from "./durable.ts";
|
|
17
|
+
export { type Advanced, type LifecycleMethods, type LifecycleSnapshot, lifecycleEventsBetween, withLifecycle, } from "./lifecycle.ts";
|
|
15
18
|
export { durableLogs, type LogMethods, type LogRow, type LogRows, type LogsQuery, type LogsRpc, tailRecords, withLogs, } from "./logs.ts";
|
|
16
19
|
export { type Flushed, type OutboxEvent, type OutboxMethods, withOutbox } from "./outbox.ts";
|
|
17
20
|
export { durableOutcomes, type OutcomeMethods, type OutcomesRpc, withOutcomes, } from "./outcomes.ts";
|
package/dist/index.js
CHANGED
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* **One mixin per piece**, because a Worker in production keeps one object per vehicle and one for
|
|
11
11
|
* the fleet, and each object should carry only what it holds: the subscriptions in the one, an
|
|
12
|
-
* outbox in every other. A Worker with a single object composes all
|
|
12
|
+
* outbox in every other. A Worker with a single object composes all of them in it; one that
|
|
13
|
+
* publishes the lifecycle of its Tasks and Alerts from snapshots adds `withLifecycle` over its
|
|
14
|
+
* outbox.
|
|
13
15
|
*/
|
|
16
|
+
export { lifecycleEventsBetween, withLifecycle, } from "./lifecycle.js";
|
|
14
17
|
export { durableLogs, tailRecords, withLogs, } from "./logs.js";
|
|
15
18
|
export { withOutbox } from "./outbox.js";
|
|
16
19
|
export { durableOutcomes, withOutcomes, } from "./outcomes.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type Alert, type OpenTask } from "@worker-protocol/hono";
|
|
2
|
+
import { type DurableObjectClass, type Mixed } from "./durable.ts";
|
|
3
|
+
import type { OutboxEvent, OutboxMethods } from "./outbox.ts";
|
|
4
|
+
/**
|
|
5
|
+
* When a Task or an Alert is born and when it ends (EVT-15), owed from a Durable Object that derives
|
|
6
|
+
* both from its own Facts.
|
|
7
|
+
*
|
|
8
|
+
* **There is one way of finding out, and the Worker supplies two things to it.** `snapshot(at)` is
|
|
9
|
+
* the Tasks and Alerts whose conditions hold at an instant, and `nextChange(after)` is the next
|
|
10
|
+
* instant at which one can begin or end without anybody acting — a minute boundary, the end of an
|
|
11
|
+
* incident, a deadline. Everything else is here, because every Worker that publishes its lifecycle
|
|
12
|
+
* from snapshots wrote it again:
|
|
13
|
+
*
|
|
14
|
+
* - **Time, which nobody witnesses,** is compared at every instant `nextChange` names between the
|
|
15
|
+
* last one compared and now. The mark of the last instant survives the object, so a run that was
|
|
16
|
+
* missed leaves no gap; one that fell further behind than `catchUpMs` gives the rest up and says
|
|
17
|
+
* so, and one run compares at most `mostPerRunMs`, so a catch-up is spread over several.
|
|
18
|
+
* - **An Action** compares the instant it acts at before its write and after it, in the same
|
|
19
|
+
* transaction (`changing`), and owes what changed in the same call — the moment only it knows.
|
|
20
|
+
* - **Every id is derived, never drawn:** the transition, the resource's id and its `since`. So an
|
|
21
|
+
* instant compared twice, an Action and the heartbeat seeing the same change, or an alarm
|
|
22
|
+
* delivered again all name the same event, and the ids already owed are remembered for
|
|
23
|
+
* `keepAnnouncedMs` so that none is owed twice.
|
|
24
|
+
* - **Nothing announces an end without its beginning.** A Task an Action ended before the heartbeat
|
|
25
|
+
* compared the instant it was born at was never announced, so its birth is owed with its end.
|
|
26
|
+
* - **The heartbeat keeps itself going** on the one alarm `withOutbox` shares, at the next instant
|
|
27
|
+
* `nextChange` names. A cron that calls `heartbeat()` every so often is what starts the chain and
|
|
28
|
+
* restarts it if an alarm is ever lost.
|
|
29
|
+
*
|
|
30
|
+
* It composes over `withOutbox`, whose `enqueue` it owes into and whose alarm it shares.
|
|
31
|
+
*/
|
|
32
|
+
/** The Tasks and Alerts whose conditions hold at one instant. */
|
|
33
|
+
export type LifecycleSnapshot = {
|
|
34
|
+
tasks: OpenTask[];
|
|
35
|
+
alerts: Alert[];
|
|
36
|
+
};
|
|
37
|
+
/** What one comparison run did: the instants it covered, what it owed, and what it gave up. */
|
|
38
|
+
export type Advanced = {
|
|
39
|
+
from: number;
|
|
40
|
+
to: number;
|
|
41
|
+
owed: number;
|
|
42
|
+
/** The instants it did not compare because it had fallen further behind than `catchUpMs`. */
|
|
43
|
+
skipped?: {
|
|
44
|
+
from: number;
|
|
45
|
+
to: number;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** What `withLifecycle` adds to a Durable Object, and the two methods it asks of it. */
|
|
49
|
+
export interface LifecycleMethods {
|
|
50
|
+
/** The Tasks and Alerts whose conditions hold at `at`, read synchronously from this object. */
|
|
51
|
+
snapshot(at: number): LifecycleSnapshot;
|
|
52
|
+
/** The first instant after `after` at which one can begin or end without anybody acting. */
|
|
53
|
+
nextChange(after: number): number;
|
|
54
|
+
changing<T>(now: number, write: () => T): T;
|
|
55
|
+
advance(now: number): Promise<Advanced>;
|
|
56
|
+
heartbeat(): Promise<Advanced & {
|
|
57
|
+
next: number;
|
|
58
|
+
}>;
|
|
59
|
+
lifecycleSkipped(range: {
|
|
60
|
+
from: number;
|
|
61
|
+
to: number;
|
|
62
|
+
}): void;
|
|
63
|
+
}
|
|
64
|
+
/** What was born and what ended between two snapshots; an ending is dated `at`, after its birth. */
|
|
65
|
+
export declare function lifecycleEventsBetween(given: {
|
|
66
|
+
previous: LifecycleSnapshot;
|
|
67
|
+
current: LifecycleSnapshot;
|
|
68
|
+
at: number;
|
|
69
|
+
}): OutboxEvent[];
|
|
70
|
+
export declare function withLifecycle<B extends Mixed<DurableObjectClass, OutboxMethods>>(Base: B, options?: {
|
|
71
|
+
/** How far behind the comparison may fall before the instants in between are given up. */
|
|
72
|
+
catchUpMs?: number;
|
|
73
|
+
/** How much one run compares at most, so a catch-up is spread over several. */
|
|
74
|
+
mostPerRunMs?: number;
|
|
75
|
+
/** How long an owed id is remembered: well past any instant that could be compared again. */
|
|
76
|
+
keepAnnouncedMs?: number;
|
|
77
|
+
}): Mixed<B, LifecycleMethods>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { alertEnded, alertRaised, lifecycleChanges, taskEnded, taskRaised, } from "@worker-protocol/hono";
|
|
2
|
+
import { first } from "./durable.js";
|
|
3
|
+
import { migrate } from "./schema.js";
|
|
4
|
+
const MINUTE = 60_000;
|
|
5
|
+
/** `migrate` applies these; a new step goes at the end, and a published one is never edited. */
|
|
6
|
+
const SCHEMA = {
|
|
7
|
+
piece: "worker-protocol.lifecycle",
|
|
8
|
+
steps: [
|
|
9
|
+
[
|
|
10
|
+
// Every lifecycle event ever owed, by id, so none is owed twice.
|
|
11
|
+
`CREATE TABLE IF NOT EXISTS wp_lifecycle_owed (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
at INTEGER NOT NULL
|
|
14
|
+
)`,
|
|
15
|
+
"CREATE INDEX IF NOT EXISTS wp_lifecycle_owed_at ON wp_lifecycle_owed (at)",
|
|
16
|
+
// The last instant compared, one row.
|
|
17
|
+
`CREATE TABLE IF NOT EXISTS wp_lifecycle_mark (
|
|
18
|
+
one INTEGER PRIMARY KEY CHECK (one = 1),
|
|
19
|
+
at INTEGER NOT NULL
|
|
20
|
+
)`,
|
|
21
|
+
],
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
const idOf = (transition, resource) => `${transition}:${resource.id}:${resource.since.getTime()}`;
|
|
25
|
+
/** A birth carries the `since` the resource declares (TASK-28, ALRT-3) as its `time`. */
|
|
26
|
+
const bornTask = (task) => ({
|
|
27
|
+
...taskRaised(task),
|
|
28
|
+
id: idOf("task-raised", task),
|
|
29
|
+
time: task.since.getTime(),
|
|
30
|
+
});
|
|
31
|
+
const bornAlert = (alert) => ({
|
|
32
|
+
...alertRaised(alert),
|
|
33
|
+
id: idOf("alert-raised", alert),
|
|
34
|
+
time: alert.since.getTime(),
|
|
35
|
+
});
|
|
36
|
+
/** What was born and what ended between two snapshots; an ending is dated `at`, after its birth. */
|
|
37
|
+
export function lifecycleEventsBetween(given) {
|
|
38
|
+
const { previous, current, at } = given;
|
|
39
|
+
const tasks = lifecycleChanges({ previous: previous.tasks, current: current.tasks });
|
|
40
|
+
const alerts = lifecycleChanges({ previous: previous.alerts, current: current.alerts });
|
|
41
|
+
return [
|
|
42
|
+
...tasks.raised.map(bornTask),
|
|
43
|
+
...tasks.ended.flatMap((task) => [
|
|
44
|
+
bornTask(task),
|
|
45
|
+
{ ...taskEnded(task), id: idOf("task-ended", task), time: at },
|
|
46
|
+
]),
|
|
47
|
+
...alerts.raised.map(bornAlert),
|
|
48
|
+
...alerts.ended.flatMap((alert) => [
|
|
49
|
+
bornAlert(alert),
|
|
50
|
+
{ ...alertEnded(alert), id: idOf("alert-ended", alert), time: at },
|
|
51
|
+
]),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Owes each event not owed before, in the transaction the caller is in, and answers how many were
|
|
56
|
+
* new. The outbox holds an id back only while it waits; this remembers it after.
|
|
57
|
+
*
|
|
58
|
+
* **A function of the module and not a method of the mixin, and that is the rule for every mixin
|
|
59
|
+
* here.** `private` is TypeScript's alone: at run time it is a method on the prototype like any
|
|
60
|
+
* other, and `Mixed` does not carry it, so a Worker whose class declares a method of the same name
|
|
61
|
+
* replaces it without a word from the compiler or the runtime. Only what a mixin publishes as its
|
|
62
|
+
* interface is a method; what it does internally is reached by no subclass.
|
|
63
|
+
*/
|
|
64
|
+
function owe(given) {
|
|
65
|
+
const { storage, enqueue, now, events } = given;
|
|
66
|
+
const sql = migrate(storage, SCHEMA);
|
|
67
|
+
// `RETURNING` answers a row only for an id this inserted, and nothing for one already there.
|
|
68
|
+
const fresh = events.filter((event) => sql
|
|
69
|
+
.exec("INSERT INTO wp_lifecycle_owed (id, at) VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING id", event.id, now)
|
|
70
|
+
.toArray().length > 0);
|
|
71
|
+
enqueue(now, fresh);
|
|
72
|
+
return fresh.length;
|
|
73
|
+
}
|
|
74
|
+
export function withLifecycle(Base, options = {}) {
|
|
75
|
+
const catchUpMs = options.catchUpMs ?? 120 * MINUTE;
|
|
76
|
+
const mostPerRunMs = options.mostPerRunMs ?? 60 * MINUTE;
|
|
77
|
+
const keepAnnouncedMs = options.keepAnnouncedMs ?? 2 * 24 * 60 * MINUTE;
|
|
78
|
+
class WithLifecycle extends Base {
|
|
79
|
+
/**
|
|
80
|
+
* Performs `write` and owes what it changed: the Tasks and Alerts at `now` before it and after
|
|
81
|
+
* it, compared in the same transaction as the write. `write` is synchronous, because the
|
|
82
|
+
* comparison is only true of the write if nothing interleaves between the three.
|
|
83
|
+
*/
|
|
84
|
+
changing(now, write) {
|
|
85
|
+
const before = this.snapshot(now);
|
|
86
|
+
const done = write();
|
|
87
|
+
owe({
|
|
88
|
+
storage: this.ctx.storage,
|
|
89
|
+
enqueue: (at, events) => this.enqueue(at, events),
|
|
90
|
+
now,
|
|
91
|
+
events: lifecycleEventsBetween({ previous: before, current: this.snapshot(now), at: now }),
|
|
92
|
+
});
|
|
93
|
+
return done;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Compares from the last instant compared up to `now`, at every instant `nextChange` names,
|
|
97
|
+
* owes what was born and what ended, and sends it. The first run only sets the mark, because
|
|
98
|
+
* what was born before anybody watched was not seen being born.
|
|
99
|
+
*/
|
|
100
|
+
async advance(now) {
|
|
101
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
102
|
+
const mark = first(sql.exec("SELECT at FROM wp_lifecycle_mark WHERE one = 1"))?.at;
|
|
103
|
+
let from = mark ?? now;
|
|
104
|
+
let skipped;
|
|
105
|
+
if (now - from > catchUpMs) {
|
|
106
|
+
skipped = { from, to: now - catchUpMs };
|
|
107
|
+
from = now - catchUpMs;
|
|
108
|
+
this.lifecycleSkipped(skipped);
|
|
109
|
+
}
|
|
110
|
+
const to = Math.min(now, from + mostPerRunMs);
|
|
111
|
+
const owed = [];
|
|
112
|
+
if (to > from) {
|
|
113
|
+
let previous = this.snapshot(from);
|
|
114
|
+
let at = from;
|
|
115
|
+
while (at < to) {
|
|
116
|
+
const asked = this.nextChange(at);
|
|
117
|
+
// An answer that does not move forward is read as nothing before `to`.
|
|
118
|
+
const next = asked > at ? Math.min(asked, to) : to;
|
|
119
|
+
const current = this.snapshot(next);
|
|
120
|
+
owed.push(...lifecycleEventsBetween({ previous, current, at: next }));
|
|
121
|
+
previous = current;
|
|
122
|
+
at = next;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const fresh = owe({
|
|
126
|
+
storage: this.ctx.storage,
|
|
127
|
+
enqueue: (at, events) => this.enqueue(at, events),
|
|
128
|
+
now,
|
|
129
|
+
events: owed,
|
|
130
|
+
});
|
|
131
|
+
sql.exec("INSERT OR REPLACE INTO wp_lifecycle_mark (one, at) VALUES (1, ?)", Math.max(to, from));
|
|
132
|
+
sql.exec("DELETE FROM wp_lifecycle_owed WHERE at < ?", now - keepAnnouncedMs);
|
|
133
|
+
await this.flush();
|
|
134
|
+
return { from, to, owed: fresh, ...(skipped === undefined ? {} : { skipped }) };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Compares up to now, then asks the one alarm to come back at the next instant anything can
|
|
138
|
+
* change at. A cron calls it to start the chain and to restart it if an alarm was ever lost;
|
|
139
|
+
* after that the alarm keeps it going on its own (`wake`).
|
|
140
|
+
*/
|
|
141
|
+
async heartbeat() {
|
|
142
|
+
const advanced = await this.advance(Date.now());
|
|
143
|
+
const next = this.nextChange(Date.now());
|
|
144
|
+
await this.wakeAt(next);
|
|
145
|
+
return { ...advanced, next };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Told when a run gave instants up, for a Worker that keeps records to write one. Nothing,
|
|
149
|
+
* unless overridden.
|
|
150
|
+
*/
|
|
151
|
+
lifecycleSkipped(_range) { }
|
|
152
|
+
/** The alarm `heartbeat` asked for. A Worker that needs the alarm too calls `super.wake()`. */
|
|
153
|
+
async wake() {
|
|
154
|
+
await this.heartbeat();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return WithLifecycle;
|
|
158
|
+
}
|
package/dist/queues.d.ts
CHANGED
|
@@ -4,12 +4,20 @@ import type { OutboxEvent } from "./outbox.ts";
|
|
|
4
4
|
/**
|
|
5
5
|
* The two Queues between an outbox and a sink, and the one handler that consumes both.
|
|
6
6
|
*
|
|
7
|
-
* **Events, then deliveries.** Every object's `flush()` sends its events to
|
|
8
|
-
* consumer
|
|
9
|
-
* whole batch, from the one object that holds them — and
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* **Events, then the first attempt, then deliveries.** Every object's `flush()` sends its events to
|
|
8
|
+
* the events Queue. Its consumer routes each batch through the hub — the subscriptions are read once
|
|
9
|
+
* per type for the whole batch, from the one object that holds them — and makes the first attempt
|
|
10
|
+
* at every delivery itself, reading each subscription once. Only what the hub asks to retry goes to
|
|
11
|
+
* the deliveries Queue, with the delay it asked for, and that Queue's consumer makes the later
|
|
12
|
+
* attempts. Fanning out from each object instead would make a run that touches thousands of them
|
|
13
|
+
* thousands of calls to that one object, and an outage of it would leave every outbox retrying.
|
|
14
|
+
*
|
|
15
|
+
* **The first attempt is made here because each Queue costs a batch wait.** An event that crossed
|
|
16
|
+
* both Queues before its first attempt paid the wait twice, which put it seconds behind the Fact
|
|
17
|
+
* that caused it. A sink slower than the hub's `attemptTimeoutMs` holds the batch that long and no
|
|
18
|
+
* longer, and is retried from the deliveries Queue like any other failure. If the first attempts
|
|
19
|
+
* cannot be made at all — the subscriptions object is unreachable — the deliveries are queued as
|
|
20
|
+
* they were and nothing is lost.
|
|
13
21
|
*
|
|
14
22
|
* **What is given up stays visible.** A delivery `deliver()` gives up on, and an event of a type the
|
|
15
23
|
* Worker does not declare, go to the dead-letter queue with the reason, to be inspected and never
|
|
@@ -26,8 +34,9 @@ type Hub = ReturnType<typeof eventHub>;
|
|
|
26
34
|
export declare const deliveryQueue: (queue: Queue<Delivery>) => DeliveryQueue;
|
|
27
35
|
/**
|
|
28
36
|
* What the consumer sets aside in the dead-letter queue, tagged by what it is: a delivery given up,
|
|
29
|
-
* with why, or an event
|
|
30
|
-
* own code, which no
|
|
37
|
+
* with why, or an event the Worker's own declaration refuses — a type it does not declare (EVT-12),
|
|
38
|
+
* or an extension its type does not (EVT-18). Both are mistakes in the Worker's own code, which no
|
|
39
|
+
* retry would mend; `detail` says which.
|
|
31
40
|
*/
|
|
32
41
|
export type GivenUp = {
|
|
33
42
|
kind: "delivery";
|
|
@@ -37,6 +46,7 @@ export type GivenUp = {
|
|
|
37
46
|
kind: "event";
|
|
38
47
|
event: OutboxEvent;
|
|
39
48
|
reason: "undeclared";
|
|
49
|
+
detail: string;
|
|
40
50
|
};
|
|
41
51
|
export type QueuesConfig<E> = {
|
|
42
52
|
/** The Queue names, as `wrangler.jsonc` gives them: how the handler tells the two batches apart. */
|
package/dist/queues.js
CHANGED
|
@@ -6,8 +6,11 @@ const backoff = (attempts) => Math.min(10 * 2 ** Math.max(attempts - 1, 0), 3600
|
|
|
6
6
|
* hub has several. The chunks are independent — the Queue promises no order — so they go together.
|
|
7
7
|
*/
|
|
8
8
|
export const deliveryQueue = (queue) => ({
|
|
9
|
-
send: async (delivery) => {
|
|
10
|
-
await queue.send(delivery, {
|
|
9
|
+
send: async (delivery, options) => {
|
|
10
|
+
await queue.send(delivery, {
|
|
11
|
+
contentType: "json",
|
|
12
|
+
...(options?.delaySeconds === undefined ? {} : { delaySeconds: options.delaySeconds }),
|
|
13
|
+
});
|
|
11
14
|
},
|
|
12
15
|
sendBatch: async (deliveries) => {
|
|
13
16
|
const chunks = [];
|
|
@@ -41,35 +44,64 @@ export function consumeQueues(config) {
|
|
|
41
44
|
// The record is the courtesy copy; the dead-letter queue is the one that is kept.
|
|
42
45
|
}
|
|
43
46
|
};
|
|
47
|
+
/** A delivery given up, set aside with why, and a record of it where the Worker keeps logs. */
|
|
48
|
+
const giveUp = (given) => {
|
|
49
|
+
const { delivery, gaveUp } = given;
|
|
50
|
+
return setAside({
|
|
51
|
+
message: given.message,
|
|
52
|
+
kept: { kind: "delivery", delivery, gaveUp },
|
|
53
|
+
deadLetter: given.deadLetter,
|
|
54
|
+
rows: given.rows,
|
|
55
|
+
row: {
|
|
56
|
+
at: Date.now(),
|
|
57
|
+
level: "warn",
|
|
58
|
+
message: "a delivery was given up",
|
|
59
|
+
fields: {
|
|
60
|
+
subscription: delivery.subscription,
|
|
61
|
+
event: delivery.event.id,
|
|
62
|
+
type: delivery.event.type,
|
|
63
|
+
reason: gaveUp.reason,
|
|
64
|
+
...(gaveUp.status === null ? {} : { status: gaveUp.status }),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
};
|
|
44
69
|
async function events(batch, env) {
|
|
45
70
|
const hub = config.hub(env);
|
|
46
71
|
const deadLetter = config.deadLetter(env);
|
|
47
72
|
const rows = [];
|
|
48
|
-
// EVT-12, asked first: an event the Worker
|
|
49
|
-
// set aside at once rather than failing the batch beside it a hundred
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
// EVT-12, EVT-18, asked first: an event the Worker's own declaration refuses can never be
|
|
74
|
+
// published, so it is set aside at once rather than failing the batch beside it a hundred
|
|
75
|
+
// times over.
|
|
76
|
+
const declared = batch.messages.filter((message) => hub.fault(message.body) === undefined);
|
|
77
|
+
const refused = batch.messages.filter((message) => hub.fault(message.body) !== undefined);
|
|
78
|
+
await Promise.all(refused.map((message) => {
|
|
79
|
+
const detail = hub.fault(message.body);
|
|
80
|
+
return setAside({
|
|
81
|
+
message,
|
|
82
|
+
kept: { kind: "event", event: message.body, reason: "undeclared", detail },
|
|
83
|
+
deadLetter,
|
|
84
|
+
rows,
|
|
85
|
+
row: {
|
|
86
|
+
at: Date.now(),
|
|
87
|
+
level: "error",
|
|
88
|
+
message: "an event this Worker's declaration refuses was set aside",
|
|
89
|
+
fields: { event: message.body.id, type: message.body.type, detail },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}));
|
|
64
93
|
let published;
|
|
65
94
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
const routed = declared.length === 0
|
|
96
|
+
? { events: [], deliveries: [] }
|
|
97
|
+
: await hub.route(declared.map((message) => message.body));
|
|
98
|
+
await attemptFirst({ hub, deliveries: routed.deliveries, deadLetter, rows });
|
|
99
|
+
published = routed.events;
|
|
68
100
|
}
|
|
69
101
|
catch {
|
|
70
102
|
// The store or the deliveries Queue could not be reached. The batch is retried whole, once,
|
|
71
103
|
// rather than event by event against the object that is already struggling; a delivery the
|
|
72
|
-
// first attempt did
|
|
104
|
+
// first attempt did make is made again under the same id, which a sink discards.
|
|
73
105
|
for (const message of declared)
|
|
74
106
|
message.retry({ delaySeconds: backoff(message.attempts) });
|
|
75
107
|
}
|
|
@@ -89,47 +121,82 @@ export function consumeQueues(config) {
|
|
|
89
121
|
}
|
|
90
122
|
await record(rows, env);
|
|
91
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The first attempt at every delivery, made here rather than after a second Queue. What the hub
|
|
126
|
+
* asks to retry goes to the deliveries Queue with the delay it asked for and the attempt counted;
|
|
127
|
+
* what it gives up is set aside. Where the attempts cannot be made at all, every delivery is
|
|
128
|
+
* queued as it is, so the later path makes the first attempt instead.
|
|
129
|
+
*/
|
|
130
|
+
async function attemptFirst(given) {
|
|
131
|
+
const { hub, deliveries, deadLetter, rows } = given;
|
|
132
|
+
if (deliveries.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
let outcomes;
|
|
135
|
+
try {
|
|
136
|
+
outcomes = await hub.deliverAll(deliveries);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
await Promise.all(deliveries.map((delivery) => hub.later(delivery)));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
await Promise.all(deliveries.map(async (delivery, at) => {
|
|
143
|
+
const outcome = outcomes[at];
|
|
144
|
+
if ("retryAfterSeconds" in outcome) {
|
|
145
|
+
await hub.later({ ...delivery, attempt: delivery.attempt + 1 }, outcome.retryAfterSeconds);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (outcome.gaveUp === undefined)
|
|
149
|
+
return;
|
|
150
|
+
// No message of its own to settle: the event it belongs to is acked by the caller.
|
|
151
|
+
const kept = { kind: "delivery", delivery, gaveUp: outcome.gaveUp };
|
|
152
|
+
await deadLetter.send(kept, { contentType: "json" });
|
|
153
|
+
rows.push({
|
|
154
|
+
at: Date.now(),
|
|
155
|
+
level: "warn",
|
|
156
|
+
message: "a delivery was given up",
|
|
157
|
+
fields: {
|
|
158
|
+
subscription: delivery.subscription,
|
|
159
|
+
event: delivery.event.id,
|
|
160
|
+
type: delivery.event.type,
|
|
161
|
+
reason: outcome.gaveUp.reason,
|
|
162
|
+
...(outcome.gaveUp.status === null ? {} : { status: outcome.gaveUp.status }),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
92
167
|
async function deliveries(batch, env) {
|
|
93
168
|
const hub = config.hub(env);
|
|
94
169
|
const deadLetter = config.deadLetter(env);
|
|
95
170
|
const rows = [];
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
await setAside({
|
|
111
|
-
message,
|
|
112
|
-
kept: { kind: "delivery", delivery, gaveUp: outcome.gaveUp },
|
|
113
|
-
deadLetter,
|
|
114
|
-
rows,
|
|
115
|
-
row: {
|
|
116
|
-
at: Date.now(),
|
|
117
|
-
level: "warn",
|
|
118
|
-
message: "a delivery was given up",
|
|
119
|
-
fields: {
|
|
120
|
-
subscription: delivery.subscription,
|
|
121
|
-
event: delivery.event.id,
|
|
122
|
-
type: delivery.event.type,
|
|
123
|
-
reason,
|
|
124
|
-
...(status === null ? {} : { status }),
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
// The store could not be reached: nothing was settled.
|
|
171
|
+
// `attempt` is the larger of what the body carries and what the Queue counted: a delivery queued
|
|
172
|
+
// after a first attempt made elsewhere already carries 2, and the Queue starts its own count at
|
|
173
|
+
// 1. Either alone would restart the backoff.
|
|
174
|
+
const attempted = batch.messages.map((message) => ({
|
|
175
|
+
message,
|
|
176
|
+
delivery: { ...message.body, attempt: Math.max(message.body.attempt, message.attempts) },
|
|
177
|
+
}));
|
|
178
|
+
let outcomes;
|
|
179
|
+
try {
|
|
180
|
+
outcomes = await hub.deliverAll(attempted.map((one) => one.delivery));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// The store could not be reached: nothing was settled.
|
|
184
|
+
for (const { message } of attempted) {
|
|
131
185
|
message.retry({ delaySeconds: backoff(message.attempts) });
|
|
132
186
|
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
await Promise.all(attempted.map(async ({ message, delivery }, at) => {
|
|
190
|
+
const outcome = outcomes[at];
|
|
191
|
+
if ("retryAfterSeconds" in outcome) {
|
|
192
|
+
message.retry({ delaySeconds: outcome.retryAfterSeconds });
|
|
193
|
+
}
|
|
194
|
+
else if (outcome.gaveUp === undefined) {
|
|
195
|
+
message.ack();
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
await giveUp({ message, delivery, gaveUp: outcome.gaveUp, deadLetter, rows });
|
|
199
|
+
}
|
|
133
200
|
}));
|
|
134
201
|
await record(rows, env);
|
|
135
202
|
}
|
package/dist/subscriptions.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Publishable, type SubscriptionStore } from "@worker-protocol/hono";
|
|
2
2
|
import { type DurableObjectClass, type Mixed, type Rpc } from "./durable.ts";
|
|
3
3
|
/** What `withSubscriptions` adds to a Durable Object. Every subscription crosses as JSON. */
|
|
4
4
|
export interface SubscriptionMethods {
|
|
@@ -8,6 +8,7 @@ export interface SubscriptionMethods {
|
|
|
8
8
|
created: boolean;
|
|
9
9
|
};
|
|
10
10
|
getSubscription(id: string): string | null;
|
|
11
|
+
subscriptionsById(ids: string[]): string[];
|
|
11
12
|
subscriptionsOf(caller: string | null): string[];
|
|
12
13
|
subscriptionsFor(type: string): string[];
|
|
13
14
|
updateSubscription(id: string, change: {
|
|
@@ -15,6 +16,7 @@ export interface SubscriptionMethods {
|
|
|
15
16
|
clear: string[];
|
|
16
17
|
}): void;
|
|
17
18
|
removeSubscription(id: string): void;
|
|
19
|
+
wantedHere(source: string, events: Publishable[]): boolean[];
|
|
18
20
|
}
|
|
19
21
|
export declare function withSubscriptions<B extends DurableObjectClass>(Base: B): Mixed<B, SubscriptionMethods>;
|
|
20
22
|
/** What `durableSubscriptions` needs of a stub: the methods `withSubscriptions` adds, over RPC. */
|
package/dist/subscriptions.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wantedBy, } from "@worker-protocol/hono";
|
|
1
2
|
import { first } from "./durable.js";
|
|
2
3
|
import { migrate } from "./schema.js";
|
|
3
4
|
/**
|
|
@@ -57,6 +58,16 @@ export function withSubscriptions(Base) {
|
|
|
57
58
|
const row = first(sql.exec("SELECT record FROM wp_subscription WHERE id = ?", id));
|
|
58
59
|
return row?.record ?? null;
|
|
59
60
|
}
|
|
61
|
+
/** Several by id in one call, so a batch of deliveries crosses the RPC boundary once. */
|
|
62
|
+
subscriptionsById(ids) {
|
|
63
|
+
if (ids.length === 0)
|
|
64
|
+
return [];
|
|
65
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
66
|
+
return sql
|
|
67
|
+
.exec("SELECT record FROM wp_subscription WHERE id IN (SELECT value FROM json_each(?))", JSON.stringify(ids))
|
|
68
|
+
.toArray()
|
|
69
|
+
.map((row) => row.record);
|
|
70
|
+
}
|
|
60
71
|
/** SUB-8: one caller's, ended ones included. `IS` because a caller may be `null`. */
|
|
61
72
|
subscriptionsOf(caller) {
|
|
62
73
|
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
@@ -93,6 +104,19 @@ export function withSubscriptions(Base) {
|
|
|
93
104
|
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
94
105
|
sql.exec("DELETE FROM wp_subscription WHERE id = ?", id);
|
|
95
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Which of these events a live subscription held here would receive (SUB-13), answered
|
|
109
|
+
* synchronously — so a Worker can leave the rest out of its outbox inside the very transaction
|
|
110
|
+
* of the write that raised them, as `changing()` needs. `source` is the Worker's id, which every
|
|
111
|
+
* event carries (EVT-1); the envelope is the hub's own, so the filters read the same attributes.
|
|
112
|
+
*/
|
|
113
|
+
wantedHere(source, events) {
|
|
114
|
+
return wantedBy({
|
|
115
|
+
source,
|
|
116
|
+
subscribed: (type) => this.subscriptionsFor(type).map(parse),
|
|
117
|
+
events,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
96
120
|
}
|
|
97
121
|
return WithSubscriptions;
|
|
98
122
|
}
|
|
@@ -107,6 +131,7 @@ export const durableSubscriptions = (stub) => {
|
|
|
107
131
|
return { subscription: parse(record), created };
|
|
108
132
|
},
|
|
109
133
|
get: async (id) => read(await stub.getSubscription(id)),
|
|
134
|
+
getMany: async (ids) => (await stub.subscriptionsById(ids)).map(parse),
|
|
110
135
|
list: async (caller) => (await stub.subscriptionsOf(caller)).map(parse),
|
|
111
136
|
forType: async (type) => (await stub.subscriptionsFor(type)).map(parse),
|
|
112
137
|
// `undefined` names a member to clear, and JSON drops it — so the cleared ones go by name.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worker-protocol/cloudflare",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"workerProtocolEdition": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"workerProtocolEdition": "0.6",
|
|
5
5
|
"description": "The protocol's stores in Durable Objects, and the Queues between an outbox and a sink: mixins for ENDP-16, SUB-7, LOG-2 and an outbox, for Workers on Cloudflare",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"worker-protocol",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"NOTICE"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@worker-protocol/hono": "0.
|
|
42
|
+
"@worker-protocol/hono": "0.8.1"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"hono": "^4.13.7",
|