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 +22 -0
- package/client-v2/builders/QueueBuilder.js +8 -0
- package/client-v2/consumer/ConsumerManager.js +8 -0
- package/client-v2/consumer/Supervision.js +78 -0
- package/client-v2/http/HttpClient.js +2 -2
- package/package.json +2 -2
- package/test-v2/consumer-unit/supervision.test.js +97 -0
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
|
+
"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
|
+
})
|