@sproutboat/wire 0.19.0 → 0.20.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 CHANGED
@@ -1,5 +1,56 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 482a55c: Queues like Cloudflare's, from compatibility date 2026-10-31, with queue names and consumer
8
+ settings in `sproutboat.jsonc` at every date.
9
+
10
+ - **Config (`@sproutboat/config`):** a `queues` entry may be `{ "binding": "QUEUE", "queue": "q",
11
+ "max_batch_size": 10, "max_batch_timeout": 1, "max_retries": 3, "retry_delay": 0,
12
+ "dead_letter_queue": "dlq" }`, the settings named as in wrangler's `queues.consumers`; `id`
13
+ is optional there. `queueSettings()` normalizes the entries for the build, and `pinBindingId`
14
+ adds an id to such an object.
15
+ - **Names and settings, at every date (opt-in):** `batch.queue` is the queue's name (the binding
16
+ name when unset). `max_batch_size` (1 to 100, default 10) and `max_batch_timeout` (0 to 60 s,
17
+ default 0: delivered at once, where Cloudflare waits up to 5 s) shape batches; `max_retries`,
18
+ `retry_delay` and `dead_letter_queue` decide what a retry does. A dead letter queue the worker
19
+ doesn't bind keeps its messages until one does.
20
+ - **From 2026-10-31:**
21
+ - `send()` and `sendBatch()` return Promises of `{ metadata: { metrics } }`, the queue's backlog
22
+ after the send as Cloudflare documents it (miniflare answers zeros); `metrics()` is added.
23
+ - Bodies keep their content type: `json` (the default), `text`, `bytes` (arrives as an
24
+ ArrayBuffer) and `v8` (structured clone). A string that looks like JSON or a number arrives as
25
+ the same string.
26
+ - workerd's argument errors: an undefined or BigInt `json` body, a `text` body that isn't a
27
+ string, an unknown content type, a body over 128 KiB, an empty batch, more than 100 messages
28
+ or 256 KiB in a batch, a batch that isn't iterable.
29
+ - A message is `{ id, timestamp, body, attempts }`, `timestamp` a Date of when it was sent, with
30
+ `ack()` and `retry({ delaySeconds })` on its prototype; a batch has `queue`, `messages` and
31
+ `metadata`, with `ackAll()` and `retryAll({ delaySeconds })`. The first of `ack()` and `retry()`
32
+ wins, and a message's own call wins over `ackAll()`/`retryAll()`, as Cloudflare documents
33
+ (miniflare lets a later `retryAll()` win).
34
+ - By default a message is retried at once, 3 times, then dropped or sent to the dead letter
35
+ queue. Earlier dates keep 5 s between attempts and 5 attempts.
36
+ - **Stores (embedded transport and `@sproutboat/wire`'s broker):** messages keep their send time
37
+ (an additive `sent_at` column), `queue.send` answers the backlog when asked, `queue.metrics` is
38
+ new, and a batch's outcome may carry retry delays and messages for the dead letter queue. An
39
+ older broker ignores those and retries as before; bodies sent from 2026-10-31 are tagged text, so
40
+ they travel through it unchanged.
41
+
42
+ ### Patch Changes
43
+
44
+ - Updated dependencies [482a55c]
45
+ - @sproutboat/runtime@0.32.0
46
+
47
+ ## 0.19.1
48
+
49
+ ### Patch Changes
50
+
51
+ - Updated dependencies [a04d0aa]
52
+ - @sproutboat/runtime@0.31.0
53
+
3
54
  ## 0.19.0
4
55
 
5
56
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,6 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@sproutboat/assets": "^0.3.0",
26
- "@sproutboat/runtime": "^0.30.0"
26
+ "@sproutboat/runtime": "^0.32.0"
27
27
  }
28
28
  }
@@ -1025,6 +1025,67 @@ test("Queues: send enqueues; the consumer delivers a batch and acked messages ar
1025
1025
  await expect(b.dispatch({ op: "queue.send", queue: "NOPE", body: "x" })).rejects.toThrow("not bound");
1026
1026
  });
1027
1027
 
1028
+ test("Queues: send reports the backlog; batches carry send times, metrics and the batch settings", async () => {
1029
+ const delivered: JsonObject[] = [];
1030
+ const fetchImpl: FetchLike = async (_url, init) => {
1031
+ const body = obj(parseJsonValue(String(init?.body)));
1032
+ delivered.push(body);
1033
+ return new Response(JSON.stringify({ ack: arr(body.messages).map((m) => obj(m).id), retry: [] }), { status: 200 });
1034
+ };
1035
+ const b = make({
1036
+ bindings: {
1037
+ queues: ["JOBS"],
1038
+ queueConfig: [{ binding: "JOBS", queue: "jobs", maxBatchSize: 2, maxBatchTimeout: 1 }],
1039
+ },
1040
+ sproutUrl: "http://127.0.0.1:1/",
1041
+ fetchImpl,
1042
+ });
1043
+ const before = Date.now();
1044
+ const sent = await b.dispatch({ op: "queue.send", queue: "JOBS", body: "one", metrics: true });
1045
+ expect(obj(sent.metrics)).toMatchObject({ backlogCount: 1, backlogBytes: 3 });
1046
+ expect(Number(obj(sent.metrics).oldestMessageTimestamp)).toBeGreaterThanOrEqual(before);
1047
+ expect(await b.dispatch({ op: "queue.metrics", queue: "JOBS" })).toMatchObject({ metrics: { backlogCount: 1 } });
1048
+ // One message waits for max_batch_timeout (1 s); three fill a batch of two at once.
1049
+ await Bun.sleep(700);
1050
+ expect(delivered.length).toBe(0);
1051
+ await Bun.sleep(900);
1052
+ expect(delivered.length).toBe(1);
1053
+ const batch = obj(delivered[0]);
1054
+ expect(Number(obj(arr(batch.messages)[0]).timestamp)).toBeGreaterThanOrEqual(before);
1055
+ expect(obj(obj(batch.metadata).metrics).backlogCount).toBe(1);
1056
+ await b.dispatch({ op: "queue.send_batch", queue: "JOBS", messages: [{ body: "a" }, { body: "b" }, { body: "c" }] });
1057
+ await Bun.sleep(600);
1058
+ expect(arr(obj(delivered[1]).messages).length).toBe(2);
1059
+ });
1060
+
1061
+ test("Queues: retry delays from the sprout, and messages past max_retries move to the dead letter queue", async () => {
1062
+ const delivered: JsonObject[] = [];
1063
+ const fetchImpl: FetchLike = async (_url, init) => {
1064
+ const body = obj(parseJsonValue(String(init?.body)));
1065
+ delivered.push(body);
1066
+ const ids = arr(body.messages).map((m) => String(obj(m).id));
1067
+ if (body.queue === "DLQ") return new Response(JSON.stringify({ ack: ids, retry: [] }), { status: 200 });
1068
+ // The first delivery retries at once; the second sends the message to the dead letter queue.
1069
+ const second = Number(obj(arr(body.messages)[0]).attempts) > 1;
1070
+ return new Response(
1071
+ JSON.stringify(
1072
+ second
1073
+ ? { ack: [], retry: ids, delays: {}, dead: ids, deadLetter: "DLQ" }
1074
+ : { ack: [], retry: ids, delays: Object.fromEntries(ids.map((id) => [id, 0])), dead: [] },
1075
+ ),
1076
+ { status: 200 },
1077
+ );
1078
+ };
1079
+ const b = make({ bindings: { queues: ["JOBS", "DLQ"] }, sproutUrl: "http://127.0.0.1:1/", fetchImpl });
1080
+ await b.dispatch({ op: "queue.send", queue: "JOBS", body: "work" });
1081
+ await Bun.sleep(1800);
1082
+ expect(delivered.map((d) => [d.queue, obj(arr(d.messages)[0]).attempts, obj(arr(d.messages)[0]).body])).toEqual([
1083
+ ["JOBS", 1, "work"],
1084
+ ["JOBS", 2, "work"],
1085
+ ["DLQ", 1, "work"],
1086
+ ]);
1087
+ });
1088
+
1028
1089
  test("assets.get: serves a bound file, falls back per not_found_handling", async () => {
1029
1090
  const dir = mkdtempSync(join(tmpdir(), "sb-assets-"));
1030
1091
  const assetsDir = join(dir, "assets");
package/src/broker.ts CHANGED
@@ -48,6 +48,15 @@ export type Bindings = {
48
48
  d1: string[];
49
49
  r2: string[];
50
50
  queues: string[];
51
+ /** Each queue binding's name and consumer settings (`queueSettings` in @sproutboat/config).
52
+ * The broker reads the batch settings; the sprout applies the retry settings. */
53
+ queueConfig?: Array<{
54
+ binding: string;
55
+ queue: string;
56
+ maxBatchSize?: number;
57
+ maxBatchTimeout?: number;
58
+ maxRetries?: number;
59
+ }>;
51
60
  analytics: string[];
52
61
  do: Array<{ binding: string; className: string }>;
53
62
  /** #48 — worker-to-worker: binding name -> the project it calls. */
@@ -369,6 +378,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
369
378
  .some((c) => c.name === "claim_token")
370
379
  )
371
380
  conn.exec("ALTER TABLE mq ADD COLUMN claim_token TEXT NOT NULL DEFAULT ''");
381
+ // When the message was sent (message.timestamp); 0 for messages queued before the column.
382
+ if (
383
+ !conn
384
+ .query<{ name: string }, []>("PRAGMA table_info(mq)")
385
+ .all()
386
+ .some((c) => c.name === "sent_at")
387
+ )
388
+ conn.exec("ALTER TABLE mq ADD COLUMN sent_at INTEGER NOT NULL DEFAULT 0");
372
389
  return conn;
373
390
  };
374
391
 
@@ -647,6 +664,20 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
647
664
  const requireD1 = bound(bindings.d1, "D1 database");
648
665
  const requireR2 = bound(bindings.r2, "R2 bucket");
649
666
  const requireQueue = bound(bindings.queues, "queue");
667
+ /** A queue's backlog as Queues reports it: live messages, their bytes, the oldest send time. */
668
+ const queueBacklog = (store: Database, part: string): JsonObject => {
669
+ const r = store
670
+ .query<{ n: number; bytes: number; oldest: number | null }, [string]>(
671
+ "SELECT COUNT(*) AS n, COALESCE(SUM(LENGTH(CAST(body AS BLOB))), 0) AS bytes, MIN(NULLIF(sent_at, 0)) AS oldest FROM mq WHERE queue = ? AND dead = 0",
672
+ )
673
+ .get(part);
674
+ return { backlogCount: r?.n ?? 0, backlogBytes: r?.bytes ?? 0, oldestMessageTimestamp: r?.oldest ?? 0 };
675
+ };
676
+ /** Where a queue partition named by the sprout lives: a bound queue, or "#name" (a dead
677
+ * letter queue the sprout does not bind) in the per-broker db. */
678
+ const queueStore = (part: string): { store: Database; part: string } =>
679
+ part.startsWith("#") ? { store: db, part } : storeFor("queue", requireQueue(part));
680
+ const queueSettingsOf = (binding: string) => bindings.queueConfig?.find((q) => q.binding === binding);
650
681
  const requireAe = bound(bindings.analytics, "analytics dataset");
651
682
  const rateLimiterNames = bindings.ratelimiters.map((r) => r.binding);
652
683
  const requireRateLimiter = bound(rateLimiterNames, "rate limiter");
@@ -1750,21 +1781,29 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1750
1781
 
1751
1782
  case "queue.send": {
1752
1783
  const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
1753
- const at = Date.now() + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
1784
+ const now = Date.now();
1785
+ const at = now + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
1754
1786
  store
1755
- .query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)")
1756
- .run(q, newId(), str(msg.body), at);
1757
- return { ok: true };
1787
+ .query("INSERT INTO mq (queue, id, body, visible_at, sent_at) VALUES (?, ?, ?, ?, ?)")
1788
+ .run(q, newId(), str(msg.body), at, now);
1789
+ return msg.metrics === true ? { ok: true, metrics: queueBacklog(store, q) } : { ok: true };
1758
1790
  }
1759
1791
  case "queue.send_batch": {
1760
1792
  const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
1761
1793
  const msgs = Array.isArray(msg.messages) ? msg.messages.map((m) => jsonObject(m) ?? {}) : [];
1762
- const ins = store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
1794
+ const ins = store.query("INSERT INTO mq (queue, id, body, visible_at, sent_at) VALUES (?, ?, ?, ?, ?)");
1795
+ const now = Date.now();
1763
1796
  store.transaction(() => {
1764
1797
  for (const m of msgs)
1765
- ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
1798
+ ins.run(q, newId(), str(m.body), now + Math.max(0, Number(m.delaySeconds) || 0) * 1000, now);
1766
1799
  })();
1767
- return { ok: true, count: msgs.length };
1800
+ return msg.metrics === true
1801
+ ? { ok: true, count: msgs.length, metrics: queueBacklog(store, q) }
1802
+ : { ok: true, count: msgs.length };
1803
+ }
1804
+ case "queue.metrics": {
1805
+ const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
1806
+ return { ok: true, metrics: queueBacklog(store, q) };
1768
1807
  }
1769
1808
 
1770
1809
  case "ae.write": {
@@ -2212,18 +2251,30 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
2212
2251
  if (!opts.sproutUrl || !dispatchEnabled() || closed) return;
2213
2252
  for (const binding of bindings.queues) {
2214
2253
  const { store, part } = storeFor("queue", binding);
2254
+ const settings = queueSettingsOf(binding);
2255
+ const size = Math.min(100, Math.max(1, Number(settings?.maxBatchSize) || QUEUE_BATCH));
2256
+ const wait = Math.max(0, Number(settings?.maxBatchTimeout) || 0) * 1000;
2257
+ // Deliveries that never came back (the sprout died or timed out each time) stop after
2258
+ // 5 attempts, or max_retries + 1 when that is more.
2259
+ const cap = Math.max(QUEUE_MAX_ATTEMPTS, (settings?.maxRetries ?? 3) + 1);
2215
2260
  const now = Date.now();
2216
2261
  const claim = randomBytes(24).toString("hex");
2217
2262
  const rows = store
2218
2263
  .transaction(() => {
2219
2264
  store
2220
2265
  .query("UPDATE mq SET dead = 1 WHERE queue = ? AND visible_at <= ? AND attempts >= ?")
2221
- .run(part, now, QUEUE_MAX_ATTEMPTS);
2266
+ .run(part, now, cap);
2222
2267
  const due = store
2223
- .query<{ id: string; body: string; attempts: number }, [string, number, number]>(
2224
- "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
2268
+ .query<
2269
+ { id: string; body: string; attempts: number; sent_at: number; visible_at: number },
2270
+ [string, number, number]
2271
+ >(
2272
+ "SELECT id, body, attempts, sent_at, visible_at FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
2225
2273
  )
2226
- .all(part, now, QUEUE_BATCH);
2274
+ .all(part, now, size);
2275
+ // max_batch_timeout: a batch short of max_batch_size waits until its oldest
2276
+ // message has been due that long.
2277
+ if (wait > 0 && due.length > 0 && due.length < size && due[0].visible_at + wait > now) return [];
2227
2278
  for (const row of due)
2228
2279
  store
2229
2280
  .query(
@@ -2239,14 +2290,18 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
2239
2290
  messages: rows.map((r) => ({
2240
2291
  id: r.id,
2241
2292
  body: r.body,
2242
- timestamp: now,
2293
+ timestamp: r.sent_at || now,
2243
2294
  attempts: r.attempts + 1,
2244
2295
  })),
2296
+ metadata: { metrics: queueBacklog(store, part) },
2245
2297
  }).then(async (res) => {
2246
2298
  if (closed || !dispatchEnabled()) return;
2247
2299
  const claimed = new Set(rows.map((r) => r.id));
2248
2300
  let ack = rows.map((r) => r.id);
2249
2301
  let retry: string[] = [];
2302
+ let dead: string[] = [];
2303
+ let deadLetter = "";
2304
+ let delays: JsonObject | null = null;
2250
2305
  if (res?.ok) {
2251
2306
  try {
2252
2307
  const parsed = jsonObject(parseJsonValue(await res.text()));
@@ -2254,6 +2309,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
2254
2309
  Array.isArray(v) ? v.filter(isString).filter((id) => claimed.has(id)) : null;
2255
2310
  ack = (parsed && ids(parsed.ack)) ?? ack;
2256
2311
  retry = (parsed && ids(parsed.retry)) ?? [];
2312
+ // From runtime 0.32: retry delays, and messages past max_retries (to the dead
2313
+ // letter queue when one is named). An older sprout sends neither.
2314
+ dead = (parsed && ids(parsed.dead)) ?? [];
2315
+ deadLetter = parsed && isString(parsed.deadLetter) ? parsed.deadLetter : "";
2316
+ delays = (parsed && jsonObject(parsed.delays ?? null)) ?? null;
2257
2317
  } catch {
2258
2318
  ack = [];
2259
2319
  retry = rows.map((r) => r.id);
@@ -2264,19 +2324,68 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
2264
2324
  }
2265
2325
  if (closed || !dispatchEnabled()) return;
2266
2326
  const retrySet = new Set(retry);
2327
+ const deadSet = new Set(dead);
2328
+ let target: { store: Database; part: string } | null = null;
2329
+ if (dead.length && deadLetter) {
2330
+ try {
2331
+ target = queueStore(deadLetter);
2332
+ } catch {
2333
+ target = null; // not a queue this broker serves: mark them dead instead
2334
+ }
2335
+ }
2336
+ // Into the dead letter queue first, then out of this one: a crash between the two
2337
+ // leaves a copy in each rather than none.
2338
+ if (target && target.store !== store) {
2339
+ const moved = store.query<{ id: string; body: string; sent_at: number }, [string, string, string, number]>(
2340
+ "SELECT id, body, sent_at FROM mq WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?",
2341
+ );
2342
+ const found = dead.flatMap((id) => moved.all(part, id, claim, Date.now()));
2343
+ const ins = target.store.query(
2344
+ "INSERT OR IGNORE INTO mq (queue, id, body, visible_at, sent_at) VALUES (?, ?, ?, ?, ?)",
2345
+ );
2346
+ const at = Date.now();
2347
+ const into = target;
2348
+ into.store.transaction(() => {
2349
+ for (const r of found) ins.run(into.part, r.id, r.body, at, r.sent_at);
2350
+ })();
2351
+ }
2267
2352
  store
2268
2353
  .transaction(() => {
2354
+ const at = Date.now();
2355
+ for (const id of dead) {
2356
+ if (target && target.store === store)
2357
+ store
2358
+ .query(
2359
+ "UPDATE mq SET queue = ?, visible_at = ?, attempts = 0, claim_token = '', dead = 0 WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?",
2360
+ )
2361
+ .run(target.part, at, part, id, claim, at);
2362
+ else if (target)
2363
+ store
2364
+ .query("DELETE FROM mq WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?")
2365
+ .run(part, id, claim, at);
2366
+ else
2367
+ store
2368
+ .query(
2369
+ "UPDATE mq SET dead = 1, claim_token = '' WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?",
2370
+ )
2371
+ .run(part, id, claim, at);
2372
+ }
2269
2373
  for (const id of ack)
2270
- if (!retrySet.has(id))
2374
+ if (!retrySet.has(id) && !deadSet.has(id))
2271
2375
  store
2272
2376
  .query("DELETE FROM mq WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?")
2273
- .run(part, id, claim, Date.now());
2274
- for (const id of retry)
2377
+ .run(part, id, claim, at);
2378
+ for (const id of retry) {
2379
+ if (deadSet.has(id)) continue;
2380
+ const seconds = delays ? Number(delays[id]) : NaN;
2381
+ const delay = Number.isFinite(seconds) ? Math.max(0, seconds) * 1000 : 5_000;
2382
+ // With runtime delays, max_retries is the sprout's: only the delivery cap applies.
2275
2383
  store
2276
2384
  .query(
2277
2385
  "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 > ?",
2278
2386
  )
2279
- .run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, part, id, claim, Date.now());
2387
+ .run(at + delay, delays ? cap : QUEUE_MAX_ATTEMPTS, part, id, claim, at);
2388
+ }
2280
2389
  })
2281
2390
  .immediate();
2282
2391
  });