queen-mq 2.0.3 → 2.0.4

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 CHANGED
@@ -1079,3 +1079,25 @@ const messages: Message<OrderData>[] = await queen.queue('orders').pop()
1079
1079
  ## License
1080
1080
 
1081
1081
  Apache 2.0 - See [LICENSE.md](../LICENSE.md)
1082
+
1083
+ ### Optional consumer supervision
1084
+
1085
+ ```js
1086
+ await queen.queue('orders').group('billing')
1087
+ .supervision({ group: 'billing-production' })
1088
+ .concurrency(4).each().consume(async message => { /* process message */ })
1089
+ ```
1090
+
1091
+ Supervision defaults to off; `.supervision(false)` disables it. The group names
1092
+ an application/deployment in the dashboard, independently of the consumer group.
1093
+ Each consume invocation publishes its own instance into the broker's
1094
+ `queen-supervisor` KV namespace every 10 seconds (30-second heartbeat timeout,
1095
+ 60-second TTL), plus a final stopped observation. The credential needs KV write
1096
+ access. Publication is serialized and best effort with a two-second deadline.
1097
+
1098
+ The Supervisors page supporting `queen.consumer.status/v1` shows live async
1099
+ consumer loops, busy handlers, successful/failed handler calls and progress times.
1100
+ A batch is one handler call; completion does not imply ACK success. No payloads or
1101
+ error text are published. Event-loop starvation can stop heartbeats. Reporting
1102
+ does not restart processes or tasks, change ACK/lease policies, or enable remote
1103
+ control. With reporting off there is no additional timer or network traffic.
@@ -42,6 +42,7 @@ export class QueueBuilder {
42
42
  #config = {}
43
43
 
44
44
  // Consume options
45
+ #supervision = false
45
46
  #concurrency = CONSUME_DEFAULTS.concurrency
46
47
  // batch / maxPartitions hold the USER's value, and null means the setter was
47
48
  // never called -- which is the dimension pop autopilot gets to choose. The
@@ -238,6 +239,12 @@ export class QueueBuilder {
238
239
  return this
239
240
  }
240
241
 
242
+ /** Opt-in consumer observations in the broker dashboard; false by default. */
243
+ supervision(config = false) {
244
+ this.#supervision = config
245
+ return this
246
+ }
247
+
241
248
  concurrency(count) {
242
249
  this.#concurrency = Math.max(1, count)
243
250
  return this
@@ -410,6 +417,7 @@ export class QueueBuilder {
410
417
  // has to survive all the way to #buildParams: it is the ONLY record that
411
418
  // the user said nothing about that dimension.
412
419
  autopilot: this.#autopilotEnabled(),
420
+ supervision: options.supervision ?? this.#supervision,
413
421
  signal: options.signal
414
422
  }
415
423
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import * as logger from '../utils/logger.js'
6
+ import { Supervision } from './Supervision.js'
6
7
  import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/conflation.js'
7
8
  import { popSizing, parseAutopilotDecision, emptyPollDelayMillis } from '../utils/autopilot.js'
8
9
  import { CONSUME_DEFAULTS } from '../utils/defaults.js'
@@ -92,9 +93,13 @@ export class ConsumerManager {
92
93
  // Generate affinity key for consistent routing to same backend
93
94
  const affinityKey = this.#getAffinityKey(queue, partition, namespace, task, group)
94
95
 
96
+ const supervision = options.supervision ? new Supervision(this.#httpClient, options.supervision, options) : null
97
+ if (supervision) handler = supervision.wrap(handler)
98
+
95
99
  // Start workers
96
100
  const workers = []
97
101
  for (let i = 0; i < concurrency; i++) {
102
+ if (supervision) supervision.running++
98
103
  workers.push(this.#worker(i, handler, path, baseParams, {
99
104
  batch,
100
105
  limit,
@@ -113,9 +118,12 @@ export class ConsumerManager {
113
118
  // the pop target to key the once-per-(queue,group) conflict warning.
114
119
  conflation,
115
120
  conflationCtx: { queue, namespace, task, group }
121
+ }).finally(async () => {
122
+ if (supervision && --supervision.running === 0) await supervision.stop()
116
123
  }))
117
124
  }
118
125
 
126
+ supervision?.start()
119
127
  logger.log('ConsumerManager.start', { status: 'workers-started', count: concurrency })
120
128
 
121
129
  // Wait for all workers to complete
@@ -0,0 +1,78 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { hostname } from 'node:os'
3
+ import * as logger from '../utils/logger.js'
4
+
5
+ // One opt-in reporter per consume invocation. Handler calls are not ACKs.
6
+ export class Supervision {
7
+ constructor(http, config, options) {
8
+ if (!config || typeof config !== 'object' || typeof config.group !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(config.group) || config.group === 'coordination' || /[\r\n]/.test(config.group)) throw new Error('supervision.group must be a valid application/deployment name')
9
+ if (!Number.isInteger(options.concurrency) || options.concurrency < 1 || options.concurrency > 4096) throw new Error('supervision requires concurrency between 1 and 4096')
10
+ this.http = http
11
+ this.options = options
12
+ this.group = config.group
13
+ this.id = randomBytes(16).toString('hex')
14
+ this.started = Date.now()
15
+ this.monotonic = performance.now()
16
+ this.running = this.completed = this.failed = this.sequence = 0
17
+ this.last = this.pending = this.timer = null
18
+ this.active = new Map()
19
+ this.state = 'running'
20
+ this.warned = false
21
+ }
22
+ wrap(handler) {
23
+ return async (...args) => {
24
+ const id = this.sequence++
25
+ this.active.set(id, performance.now())
26
+ try { const result = await handler(...args); this.completed++; return result }
27
+ catch (error) { this.failed++; throw error }
28
+ finally { this.active.delete(id); this.last = Math.floor(Date.now() / 1000) }
29
+ }
30
+ }
31
+ start() {
32
+ this.publish()
33
+ this.timer = setInterval(() => this.publish(), 10_000)
34
+ this.timer.unref()
35
+ }
36
+ document() {
37
+ const now = performance.now()
38
+ return {
39
+ schema: 'queen.consumer.status/v1', instance_id: this.id, engine: 'js', execution_model: 'async-tasks',
40
+ hostname: hostname(), pid: process.pid, state: this.state, updated_at_epoch: Math.floor(Date.now() / 1000),
41
+ started_at_epoch: Math.floor(this.started / 1000), uptime_seconds: Math.floor((now - this.monotonic) / 1000),
42
+ configuration: { heartbeat_timeout: 30 },
43
+ pool_status: [{ name: 'consumer', queue: this.options.queue || null, namespace: this.options.namespace || null,
44
+ task: this.options.task || null, consumer_group: this.options.group || '__QUEUE_MODE__',
45
+ desired: this.options.concurrency, running: this.running, busy: this.active.size,
46
+ completed: this.completed, failed: this.failed, last_completed_at_epoch: this.last,
47
+ oldest_inflight_seconds: this.active.size ? Math.floor((now - Math.min(...this.active.values())) / 1000) : null }],
48
+ }
49
+ }
50
+ publish() {
51
+ if (this.pending) return this.pending
52
+ this.pending = this.send().finally(() => { this.pending = null })
53
+ return this.pending
54
+ }
55
+ async send() {
56
+ try {
57
+ const bytes = Buffer.from(JSON.stringify(this.document()))
58
+ if (bytes.length > 45_000) throw new Error('Consumer status exceeds one chunk')
59
+ const write = randomBytes(16).toString('hex'), slot = `${this.group}/${this.id}`
60
+ const ops = [
61
+ { op: 'put', ns: 'queen-supervisor', key: `${slot}/head`, value: { format: 'queen.supervisor.remote-status/v1', write, chunks: 1, bytes: bytes.length }, ttlSeconds: 60 },
62
+ { op: 'put', ns: 'queen-supervisor', key: `${slot}/chunk/0000`, value: { write, index: 0, data: bytes.toString('base64') }, ttlSeconds: 60 },
63
+ ]
64
+ const result = await this.http.post('/api/v1/kv', { operations: ops }, 2000, null, null, AbortSignal.timeout(2000))
65
+ if (!Array.isArray(result?.results) || result.results.length !== 2 || result.results.some(r => r.applied !== true)) throw new Error('Consumer status publication was not applied')
66
+ this.warned = false
67
+ } catch {
68
+ if (!this.warned) logger.warn('Consumer.supervision', 'Status publication failed; consumption continues')
69
+ this.warned = true
70
+ }
71
+ }
72
+ async stop() {
73
+ clearInterval(this.timer)
74
+ if (this.pending) await this.pending
75
+ this.state = 'stopped'
76
+ await this.publish()
77
+ }
78
+ }
@@ -611,8 +611,8 @@ export class HttpClient {
611
611
  return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey, retryKind, signal)
612
612
  }
613
613
 
614
- async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
615
- return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind)
614
+ async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
615
+ return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind, signal)
616
616
  }
617
617
 
618
618
  async put(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
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/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
+ })