queen-mq 2.0.3 → 2.1.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.
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Locks: a lock and a semaphore, as leases with a fencing token.
3
+ *
4
+ * const lock = queen.lock('daily-report', { ttl: '30s' })
5
+ * if (!(await lock.acquire())) return // somebody else has it
6
+ * try {
7
+ * await queen.transaction()
8
+ * .guard(lock) // commits only while the lock is ours
9
+ * .queue('reports').push([{ data: report }])
10
+ * .commit()
11
+ * } finally {
12
+ * await lock.release()
13
+ * }
14
+ *
15
+ * WHAT IT IS. A permit is one KV row in the namespace `queen-locks`, written
16
+ * with a lifetime: `acquire` is a `putIfAbsent`, `renew` a `put` with `expect`,
17
+ * `release` a `delete` with `expect`. The broker's `POST /api/v1/locks` does
18
+ * that turning, so there is one implementation of it for every client. A lock
19
+ * is the semaphore of one permit; `queen.semaphore(name, n)` is the same thing
20
+ * with n.
21
+ *
22
+ * WHAT IT IS NOT: A MUTEX. A permit EXPIRES, and nobody tells its holder. A
23
+ * process that is paused, partitioned or slow keeps running past its lifetime
24
+ * while somebody else acquires. So the lock alone never makes two holders
25
+ * impossible; what makes their WORK exclusive is the token:
26
+ *
27
+ * * inside Queen, `.guard(lock)` on a transaction: the acks, pushes, KV
28
+ * writes and timers of the step commit only if the permit is still this
29
+ * holder's, in the same log entry. A holder that was replaced commits
30
+ * nothing.
31
+ * * outside Queen, `lock.token`: a number that only rises on a lock. A
32
+ * resource that remembers the highest token it has accepted and refuses a
33
+ * lower one (`WHERE fence <= $token`) refuses the holder that was
34
+ * replaced. Accept an EQUAL one: a holder writes many times with one token.
35
+ *
36
+ * Work that goes through neither is protected only by the lifetime being
37
+ * longer than the work, which is a hope, not a guarantee.
38
+ *
39
+ * THE TOKEN CHANGES AT EVERY RENEW. A renew rewrites the row, so the broker
40
+ * answers a new token and the one before stops working. This handle keeps the
41
+ * current one: read `lock.token` and `lock.guard()` when you use them, never
42
+ * hold a copy across an `await`.
43
+ *
44
+ * THE OWNER is the holder's identity, minted here per handle. It is what makes
45
+ * a call safe to send again when its answer was lost: the broker answers the
46
+ * permit the first attempt took. Two handles with one owner are one holder —
47
+ * pass your own only if that is what you mean.
48
+ */
49
+
50
+ import os from 'node:os'
51
+ import { randomBytes } from 'node:crypto'
52
+
53
+ import * as logger from '../utils/logger.js'
54
+ import { parseDurationMs } from '../kv/expiry.js'
55
+
56
+ /** `.code` of the error a guarded step throws when its lock is not held. */
57
+ export const LOCK_NOT_HELD = 'LOCK_NOT_HELD'
58
+
59
+ const NAME_MAX_BYTES = 256
60
+ const OWNER_MAX_BYTES = 256
61
+ const LIMIT_MAX = 1024
62
+
63
+ function requireName(name) {
64
+ // The broker's rule, checked here so the mistake surfaces at the call: no
65
+ // '#', which sits between a name and its slot in the row's key.
66
+ if (typeof name !== 'string' || name.length === 0 || Buffer.byteLength(name) > NAME_MAX_BYTES ||
67
+ /[\u0000-\u001f\u007f-\u009f#]/.test(name)) {
68
+ throw new Error(
69
+ `lock: a name is a non-empty string of at most ${NAME_MAX_BYTES} bytes, without control characters ` +
70
+ `and without '#' — got ${JSON.stringify(name)}`
71
+ )
72
+ }
73
+ return name
74
+ }
75
+
76
+ /**
77
+ * The lifetime, in whole seconds, rounded UP. `ttl` is a duration string
78
+ * ('30s', '5m'), `ttlSeconds` a number. There is no `forever` and no default:
79
+ * a lock that never expires is one nobody can take back from a dead holder.
80
+ */
81
+ export function lockTtlSeconds(opts = {}) {
82
+ if (opts.forever !== undefined) {
83
+ throw new Error('lock: there is no `forever`. A lock takes a lifetime, and a holder that needs longer renews')
84
+ }
85
+ if (opts.ttl !== undefined && opts.ttlSeconds !== undefined) {
86
+ throw new Error('lock: lifetime declared twice (ttl and ttlSeconds) — pick one')
87
+ }
88
+ const seconds = opts.ttl !== undefined ? Math.ceil(parseDurationMs(opts.ttl) / 1000) : opts.ttlSeconds
89
+ if (!Number.isInteger(seconds) || seconds <= 0) {
90
+ throw new Error(
91
+ "lock: a lifetime is required — ttl: '30s' or ttlSeconds: 30 (a whole number of seconds above zero)"
92
+ )
93
+ }
94
+ return seconds
95
+ }
96
+
97
+ function mintOwner() {
98
+ const host = os.hostname().slice(0, 128)
99
+ return `${host}:${process.pid}:${randomBytes(6).toString('hex')}`
100
+ }
101
+
102
+ function requireOwner(owner) {
103
+ if (typeof owner !== 'string' || owner.length === 0 || Buffer.byteLength(owner) > OWNER_MAX_BYTES ||
104
+ /[\u0000-\u001f\u007f-\u009f]/.test(owner)) {
105
+ throw new Error(`lock: owner is a non-empty string of at most ${OWNER_MAX_BYTES} bytes, without control characters`)
106
+ }
107
+ return owner
108
+ }
109
+
110
+ const sleep = (ms, signal) => new Promise((resolve) => {
111
+ if (signal?.aborted) return resolve()
112
+ const t = setTimeout(done, ms)
113
+ function done() {
114
+ signal?.removeEventListener('abort', done)
115
+ clearTimeout(t)
116
+ resolve()
117
+ }
118
+ signal?.addEventListener('abort', done, { once: true })
119
+ })
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // The wire: POST /api/v1/locks
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * The four operations as the broker speaks them, with no state kept here.
127
+ * `queen.lock()` is what most code wants; this is for a caller that keeps the
128
+ * token itself, and for `get`.
129
+ *
130
+ * As on the KV routes, the HTTP status says how the CALL went and never what
131
+ * an operation answered: a lock held by somebody else is a 200 with
132
+ * `acquired: false`. Every result is an OBJECT and objects are truthy — read
133
+ * the field.
134
+ */
135
+ export class Locks {
136
+ #httpClient
137
+ #held = new Set()
138
+
139
+ constructor(httpClient) {
140
+ this.#httpClient = httpClient
141
+ }
142
+
143
+ /**
144
+ * Several operations in one call, each on a different lock: one result per
145
+ * operation, in order. They are independent — nothing here is
146
+ * all-or-nothing.
147
+ */
148
+ async batch(operations) {
149
+ if (!Array.isArray(operations) || operations.length === 0) {
150
+ throw new Error('locks: batch needs a non-empty array of operations')
151
+ }
152
+ logger.log('Locks.batch', { count: operations.length, ops: operations.map(o => o.op) })
153
+ let body
154
+ try {
155
+ body = await this.#httpClient.post('/api/v1/locks', { operations })
156
+ } catch (error) {
157
+ // These routes put the code in `error`, which HttpClient maps onto the
158
+ // message: mirror it on `.code` so nobody branches on prose (as Kv does).
159
+ if (error && !error.code && typeof error.message === 'string') error.code = error.message
160
+ logger.error('Locks.batch', { error: error.message, status: error.status, code: error.code })
161
+ throw error
162
+ }
163
+ const results = body && Array.isArray(body.results) ? body.results : null
164
+ if (!results || results.length !== operations.length) {
165
+ throw new Error(
166
+ `locks: expected {"results":[...]} with ${operations.length} element(s), got ` +
167
+ (results ? `${results.length}` : 'another envelope')
168
+ )
169
+ }
170
+ return results
171
+ }
172
+
173
+ async #one(operation, flag) {
174
+ const [result] = await this.batch([operation])
175
+ if (flag && typeof result[flag] !== 'boolean') {
176
+ throw new Error(`locks: ${operation.op} result carries no \`${flag}\` field; refusing to guess the verdict`)
177
+ }
178
+ return result
179
+ }
180
+
181
+ /**
182
+ * Take a permit: `{acquired, slot, token, owner, guard, already?}`, or
183
+ * `{acquired: false, reason: 'held' | 'contended', holders}`.
184
+ *
185
+ * `limit` above 1 makes it a semaphore of that many permits. Every caller
186
+ * of one name passes the same limit; it is stored nowhere.
187
+ */
188
+ async acquire(name, opts = {}) {
189
+ const op = { op: 'acquire', name: requireName(name), ttlSeconds: lockTtlSeconds(opts) }
190
+ if (opts.owner !== undefined && opts.owner !== null) op.owner = requireOwner(opts.owner)
191
+ if (opts.limit !== undefined) {
192
+ if (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > LIMIT_MAX) {
193
+ throw new Error(`lock: limit is a whole number from 1 (a lock) to ${LIMIT_MAX}`)
194
+ }
195
+ op.limit = opts.limit
196
+ }
197
+ return this.#one(op, 'acquired')
198
+ }
199
+
200
+ /**
201
+ * Extend a permit: `{renewed, slot, token, guard}` with a NEW token, or
202
+ * `{renewed: false, reason: 'lost', holders}`.
203
+ */
204
+ async renew(name, opts = {}) {
205
+ const op = { op: 'renew', name: requireName(name), token: opts.token, ttlSeconds: lockTtlSeconds(opts) }
206
+ if (!Number.isSafeInteger(op.token) || op.token <= 0) {
207
+ throw new Error('lock: renew needs the token of the permit (the one the last acquire or renew answered)')
208
+ }
209
+ if (opts.slot !== undefined) op.slot = opts.slot
210
+ if (opts.owner !== undefined && opts.owner !== null) op.owner = requireOwner(opts.owner)
211
+ return this.#one(op, 'renewed')
212
+ }
213
+
214
+ /** Give a permit back: `{released}`; `false` with `reason: 'lost'` when the token is no longer the row's. */
215
+ async release(name, opts = {}) {
216
+ const op = { op: 'release', name: requireName(name), token: opts.token }
217
+ if (!Number.isSafeInteger(op.token) || op.token <= 0) {
218
+ throw new Error('lock: release needs the token of the permit (the one the last acquire or renew answered)')
219
+ }
220
+ if (opts.slot !== undefined) op.slot = opts.slot
221
+ return this.#one(op, 'released')
222
+ }
223
+
224
+ /** Who holds it: `{held, holders: [{slot, owner, token, since, expiresAt, renewedAt}]}`. */
225
+ async get(name) {
226
+ return this.#one({ op: 'get', name: requireName(name) }, 'held')
227
+ }
228
+
229
+ // ---- the handles of this client, for close() -------------------------------
230
+
231
+ /** @internal */
232
+ _track(lock, held) {
233
+ if (held) this.#held.add(lock)
234
+ else this.#held.delete(lock)
235
+ }
236
+
237
+ /**
238
+ * Give back every permit this client's handles hold, best effort. Called by
239
+ * `queen.close()`: a permit left behind is only a wait of one lifetime for
240
+ * the next holder, never a leak.
241
+ * @internal
242
+ */
243
+ async releaseAll() {
244
+ const held = [...this.#held]
245
+ await Promise.all(held.map(lock => lock.release().catch(() => false)))
246
+ return held.length
247
+ }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // The handle
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * One holder's hold on one lock (or one permit of a semaphore).
256
+ *
257
+ * It keeps the token current, renews in the background (every third of the
258
+ * lifetime, unless `autoRenew: false`), and says when the permit is gone:
259
+ * `lock.signal` aborts and `onLost` handlers run. "Gone" is the broker saying
260
+ * so, or the lifetime passing on THIS machine's clock with no renew having
261
+ * succeeded — a client that cannot reach the broker must assume the worst.
262
+ */
263
+ export class Lock {
264
+ #locks
265
+ #name
266
+ #limit
267
+ #owner
268
+ #ttlSeconds
269
+ #autoRenew
270
+ #renewEveryMs
271
+ #retry
272
+
273
+ #token = null
274
+ #slot = null
275
+ // The broker's own `guard` of the current lease period, kept as answered:
276
+ // where a permit's row lives is the broker's rule and is written once, there.
277
+ #guard = null
278
+ #validUntil = 0
279
+ #abort = new AbortController()
280
+ #lostHandlers = []
281
+ #timer = null
282
+ #renewing = null
283
+
284
+ constructor(locks, name, opts = {}) {
285
+ this.#locks = locks
286
+ this.#name = requireName(name)
287
+ this.#ttlSeconds = lockTtlSeconds(opts)
288
+ this.#limit = opts.limit ?? 1
289
+ if (!Number.isInteger(this.#limit) || this.#limit < 1 || this.#limit > LIMIT_MAX) {
290
+ throw new Error(`lock: limit is a whole number from 1 (a lock) to ${LIMIT_MAX}`)
291
+ }
292
+ this.#owner = opts.owner !== undefined && opts.owner !== null ? requireOwner(opts.owner) : mintOwner()
293
+ this.#autoRenew = opts.autoRenew !== false
294
+ const every = opts.renewEvery !== undefined ? parseDurationMs(opts.renewEvery) : (this.#ttlSeconds * 1000) / 3
295
+ if (!(every > 0) || every >= this.#ttlSeconds * 1000) {
296
+ throw new Error('lock: renewEvery must be shorter than the lifetime, or the permit expires between renews')
297
+ }
298
+ this.#renewEveryMs = every
299
+ // How a waiting acquire comes back: first after `min`, then 1.5x each time
300
+ // up to `max`, each wait shortened by a random quarter so a crowd spreads.
301
+ this.#retry = { min: opts.retryMinMs ?? 100, max: opts.retryMaxMs ?? 1000 }
302
+ }
303
+
304
+ get name() { return this.#name }
305
+ get owner() { return this.#owner }
306
+ get limit() { return this.#limit }
307
+ get ttlSeconds() { return this.#ttlSeconds }
308
+
309
+ /**
310
+ * Whether this handle holds a permit, as far as it can know: the broker
311
+ * granted or renewed it, and its lifetime has not run out on this machine's
312
+ * clock. `true` here is a belief with a deadline, not a proof — the proof
313
+ * is the guard on the transaction.
314
+ */
315
+ get held() {
316
+ return this.#token !== null && Date.now() < this.#validUntil
317
+ }
318
+
319
+ /** The fencing token of the current lease period; `null` when not held. */
320
+ get token() { return this.held ? this.#token : null }
321
+
322
+ /** The semaphore slot this handle holds (0 for a lock); `null` when not held. */
323
+ get slot() { return this.held ? this.#slot : null }
324
+
325
+ /**
326
+ * Epoch milliseconds, on this machine's clock, past which the permit must
327
+ * be considered gone unless a renew succeeds first. Counted from when the
328
+ * request was SENT, so it is never later than the broker's own deadline.
329
+ */
330
+ get validUntil() { return this.held ? this.#validUntil : 0 }
331
+
332
+ /** Aborts when the permit is lost. A release is not a loss and does not abort it. */
333
+ get signal() { return this.#abort.signal }
334
+
335
+ /** Run `fn(reason)` when the permit is lost: 'renew', 'guard', 'expired'. */
336
+ onLost(fn) {
337
+ this.#lostHandlers.push(fn)
338
+ return this
339
+ }
340
+
341
+ /**
342
+ * The KV operation that holds while the permit is this handle's: a `check`
343
+ * of the permit's row at the current token, `required`. `.guard(lock)` on a
344
+ * transaction adds it for you and follows a renew; use this one to put it in
345
+ * a KV batch yourself, and take it at the moment you send.
346
+ */
347
+ guard() {
348
+ if (!this.held) {
349
+ const error = new Error(`lock: '${this.#name}' is not held; there is nothing to guard with`)
350
+ error.code = LOCK_NOT_HELD
351
+ throw error
352
+ }
353
+ return { ...this.#guard }
354
+ }
355
+
356
+ /**
357
+ * Take the permit. Resolves `true` or `false` — a boolean, on purpose, so
358
+ * `if (await lock.acquire())` means what it reads as.
359
+ *
360
+ * With `wait` (a duration string) it keeps trying until the permit is free
361
+ * or the wait is over, coming back every 100 ms to 1 s with jitter. Without
362
+ * it there is one attempt. `signal` gives the wait up early.
363
+ */
364
+ async acquire(opts = {}) {
365
+ if (this.held) return true
366
+ const waitMs = opts.wait !== undefined ? parseDurationMs(opts.wait) : 0
367
+ const deadline = Date.now() + waitMs
368
+ let pause = this.#retry.min
369
+ for (;;) {
370
+ const sentAt = Date.now()
371
+ const r = await this.#locks.acquire(this.#name, {
372
+ ttlSeconds: this.#ttlSeconds,
373
+ owner: this.#owner,
374
+ ...(this.#limit > 1 ? { limit: this.#limit } : {})
375
+ })
376
+ if (r.acquired) {
377
+ this.#take(r, sentAt)
378
+ logger.log('Lock.acquire', { name: this.#name, slot: r.slot, already: r.already === true })
379
+ return true
380
+ }
381
+ const left = deadline - Date.now()
382
+ if (left <= 0 || opts.signal?.aborted) return false
383
+ const jittered = pause * (0.75 + Math.random() * 0.25)
384
+ await sleep(Math.min(jittered, left), opts.signal)
385
+ pause = Math.min(pause * 1.5, this.#retry.max)
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Extend the lease now. `true`, with a new token in place, or `false`: the
391
+ * permit is gone and the handle says so (`signal`, `onLost`). Rejects when
392
+ * the broker could not be asked; the permit is then neither renewed nor
393
+ * known lost, and its deadline stands.
394
+ *
395
+ * The background renewal calls this; call it yourself with
396
+ * `autoRenew: false`, or before a long step.
397
+ */
398
+ async renew() {
399
+ if (this.#renewing) return this.#renewing
400
+ if (this.#token === null) return false
401
+ this.#renewing = this.#renewOnce().finally(() => { this.#renewing = null })
402
+ return this.#renewing
403
+ }
404
+
405
+ async #renewOnce() {
406
+ const token = this.#token
407
+ const sentAt = Date.now()
408
+ const r = await this.#locks.renew(this.#name, {
409
+ token,
410
+ slot: this.#slot,
411
+ ttlSeconds: this.#ttlSeconds,
412
+ owner: this.#owner
413
+ })
414
+ // Released, or lost, while the renew was in flight: its answer is about a
415
+ // permit this handle no longer has.
416
+ if (this.#token !== token) return false
417
+ if (!r.renewed) {
418
+ this.#lose('renew')
419
+ return false
420
+ }
421
+ this.#token = r.token
422
+ this.#guard = r.guard
423
+ this.#validUntil = sentAt + this.#ttlSeconds * 1000
424
+ return true
425
+ }
426
+
427
+ /**
428
+ * Give the permit back. `true` when the broker removed it; `false` when it
429
+ * was not this handle's any more (expired, or taken over) or was never
430
+ * held. Either way the handle holds nothing afterwards and can acquire again.
431
+ */
432
+ async release() {
433
+ // A renew in flight owns the token until it answers.
434
+ if (this.#renewing) await this.#renewing.catch(() => {})
435
+ if (this.#token === null) return false
436
+ const token = this.#token
437
+ const slot = this.#slot
438
+ this.#drop()
439
+ const r = await this.#locks.release(this.#name, { token, slot })
440
+ logger.log('Lock.release', { name: this.#name, released: r.released })
441
+ return r.released === true
442
+ }
443
+
444
+ /**
445
+ * Acquire, run `fn(lock)`, release — whatever `fn` does. Resolves
446
+ * `{acquired: false}` when the permit could not be had (after `wait`, if
447
+ * given), else `{acquired: true, value}` with what `fn` returned.
448
+ *
449
+ * It does not stop `fn` when the permit is lost; nothing can. Pass
450
+ * `lock.signal` to whatever `fn` awaits, and guard what it commits.
451
+ */
452
+ async run(fn, opts = {}) {
453
+ if (!(await this.acquire(opts))) return { acquired: false }
454
+ try {
455
+ return { acquired: true, value: await fn(this) }
456
+ } finally {
457
+ await this.release().catch((error) => {
458
+ logger.error('Lock.run', { name: this.#name, error: error.message, note: 'release failed; the permit expires by itself' })
459
+ })
460
+ }
461
+ }
462
+
463
+ // ---- used by TransactionBuilder.guard --------------------------------------
464
+
465
+ /** Resolves once no renew is in flight: the token is then the current one. @internal */
466
+ async _settled() {
467
+ if (this.#renewing) await this.#renewing.catch(() => {})
468
+ }
469
+
470
+ /** The broker said the permit is not this handle's. @internal */
471
+ _lost(reason) {
472
+ if (this.#token !== null) this.#lose(reason)
473
+ }
474
+
475
+ // ---- state ------------------------------------------------------------------
476
+
477
+ #take(result, sentAt) {
478
+ this.#token = result.token
479
+ this.#slot = result.slot
480
+ this.#guard = result.guard
481
+ this.#validUntil = sentAt + this.#ttlSeconds * 1000
482
+ // A handle that lost a permit earlier gets a fresh signal with the new one.
483
+ if (this.#abort.signal.aborted) this.#abort = new AbortController()
484
+ this.#locks._track(this, true)
485
+ this.#schedule()
486
+ }
487
+
488
+ #drop() {
489
+ this.#token = null
490
+ this.#slot = null
491
+ this.#guard = null
492
+ this.#validUntil = 0
493
+ if (this.#timer) clearTimeout(this.#timer)
494
+ this.#timer = null
495
+ this.#locks._track(this, false)
496
+ }
497
+
498
+ #lose(reason) {
499
+ logger.warn('Lock.lost', { name: this.#name, owner: this.#owner, reason })
500
+ this.#drop()
501
+ this.#abort.abort(Object.assign(new Error(`lock '${this.#name}' was lost (${reason})`), { code: LOCK_NOT_HELD }))
502
+ for (const fn of this.#lostHandlers) {
503
+ try { fn(reason) } catch (error) { logger.error('Lock.onLost', { error: error.message }) }
504
+ }
505
+ }
506
+
507
+ /**
508
+ * The background renewal, and the deadline. One timer: it fires when the
509
+ * next renew is due, or — with `autoRenew: false`, or after renews that
510
+ * could not reach the broker — when the lifetime runs out.
511
+ */
512
+ #schedule() {
513
+ if (this.#timer) clearTimeout(this.#timer)
514
+ const untilDead = this.#validUntil - Date.now()
515
+ const delay = this.#autoRenew ? Math.min(this.#renewEveryMs, Math.max(untilDead, 0)) : Math.max(untilDead, 0)
516
+ this.#timer = setTimeout(() => this.#tick(), delay)
517
+ // A held lock must not keep a finished process alive.
518
+ this.#timer.unref?.()
519
+ }
520
+
521
+ async #tick() {
522
+ if (this.#token === null) return
523
+ if (Date.now() >= this.#validUntil) {
524
+ this.#lose('expired')
525
+ return
526
+ }
527
+ if (this.#autoRenew) {
528
+ try {
529
+ await this.renew()
530
+ } catch (error) {
531
+ // Could not ask. Not a loss yet: try again sooner, until the deadline.
532
+ logger.warn('Lock.renew', { name: this.#name, error: error.message, status: error.status })
533
+ if (this.#token !== null) {
534
+ if (this.#timer) clearTimeout(this.#timer)
535
+ const left = this.#validUntil - Date.now()
536
+ this.#timer = setTimeout(() => this.#tick(), Math.max(Math.min(left / 4, this.#renewEveryMs), 50))
537
+ this.#timer.unref?.()
538
+ }
539
+ return
540
+ }
541
+ }
542
+ if (this.#token !== null) this.#schedule()
543
+ }
544
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "Partitioned message queue on a replicated broker log — broker client + fluent streaming SDK (windows, joins, gates) in one package",
6
6
  "main": "client-v2/index.js",
7
7
  "scripts": {
8
8
  "test": "npm run test:unit && node test-v2/run.js human",
9
- "test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js",
9
+ "test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/kv-unit/locksWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js test-v2/consumer-unit/supervision.test.js",
10
10
  "test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
11
11
  "test:integration": "node test-v2/run.js human",
12
12
  "test:streams": "node test-v2/run.js stream",
@@ -0,0 +1,97 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { ConsumerManager } from '../../client-v2/consumer/ConsumerManager.js'
4
+ import { Supervision } from '../../client-v2/consumer/Supervision.js'
5
+
6
+ const options = { queue: 'orders', concurrency: 2, timeoutMillis: 30000, limit: 1, batch: 1, each: true, autoAck: true, wait: false }
7
+ const decode = body => {
8
+ assert.equal(body.operations.length, 2)
9
+ const [head, chunk] = body.operations
10
+ assert.equal(head.ns, 'queen-supervisor')
11
+ assert.equal(head.ttlSeconds, 60)
12
+ assert.equal(chunk.ttlSeconds, 60)
13
+ assert.equal(head.value.write, chunk.value.write)
14
+ const bytes = Buffer.from(chunk.value.data, 'base64')
15
+ assert.equal(bytes.length, head.value.bytes)
16
+ return JSON.parse(bytes)
17
+ }
18
+ function rig(fail = false) {
19
+ const docs = [], acks = []
20
+ const http = { get: async () => ({ messages: [{ transactionId: 't', partitionId: 'p', data: { secret: 42 } }] }),
21
+ post: async (path, body) => { assert.equal(path, '/api/v1/kv'); docs.push(decode(body)); if (fail) throw new Error('offline'); return { results: [{ applied: true }, { applied: true }] } } }
22
+ const queen = { ack: async (_, success) => { acks.push(success); return { success: true } } }
23
+ return { docs, acks, http, manager: new ConsumerManager(http, queen) }
24
+ }
25
+ test('default and explicit off create no publications and preserve acknowledgement', async () => {
26
+ for (const supervision of [undefined, false]) {
27
+ const r = rig(); await r.manager.start(async () => {}, { ...options, supervision })
28
+ assert.equal(r.docs.length, 0); assert.deepEqual(r.acks, [true, true])
29
+ }
30
+ })
31
+ test('enabled consumers count actual exits, handler failures and final state without changing ACKs', async () => {
32
+ const r = rig()
33
+ let n = 0
34
+ await r.manager.start(async () => { if (++n === 1) throw new Error('private error') }, { ...options, supervision: { group: 'billing-production' } })
35
+ const last = r.docs.at(-1)
36
+ assert.equal(last.state, 'stopped'); assert.equal(last.pool_status[0].running, 0)
37
+ assert.equal(last.pool_status[0].busy, 0); assert.equal(last.pool_status[0].completed, 1)
38
+ assert.equal(last.pool_status[0].failed, 1); assert.deepEqual(r.acks.sort(), [false, true])
39
+ assert.equal(JSON.stringify(r.docs).includes('private error'), false)
40
+ assert.equal(JSON.stringify(r.docs).includes('secret'), false)
41
+ })
42
+ test('publication errors do not affect consumption; instances are unique', async () => {
43
+ const r = rig(true)
44
+ await r.manager.start(async () => {}, { ...options, supervision: { group: 'billing' } })
45
+ const first = r.docs[0].instance_id
46
+ await r.manager.start(async () => {}, { ...options, supervision: { group: 'billing' } })
47
+ assert.notEqual(first, r.docs.at(-1).instance_id); assert.equal(r.acks.length, 4)
48
+ })
49
+ test('busy handlers remain observable and publication is serialized', async () => {
50
+ const r = rig(); const reporter = new Supervision(r.http, { group: 'billing' }, options)
51
+ let release
52
+ const work = reporter.wrap(() => new Promise(resolve => { release = resolve }))()
53
+ reporter.running = 1
54
+ await Promise.all([reporter.publish(), reporter.publish()])
55
+ assert.equal(r.docs.length, 1); assert.equal(r.docs[0].pool_status[0].busy, 1)
56
+ assert.equal(r.docs[0].pool_status[0].completed, 0)
57
+ release(); await work
58
+ assert.equal(reporter.document().pool_status[0].completed, 1)
59
+ assert.equal(reporter.document().pool_status[0].oldest_inflight_seconds, null)
60
+ })
61
+ test('invalid opt-in groups fail before polling', async () => {
62
+ for (const group of ['', 'coordination', 'a/b', 'billing\n', undefined]) {
63
+ const r = rig(); await assert.rejects(r.manager.start(async () => {}, { ...options, supervision: { group } }), /supervision.group/)
64
+ assert.equal(r.docs.length, 0)
65
+ }
66
+ })
67
+
68
+ test('real HTTP publishing preserves authentication and has a total deadline', async () => {
69
+ const { createServer } = await import('node:http')
70
+ const { Queen } = await import('../../client-v2/index.js')
71
+ let kvCalls = 0, polls = 0
72
+ const docs = []
73
+ const server = createServer((req, res) => {
74
+ if (req.url === '/api/v1/kv') {
75
+ kvCalls++
76
+ assert.equal(req.headers.authorization, 'Bearer test-token')
77
+ let bytes = ''
78
+ req.on('data', chunk => { bytes += chunk })
79
+ req.on('end', () => { docs.push(decode(JSON.parse(bytes))) })
80
+ return // Deliberately never answer. Consumption and shutdown stay bounded.
81
+ }
82
+ polls++
83
+ res.setHeader('Content-Type', 'application/json')
84
+ res.end(JSON.stringify({ messages: [{ transactionId: 't', partitionId: 'p', data: {} }] }))
85
+ })
86
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
87
+ const queen = new Queen({ url: `http://127.0.0.1:${server.address().port}`, bearerToken: 'test-token', handleSignals: false })
88
+ const start = performance.now()
89
+ try {
90
+ await queen.queue('orders').supervision({ group: 'wire' }).wait(false).autoAck(false).limit(1).consume(async () => {})
91
+ assert.equal(polls, 1); assert.equal(kvCalls, 2)
92
+ assert.equal(docs.at(-1).state, 'stopped')
93
+ assert.ok(performance.now() - start < 6000, 'two publications are bounded by two seconds each')
94
+ } finally {
95
+ await queen.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve))
96
+ }
97
+ })