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
package/README.md CHANGED
@@ -28,6 +28,7 @@ Queen MQ is a PostgreSQL-backed message queue system with a powerful feature set
28
28
  - **Message Tracing** - Debug distributed workflows with trace timelines
29
29
  - **Client-Side Buffering** - 10x-100x throughput boost for high-volume pushes
30
30
  - **Real-time Streaming** - Windowed aggregation and processing
31
+ - **Key/Value State and Timers** - Transactional state and scheduled messages
31
32
 
32
33
  This client provides a fluent, promise-based API for Node.js applications.
33
34
 
@@ -373,6 +374,145 @@ await queen.queue('orders').consume(async (msg) => {
373
374
 
374
375
  ---
375
376
 
377
+ ## Key/Value State and Timers
378
+
379
+ Both surfaces are **always there**. There is nothing to enable: kv and timers are part of the
380
+ broker the way push and pop are, on every cell that runs it. There is no capability to probe and
381
+ no 404 that means "this cell does not have the feature" — a 404 from these routes is a bug.
382
+
383
+ What an operator can still do is **pause** them, with the runtime kill switch in
384
+ `queen.system_state` (`kv_enabled`, `timers_schedule_enabled`, `timers_fire_enabled`) — the same
385
+ class of lever as maintenance mode, pulled live during an incident and expected to be pulled back.
386
+ A paused surface answers `503` with `Retry-After` and `error: 'kv_disabled'` / `'timers_disabled'`,
387
+ which this client retries like any other 5xx. Inside a transaction it is a `403` on the `kv` or
388
+ `timers` rider instead, so a bundle holding messages does not spin forever on a paused cell.
389
+
390
+ ```javascript
391
+ try {
392
+ await queen.kv.put('orders', 'order:9f1', { state: 'held' }, { ttl: '60s' })
393
+ } catch (e) {
394
+ if (e.code === 'kv_disabled') { /* paused by an operator; it will come back */ }
395
+ throw e
396
+ }
397
+ ```
398
+
399
+ Branch on `e.code`, never on the message. And write that branch as "temporarily paused", not as
400
+ "this deployment lacks KV": handling the refusal is right, treating it as a configuration to check
401
+ before you use the surface is not.
402
+
403
+ ### Key/Value
404
+
405
+ ```javascript
406
+ // An expiry is MANDATORY on every write: exactly one of ttlSeconds (or the
407
+ // sugar ttl / until) and forever: true. A put never inherits the previous TTL.
408
+ await queen.kv.put('orders', 'order:9f1', { state: 'held' }, { ttl: '60s' })
409
+
410
+ const row = await queen.kv.get('orders', 'order:9f1')
411
+ if (row.found) console.log(row.value, row.version) // found is separate: null is a legal value
412
+
413
+ // "Did I win?" in one call. This is the idempotency marker.
414
+ const { won, value } = await queen.kv.once('dedup', eventId, { ttl: '24h' })
415
+ if (!won) return // somebody already did this
416
+
417
+ // Optimistic lock. expect: 0 means "must not exist"; expect: N is a pure
418
+ // update that creates nothing when it matches no row.
419
+ const res = await queen.kv.put('orders', 'order:9f1', { state: 'shipped' },
420
+ { ttl: '60s', expect: row.version })
421
+ if (!res.applied) console.log(res.reason) // 'version' | 'exists' | 'absent' | 'limit' | 'type'
422
+
423
+ // Rate limiting without a CAS loop. With max, `applied` IS the admission
424
+ // decision: nothing saturates, nothing truncates, a refusal spends no budget.
425
+ const hit = await queen.kv.incr('quota', `${customer}:${hour}`, 1, { max: 1000, ttl: '1h' })
426
+ if (!hit.applied) throw new TooManyRequests()
427
+
428
+ for await (const r of queen.kv.listAll('saga', 'order:9f1:')) { /* follows nextAfter */ }
429
+ ```
430
+
431
+ Seven operations: `get`, `getMany`, `getPrefix`, `put`, `putIfAbsent`, `delete`, `incr`, plus the
432
+ two conveniences this client owns, `once` and `listAll`.
433
+
434
+ **Every write returns an OBJECT, and objects are always truthy.** `if (await queen.kv.delete(ns,
435
+ key))` is always taken and is a bug. Read `.applied`, or `.won` on `once`, or `.found` on a read.
436
+ That holds for all five writes, and it is the one trap this language cannot defend against
437
+ structurally.
438
+
439
+ **A write that did not apply is not an error.** `applied: false` answers HTTP 200 with the current
440
+ value and version, so the loser needs no second round trip.
441
+
442
+ **Read-modify-write across two calls is safe only when the KV key derives from the partition key.**
443
+ Otherwise the lanes do not serialise it for you: use `incr`, or carry `expect`.
444
+
445
+ **`putIfAbsent` plus a TTL is not a distributed lock.** A lock that expires is not revoked: the old
446
+ holder keeps working, it simply no longer has the row. Carry your `version` as `expect` on every
447
+ later write so a lapsed holder fails with `reason: 'version'` instead of overwriting the new one.
448
+
449
+ ### Timers
450
+
451
+ ```javascript
452
+ // Fire no earlier than 30 minutes from now, into a real queue, through the log.
453
+ const res = await queen.timer('reminders')
454
+ .key(`order:${orderId}`)
455
+ .delay('30m') // or .delayMs(250)
456
+ .payload({ orderId })
457
+ .schedule() // status: 'scheduled' | 'rescheduled' | 'too_late'
458
+
459
+ await queen.timer('reminders').key(`order:${orderId}`).peek()
460
+ await queen.timer('reminders').list({ limit: 50 })
461
+ await queen.timer('reminders').key(`order:${orderId}`).cancel()
462
+ ```
463
+
464
+ Scheduling the same `(queue, timerKey)` again is the same upsert, so a retry after a client crash
465
+ is safe by construction and `status` says which it was. A reschedule mints a new `txn` and resets
466
+ the retry budget.
467
+
468
+ Durations that can be sub-second are in **milliseconds** (`delayMs`), the ones that cannot are in
469
+ **seconds** (`ttlSeconds`). Only relative delays exist, because there is one clock and it is the
470
+ database's. A delay in the past is legal and fires on the first cycle.
471
+
472
+ `deliverAt` is **"not before"**, never "exactly at".
473
+
474
+ **`absent` means "no longer pending" and may mean ALREADY DELIVERED.** There is no tombstone: a
475
+ fired timer has no row left, so `absent` carries `ok: false` and the answer echoes the `txn` so the
476
+ authority, the log, can be consulted without a second API. A saga that cancels a compensation timer
477
+ must have the compensating consumer re-check the saga's KV state before compensating, because the
478
+ cancel may have arrived 5 ms after the fire.
479
+
480
+ Use `queen.timer(q).key(k).cancel()` rather than a cancel inside a bundle when the cancel must land
481
+ regardless: it takes the DELETE route, the one a quota is forbidden to block. A tenant that cannot
482
+ cancel keeps producing messages it cannot stop.
483
+
484
+ ### Inside a transaction
485
+
486
+ The transaction is the **primary fence**; `expect` is only the secondary assertion. A state write
487
+ that shares the transaction with its ack is undone when an expired lease makes the ack fail, which
488
+ a compare-and-set cannot do.
489
+
490
+ ```javascript
491
+ const result = await queen.transaction()
492
+ .ack(message)
493
+ .queue('emails').push([{ data: mail }])
494
+ .once('sent', message.transactionId, { ttl: '24h' }) // the gate
495
+ .timer('reminders').key(orderId).delay('24h').payload({ orderId }).schedule()
496
+ .commit()
497
+
498
+ if (result.success === false) {
499
+ // RETURNED, not thrown: a lost gate is the expected outcome of a legitimate
500
+ // redelivery, so it stays out of your retry policy and your error metrics.
501
+ result.reason // 'kv_precondition'
502
+ result.failedIndex, result.kvReason, result.version, result.value
503
+ return
504
+ }
505
+ ```
506
+
507
+ `once` is `putIfAbsent` with `required: true`, and `required` is what makes it a gate: without it a
508
+ lost precondition is only a verdict in the results, and the push and the ack still go through.
509
+
510
+ `kv.getPrefix` is not available inside a transaction and throws here rather than at the broker: its
511
+ cost is not bounded by the caller. `get` and `getMany` are allowed, because they are. Everything
512
+ other than the lost precondition still throws.
513
+
514
+ ---
515
+
376
516
  ## Examples
377
517
 
378
518
  ### Complete Pipeline with Consumer Groups
@@ -521,6 +661,41 @@ await queen.transaction()
521
661
  .queue('output')
522
662
  .push([{ data: { result: 'processed' } }])
523
663
  .commit()
664
+
665
+ // Riders. commit() RETURNS on a lost gate, and throws on everything else.
666
+ await queen.transaction()
667
+ .ack(message)
668
+ .kv.put('saga', sagaId, { step: 'charged' }, { ttl: '24h' })
669
+ .once('dedup', message.transactionId, { ttl: '24h' })
670
+ .timer('reminders').key(orderId).delay('30m').payload({ orderId }).schedule()
671
+ .commit()
672
+ ```
673
+
674
+ ### Key/Value
675
+
676
+ ```javascript
677
+ await queen.kv.get(ns, key) // {found, key, value, version, expiresAt, updatedAt}
678
+ await queen.kv.getMany(ns, [k1, k2]) // {rows, missing, truncated}
679
+ await queen.kv.getPrefix(ns, prefix, { limit: 100, after, keysOnly })
680
+ await queen.kv.put(ns, key, value, { ttl: '24h', expect, required })
681
+ await queen.kv.putIfAbsent(ns, key, value, { ttlSeconds: 86400 })
682
+ await queen.kv.delete(ns, key, { expect })
683
+ await queen.kv.incr(ns, key, 1, { max: 1000, min, ttl: '1h' })
684
+ await queen.kv.once(ns, key, { ttl: '24h' }) // {won, value, version, result}
685
+ for await (const row of queen.kv.listAll(ns, prefix)) { }
686
+ ```
687
+
688
+ ### Timers
689
+
690
+ ```javascript
691
+ // Builder steps: .key(timerKey) required, .delayMs(250) or .delay('30m') required,
692
+ // .payload(anyJsonOrBuffer) required to schedule, .partition(name) optional
693
+ // (defaults to 'Default'), .txn(transactionId) optional (minted when absent).
694
+
695
+ await queen.timer(q).key(k).delay('30m').payload(p).schedule() // {ok, status, txn, messageId, deliverAt}
696
+ await queen.timer(q).key(k).cancel() // {ok, status, txn}
697
+ await queen.timer(q).key(k).peek() // {found, ...}
698
+ await queen.timer(q).list({ limit: 50, after }) // {rows, truncated, nextAfter}
524
699
  ```
525
700
 
526
701
  ### Lease Renewal
@@ -8,6 +8,8 @@ import { LoadBalancer } from './http/LoadBalancer.js'
8
8
  import { BufferManager } from './buffer/BufferManager.js'
9
9
  import { QueueBuilder } from './builders/QueueBuilder.js'
10
10
  import { TransactionBuilder } from './builders/TransactionBuilder.js'
11
+ import { TimerBuilder } from './builders/TimerBuilder.js'
12
+ import { Kv } from './kv/Kv.js'
11
13
  import { StreamBuilder } from './stream/StreamBuilder.js'
12
14
  import { StreamConsumer } from './stream/StreamConsumer.js'
13
15
  import { Admin } from './admin/Admin.js'
@@ -15,12 +17,49 @@ import { CLIENT_DEFAULTS } from './utils/defaults.js'
15
17
  import { validateUrl, validateUrls } from './utils/validation.js'
16
18
  import * as logger from './utils/logger.js'
17
19
 
20
+ // Both /api/v1/ack and /api/v1/ack/batch respond with a top-level JSON array,
21
+ // one item per acknowledgment in request order:
22
+ // [{index, transactionId, success, error, queueName, partitionName, leaseReleased, dlq}]
23
+ // (see queen.ack_messages_v2 / routes/ack.cpp). A rejected ack/nack (e.g.
24
+ // "Invalid or expired lease") still arrives as HTTP 200 with success=false on
25
+ // the item, so the per-item flag is the only signal that the broker accepted it.
26
+
27
+ function normalizeAckItem(item, index) {
28
+ if (item === null || typeof item !== 'object') {
29
+ throw new Error(`Unexpected ack result item at index ${index}`)
30
+ }
31
+ let success = typeof item.success === 'boolean' ? item.success : true
32
+ let error = typeof item.error === 'string' && item.error.length > 0 ? item.error : null
33
+ if (error) success = false
34
+ if (!success && !error) error = 'Acknowledgment rejected by server'
35
+ return { ...item, success, error }
36
+ }
37
+
38
+ // expected is the number of acknowledgments sent, so a truncated or misaligned
39
+ // response fails loudly instead of being misattributed to the wrong message.
40
+ function parseAckResults(result, expected) {
41
+ if (Array.isArray(result)) {
42
+ if (result.length !== expected) {
43
+ throw new Error(`Ack response has ${result.length} results, expected ${expected}`)
44
+ }
45
+ return result.map(normalizeAckItem)
46
+ }
47
+
48
+ // Top-level error envelope: the whole request was rejected.
49
+ if (result && typeof result === 'object' && typeof result.error === 'string' && result.error.length > 0) {
50
+ return Array.from({ length: expected }, () => ({ success: false, error: result.error }))
51
+ }
52
+
53
+ throw new Error('Unexpected ack response format: missing per-item result array')
54
+ }
55
+
18
56
  export class Queen {
19
57
  #httpClient
20
58
  #bufferManager
21
59
  #config
22
60
  #shutdownHandlers = []
23
61
  #admin = null
62
+ #kv = null
24
63
 
25
64
  constructor(config = {}) {
26
65
  // Configure custom logger before anything else.
@@ -87,7 +126,7 @@ export class Queen {
87
126
  }
88
127
 
89
128
  #createHttpClient() {
90
- const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers } = this.#config
129
+ const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers, hostHeader, retry429 } = this.#config
91
130
 
92
131
  if (urls.length === 1) {
93
132
  // Single server
@@ -97,7 +136,9 @@ export class Queen {
97
136
  retryAttempts,
98
137
  retryDelayMillis,
99
138
  bearerToken,
100
- headers
139
+ headers,
140
+ hostHeader,
141
+ retry429
101
142
  })
102
143
  }
103
144
 
@@ -113,7 +154,9 @@ export class Queen {
113
154
  retryDelayMillis,
114
155
  enableFailover,
115
156
  bearerToken,
116
- headers
157
+ headers,
158
+ hostHeader,
159
+ retry429
117
160
  })
118
161
  }
119
162
 
@@ -176,6 +219,57 @@ export class Queen {
176
219
  return this.#admin
177
220
  }
178
221
 
222
+ // ===========================
223
+ // KV API Entry Point
224
+ // ===========================
225
+
226
+ /**
227
+ * Transactional key/value state, alongside the log
228
+ * (PLAN_KV_TIMERS.md §5).
229
+ *
230
+ * const { won } = await queen.kv.once('idem', orderId, { ttl: '24h' })
231
+ * const row = await queen.kv.get('saga', sagaId) // {found, value, version, ...}
232
+ *
233
+ * Two things to carry into every use of it:
234
+ * * every write returns an OBJECT, and objects are always truthy --
235
+ * `if (await queen.kv.delete(ns, key))` is always true. Read `.applied`.
236
+ * * an expiry is MANDATORY on every write: exactly one of `ttlSeconds`
237
+ * (or the sugar `ttl` / `until`) and `forever: true`. There is no
238
+ * default, because a default is how a marker becomes immortal.
239
+ *
240
+ * Lazily initialized, singleton, like `admin`.
241
+ * @returns {Kv}
242
+ */
243
+ get kv() {
244
+ if (!this.#kv) {
245
+ this.#kv = new Kv(this.#httpClient)
246
+ }
247
+ return this.#kv
248
+ }
249
+
250
+ // ===========================
251
+ // Timers API Entry Point
252
+ // ===========================
253
+
254
+ /**
255
+ * Scheduled messages for one queue (PLAN_KV_TIMERS.md §4).
256
+ *
257
+ * await queen.timer('orders').key(orderId).delay('30m')
258
+ * .payload({ orderId }).schedule()
259
+ * await queen.timer('orders').key(orderId).cancel()
260
+ *
261
+ * `deliverAt` is "not before", never "exactly at". A cancel that answers
262
+ * `absent` means "no longer pending" and MAY MEAN ALREADY DELIVERED -- there
263
+ * is no tombstone, and the authority is the log.
264
+ *
265
+ * @param {string} queueName - destination queue (mandatory)
266
+ * @returns {TimerBuilder}
267
+ */
268
+ timer(queueName) {
269
+ logger.log('Queen.timer', { queue: queueName })
270
+ return new TimerBuilder(this.#httpClient, queueName)
271
+ }
272
+
179
273
  // ===========================
180
274
  // Transaction API
181
275
  // ===========================
@@ -195,7 +289,7 @@ export class Queen {
195
289
  // Handle batch acknowledgment
196
290
  if (Array.isArray(message)) {
197
291
  if (message.length === 0) {
198
- return { processed: 0, results: [] }
292
+ return { success: true, processed: 0, results: [] }
199
293
  }
200
294
 
201
295
  // Check if messages have individual status
@@ -277,13 +371,19 @@ export class Queen {
277
371
  consumerGroup: context.group || null
278
372
  })
279
373
 
280
- if (result && result.error) {
281
- logger.error('Queen.ack', { type: 'batch', error: result.error })
282
- return { success: false, error: result.error }
374
+ const results = parseAckResults(result, acknowledgments.length)
375
+ const failed = results.filter(r => !r.success)
376
+
377
+ if (failed.length > 0) {
378
+ const error = failed.length === 1
379
+ ? failed[0].error
380
+ : `${failed.length} of ${results.length} acknowledgments rejected: ${failed[0].error}`
381
+ logger.error('Queen.ack', { type: 'batch', error, failed: failed.length, count: results.length })
382
+ return { success: false, error, results }
283
383
  }
284
384
 
285
- logger.log('Queen.ack', { type: 'batch', success: true, count: acknowledgments.length })
286
- return { success: true, ...result }
385
+ logger.log('Queen.ack', { type: 'batch', success: true, count: results.length })
386
+ return { success: true, processed: results.length, results }
287
387
  } catch (error) {
288
388
  logger.error('Queen.ack', { type: 'batch', error: error.message })
289
389
  return { success: false, error: error.message }
@@ -321,13 +421,14 @@ export class Queen {
321
421
  try {
322
422
  const result = await this.#httpClient.post('/api/v1/ack', body)
323
423
 
324
- if (result && result.error) {
325
- logger.error('Queen.ack', { type: 'single', transactionId, error: result.error })
326
- return { success: false, error: result.error }
327
- }
424
+ const [ackResult] = parseAckResults(result, 1)
328
425
 
329
- logger.log('Queen.ack', { type: 'single', transactionId, success: true })
330
- return { success: true, ...result }
426
+ if (!ackResult.success) {
427
+ logger.error('Queen.ack', { type: 'single', transactionId, error: ackResult.error })
428
+ } else {
429
+ logger.log('Queen.ack', { type: 'single', transactionId, success: true })
430
+ }
431
+ return ackResult
331
432
  } catch (error) {
332
433
  logger.error('Queen.ack', { type: 'single', transactionId, error: error.message })
333
434
  return { success: false, error: error.message }
@@ -512,6 +613,15 @@ export class Queen {
512
613
  }
513
614
  this.#shutdownHandlers = []
514
615
 
616
+ // Release the HTTP agent's keep-alive sockets — without this the Node
617
+ // event loop stays pinned by open sockets and the process never exits
618
+ // naturally after close().
619
+ try {
620
+ await this.#httpClient.destroy()
621
+ } catch (error) {
622
+ logger.warn('Queen.close', { error: error.message, phase: 'http-destroy' })
623
+ }
624
+
515
625
  logger.log('Queen.close', 'Client closed successfully')
516
626
  }
517
627
  }
@@ -104,6 +104,43 @@ const queen = new Queen({
104
104
  })
105
105
  ```
106
106
 
107
+ ### Connecting Through a Hosted Proxy (Cluster Selection)
108
+
109
+ A Queen proxy deployment routes each request to a tenant cluster using the
110
+ **Host header** (its first DNS label is the cluster slug). Normally you don't
111
+ have to think about it — every cluster has its own hostname, so pointing the
112
+ client at that hostname is all it takes:
113
+
114
+ ```javascript
115
+ const queen = new Queen({
116
+ url: 'https://acme.eu1.queenmq.cloud', // Host follows from the URL
117
+ bearerToken: process.env.QUEEN_API_KEY
118
+ })
119
+ ```
120
+
121
+ When the base URL can't be the cluster's hostname — an IP or a shared cell
122
+ endpoint, a local rig, a staging box, split-horizon DNS — use `hostHeader` to
123
+ advertise the cluster host while the connection still goes to the configured
124
+ address (the same thing `curl --resolve` does; TLS SNI and certificate
125
+ validation follow `hostHeader`, not the address):
126
+
127
+ ```javascript
128
+ const queen = new Queen({
129
+ url: 'https://10.0.0.7:6711', // where we connect
130
+ hostHeader: 'acme.eu1.queenmq.cloud', // which cluster we ask for
131
+ bearerToken: process.env.QUEEN_API_KEY
132
+ })
133
+ ```
134
+
135
+ `hostHeader` takes a bare authority (`'acme'`, `'acme.eu1.queenmq.cloud'`,
136
+ `'acme.local:6711'`) — not a URL. It works with `urls: [...]` too: every
137
+ backend is dialed as usual, and all of them get the same Host.
138
+
139
+ > ⚠️ `headers: { Host: '...' }` **cannot** work in JavaScript: `Host` is a
140
+ > WHATWG forbidden header name and `fetch()` drops it. Rather than let that
141
+ > silently route you to the wrong cluster, the client maps it onto
142
+ > `hostHeader` and warns once. Set `hostHeader` directly.
143
+
107
144
  ---
108
145
 
109
146
  ## Part 1: Hello Queue!
@@ -1693,6 +1730,38 @@ await queen
1693
1730
  .commit()
1694
1731
  ```
1695
1732
 
1733
+ ### Key/Value and Timers
1734
+
1735
+ Always present on every broker — no flag, nothing to probe. An operator's runtime kill switch can
1736
+ pause them (`503` + `Retry-After`, `error: 'kv_disabled'` / `'timers_disabled'`; `403` on a rider
1737
+ inside a transaction), which is a pause and not an absence. Full treatment in the
1738
+ [client README](../README.md#keyvalue-state-and-timers).
1739
+
1740
+ ```javascript
1741
+ // Every write states its lifetime: exactly one of ttl/ttlSeconds/until and forever.
1742
+ await queen.kv.put('orders', 'order:9f1', { state: 'held' }, { ttl: '60s' })
1743
+ const row = await queen.kv.get('orders', 'order:9f1') // {found, value, version, ...}
1744
+
1745
+ // Writes return an OBJECT, always truthy. Read .applied, never the result itself.
1746
+ const res = await queen.kv.delete('orders', 'order:9f1')
1747
+ if (res.applied) { }
1748
+
1749
+ const { won } = await queen.kv.once('dedup', eventId, { ttl: '24h' })
1750
+ const hit = await queen.kv.incr('quota', key, 1, { max: 1000, ttl: '1h' }) // applied IS admission
1751
+
1752
+ await queen.timer('reminders').key(orderId).delay('30m').payload({ orderId }).schedule()
1753
+ await queen.timer('reminders').key(orderId).cancel() // 'absent' may mean already delivered
1754
+
1755
+ // The gate: marker, push and ack commit or roll back together.
1756
+ const out = await queen
1757
+ .transaction()
1758
+ .ack(message)
1759
+ .queue('emails').push([{ data: mail }])
1760
+ .once('sent', message.transactionId, { ttl: '24h' })
1761
+ .commit()
1762
+ if (out.success === false) return // returned, not thrown: a redelivery already handled
1763
+ ```
1764
+
1696
1765
  ### Lease Renewal
1697
1766
 
1698
1767
  ```javascript
@@ -335,8 +335,10 @@ export class QueueBuilder {
335
335
 
336
336
  // Generate affinity key for consistent routing to same backend
337
337
  const affinityKey = this.#getAffinityKey()
338
-
339
- const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey)
338
+
339
+ // wait=true is a long-poll: on 429 it should back off and keep waiting
340
+ // rather than give up after a handful of tries (retryKind: 'pop').
341
+ const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey, this.#wait ? 'pop' : null)
340
342
 
341
343
  if (!result || !result.messages) {
342
344
  logger.log('QueueBuilder.pop', { status: 'no-messages' })
@@ -347,8 +349,12 @@ export class QueueBuilder {
347
349
  logger.log('QueueBuilder.pop', { status: 'success', count: messages.length })
348
350
  return messages
349
351
  } catch (error) {
350
- // Return empty array on error instead of throwing
351
- logger.error('QueueBuilder.pop', { error: error.message })
352
+ // Return empty array on error instead of throwing. This also covers a
353
+ // 429 whose retry429 policy was exhausted (bounded pop, or an explicit
354
+ // maxAttempts override) and a terminal 403 (e.g. cluster_suspended) --
355
+ // both are logged with their `.code` rather than raising, matching this
356
+ // method's existing swallow-to-[] contract.
357
+ logger.error('QueueBuilder.pop', { error: error.message, status: error.status, code: error.code })
352
358
  return []
353
359
  }
354
360
  }
@@ -368,22 +374,14 @@ export class QueueBuilder {
368
374
  throw new Error('Must specify queue, namespace, or task for pop operation')
369
375
  }
370
376
 
371
- #buildPopParams() {
372
- const params = new URLSearchParams({
373
- batch: this.#batch.toString(),
374
- wait: this.#wait.toString(),
375
- timeout: this.#timeoutMillis.toString() // Server expects 'timeout', not 'timeoutMillis'
376
- })
377
-
378
- if (this.#group) params.append('consumerGroup', this.#group)
379
- if (this.#namespace) params.append('namespace', this.#namespace)
380
- if (this.#task) params.append('task', this.#task)
381
- if (this.#autoAck) params.append('autoAck', 'true')
382
- if (this.#subscriptionMode) params.append('subscriptionMode', this.#subscriptionMode)
383
- if (this.#subscriptionFrom) params.append('subscriptionFrom', this.#subscriptionFrom)
384
-
385
- return params
386
- }
377
+ // NOTE: a second, DEAD copy of the pop parameter builder lived here and was
378
+ // deleted with the kv/timers work (PLAN_KV_TIMERS.md §10.4). pop() builds its
379
+ // own params inline, above, because it has to override autoAck with the POP
380
+ // defaults; the dead copy did not. Anyone adding a parameter by looking for
381
+ // the method whose name says "build pop params" would have added it to the
382
+ // copy nobody calls: the pop would keep working and the parameter would
383
+ // simply never arrive, which reads as a server-side mystery and not as a
384
+ // client bug.
387
385
 
388
386
  // ===========================
389
387
  // Buffer Management Methods