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.
Files changed (44) hide show
  1. package/README.md +175 -0
  2. package/client-v2/Queen.js +125 -15
  3. package/client-v2/README.md +69 -0
  4. package/client-v2/builders/QueueBuilder.js +18 -20
  5. package/client-v2/builders/TimerBuilder.js +262 -0
  6. package/client-v2/builders/TransactionBuilder.js +199 -10
  7. package/client-v2/consumer/ConsumerManager.js +64 -12
  8. package/client-v2/http/HttpClient.js +382 -32
  9. package/client-v2/kv/Kv.js +432 -0
  10. package/client-v2/kv/expiry.js +148 -0
  11. package/client-v2/streams/runtime/Runner.js +45 -0
  12. package/client-v2/utils/defaults.js +20 -1
  13. package/package.json +12 -3
  14. package/test-v2/_kvtimers.js +71 -0
  15. package/test-v2/ackwindow.js +265 -0
  16. package/test-v2/auth.js +65 -149
  17. package/test-v2/docs.js +204 -0
  18. package/test-v2/http-unit/hostHeader.test.js +411 -0
  19. package/test-v2/http-unit/retry429.test.js +319 -0
  20. package/test-v2/kv-unit/_planServer.js +65 -0
  21. package/test-v2/kv-unit/kvWire.test.js +377 -0
  22. package/test-v2/kv-unit/timerWire.test.js +177 -0
  23. package/test-v2/kv-unit/txnWire.test.js +222 -0
  24. package/test-v2/kv.js +273 -0
  25. package/test-v2/load.js +37 -41
  26. package/test-v2/maintenance.js +2 -2
  27. package/test-v2/push.js +25 -35
  28. package/test-v2/run.js +67 -5
  29. package/test-v2/semantics.js +801 -0
  30. package/test-v2/stream/_helpers.js +8 -1
  31. package/test-v2/stream/combined.js +4 -3
  32. package/test-v2/stream/cron.js +1 -1
  33. package/test-v2/stream/eventTime.js +8 -5
  34. package/test-v2/stream/operators.js +5 -5
  35. package/test-v2/stream/recovery.js +4 -1
  36. package/test-v2/stream/session.js +3 -3
  37. package/test-v2/stream/sliding.js +1 -1
  38. package/test-v2/stream/throughput.js +3 -2
  39. package/test-v2/stream/tumbling.js +16 -12
  40. package/test-v2/streams-unit/ack.test.js +203 -0
  41. package/test-v2/streams-unit/e2e.test.js +4 -1
  42. package/test-v2/timers.js +209 -0
  43. package/test-v2/transaction.js +4 -1
  44. 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.16.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
- // Direct SP call via dbPool — bypasses HTTP and JWT entirely. Used by the
114
- // SP-level tests below so they work regardless of whether the server has
115
- // auth enabled or not.
116
- async function callPushMessagesV3(items) {
117
- await dbPool.query(
118
- 'SELECT queen.push_messages_v3($1::jsonb)',
119
- [JSON.stringify(items)]
120
- )
121
- }
122
-
123
- // Direct SP pop. Returns the `messages` array for the first (only) partition.
124
- async function callPopSpecificBatch(queueName) {
125
- const reqs = [{
126
- idx: 0,
127
- queue_name: queueName,
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 { success: true, message: 'producer_sub round-trips through push_messages_v3 and pop exposes it' }
111
+ const body = text ? JSON.parse(text) : {}
112
+ return (body.messages || []).filter(m => m != null)
178
113
  }
179
114
 
180
- // ===========================================================================
181
- // TEST 2: SP with no producerSub => producer_sub is NULL
182
- // ---------------------------------------------------------------------------
183
- // Guards against an accidental "default to empty string" regression in the SP.
184
- // ===========================================================================
185
- export async function producerSubNullWhenNotProvided(client) {
186
- const q = 'test-auth-sp-null'
187
- const tx = `tx-sp-null-${Date.now()}`
188
-
189
- await callPushMessagesV3([{
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
- // TEST 3: SP treats empty string producerSub as NULL
216
- // ---------------------------------------------------------------------------
217
- // The SP uses NULLIF(..., '') to avoid storing a literal empty string when a
218
- // client accidentally sends "". Verifies that invariant.
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
- const stored = await getStoredProducerSub(q, tx)
269
- if (stored !== null) {
270
- return { success: false, message: `Expected NULL producer_sub (auth disabled), got ${JSON.stringify(stored)} - client was able to set it!` }
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; stored as NULL' }
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
- const stored = await getStoredProducerSub(q, tx)
301
- if (stored !== 'alice-producer') {
302
- return { success: false, message: `Expected producer_sub='alice-producer', got ${JSON.stringify(stored)}` }
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
- const stored = await getStoredProducerSub(q, tx)
332
- if (stored !== 'legit-producer') {
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: stored producer_sub=${JSON.stringify(stored)}, expected 'legit-producer'`
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; stored sub is from validated JWT' }
255
+ return { success: true, message: 'Body-supplied producerSub ignored; observed sub is from validated JWT' }
340
256
  }