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/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queen-mq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Partitioned message queue on PostgreSQL — broker client + fluent streaming SDK (windows, joins, gates) in one package",
|
|
6
6
|
"main": "client-v2/index.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "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 && 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",
|
|
8
|
+
"test": "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/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js && 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/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"axios": "^1.12.2",
|
|
22
22
|
"pg": "^8.16.3",
|
|
23
|
+
"undici": "^6.21.0",
|
|
23
24
|
"uuid": "^13.0.0"
|
|
24
25
|
},
|
|
25
26
|
"engines": {
|
|
@@ -27,16 +28,24 @@
|
|
|
27
28
|
},
|
|
28
29
|
"author": "Smartness",
|
|
29
30
|
"license": "Apache-2.0",
|
|
31
|
+
"homepage": "https://queenmq.com",
|
|
30
32
|
"repository": {
|
|
31
33
|
"type": "git",
|
|
32
34
|
"url": "https://github.com/queen-mq/queen"
|
|
33
35
|
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/queen-mq/queen/issues"
|
|
38
|
+
},
|
|
34
39
|
"keywords": [
|
|
35
40
|
"message-queue",
|
|
36
41
|
"queue",
|
|
42
|
+
"postgres",
|
|
43
|
+
"postgresql",
|
|
37
44
|
"broker",
|
|
38
45
|
"message-queue-system",
|
|
39
46
|
"fifo",
|
|
47
|
+
"consumer-groups",
|
|
48
|
+
"dead-letter-queue",
|
|
40
49
|
"streaming",
|
|
41
50
|
"stream-processing",
|
|
42
51
|
"rate-limiter",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the kv and timers integration suites.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT registered in run.js: that runner treats every export of a
|
|
5
|
+
* registered module as a test function, so a helper exported from kv.js would
|
|
6
|
+
* be executed as a test and reported as one.
|
|
7
|
+
*
|
|
8
|
+
* WHY THERE IS NO AVAILABILITY PROBE HERE ANY MORE. There used to be one, plus
|
|
9
|
+
* a `skipped()` verdict, because `QUEEN_KV_ENABLED` and `QUEEN_TIMERS_ENABLED`
|
|
10
|
+
* were boot flags that defaulted to false: with them off the routes were not
|
|
11
|
+
* registered and every call here answered 404, so a red suite was the DEFAULT
|
|
12
|
+
* configuration's own fault and the skip existed to stop that training everyone
|
|
13
|
+
* to ignore the colour.
|
|
14
|
+
*
|
|
15
|
+
* Those flags are gone. Kv and timers are not features, they are the broker:
|
|
16
|
+
* there is no `QUEEN_PUSH_ENABLED` either. Every cell that runs this binary has
|
|
17
|
+
* both surfaces, so these suites RUN, and a 404 from any of them is a bug in
|
|
18
|
+
* the broker, not a configuration to detect. A test that skips is a test that
|
|
19
|
+
* says nothing, and that was only tolerable while the 404 was legitimate.
|
|
20
|
+
*
|
|
21
|
+
* What still exists is the operator's RUNTIME kill switch (`kv_enabled`,
|
|
22
|
+
* `timers_schedule_enabled`, `timers_fire_enabled` in `queen.system_state`) --
|
|
23
|
+
* the maintenance-mode lever, pulled live during an incident. It answers 503
|
|
24
|
+
* with `Retry-After` on the kv/timer routes and 403 on a `kv`/`timers` rider
|
|
25
|
+
* inside a transaction, never 404. If a run here ever goes red against that,
|
|
26
|
+
* the switch is down on the rig and somebody pulled it; it is not something
|
|
27
|
+
* these tests should paper over.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const KV_NS = 'test-kv'
|
|
31
|
+
export const TIMER_QUEUE = 'test-timers'
|
|
32
|
+
|
|
33
|
+
export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Poll a queue until `match` accepts a message, or the deadline passes.
|
|
37
|
+
*
|
|
38
|
+
* WHY THIS IS A POLL AND NOT A LONG POLL, MEASURED ON A REAL RIG. A timer's
|
|
39
|
+
* delivery is committed by the sweeper INSIDE PostgreSQL
|
|
40
|
+
* (`log_timers_fire_v1` = DELETE + push in one transaction), so it does not
|
|
41
|
+
* pass through the broker's push handler and the destination partition is not
|
|
42
|
+
* marked ready in that broker's hot list. A consumer therefore sees the
|
|
43
|
+
* message only at the next hot-list reseed: on a default broker
|
|
44
|
+
* (QUEEN_HOTLIST_RESEED_MS = 30000) a timer scheduled for +300 ms was measured
|
|
45
|
+
* arriving at +30.7 s, and a 20 s long poll timed out with the message already
|
|
46
|
+
* committed in the log.
|
|
47
|
+
*
|
|
48
|
+
* That is a broker property, not a client one, and it is why these tests
|
|
49
|
+
* assert ARRIVAL and never latency: `deliverAt` is "not before", never
|
|
50
|
+
* "exactly at". The deadline here is generous on purpose -- it is sized
|
|
51
|
+
* against the reseed period, not against the delay under test.
|
|
52
|
+
*/
|
|
53
|
+
export async function popUntil(client, queueName, match, timeoutMs = 60000) {
|
|
54
|
+
const deadline = Date.now() + timeoutMs
|
|
55
|
+
for (;;) {
|
|
56
|
+
const messages = await client.queue(queueName).batch(10).wait(false).pop()
|
|
57
|
+
const hit = messages.filter(match)
|
|
58
|
+
if (hit.length > 0) return hit
|
|
59
|
+
// Anything else in this queue is a stranger -- a delivery left behind by an
|
|
60
|
+
// earlier run of this same test. It is ACKED rather than ignored, and that
|
|
61
|
+
// is not tidiness: queue-mode delivery is ordered, so an unacked stranger
|
|
62
|
+
// holds its lease and blocks the cursor, and the message under test would
|
|
63
|
+
// never be reached inside any deadline. This is the shape of the failure
|
|
64
|
+
// that a "wait longer" would never have fixed.
|
|
65
|
+
for (const stranger of messages) {
|
|
66
|
+
await client.ack(stranger)
|
|
67
|
+
}
|
|
68
|
+
if (Date.now() > deadline) return []
|
|
69
|
+
await sleep(500)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ack-window honesty tests (2026-07-30).
|
|
3
|
+
*
|
|
4
|
+
* queen.log_ack_by_hash_v1 resolves txn hashes through the queen.log_txns
|
|
5
|
+
* sidecar, which is purged on its own clock (GREATEST(dedup_window,
|
|
6
|
+
* completed_retention, 900s)). A hash that cannot be resolved — purged row,
|
|
7
|
+
* or a transactionId that never existed — is correctly NOT acked (the cursor
|
|
8
|
+
* stops, frames redeliver: redelivery over loss). The BUG these tests pin:
|
|
9
|
+
* the broker reported those items as success=true (they appear in neither
|
|
10
|
+
* noopHashes nor staleHashes), so the client believed the ack landed while
|
|
11
|
+
* the cursor never moved — a silent redelivery livelock, and a nack/dlq in
|
|
12
|
+
* that state could never dead-letter its poison message.
|
|
13
|
+
*
|
|
14
|
+
* Expected contract (post-fix): unresolvable items come back success=false
|
|
15
|
+
* with an explicit "unresolvable" error, and the cursor is untouched.
|
|
16
|
+
*
|
|
17
|
+
* Companion test: emptyPartitionCursorSealsAfterRetention pins the empty-
|
|
18
|
+
* partition cursor seal in log_pop_v1 (the Rust port of the C++
|
|
19
|
+
* pop_unified_batch_v4 starvation fix): a partition whose segments were all
|
|
20
|
+
* removed by retention must stop being "phantom pending" after one pop.
|
|
21
|
+
*
|
|
22
|
+
* Run: node run.js ackUnknownTxnMustFail
|
|
23
|
+
* node run.js ackAfterHashPurgeMustFailExplicitly
|
|
24
|
+
* node run.js emptyPartitionCursorSealsAfterRetention
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { dbPool } from './run.js'
|
|
28
|
+
|
|
29
|
+
const TENANT = '00000000-0000-0000-0000-000000000001'
|
|
30
|
+
|
|
31
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
32
|
+
|
|
33
|
+
async function popRetry(client, queue, { batch = 3, group = null, partition = null, tries = 30, mode = null } = {}) {
|
|
34
|
+
for (let i = 0; i < tries; i++) {
|
|
35
|
+
let b = client.queue(queue).batch(batch).wait(false)
|
|
36
|
+
if (group) b = b.group(group)
|
|
37
|
+
if (partition) b = b.partition(partition)
|
|
38
|
+
if (mode) b = b.subscriptionMode(mode)
|
|
39
|
+
const msgs = await b.pop()
|
|
40
|
+
if (msgs && msgs.length > 0) return msgs
|
|
41
|
+
await sleep(150)
|
|
42
|
+
}
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function partitionRow(queue, partition = 'Default') {
|
|
47
|
+
const r = await dbPool.query(
|
|
48
|
+
`SELECT p.id::text AS id, p.last_offset::bigint AS last_offset, p.log_start::bigint AS log_start
|
|
49
|
+
FROM queen.log_partitions p
|
|
50
|
+
JOIN queen.queues lq ON lq.id = p.queue_id
|
|
51
|
+
WHERE lq.name = $1 AND p.name = $2 AND lq.tenant_id = $3::uuid`,
|
|
52
|
+
[queue, partition, TENANT])
|
|
53
|
+
return r.rows[0] || null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function committedOf(partitionId, group) {
|
|
57
|
+
const r = await dbPool.query(
|
|
58
|
+
`SELECT c.committed::bigint AS committed FROM queen.log_consumers c
|
|
59
|
+
WHERE c.partition_id = $1::uuid AND c.consumer_group = $2`,
|
|
60
|
+
[partitionId, group])
|
|
61
|
+
return r.rows.length ? Number(r.rows[0].committed) : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function segmentCount(partitionId) {
|
|
65
|
+
const r = await dbPool.query(
|
|
66
|
+
`SELECT count(*)::int AS n FROM queen.log_segments WHERE partition_id = $1::uuid`,
|
|
67
|
+
[partitionId])
|
|
68
|
+
return r.rows[0].n
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Test A (wire-only): acking a transactionId that never existed must fail
|
|
73
|
+
* explicitly, for both a completed ack and a failed nack. Today the broker
|
|
74
|
+
* answers success=true for both.
|
|
75
|
+
*/
|
|
76
|
+
export async function ackUnknownTxnMustFail(client) {
|
|
77
|
+
const queue = 'test-queue-v2-ackwindow-unknown'
|
|
78
|
+
await client.queue(queue).create()
|
|
79
|
+
await client.queue(queue).partition('Default').push([
|
|
80
|
+
{ data: { n: 1 }, transactionId: `${queue}-tx-1` },
|
|
81
|
+
{ data: { n: 2 }, transactionId: `${queue}-tx-2` },
|
|
82
|
+
{ data: { n: 3 }, transactionId: `${queue}-tx-3` },
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
const msgs = await popRetry(client, queue, { batch: 3 })
|
|
86
|
+
if (msgs.length !== 3) return { success: false, message: `expected 3 messages, got ${msgs.length}` }
|
|
87
|
+
|
|
88
|
+
const ghost = {
|
|
89
|
+
transactionId: `${queue}-ghost-never-pushed`,
|
|
90
|
+
partitionId: msgs[0].partitionId,
|
|
91
|
+
leaseId: msgs[0].leaseId,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Completed ack of a nonexistent txn: must NOT be reported as success.
|
|
95
|
+
const r1 = await client.ack([ghost], true)
|
|
96
|
+
const item1 = (r1.results || [])[0] || {}
|
|
97
|
+
if (r1.success !== false || item1.success !== false) {
|
|
98
|
+
return { success: false, message: `BUG: completed ack of a never-pushed txn reported success (overall=${r1.success}, item=${item1.success})` }
|
|
99
|
+
}
|
|
100
|
+
if (!/unresolv/i.test(item1.error || '')) {
|
|
101
|
+
return { success: false, message: `unknown-txn ack rejected with wrong error: '${item1.error}'` }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Failed nack of a nonexistent txn: must NOT be swallowed as success
|
|
105
|
+
// (pre-fix this was the worst variant: the nack vanished — no retry
|
|
106
|
+
// charge, no DLQ — while the client was told ok).
|
|
107
|
+
const r2 = await client.ack([{ ...ghost }], false)
|
|
108
|
+
const item2 = (r2.results || [])[0] || {}
|
|
109
|
+
if (r2.success !== false || item2.success !== false) {
|
|
110
|
+
return { success: false, message: `BUG: failed nack of a never-pushed txn reported success (overall=${r2.success}, item=${item2.success})` }
|
|
111
|
+
}
|
|
112
|
+
if (!/unresolv/i.test(item2.error || '')) {
|
|
113
|
+
return { success: false, message: `unknown-txn nack rejected with wrong error: '${item2.error}'` }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The real batch must still be ackable (the lease survived the rejected calls).
|
|
117
|
+
const r3 = await client.ack(msgs, true)
|
|
118
|
+
if (r3.success !== true) {
|
|
119
|
+
return { success: false, message: `real batch ack failed after ghost-ack rejections: ${r3.error}` }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { success: true, message: 'unknown-txn ack and nack both rejected explicitly; real batch still ackable' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Test B (with SQL): the real-world shape — messages still delivered (segments
|
|
127
|
+
* intact) but their log_txns hash rows purged. Per-message acks must fail
|
|
128
|
+
* explicitly and the cursor must not move. Pre-fix: every ack reported
|
|
129
|
+
* success=true while committed stayed put (silent livelock).
|
|
130
|
+
*/
|
|
131
|
+
export async function ackAfterHashPurgeMustFailExplicitly(client) {
|
|
132
|
+
const queue = 'test-queue-v2-ackwindow-purged'
|
|
133
|
+
await client.queue(queue).create()
|
|
134
|
+
const txns = [1, 2, 3, 4, 5].map(n => `${queue}-tx-${n}`)
|
|
135
|
+
await client.queue(queue).partition('Default').push(
|
|
136
|
+
txns.map((t, i) => ({ data: { n: i }, transactionId: t })))
|
|
137
|
+
|
|
138
|
+
// Wait until the segments are visible, then purge the hash sidecar —
|
|
139
|
+
// exactly what log_txns_purge_step_v1 does after the window expires
|
|
140
|
+
// (the 900s floor makes the real purge untestable in-suite).
|
|
141
|
+
const part = await partitionRow(queue)
|
|
142
|
+
if (!part) return { success: false, message: 'partition row not found' }
|
|
143
|
+
for (let i = 0; i < 30 && (await segmentCount(part.id)) === 0; i++) await sleep(100)
|
|
144
|
+
|
|
145
|
+
const del = await dbPool.query(
|
|
146
|
+
`DELETE FROM queen.log_txns WHERE partition_id = $1::uuid`, [part.id])
|
|
147
|
+
if (del.rowCount === 0) return { success: false, message: 'no log_txns rows to purge — test setup broken' }
|
|
148
|
+
|
|
149
|
+
// Segments untouched: the messages still DELIVER.
|
|
150
|
+
const msgs = await popRetry(client, queue, { batch: 5 })
|
|
151
|
+
if (msgs.length !== 5) return { success: false, message: `expected 5 delivered messages after purge, got ${msgs.length}` }
|
|
152
|
+
|
|
153
|
+
// Per-message completed ack (the JS per-message consumer shape): the hash
|
|
154
|
+
// cannot resolve, so the honest answer is an explicit failure.
|
|
155
|
+
const r1 = await client.ack([msgs[0]], true)
|
|
156
|
+
const item1 = (r1.results || [])[0] || {}
|
|
157
|
+
if (item1.success !== false || !/unresolv/i.test(item1.error || '')) {
|
|
158
|
+
return { success: false, message: `BUG: ack with purged hash answered success=${item1.success}, error='${item1.error}'` }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Nack of a purged-hash message: same — and pre-fix this also meant the
|
|
162
|
+
// poison could never reach the DLQ.
|
|
163
|
+
const r2 = await client.ack([msgs[1]], false)
|
|
164
|
+
const item2 = (r2.results || [])[0] || {}
|
|
165
|
+
if (item2.success !== false || !/unresolv/i.test(item2.error || '')) {
|
|
166
|
+
return { success: false, message: `BUG: nack with purged hash answered success=${item2.success}, error='${item2.error}'` }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The cursor must not have moved (redelivery over loss — that part of the
|
|
170
|
+
// contract was always right; only the reporting lied).
|
|
171
|
+
const committed = await committedOf(part.id, '__QUEUE_MODE__')
|
|
172
|
+
if (committed !== -1) {
|
|
173
|
+
return { success: false, message: `cursor moved on unresolvable acks: committed=${committed}, expected -1` }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { success: true, message: 'purged-hash ack and nack rejected explicitly, cursor untouched, messages still deliverable' }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Test C (bug 2, with SQL): empty-partition cursor seal. A group consumes
|
|
181
|
+
* part of a partition, retention then deletes ALL segments (time rule deletes
|
|
182
|
+
* unconsumed data too): last_offset > committed with zero segments = a
|
|
183
|
+
* phantom-pending row that used to stay a wildcard candidate forever and
|
|
184
|
+
* pinned the empty-scan watermark at epoch. After the fix, one empty pop
|
|
185
|
+
* seals committed to the partition's last_offset. A delayed-processing queue
|
|
186
|
+
* pins the guard: segments EXIST but are deferred → no seal.
|
|
187
|
+
*/
|
|
188
|
+
export async function emptyPartitionCursorSealsAfterRetention(client) {
|
|
189
|
+
const queue = 'test-queue-v2-ackwindow-seal'
|
|
190
|
+
const group = 'g-seal'
|
|
191
|
+
await client.queue(queue).config({ retentionEnabled: true, retentionSeconds: 1, leaseTime: 30 }).create()
|
|
192
|
+
await client.queue(queue).partition('Default').push(
|
|
193
|
+
[0, 1, 2, 3, 4, 5].map(n => ({ data: { n }, transactionId: `${queue}-tx-${n}` })))
|
|
194
|
+
|
|
195
|
+
// Consume + ack only the first two: committed lands at 1, the rest stays.
|
|
196
|
+
// First contact for g-seal, and the 6 messages are already pushed: the
|
|
197
|
+
// group has to ask for the backlog it is about to half-consume.
|
|
198
|
+
const first = await popRetry(client, queue, { batch: 2, group, mode: 'all' })
|
|
199
|
+
if (first.length !== 2) return { success: false, message: `expected 2 messages, got ${first.length}` }
|
|
200
|
+
const ackr = await client.ack(first, true, { group })
|
|
201
|
+
if (ackr.success !== true) return { success: false, message: `ack failed: ${ackr.error}` }
|
|
202
|
+
|
|
203
|
+
const part = await partitionRow(queue)
|
|
204
|
+
if (!part) return { success: false, message: 'partition row not found' }
|
|
205
|
+
|
|
206
|
+
// Retention (interval 2s in the harness, cutoff 1s) must delete ALL
|
|
207
|
+
// segments — the time rule ignores cursors.
|
|
208
|
+
let segs = -1
|
|
209
|
+
for (let i = 0; i < 60; i++) {
|
|
210
|
+
segs = await segmentCount(part.id)
|
|
211
|
+
if (segs === 0) break
|
|
212
|
+
await sleep(500)
|
|
213
|
+
}
|
|
214
|
+
if (segs !== 0) return { success: false, message: `retention never emptied the partition (segments=${segs})` }
|
|
215
|
+
|
|
216
|
+
const before = await committedOf(part.id, group)
|
|
217
|
+
const rowNow = await partitionRow(queue)
|
|
218
|
+
if (before === null) return { success: false, message: 'consumer row missing before the empty pop' }
|
|
219
|
+
if (before >= Number(rowNow.last_offset)) {
|
|
220
|
+
return { success: false, message: `setup broken: no phantom span (committed=${before}, last_offset=${rowNow.last_offset})` }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ONE empty pop on the emptied partition must seal the cursor.
|
|
224
|
+
const empty = await client.queue(queue).partition('Default').group(group).batch(1).wait(false).pop()
|
|
225
|
+
if (empty && empty.length > 0) return { success: false, message: `expected an empty pop, got ${empty.length} messages` }
|
|
226
|
+
|
|
227
|
+
const after = await committedOf(part.id, group)
|
|
228
|
+
if (after !== Number(rowNow.last_offset)) {
|
|
229
|
+
return { success: false, message: `BUG: empty pop did not seal the cursor (committed=${after}, want last_offset=${rowNow.last_offset}) — partition stays phantom-pending forever` }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Guard: delayed messages are NOT sealed over. Segments exist but are
|
|
233
|
+
// deferred; the cursor must stay put so they deliver when eligible.
|
|
234
|
+
const dqueue = 'test-queue-v2-ackwindow-seal-delayed'
|
|
235
|
+
await client.queue(dqueue).config({ delayedProcessing: 30, leaseTime: 30 }).create()
|
|
236
|
+
await client.queue(dqueue).partition('Default').push([
|
|
237
|
+
{ data: { n: 0 }, transactionId: `${dqueue}-tx-0` },
|
|
238
|
+
{ data: { n: 1 }, transactionId: `${dqueue}-tx-1` },
|
|
239
|
+
])
|
|
240
|
+
// Give the fusion flush a moment so the segments exist before the pop.
|
|
241
|
+
const dpart0 = await (async () => {
|
|
242
|
+
for (let i = 0; i < 30; i++) {
|
|
243
|
+
const p = await partitionRow(dqueue)
|
|
244
|
+
if (p && (await segmentCount(p.id)) > 0) return p
|
|
245
|
+
await sleep(100)
|
|
246
|
+
}
|
|
247
|
+
return null
|
|
248
|
+
})()
|
|
249
|
+
if (!dpart0) return { success: false, message: 'delayed queue segments never appeared' }
|
|
250
|
+
|
|
251
|
+
// First contact for g-seal on THIS queue too (metadata is queue-scoped), and
|
|
252
|
+
// the 2 delayed messages are already pushed: under the default 'new' the
|
|
253
|
+
// cursor would seed at last_offset and the guard below could not tell a seed
|
|
254
|
+
// from a seal. 'all' seeds at -1, so a non -1 cursor can only be the seal.
|
|
255
|
+
const dempty = await client.queue(dqueue).partition('Default').group(group).subscriptionMode('all').batch(1).wait(false).pop()
|
|
256
|
+
if (dempty && dempty.length > 0) return { success: false, message: 'delayed message delivered early?' }
|
|
257
|
+
const dcommitted = await committedOf(dpart0.id, group)
|
|
258
|
+
const dsegs = await segmentCount(dpart0.id)
|
|
259
|
+
if (dsegs === 0) return { success: false, message: 'delayed queue segments vanished' }
|
|
260
|
+
if (dcommitted !== -1 && dcommitted !== null) {
|
|
261
|
+
return { success: false, message: `BUG: seal fired over deferred-but-present segments (committed=${dcommitted}) — delayed messages would be skipped` }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { success: true, message: 'empty partition sealed after one pop; deferred segments not sealed over' }
|
|
265
|
+
}
|
package/test-v2/auth.js
CHANGED
|
@@ -19,9 +19,8 @@
|
|
|
19
19
|
// unattended in either server configuration.
|
|
20
20
|
|
|
21
21
|
import crypto from 'crypto'
|
|
22
|
-
import { dbPool } from './run.js'
|
|
23
22
|
|
|
24
|
-
const SERVER_URL = process.env.QUEEN_URL || 'http://localhost:6632'
|
|
23
|
+
const SERVER_URL = process.env.QUEEN_URL || process.env.QUEEN_SERVER_URL || 'http://localhost:6632'
|
|
25
24
|
const JWT_SECRET = process.env.JWT_SECRET || ''
|
|
26
25
|
const JWT_ENABLED = JWT_SECRET.length > 0
|
|
27
26
|
|
|
@@ -50,23 +49,6 @@ function makeToken(sub, role = 'read-write') {
|
|
|
50
49
|
)
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Ground-truth helper: read producer_sub directly from Postgres, bypassing
|
|
55
|
-
// the API so we can tell the difference between a server that actually stores
|
|
56
|
-
// the value and one that just echoes the client's input.
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
async function getStoredProducerSub(queueName, transactionId) {
|
|
59
|
-
const res = await dbPool.query(
|
|
60
|
-
`SELECT m.producer_sub
|
|
61
|
-
FROM queen.messages m
|
|
62
|
-
JOIN queen.partitions p ON p.id = m.partition_id
|
|
63
|
-
JOIN queen.queues q ON q.id = p.queue_id
|
|
64
|
-
WHERE q.name = $1 AND m.transaction_id = $2`,
|
|
65
|
-
[queueName, transactionId]
|
|
66
|
-
)
|
|
67
|
-
return res.rows.length > 0 ? res.rows[0].producer_sub : null
|
|
68
|
-
}
|
|
69
|
-
|
|
70
52
|
// Raw HTTP push so we can control the Authorization header and inject
|
|
71
53
|
// arbitrary extra fields (like a forged `producerSub`) per-test.
|
|
72
54
|
async function httpPush({ queue, partition = 'Default', transactionId, data, bearerToken, extraItemFields = {} }) {
|
|
@@ -110,132 +92,51 @@ async function httpConfigure(queue, bearerToken) {
|
|
|
110
92
|
}
|
|
111
93
|
}
|
|
112
94
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
partition_name: 'Default',
|
|
129
|
-
consumer_group: '__QUEUE_MODE__',
|
|
130
|
-
lease_seconds: 60,
|
|
131
|
-
worker_id: `test-worker-${Date.now()}`,
|
|
132
|
-
batch_size: 10,
|
|
133
|
-
sub_mode: 'all',
|
|
134
|
-
sub_from: ''
|
|
135
|
-
}]
|
|
136
|
-
const res = await dbPool.query(
|
|
137
|
-
'SELECT queen.pop_specific_batch($1::jsonb) as out',
|
|
138
|
-
[JSON.stringify(reqs)]
|
|
139
|
-
)
|
|
140
|
-
const out = res.rows[0].out
|
|
141
|
-
return out[0]?.result?.messages || []
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ===========================================================================
|
|
145
|
-
// TEST 1: Stored procedure round-trip (SQL-only, no HTTP, no JWT)
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
// Exercises the schema + SP contract end-to-end purely via SQL. If this fails,
|
|
148
|
-
// the migration or 001_push.sql / 002_pop_unified.sql is wrong. Runs in any
|
|
149
|
-
// server configuration because it talks to Postgres directly.
|
|
150
|
-
// ===========================================================================
|
|
151
|
-
export async function producerSubRoundTripViaStoredProcedure(client) {
|
|
152
|
-
const q = 'test-auth-sp-roundtrip'
|
|
153
|
-
const tx = `tx-sp-${Date.now()}`
|
|
154
|
-
|
|
155
|
-
await callPushMessagesV3([{
|
|
156
|
-
queue: q,
|
|
157
|
-
partition: 'Default',
|
|
158
|
-
transactionId: tx,
|
|
159
|
-
payload: { x: 1 },
|
|
160
|
-
producerSub: 'sp-test-sub'
|
|
161
|
-
}])
|
|
162
|
-
|
|
163
|
-
const stored = await getStoredProducerSub(q, tx)
|
|
164
|
-
if (stored !== 'sp-test-sub') {
|
|
165
|
-
return { success: false, message: `Expected producer_sub='sp-test-sub' in DB, got ${JSON.stringify(stored)}` }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const popped = await callPopSpecificBatch(q)
|
|
169
|
-
const target = popped.find(m => m.transactionId === tx)
|
|
170
|
-
if (!target) {
|
|
171
|
-
return { success: false, message: `Expected to find tx ${tx} in pop result` }
|
|
172
|
-
}
|
|
173
|
-
if (target.producerSub !== 'sp-test-sub') {
|
|
174
|
-
return { success: false, message: `Pop returned producerSub=${JSON.stringify(target.producerSub)}, expected 'sp-test-sub'` }
|
|
95
|
+
// Black-box observation for the HTTP push tests: pop the message back through
|
|
96
|
+
// the API and read its `producerSub`. The segments engine stores messages in
|
|
97
|
+
// queen.seg_segments (never queen.messages), so a seg-broker push is only
|
|
98
|
+
// observable via pop — the seg-native ground truth. Optional bearer so the
|
|
99
|
+
// JWT-enabled pops are authorized.
|
|
100
|
+
async function httpPop({ queue, bearerToken, batch = 20, waitMs = 5000 }) {
|
|
101
|
+
const headers = {}
|
|
102
|
+
if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`
|
|
103
|
+
const url = `${SERVER_URL}/api/v1/pop/queue/${encodeURIComponent(queue)}`
|
|
104
|
+
+ `?batch=${batch}&wait=true&timeout=${waitMs}&autoAck=true`
|
|
105
|
+
const res = await fetch(url, { method: 'GET', headers })
|
|
106
|
+
if (res.status === 204) return []
|
|
107
|
+
const text = await res.text()
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
throw new Error(`pop failed ${res.status}: ${text}`)
|
|
175
110
|
}
|
|
176
|
-
|
|
177
|
-
return
|
|
111
|
+
const body = text ? JSON.parse(text) : {}
|
|
112
|
+
return (body.messages || []).filter(m => m != null)
|
|
178
113
|
}
|
|
179
114
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
queue: q,
|
|
191
|
-
partition: 'Default',
|
|
192
|
-
transactionId: tx,
|
|
193
|
-
payload: { x: 1 }
|
|
194
|
-
// no producerSub
|
|
195
|
-
}])
|
|
196
|
-
|
|
197
|
-
const stored = await getStoredProducerSub(q, tx)
|
|
198
|
-
if (stored !== null) {
|
|
199
|
-
return { success: false, message: `Expected NULL producer_sub, got ${JSON.stringify(stored)}` }
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const popped = await callPopSpecificBatch(q)
|
|
203
|
-
const target = popped.find(m => m.transactionId === tx)
|
|
204
|
-
if (!target) {
|
|
205
|
-
return { success: false, message: `Expected to find tx ${tx} in pop result` }
|
|
206
|
-
}
|
|
207
|
-
if (target.producerSub != null) {
|
|
208
|
-
return { success: false, message: `Pop returned producerSub=${JSON.stringify(target.producerSub)}, expected null` }
|
|
115
|
+
// Pop until we observe the message with the given transactionId; returns its
|
|
116
|
+
// producerSub (a seg push is immediately visible via fire-on-idle, but retry a
|
|
117
|
+
// few times to be robust to any push→visibility race).
|
|
118
|
+
async function poppedProducerSub(queue, transactionId, bearerToken) {
|
|
119
|
+
const deadline = Date.now() + 6000
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
const msgs = await httpPop({ queue, bearerToken })
|
|
122
|
+
const hit = msgs.find(m => m.transactionId === transactionId)
|
|
123
|
+
if (hit) return { found: true, producerSub: hit.producerSub }
|
|
124
|
+
await new Promise(r => setTimeout(r, 150))
|
|
209
125
|
}
|
|
210
|
-
|
|
211
|
-
return { success: true, message: 'Omitting producerSub yields NULL in DB and null in pop response' }
|
|
126
|
+
return { found: false, producerSub: undefined }
|
|
212
127
|
}
|
|
213
128
|
|
|
214
129
|
// ===========================================================================
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
130
|
+
// (Retired) SP-level producer_sub round-trip / NULL / empty-string tests.
|
|
131
|
+
// These drove the ROWS engine directly (queen.push_messages_v3 +
|
|
132
|
+
// queen.pop_specific_batch + a queen.messages read) to verify the SP's
|
|
133
|
+
// producer_sub NULLIF handling. The rows engine is retired (segments-only), so
|
|
134
|
+
// they were removed — the segments engine stores payloads in queen.seg_segments
|
|
135
|
+
// and stamps producer_sub in the broker, never in queen.messages. The shipping
|
|
136
|
+
// producer_sub behavior (server-stamped, body-ignored without auth, spoof-proof
|
|
137
|
+
// under JWT) is covered black-box through the HTTP push -> pop path by the tests
|
|
138
|
+
// below.
|
|
219
139
|
// ===========================================================================
|
|
220
|
-
export async function producerSubEmptyStringStoredAsNull(client) {
|
|
221
|
-
const q = 'test-auth-sp-empty'
|
|
222
|
-
const tx = `tx-sp-empty-${Date.now()}`
|
|
223
|
-
|
|
224
|
-
await callPushMessagesV3([{
|
|
225
|
-
queue: q,
|
|
226
|
-
partition: 'Default',
|
|
227
|
-
transactionId: tx,
|
|
228
|
-
payload: { x: 1 },
|
|
229
|
-
producerSub: ''
|
|
230
|
-
}])
|
|
231
|
-
|
|
232
|
-
const stored = await getStoredProducerSub(q, tx)
|
|
233
|
-
if (stored !== null) {
|
|
234
|
-
return { success: false, message: `Expected NULL for empty-string producerSub, got ${JSON.stringify(stored)}` }
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return { success: true, message: 'Empty-string producerSub is stored as NULL' }
|
|
238
|
-
}
|
|
239
140
|
|
|
240
141
|
// ===========================================================================
|
|
241
142
|
// TEST 4: HTTP push without JWT => producer_sub is NULL
|
|
@@ -265,12 +166,17 @@ export async function producerSubIgnoredFromBodyWithoutAuth(client) {
|
|
|
265
166
|
extraItemFields: { producerSub: 'attacker-no-jwt' }
|
|
266
167
|
})
|
|
267
168
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
169
|
+
// Black-box: observe the pushed message via pop. producerSub must be null
|
|
170
|
+
// (the field is not client-settable, and there is no JWT sub to stamp).
|
|
171
|
+
const { found, producerSub } = await poppedProducerSub(q, tx)
|
|
172
|
+
if (!found) {
|
|
173
|
+
return { success: false, message: `Pushed message ${tx} not observed via pop` }
|
|
174
|
+
}
|
|
175
|
+
if (producerSub !== null) {
|
|
176
|
+
return { success: false, message: `Expected producerSub null (auth disabled), got ${JSON.stringify(producerSub)} - client was able to set it!` }
|
|
271
177
|
}
|
|
272
178
|
|
|
273
|
-
return { success: true, message: 'Body-supplied producerSub ignored when auth disabled;
|
|
179
|
+
return { success: true, message: 'Body-supplied producerSub ignored when auth disabled; pop shows null' }
|
|
274
180
|
}
|
|
275
181
|
|
|
276
182
|
// ===========================================================================
|
|
@@ -297,12 +203,17 @@ export async function producerSubStampedFromJwt(client) {
|
|
|
297
203
|
bearerToken: token
|
|
298
204
|
})
|
|
299
205
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
206
|
+
// Black-box: pop the message back and read producerSub (the seg-native
|
|
207
|
+
// ground truth). It must equal the authenticated JWT sub.
|
|
208
|
+
const { found, producerSub } = await poppedProducerSub(q, tx, token)
|
|
209
|
+
if (!found) {
|
|
210
|
+
return { success: false, message: `Pushed message ${tx} not observed via pop` }
|
|
211
|
+
}
|
|
212
|
+
if (producerSub !== 'alice-producer') {
|
|
213
|
+
return { success: false, message: `Expected producerSub='alice-producer', got ${JSON.stringify(producerSub)}` }
|
|
303
214
|
}
|
|
304
215
|
|
|
305
|
-
return { success: true, message: 'producer_sub stamped from authenticated JWT sub claim' }
|
|
216
|
+
return { success: true, message: 'producer_sub stamped from authenticated JWT sub claim (observed via pop)' }
|
|
306
217
|
}
|
|
307
218
|
|
|
308
219
|
// ===========================================================================
|
|
@@ -328,13 +239,18 @@ export async function producerSubSpoofingIgnoredWithJwt(client) {
|
|
|
328
239
|
extraItemFields: { producerSub: 'attacker' }
|
|
329
240
|
})
|
|
330
241
|
|
|
331
|
-
|
|
332
|
-
|
|
242
|
+
// Black-box: pop the message back; the body-supplied producerSub must be
|
|
243
|
+
// ignored and the observed value must be the validated JWT sub.
|
|
244
|
+
const { found, producerSub } = await poppedProducerSub(q, tx, token)
|
|
245
|
+
if (!found) {
|
|
246
|
+
return { success: false, message: `Pushed message ${tx} not observed via pop` }
|
|
247
|
+
}
|
|
248
|
+
if (producerSub !== 'legit-producer') {
|
|
333
249
|
return {
|
|
334
250
|
success: false,
|
|
335
|
-
message: `Impersonation not prevented:
|
|
251
|
+
message: `Impersonation not prevented: observed producerSub=${JSON.stringify(producerSub)}, expected 'legit-producer'`
|
|
336
252
|
}
|
|
337
253
|
}
|
|
338
254
|
|
|
339
|
-
return { success: true, message: 'Body-supplied producerSub ignored;
|
|
255
|
+
return { success: true, message: 'Body-supplied producerSub ignored; observed sub is from validated JWT' }
|
|
340
256
|
}
|