@sproutboat/wire 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +42 -0
- package/package.json +6 -4
- package/src/broker.test.ts +147 -1
- package/src/broker.ts +324 -197
- package/src/build-d1-budget.ts +38 -0
- package/src/d1-budget.c +67 -0
- package/src/d1-pool.test.ts +209 -0
- package/src/d1-pool.ts +156 -0
- package/src/d1-sqlite.ts +14 -0
- package/src/d1-worker.ts +22 -0
- package/src/d1.ts +164 -0
- package/src/pool.test.ts +53 -0
- package/src/pool.ts +60 -0
package/src/broker.ts
CHANGED
|
@@ -17,17 +17,19 @@
|
|
|
17
17
|
* Encryption at rest and per-key TTLs are v2; redirects are never followed,
|
|
18
18
|
* so there is nothing to re-validate.
|
|
19
19
|
*/
|
|
20
|
+
import { createD1Store } from "./d1";
|
|
21
|
+
import type { D1Pool } from "./d1-pool";
|
|
20
22
|
import { Database, type Statement } from "bun:sqlite";
|
|
21
23
|
import { createHash, randomBytes } from "node:crypto";
|
|
22
24
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statfsSync, statSync, writeFileSync } from "node:fs";
|
|
23
25
|
import { open } from "node:fs/promises";
|
|
24
26
|
import { dirname, join, normalize, resolve } from "node:path";
|
|
25
27
|
import { parseArgs } from "node:util";
|
|
26
|
-
import { resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
|
|
28
|
+
import { matchRedirect, resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
|
|
27
29
|
import { egressAllowList } from "@sproutboat/runtime";
|
|
28
30
|
import { EGRESS_ERROR_HEADER, EGRESS_TOKEN_HEADER, EGRESS_URL_HEADER, resolveAll, vettedFetch } from "./egress";
|
|
29
31
|
import { isSafeInteger } from "./json";
|
|
30
|
-
import {
|
|
32
|
+
import { isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
|
|
31
33
|
|
|
32
34
|
export type Bindings = {
|
|
33
35
|
kv: string[];
|
|
@@ -61,6 +63,8 @@ export type Frame = { [key: string]: JsonValue | undefined };
|
|
|
61
63
|
export type FetchLike = (input: URL | string, init?: RequestInit) => Promise<Response>;
|
|
62
64
|
|
|
63
65
|
export type BrokerOptions = {
|
|
66
|
+
/** Shared-mode SQL isolation, owned by the multi-session worker. */
|
|
67
|
+
d1Pool?: D1Pool;
|
|
64
68
|
db?: string;
|
|
65
69
|
/** Directory for per-D1-binding SQLite files. Defaults to `<dirname(db)>/d1`, or in-memory when `db` is `:memory:`. */
|
|
66
70
|
dataDir?: string;
|
|
@@ -114,6 +118,9 @@ export type BrokerOptions = {
|
|
|
114
118
|
/** Lets a supervisor prepare a replacement without two brokers claiming
|
|
115
119
|
* queues, alarms, or cron ticks at once. Defaults to active. */
|
|
116
120
|
dispatchEnabled?: () => boolean;
|
|
121
|
+
/** Bounded delivery timeout; leases outlive this deadline. */
|
|
122
|
+
deliveryTimeoutMs?: number;
|
|
123
|
+
deliveryLeaseMs?: number;
|
|
117
124
|
/** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
|
|
118
125
|
assetsDir?: string;
|
|
119
126
|
/** Injected in tests; defaults to the global `fetch`. */
|
|
@@ -129,12 +136,6 @@ export type BrokerOptions = {
|
|
|
129
136
|
egressToken?: string;
|
|
130
137
|
};
|
|
131
138
|
|
|
132
|
-
type SqlParam = string | number | null;
|
|
133
|
-
/** The `{ results, meta }` shape Cloudflare's D1 client expects back per statement. */
|
|
134
|
-
type D1Result = {
|
|
135
|
-
results: JsonValue[];
|
|
136
|
-
meta: { duration: number; changes: number; last_row_id: number; rows_read: number };
|
|
137
|
-
};
|
|
138
139
|
type R2Row = {
|
|
139
140
|
key: string;
|
|
140
141
|
size: number;
|
|
@@ -165,11 +166,6 @@ type R2TransferRow = {
|
|
|
165
166
|
};
|
|
166
167
|
const MULTIPART_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
167
168
|
const isNumber = (v: JsonValue | undefined): v is number => Number.isFinite(v);
|
|
168
|
-
const sqlParams = (v: JsonValue | undefined): SqlParam[] => {
|
|
169
|
-
if (!Array.isArray(v)) return [];
|
|
170
|
-
return v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? (p ? 1 : 0) : String(p)));
|
|
171
|
-
};
|
|
172
|
-
|
|
173
169
|
// One binding call = one frame, and an R2/KV value travels inside it as a JSON
|
|
174
170
|
// string (escaping can inflate binary content several ×). 32 MiB keeps a ~25 MB
|
|
175
171
|
// upload working; true large-object streaming is v2.
|
|
@@ -281,7 +277,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
281
277
|
const openStore = (path: string): Database => {
|
|
282
278
|
const conn = new Database(path, { create: true });
|
|
283
279
|
conn.exec("PRAGMA journal_mode = WAL");
|
|
284
|
-
conn.exec("PRAGMA synchronous = NORMAL");
|
|
280
|
+
conn.exec("PRAGMA synchronous = NORMAL; PRAGMA busy_timeout=5000");
|
|
285
281
|
conn.exec(
|
|
286
282
|
"CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
|
|
287
283
|
"expires_at INTEGER, PRIMARY KEY (ns, key))",
|
|
@@ -327,6 +323,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
327
323
|
// Every queue poll filters one partition's live rows and takes its earliest
|
|
328
324
|
// visible batch. This additive index also upgrades existing resource files.
|
|
329
325
|
conn.exec("CREATE INDEX IF NOT EXISTS mq_due ON mq (queue, dead, visible_at)");
|
|
326
|
+
if (!conn.query<{ name: string }, []>("PRAGMA table_info(mq)").all().some((c) => c.name === "claim_token"))
|
|
327
|
+
conn.exec("ALTER TABLE mq ADD COLUMN claim_token TEXT NOT NULL DEFAULT ''");
|
|
330
328
|
return conn;
|
|
331
329
|
};
|
|
332
330
|
|
|
@@ -354,6 +352,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
354
352
|
);
|
|
355
353
|
// Alarm polling reads the earliest due objects across classes.
|
|
356
354
|
doDb.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
|
|
355
|
+
doDb.exec("CREATE TABLE IF NOT EXISTS do_alarm_delivery (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, attempts INTEGER NOT NULL, token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY(cls,id))");
|
|
357
356
|
// #69 — fixed-window rate-limiter counters, per binding name + key.
|
|
358
357
|
db.exec(
|
|
359
358
|
"CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
|
|
@@ -577,69 +576,17 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
577
576
|
// binding wired to an account-level resource (#74) opens `<resource-id>.sqlite`
|
|
578
577
|
// under `resourceDir` (survives redeploys); a bare-string binding keeps its
|
|
579
578
|
// per-broker `<name>.sqlite` under `d1Dir`.
|
|
580
|
-
const
|
|
581
|
-
const
|
|
579
|
+
const d1Store = createD1Store();
|
|
580
|
+
const d1Session = opts.d1Pool?.session();
|
|
581
|
+
const d1 = (name: string, msg: Frame): Frame | Promise<Frame> => {
|
|
582
582
|
const resource = bindings.resources[name];
|
|
583
583
|
const key = resource && resource.kind === "d1" ? resource.id : name;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
conn.exec("PRAGMA synchronous = NORMAL");
|
|
591
|
-
d1Conns.set(key, conn);
|
|
592
|
-
}
|
|
593
|
-
return conn;
|
|
594
|
-
};
|
|
595
|
-
|
|
596
|
-
// Run one statement, return CF-D1-shaped { results, meta }.
|
|
597
|
-
const d1Run = (conn: Database, sql: string, params: SqlParam[]): D1Result => {
|
|
598
|
-
const started = performance.now();
|
|
599
|
-
// SQLite hands back column->(string|number|null|blob) rows, i.e. a JSON object.
|
|
600
|
-
const results = conn.query<JsonObject, SqlParam[]>(sql).all(...params);
|
|
601
|
-
const m = conn
|
|
602
|
-
.query<{ changes: number; last_row_id: number }, []>(
|
|
603
|
-
"SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
|
|
604
|
-
)
|
|
605
|
-
.get();
|
|
606
|
-
return {
|
|
607
|
-
results,
|
|
608
|
-
meta: {
|
|
609
|
-
duration: performance.now() - started,
|
|
610
|
-
changes: m?.changes ?? 0,
|
|
611
|
-
last_row_id: m?.last_row_id ?? 0,
|
|
612
|
-
rows_read: results.length,
|
|
613
|
-
},
|
|
614
|
-
};
|
|
615
|
-
};
|
|
616
|
-
|
|
617
|
-
// #164 — an online, integrity-checked snapshot of a D1 database via
|
|
618
|
-
// VACUUM INTO. Standalone (transport-embedded) is the primary target; the
|
|
619
|
-
// broker carries it too so one conformance suite covers both backends.
|
|
620
|
-
const d1Backup = (conn: Database, dbName: string, name: string) => {
|
|
621
|
-
const source = conn.filename;
|
|
622
|
-
if (!source || source === ":memory:") throw new Error("d1 backup needs a file-backed database");
|
|
623
|
-
// A caller-supplied name must be a plain basename; else use the default.
|
|
624
|
-
const file = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
|
|
625
|
-
? name
|
|
626
|
-
: `${dbName}-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
|
|
627
|
-
const dir = join(dirname(dirname(resolve(source))), "backups");
|
|
628
|
-
mkdirSync(dir, { recursive: true });
|
|
629
|
-
const dest = join(dir, file);
|
|
630
|
-
if (existsSync(dest)) rmSync(dest); // VACUUM INTO refuses an existing target
|
|
631
|
-
conn.run("VACUUM INTO ?", [dest]);
|
|
632
|
-
const copy = new Database(dest, { readonly: true });
|
|
633
|
-
try {
|
|
634
|
-
const row = copy.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get();
|
|
635
|
-
if (row?.integrity_check !== "ok") {
|
|
636
|
-
rmSync(dest, { force: true });
|
|
637
|
-
throw new Error("integrity check failed on the backup");
|
|
638
|
-
}
|
|
639
|
-
} finally {
|
|
640
|
-
copy.close();
|
|
641
|
-
}
|
|
642
|
-
return { path: dest, bytes: statSync(dest).size };
|
|
584
|
+
const dir = resource && resource.kind === "d1" ? resourceDir : d1Dir;
|
|
585
|
+
const path = dir ? resolve(dir, `${key}.sqlite`) : ":memory:";
|
|
586
|
+
// In-memory namespaces belong to this session; file resources share affinity.
|
|
587
|
+
const identity = path === ":memory:" ? `${token}:${dbPath}:${key}` : path;
|
|
588
|
+
const request = { key: identity, path, name, msg };
|
|
589
|
+
return d1Session ? d1Session.execute(request) : d1Store.execute({ ...request, owner: "dedicated" });
|
|
643
590
|
};
|
|
644
591
|
|
|
645
592
|
/**
|
|
@@ -948,7 +895,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
948
895
|
const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, ticket.key);
|
|
949
896
|
if (!row) return new Response("not found", { status: 404 });
|
|
950
897
|
const etag = `"${row.etag}"`;
|
|
951
|
-
|
|
898
|
+
const noneMatch = request.headers.get("if-none-match");
|
|
899
|
+
if (noneMatch?.split(",").some((value) => value.trim() === "*" || value.trim().replace(/^W\//, "") === etag))
|
|
900
|
+
return new Response(null, { status: 304, headers: { etag } });
|
|
952
901
|
const rangeHeader = request.headers.get("range");
|
|
953
902
|
const range = parseRange(rangeHeader, row.size);
|
|
954
903
|
if (!range) {
|
|
@@ -1365,25 +1314,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1365
1314
|
case "service.fetch":
|
|
1366
1315
|
return asTextFrame(await serviceFetch(msg));
|
|
1367
1316
|
|
|
1368
|
-
case "d1.query":
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const conn = d1(requireD1(msg.db));
|
|
1374
|
-
const stmts = Array.isArray(msg.statements) ? msg.statements.map((s) => jsonObject(s) ?? {}) : [];
|
|
1375
|
-
const runAll = conn.transaction(() => stmts.map((s) => d1Run(conn, str(s.sql), sqlParams(s.params))));
|
|
1376
|
-
return { ok: true, results: runAll() };
|
|
1377
|
-
}
|
|
1378
|
-
case "d1.exec": {
|
|
1379
|
-
const conn = d1(requireD1(msg.db));
|
|
1380
|
-
conn.exec(str(msg.sql));
|
|
1381
|
-
return { ok: true };
|
|
1382
|
-
}
|
|
1383
|
-
case "d1.backup": {
|
|
1384
|
-
const name = requireD1(msg.db);
|
|
1385
|
-
return { ok: true, ...d1Backup(d1(name), name, msg.name == null ? "" : str(msg.name)) };
|
|
1386
|
-
}
|
|
1317
|
+
case "d1.query":
|
|
1318
|
+
case "d1.batch":
|
|
1319
|
+
case "d1.exec":
|
|
1320
|
+
case "d1.backup":
|
|
1321
|
+
return await d1(requireD1(msg.db), msg);
|
|
1387
1322
|
|
|
1388
1323
|
case "ratelimit.check": {
|
|
1389
1324
|
// #69 — fixed window; limit/period arrive in the message. Same upsert as
|
|
@@ -1640,21 +1575,37 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1640
1575
|
|
|
1641
1576
|
// #125 — alarms. `at` is epoch ms; setting one replaces any pending alarm
|
|
1642
1577
|
// for that object, and attempts resets because this is a fresh schedule.
|
|
1643
|
-
case "do.alarm.set":
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1578
|
+
case "do.alarm.set": {
|
|
1579
|
+
const cls = requireDoClass(msg.cls);
|
|
1580
|
+
doDb.transaction(() => {
|
|
1581
|
+
doDb.query("DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND expires<=?").run(cls,str(msg.id),Date.now());
|
|
1582
|
+
doDb.query("INSERT INTO do_alarm (cls,id,at,attempts) VALUES (?1,?2,?3,0) ON CONFLICT (cls,id) DO UPDATE SET at=?3,attempts=0").run(cls,str(msg.id),Math.trunc(Number(msg.at)||0));
|
|
1583
|
+
}).immediate();
|
|
1584
|
+
return {ok:true};
|
|
1585
|
+
}
|
|
1649
1586
|
case "do.alarm.get": {
|
|
1650
1587
|
const row = doDb
|
|
1651
|
-
.query<{ at: number }, [string, string]>(
|
|
1588
|
+
.query<{ at: number }, [string, string]>(
|
|
1589
|
+
"SELECT at FROM do_alarm WHERE cls = ? AND id = ?",
|
|
1590
|
+
)
|
|
1652
1591
|
.get(requireDoClass(msg.cls), str(msg.id));
|
|
1653
|
-
|
|
1592
|
+
const retry = row
|
|
1593
|
+
? null
|
|
1594
|
+
: doDb
|
|
1595
|
+
.query<{ at: number }, [string, string, number]>(
|
|
1596
|
+
"SELECT at FROM do_alarm_delivery WHERE cls=? AND id=? AND expires <= ? AND attempts < 5",
|
|
1597
|
+
)
|
|
1598
|
+
.get(str(msg.cls), str(msg.id), Date.now() + 5_000);
|
|
1599
|
+
return { ok: true, at: row ? row.at : (retry?.at ?? null) };
|
|
1654
1600
|
}
|
|
1655
1601
|
case "do.alarm.delete": {
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1602
|
+
const cls = requireDoClass(msg.cls);
|
|
1603
|
+
const deleted = doDb.transaction(() => {
|
|
1604
|
+
const pending = doDb.query("DELETE FROM do_alarm WHERE cls=? AND id=?").run(cls,str(msg.id));
|
|
1605
|
+
const delivery = doDb.query("DELETE FROM do_alarm_delivery WHERE cls=? AND id=?").run(cls,str(msg.id));
|
|
1606
|
+
return pending.changes > 0 || delivery.changes > 0;
|
|
1607
|
+
}).immediate();
|
|
1608
|
+
return {ok:true,deleted};
|
|
1658
1609
|
}
|
|
1659
1610
|
|
|
1660
1611
|
case "assets.get": {
|
|
@@ -1763,6 +1714,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1763
1714
|
if (msg.op === "assets.get") {
|
|
1764
1715
|
if (!bindings.assets) throw new Error("assets not bound");
|
|
1765
1716
|
const reqPath = str(msg.path) || "/";
|
|
1717
|
+
const rules = { headers: assetManifest?.headers ?? [], redirects: assetManifest?.redirects ?? [] };
|
|
1718
|
+
for (const rule of rules.redirects) {
|
|
1719
|
+
const location = matchRedirect(rule, reqPath);
|
|
1720
|
+
if (location !== null) return { reply: { ok: true, found: false, status: rule.status, location, rules } };
|
|
1721
|
+
}
|
|
1766
1722
|
const key = resolveAssetKey(reqPath, (candidate) => !!assetManifest?.files[candidate]);
|
|
1767
1723
|
let hit = key ? readAsset(key) : null;
|
|
1768
1724
|
let found = Boolean(hit);
|
|
@@ -1774,9 +1730,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1774
1730
|
} else if (!hit && assetManifest?.notFound === "404-page") {
|
|
1775
1731
|
hit = readAsset("/404.html");
|
|
1776
1732
|
}
|
|
1777
|
-
if (!hit) return { reply: { ok: true, found: false, status: 404 }, bytes: new TextEncoder().encode("Not Found") };
|
|
1733
|
+
if (!hit) return { reply: { ok: true, found: false, status: 404, rules }, bytes: new TextEncoder().encode("Not Found") };
|
|
1778
1734
|
return {
|
|
1779
|
-
reply: { ok: true, found, status, type: hit.type, hash: hit.hash },
|
|
1735
|
+
reply: { ok: true, found, status, type: hit.type, hash: hit.hash, rules },
|
|
1780
1736
|
bytes: hit.body,
|
|
1781
1737
|
};
|
|
1782
1738
|
}
|
|
@@ -1893,103 +1849,189 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1893
1849
|
const QUEUE_BATCH = 10;
|
|
1894
1850
|
const QUEUE_MAX_ATTEMPTS = 5;
|
|
1895
1851
|
|
|
1896
|
-
|
|
1897
|
-
|
|
1852
|
+
const timeoutMs = opts.deliveryTimeoutMs ?? 25_000;
|
|
1853
|
+
const leaseMs = opts.deliveryLeaseMs ?? 30_000;
|
|
1854
|
+
if (!(timeoutMs > 0 && leaseMs > timeoutMs))
|
|
1855
|
+
throw new Error("delivery lease must exceed its timeout");
|
|
1856
|
+
let closed = false;
|
|
1857
|
+
const deliveries = new Set<AbortController>();
|
|
1858
|
+
async function deliverTrigger(
|
|
1859
|
+
kind: "scheduled" | "queue" | "alarm",
|
|
1860
|
+
body: JsonObject,
|
|
1861
|
+
): Promise<Response | null> {
|
|
1862
|
+
if (!opts.sproutUrl || !dispatchEnabled() || closed) return null;
|
|
1863
|
+
const abort = new AbortController();
|
|
1864
|
+
deliveries.add(abort);
|
|
1865
|
+
const timer = setTimeout(() => abort.abort(), timeoutMs);
|
|
1898
1866
|
try {
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1867
|
+
const response = await Promise.race([
|
|
1868
|
+
doFetch(opts.sproutUrl, {
|
|
1869
|
+
method: "POST",
|
|
1870
|
+
signal: abort.signal,
|
|
1871
|
+
headers: {
|
|
1872
|
+
"x-sb-trigger": kind,
|
|
1873
|
+
"x-sb-token": token,
|
|
1874
|
+
"content-type": "application/json",
|
|
1875
|
+
},
|
|
1876
|
+
body: JSON.stringify(body),
|
|
1877
|
+
}).then(async (res) => {
|
|
1878
|
+
if (kind !== "queue") { await res.body?.cancel(); return new Response(null,{status:res.status}); }
|
|
1879
|
+
const chunks: Uint8Array[] = [];
|
|
1880
|
+
let size = 0;
|
|
1881
|
+
const reader = res.body?.getReader();
|
|
1882
|
+
try {
|
|
1883
|
+
if (reader) for (;;) {
|
|
1884
|
+
const part = await reader.read();
|
|
1885
|
+
if (part.done) break;
|
|
1886
|
+
size += part.value.byteLength;
|
|
1887
|
+
if (size > 65_536) { await reader.cancel(); throw new Error("delivery acknowledgement exceeds 64 KiB"); }
|
|
1888
|
+
chunks.push(part.value);
|
|
1889
|
+
}
|
|
1890
|
+
} finally { reader?.releaseLock(); }
|
|
1891
|
+
return new Response(size ? Buffer.concat(chunks) : null,{status:res.status,headers:res.headers});
|
|
1892
|
+
}),
|
|
1893
|
+
new Promise<never>((_, reject) =>
|
|
1894
|
+
abort.signal.addEventListener("abort", () => reject(new Error("delivery interrupted")), {
|
|
1895
|
+
once: true,
|
|
1896
|
+
}),
|
|
1897
|
+
),
|
|
1898
|
+
]);
|
|
1899
|
+
return response;
|
|
1904
1900
|
} catch {
|
|
1905
1901
|
return null;
|
|
1902
|
+
} finally {
|
|
1903
|
+
clearTimeout(timer);
|
|
1904
|
+
deliveries.delete(abort);
|
|
1906
1905
|
}
|
|
1907
1906
|
}
|
|
1908
1907
|
|
|
1909
1908
|
function drainQueuesOnce(): void {
|
|
1910
|
-
if (!opts.sproutUrl || !dispatchEnabled() ||
|
|
1911
|
-
const now = Date.now();
|
|
1909
|
+
if (!opts.sproutUrl || !dispatchEnabled() || closed) return;
|
|
1912
1910
|
for (const binding of bindings.queues) {
|
|
1913
|
-
// `store`/`part` route the rows to this queue's backing file (#74); the
|
|
1914
|
-
// trigger payload still carries the binding name the sprout matches on.
|
|
1915
1911
|
const { store, part } = storeFor("queue", binding);
|
|
1912
|
+
const now = Date.now();
|
|
1913
|
+
const claim = randomBytes(24).toString("hex");
|
|
1916
1914
|
const rows = store
|
|
1917
|
-
.
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1915
|
+
.transaction(() => {
|
|
1916
|
+
store
|
|
1917
|
+
.query("UPDATE mq SET dead = 1 WHERE queue = ? AND visible_at <= ? AND attempts >= ?")
|
|
1918
|
+
.run(part, now, QUEUE_MAX_ATTEMPTS);
|
|
1919
|
+
const due = store
|
|
1920
|
+
.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
|
|
1921
|
+
"SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
|
|
1922
|
+
)
|
|
1923
|
+
.all(part, now, QUEUE_BATCH);
|
|
1924
|
+
for (const row of due)
|
|
1925
|
+
store
|
|
1926
|
+
.query(
|
|
1927
|
+
"UPDATE mq SET visible_at = ?, claim_token = ?, attempts = attempts + 1 WHERE queue = ? AND id = ?",
|
|
1928
|
+
)
|
|
1929
|
+
.run(now + leaseMs, claim, part, row.id);
|
|
1930
|
+
return due;
|
|
1931
|
+
})
|
|
1932
|
+
.immediate();
|
|
1933
|
+
if (!rows.length) continue;
|
|
1927
1934
|
void deliverTrigger("queue", {
|
|
1928
1935
|
queue: binding,
|
|
1929
|
-
messages: rows.map((r) => ({
|
|
1936
|
+
messages: rows.map((r) => ({
|
|
1937
|
+
id: r.id,
|
|
1938
|
+
body: r.body,
|
|
1939
|
+
timestamp: now,
|
|
1940
|
+
attempts: r.attempts + 1,
|
|
1941
|
+
})),
|
|
1930
1942
|
}).then(async (res) => {
|
|
1931
|
-
|
|
1943
|
+
if (closed || !dispatchEnabled()) return;
|
|
1944
|
+
const claimed = new Set(rows.map((r) => r.id));
|
|
1945
|
+
let ack = rows.map((r) => r.id);
|
|
1932
1946
|
let retry: string[] = [];
|
|
1933
|
-
if (res
|
|
1947
|
+
if (res?.ok) {
|
|
1934
1948
|
try {
|
|
1935
1949
|
const parsed = jsonObject(parseJsonValue(await res.text()));
|
|
1936
|
-
const ids = (
|
|
1950
|
+
const ids = (v: JsonValue | undefined) =>
|
|
1951
|
+
Array.isArray(v) ? v.filter(isString).filter((id) => claimed.has(id)) : null;
|
|
1937
1952
|
ack = (parsed && ids(parsed.ack)) ?? ack;
|
|
1938
1953
|
retry = (parsed && ids(parsed.retry)) ?? [];
|
|
1939
1954
|
} catch {
|
|
1940
|
-
|
|
1955
|
+
ack = [];
|
|
1956
|
+
retry = rows.map((r) => r.id);
|
|
1941
1957
|
}
|
|
1942
1958
|
} else {
|
|
1943
1959
|
ack = [];
|
|
1944
|
-
retry = rows.map((r) => r.id);
|
|
1960
|
+
retry = rows.map((r) => r.id);
|
|
1945
1961
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1962
|
+
if (closed || !dispatchEnabled()) return;
|
|
1963
|
+
const retrySet = new Set(retry);
|
|
1964
|
+
store
|
|
1965
|
+
.transaction(() => {
|
|
1966
|
+
for (const id of ack)
|
|
1967
|
+
if (!retrySet.has(id))
|
|
1968
|
+
store
|
|
1969
|
+
.query(
|
|
1970
|
+
"DELETE FROM mq WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?",
|
|
1971
|
+
)
|
|
1972
|
+
.run(part, id, claim, Date.now());
|
|
1973
|
+
for (const id of retry)
|
|
1974
|
+
store
|
|
1975
|
+
.query(
|
|
1976
|
+
"UPDATE mq SET visible_at = ?, claim_token = '', dead = CASE WHEN attempts >= ? THEN 1 ELSE 0 END WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?",
|
|
1977
|
+
)
|
|
1978
|
+
.run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, part, id, claim, Date.now());
|
|
1979
|
+
})
|
|
1980
|
+
.immediate();
|
|
1952
1981
|
});
|
|
1953
1982
|
}
|
|
1954
1983
|
}
|
|
1955
1984
|
|
|
1956
1985
|
const ALARM_MAX_ATTEMPTS = 5;
|
|
1957
|
-
|
|
1958
|
-
/**
|
|
1959
|
-
* #125 — fire alarms that are due.
|
|
1960
|
-
*
|
|
1961
|
-
* Claim-then-deliver, not deliver-then-delete: `alarm()` is allowed to call
|
|
1962
|
-
* `setAlarm()` for its next run, and that write lands *during* delivery. If
|
|
1963
|
-
* we deleted afterwards we would erase the alarm the handler just scheduled,
|
|
1964
|
-
* which is how a self-rescheduling object silently stops. Deleting first
|
|
1965
|
-
* means the handler's row is a fresh insert nothing else touches.
|
|
1966
|
-
*
|
|
1967
|
-
* A failed delivery re-arms with the queue consumer's policy — 5s apart, five
|
|
1968
|
-
* attempts — and `DO NOTHING` so a re-arm never overwrites an alarm the
|
|
1969
|
-
* handler set itself.
|
|
1970
|
-
*/
|
|
1971
1986
|
function fireAlarmsOnce(): void {
|
|
1972
|
-
if (!opts.sproutUrl || !dispatchEnabled() || bindings.do.length === 0) return;
|
|
1987
|
+
if (!opts.sproutUrl || !dispatchEnabled() || bindings.do.length === 0 || closed) return;
|
|
1973
1988
|
const now = Date.now();
|
|
1974
|
-
const
|
|
1975
|
-
.
|
|
1976
|
-
"
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1989
|
+
const claimed = doDb
|
|
1990
|
+
.transaction(() => {
|
|
1991
|
+
doDb.query("DELETE FROM do_alarm_delivery WHERE expires<=? AND attempts>=5 AND EXISTS (SELECT 1 FROM do_alarm a WHERE a.cls=do_alarm_delivery.cls AND a.id=do_alarm_delivery.id)").run(now);
|
|
1992
|
+
const due = doDb
|
|
1993
|
+
.query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
|
|
1994
|
+
"SELECT cls,id,at,attempts FROM do_alarm WHERE at <= ? AND NOT EXISTS (SELECT 1 FROM do_alarm_delivery d WHERE d.cls=do_alarm.cls AND d.id=do_alarm.id) ORDER BY at LIMIT 10",
|
|
1995
|
+
)
|
|
1996
|
+
.all(now)
|
|
1997
|
+
.filter((r) => bindings.do.some((b) => b.className === r.cls));
|
|
1998
|
+
for (const r of due) {
|
|
1999
|
+
doDb
|
|
2000
|
+
.query("INSERT INTO do_alarm_delivery VALUES (?,?,?,?,?,?)")
|
|
2001
|
+
.run(r.cls, r.id, r.at, r.attempts, "", 0);
|
|
2002
|
+
doDb.query("DELETE FROM do_alarm WHERE cls=? AND id=? AND at=?").run(r.cls, r.id, r.at);
|
|
2003
|
+
}
|
|
2004
|
+
const expired = doDb
|
|
2005
|
+
.query<{ cls: string; id: string; at: number; attempts: number }, [number, number]>(
|
|
2006
|
+
"SELECT cls,id,at,attempts FROM do_alarm_delivery WHERE expires <= ? AND attempts < ? LIMIT 10",
|
|
2007
|
+
)
|
|
2008
|
+
.all(now, ALARM_MAX_ATTEMPTS)
|
|
2009
|
+
.filter((r) => bindings.do.some((b) => b.className === r.cls));
|
|
2010
|
+
return expired.map((r) => {
|
|
2011
|
+
const claim = randomBytes(24).toString("hex");
|
|
2012
|
+
doDb
|
|
2013
|
+
.query(
|
|
2014
|
+
"UPDATE do_alarm_delivery SET token=?,expires=?,attempts=attempts+1 WHERE cls=? AND id=?",
|
|
2015
|
+
)
|
|
2016
|
+
.run(claim, now + leaseMs, r.cls, r.id);
|
|
2017
|
+
return { ...r, claim };
|
|
2018
|
+
});
|
|
2019
|
+
})
|
|
2020
|
+
.immediate();
|
|
2021
|
+
for (const r of claimed)
|
|
2022
|
+
void deliverTrigger("alarm", { cls: r.cls, id: r.id, scheduledTime: r.at }).then((res) => {
|
|
2023
|
+
if (closed || !dispatchEnabled()) return;
|
|
2024
|
+
if (res?.ok || doDb.query("SELECT 1 FROM do_alarm WHERE cls=? AND id=?").get(r.cls, r.id))
|
|
2025
|
+
doDb
|
|
2026
|
+
.query("DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires > ?")
|
|
2027
|
+
.run(r.cls, r.id, r.claim, Date.now());
|
|
2028
|
+
else
|
|
2029
|
+
doDb
|
|
2030
|
+
.query(
|
|
2031
|
+
"UPDATE do_alarm_delivery SET expires=? WHERE cls=? AND id=? AND token=? AND expires > ?",
|
|
2032
|
+
)
|
|
2033
|
+
.run(Date.now() + 5_000, r.cls, r.id, r.claim, Date.now());
|
|
1991
2034
|
});
|
|
1992
|
-
}
|
|
1993
2035
|
}
|
|
1994
2036
|
|
|
1995
2037
|
if (opts.sproutUrl) {
|
|
@@ -2005,7 +2047,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
2005
2047
|
if (stamp === lastTick) return; // once per minute
|
|
2006
2048
|
lastTick = stamp;
|
|
2007
2049
|
for (const expr of bindings.crons) {
|
|
2008
|
-
if (cronMatches(expr, now))
|
|
2050
|
+
if (cronMatches(expr, now))
|
|
2051
|
+
void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
|
|
2009
2052
|
}
|
|
2010
2053
|
}, 15_000),
|
|
2011
2054
|
);
|
|
@@ -2018,8 +2061,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
2018
2061
|
handleFrame,
|
|
2019
2062
|
transfer,
|
|
2020
2063
|
close: () => {
|
|
2064
|
+
closed = true;
|
|
2065
|
+
for (const delivery of deliveries) delivery.abort();
|
|
2021
2066
|
for (const t of timers) clearInterval(t);
|
|
2022
|
-
|
|
2067
|
+
d1Session?.close();
|
|
2068
|
+
d1Store.close();
|
|
2023
2069
|
for (const conn of resourceDbs.values()) conn.close();
|
|
2024
2070
|
if (doDb !== db) doDb.close();
|
|
2025
2071
|
db.close();
|
|
@@ -2070,18 +2116,63 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
|
|
|
2070
2116
|
// Concatenating on every chunk copies the accumulated buffer each time, which
|
|
2071
2117
|
// is quadratic in the body size: an 8 MB object arriving in 64 KB pieces
|
|
2072
2118
|
// copied hundreds of megabytes before anything was parsed.
|
|
2073
|
-
|
|
2119
|
+
type SocketState = {
|
|
2120
|
+
chunks: Buffer[];
|
|
2121
|
+
size: number;
|
|
2122
|
+
processing: boolean;
|
|
2123
|
+
closed: boolean;
|
|
2124
|
+
output: Buffer | null;
|
|
2125
|
+
offset: number;
|
|
2126
|
+
written: (() => void) | null;
|
|
2127
|
+
};
|
|
2128
|
+
const flush = (socket: Bun.Socket<SocketState>): void => {
|
|
2129
|
+
const state = socket.data;
|
|
2130
|
+
if (state.closed || !state.output) return;
|
|
2131
|
+
const accepted = socket.write(state.output.subarray(state.offset));
|
|
2132
|
+
if (accepted < 0) { socket.end(); return; }
|
|
2133
|
+
state.offset += accepted;
|
|
2134
|
+
if (state.offset === state.output.length) {
|
|
2135
|
+
state.output = null;
|
|
2136
|
+
const done = state.written;
|
|
2137
|
+
state.written = null;
|
|
2138
|
+
done?.();
|
|
2139
|
+
}
|
|
2140
|
+
};
|
|
2141
|
+
const send = (socket: Bun.Socket<SocketState>, output: Buffer): Promise<void> => {
|
|
2142
|
+
if (socket.data.closed) return Promise.resolve();
|
|
2143
|
+
return new Promise((done) => {
|
|
2144
|
+
socket.data.output = output;
|
|
2145
|
+
socket.data.offset = 0;
|
|
2146
|
+
socket.data.written = done;
|
|
2147
|
+
flush(socket);
|
|
2148
|
+
});
|
|
2149
|
+
};
|
|
2150
|
+
const server = Bun.listen<SocketState>({
|
|
2074
2151
|
hostname,
|
|
2075
2152
|
port,
|
|
2076
2153
|
socket: {
|
|
2077
2154
|
open(socket) {
|
|
2078
|
-
socket.data = { chunks: [], size: 0 };
|
|
2155
|
+
socket.data = { chunks: [], size: 0, processing: false, closed: false, output: null, offset: 0, written: null };
|
|
2156
|
+
},
|
|
2157
|
+
drain: flush,
|
|
2158
|
+
close(socket) {
|
|
2159
|
+
const state = socket.data;
|
|
2160
|
+
state.closed = true;
|
|
2161
|
+
state.chunks = [];
|
|
2162
|
+
state.size = 0;
|
|
2163
|
+
state.output = null;
|
|
2164
|
+
const done = state.written;
|
|
2165
|
+
state.written = null;
|
|
2166
|
+
done?.();
|
|
2079
2167
|
},
|
|
2080
2168
|
async data(socket, chunk) {
|
|
2081
2169
|
const state = socket.data;
|
|
2082
2170
|
state.chunks.push(chunk);
|
|
2083
2171
|
state.size += chunk.length;
|
|
2084
|
-
|
|
2172
|
+
if (state.processing) return;
|
|
2173
|
+
state.processing = true;
|
|
2174
|
+
try { for (;;) {
|
|
2175
|
+
if (state.closed) return;
|
|
2085
2176
|
if (state.size < 4) return;
|
|
2086
2177
|
if (state.chunks.length > 1) {
|
|
2087
2178
|
state.chunks = [Buffer.concat(state.chunks, state.size)];
|
|
@@ -2089,7 +2180,7 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
|
|
|
2089
2180
|
const buf = state.chunks[0];
|
|
2090
2181
|
const len = buf.readUInt32LE(0);
|
|
2091
2182
|
if (len > MAX_FRAME) {
|
|
2092
|
-
socket
|
|
2183
|
+
await send(socket, encodeFrame({ ok: false, error: "frame too large" }));
|
|
2093
2184
|
socket.end();
|
|
2094
2185
|
return;
|
|
2095
2186
|
}
|
|
@@ -2100,8 +2191,10 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
|
|
|
2100
2191
|
const rest = buf.subarray(4 + len);
|
|
2101
2192
|
state.chunks = rest.length ? [Buffer.from(rest)] : [];
|
|
2102
2193
|
state.size = rest.length;
|
|
2103
|
-
|
|
2104
|
-
|
|
2194
|
+
// Bun does not buffer unwritten bytes. Serialize replies and retain
|
|
2195
|
+
// the unsent suffix until drain, including binary R2 responses.
|
|
2196
|
+
await send(socket, await broker.handleFrame(payload));
|
|
2197
|
+
} } finally { state.processing = false; }
|
|
2105
2198
|
},
|
|
2106
2199
|
},
|
|
2107
2200
|
});
|
|
@@ -2119,8 +2212,9 @@ export function listenTransfers(broker: Broker, hostname: string, port: number):
|
|
|
2119
2212
|
return { port: server.port ?? port, stop: () => server.stop(true) };
|
|
2120
2213
|
}
|
|
2121
2214
|
|
|
2122
|
-
|
|
2215
|
+
export function startBroker(argv: string[], d1Pool?: D1Pool) {
|
|
2123
2216
|
const { values } = parseArgs({
|
|
2217
|
+
args: argv,
|
|
2124
2218
|
options: {
|
|
2125
2219
|
port: { type: "string" },
|
|
2126
2220
|
"transfer-port": { type: "string" },
|
|
@@ -2160,7 +2254,9 @@ if (import.meta.main) {
|
|
|
2160
2254
|
const parsed = jsonObject(parseJsonValue(values.services));
|
|
2161
2255
|
if (parsed) {
|
|
2162
2256
|
services = Object.fromEntries(
|
|
2163
|
-
Object.entries(parsed).flatMap(([binding, host]) =>
|
|
2257
|
+
Object.entries(parsed).flatMap(([binding, host]) =>
|
|
2258
|
+
isString(host) ? [[binding, host] as const] : [],
|
|
2259
|
+
),
|
|
2164
2260
|
);
|
|
2165
2261
|
}
|
|
2166
2262
|
} catch {
|
|
@@ -2171,21 +2267,20 @@ if (import.meta.main) {
|
|
|
2171
2267
|
// supervisor promotes one only after the edge points at it, using a local
|
|
2172
2268
|
// signal instead of a network-visible control endpoint.
|
|
2173
2269
|
let dispatchEnabled = !values["dispatch-disabled"];
|
|
2174
|
-
process.on("SIGUSR1", () => {
|
|
2175
|
-
dispatchEnabled = true;
|
|
2176
|
-
});
|
|
2177
|
-
process.on("SIGUSR2", () => {
|
|
2178
|
-
dispatchEnabled = false;
|
|
2179
|
-
});
|
|
2180
2270
|
const broker = createBroker({
|
|
2271
|
+
d1Pool,
|
|
2181
2272
|
db: values.db,
|
|
2182
2273
|
dataDir: values["data-dir"],
|
|
2183
2274
|
resourceDir: values["resource-dir"],
|
|
2184
2275
|
doDb: values["do-db"],
|
|
2185
2276
|
ownerId: values["owner-id"],
|
|
2186
|
-
r2QuotaBytes:
|
|
2187
|
-
|
|
2188
|
-
|
|
2277
|
+
r2QuotaBytes:
|
|
2278
|
+
Number(values["r2-quota-bytes"] ?? process.env.SPROUTBOAT_R2_QUOTA_BYTES) || undefined,
|
|
2279
|
+
r2ResourceIds: values["r2-resource-ids"]
|
|
2280
|
+
? String(values["r2-resource-ids"]).split(",").filter(Boolean)
|
|
2281
|
+
: undefined,
|
|
2282
|
+
r2MinFreeBytes:
|
|
2283
|
+
Number(values["r2-min-free-bytes"] ?? process.env.SPROUTBOAT_R2_MIN_FREE_BYTES) || undefined,
|
|
2189
2284
|
token: values.token ?? process.env.SB_BROKER_TOKEN,
|
|
2190
2285
|
bindings,
|
|
2191
2286
|
secrets,
|
|
@@ -2195,10 +2290,42 @@ if (import.meta.main) {
|
|
|
2195
2290
|
services,
|
|
2196
2291
|
dispatchEnabled: () => dispatchEnabled,
|
|
2197
2292
|
});
|
|
2198
|
-
|
|
2293
|
+
let listener: BrokerServer;
|
|
2294
|
+
try {
|
|
2295
|
+
listener = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
broker.close();
|
|
2298
|
+
throw error;
|
|
2299
|
+
}
|
|
2300
|
+
const { port } = listener;
|
|
2199
2301
|
const transferPort = Number(values["transfer-port"] ?? process.env.SB_R2_TRANSFER_PORT ?? 0);
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
)
|
|
2302
|
+
let transferServer: BrokerServer | null = null;
|
|
2303
|
+
try {
|
|
2304
|
+
transferServer = transferPort > 0 ? listenTransfers(broker, "127.0.0.1", transferPort) : null;
|
|
2305
|
+
} catch (error) {
|
|
2306
|
+
listener.stop();
|
|
2307
|
+
broker.close();
|
|
2308
|
+
throw error;
|
|
2309
|
+
}
|
|
2310
|
+
return {
|
|
2311
|
+
port,
|
|
2312
|
+
enableDispatch: () => {
|
|
2313
|
+
dispatchEnabled = true;
|
|
2314
|
+
},
|
|
2315
|
+
disableDispatch: () => {
|
|
2316
|
+
dispatchEnabled = false;
|
|
2317
|
+
},
|
|
2318
|
+
close: () => {
|
|
2319
|
+
listener.stop();
|
|
2320
|
+
transferServer?.stop();
|
|
2321
|
+
broker.close();
|
|
2322
|
+
},
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
if (import.meta.main) {
|
|
2327
|
+
const session = startBroker(process.argv.slice(2));
|
|
2328
|
+
process.on("SIGUSR1", session.enableDispatch);
|
|
2329
|
+
process.on("SIGUSR2", session.disableDispatch);
|
|
2330
|
+
console.log(`sproutboat broker: 127.0.0.1:${session.port}`);
|
|
2204
2331
|
}
|