queen-mq 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -18
- package/client-v2/Queen.js +45 -13
- package/client-v2/README.md +26 -11
- package/client-v2/admin/Admin.js +0 -47
- package/client-v2/builders/QueueBuilder.js +33 -10
- package/client-v2/builders/TimerBuilder.js +2 -2
- package/client-v2/builders/TransactionBuilder.js +35 -9
- package/client-v2/consumer/ConsumerManager.js +67 -46
- package/client-v2/ephemeral/Ephemeral.js +7 -9
- package/client-v2/kv/Kv.js +4 -4
- package/client-v2/kv/expiry.js +1 -1
- package/client-v2/streams/Stream.js +8 -3
- package/client-v2/streams/helpers/rateLimiter.js +4 -4
- package/client-v2/streams/operators/GateOperator.js +9 -2
- package/client-v2/streams/operators/ReduceOperator.js +2 -2
- package/client-v2/streams/operators/WindowSessionOperator.js +3 -3
- package/client-v2/streams/runtime/Runner.js +111 -54
- package/client-v2/streams/runtime/cycle.js +4 -4
- package/client-v2/streams/runtime/register.js +1 -1
- package/client-v2/utils/conflation.js +0 -6
- package/client-v2/utils/consumerGroup.js +54 -0
- package/package.json +5 -8
- package/test-v2/_kvtimers.js +12 -13
- package/test-v2/ackwindow.js +12 -192
- package/test-v2/bootstrap.js +2 -2
- package/test-v2/conflation-unit/conflationWire.test.js +0 -12
- package/test-v2/consume.js +41 -1
- package/test-v2/consumer-unit/handlerError.test.js +161 -0
- package/test-v2/docs.js +5 -4
- package/test-v2/http-unit/pushStatus.test.js +142 -0
- package/test-v2/http-unit/renew.test.js +96 -0
- package/test-v2/kv-unit/timerWire.test.js +1 -1
- package/test-v2/kv-unit/txnWire.test.js +101 -2
- package/test-v2/kv.js +6 -5
- package/test-v2/pop.js +28 -1
- package/test-v2/run.js +50 -114
- package/test-v2/runner-unit/fatalExit.test.js +64 -0
- package/test-v2/semantics.js +20 -34
- package/test-v2/stream/_helpers.js +10 -19
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/gate.js +54 -0
- package/test-v2/stream/index.js +2 -0
- package/test-v2/stream/tumbling.js +3 -3
- package/test-v2/streams-unit/ack.test.js +61 -0
- package/test-v2/streams-unit/cycle.test.js +1 -1
- package/test-v2/streams-unit/e2e.test.js +6 -10
- package/test-v2/streams-unit/gate.test.js +189 -0
- package/test-v2/timers.js +6 -5
- package/test-v2/transaction.js +169 -0
- package/test-v2/watermark.js +38 -176
- package/test-v2/maintenance.js +0 -277
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push per-item statuses.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/v1/push answers 201 with one result per item, in input order, and
|
|
5
|
+
* a refused item fails INSIDE a successful request, so the per-item status is
|
|
6
|
+
* the only signal that a message was not stored. The statuses brokers send:
|
|
7
|
+
*
|
|
8
|
+
* queued stored (every broker)
|
|
9
|
+
* duplicate its transactionId is already stored (every broker)
|
|
10
|
+
* error refused, no per-item message (2.x)
|
|
11
|
+
* failed refused, with `error` (1.x)
|
|
12
|
+
* buffered written to the broker's failover file, replayed (1.x)
|
|
13
|
+
*
|
|
14
|
+
* The client used to look for `failed` only, so a 2.x refusal (`error`) was
|
|
15
|
+
* neither reported nor thrown: push() resolved as if the message were stored.
|
|
16
|
+
* Found 2026-10-02 against 2.0.0-beta.6, which answers `error` when a
|
|
17
|
+
* partition's planned append exceeds QUEEN_RAFT_ENTRY_MAX_BYTES.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it } from 'node:test'
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
|
|
23
|
+
import { Queen } from '../../client-v2/index.js'
|
|
24
|
+
import { withPlanServer } from '../kv-unit/_planServer.js'
|
|
25
|
+
|
|
26
|
+
const QUEUE = 'test-push-status'
|
|
27
|
+
|
|
28
|
+
const item = (index, status, extra = {}) => ({
|
|
29
|
+
index,
|
|
30
|
+
message_id: `0199aaaa-0000-7000-8000-00000000000${index}`,
|
|
31
|
+
transaction_id: `tx-${index}`,
|
|
32
|
+
queueName: QUEUE,
|
|
33
|
+
status,
|
|
34
|
+
...extra
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// The exact shape 2.0.0-beta.6 answered for an item it refused (no offset,
|
|
38
|
+
// no error text).
|
|
39
|
+
const created = (results) => ({ status: 201, body: results })
|
|
40
|
+
|
|
41
|
+
async function withQueen(response, run) {
|
|
42
|
+
await withPlanServer([response], response, async (url, hits) => {
|
|
43
|
+
const queen = new Queen({ url, handleSignals: false, retryAttempts: 1 })
|
|
44
|
+
try {
|
|
45
|
+
await run(queen, hits)
|
|
46
|
+
} finally {
|
|
47
|
+
await queen.close()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('push — a refused item is reported, whatever the broker calls it', () => {
|
|
53
|
+
it('a 2.x `error` item makes push() reject, carrying the per-item results', async () => {
|
|
54
|
+
await withQueen(created([item(0, 'error')]), async (queen) => {
|
|
55
|
+
await assert.rejects(
|
|
56
|
+
async () => { await queen.queue(QUEUE).push([{ data: { n: 1 } }]) },
|
|
57
|
+
(err) => {
|
|
58
|
+
assert.match(err.message, /rejected by the broker/)
|
|
59
|
+
assert.match(err.message, /"error"/, 'the status the broker answered is named')
|
|
60
|
+
assert.deepEqual(err.results.map(r => r.status), ['error'])
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('a 2.x `error` item goes to onError, and the stored ones still go to onSuccess', async () => {
|
|
68
|
+
await withQueen(created([item(0, 'queued', { offset: 0 }), item(1, 'error')]), async (queen) => {
|
|
69
|
+
let failedItems = null
|
|
70
|
+
let failure = null
|
|
71
|
+
let succeeded = null
|
|
72
|
+
const res = await queen.queue(QUEUE)
|
|
73
|
+
.push([{ data: { n: 1 } }, { data: { n: 2 } }])
|
|
74
|
+
.onError(async (items, err) => { failedItems = items; failure = err })
|
|
75
|
+
.onSuccess(async (items) => { succeeded = items })
|
|
76
|
+
|
|
77
|
+
assert.ok(Array.isArray(res), 'with onError the call resolves with the broker results')
|
|
78
|
+
assert.equal(failedItems.length, 1)
|
|
79
|
+
assert.deepEqual(failedItems[0].payload, { n: 2 })
|
|
80
|
+
assert.equal(failedItems[0].result.status, 'error')
|
|
81
|
+
assert.match(failure.message, /rejected by the broker/)
|
|
82
|
+
assert.equal(succeeded.length, 1)
|
|
83
|
+
assert.deepEqual(succeeded[0].payload, { n: 1 })
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('a 1.x `failed` item is reported with the broker\'s own text', async () => {
|
|
88
|
+
await withQueen(created([item(0, 'failed', { error: 'File buffer write failed' })]), async (queen) => {
|
|
89
|
+
await assert.rejects(
|
|
90
|
+
async () => { await queen.queue(QUEUE).push([{ data: { n: 1 } }]) },
|
|
91
|
+
(err) => { assert.equal(err.message, 'File buffer write failed'); return true }
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('a status this client does not know is a failure, not a silent success', async () => {
|
|
97
|
+
await withQueen(created([item(0, 'refused-by-something-new')]), async (queen) => {
|
|
98
|
+
await assert.rejects(
|
|
99
|
+
async () => { await queen.queue(QUEUE).push([{ data: { n: 1 } }]) },
|
|
100
|
+
(err) => { assert.match(err.message, /refused-by-something-new/); return true }
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('several refused items: one error for the call, counting them', async () => {
|
|
106
|
+
await withQueen(created([item(0, 'error'), item(1, 'queued', { offset: 0 }), item(2, 'error')]), async (queen) => {
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
async () => { await queen.queue(QUEUE).push([{ data: 1 }, { data: 2 }, { data: 3 }]) },
|
|
109
|
+
(err) => { assert.match(err.message, /^2 of 3 pushed items rejected/); return true }
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('push — accepted statuses stay accepted', () => {
|
|
116
|
+
it('`queued` resolves with the broker results and reaches onSuccess', async () => {
|
|
117
|
+
await withQueen(created([item(0, 'queued', { offset: 7 })]), async (queen) => {
|
|
118
|
+
let succeeded = null
|
|
119
|
+
const res = await queen.queue(QUEUE).push([{ data: { n: 1 } }]).onSuccess(async (items) => { succeeded = items })
|
|
120
|
+
assert.equal(res[0].status, 'queued')
|
|
121
|
+
assert.equal(succeeded.length, 1)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('a 1.x `buffered` item is accepted: the broker owns its delivery', async () => {
|
|
126
|
+
await withQueen(created([item(0, 'buffered')]), async (queen) => {
|
|
127
|
+
let succeeded = null
|
|
128
|
+
const res = await queen.queue(QUEUE).push([{ data: { n: 1 } }]).onSuccess(async (items) => { succeeded = items })
|
|
129
|
+
assert.equal(res[0].status, 'buffered')
|
|
130
|
+
assert.equal(succeeded.length, 1)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('`duplicate` goes to onDuplicate and is not a failure', async () => {
|
|
135
|
+
await withQueen(created([item(0, 'duplicate', { offset: 3 })]), async (queen) => {
|
|
136
|
+
let dups = null
|
|
137
|
+
const res = await queen.queue(QUEUE).push([{ data: { n: 1 } }]).onDuplicate(async (items) => { dups = items })
|
|
138
|
+
assert.equal(res[0].status, 'duplicate')
|
|
139
|
+
assert.equal(dups.length, 1)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queen.renew() outcome.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/v1/lease/:leaseId/extend answers HTTP 200 whether or not anything
|
|
5
|
+
* was renewed. `success` in the body is the only signal, and the client used
|
|
6
|
+
* to ignore it: renewing a lease that had expired, or that an ack had already
|
|
7
|
+
* released, reported `success: true` with `newExpiresAt: null` (found
|
|
8
|
+
* 2026-10-02 against 2.0.0-beta.6). The bodies below are what brokers send:
|
|
9
|
+
*
|
|
10
|
+
* 1.x and 2.x: {leaseId, success, renewed, newExpiresAt, expiresAt, lease_expires_at}
|
|
11
|
+
* C++: [{index, leaseId, success, error, expiresAt}]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
|
|
17
|
+
import { Queen } from '../../client-v2/index.js'
|
|
18
|
+
import { withPlanServer } from '../kv-unit/_planServer.js'
|
|
19
|
+
|
|
20
|
+
const LEASE = '01a0fcf2-1a28-7001-8810-d74deda1fd42'
|
|
21
|
+
const EXPIRES = '2026-10-02T14:09:40.273Z'
|
|
22
|
+
|
|
23
|
+
// What 2.0.0-beta.6 answered for a live lease covering two partitions, and
|
|
24
|
+
// for a lease that no longer exists.
|
|
25
|
+
const renewed = (leaseId = LEASE) => ({
|
|
26
|
+
leaseId, success: true, renewed: 2, newExpiresAt: EXPIRES, expiresAt: EXPIRES, lease_expires_at: EXPIRES
|
|
27
|
+
})
|
|
28
|
+
const notRenewed = (leaseId = LEASE) => ({
|
|
29
|
+
leaseId, success: false, renewed: 0, newExpiresAt: null, expiresAt: null, lease_expires_at: null
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
async function withQueen(plan, run) {
|
|
33
|
+
await withPlanServer(plan, { status: 200, body: renewed() }, async (url, hits) => {
|
|
34
|
+
const queen = new Queen({ url, handleSignals: false, retryAttempts: 1 })
|
|
35
|
+
try {
|
|
36
|
+
await run(queen, hits)
|
|
37
|
+
} finally {
|
|
38
|
+
await queen.close()
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('Queen.renew — the broker\'s verdict, not the HTTP status', () => {
|
|
44
|
+
it('a renewed lease reports success, its new expiry, and how many partitions it covers', async () => {
|
|
45
|
+
await withQueen([{ body: renewed() }], async (queen, hits) => {
|
|
46
|
+
const res = await queen.renew(LEASE)
|
|
47
|
+
assert.deepEqual(res, { leaseId: LEASE, success: true, newExpiresAt: EXPIRES, renewed: 2 })
|
|
48
|
+
assert.equal(hits[0].url, `/api/v1/lease/${LEASE}/extend`)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('a lease that is gone reports failure, with a reason', async () => {
|
|
53
|
+
await withQueen([{ body: notRenewed() }], async (queen) => {
|
|
54
|
+
const res = await queen.renew({ leaseId: LEASE, transactionId: 'tx-1' })
|
|
55
|
+
assert.equal(res.success, false)
|
|
56
|
+
assert.equal(res.renewed, 0)
|
|
57
|
+
assert.equal(res.newExpiresAt, null)
|
|
58
|
+
assert.match(res.error, /not renewed/)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('an array of messages answers one outcome per distinct lease', async () => {
|
|
63
|
+
const other = '01a0fcf2-0000-7000-8000-000000000000'
|
|
64
|
+
await withQueen([{ body: renewed() }, { body: notRenewed(other) }], async (queen, hits) => {
|
|
65
|
+
const res = await queen.renew([
|
|
66
|
+
{ leaseId: LEASE, transactionId: 'tx-1' },
|
|
67
|
+
{ leaseId: LEASE, transactionId: 'tx-2' },
|
|
68
|
+
{ leaseId: other, transactionId: 'tx-3' }
|
|
69
|
+
])
|
|
70
|
+
assert.equal(hits.length, 2, 'one call per lease, not per message')
|
|
71
|
+
assert.deepEqual(res.map(r => [r.leaseId, r.success]), [[LEASE, true], [other, false]])
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('reads the C++ broker\'s array, including its error text', async () => {
|
|
76
|
+
await withQueen([
|
|
77
|
+
{ body: [{ index: 0, leaseId: LEASE, success: true, error: null, expiresAt: EXPIRES }] },
|
|
78
|
+
{ body: [{ index: 0, leaseId: LEASE, success: false, error: 'Lease already expired', expiresAt: null }] }
|
|
79
|
+
], async (queen) => {
|
|
80
|
+
const ok = await queen.renew(LEASE)
|
|
81
|
+
assert.equal(ok.success, true)
|
|
82
|
+
assert.equal(ok.newExpiresAt, EXPIRES)
|
|
83
|
+
const gone = await queen.renew(LEASE)
|
|
84
|
+
assert.equal(gone.success, false)
|
|
85
|
+
assert.equal(gone.error, 'Lease already expired')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('an HTTP failure is still a failed renewal, not a throw', async () => {
|
|
90
|
+
await withQueen([{ status: 500, body: { error: 'boom' } }], async (queen) => {
|
|
91
|
+
const res = await queen.renew(LEASE)
|
|
92
|
+
assert.equal(res.success, false)
|
|
93
|
+
assert.ok(res.error)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* a tenant that is over quota -- while the fire never stops by itself, so
|
|
11
11
|
* that tenant keeps producing messages it cannot stop.
|
|
12
12
|
* 2. delayMs, never delaySeconds and never an absolute instant (§4.2,
|
|
13
|
-
* §20.6). One clock,
|
|
13
|
+
* §20.6). One clock, the broker's. `deliverAt` in an op is a 22023.
|
|
14
14
|
* 3. The payload is base64 (§6.2), and the SDK encodes it, so a caller never
|
|
15
15
|
* has to know.
|
|
16
16
|
* 4. `txn` is mandatory on a schedule and is echoed on `absent`, which is
|
|
@@ -181,11 +181,19 @@ describe('Transaction wire — the verdict that must not throw', () => {
|
|
|
181
181
|
})
|
|
182
182
|
|
|
183
183
|
it('commit() still THROWS on every other failure', async () => {
|
|
184
|
-
|
|
184
|
+
// The body a 2.0 broker answers for an ack whose lease is gone (a 1.x
|
|
185
|
+
// broker spelled the reason ack_rejected).
|
|
186
|
+
const rejected = ok({
|
|
187
|
+
transactionId: 'txn-1',
|
|
188
|
+
success: false,
|
|
189
|
+
reason: 'rejected_ack',
|
|
190
|
+
error: 'QTXN 1 acked message(s) on partition 3 are not leased by this worker or were already acked; the transaction rolled back',
|
|
191
|
+
results: []
|
|
192
|
+
})
|
|
185
193
|
await withQueen([rejected], async (queen) => {
|
|
186
194
|
await assert.rejects(() => queen.transaction().ack(MSG).commit(), (e) => {
|
|
187
195
|
assert.match(e.message, /QTXN/)
|
|
188
|
-
assert.equal(e.reason, '
|
|
196
|
+
assert.equal(e.reason, 'rejected_ack', 'the broker\'s reason travels on the error, so nobody matches the message')
|
|
189
197
|
return true
|
|
190
198
|
})
|
|
191
199
|
})
|
|
@@ -220,3 +228,94 @@ describe('Transaction wire — the verdict that must not throw', () => {
|
|
|
220
228
|
})
|
|
221
229
|
})
|
|
222
230
|
})
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// The consumer group of an ack op. The lease an ack has to match belongs to
|
|
234
|
+
// the group the message was popped under, and an op that names no group is
|
|
235
|
+
// judged in queue mode: a message popped by a group and acked without one
|
|
236
|
+
// rolled the whole bundle back with `rejected_ack` (found 2026-10-02 against
|
|
237
|
+
// 2.0.0-beta.6). Every pop answers each message with its `consumerGroup`.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
describe('Transaction wire — the ack op carries the group the message was popped under', () => {
|
|
241
|
+
const fromGroup = (group, n = 1) => ({
|
|
242
|
+
...MSG,
|
|
243
|
+
transactionId: `aaaaaaaa-aaaa-7aaa-8aaa-00000000000${n}`,
|
|
244
|
+
consumerGroup: group
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('takes consumerGroup from the message when the caller names none', async () => {
|
|
248
|
+
await withQueen([success()], async (queen, hits) => {
|
|
249
|
+
await queen.transaction().ack(fromGroup('workers')).commit()
|
|
250
|
+
assert.equal(hits[0].body.operations[0].consumerGroup, 'workers')
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('a queue-mode message adds no consumerGroup key, as before', async () => {
|
|
255
|
+
await withQueen([success(), success()], async (queen, hits) => {
|
|
256
|
+
await queen.transaction().ack(fromGroup('__QUEUE_MODE__')).commit()
|
|
257
|
+
await queen.transaction().ack(MSG).commit() // hand-built, no group at all
|
|
258
|
+
for (const hit of hits) {
|
|
259
|
+
assert.deepEqual(Object.keys(hit.body.operations[0]).sort(), ['leaseId', 'partitionId', 'status', 'transactionId', 'type'])
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('an explicit group wins, as { consumerGroup } or as { group }', async () => {
|
|
265
|
+
await withQueen([success(), success()], async (queen, hits) => {
|
|
266
|
+
await queen.transaction().ack(fromGroup('workers'), 'completed', { consumerGroup: 'auditors' }).commit()
|
|
267
|
+
await queen.transaction().ack(fromGroup('workers'), 'completed', { group: 'auditors' }).commit()
|
|
268
|
+
assert.equal(hits[0].body.operations[0].consumerGroup, 'auditors')
|
|
269
|
+
assert.equal(hits[1].body.operations[0].consumerGroup, 'auditors')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('messages from different groups each keep their own', async () => {
|
|
274
|
+
await withQueen([success()], async (queen, hits) => {
|
|
275
|
+
await queen.transaction().ack([fromGroup('a', 1), fromGroup('b', 2)]).commit()
|
|
276
|
+
assert.deepEqual(hits[0].body.operations.map(op => op.consumerGroup), ['a', 'b'])
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// The lease of an ack op. A Queen 2 broker fences each ack with the leaseId
|
|
282
|
+
// the operation carries, and lends it one from requiredLeases only when every
|
|
283
|
+
// lease in the bundle is the same lease. An ack whose lease travelled in
|
|
284
|
+
// requiredLeases alone was applied unfenced in a bundle of two leases, even
|
|
285
|
+
// after that lease expired and another consumer took the message (found
|
|
286
|
+
// 2026-10-03 against 2.0.0-beta.6).
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
describe('Transaction wire — each ack op carries its own lease', () => {
|
|
290
|
+
const leased = (n, leaseId) => ({
|
|
291
|
+
transactionId: `aaaaaaaa-aaaa-7aaa-8aaa-00000000000${n}`,
|
|
292
|
+
partitionId: `pppppppp-pppp-7ppp-8ppp-00000000000${n}`,
|
|
293
|
+
...(leaseId === undefined ? {} : { leaseId })
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('a bundle spanning two leases names each lease on its own ack', async () => {
|
|
297
|
+
await withQueen([success()], async (queen, hits) => {
|
|
298
|
+
await queen.transaction()
|
|
299
|
+
.ack(leased(1, 'lease-1'))
|
|
300
|
+
.ack(leased(2, 'lease-2'))
|
|
301
|
+
.ack(leased(3))
|
|
302
|
+
.commit()
|
|
303
|
+
|
|
304
|
+
const [first, second, third] = hits[0].body.operations
|
|
305
|
+
assert.equal(first.leaseId, 'lease-1')
|
|
306
|
+
assert.equal(second.leaseId, 'lease-2')
|
|
307
|
+
assert.ok(!('leaseId' in third), 'a message without a lease adds no leaseId key')
|
|
308
|
+
assert.deepEqual(hits[0].body.requiredLeases, ['lease-1', 'lease-2'])
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('a null or empty lease adds no leaseId key either', async () => {
|
|
313
|
+
await withQueen([success()], async (queen, hits) => {
|
|
314
|
+
await queen.transaction().ack([leased(1, null), leased(2, '')]).commit()
|
|
315
|
+
for (const op of hits[0].body.operations) {
|
|
316
|
+
assert.ok(!('leaseId' in op))
|
|
317
|
+
}
|
|
318
|
+
assert.deepEqual(hits[0].body.requiredLeases, [])
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
})
|
package/test-v2/kv.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* KV integration suite (PLAN_KV_TIMERS.md §5, §8.3).
|
|
3
3
|
*
|
|
4
|
-
* Every test here is repeatable ONLY because run
|
|
5
|
-
*
|
|
4
|
+
* Every test here is repeatable ONLY because every run starts on a fresh broker
|
|
5
|
+
* (test/run.sh creates the lane's data volume empty and destroys it with
|
|
6
|
+
* `down -v`): against a broker that kept the previous run's keys, a
|
|
6
7
|
* `putIfAbsent` test is green on its first execution and red forever after,
|
|
7
|
-
* and an `incr` test accumulates between runs.
|
|
8
|
-
*
|
|
8
|
+
* and an `incr` test accumulates between runs. Keep the namespaces under
|
|
9
|
+
* `test-`, like every other fixture of the suite.
|
|
9
10
|
*
|
|
10
11
|
* And `forever` is BANNED in this file. A test that goes wrong with a TTL
|
|
11
12
|
* leaves state that expires by itself; a test that goes wrong with `forever`
|
|
12
|
-
* leaves immortal state in a shared
|
|
13
|
+
* leaves immortal state in a shared broker.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { KV_NS, sleep } from './_kvtimers.js'
|
package/test-v2/pop.js
CHANGED
|
@@ -226,11 +226,14 @@ export async function popV4DefaultOne(client) {
|
|
|
226
226
|
|
|
227
227
|
await new Promise(r => setTimeout(r, 500))
|
|
228
228
|
|
|
229
|
-
// No .partitions()
|
|
229
|
+
// No .partitions() and autopilot off → the pre-1.2 default of one
|
|
230
|
+
// partition. (With autopilot on, the SDK default, an unset width is the
|
|
231
|
+
// broker's to pick: one consumer and four ready partitions take all four.)
|
|
230
232
|
const res = await client
|
|
231
233
|
.queue(queueName)
|
|
232
234
|
.batch(100)
|
|
233
235
|
.wait(false)
|
|
236
|
+
.autopilot(false)
|
|
234
237
|
.pop()
|
|
235
238
|
|
|
236
239
|
if (res.length !== 5) {
|
|
@@ -334,3 +337,27 @@ export async function popV4SharedLeaseRenew(client) {
|
|
|
334
337
|
return { success: true, message: 'Shared leaseId renewed once and dedup confirmed' }
|
|
335
338
|
}
|
|
336
339
|
|
|
340
|
+
|
|
341
|
+
// renew() reports the broker's verdict (2026-10-02, 2.0.0-beta.6): the extend
|
|
342
|
+
// route answers HTTP 200 whether or not anything was renewed, and a lease an
|
|
343
|
+
// ack had already released came back success:true.
|
|
344
|
+
export async function renewReportsAReleasedLease(client) {
|
|
345
|
+
const queueName = 'test-queue-v2-renew-released'
|
|
346
|
+
const queue = await client.queue(queueName).config({ leaseTime: 60 }).create()
|
|
347
|
+
if (!queue.configured) {
|
|
348
|
+
return { success: false, message: 'Queue not created' }
|
|
349
|
+
}
|
|
350
|
+
await client.queue(queueName).push([{ data: { n: 1 } }])
|
|
351
|
+
|
|
352
|
+
const [message] = await client.queue(queueName).batch(1).wait(false).pop()
|
|
353
|
+
if (!message) {
|
|
354
|
+
return { success: false, message: 'Nothing popped' }
|
|
355
|
+
}
|
|
356
|
+
const live = await client.renew(message)
|
|
357
|
+
await client.ack(message, true)
|
|
358
|
+
const released = await client.renew(message)
|
|
359
|
+
|
|
360
|
+
const success = live.success === true && typeof live.newExpiresAt === 'string' &&
|
|
361
|
+
released.success === false && released.newExpiresAt === null && typeof released.error === 'string'
|
|
362
|
+
return { success, message: `live lease: ${JSON.stringify(live)}; after the ack: ${JSON.stringify(released)}` }
|
|
363
|
+
}
|
package/test-v2/run.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import pg from 'pg';
|
|
2
1
|
import { Queen } from '../client-v2/index.js'
|
|
3
2
|
import * as queueTests from './queue.js'
|
|
4
3
|
import * as pushTests from './push.js'
|
|
@@ -9,7 +8,6 @@ import * as dlqTests from './dlq.js'
|
|
|
9
8
|
import * as completeTests from './complete.js'
|
|
10
9
|
import * as transactionTests from './transaction.js'
|
|
11
10
|
import * as subscriptionTests from './subscription.js'
|
|
12
|
-
import * as maintenanceTests from './maintenance.js'
|
|
13
11
|
import * as retentionTests from './retention.js'
|
|
14
12
|
import * as bootstrapTests from './bootstrap.js'
|
|
15
13
|
import * as loggerTests from './logger.js'
|
|
@@ -28,46 +26,43 @@ import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
|
28
26
|
|
|
29
27
|
export const TEST_CONFIG_SINGLE = {
|
|
30
28
|
baseUrls: [process.env.QUEEN_SERVER_URL || 'http://localhost:6632'],
|
|
31
|
-
loadBalancingStrategy: 'affinity'
|
|
32
|
-
dbConfig: {
|
|
33
|
-
host: process.env.PG_HOST || 'localhost',
|
|
34
|
-
port: process.env.PG_PORT || 5432,
|
|
35
|
-
database: process.env.PG_DB || 'postgres',
|
|
36
|
-
user: process.env.PG_USER || 'postgres',
|
|
37
|
-
password: process.env.PG_PASSWORD || 'postgres'
|
|
38
|
-
}
|
|
29
|
+
loadBalancingStrategy: 'affinity'
|
|
39
30
|
};
|
|
40
31
|
|
|
41
32
|
export const TEST_CONFIG_MULTIPLE = {
|
|
42
33
|
baseUrls: ['http://localhost:6632','http://localhost:6633'],
|
|
43
|
-
loadBalancingStrategy: 'round-robin'
|
|
44
|
-
dbConfig: {
|
|
45
|
-
host: process.env.PG_HOST || 'localhost',
|
|
46
|
-
port: process.env.PG_PORT || 5432,
|
|
47
|
-
database: process.env.PG_DB || 'postgres',
|
|
48
|
-
user: process.env.PG_USER || 'postgres',
|
|
49
|
-
password: process.env.PG_PASSWORD || 'postgres'
|
|
50
|
-
}
|
|
34
|
+
loadBalancingStrategy: 'round-robin'
|
|
51
35
|
};
|
|
52
36
|
|
|
53
37
|
export const TEST_CONFIG = process.env.TEST_CONFIG === 'multiple' ? TEST_CONFIG_MULTIPLE : TEST_CONFIG_SINGLE;
|
|
54
38
|
console.log('TEST_CONFIG:', TEST_CONFIG);
|
|
55
39
|
|
|
56
40
|
// Global test state
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
41
|
+
let activeClient;
|
|
42
|
+
|
|
43
|
+
// The suite talks to the broker through its public HTTP API only: there is no
|
|
44
|
+
// database and no cleanup step. test/run.sh gives every lane a FRESH broker
|
|
45
|
+
// (its data volume is created empty and destroyed with `down -v`), so the
|
|
46
|
+
// suite starts on an empty store. A second run against the same broker is not
|
|
47
|
+
// expected to be green: fixed-name fixtures (docs.js's fixed transactionId,
|
|
48
|
+
// the kv and timer suites) would find the first run's state.
|
|
49
|
+
//
|
|
50
|
+
// The preflight makes a missing or unhealthy broker fail the run up front,
|
|
51
|
+
// with the reason, instead of as a wall of per-test connection errors.
|
|
52
|
+
async function preflightBroker(baseUrl) {
|
|
53
|
+
const url = `${baseUrl}/health`
|
|
54
|
+
let res
|
|
55
|
+
try {
|
|
56
|
+
res = await fetch(url, { signal: AbortSignal.timeout(5000) })
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new Error(`broker preflight ${url}: ${e.cause?.message || e.message}`)
|
|
59
|
+
}
|
|
60
|
+
// Drain the body so the connection is released either way.
|
|
61
|
+
await res.arrayBuffer().catch(() => {})
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
throw new Error(`broker preflight ${url}: HTTP ${res.status}`)
|
|
64
|
+
}
|
|
65
|
+
log(true, `Broker preflight ${url}: HTTP ${res.status}`)
|
|
71
66
|
}
|
|
72
67
|
|
|
73
68
|
function log (success, ...args) {
|
|
@@ -92,78 +87,13 @@ function printResults() {
|
|
|
92
87
|
console.log('='.repeat(80))
|
|
93
88
|
}
|
|
94
89
|
|
|
95
|
-
export const cleanupTestData = async () => {
|
|
96
|
-
// All the LIKE patterns test queues use. The three exact names are the
|
|
97
|
-
// documentation queues (test-v2/docs.js): purging them here is what lets
|
|
98
|
-
// the published dedup snippet keep a fixed transactionId across runs.
|
|
99
|
-
const patterns = ['test-%', 'edge-%', 'pattern-%', 'workflow-%', 'orders', 'payments', 'invoices'];
|
|
100
|
-
try {
|
|
101
|
-
// Drop streaming queries first (CASCADE removes their state rows).
|
|
102
|
-
// Safe even when queen_streams isn't installed yet — we swallow the
|
|
103
|
-
// error if the schema doesn't exist.
|
|
104
|
-
try {
|
|
105
|
-
await dbPool.query(`DELETE FROM queen_streams.queries WHERE name LIKE 'test-%'`);
|
|
106
|
-
} catch (e) {
|
|
107
|
-
// queen_streams schema not installed — ignore.
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Queue identity is now the queen.queues id (log_queues was merged
|
|
111
|
-
// away): log_partitions, consumer_watermarks, consumer_groups_metadata
|
|
112
|
-
// and queue_lag_metrics all cascade from the queues row. Only log_txns
|
|
113
|
-
// and log_dlq have NO foreign key by design, so they get an explicit
|
|
114
|
-
// purge keyed via log_partitions first. Without it, every suite run
|
|
115
|
-
// inherits the previous run's messages and dedup window entries —
|
|
116
|
-
// fixed-transactionId tests report 'duplicate' on their FIRST push.
|
|
117
|
-
try {
|
|
118
|
-
await dbPool.query(`
|
|
119
|
-
WITH parts AS (
|
|
120
|
-
SELECT lp.id FROM queen.log_partitions lp
|
|
121
|
-
JOIN queen.queues q ON q.id = lp.queue_id
|
|
122
|
-
WHERE q.name LIKE ANY($1::text[])
|
|
123
|
-
),
|
|
124
|
-
d1 AS (DELETE FROM queen.log_txns WHERE partition_id IN (SELECT id FROM parts)),
|
|
125
|
-
d2 AS (DELETE FROM queen.log_dlq WHERE partition_id IN (SELECT id FROM parts))
|
|
126
|
-
SELECT 1`, [patterns]);
|
|
127
|
-
} catch (e) {
|
|
128
|
-
// Log-engine schema not installed (rows-only server) — ignore.
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
await dbPool.query(`DELETE FROM queen.queues WHERE name LIKE ANY($1::text[])`, [patterns]);
|
|
132
|
-
|
|
133
|
-
// KV keys and pending timers (PLAN_KV_TIMERS.md §10.4). NOT cosmetic:
|
|
134
|
-
// without this purge a putIfAbsent test is green on its first run and red
|
|
135
|
-
// forever after, an incr test accumulates between runs, and a timer left
|
|
136
|
-
// pending by an earlier run fires into a later one and shows up as a
|
|
137
|
-
// phantom message in an unrelated test. Neither table has a foreign key
|
|
138
|
-
// to queen.queues -- log_timers is keyed by NAMES on purpose -- so the
|
|
139
|
-
// queue delete above does not reach them.
|
|
140
|
-
//
|
|
141
|
-
// Both are deleted across every tenant: a test rig may run with
|
|
142
|
-
// QUEEN_TENANCY_HEADER on, and the rows to purge are identified by the
|
|
143
|
-
// test naming convention, never by tenant.
|
|
144
|
-
//
|
|
145
|
-
// These two used to be wrapped in a swallowing try/catch, on the grounds
|
|
146
|
-
// that a broker booted with the kv/timer flags off had never applied
|
|
147
|
-
// 024_kv.sql / 025_timers.sql. There are no such flags: schema.rs applies
|
|
148
|
-
// both on every boot, so a missing `queen.kv` or `queen.log_timers` is a
|
|
149
|
-
// broken rig and must be loud. Swallowing it would leave the purge silently
|
|
150
|
-
// undone, which is exactly the failure the purge exists to prevent -- a
|
|
151
|
-
// putIfAbsent test green on its first run and red forever after.
|
|
152
|
-
await dbPool.query(`DELETE FROM queen.kv WHERE namespace LIKE ANY($1::text[])`, [patterns]);
|
|
153
|
-
await dbPool.query(`DELETE FROM queen.log_timers WHERE queue LIKE ANY($1::text[])`, [patterns]);
|
|
154
|
-
|
|
155
|
-
log(true, 'Test data cleaned up (rows + segments + kv + timers)');
|
|
156
|
-
} catch (error) {
|
|
157
|
-
log(false, `Cleanup error: ${error.message}`);
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
|
|
161
90
|
async function main() {
|
|
162
91
|
const client = new Queen({
|
|
163
92
|
urls: TEST_CONFIG.baseUrls,
|
|
164
93
|
loadBalancingStrategy: TEST_CONFIG.loadBalancingStrategy
|
|
165
94
|
})
|
|
166
|
-
|
|
95
|
+
activeClient = client
|
|
96
|
+
await preflightBroker(TEST_CONFIG.baseUrls[0])
|
|
167
97
|
|
|
168
98
|
// Separate human and AI tests
|
|
169
99
|
const humanTests = [
|
|
@@ -177,7 +107,6 @@ async function main() {
|
|
|
177
107
|
transactionTests,
|
|
178
108
|
subscriptionTests,
|
|
179
109
|
retentionTests,
|
|
180
|
-
maintenanceTests,
|
|
181
110
|
bootstrapTests,
|
|
182
111
|
loggerTests,
|
|
183
112
|
watermarkTests,
|
|
@@ -194,7 +123,7 @@ async function main() {
|
|
|
194
123
|
]
|
|
195
124
|
|
|
196
125
|
// Streaming tests (queen-streams). Run via `node run.js stream`.
|
|
197
|
-
// Require
|
|
126
|
+
// Require a broker that serves the /streams/v1 routes.
|
|
198
127
|
const streamGroupTests = [streamTests]
|
|
199
128
|
|
|
200
129
|
const allTests = [...humanTests, ...aiTests, ...streamGroupTests]
|
|
@@ -240,8 +169,7 @@ async function main() {
|
|
|
240
169
|
humanTestFunctions.forEach(t => console.log(` - ${t.name}`))
|
|
241
170
|
console.log('\n🌊 queen-streams tests:')
|
|
242
171
|
streamTestFunctions.forEach(t => console.log(` - ${t.name}`))
|
|
243
|
-
|
|
244
|
-
process.exit(1)
|
|
172
|
+
return 1
|
|
245
173
|
}
|
|
246
174
|
testsToRun = [testFunc]
|
|
247
175
|
mode = 'single'
|
|
@@ -250,9 +178,6 @@ async function main() {
|
|
|
250
178
|
log(true, `Running all tests (${allTestFunctions.length} tests)...`)
|
|
251
179
|
}
|
|
252
180
|
|
|
253
|
-
// Cleanup test data
|
|
254
|
-
await cleanupTestData()
|
|
255
|
-
|
|
256
181
|
for (const test of testsToRun) {
|
|
257
182
|
try {
|
|
258
183
|
console.log('Running test:', test.name)
|
|
@@ -279,17 +204,28 @@ async function main() {
|
|
|
279
204
|
console.log('\n💡 Tip: Run "node run.js" to test all tests')
|
|
280
205
|
}
|
|
281
206
|
|
|
282
|
-
//await cleanupTestData()
|
|
283
|
-
await closeDb()
|
|
284
|
-
// Queen.close() destroys the per-client HTTP agent, releasing keep-alive
|
|
285
|
-
// sockets so the loop can drain; the explicit exit code is for CI.
|
|
286
|
-
await client.close()
|
|
287
207
|
const failedCount = testResults.filter(x => !x.success).length
|
|
288
|
-
|
|
208
|
+
return failedCount > 0 ? 1 : 0
|
|
289
209
|
}
|
|
290
210
|
|
|
211
|
+
let exitCode = 1
|
|
291
212
|
try {
|
|
292
|
-
await main()
|
|
213
|
+
exitCode = await main()
|
|
293
214
|
} catch (error) {
|
|
294
215
|
log(false, 'Main error:', error.message)
|
|
295
|
-
}
|
|
216
|
+
} finally {
|
|
217
|
+
// Always release the client. Previously, an init failure skipped this
|
|
218
|
+
// teardown and the outer catch returned a successful process status,
|
|
219
|
+
// allowing a broken integration lane to appear green.
|
|
220
|
+
const cleanupResults = await Promise.allSettled([
|
|
221
|
+
activeClient ? activeClient.close() : Promise.resolve()
|
|
222
|
+
])
|
|
223
|
+
for (const result of cleanupResults) {
|
|
224
|
+
if (result.status === 'rejected') {
|
|
225
|
+
exitCode = 1
|
|
226
|
+
log(false, 'Cleanup error:', result.reason?.message || String(result.reason))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
process.exit(exitCode)
|