@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6880f60: Add an opt-in bounded multi-session broker worker with private supervisor control.
|
|
8
|
+
Fence queue acknowledgements to durable delivery claims and retain recoverable alarm deliveries across process crashes in broker and embedded transports.
|
|
9
|
+
|
|
10
|
+
Resume native R2 downloads until completion or renewed backpressure instead of leaving large responses stalled.
|
|
11
|
+
|
|
12
|
+
Retain partially written TCP replies until drain and serialize pipelined replies so large broker-backed R2 reads cannot truncate or interleave frames.
|
|
13
|
+
|
|
14
|
+
Honor wildcard, weak and list-based If-None-Match conditions on broker direct downloads.
|
|
15
|
+
|
|
16
|
+
Move D1 queries, batches, exec and backups off the shared broker event loop into two lazy SQL threads per broker process. Preserve database affinity, transaction rollback and session isolation, and bound queued work with per-session admission and queue deadlines. Running SQLite statements remain non-preemptible; keep shared mode opt-in.
|
|
17
|
+
|
|
18
|
+
Enforce shared D1 execution budgets through a capability-protected native SQLite progress hook. Interrupted batches roll back and lanes remain usable. A broker watchdog revokes all sessions if native work cannot be interrupted; uncertain SQL outcomes are never automatically retried. Build the small SQLite extension during host/image preparation.
|
|
19
|
+
- a96a443: Apply static asset redirects and header rules in dev and standalone asset bindings, preserving binary response bodies.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [5d4d40b]
|
|
24
|
+
- Updated dependencies [6880f60]
|
|
25
|
+
- Updated dependencies [846519f]
|
|
26
|
+
- Updated dependencies [b47aad4]
|
|
27
|
+
- Updated dependencies [f19ca0d]
|
|
28
|
+
- Updated dependencies [ddb0f9d]
|
|
29
|
+
- Updated dependencies [f201b58]
|
|
30
|
+
- Updated dependencies [08d0192]
|
|
31
|
+
- Updated dependencies [6d6ae43]
|
|
32
|
+
- Updated dependencies [9fdf3fa]
|
|
33
|
+
- Updated dependencies [4c78d83]
|
|
34
|
+
- Updated dependencies [088cad0]
|
|
35
|
+
- Updated dependencies [a96a443]
|
|
36
|
+
- Updated dependencies [6e42051]
|
|
37
|
+
- Updated dependencies [6e88627]
|
|
38
|
+
- Updated dependencies [9dc109d]
|
|
39
|
+
- Updated dependencies [28b0c39]
|
|
40
|
+
- Updated dependencies [6b64985]
|
|
41
|
+
- Updated dependencies [eec94f1]
|
|
42
|
+
- @sproutboat/runtime@0.17.0
|
|
43
|
+
|
|
3
44
|
## 0.12.2
|
|
4
45
|
|
|
5
46
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -28,3 +28,45 @@ import { parseJsonValue } from "@sproutboat/wire";
|
|
|
28
28
|
an unparsed value.
|
|
29
29
|
|
|
30
30
|
See `src/broker.test.ts` for the protocol contract in executable form.
|
|
31
|
+
|
|
32
|
+
## Shared SQL execution budgets
|
|
33
|
+
|
|
34
|
+
Shared mode requires the small SQLite deadline extension. Build it during host
|
|
35
|
+
or image preparation, before starting the broker, with:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
bun packages/wire/src/build-d1-budget.ts
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From the published package directory, use `bun run build:sql-budget`. The build
|
|
42
|
+
needs a C compiler and SQLite development headers (`gcc libsqlite3-dev` on
|
|
43
|
+
Debian/Ubuntu). It uses SQLite's extension ABI and does not link another SQLite
|
|
44
|
+
engine on Linux. Native outputs are host-specific and excluded from the source
|
|
45
|
+
package. The platform installer builds the extension during provisioning and
|
|
46
|
+
staged updates; tenant requests never invoke a compiler.
|
|
47
|
+
|
|
48
|
+
The default shared D1 execution budget is 1,000 ms per operation, including an
|
|
49
|
+
entire transactional batch. Operators can set `SPROUTBOAT_D1_EXECUTION_MS` to an
|
|
50
|
+
integer from 10 to 30,000. Queue admission remains 128 active/queued calls per
|
|
51
|
+
broker, 16 per session, with a separate ten-second queue deadline. Tenant request
|
|
52
|
+
fields cannot change execution budgets.
|
|
53
|
+
|
|
54
|
+
A native progress callback checks the monotonic deadline every 1,000 SQLite VM
|
|
55
|
+
instructions. Interrupted batches roll back, and the same SQL lane remains
|
|
56
|
+
usable. Multi-statement `exec` retains SQLite's existing partial-commit behavior:
|
|
57
|
+
earlier statements can have committed before a later statement fails. The
|
|
58
|
+
executor never automatically retries uncertain mutations.
|
|
59
|
+
|
|
60
|
+
Locks and other native operations may not poll SQLite progress. A broker-thread
|
|
61
|
+
watchdog allows one additional second, then revokes the broker and all of its
|
|
62
|
+
sessions through supervisor recovery. That fallback reports an uncertain SQL
|
|
63
|
+
outcome; it does not assert that every earlier mutation rolled back. Shared mode
|
|
64
|
+
remains opt-in, and this mechanism is not a memory isolation boundary.
|
|
65
|
+
|
|
66
|
+
On macOS, Bun's default Apple SQLite disables extensions. Install an
|
|
67
|
+
extension-capable SQLite library and select it before opening any SQLite DBs.
|
|
68
|
+
The shared broker and package test preload select Homebrew's standard library
|
|
69
|
+
location; `SPROUTBOAT_D1_SQLITE_LIBRARY` overrides it. `SQLITE_INCLUDE` overrides
|
|
70
|
+
the build header directory, and `SPROUTBOAT_D1_BUDGET_EXTENSION` overrides the
|
|
71
|
+
trusted extension path. Linux uses Bun's existing SQLite engine. Dedicated
|
|
72
|
+
broker mode does not enable execution budgets.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@sproutboat/assets": "^0.3.0",
|
|
15
|
-
"@sproutboat/runtime": "^0.
|
|
15
|
+
"@sproutboat/runtime": "^0.17.0"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts",
|
|
19
19
|
"./broker": "./src/broker.ts",
|
|
20
20
|
"./package.json": "./package.json",
|
|
21
|
-
"./egress": "./src/egress.ts"
|
|
21
|
+
"./egress": "./src/egress.ts",
|
|
22
|
+
"./pool": "./src/pool.ts"
|
|
22
23
|
},
|
|
23
24
|
"scripts": {
|
|
24
|
-
"test": "bun test ./"
|
|
25
|
+
"test": "bun test ./",
|
|
26
|
+
"build:sql-budget": "bun src/build-d1-budget.ts"
|
|
25
27
|
}
|
|
26
28
|
}
|
package/src/broker.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createBroker,
|
|
8
8
|
cronMatches,
|
|
9
9
|
encodeFrame,
|
|
10
|
+
frameOf,
|
|
10
11
|
encodeV1,
|
|
11
12
|
listen,
|
|
12
13
|
listenTransfers,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
type FetchLike,
|
|
16
17
|
} from "./broker";
|
|
17
18
|
import { createHash } from "node:crypto";
|
|
19
|
+
import { createConnection } from "node:net";
|
|
18
20
|
import { walkAssets, type AssetManifest } from "@sproutboat/assets";
|
|
19
21
|
import { jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
|
|
20
22
|
|
|
@@ -35,11 +37,77 @@ function make(opts: Parameters<typeof createBroker>[0] = {}): Broker {
|
|
|
35
37
|
return b;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
test("a stale queue acknowledgement cannot remove a newer delivery claim", async () => {
|
|
41
|
+
const root = mkdtempSync(join(tmpdir(), "sb-claim-fence-"));
|
|
42
|
+
const path = join(root,"state.sqlite");
|
|
43
|
+
const first = Promise.withResolvers<void>();
|
|
44
|
+
const second = Promise.withResolvers<void>();
|
|
45
|
+
const finishFirst = Promise.withResolvers<Response>();
|
|
46
|
+
const finishSecond = Promise.withResolvers<Response>();
|
|
47
|
+
let enabled = false;
|
|
48
|
+
const a = createBroker({db:path,bindings:{queues:["JOBS"]},sproutUrl:"http://127.0.0.1:1/",deliveryTimeoutMs:2_000,deliveryLeaseMs:3_000,fetchImpl:async()=>{first.resolve();return finishFirst.promise;}});
|
|
49
|
+
const b = createBroker({db:path,bindings:{queues:["JOBS"]},sproutUrl:"http://127.0.0.1:1/",dispatchEnabled:()=>enabled,deliveryTimeoutMs:2_000,deliveryLeaseMs:3_000,fetchImpl:async()=>{second.resolve();return finishSecond.promise;}});
|
|
50
|
+
const inspect = new Database(path);
|
|
51
|
+
try {
|
|
52
|
+
await a.dispatch({op:"queue.send",queue:"JOBS",body:"work"});
|
|
53
|
+
await first.promise;
|
|
54
|
+
const row=inspect.query<{id:string;claim_token:string},[]>("SELECT id,claim_token FROM mq").get()!;
|
|
55
|
+
inspect.query("UPDATE mq SET visible_at=0").run();
|
|
56
|
+
enabled=true;
|
|
57
|
+
await second.promise;
|
|
58
|
+
expect(inspect.query<{claim_token:string},[]>("SELECT claim_token FROM mq").get()!.claim_token).not.toBe(row.claim_token);
|
|
59
|
+
finishFirst.resolve(Response.json({ack:[row.id],retry:[]}));
|
|
60
|
+
await Bun.sleep(30);
|
|
61
|
+
expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM mq").get()!.count).toBe(1);
|
|
62
|
+
finishSecond.resolve(Response.json({ack:[row.id],retry:[]}));
|
|
63
|
+
await Bun.sleep(30);
|
|
64
|
+
expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM mq").get()!.count).toBe(0);
|
|
65
|
+
} finally {a.close();b.close();inspect.close();rmSync(root,{recursive:true,force:true});}
|
|
66
|
+
},5_000);
|
|
67
|
+
|
|
38
68
|
test("ping round-trips", async () => {
|
|
39
69
|
const b = make();
|
|
40
70
|
expect(await b.dispatch({ op: "ping", msg: "hi" })).toMatchObject({ ok: true, op: "pong", echo: "hi" });
|
|
41
71
|
});
|
|
42
72
|
|
|
73
|
+
test("a new alarm replaces exhausted delivery state and delete cancels durable retries", async () => {
|
|
74
|
+
const root=mkdtempSync(join(tmpdir(),"sb-alarm-replace-"));
|
|
75
|
+
const path=join(root,"state.sqlite");
|
|
76
|
+
const broker=createBroker({db:path,bindings:{do:[{binding:"COUNTER",className:"Counter"}]}});
|
|
77
|
+
const inspect=new Database(path);
|
|
78
|
+
try {
|
|
79
|
+
inspect.query("INSERT INTO do_alarm_delivery VALUES ('Counter','one',1,5,'old',0)").run();
|
|
80
|
+
await broker.dispatch({op:"do.alarm.set",cls:"Counter",id:"one",at:Date.now()+60_000});
|
|
81
|
+
expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM do_alarm_delivery").get()!.count).toBe(0);
|
|
82
|
+
expect((await broker.dispatch({op:"do.alarm.get",cls:"Counter",id:"one"})).at).toBeGreaterThan(Date.now());
|
|
83
|
+
inspect.query("INSERT INTO do_alarm_delivery VALUES ('Counter','one',1,1,'active',9999999999999)").run();
|
|
84
|
+
expect((await broker.dispatch({op:"do.alarm.delete",cls:"Counter",id:"one"})).deleted).toBe(true);
|
|
85
|
+
expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM do_alarm_delivery").get()!.count).toBe(0);
|
|
86
|
+
expect((await broker.dispatch({op:"do.alarm.get",cls:"Counter",id:"one"})).at).toBeNull();
|
|
87
|
+
} finally {inspect.close();broker.close();rmSync(root,{recursive:true,force:true});}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("oversized queue acknowledgement bodies are cancelled and retried", async () => {
|
|
91
|
+
let cancelled=false;
|
|
92
|
+
const delivered=Promise.withResolvers<void>();
|
|
93
|
+
const root=mkdtempSync(join(tmpdir(),"sb-ack-bound-"));
|
|
94
|
+
const path=join(root,"state.sqlite");
|
|
95
|
+
const broker=createBroker({db:path,bindings:{queues:["JOBS"]},sproutUrl:"http://127.0.0.1:1/",fetchImpl:async()=>{
|
|
96
|
+
delivered.resolve();
|
|
97
|
+
return new Response(new ReadableStream({pull(controller){controller.enqueue(new Uint8Array(65_536));},cancel(){cancelled=true;}}));
|
|
98
|
+
}});
|
|
99
|
+
const inspect=new Database(path);
|
|
100
|
+
try {
|
|
101
|
+
await broker.dispatch({op:"queue.send",queue:"JOBS",body:"work"});
|
|
102
|
+
await delivered.promise;
|
|
103
|
+
await Bun.sleep(40);
|
|
104
|
+
expect(cancelled).toBe(true);
|
|
105
|
+
const row=inspect.query<{attempts:number;claim_token:string},[]>("SELECT attempts,claim_token FROM mq").get()!;
|
|
106
|
+
expect(row.attempts).toBe(1);
|
|
107
|
+
expect(row.claim_token).toBe("");
|
|
108
|
+
} finally {inspect.close();broker.close();rmSync(root,{recursive:true,force:true});}
|
|
109
|
+
},3_000);
|
|
110
|
+
|
|
43
111
|
test("#233: a KV value put as bytes is flagged binary and survives an existing store", async () => {
|
|
44
112
|
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
45
113
|
// One char per byte, as the runtime sends a put of an ArrayBuffer/view.
|
|
@@ -1251,15 +1319,30 @@ test("v1: a binary static asset round-trips without UTF-8 replacement", async ()
|
|
|
1251
1319
|
writeFileSync(
|
|
1252
1320
|
join(dir, "assets.json"),
|
|
1253
1321
|
JSON.stringify({
|
|
1322
|
+
headers: [{ pattern: "/*", set: [["X-Test", "asset"]], unset: ["ETag"] }],
|
|
1323
|
+
redirects: [{ from: "/old", to: "/new", status: 301 }],
|
|
1254
1324
|
notFound: "none",
|
|
1255
1325
|
runSproutFirst: false,
|
|
1256
|
-
files: {
|
|
1326
|
+
files: {
|
|
1327
|
+
"/fixture.bin": { type: "application/octet-stream", hash: "fixture" },
|
|
1328
|
+
"/old": { type: "text/html", hash: "missing-file" },
|
|
1329
|
+
},
|
|
1257
1330
|
}),
|
|
1258
1331
|
);
|
|
1259
1332
|
const b = make({ bindings: { assets: "ASSETS" }, assetsDir: assets, token: "tok" });
|
|
1260
1333
|
const server = listen(b, "127.0.0.1", 0);
|
|
1261
1334
|
const got = await v1(server, { v: 1, token: "tok", op: "assets.get", path: "/fixture.bin" });
|
|
1262
1335
|
expect(got.json.found).toBe(true);
|
|
1336
|
+
expect(got.json.rules).toEqual({
|
|
1337
|
+
headers: [{ pattern: "/*", set: [["X-Test", "asset"]], unset: ["ETag"] }],
|
|
1338
|
+
redirects: [{ from: "/old", to: "/new", status: 301 }],
|
|
1339
|
+
});
|
|
1340
|
+
const missing = await v1(server, { v: 1, token: "tok", op: "assets.get", path: "/old" });
|
|
1341
|
+
expect(missing.json.found).toBe(false);
|
|
1342
|
+
expect(missing.json.status).toBe(301);
|
|
1343
|
+
expect(missing.json.location).toBe("/new");
|
|
1344
|
+
expect(missing.bytes.length).toBe(0);
|
|
1345
|
+
expect(missing.json.rules).toEqual(got.json.rules);
|
|
1263
1346
|
expect(Buffer.from(got.bytes).equals(Buffer.from(body))).toBe(true);
|
|
1264
1347
|
server.stop();
|
|
1265
1348
|
b.close();
|
|
@@ -1345,3 +1428,66 @@ test("ratelimit: fixed window per key, limit/period from the message (#69)", asy
|
|
|
1345
1428
|
"not bound",
|
|
1346
1429
|
);
|
|
1347
1430
|
});
|
|
1431
|
+
|
|
1432
|
+
test("TCP: large replies survive backpressure and stay ordered with a pipelined request", async () => {
|
|
1433
|
+
const bytes = Buffer.alloc(8 * 1024 * 1024);
|
|
1434
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = i % 256;
|
|
1435
|
+
const first = frameOf(encodeV1({ ok: true, id: 1 }, bytes));
|
|
1436
|
+
const second = encodeFrame({ ok: true, id: 2 });
|
|
1437
|
+
let calls = 0;
|
|
1438
|
+
const broker = make();
|
|
1439
|
+
const server = listen({ ...broker, async handleFrame() {
|
|
1440
|
+
if (calls++ === 0) { await Bun.sleep(10); return first; }
|
|
1441
|
+
return second;
|
|
1442
|
+
} }, "127.0.0.1", 0);
|
|
1443
|
+
const socket = createConnection({ host: "127.0.0.1", port: server.port });
|
|
1444
|
+
let resume: ReturnType<typeof setTimeout> | undefined;
|
|
1445
|
+
let next: ReturnType<typeof setTimeout> | undefined;
|
|
1446
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1447
|
+
try {
|
|
1448
|
+
const received = await new Promise<Buffer>((done, reject) => {
|
|
1449
|
+
const chunks: Buffer[] = [];
|
|
1450
|
+
let size = 0;
|
|
1451
|
+
timeout = setTimeout(() => reject(new Error("large broker reply stalled or truncated")), 3000);
|
|
1452
|
+
socket.on("error", reject);
|
|
1453
|
+
socket.on("data", (chunk: Buffer) => {
|
|
1454
|
+
chunks.push(chunk); size += chunk.length;
|
|
1455
|
+
if (size >= first.length + second.length) done(Buffer.concat(chunks));
|
|
1456
|
+
});
|
|
1457
|
+
socket.on("connect", () => {
|
|
1458
|
+
socket.pause();
|
|
1459
|
+
socket.write(frameOf(Buffer.from("first")));
|
|
1460
|
+
next = setTimeout(() => socket.write(frameOf(Buffer.from("second"))), 2);
|
|
1461
|
+
resume = setTimeout(() => socket.resume(), 50);
|
|
1462
|
+
});
|
|
1463
|
+
});
|
|
1464
|
+
expect(received.length).toBe(first.length + second.length);
|
|
1465
|
+
expect(received.subarray(0, first.length).equals(first)).toBe(true);
|
|
1466
|
+
expect(received.subarray(first.length).equals(second)).toBe(true);
|
|
1467
|
+
expect(calls).toBe(2);
|
|
1468
|
+
} finally {
|
|
1469
|
+
clearTimeout(timeout); clearTimeout(next); clearTimeout(resume);
|
|
1470
|
+
socket.destroy(); server.stop();
|
|
1471
|
+
}
|
|
1472
|
+
}, 10_000);
|
|
1473
|
+
|
|
1474
|
+
test("R2 direct download: If-None-Match supports wildcard, lists and weak comparison", async () => {
|
|
1475
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-transfer-condition-"));
|
|
1476
|
+
const broker = make({ db: join(dir, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
|
|
1477
|
+
try {
|
|
1478
|
+
const put = await broker.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "content" });
|
|
1479
|
+
const etag = `"${String(obj(put.object).etag)}"`;
|
|
1480
|
+
const ticket = await broker.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "object", method: "download" });
|
|
1481
|
+
const url = `http://127.0.0.1${ticket.url}`;
|
|
1482
|
+
for (const value of ["*", etag, `W/${etag}`, `"other", W/${etag}`]) {
|
|
1483
|
+
const response = await broker.transfer(new Request(url, { headers: { "if-none-match": value } }));
|
|
1484
|
+
expect(response.status).toBe(304);
|
|
1485
|
+
expect(response.headers.get("etag")).toBe(etag);
|
|
1486
|
+
expect(await response.text()).toBe("");
|
|
1487
|
+
}
|
|
1488
|
+
const fresh = await broker.transfer(new Request(url, { headers: { "if-none-match": '"different"' } }));
|
|
1489
|
+
expect(fresh.status).toBe(200);
|
|
1490
|
+
expect(await fresh.text()).toBe("content");
|
|
1491
|
+
expect((await broker.transfer(new Request(url))).status).toBe(410);
|
|
1492
|
+
} finally { broker.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
1493
|
+
});
|