queen-mq 0.16.0 → 1.0.3

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.
Files changed (44) hide show
  1. package/README.md +175 -0
  2. package/client-v2/Queen.js +125 -15
  3. package/client-v2/README.md +69 -0
  4. package/client-v2/builders/QueueBuilder.js +18 -20
  5. package/client-v2/builders/TimerBuilder.js +262 -0
  6. package/client-v2/builders/TransactionBuilder.js +199 -10
  7. package/client-v2/consumer/ConsumerManager.js +64 -12
  8. package/client-v2/http/HttpClient.js +382 -32
  9. package/client-v2/kv/Kv.js +432 -0
  10. package/client-v2/kv/expiry.js +148 -0
  11. package/client-v2/streams/runtime/Runner.js +45 -0
  12. package/client-v2/utils/defaults.js +20 -1
  13. package/package.json +12 -3
  14. package/test-v2/_kvtimers.js +71 -0
  15. package/test-v2/ackwindow.js +265 -0
  16. package/test-v2/auth.js +65 -149
  17. package/test-v2/docs.js +204 -0
  18. package/test-v2/http-unit/hostHeader.test.js +411 -0
  19. package/test-v2/http-unit/retry429.test.js +319 -0
  20. package/test-v2/kv-unit/_planServer.js +65 -0
  21. package/test-v2/kv-unit/kvWire.test.js +377 -0
  22. package/test-v2/kv-unit/timerWire.test.js +177 -0
  23. package/test-v2/kv-unit/txnWire.test.js +222 -0
  24. package/test-v2/kv.js +273 -0
  25. package/test-v2/load.js +37 -41
  26. package/test-v2/maintenance.js +2 -2
  27. package/test-v2/push.js +25 -35
  28. package/test-v2/run.js +67 -5
  29. package/test-v2/semantics.js +801 -0
  30. package/test-v2/stream/_helpers.js +8 -1
  31. package/test-v2/stream/combined.js +4 -3
  32. package/test-v2/stream/cron.js +1 -1
  33. package/test-v2/stream/eventTime.js +8 -5
  34. package/test-v2/stream/operators.js +5 -5
  35. package/test-v2/stream/recovery.js +4 -1
  36. package/test-v2/stream/session.js +3 -3
  37. package/test-v2/stream/sliding.js +1 -1
  38. package/test-v2/stream/throughput.js +3 -2
  39. package/test-v2/stream/tumbling.js +16 -12
  40. package/test-v2/streams-unit/ack.test.js +203 -0
  41. package/test-v2/streams-unit/e2e.test.js +4 -1
  42. package/test-v2/timers.js +209 -0
  43. package/test-v2/transaction.js +4 -1
  44. package/test-v2/watermark.js +78 -62
@@ -0,0 +1,432 @@
1
+ /**
2
+ * The KV surface (PLAN_KV_TIMERS.md §5, §8.1).
3
+ *
4
+ * Seven operations, five code paths: get, getMany, getPrefix, put,
5
+ * putIfAbsent (an alias that desugars to put with expect:0 inside the stored
6
+ * procedure), delete, incr. Plus two conveniences this client owns: `once`,
7
+ * which is the idempotency marker written the way people actually reach for
8
+ * it, and `listAll`, which walks the keyset cursor of getPrefix.
9
+ *
10
+ * EVERYTHING GOES THROUGH `POST /api/v1/kv`, INCLUDING THE SINGLE-KEY READS.
11
+ * The path routes (`GET|PUT|DELETE /api/v1/kv/:ns/*key`) exist and are correct,
12
+ * but they are sugar "for the three cases people write by hand" (§8.1). An SDK
13
+ * is not one of them, and the batch route buys three things a path route
14
+ * cannot: it is the ONLY surface that accepts `incr` and `getPrefix`, so one
15
+ * transport serves all seven ops; it never has to percent-encode a key into a
16
+ * URL; and it keeps keys out of access logs, proxy samples and tracing spans,
17
+ * which is the same reasoning §5.5 applies to prefixes and which does not stop
18
+ * being true for a key.
19
+ *
20
+ * THE STATUS-CODE RULE YOU WILL NOTICE FIRST (§8.1): the HTTP status describes
21
+ * the outcome of the CALL, never the verdict of the predicate. An absent key, a
22
+ * lost putIfAbsent race, a delete that hit nothing -- all 200, with an explicit
23
+ * field in the body. `applied:false` is the single most frequent outcome of
24
+ * this product and it is not an error.
25
+ *
26
+ * WHICH LEADS TO THE ONE TRAP THIS LANGUAGE CANNOT DEFEND AGAINST
27
+ * STRUCTURALLY (§10.4): **every write returns an OBJECT, and objects are
28
+ * always truthy**.
29
+ *
30
+ * if (await kv.delete(ns, key)) { ... } // ALWAYS TAKEN. A bug.
31
+ * const r = await kv.delete(ns, key)
32
+ * if (r.applied) { ... } // the field is the answer
33
+ *
34
+ * That holds for all five writes -- put, putIfAbsent, delete, incr, once (whose
35
+ * field is `won`).
36
+ *
37
+ * AND THE ONE ABOUT EXPIRY (§5.7): a key that has expired is never returned and
38
+ * never counts as existing, even before the sweeper has pruned it. A `put`
39
+ * does NOT inherit the previous TTL -- that is not expressible, because a put
40
+ * that silently inherited an expiry is the fastest way to make a marker
41
+ * immortal.
42
+ */
43
+
44
+ import * as logger from '../utils/logger.js'
45
+ import { resolveExpiry } from './expiry.js'
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Op builders. Shared with TransactionBuilder, which puts the very same
49
+ // objects in the `kv` array of a bundle -- one definition of the wire shape,
50
+ // so the standalone route and the transaction rider can never drift.
51
+ //
52
+ // An entry is `{ base, expiry }`: `base` is the finished op minus its expiry,
53
+ // `expiry` is the caller's sugar, unresolved. `materializeKvOp` resolves it,
54
+ // and it is called at SEND time -- immediately here, at commit() there --
55
+ // because `until` is an instant and freezing it when the op was queued would
56
+ // ship a TTL that is already stale.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function requireName(value, what) {
60
+ if (typeof value !== 'string' || value.length === 0) {
61
+ throw new Error(`kv: ${what} must be a non-empty string, got ${JSON.stringify(value)}`)
62
+ }
63
+ return value
64
+ }
65
+
66
+ /**
67
+ * §5.3: an explicitly undefined or null `expect` is a CLIENT-SIDE BUG, never a
68
+ * silent downgrade to upsert. The caller wrote the word `expect`, so they
69
+ * declared the intention to fence; sending the op without it would perform the
70
+ * unconditional write the fence existed to prevent.
71
+ */
72
+ function withExpect(base, opts) {
73
+ if ('expect' in opts) {
74
+ if (opts.expect === undefined || opts.expect === null) {
75
+ throw new Error(
76
+ 'kv: `expect` was written but has no value. An absent expect is not an upsert — it is a bug in the ' +
77
+ 'caller: drop the field to mean "unconditional", or pass 0 to mean "must not exist".'
78
+ )
79
+ }
80
+ base.expect = opts.expect
81
+ }
82
+ return base
83
+ }
84
+
85
+ function withRequired(base, opts) {
86
+ if (opts.required === true) base.required = true
87
+ return base
88
+ }
89
+
90
+ export const kvOp = {
91
+ get(ns, key) {
92
+ return { base: { op: 'get', ns: requireName(ns, 'ns'), key: requireName(key, 'key') }, expiry: null }
93
+ },
94
+
95
+ getMany(ns, keys) {
96
+ requireName(ns, 'ns')
97
+ if (!Array.isArray(keys) || keys.length === 0) {
98
+ throw new Error('kv: getMany needs a non-empty array of keys')
99
+ }
100
+ keys.forEach(k => requireName(k, 'key'))
101
+ return { base: { op: 'getMany', ns, keys: [...keys] }, expiry: null }
102
+ },
103
+
104
+ getPrefix(ns, prefix, opts = {}) {
105
+ requireName(ns, 'ns')
106
+ // A namespace is not a table to enumerate: the empty prefix is the declared
107
+ // boundary, not an oversight (§5.5).
108
+ requireName(prefix, 'prefix')
109
+ const base = { op: 'getPrefix', ns, prefix }
110
+ if (opts.limit !== undefined) base.limit = opts.limit
111
+ if (opts.after !== undefined && opts.after !== null) base.after = opts.after
112
+ if (opts.keysOnly === true) base.keysOnly = true
113
+ return { base, expiry: null }
114
+ },
115
+
116
+ put(ns, key, value, opts = {}) {
117
+ requireName(ns, 'ns')
118
+ requireName(key, 'key')
119
+ const base = { op: 'put', ns, key, value }
120
+ return { base: withRequired(withExpect(base, opts), opts), expiry: opts }
121
+ },
122
+
123
+ putIfAbsent(ns, key, value, opts = {}) {
124
+ requireName(ns, 'ns')
125
+ requireName(key, 'key')
126
+ if ('expect' in opts) {
127
+ throw new Error('kv: putIfAbsent IS expect:0 — passing a different expect is a contradiction the broker rejects')
128
+ }
129
+ const base = { op: 'putIfAbsent', ns, key, value }
130
+ return { base: withRequired(base, opts), expiry: opts }
131
+ },
132
+
133
+ delete(ns, key, opts = {}) {
134
+ requireName(ns, 'ns')
135
+ requireName(key, 'key')
136
+ const base = { op: 'delete', ns, key }
137
+ return { base: withRequired(withExpect(base, opts), opts), expiry: null }
138
+ },
139
+
140
+ incr(ns, key, delta = 1, opts = {}) {
141
+ requireName(ns, 'ns')
142
+ requireName(key, 'key')
143
+ if ('expect' in opts) {
144
+ // §5.4: incr is the way OUT of CAS. A precondition on it would
145
+ // reintroduce the very loop it exists to remove.
146
+ throw new Error('kv: incr takes no expect — it is the escape from the CAS loop, not another CAS')
147
+ }
148
+ if (typeof delta !== 'number' || !Number.isFinite(delta)) {
149
+ throw new Error(`kv: incr needs a finite numeric delta, got ${JSON.stringify(delta)}`)
150
+ }
151
+ const base = { op: 'incr', ns, key, delta }
152
+ if (opts.min !== undefined) base.min = opts.min
153
+ if (opts.max !== undefined) base.max = opts.max
154
+ return { base: withRequired(base, opts), expiry: opts }
155
+ }
156
+ }
157
+
158
+ /** Finish one op: merge the resolved expiry into the base. */
159
+ export function materializeKvOp(entry, nowMs = Date.now()) {
160
+ if (!entry.expiry) return { ...entry.base }
161
+ return { ...entry.base, ...resolveExpiry(entry.expiry, nowMs) }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Response handling.
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * The KV routes put the CODE in `error` (§9.5's closed taxonomy) and have no
170
+ * separate `code` field, while the proxy's own refusals do. HttpClient maps
171
+ * `body.error` onto `.message`, so on these routes the message IS the code.
172
+ * Copying it into `.code` -- without ever overwriting a proxy-set one -- is
173
+ * what lets callers branch on `err.code` here exactly as they do on a proxy
174
+ * 403, and never on prose, which is forbidden everywhere in this codebase.
175
+ */
176
+ function decorate(error) {
177
+ if (error && !error.code && typeof error.message === 'string') {
178
+ error.code = error.message
179
+ }
180
+ return error
181
+ }
182
+
183
+ /** A lost `required` gate arrives as HTTP 200 with this envelope (§8.3). */
184
+ function isPrecondition(body) {
185
+ return body && typeof body === 'object' && body.ok === false && body.reason === 'kv_precondition'
186
+ }
187
+
188
+ /**
189
+ * Render a precondition verdict as a WriteResult, so that `applied` remains
190
+ * THE field to read whichever way the verdict arrived. `precondition:true`
191
+ * says the transaction was rolled back rather than merely refused, and
192
+ * `kvReason` is preserved under the name `reason` the other path uses.
193
+ */
194
+ function preconditionResult(body, op, key) {
195
+ return {
196
+ op,
197
+ key,
198
+ applied: false,
199
+ precondition: true,
200
+ reason: body.kvReason ?? null,
201
+ value: body.value,
202
+ version: body.version,
203
+ failedIndex: body.failedIndex
204
+ }
205
+ }
206
+
207
+ /**
208
+ * §5.4: past 2^53 `JSON.parse` loses precision SILENTLY, and `incr` runs on
209
+ * `numeric` server-side while `version` is a BIGINT. A rate limiter reading a
210
+ * counter that is quietly wrong is worse than one that fails, so this raises.
211
+ */
212
+ function assertSafeNumber(value, what) {
213
+ if (typeof value === 'number' && Math.abs(value) > Number.MAX_SAFE_INTEGER) {
214
+ throw new Error(
215
+ `kv: ${what} is ${value}, beyond 2^53 — JSON.parse has already lost precision on it. ` +
216
+ 'Refusing to hand back a number that is silently wrong.'
217
+ )
218
+ }
219
+ return value
220
+ }
221
+
222
+ export class Kv {
223
+ #httpClient
224
+
225
+ constructor(httpClient) {
226
+ this.#httpClient = httpClient
227
+ }
228
+
229
+ /**
230
+ * The one path to the broker. Returns either `{ results }` (index-aligned to
231
+ * the ops, §6.4) or `{ precondition }` for the 200-with-`ok:false` verdict.
232
+ */
233
+ async #apply(entries) {
234
+ const now = Date.now()
235
+ const operations = entries.map(e => materializeKvOp(e, now))
236
+ logger.log('Kv.apply', { count: operations.length, ops: operations.map(o => o.op) })
237
+
238
+ let body
239
+ try {
240
+ body = await this.#httpClient.post('/api/v1/kv', { operations })
241
+ } catch (error) {
242
+ logger.error('Kv.apply', { error: error.message, status: error.status, code: error.code })
243
+ throw decorate(error)
244
+ }
245
+
246
+ if (isPrecondition(body)) return { precondition: body }
247
+
248
+ const results = body && Array.isArray(body.results) ? body.results : null
249
+ if (!results) {
250
+ throw new Error('kv: unexpected response envelope — expected {"results":[...]}')
251
+ }
252
+ // The stored procedure guarantees one result per op and raises rather than
253
+ // returning a short array (§6.4). A short one here means something between
254
+ // the two rewrote the answer, and attributing result i to op j is how a
255
+ // caller ends up trusting the wrong verdict.
256
+ if (results.length !== operations.length) {
257
+ throw new Error(`kv: got ${results.length} results for ${operations.length} operations`)
258
+ }
259
+ return { results }
260
+ }
261
+
262
+ async #one(entry) {
263
+ const { results, precondition } = await this.#apply([entry])
264
+ if (precondition) return { precondition }
265
+ return { result: results[0] }
266
+ }
267
+
268
+ /** A read result, unwrapped. Reads have no precondition to lose. */
269
+ async #read(entry) {
270
+ const { result, precondition } = await this.#one(entry)
271
+ if (precondition) {
272
+ // Unreachable by construction (`required` is a write-only escalation),
273
+ // and therefore exactly the kind of thing that must not silently return
274
+ // `undefined` if the broker's envelope ever changes shape.
275
+ throw new Error(`kv: ${entry.base.op} came back as a precondition verdict, which a read cannot lose`)
276
+ }
277
+ return result
278
+ }
279
+
280
+ /** A write result, with the precondition envelope folded into the same shape. */
281
+ async #write(entry) {
282
+ const { result, precondition } = await this.#one(entry)
283
+ if (precondition) return preconditionResult(precondition, entry.base.op, entry.base.key)
284
+ if (typeof result.applied !== 'boolean') {
285
+ throw new Error(`kv: ${entry.base.op} result carries no \`applied\` field; refusing to guess the verdict`)
286
+ }
287
+ return result
288
+ }
289
+
290
+ // -------------------------------------------------------------- reads
291
+
292
+ /**
293
+ * One key. Returns the ROW, not the value:
294
+ * `{found, key, value?, version?, expiresAt?, updatedAt?}`.
295
+ *
296
+ * `found` is separate from `value` because `null` is a legal JSONB value
297
+ * (§5.5): `{found:true, value:null}` and `{found:false}` are different
298
+ * things, and an SDK that returned "the value or null" would collapse them.
299
+ */
300
+ async get(ns, key) {
301
+ return this.#read(kvOp.get(ns, key))
302
+ }
303
+
304
+ /**
305
+ * Many keys of one namespace. Returns `{rows, missing, truncated}`.
306
+ *
307
+ * `missing` is EXPLICIT: absence is a datum, not a hole the caller computes
308
+ * by difference. `truncated` means the byte budget cut the page -- those
309
+ * keys are in neither list, because calling them absent would be a lie.
310
+ */
311
+ async getMany(ns, keys) {
312
+ return this.#read(kvOp.getMany(ns, keys))
313
+ }
314
+
315
+ /**
316
+ * One page of a prefix scan: `{rows, truncated, nextAfter}`.
317
+ *
318
+ * `limit` is CLAMPED by the broker (default 100, ceiling 1000) and never
319
+ * rejected, plus a byte ceiling on the aggregate, with `truncated` telling
320
+ * the truth about both. `after` is an exclusive keyset cursor, not an
321
+ * offset.
322
+ *
323
+ * EACH PAGE IS ITS OWN SNAPSHOT. It is not an instant of the namespace: with
324
+ * `after` it can miss a key inserted behind the cursor. Good for compacting
325
+ * state, not for an exact count.
326
+ */
327
+ async getPrefix(ns, prefix, opts = {}) {
328
+ return this.#read(kvOp.getPrefix(ns, prefix, opts))
329
+ }
330
+
331
+ /**
332
+ * Every row under a prefix, one page at a time.
333
+ *
334
+ * An ASYNC GENERATOR, not an array: the page count is not knowable in
335
+ * advance, and a method that quietly buffered a namespace into memory would
336
+ * be the one thing this API is careful not to offer.
337
+ *
338
+ * for await (const row of kv.listAll(ns, 'quota:acme:')) { ... }
339
+ *
340
+ * It inherits getPrefix's snapshot caveat, one page at a time.
341
+ */
342
+ async *listAll(ns, prefix, opts = {}) {
343
+ let after = opts.after
344
+ for (;;) {
345
+ const page = await this.getPrefix(ns, prefix, { ...opts, after })
346
+ for (const row of page.rows || []) yield row
347
+ if (!page.truncated) return
348
+ if (!page.nextAfter) return
349
+ after = page.nextAfter
350
+ }
351
+ }
352
+
353
+ // ------------------------------------------------------------- writes
354
+
355
+ /**
356
+ * Write a value. Exactly one of `ttlSeconds` (or its sugar `ttl` / `until`)
357
+ * and `forever:true` is required -- the broker owns that rule so all seven
358
+ * clients inherit it.
359
+ *
360
+ * `expect` is the optimistic lock: `0` means "must not exist" (and wins even
361
+ * against an expired row not yet pruned), `N > 0` is a pure UPDATE that
362
+ * creates NOTHING when it matches no row. The version handed to a loser is
363
+ * ADVISORY -- never reuse it blindly as a fencing token.
364
+ *
365
+ * `required:true` escalates a lost precondition into a rolled-back
366
+ * transaction; on this route that comes back as a WriteResult with
367
+ * `precondition:true`, and inside a bundle it is what makes `commit()`
368
+ * return the verdict.
369
+ *
370
+ * Returns a WriteResult: `{applied, op, key, value, version, reason?}`.
371
+ * ALWAYS TRUTHY -- read `applied`.
372
+ */
373
+ async put(ns, key, value, opts = {}) {
374
+ return this.#write(kvOp.put(ns, key, value, opts))
375
+ }
376
+
377
+ /** put with `expect:0`, under the name of the thing. Same WriteResult, and the loser gets the winner's value. */
378
+ async putIfAbsent(ns, key, value, opts = {}) {
379
+ return this.#write(kvOp.putIfAbsent(ns, key, value, opts))
380
+ }
381
+
382
+ /**
383
+ * Delete a key, optionally fenced with `expect`.
384
+ *
385
+ * Returns a WriteResult. `if (await kv.delete(...))` is ALWAYS TRUE and is a
386
+ * bug: read `.applied`.
387
+ */
388
+ async delete(ns, key, opts = {}) {
389
+ return this.#write(kvOp.delete(ns, key, opts))
390
+ }
391
+
392
+ /**
393
+ * Add `delta` to a numeric key, atomically and without a CAS loop.
394
+ *
395
+ * With `max`, **`applied` IS the admission decision**: an increment that
396
+ * would break the ceiling does not apply, does not saturate and does not
397
+ * truncate -- it comes back `applied:false, reason:'limit'` with the CURRENT
398
+ * value. Comparing client-side after incrementing means the request that
399
+ * broke the ceiling has already consumed budget.
400
+ *
401
+ * The TTL is CREATE-ONLY: a live row keeps its expiry, so a fixed-window
402
+ * limiter actually closes its window. An expired row counts as zero and
403
+ * starts a new window, which is what makes the limiter one call.
404
+ */
405
+ async incr(ns, key, delta = 1, opts = {}) {
406
+ const res = await this.#write(kvOp.incr(ns, key, delta, opts))
407
+ assertSafeNumber(res.value, `the counter at ${key}`)
408
+ return res
409
+ }
410
+
411
+ /**
412
+ * The idempotency marker, written the way it is actually used:
413
+ *
414
+ * const { won } = await kv.once('test-idem', orderId, { ttl: '24h' })
415
+ * if (!won) return // somebody already did this
416
+ *
417
+ * `putIfAbsent` with a default value of `true`. Returns `{won, value,
418
+ * version, result}` -- `value` being the WINNER's value, so the loser never
419
+ * needs a second round trip.
420
+ *
421
+ * And the sentence that has to travel with it: **putIfAbsent plus a TTL is
422
+ * not a distributed lock** (§5.7). A lock that expires is not revoked; the
423
+ * old holder keeps working, it simply no longer has the row. The defence is
424
+ * fencing -- carry the `version` as `expect` on every later write -- and
425
+ * that limits the damage rather than removing it.
426
+ */
427
+ async once(ns, key, opts = {}) {
428
+ const value = opts.value !== undefined ? opts.value : true
429
+ const res = await this.putIfAbsent(ns, key, value, opts)
430
+ return { won: res.applied === true, value: res.value, version: res.version, result: res }
431
+ }
432
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Expiry sugar for the KV surface, and the ONE duration parser this SDK has.
3
+ *
4
+ * PLAN_KV_TIMERS.md §20.1 ratified `ttlSeconds` as the wire's unit: the server
5
+ * speaks seconds everywhere (`dedup_window_seconds`, `lease_seconds`,
6
+ * `retention_seconds`), so the product keeps ONE duration convention and the
7
+ * SDKs do the converting. `ttlMillis` does not exist in any client and must not
8
+ * be added -- it would reintroduce, through the service entrance, the double
9
+ * convention that decision removed.
10
+ *
11
+ * §20.6 is the other half of the rule, and it is what this file's parser
12
+ * serves twice: DURATIONS THAT CAN BE SUB-SECOND ARE IN MILLISECONDS, THE ONES
13
+ * THAT CANNOT ARE IN SECONDS. A 250 ms retry backoff is a real and central use
14
+ * of timers, so their wire is `delayMs`; a sub-second TTL is not a real use for
15
+ * anybody, so the KV wire is `ttlSeconds`. The parser below returns
16
+ * MILLISECONDS -- the finer of the two -- and each caller converts.
17
+ *
18
+ * NOT EXPORTED FROM THE BARREL, deliberately (§10.4). The client's stated
19
+ * convention is "a number with the unit in its name", and this is its first
20
+ * string duration. Confining the parser to this file is what keeps it from
21
+ * becoming a general-purpose utility that then has to accept every format
22
+ * anybody ever wrote.
23
+ *
24
+ * ROUNDING IS ALWAYS UP. A TTL rounded down can expire a marker BEFORE the
25
+ * window it was supposed to cover, which is the failure this feature exists to
26
+ * prevent; rounding up costs at most a second of retention.
27
+ */
28
+
29
+ const UNIT_MS = {
30
+ ms: 1,
31
+ s: 1000,
32
+ m: 60 * 1000,
33
+ h: 60 * 60 * 1000,
34
+ d: 24 * 60 * 60 * 1000
35
+ }
36
+
37
+ // One or more <number><unit> pairs: '30s', '1h30m', '250ms'. Anchored, so a
38
+ // typo is an error and never a silent partial parse ('1hour' does not become
39
+ // one hour).
40
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/
41
+
42
+ /**
43
+ * Parse a duration STRING into milliseconds. Numbers are refused on purpose:
44
+ * `{ ttl: 5000 }` cannot be read as seconds or milliseconds without guessing,
45
+ * and guessing here would be a factor-of-1000 bug in somebody's retention.
46
+ * The numeric spellings are the ones with the unit in the name --
47
+ * `ttlSeconds` and `delayMs`.
48
+ */
49
+ export function parseDurationMs(value) {
50
+ if (typeof value !== 'string' || value.trim() === '') {
51
+ throw new Error(
52
+ `duration must be a string with a unit, e.g. '250ms', '30s', '15m', '24h', '7d' — got ${JSON.stringify(value)}. ` +
53
+ 'For a plain number use ttlSeconds (KV) or delayMs (timers), which carry their unit in the name.'
54
+ )
55
+ }
56
+ const parts = value.trim().toLowerCase().match(/\d+(?:\.\d+)?[a-z]+/g)
57
+ const joined = parts ? parts.join('') : ''
58
+ if (!parts || joined !== value.trim().toLowerCase()) {
59
+ throw new Error(`invalid duration '${value}': expected <number><unit> parts, e.g. '30s' or '1h30m'`)
60
+ }
61
+ let ms = 0
62
+ for (const part of parts) {
63
+ const m = DURATION_RE.exec(part)
64
+ if (!m) {
65
+ throw new Error(`invalid duration '${value}': unknown unit in '${part}' (use ms, s, m, h, d)`)
66
+ }
67
+ ms += Number(m[1]) * UNIT_MS[m[2]]
68
+ }
69
+ if (!(ms > 0)) {
70
+ throw new Error(`invalid duration '${value}': must be greater than zero`)
71
+ }
72
+ return ms
73
+ }
74
+
75
+ /** Milliseconds since the epoch for a Date, an ISO string or an epoch number. */
76
+ function instantMs(value) {
77
+ if (value instanceof Date) {
78
+ const t = value.getTime()
79
+ if (Number.isNaN(t)) throw new Error('until: invalid Date')
80
+ return t
81
+ }
82
+ if (typeof value === 'number' && Number.isFinite(value)) return value
83
+ if (typeof value === 'string') {
84
+ const t = Date.parse(value)
85
+ if (Number.isNaN(t)) throw new Error(`until: '${value}' is not a parseable date`)
86
+ return t
87
+ }
88
+ throw new Error(`until must be a Date, an ISO 8601 string or an epoch-milliseconds number — got ${typeof value}`)
89
+ }
90
+
91
+ /**
92
+ * Resolve the expiry fields of one KV write into the CANONICAL wire shape.
93
+ *
94
+ * Returns `{ ttlSeconds }`, `{ forever: true }`, or `{}`.
95
+ *
96
+ * The empty answer is deliberate and is the reason this function validates so
97
+ * little: §5.1's rule -- exactly one of `ttlSeconds` and `forever:true`, zero
98
+ * or two being the same error -- lives in `kv_apply_v1`, so that all seven
99
+ * clients AND the embedded broker (which never passes through an HTTP handler)
100
+ * inherit it without a line of their own. Re-implementing it here would give
101
+ * the product two places that can disagree about when a key dies. What IS
102
+ * checked here is only what the server cannot see, because it is sugar that
103
+ * never reaches it: two spellings of the same field, a `forever` that is not
104
+ * `true`, and an `until` already in the past.
105
+ *
106
+ * `nowMs` is a parameter so the conversion happens at SEND time. In a
107
+ * transaction the ops are built when the caller writes them and sent at
108
+ * `commit()`, and an `until` frozen at build time would ship a TTL that is
109
+ * already stale by however long the bundle took to assemble.
110
+ */
111
+ export function resolveExpiry(opts = {}, nowMs = Date.now()) {
112
+ const spellings = ['ttlSeconds', 'ttl', 'until'].filter(k => opts[k] !== undefined)
113
+ if (spellings.length > 1) {
114
+ throw new Error(
115
+ `expiry declared twice (${spellings.join(' and ')}): ttlSeconds is the canonical field, ` +
116
+ '`ttl` is a duration string and `until` is an instant — pick one'
117
+ )
118
+ }
119
+
120
+ const out = {}
121
+
122
+ if (opts.forever !== undefined) {
123
+ if (opts.forever !== true) {
124
+ throw new Error('forever must be exactly true; there is no forever:false — omit it and declare a TTL')
125
+ }
126
+ out.forever = true
127
+ }
128
+
129
+ if (opts.ttlSeconds !== undefined) {
130
+ out.ttlSeconds = opts.ttlSeconds
131
+ } else if (opts.ttl !== undefined) {
132
+ out.ttlSeconds = Math.ceil(parseDurationMs(opts.ttl) / 1000)
133
+ } else if (opts.until !== undefined) {
134
+ const deltaMs = instantMs(opts.until) - nowMs
135
+ const seconds = Math.ceil(deltaMs / 1000)
136
+ if (seconds <= 0) {
137
+ throw new Error(
138
+ `until is already in the past (${Math.round(deltaMs)}ms from now): a key written with a dead ` +
139
+ 'TTL would be refused by the broker, and a key written with none would be immortal'
140
+ )
141
+ }
142
+ out.ttlSeconds = seconds
143
+ }
144
+
145
+ // Both declared: forwarded as-is on purpose, so the broker gives its own
146
+ // `kv_expiry_not_specified` verdict and there is one rule, in one place.
147
+ return out
148
+ }
@@ -78,6 +78,17 @@ export class Runner {
78
78
  this._loopPromise = null
79
79
  this._flushTimers = [] // setInterval handles
80
80
  this._flushInFlight = false
81
+ // Per-partition mutex between the pop/cycle loop and the idle-flush
82
+ // timer. Both paths are read(state) -> compute -> commit against the
83
+ // same (query_id, partition_id) state rows; the server's advisory
84
+ // lock only serialises the COMMITS, so an unsynchronised flush can
85
+ // read a window's acc, have the cycle emit+delete (or re-upsert) it,
86
+ // and then emit the same acc again — a duplicate emit (or, with the
87
+ // opposite interleave, drop a freshly-reduced value). Node is
88
+ // single-threaded but both paths interleave at await points, which is
89
+ // exactly where the read->compute->commit race bites. Serialising the
90
+ // two in-process paths removes the race at its source.
91
+ this._partitionMutexes = new Map() // partitionId -> tail promise
81
92
  this._recentPartitions = new Map() // partitionId -> { partitionName, touchedAt }
82
93
  this._partitionWatermarks = new Map() // partitionId -> wmMs (cache; PG is source of truth)
83
94
  this._stats = {
@@ -253,9 +264,38 @@ export class Runner {
253
264
  }
254
265
  }
255
266
 
267
+ // ------------------------------------------------- per-partition mutex
268
+
269
+ /**
270
+ * Run `fn` with the per-partition mutex held (see _partitionMutexes in
271
+ * the constructor). Implemented as a promise chain per partitionId: each
272
+ * entrant waits on the previous tail, so the cycle path and the
273
+ * idle-flush path can never interleave their read->compute->commit
274
+ * sections for the same partition. The returned promise settles like
275
+ * `fn()` (errors propagate to the caller); the stored tail never
276
+ * rejects, so a failed cycle doesn't poison the chain.
277
+ */
278
+ _withPartitionLock(partitionId, fn) {
279
+ const key = partitionId || 'unknown'
280
+ const tail = this._partitionMutexes.get(key) || Promise.resolve()
281
+ const run = tail.then(fn)
282
+ const next = run.then(() => {}, () => {})
283
+ this._partitionMutexes.set(key, next)
284
+ next.then(() => {
285
+ // GC: drop the entry once the chain drains.
286
+ if (this._partitionMutexes.get(key) === next) this._partitionMutexes.delete(key)
287
+ })
288
+ return run
289
+ }
290
+
256
291
  // ----------------------------------------------------------- cycle
257
292
 
258
293
  async _processPartitionCycle(group) {
294
+ // Mutual exclusion with the idle-flush timer (see _partitionMutexes).
295
+ return this._withPartitionLock(group.partitionId, () => this._processPartitionCycleInner(group))
296
+ }
297
+
298
+ async _processPartitionCycleInner(group) {
259
299
  const stages = this.stream.stages
260
300
  const partitionId = group.partitionId
261
301
  const partitionName = group.partitionName
@@ -787,6 +827,11 @@ export class Runner {
787
827
  }
788
828
 
789
829
  async _flushPartition(partitionId, partitionName) {
830
+ // Mutual exclusion with the pop/cycle loop (see _partitionMutexes).
831
+ return this._withPartitionLock(partitionId, () => this._flushPartitionInner(partitionId, partitionName))
832
+ }
833
+
834
+ async _flushPartitionInner(partitionId, partitionName) {
790
835
  const stages = this.stream.stages
791
836
  const window = stages.window
792
837
 
@@ -16,8 +16,27 @@ export const CLIENT_DEFAULTS = {
16
16
  healthRetryAfterMillis: 5000, // Retry unhealthy backends after 5 seconds
17
17
  bearerToken: null, // Bearer token for proxy authentication
18
18
  headers: {}, // Custom headers to include in every request
19
+ // Host to advertise on every request, independent of the address dialed.
20
+ // A queen_proxy deployment picks the tenant cluster from the Host header's
21
+ // first DNS label, so this selects a cluster when `url` points at a shared
22
+ // address (an IP, a cell endpoint, a local rig) instead of the cluster's own
23
+ // subdomain. Bare authority only: 'acme.eu1.queenmq.cloud' or 'acme:6711'.
24
+ // The connection still goes to `url`; only the request authority (and TLS
25
+ // SNI) is rewritten. In production each cluster has its own hostname, so the
26
+ // base URL usually carries the right Host and this stays null.
27
+ // NOTE: `headers: { Host }` cannot work (fetch forbids it) — it is mapped
28
+ // onto this option with a warning.
29
+ hostHeader: null,
19
30
  handleSignals: true, // Register SIGINT/SIGTERM handlers (disable when used as a library)
20
- logger: null // Custom logger instance (must implement info/warn/error)
31
+ logger: null, // Custom logger instance (must implement info/warn/error)
32
+ // Backoff policy for HTTP 429 (rate limited) responses from a queen_proxy
33
+ // deployment. Optional -- omit for the defaults below. Shape:
34
+ // { maxAttempts?: number, baseMs?: number, capMs?: number }
35
+ // maxAttempts defaults to 10 for push/admin calls; long-poll pop (wait=true)
36
+ // retries unboundedly (paced by backoff) unless maxAttempts is set here, in
37
+ // which case it applies to both. baseMs (500) / capMs (30000) size the
38
+ // exponential backoff used when the server doesn't send Retry-After.
39
+ retry429: undefined
21
40
  }
22
41
 
23
42
  export const QUEUE_DEFAULTS = {