@worker-protocol/cloudflare 0.0.0-stage → 0.6.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +191 -2
- package/dist/durable.d.ts +38 -0
- package/dist/durable.js +6 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/logs.d.ts +64 -0
- package/dist/logs.js +120 -0
- package/dist/outbox.d.ts +52 -0
- package/dist/outbox.js +173 -0
- package/dist/outcomes.d.ts +13 -0
- package/dist/outcomes.js +62 -0
- package/dist/queues.d.ts +68 -0
- package/dist/queues.js +148 -0
- package/dist/schema.d.ts +44 -0
- package/dist/schema.js +64 -0
- package/dist/subscriptions.d.ts +23 -0
- package/dist/subscriptions.js +121 -0
- package/package.json +59 -3
package/dist/logs.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { LEVELS } from "@worker-protocol/hono";
|
|
2
|
+
import { migrate } from "./schema.js";
|
|
3
|
+
/** `migrate` applies these; a new step goes at the end, and a published one is never edited. */
|
|
4
|
+
const SCHEMA = {
|
|
5
|
+
piece: "worker-protocol.logs",
|
|
6
|
+
steps: [
|
|
7
|
+
// 1. The tables as first released, `IF NOT EXISTS` so an object that already has them adopts them.
|
|
8
|
+
[
|
|
9
|
+
`CREATE TABLE IF NOT EXISTS wp_log (
|
|
10
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
at INTEGER NOT NULL,
|
|
12
|
+
level TEXT NOT NULL,
|
|
13
|
+
rank INTEGER NOT NULL,
|
|
14
|
+
message TEXT NOT NULL,
|
|
15
|
+
fields TEXT
|
|
16
|
+
)`,
|
|
17
|
+
],
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
export function withLogs(Base, options) {
|
|
21
|
+
class WithLogs extends Base {
|
|
22
|
+
/** One write per call, whatever the line count — per line it would be a write per record. */
|
|
23
|
+
record(rows) {
|
|
24
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
sql.exec("INSERT INTO wp_log (at, level, rank, message, fields) VALUES (?, ?, ?, ?, ?)", row.at, row.level,
|
|
27
|
+
// LOG-5's ladder as a number, so that LOG-7's floor is a comparison the store can make.
|
|
28
|
+
LEVELS.indexOf(row.level), row.message, row.fields === undefined ? null : JSON.stringify(row.fields));
|
|
29
|
+
}
|
|
30
|
+
// A window rather than an archive, which is what makes the end of the collection mean *the
|
|
31
|
+
// end of what this Worker still holds*.
|
|
32
|
+
sql.exec("DELETE FROM wp_log WHERE seq <= (SELECT MAX(seq) FROM wp_log) - ?", options.keep);
|
|
33
|
+
}
|
|
34
|
+
/** LOG-3, LOG-7, LOG-8, ENDP-33 — one page, most recent first, in one query. */
|
|
35
|
+
logs(query) {
|
|
36
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
37
|
+
const found = sql
|
|
38
|
+
.exec(`SELECT seq, at, level, message, fields FROM wp_log
|
|
39
|
+
WHERE rank >= ?1
|
|
40
|
+
AND (?2 IS NULL OR seq < ?2)
|
|
41
|
+
AND (?3 IS NULL OR at >= ?3)
|
|
42
|
+
AND (?4 IS NULL OR at < ?4)
|
|
43
|
+
ORDER BY seq DESC
|
|
44
|
+
LIMIT ?5`, query.minRank, query.before, query.from, query.to,
|
|
45
|
+
// One more than asked for, which is how the cursor knows whether there IS a next page.
|
|
46
|
+
query.limit + 1)
|
|
47
|
+
.toArray();
|
|
48
|
+
const page = found.slice(0, query.limit);
|
|
49
|
+
const rows = page.map((row) => ({
|
|
50
|
+
at: row.at,
|
|
51
|
+
level: row.level,
|
|
52
|
+
message: row.message,
|
|
53
|
+
...(row.fields === null ? {} : { fields: JSON.parse(row.fields) }),
|
|
54
|
+
}));
|
|
55
|
+
// ENDP-20: the cursor exists exactly when the query found a row beyond the cap.
|
|
56
|
+
const last = found.length > query.limit ? page.at(-1) : undefined;
|
|
57
|
+
return last === undefined ? { rows } : { rows, nextCursor: String(last.seq) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return WithLogs;
|
|
61
|
+
}
|
|
62
|
+
/** `logs` for `mount()`, over the object with `withLogs`. `mount()` decodes the query. */
|
|
63
|
+
export const durableLogs = (stub, options) => ({
|
|
64
|
+
pageSize: options.pageSize,
|
|
65
|
+
read: async ({ levels, from, to, cursor, limit }) => {
|
|
66
|
+
const page = await stub.logs({
|
|
67
|
+
// LOG-7's floor: the levels arrive in order, so the first is the lowest asked for.
|
|
68
|
+
minRank: LEVELS.indexOf(levels[0] ?? "debug"),
|
|
69
|
+
before: cursor === undefined ? null : Number(cursor),
|
|
70
|
+
from: from?.getTime() ?? null,
|
|
71
|
+
to: to?.getTime() ?? null,
|
|
72
|
+
limit,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
records: page.rows.map((row) => ({
|
|
76
|
+
at: new Date(row.at),
|
|
77
|
+
level: row.level,
|
|
78
|
+
message: row.message,
|
|
79
|
+
...(row.fields === undefined ? {} : { fields: row.fields }),
|
|
80
|
+
})),
|
|
81
|
+
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* What a Tail Worker records of the invocations it is handed: an exception nobody caught, and an
|
|
87
|
+
* invocation that ended badly — the two things a Worker cannot record about itself, because by
|
|
88
|
+
* then it has stopped running.
|
|
89
|
+
*
|
|
90
|
+
* **It throws `event.logs` away**, which is every `console` call the producer made: forwarding it
|
|
91
|
+
* would be the capture `spec/logs.md` argues against. That filter is also what stops a tail from
|
|
92
|
+
* feeding itself — its own write to the object is traced, comes back as `ok` with no exceptions,
|
|
93
|
+
* and records nothing.
|
|
94
|
+
*/
|
|
95
|
+
export function tailRecords(events) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
for (const event of events) {
|
|
98
|
+
const at = event.eventTimestamp ?? Date.now();
|
|
99
|
+
const fields = { script: event.scriptName ?? "unknown", outcome: event.outcome };
|
|
100
|
+
for (const thrown of event.exceptions) {
|
|
101
|
+
lines.push({
|
|
102
|
+
at: thrown.timestamp ?? at,
|
|
103
|
+
level: "error",
|
|
104
|
+
message: `uncaught ${thrown.name}: ${String(thrown.message)}`,
|
|
105
|
+
fields,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// `ok` says nothing an operator needs; `canceled` is a client that hung up, worth knowing when
|
|
109
|
+
// it happens a hundred times an hour; `exceededCpu` is what nothing inside a Worker can report.
|
|
110
|
+
if (event.outcome !== "ok" && event.exceptions.length === 0) {
|
|
111
|
+
lines.push({
|
|
112
|
+
at,
|
|
113
|
+
level: event.outcome === "canceled" ? "warn" : "error",
|
|
114
|
+
message: `the invocation ended \`${event.outcome}\``,
|
|
115
|
+
fields,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Publishable } from "@worker-protocol/hono";
|
|
2
|
+
import { type DurableObjectClass, type EnvOf, type Mixed } from "./durable.ts";
|
|
3
|
+
/**
|
|
4
|
+
* An outbox, in every Durable Object whose changes raise events.
|
|
5
|
+
*
|
|
6
|
+
* **The row is written in the same operation as the change it reports.** `enqueue` is synchronous
|
|
7
|
+
* SQL, so called from a domain method between the domain's own writes it lands in the same
|
|
8
|
+
* transaction: there is no moment at which a Fact changed and its event was not yet owed. That is
|
|
9
|
+
* what makes it an outbox rather than a send that might not happen.
|
|
10
|
+
*
|
|
11
|
+
* **It holds the whole event**, as `Publishable` JSON with its id, until it is sent. A Worker's own
|
|
12
|
+
* retention then need not wait for what is pending — the copy lives for the seconds it takes to
|
|
13
|
+
* leave. An id still waiting is not enqueued twice; once sent, the same id enqueued again is sent
|
|
14
|
+
* again, which is a republication under the same `source` and `id` that a consumer remembering them
|
|
15
|
+
* discards (EVT-8). A domain that writes `INSERT OR IGNORE` and enqueues only what it inserted
|
|
16
|
+
* raises nothing twice at all.
|
|
17
|
+
*
|
|
18
|
+
* **It drains when the call ends, and retries by the one alarm.** The domain calls `flush()` at the
|
|
19
|
+
* end of a method; what does not reach the events Queue stays, in order, and the alarm tries again
|
|
20
|
+
* with a backoff. A Durable Object has one alarm, so the domain does not set its own: it asks with
|
|
21
|
+
* `wakeAt(at)` and is called back at `wake()`, and the alarm fires at the earlier of the two. A base
|
|
22
|
+
* class that sets the alarm itself does not compose with this one.
|
|
23
|
+
*/
|
|
24
|
+
/** One event on the events Queue: what was enqueued, with the id and instant the row kept. */
|
|
25
|
+
export type OutboxEvent = Publishable & {
|
|
26
|
+
id: string;
|
|
27
|
+
time: number;
|
|
28
|
+
};
|
|
29
|
+
/** What a `flush()` did: how many events left, and how many are still waiting. */
|
|
30
|
+
export type Flushed = {
|
|
31
|
+
sent: number;
|
|
32
|
+
pending: number;
|
|
33
|
+
};
|
|
34
|
+
/** What `withOutbox` adds to a Durable Object. */
|
|
35
|
+
export interface OutboxMethods {
|
|
36
|
+
enqueue(at: number, events: Publishable[]): void;
|
|
37
|
+
outbox(): {
|
|
38
|
+
depth: number;
|
|
39
|
+
oldestAt: number | null;
|
|
40
|
+
};
|
|
41
|
+
flush(): Promise<Flushed>;
|
|
42
|
+
wakeAt(at: number | null): Promise<void>;
|
|
43
|
+
wake(): void | Promise<void>;
|
|
44
|
+
alarm(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export declare function withOutbox<B extends DurableObjectClass>(Base: B, options: {
|
|
47
|
+
/**
|
|
48
|
+
* The events Queue `flush()` sends to, whose consumer fans each event out (`consumeQueues`).
|
|
49
|
+
* Resolved on every flush rather than once, so it reads whatever `env` the object holds then.
|
|
50
|
+
*/
|
|
51
|
+
events: (env: EnvOf<B>) => Queue<OutboxEvent>;
|
|
52
|
+
}): Mixed<B, OutboxMethods>;
|
package/dist/outbox.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { asJson, BATCH, first, } from "./durable.js";
|
|
2
|
+
import { migrate } from "./schema.js";
|
|
3
|
+
/** `migrate` applies these; a new step goes at the end, and a published one is never edited. */
|
|
4
|
+
const SCHEMA = {
|
|
5
|
+
piece: "worker-protocol.outbox",
|
|
6
|
+
steps: [
|
|
7
|
+
// 1. The tables as first released, `IF NOT EXISTS` so an object that already has them adopts them.
|
|
8
|
+
[
|
|
9
|
+
`CREATE TABLE IF NOT EXISTS wp_outbox (
|
|
10
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
id TEXT NOT NULL UNIQUE,
|
|
12
|
+
at INTEGER NOT NULL,
|
|
13
|
+
event TEXT NOT NULL
|
|
14
|
+
)`,
|
|
15
|
+
// The instants the one alarm is shared between: `wake` is the domain's, `retry` the outbox's.
|
|
16
|
+
`CREATE TABLE IF NOT EXISTS wp_alarm (
|
|
17
|
+
name TEXT PRIMARY KEY,
|
|
18
|
+
at INTEGER NOT NULL,
|
|
19
|
+
attempts INTEGER NOT NULL
|
|
20
|
+
)`,
|
|
21
|
+
],
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
/** The first retry of an outbox that could not be sent, and the longest wait between two. */
|
|
25
|
+
const RETRY_FIRST_MS = 5_000;
|
|
26
|
+
const RETRY_MOST_MS = 300_000;
|
|
27
|
+
/** The send in flight per object, so a call and the alarm never send the same rows at once. */
|
|
28
|
+
const inFlight = new WeakMap();
|
|
29
|
+
const depthOf = (sql) => sql.exec("SELECT COUNT(*) AS n FROM wp_outbox").one().n;
|
|
30
|
+
/** Whether a table exists, asked without creating it: after `deleteAll()` nothing should come back. */
|
|
31
|
+
const exists = (sql, table) => first(sql.exec("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", table)) !==
|
|
32
|
+
undefined;
|
|
33
|
+
/** Points the one alarm at the earliest instant anybody asked for, or clears it. */
|
|
34
|
+
async function settle(storage) {
|
|
35
|
+
if (!exists(storage.sql, "wp_alarm"))
|
|
36
|
+
return;
|
|
37
|
+
const next = storage.sql
|
|
38
|
+
.exec("SELECT MIN(at) AS at FROM wp_alarm")
|
|
39
|
+
.one().at;
|
|
40
|
+
const current = await storage.getAlarm();
|
|
41
|
+
if (next === null) {
|
|
42
|
+
if (current !== null)
|
|
43
|
+
await storage.deleteAlarm();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (current !== next)
|
|
47
|
+
await storage.setAlarm(next);
|
|
48
|
+
}
|
|
49
|
+
async function drain(given) {
|
|
50
|
+
const { storage, queue } = given;
|
|
51
|
+
const sql = storage.sql;
|
|
52
|
+
const retrying = () => first(sql.exec("SELECT 1 FROM wp_alarm WHERE name = 'retry'")) !== undefined;
|
|
53
|
+
// The common case: a call that raised nothing ends in a flush with nothing to send, and the
|
|
54
|
+
// alarm has nothing to change. It is every write of every object, so it touches nothing else.
|
|
55
|
+
if (depthOf(sql) === 0 && !retrying())
|
|
56
|
+
return { sent: 0, pending: 0 };
|
|
57
|
+
let sent = 0;
|
|
58
|
+
try {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const rows = sql
|
|
61
|
+
.exec("SELECT seq, id, at, event FROM wp_outbox ORDER BY seq LIMIT ?", BATCH)
|
|
62
|
+
.toArray();
|
|
63
|
+
const last = rows.at(-1);
|
|
64
|
+
if (last === undefined)
|
|
65
|
+
break;
|
|
66
|
+
await queue.sendBatch(asJson(rows.map((row) => ({
|
|
67
|
+
...JSON.parse(row.event),
|
|
68
|
+
id: row.id,
|
|
69
|
+
time: row.at,
|
|
70
|
+
}))));
|
|
71
|
+
// Up to the last row sent and no further: a row enqueued during the send has a later `seq`.
|
|
72
|
+
sql.exec("DELETE FROM wp_outbox WHERE seq <= ?", last.seq);
|
|
73
|
+
sent += rows.length;
|
|
74
|
+
}
|
|
75
|
+
sql.exec("DELETE FROM wp_alarm WHERE name = 'retry'");
|
|
76
|
+
await settle(storage);
|
|
77
|
+
return { sent, pending: 0 };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// What did not go stays, in order, and the alarm tries again: sooner the first time, never
|
|
81
|
+
// more often than every five minutes, so a Queue that is down for an hour is not woken
|
|
82
|
+
// thousands of times by thousands of objects.
|
|
83
|
+
if (!exists(sql, "wp_alarm"))
|
|
84
|
+
return { sent, pending: 0 };
|
|
85
|
+
const held = first(sql.exec("SELECT attempts FROM wp_alarm WHERE name = 'retry'"));
|
|
86
|
+
const attempts = held?.attempts ?? 0;
|
|
87
|
+
const wait = Math.min(RETRY_FIRST_MS * 2 ** attempts, RETRY_MOST_MS);
|
|
88
|
+
sql.exec("INSERT OR REPLACE INTO wp_alarm (name, at, attempts) VALUES ('retry', ?, ?)", Date.now() + wait, attempts + 1);
|
|
89
|
+
await settle(storage);
|
|
90
|
+
return { sent, pending: depthOf(sql) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function withOutbox(Base, options) {
|
|
94
|
+
class WithOutbox extends Base {
|
|
95
|
+
/**
|
|
96
|
+
* Owes each event, in the transaction the calling method is already in.
|
|
97
|
+
*
|
|
98
|
+
* `at` is when the Fact changed, for an event that does not carry its own `time`. An event
|
|
99
|
+
* whose `id` is still waiting is not enqueued again. Public only because TypeScript cannot
|
|
100
|
+
* emit a mixin's protected members; over RPC it would lose the transaction that is its point.
|
|
101
|
+
*/
|
|
102
|
+
enqueue(at, events) {
|
|
103
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
104
|
+
for (const one of events) {
|
|
105
|
+
const { id, time, ...rest } = one;
|
|
106
|
+
const when = time === undefined ? at : typeof time === "number" ? time : time.getTime();
|
|
107
|
+
sql.exec("INSERT OR IGNORE INTO wp_outbox (id, at, event) VALUES (?, ?, ?)", id ?? crypto.randomUUID(), when, JSON.stringify(rest));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** How deep the outbox is and when its oldest event was raised, which `health` may report. */
|
|
111
|
+
outbox() {
|
|
112
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
113
|
+
const row = sql
|
|
114
|
+
.exec("SELECT COUNT(*) AS n, MIN(at) AS oldest FROM wp_outbox")
|
|
115
|
+
.one();
|
|
116
|
+
return { depth: row.n, oldestAt: row.oldest };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Sends what is waiting to the events Queue, in order, a hundred at a time. Never rejects: what
|
|
120
|
+
* could not be sent stays and the alarm is pointed at a retry. A send already in flight is
|
|
121
|
+
* joined rather than repeated.
|
|
122
|
+
*/
|
|
123
|
+
flush() {
|
|
124
|
+
const storage = this.ctx.storage;
|
|
125
|
+
migrate(storage, SCHEMA);
|
|
126
|
+
const running = inFlight.get(storage) ??
|
|
127
|
+
drain({ storage, queue: options.events(this.env) }).finally(() => inFlight.delete(storage));
|
|
128
|
+
inFlight.set(storage, running);
|
|
129
|
+
return running;
|
|
130
|
+
}
|
|
131
|
+
/** The domain's own alarm, sharing the one this object has. `null` withdraws it. */
|
|
132
|
+
async wakeAt(at) {
|
|
133
|
+
const storage = this.ctx.storage;
|
|
134
|
+
migrate(storage, SCHEMA);
|
|
135
|
+
if (at === null)
|
|
136
|
+
storage.sql.exec("DELETE FROM wp_alarm WHERE name = 'wake'");
|
|
137
|
+
else {
|
|
138
|
+
storage.sql.exec("INSERT OR REPLACE INTO wp_alarm (name, at, attempts) VALUES ('wake', ?, 0)", at);
|
|
139
|
+
}
|
|
140
|
+
await settle(storage);
|
|
141
|
+
}
|
|
142
|
+
/** What the domain does when the instant it asked for with `wakeAt` comes. Nothing, unless overridden. */
|
|
143
|
+
wake() { }
|
|
144
|
+
/**
|
|
145
|
+
* The one alarm: the outbox first, then the domain's `wake()` if its instant has come. The
|
|
146
|
+
* domain overrides `wake()`, never this. `wake()` may empty the object with `deleteAll()`, and
|
|
147
|
+
* nothing here writes to it afterwards; if it throws, its instant stays and the platform's own
|
|
148
|
+
* retry of the alarm calls it again.
|
|
149
|
+
*/
|
|
150
|
+
async alarm() {
|
|
151
|
+
const storage = this.ctx.storage;
|
|
152
|
+
const sql = storage.sql;
|
|
153
|
+
// Asked, never created. Every alarm this mixin sets is written in `wp_alarm` first, so without
|
|
154
|
+
// the table there is nothing it owes: the object emptied itself, and an alarm delivered again
|
|
155
|
+
// — at least once is the platform's promise — must not bring its tables back.
|
|
156
|
+
if (!exists(sql, "wp_alarm"))
|
|
157
|
+
return;
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (depthOf(sql) > 0)
|
|
160
|
+
await this.flush();
|
|
161
|
+
const wake = first(sql.exec("SELECT at FROM wp_alarm WHERE name = 'wake'"));
|
|
162
|
+
if (wake !== undefined && wake.at <= now) {
|
|
163
|
+
await this.wake();
|
|
164
|
+
// Only the instant that fired: a `wake()` that asked again with `wakeAt` keeps its answer.
|
|
165
|
+
if (exists(sql, "wp_alarm")) {
|
|
166
|
+
sql.exec("DELETE FROM wp_alarm WHERE name = 'wake' AND at = ?", wake.at);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
await settle(storage);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return WithOutbox;
|
|
173
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OutcomeStore, Recorded, Reservation } from "@worker-protocol/hono";
|
|
2
|
+
import { type DurableObjectClass, type Mixed, type Rpc } from "./durable.ts";
|
|
3
|
+
/** What `withOutcomes` adds to a Durable Object. */
|
|
4
|
+
export interface OutcomeMethods {
|
|
5
|
+
beginOutcome(key: string, until: number): Reservation;
|
|
6
|
+
completeOutcome(key: string, answer: Recorded): void;
|
|
7
|
+
releaseOutcome(key: string): void;
|
|
8
|
+
}
|
|
9
|
+
export declare function withOutcomes<B extends DurableObjectClass>(Base: B): Mixed<B, OutcomeMethods>;
|
|
10
|
+
/** What `durableOutcomes` needs of a stub: the three methods `withOutcomes` adds, over RPC. */
|
|
11
|
+
export type OutcomesRpc = Rpc<OutcomeMethods>;
|
|
12
|
+
/** `actions.outcomes` for `mount()`, over the object that carries `withOutcomes`. */
|
|
13
|
+
export declare const durableOutcomes: (stub: OutcomesRpc) => OutcomeStore;
|
package/dist/outcomes.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { first } from "./durable.js";
|
|
2
|
+
import { migrate } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* ENDP-16's store, in whichever Durable Object a Worker keeps its outcomes in.
|
|
5
|
+
*
|
|
6
|
+
* `beginOutcome` is the reason it is a Durable Object at all rather than a KV namespace: finding a
|
|
7
|
+
* key free and taking it are one method, and a method is synchronous from its first statement to
|
|
8
|
+
* its last, so no other request interleaves between the two. Two callers under one key meet a
|
|
9
|
+
* reservation, and only one of them performs.
|
|
10
|
+
*/
|
|
11
|
+
/** `migrate` applies these; a new step goes at the end, and a published one is never edited. */
|
|
12
|
+
const SCHEMA = {
|
|
13
|
+
piece: "worker-protocol.outcomes",
|
|
14
|
+
steps: [
|
|
15
|
+
// 1. The tables as first released, `IF NOT EXISTS` so an object that already has them adopts them.
|
|
16
|
+
[
|
|
17
|
+
`CREATE TABLE IF NOT EXISTS wp_outcome (
|
|
18
|
+
key TEXT PRIMARY KEY,
|
|
19
|
+
until INTEGER NOT NULL,
|
|
20
|
+
answer TEXT
|
|
21
|
+
)`,
|
|
22
|
+
"CREATE INDEX IF NOT EXISTS wp_outcome_until ON wp_outcome (until)",
|
|
23
|
+
],
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
export function withOutcomes(Base) {
|
|
27
|
+
class WithOutcomes extends Base {
|
|
28
|
+
/**
|
|
29
|
+
* `OutcomeStore.begin`. The return type is `Reservation` by name: a stub's types are mapped,
|
|
30
|
+
* and the mapping once accepted a shape `OutcomeStore` does not take when it was spelled out.
|
|
31
|
+
*/
|
|
32
|
+
beginOutcome(key, until) {
|
|
33
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
// What has expired is nobody's any more, so it goes as soon as anybody asks: a window rather
|
|
36
|
+
// than an archive, without a schedule of its own.
|
|
37
|
+
sql.exec("DELETE FROM wp_outcome WHERE until <= ?", now);
|
|
38
|
+
const row = first(sql.exec("SELECT answer FROM wp_outcome WHERE key = ?", key));
|
|
39
|
+
if (row?.answer != null)
|
|
40
|
+
return { held: JSON.parse(row.answer) };
|
|
41
|
+
if (row !== undefined)
|
|
42
|
+
return "in-flight";
|
|
43
|
+
sql.exec("INSERT INTO wp_outcome (key, until, answer) VALUES (?, ?, NULL)", key, until);
|
|
44
|
+
return "reserved";
|
|
45
|
+
}
|
|
46
|
+
completeOutcome(key, answer) {
|
|
47
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
48
|
+
sql.exec("INSERT OR REPLACE INTO wp_outcome (key, until, answer) VALUES (?, ?, ?)", key, answer.until, JSON.stringify(answer));
|
|
49
|
+
}
|
|
50
|
+
releaseOutcome(key) {
|
|
51
|
+
const sql = migrate(this.ctx.storage, SCHEMA);
|
|
52
|
+
sql.exec("DELETE FROM wp_outcome WHERE key = ?", key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return WithOutcomes;
|
|
56
|
+
}
|
|
57
|
+
/** `actions.outcomes` for `mount()`, over the object that carries `withOutcomes`. */
|
|
58
|
+
export const durableOutcomes = (stub) => ({
|
|
59
|
+
begin: (key, until) => stub.beginOutcome(key, until),
|
|
60
|
+
complete: (key, held) => stub.completeOutcome(key, held),
|
|
61
|
+
release: (key) => stub.releaseOutcome(key),
|
|
62
|
+
});
|
package/dist/queues.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CloudEvent, Delivery, DeliveryQueue, eventHub, GaveUp } from "@worker-protocol/hono";
|
|
2
|
+
import type { LogRow } from "./logs.ts";
|
|
3
|
+
import type { OutboxEvent } from "./outbox.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The two Queues between an outbox and a sink, and the one handler that consumes both.
|
|
6
|
+
*
|
|
7
|
+
* **Events, then deliveries.** Every object's `flush()` sends its events to the events Queue. Its
|
|
8
|
+
* consumer publishes each batch through the hub — the subscriptions are read once per type for the
|
|
9
|
+
* whole batch, from the one object that holds them — and leaves one delivery per matching
|
|
10
|
+
* subscription on the deliveries Queue, whose consumer makes the attempt. Fanning out from each
|
|
11
|
+
* object instead would make a run that touches thousands of them thousands of calls to that one
|
|
12
|
+
* object, and an outage of it would leave every outbox retrying; here the events wait in a Queue.
|
|
13
|
+
*
|
|
14
|
+
* **What is given up stays visible.** A delivery `deliver()` gives up on, and an event of a type the
|
|
15
|
+
* Worker does not declare, go to the dead-letter queue with the reason, to be inspected and never
|
|
16
|
+
* redriven, and — where the Worker keeps logs — a record names each for an operator who reads the
|
|
17
|
+
* Worker through the protocol rather than through the Cloudflare account. A message the platform
|
|
18
|
+
* drops after `max_retries` reaches the same queue, as it was sent and untagged, when
|
|
19
|
+
* `wrangler.jsonc` names it as each consumer's `dead_letter_queue`: the only thing that sees those.
|
|
20
|
+
*/
|
|
21
|
+
type Hub = ReturnType<typeof eventHub>;
|
|
22
|
+
/**
|
|
23
|
+
* `subscriptions.queue` for `mount()` and `eventHub()`: the deliveries Queue, in batches where the
|
|
24
|
+
* hub has several. The chunks are independent — the Queue promises no order — so they go together.
|
|
25
|
+
*/
|
|
26
|
+
export declare const deliveryQueue: (queue: Queue<Delivery>) => DeliveryQueue;
|
|
27
|
+
/**
|
|
28
|
+
* 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 of a type the Worker does not declare (EVT-12) — a mistake in the Worker's
|
|
30
|
+
* own code, which no retry would mend.
|
|
31
|
+
*/
|
|
32
|
+
export type GivenUp = {
|
|
33
|
+
kind: "delivery";
|
|
34
|
+
delivery: Delivery;
|
|
35
|
+
gaveUp: GaveUp;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "event";
|
|
38
|
+
event: OutboxEvent;
|
|
39
|
+
reason: "undeclared";
|
|
40
|
+
};
|
|
41
|
+
export type QueuesConfig<E> = {
|
|
42
|
+
/** The Queue names, as `wrangler.jsonc` gives them: how the handler tells the two batches apart. */
|
|
43
|
+
queues: {
|
|
44
|
+
events: string;
|
|
45
|
+
deliveries: string;
|
|
46
|
+
};
|
|
47
|
+
/** The hub, built from the same declaration `mount()` serves. */
|
|
48
|
+
hub: (env: E) => Hub;
|
|
49
|
+
/** Where what is given up is kept for somebody to inspect. */
|
|
50
|
+
deadLetter: (env: E) => Queue<GivenUp>;
|
|
51
|
+
/**
|
|
52
|
+
* EVT-13: one attempt at the broker, where the Worker declares one; `false` is a broker that did
|
|
53
|
+
* not take the event. Called after the subscribers, so a broker that is down costs them a repeat
|
|
54
|
+
* under the same id, which they discard, and never a loss.
|
|
55
|
+
*/
|
|
56
|
+
broker?: (asked: {
|
|
57
|
+
event: CloudEvent;
|
|
58
|
+
env: E;
|
|
59
|
+
}) => Promise<boolean>;
|
|
60
|
+
/** LOG-2: where the records of what was given up are written, where the Worker keeps logs. */
|
|
61
|
+
record?: (asked: {
|
|
62
|
+
rows: LogRow[];
|
|
63
|
+
env: E;
|
|
64
|
+
}) => Promise<void>;
|
|
65
|
+
};
|
|
66
|
+
/** The `queue` handler of a Worker's default export, for both Queues. */
|
|
67
|
+
export declare function consumeQueues<E>(config: QueuesConfig<E>): (batch: MessageBatch<unknown>, env: E) => Promise<void>;
|
|
68
|
+
export {};
|
package/dist/queues.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { asJson, BATCH } from "./durable.js";
|
|
2
|
+
/** A Queue's own retry when nothing more specific was decided: ten seconds, doubling to an hour. */
|
|
3
|
+
const backoff = (attempts) => Math.min(10 * 2 ** Math.max(attempts - 1, 0), 3600);
|
|
4
|
+
/**
|
|
5
|
+
* `subscriptions.queue` for `mount()` and `eventHub()`: the deliveries Queue, in batches where the
|
|
6
|
+
* hub has several. The chunks are independent — the Queue promises no order — so they go together.
|
|
7
|
+
*/
|
|
8
|
+
export const deliveryQueue = (queue) => ({
|
|
9
|
+
send: async (delivery) => {
|
|
10
|
+
await queue.send(delivery, { contentType: "json" });
|
|
11
|
+
},
|
|
12
|
+
sendBatch: async (deliveries) => {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for (let at = 0; at < deliveries.length; at += BATCH) {
|
|
15
|
+
chunks.push(deliveries.slice(at, at + BATCH));
|
|
16
|
+
}
|
|
17
|
+
await Promise.all(chunks.map((chunk) => queue.sendBatch(asJson(chunk))));
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
/** The `queue` handler of a Worker's default export, for both Queues. */
|
|
21
|
+
export function consumeQueues(config) {
|
|
22
|
+
/** Sets one message's body aside, notes why, and settles it; retried if the queue refuses. */
|
|
23
|
+
const setAside = async (given) => {
|
|
24
|
+
try {
|
|
25
|
+
await given.deadLetter.send(given.kept, { contentType: "json" });
|
|
26
|
+
given.rows.push(given.row);
|
|
27
|
+
given.message.ack();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
given.message.retry({ delaySeconds: backoff(given.message.attempts) });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** The records of one batch, in one write. The dead-letter queue already holds every one. */
|
|
34
|
+
const record = async (rows, env) => {
|
|
35
|
+
if (rows.length === 0 || config.record === undefined)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
await config.record({ rows, env });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// The record is the courtesy copy; the dead-letter queue is the one that is kept.
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
async function events(batch, env) {
|
|
45
|
+
const hub = config.hub(env);
|
|
46
|
+
const deadLetter = config.deadLetter(env);
|
|
47
|
+
const rows = [];
|
|
48
|
+
// EVT-12, asked first: an event the Worker does not declare can never be published, so it is
|
|
49
|
+
// set aside at once rather than failing the batch beside it a hundred times over.
|
|
50
|
+
const declared = batch.messages.filter((message) => hub.declares(message.body.type));
|
|
51
|
+
const undeclared = batch.messages.filter((message) => !hub.declares(message.body.type));
|
|
52
|
+
await Promise.all(undeclared.map((message) => setAside({
|
|
53
|
+
message,
|
|
54
|
+
kept: { kind: "event", event: message.body, reason: "undeclared" },
|
|
55
|
+
deadLetter,
|
|
56
|
+
rows,
|
|
57
|
+
row: {
|
|
58
|
+
at: Date.now(),
|
|
59
|
+
level: "error",
|
|
60
|
+
message: "an event of a type this Worker does not declare was set aside",
|
|
61
|
+
fields: { event: message.body.id, type: message.body.type },
|
|
62
|
+
},
|
|
63
|
+
})));
|
|
64
|
+
let published;
|
|
65
|
+
try {
|
|
66
|
+
published =
|
|
67
|
+
declared.length === 0 ? [] : await hub.publishAll(declared.map((message) => message.body));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// The store or the deliveries Queue could not be reached. The batch is retried whole, once,
|
|
71
|
+
// rather than event by event against the object that is already struggling; a delivery the
|
|
72
|
+
// first attempt did queue is queued again under the same id, which a sink discards.
|
|
73
|
+
for (const message of declared)
|
|
74
|
+
message.retry({ delaySeconds: backoff(message.attempts) });
|
|
75
|
+
}
|
|
76
|
+
if (published !== undefined) {
|
|
77
|
+
const sent = published;
|
|
78
|
+
// Each event is settled on its own and the Queue promises no order, so the broker is asked
|
|
79
|
+
// for all of them at once rather than one after another.
|
|
80
|
+
await Promise.all(declared.map(async (message, at) => {
|
|
81
|
+
const event = sent[at];
|
|
82
|
+
const taken = event !== undefined &&
|
|
83
|
+
(await (config.broker?.({ event, env }).catch(() => false) ?? true));
|
|
84
|
+
if (taken)
|
|
85
|
+
message.ack();
|
|
86
|
+
else
|
|
87
|
+
message.retry({ delaySeconds: backoff(message.attempts) });
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
await record(rows, env);
|
|
91
|
+
}
|
|
92
|
+
async function deliveries(batch, env) {
|
|
93
|
+
const hub = config.hub(env);
|
|
94
|
+
const deadLetter = config.deadLetter(env);
|
|
95
|
+
const rows = [];
|
|
96
|
+
await Promise.all(batch.messages.map(async (message) => {
|
|
97
|
+
const delivery = message.body;
|
|
98
|
+
try {
|
|
99
|
+
// `attempts` is the Queue's own count, and the one `deliver` backs off by.
|
|
100
|
+
const outcome = await hub.deliver({ ...delivery, attempt: message.attempts });
|
|
101
|
+
if ("retryAfterSeconds" in outcome) {
|
|
102
|
+
message.retry({ delaySeconds: outcome.retryAfterSeconds });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (outcome.gaveUp === undefined) {
|
|
106
|
+
message.ack();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const { reason, status } = outcome.gaveUp;
|
|
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.
|
|
131
|
+
message.retry({ delaySeconds: backoff(message.attempts) });
|
|
132
|
+
}
|
|
133
|
+
}));
|
|
134
|
+
await record(rows, env);
|
|
135
|
+
}
|
|
136
|
+
return async (batch, env) => {
|
|
137
|
+
// The two bodies differ, and the Queue's name is what says which this batch carries.
|
|
138
|
+
if (batch.queue === config.queues.events) {
|
|
139
|
+
await events(batch, env);
|
|
140
|
+
}
|
|
141
|
+
else if (batch.queue === config.queues.deliveries) {
|
|
142
|
+
await deliveries(batch, env);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
throw new Error(`consumeQueues: ${batch.queue} is neither of the Queues it was given.`);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|