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.
- package/README.md +175 -0
- package/client-v2/Queen.js +125 -15
- package/client-v2/README.md +69 -0
- package/client-v2/builders/QueueBuilder.js +18 -20
- package/client-v2/builders/TimerBuilder.js +262 -0
- package/client-v2/builders/TransactionBuilder.js +199 -10
- package/client-v2/consumer/ConsumerManager.js +64 -12
- package/client-v2/http/HttpClient.js +382 -32
- package/client-v2/kv/Kv.js +432 -0
- package/client-v2/kv/expiry.js +148 -0
- package/client-v2/streams/runtime/Runner.js +45 -0
- package/client-v2/utils/defaults.js +20 -1
- package/package.json +12 -3
- package/test-v2/_kvtimers.js +71 -0
- package/test-v2/ackwindow.js +265 -0
- package/test-v2/auth.js +65 -149
- package/test-v2/docs.js +204 -0
- package/test-v2/http-unit/hostHeader.test.js +411 -0
- package/test-v2/http-unit/retry429.test.js +319 -0
- package/test-v2/kv-unit/_planServer.js +65 -0
- package/test-v2/kv-unit/kvWire.test.js +377 -0
- package/test-v2/kv-unit/timerWire.test.js +177 -0
- package/test-v2/kv-unit/txnWire.test.js +222 -0
- package/test-v2/kv.js +273 -0
- package/test-v2/load.js +37 -41
- package/test-v2/maintenance.js +2 -2
- package/test-v2/push.js +25 -35
- package/test-v2/run.js +67 -5
- package/test-v2/semantics.js +801 -0
- package/test-v2/stream/_helpers.js +8 -1
- package/test-v2/stream/combined.js +4 -3
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/eventTime.js +8 -5
- package/test-v2/stream/operators.js +5 -5
- package/test-v2/stream/recovery.js +4 -1
- package/test-v2/stream/session.js +3 -3
- package/test-v2/stream/sliding.js +1 -1
- package/test-v2/stream/throughput.js +3 -2
- package/test-v2/stream/tumbling.js +16 -12
- package/test-v2/streams-unit/ack.test.js +203 -0
- package/test-v2/streams-unit/e2e.test.js +4 -1
- package/test-v2/timers.js +209 -0
- package/test-v2/transaction.js +4 -1
- 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
|
package/client-v2/Queen.js
CHANGED
|
@@ -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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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:
|
|
286
|
-
return { success: true,
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
}
|
package/client-v2/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|