queen-mq 0.16.0 → 1.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.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Client-side 429/403 handling tests (PLAN_QUEEN_PROXY_CLOUD.md §4/§9,
3
+ * blocker B4 -- "client 429/backoff work ... mandatory pre-enforcement").
4
+ *
5
+ * The proxy error contract under test:
6
+ * 429 Retry-After: <seconds> { "error": "...", "code": "rate_limited" | "quota_exceeded" }
7
+ * 403 { "error": "...", "code": "cluster_suspended" | "storage_quota_exceeded"
8
+ * | "feature_gated" | "forbidden" }
9
+ *
10
+ * These tests run against a real local HTTP server (no broker) that plays
11
+ * back a canned response script, mirroring the style of ack.test.js
12
+ * (withAckServer) and streams-unit/fakeServer.js -- no mocking framework,
13
+ * just node:http.
14
+ */
15
+
16
+ import { describe, it } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { createServer } from 'node:http'
19
+
20
+ import { Queen } from '../../client-v2/index.js'
21
+ import { HttpClient } from '../../client-v2/http/HttpClient.js'
22
+
23
+ /**
24
+ * Spin up a real HTTP server driven by a canned response `plan` (array of
25
+ * {status, body, retryAfter, headers}, consumed in request order; any
26
+ * request beyond the plan gets `defaultResponse`). Records each hit's
27
+ * arrival time so tests can sanity-check backoff pacing.
28
+ */
29
+ function withPlanServer(plan, defaultResponse, run) {
30
+ const hits = []
31
+ const start = Date.now()
32
+ let index = 0
33
+
34
+ const server = createServer((req, res) => {
35
+ let raw = ''
36
+ req.on('data', chunk => { raw += chunk })
37
+ req.on('end', () => {
38
+ hits.push({ method: req.method, url: req.url, atMs: Date.now() - start })
39
+
40
+ const descriptor = index < plan.length ? plan[index] : defaultResponse
41
+ index++
42
+
43
+ const status = descriptor.status ?? 200
44
+ const headers = { 'Content-Type': 'application/json', ...(descriptor.headers || {}) }
45
+ if (descriptor.retryAfter != null) headers['Retry-After'] = String(descriptor.retryAfter)
46
+
47
+ res.writeHead(status, headers)
48
+ res.end(JSON.stringify(descriptor.body ?? { ok: true }))
49
+ })
50
+ })
51
+
52
+ return new Promise((resolve, reject) => {
53
+ server.listen(0, '127.0.0.1', () => {
54
+ const { port } = server.address()
55
+ const teardown = () => new Promise(r => server.close(r))
56
+ Promise.resolve()
57
+ .then(() => run(`http://127.0.0.1:${port}`, hits))
58
+ .then(
59
+ async (value) => { await teardown(); resolve(value) },
60
+ async (err) => { await teardown(); reject(err) }
61
+ )
62
+ })
63
+ })
64
+ }
65
+
66
+ function rateLimited(retryAfter) {
67
+ const descriptor = { status: 429, body: { error: 'slow down', code: 'rate_limited' } }
68
+ if (retryAfter != null) descriptor.retryAfter = retryAfter
69
+ return descriptor
70
+ }
71
+
72
+ function repeat(descriptor, n) {
73
+ return Array.from({ length: n }, () => ({ ...descriptor }))
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // HttpClient (direct) -- the centralized retry429 mechanism itself.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ describe('HttpClient — 429 retry policy', () => {
81
+ it('honors Retry-After (seconds) and succeeds once the header-specified wait elapses', async () => {
82
+ const plan = [rateLimited('0')]
83
+ await withPlanServer(plan, { status: 200, body: { ok: true } }, async (url, hits) => {
84
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 5, capMs: 50 } })
85
+ try {
86
+ const result = await client.get('/x')
87
+ assert.deepEqual(result, { ok: true })
88
+ assert.equal(hits.length, 2, 'one 429 then one success')
89
+ } finally {
90
+ await client.destroy()
91
+ }
92
+ })
93
+ })
94
+
95
+ it('falls back to exponential backoff when Retry-After is absent, and the gap grows', async () => {
96
+ const plan = repeat(rateLimited(), 2)
97
+ await withPlanServer(plan, { status: 200, body: { ok: true } }, async (url, hits) => {
98
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 20, capMs: 2000 } })
99
+ try {
100
+ const result = await client.get('/x')
101
+ assert.deepEqual(result, { ok: true })
102
+ assert.equal(hits.length, 3)
103
+ const gap1 = hits[1].atMs - hits[0].atMs
104
+ const gap2 = hits[2].atMs - hits[1].atMs
105
+ // Exponential (base=20ms -> ~20ms then ~40ms), jittered +-20%: allow
106
+ // generous slack for CI timer jitter while still proving growth.
107
+ assert.ok(gap2 > gap1 * 1.2, `expected gap2 (${gap2}ms) to exceed gap1 (${gap1}ms) by backoff growth`)
108
+ } finally {
109
+ await client.destroy()
110
+ }
111
+ })
112
+ })
113
+
114
+ it('gives up after retry429.maxAttempts on a default (push-like) call and throws rate_limited', async () => {
115
+ await withPlanServer([], rateLimited(), async (url, hits) => {
116
+ const client = new HttpClient({ baseUrl: url, retry429: { maxAttempts: 3, baseMs: 1, capMs: 5 } })
117
+ try {
118
+ await assert.rejects(
119
+ client.post('/api/v1/push', { items: [] }),
120
+ (err) => {
121
+ assert.equal(err.status, 429)
122
+ assert.equal(err.code, 'rate_limited')
123
+ return true
124
+ }
125
+ )
126
+ assert.equal(hits.length, 3, 'exactly maxAttempts tries, no more')
127
+ } finally {
128
+ await client.destroy()
129
+ }
130
+ })
131
+ })
132
+
133
+ it('defaults to 10 attempts for a default (non-pop) call when retry429 is not configured', async () => {
134
+ await withPlanServer([], rateLimited(), async (url, hits) => {
135
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 1, capMs: 2 } })
136
+ try {
137
+ await assert.rejects(client.post('/api/v1/push', { items: [] }))
138
+ assert.equal(hits.length, 10)
139
+ } finally {
140
+ await client.destroy()
141
+ }
142
+ })
143
+ })
144
+
145
+ it('retries a long-poll pop (retryKind "pop") past the push default of 10 attempts', async () => {
146
+ const plan = repeat(rateLimited('0'), 14)
147
+ await withPlanServer(plan, { status: 200, body: { messages: [{ id: 'm1' }] } }, async (url, hits) => {
148
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 1, capMs: 5 } })
149
+ try {
150
+ const result = await client.get('/api/v1/pop', null, null, 'pop')
151
+ assert.deepEqual(result, { messages: [{ id: 'm1' }] })
152
+ assert.equal(hits.length, 15, 'pop must not give up at the push default of 10 attempts')
153
+ } finally {
154
+ await client.destroy()
155
+ }
156
+ })
157
+ })
158
+
159
+ it('an explicit maxAttempts override applies to pop too (not just push)', async () => {
160
+ await withPlanServer([], rateLimited(), async (url, hits) => {
161
+ const client = new HttpClient({ baseUrl: url, retry429: { maxAttempts: 2, baseMs: 1, capMs: 5 } })
162
+ try {
163
+ await assert.rejects(client.get('/api/v1/pop', null, null, 'pop'))
164
+ assert.equal(hits.length, 2, 'explicit maxAttempts bounds pop as well as push')
165
+ } finally {
166
+ await client.destroy()
167
+ }
168
+ })
169
+ })
170
+
171
+ it('never retries a 403 and preserves its code (terminal)', async () => {
172
+ await withPlanServer([], { status: 403, body: { error: 'cluster suspended', code: 'cluster_suspended' } }, async (url, hits) => {
173
+ const client = new HttpClient({ baseUrl: url })
174
+ try {
175
+ await assert.rejects(
176
+ client.post('/api/v1/push', { items: [] }),
177
+ (err) => {
178
+ assert.equal(err.status, 403)
179
+ assert.equal(err.code, 'cluster_suspended')
180
+ return true
181
+ }
182
+ )
183
+ assert.equal(hits.length, 1, '403 must not be retried')
184
+ } finally {
185
+ await client.destroy()
186
+ }
187
+ })
188
+ })
189
+
190
+ it('still does not retry an ordinary 4xx like 400 (unaffected by the 429 changes)', async () => {
191
+ await withPlanServer([], { status: 400, body: { error: 'bad request' } }, async (url, hits) => {
192
+ const client = new HttpClient({ baseUrl: url })
193
+ try {
194
+ await assert.rejects(client.get('/x'), (err) => {
195
+ assert.equal(err.status, 400)
196
+ return true
197
+ })
198
+ assert.equal(hits.length, 1)
199
+ } finally {
200
+ await client.destroy()
201
+ }
202
+ })
203
+ })
204
+ })
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Queen public API wiring: config plumbing (retry429) + call-site marking
208
+ // (push vs. wait=true pop) actually reach HttpClient.
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe('Queen — push()/pop() wired to retry429', () => {
212
+ it('push() retries a 429 (Retry-After) then succeeds', async () => {
213
+ const plan = [rateLimited('0')]
214
+ await withPlanServer(plan, { status: 200, body: [{ status: 'queued', transactionId: 'tx-1' }] }, async (url, hits) => {
215
+ const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 5, capMs: 50 } })
216
+ try {
217
+ const result = await queen.queue('q1').push({ hello: 'world' })
218
+ assert.equal(hits.length, 2)
219
+ assert.equal(result[0].status, 'queued')
220
+ } finally {
221
+ await queen.close()
222
+ }
223
+ })
224
+ })
225
+
226
+ it('push() surfaces a terminal 403 without retrying', async () => {
227
+ await withPlanServer([], { status: 403, body: { error: 'over quota', code: 'storage_quota_exceeded' } }, async (url, hits) => {
228
+ const queen = new Queen({ url, handleSignals: false })
229
+ try {
230
+ await assert.rejects(
231
+ async () => { await queen.queue('q1').push({ hello: 'world' }) },
232
+ (err) => { assert.equal(err.code, 'storage_quota_exceeded'); return true }
233
+ )
234
+ assert.equal(hits.length, 1)
235
+ } finally {
236
+ await queen.close()
237
+ }
238
+ })
239
+ })
240
+
241
+ it('a long-poll pop() rides out more 429s than the push default before returning', async () => {
242
+ const plan = repeat(rateLimited('0'), 12)
243
+ await withPlanServer(plan, { status: 200, body: { messages: [{ transactionId: 'tx-1', data: { x: 1 } }] } }, async (url, hits) => {
244
+ const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 1, capMs: 5 } })
245
+ try {
246
+ const messages = await queen.queue('q1').wait(true).pop()
247
+ assert.equal(messages.length, 1)
248
+ assert.equal(hits.length, 13)
249
+ } finally {
250
+ await queen.close()
251
+ }
252
+ })
253
+ })
254
+
255
+ it('a non-waiting pop() uses the bounded default and swallows an exhausted 429 to []', async () => {
256
+ await withPlanServer([], rateLimited(), async (url, hits) => {
257
+ const queen = new Queen({ url, handleSignals: false, retry429: { maxAttempts: 2, baseMs: 1, capMs: 5 } })
258
+ try {
259
+ const messages = await queen.queue('q1').wait(false).pop()
260
+ assert.deepEqual(messages, [], 'pop() keeps its swallow-errors-to-[] contract')
261
+ assert.equal(hits.length, 2)
262
+ } finally {
263
+ await queen.close()
264
+ }
265
+ })
266
+ })
267
+ })
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // ConsumerManager worker loop: the actual hot-loop/die bug (B4) this task
271
+ // fixes -- consume() must back off through 429s and stop cleanly on a
272
+ // terminal 403 instead of spinning or crashing uncontrolled.
273
+ // ---------------------------------------------------------------------------
274
+
275
+ describe('Queen — consume() loop backs off on 429 and stops on terminal 403', () => {
276
+ it('backs off through repeated 429s and still delivers the message', async () => {
277
+ const plan = repeat(rateLimited('0'), 3)
278
+ await withPlanServer(
279
+ plan,
280
+ { status: 200, body: { messages: [{ transactionId: 'tx-1', partitionId: 'p-1', data: { x: 1 } }] } },
281
+ async (url, hits) => {
282
+ const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 1, capMs: 5 } })
283
+ try {
284
+ const received = []
285
+ // .each() delivers one message per handler call (default batches
286
+ // the whole array) -- simplest shape to assert on here.
287
+ await queen.queue('q1').wait(true).limit(1).autoAck(false).each().consume(async (msg) => {
288
+ received.push(msg)
289
+ })
290
+ assert.equal(received.length, 1)
291
+ assert.equal(received[0].transactionId, 'tx-1')
292
+ assert.ok(hits.length >= 4, 'the 3 rate-limited attempts plus the final success must all have hit the server')
293
+ } finally {
294
+ await queen.close()
295
+ }
296
+ }
297
+ )
298
+ })
299
+
300
+ it('stops the worker on a terminal 403 (cluster_suspended) instead of hot-looping or hanging', async () => {
301
+ await withPlanServer([], { status: 403, body: { error: 'cluster suspended', code: 'cluster_suspended' } }, async (url, hits) => {
302
+ const queen = new Queen({ url, handleSignals: false })
303
+ try {
304
+ await assert.rejects(
305
+ async () => { await queen.queue('q1').wait(true).consume(async () => {}) },
306
+ (err) => { assert.equal(err.code, 'cluster_suspended'); return true }
307
+ )
308
+ assert.equal(hits.length, 1, 'must stop after the first 403, not hot-loop')
309
+ } finally {
310
+ await queen.close()
311
+ }
312
+ })
313
+ })
314
+ })
package/test-v2/load.js CHANGED
@@ -1,3 +1,22 @@
1
+ // Ordering assertion that tolerates REDELIVERY (at-least-once is the broker's
2
+ // contract: a lease expiry / ack timeout on a slow machine legally replays a
3
+ // batch). What it still catches is the real bug class — SKIPPED messages: the
4
+ // stream must never jump PAST an id it hasn't delivered yet. Throwing on a
5
+ // redelivered (already-seen) id instead would nack the batch, and four nacks
6
+ // dead-letter the head (v0.16.0 retry semantics) — turning one benign
7
+ // redelivery into message loss and a failed test.
8
+ function makeOrderingTracker() {
9
+ let maxId = -1
10
+ return {
11
+ observe(id) {
12
+ if (id > maxId + 1) {
13
+ throw new Error(`Message ordering violation: jumped from ${maxId} to ${id} (skipped ${id - maxId - 1})`)
14
+ }
15
+ if (id > maxId) maxId = id
16
+ }
17
+ }
18
+ }
19
+
1
20
  export async function testLoad(client) {
2
21
  const queue = await client
3
22
  .queue('test-queue-v2-load')
@@ -19,24 +38,22 @@ export async function testLoad(client) {
19
38
  await client.queue('test-queue-v2-load').push(messages)
20
39
 
21
40
  let uniqueIds = new Set();
22
- let lastId = null;
41
+ const order = makeOrderingTracker();
23
42
 
43
+ // idleMillis instead of limit(10000): a hard per-worker delivery budget
44
+ // counts redeliveries too, so one replayed batch makes workers return
45
+ // "complete" with messages still queued (or spin forever waiting to hit an
46
+ // exact share). Draining until idle is redelivery-proof — same pattern as
47
+ // the bootstrap tests.
24
48
  await client
25
49
  .queue('test-queue-v2-load')
26
50
  .concurrency(10)
27
51
  .batch(100)
28
52
  .wait(false)
29
- .limit(10000)
53
+ .idleMillis(5000)
30
54
  .consume(async msgs => {
31
55
  for (const msg of msgs) {
32
- if (lastId === null) {
33
- lastId = msg.data.id
34
- } else {
35
- if (msg.data.id !== lastId + 1) {
36
- throw new Error('Message ordering violation')
37
- }
38
- lastId = msg.data.id
39
- }
56
+ order.observe(msg.data.id)
40
57
  uniqueIds.add(msg.data.id)
41
58
  }
42
59
  })
@@ -76,24 +93,16 @@ export async function testLoadPartition(client) {
76
93
 
77
94
 
78
95
  for (let i = 0; i < 10; i++) {
79
- let lastId = null;
96
+ const order = makeOrderingTracker();
80
97
  await client
81
98
  .queue('test-queue-v2-load-partition')
82
99
  .partition(i.toString())
83
100
  .batch(100)
84
101
  .wait(false)
85
- .limit(10000)
102
+ .idleMillis(5000)
86
103
  .consume(async msgs => {
87
104
  for (const msg of msgs) {
88
- if (lastId === null) {
89
- lastId = msg.data.id
90
- } else {
91
- if (msg.data.id !== lastId + 1) {
92
- console.log(`Message ordering violation: ${msg.data.id} !== ${lastId + 1}`)
93
- throw new Error('Message ordering violation')
94
- }
95
- lastId = msg.data.id
96
- }
105
+ order.observe(msg.data.id)
97
106
  uniqueIds.add(msg.data.id)
98
107
  }
99
108
  })
@@ -126,7 +135,7 @@ export async function testLoadConsumerGroup(client) {
126
135
  await client.queue('test-queue-v2-load-consumer-group').push(messages)
127
136
 
128
137
  let uniqueIdsA = new Set();
129
- let lastIdA = null;
138
+ const orderA = makeOrderingTracker();
130
139
 
131
140
  await client
132
141
  .queue('test-queue-v2-load-consumer-group')
@@ -135,23 +144,16 @@ export async function testLoadConsumerGroup(client) {
135
144
  .concurrency(10)
136
145
  .batch(100)
137
146
  .wait(false)
138
- .limit(10000)
147
+ .idleMillis(5000)
139
148
  .consume(async msgs => {
140
149
  for (const msg of msgs) {
141
- if (lastIdA === null) {
142
- lastIdA = msg.data.id
143
- } else {
144
- if (msg.data.id !== lastIdA + 1) {
145
- throw new Error('Message ordering violation')
146
- }
147
- lastIdA = msg.data.id
148
- }
150
+ orderA.observe(msg.data.id)
149
151
  uniqueIdsA.add(msg.data.id)
150
152
  }
151
153
  })
152
154
 
153
155
  let uniqueIdsB = new Set();
154
- let lastIdB = null;
156
+ const orderB = makeOrderingTracker();
155
157
 
156
158
  await client
157
159
  .queue('test-queue-v2-load-consumer-group')
@@ -160,22 +162,16 @@ export async function testLoadConsumerGroup(client) {
160
162
  .concurrency(10)
161
163
  .batch(100)
162
164
  .wait(false)
163
- .limit(10000)
165
+ .idleMillis(5000)
164
166
  .consume(async msgs => {
165
167
  for (const msg of msgs) {
166
- if (lastIdB === null) {
167
- lastIdB = msg.data.id
168
- } else {
169
- if (msg.data.id !== lastIdB + 1) {
170
- throw new Error('Message ordering violation')
171
- }
172
- lastIdB = msg.data.id
173
- }
168
+ orderB.observe(msg.data.id)
174
169
  uniqueIdsB.add(msg.data.id)
175
170
  }
176
171
  })
177
172
 
178
173
  const uniqueIdsCountA = uniqueIdsA.size;
179
174
  const uniqueIdsCountB = uniqueIdsB.size;
175
+ console.log(`Unique IDs: groupA=${uniqueIdsCountA} groupB=${uniqueIdsCountB}`)
180
176
  return { success: uniqueIdsCountA === messagesToPush && uniqueIdsCountB === messagesToPush, message: 'Load test completed successfully' }
181
177
  }
@@ -6,8 +6,8 @@ export async function test_maintenance_mode(client = null) {
6
6
 
7
7
  const queen = client
8
8
 
9
- // Use port 6632 (default Queen port) for API calls
10
- const API_URL = 'http://localhost:6632';
9
+ // Default Queen port; override with QUEEN_SERVER_URL (same as run.js).
10
+ const API_URL = process.env.QUEEN_SERVER_URL || 'http://localhost:6632';
11
11
  const QUEUE_NAME = 'test-maintenance-queue';
12
12
  const MESSAGES_PER_SECOND = 10;
13
13
  const MAINTENANCE_DURATION_SECONDS = 10;
package/test-v2/pop.js CHANGED
@@ -25,11 +25,13 @@ export async function popNonEmptyQueue(client) {
25
25
  // wait(false) wildcard pop can race past the not-yet-committed lookup
26
26
  // row. Long-poll re-runs the candidate scan and picks up the row
27
27
  // once it commits (typically within ms).
28
+ // docs:start(js-pop)
28
29
  const res = await client
29
30
  .queue('test-queue-v2-pop-non-empty')
30
31
  .batch(1)
31
32
  .wait(true)
32
33
  .pop()
34
+ // docs:end
33
35
  return { success: res.length === 1 }
34
36
  }
35
37
 
package/test-v2/push.js CHANGED
@@ -5,9 +5,11 @@ export async function pushMessage(client) {
5
5
  if (!queue.configured) {
6
6
  return { success: false, message: 'Queue not created' }
7
7
  }
8
+ // docs:start(js-push)
8
9
  const res = await client
9
10
  .queue('test-queue-v2')
10
11
  .push([{ data: { message: 'Hello, world!' } }])
12
+ // docs:end
11
13
 
12
14
  return { success: res[0].status === 'queued' }
13
15
  }
@@ -17,14 +19,17 @@ export async function pushDuplicateMessage(client) {
17
19
  if (!queue.configured) {
18
20
  return { success: false, message: 'Queue not created' }
19
21
  }
22
+ // docs:start(js-push-dedup)
20
23
  const res1 = await client
21
24
  .queue('test-queue-v2')
22
25
  .push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
23
-
26
+
24
27
  const res2 = await client
25
28
  .queue('test-queue-v2')
26
29
  .push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
27
-
30
+ // res1[0].status === 'queued', res2[0].status === 'duplicate'
31
+ // docs:end
32
+
28
33
  return { success: res1[0].status === 'queued' && res2[0].status === 'duplicate' }
29
34
  }
30
35
 
@@ -518,48 +523,37 @@ export async function testPushMalformedTraceId(client) {
518
523
 
519
524
  export async function testPushProducerSubEmptyStringBecomesNull(client) {
520
525
  // We can't set producerSub from the client (server-stamped field). The
521
- // invariant we need to preserve is that when the server writes an empty
522
- // producer_sub into the JSON, the SP stores SQL NULL. We verify it by
523
- // pushing without auth (server will stamp producerSub as empty / NULL)
524
- // and then checking via a short-lived pg client. NOTE: we intentionally
525
- // do NOT dynamically import ./run.js here — that module executes
526
- // top-level `await main()` and re-importing it while it's running
527
- // deadlocks the ESM module graph.
526
+ // invariant we need to preserve is that a message pushed without auth has a
527
+ // producerSub of null (never a literal empty string). Black-box port: the
528
+ // segments engine stores messages in queen.seg_segments and never populates
529
+ // queen.messages, so we observe the field via the pop API — the seg-native
530
+ // ground truth — instead of a direct SQL read of queen.messages.
528
531
  const queueName = 'test-v3-producer-sub-null'
529
532
  const queue = await client.queue(queueName).create()
530
533
  if (!queue.configured) {
531
534
  return { success: false, message: 'Queue not created' }
532
535
  }
533
- const res = await client.queue(queueName).push([{ data: { x: 1 } }])
536
+ const tx = 'tx-psn-' + crypto.randomBytes(4).toString('hex')
537
+ const res = await client.queue(queueName).push([{ transactionId: tx, data: { x: 1 } }])
534
538
  if (res[0].status !== 'queued') {
535
539
  return { success: false, message: 'Push failed' }
536
540
  }
537
- const messageId = res[0].message_id
538
-
539
- const { default: pg } = await import('pg')
540
- const pool = new pg.Pool({
541
- host: process.env.PG_HOST || 'localhost',
542
- port: process.env.PG_PORT || 5432,
543
- database: process.env.PG_DB || 'postgres',
544
- user: process.env.PG_USER || 'postgres',
545
- password: process.env.PG_PASSWORD || 'postgres',
546
- max: 1
547
- })
548
- try {
549
- const r = await pool.query(
550
- 'SELECT producer_sub FROM queen.messages WHERE id = $1',
551
- [messageId]
552
- )
553
- if (r.rows.length !== 1) {
554
- return { success: false, message: `Message ${messageId} not found` }
555
- }
556
- if (r.rows[0].producer_sub !== null) {
557
- return { success: false, message: `Expected producer_sub NULL, got ${JSON.stringify(r.rows[0].producer_sub)}` }
558
- }
559
- return { success: true }
560
- } finally {
561
- await pool.end()
541
+
542
+ // wait(true): ride out any push→visibility race (see pushLargePayload).
543
+ const deadline = Date.now() + 6000
544
+ let hit = null
545
+ while (Date.now() < deadline && !hit) {
546
+ const msgs = await client.queue(queueName).batch(20).wait(true).pop()
547
+ hit = (msgs || []).find(m => m && m.transactionId === tx)
548
+ if (!hit) await new Promise(r => setTimeout(r, 150))
562
549
  }
550
+ if (!hit) {
551
+ return { success: false, message: `Pushed message ${tx} not observed via pop` }
552
+ }
553
+ if (hit.producerSub != null) {
554
+ return { success: false, message: `Expected producerSub null, got ${JSON.stringify(hit.producerSub)}` }
555
+ }
556
+ return { success: true }
563
557
  }
564
558
 
565
559
  export async function pushEncryptedPayload(client) {
package/test-v2/run.js CHANGED
@@ -15,6 +15,8 @@ import * as bootstrapTests from './bootstrap.js'
15
15
  import * as loggerTests from './logger.js'
16
16
  import * as watermarkTests from './watermark.js'
17
17
  import * as authTests from './auth.js'
18
+ import * as semanticsTests from './semantics.js'
19
+ import * as ackWindowTests from './ackwindow.js'
18
20
  import * as streamTests from './stream/index.js'
19
21
  import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
20
22
 
@@ -22,7 +24,7 @@ import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
22
24
  // Test configuration
23
25
 
24
26
  export const TEST_CONFIG_SINGLE = {
25
- baseUrls: ['http://localhost:6632'],
27
+ baseUrls: [process.env.QUEEN_SERVER_URL || 'http://localhost:6632'],
26
28
  loadBalancingStrategy: 'affinity',
27
29
  dbConfig: {
28
30
  host: process.env.PG_HOST || 'localhost',
@@ -88,6 +90,8 @@ function printResults() {
88
90
  }
89
91
 
90
92
  export const cleanupTestData = async () => {
93
+ // All the LIKE patterns test queues use.
94
+ const patterns = ['test-%', 'edge-%', 'pattern-%', 'workflow-%'];
91
95
  try {
92
96
  // Drop streaming queries first (CASCADE removes their state rows).
93
97
  // Safe even when queen_streams isn't installed yet — we swallow the
@@ -98,11 +102,32 @@ export const cleanupTestData = async () => {
98
102
  // queen_streams schema not installed — ignore.
99
103
  }
100
104
 
101
- await dbPool.query(`DELETE FROM queen.queues WHERE name LIKE 'test-%' OR name LIKE 'edge-%' OR name LIKE 'pattern-%' OR name LIKE 'workflow-%'`);
105
+ // Queue identity is now the queen.queues id (log_queues was merged
106
+ // away): log_partitions, consumer_watermarks, consumer_groups_metadata
107
+ // and queue_lag_metrics all cascade from the queues row. Only log_txns
108
+ // and log_dlq have NO foreign key by design, so they get an explicit
109
+ // purge keyed via log_partitions first. Without it, every suite run
110
+ // inherits the previous run's messages and dedup window entries —
111
+ // fixed-transactionId tests report 'duplicate' on their FIRST push.
112
+ try {
113
+ await dbPool.query(`
114
+ WITH parts AS (
115
+ SELECT lp.id FROM queen.log_partitions lp
116
+ JOIN queen.queues q ON q.id = lp.queue_id
117
+ WHERE q.name LIKE ANY($1::text[])
118
+ ),
119
+ d1 AS (DELETE FROM queen.log_txns WHERE partition_id IN (SELECT id FROM parts)),
120
+ d2 AS (DELETE FROM queen.log_dlq WHERE partition_id IN (SELECT id FROM parts))
121
+ SELECT 1`, [patterns]);
122
+ } catch (e) {
123
+ // Log-engine schema not installed (rows-only server) — ignore.
124
+ }
125
+
126
+ await dbPool.query(`DELETE FROM queen.queues WHERE name LIKE ANY($1::text[])`, [patterns]);
102
127
 
103
- log('Test data cleaned up');
128
+ log(true, 'Test data cleaned up (rows + segments)');
104
129
  } catch (error) {
105
- log(`Cleanup error: ${error.message}`, 'warning');
130
+ log(false, `Cleanup error: ${error.message}`);
106
131
  }
107
132
  };
108
133
 
@@ -129,7 +154,9 @@ async function main() {
129
154
  bootstrapTests,
130
155
  loggerTests,
131
156
  watermarkTests,
132
- authTests
157
+ authTests,
158
+ semanticsTests,
159
+ ackWindowTests
133
160
  ]
134
161
 
135
162
  const aiTests = [
@@ -224,6 +251,11 @@ async function main() {
224
251
 
225
252
  //await cleanupTestData()
226
253
  await closeDb()
254
+ // Queen.close() destroys the per-client HTTP agent, releasing keep-alive
255
+ // sockets so the loop can drain; the explicit exit code is for CI.
256
+ await client.close()
257
+ const failedCount = testResults.filter(x => !x.success).length
258
+ process.exit(failedCount > 0 ? 1 : 0)
227
259
  }
228
260
 
229
261
  try {