queen-mq 0.12.4 → 0.14.1
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/client-v2/Queen.js +6 -0
- package/client-v2/builders/QueueBuilder.js +33 -0
- package/client-v2/consumer/ConsumerManager.js +5 -2
- package/client-v2/utils/defaults.js +2 -1
- package/package.json +1 -1
- package/test-v2/auth.js +340 -0
- package/test-v2/consume.js +96 -0
- package/test-v2/maintenance.js +16 -2
- package/test-v2/pop.js +225 -3
- package/test-v2/push.js +287 -3
- package/test-v2/run.js +5 -1
- package/test-v2/watermark.js +507 -0
package/test-v2/pop.js
CHANGED
|
@@ -20,10 +20,15 @@ export async function popNonEmptyQueue(client) {
|
|
|
20
20
|
.queue('test-queue-v2-pop-non-empty')
|
|
21
21
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
22
22
|
|
|
23
|
+
// wait(true) rides out the PUSHPOPLOOKUPSOL race: partition_lookup is
|
|
24
|
+
// updated asynchronously after push commits, so an immediate
|
|
25
|
+
// wait(false) wildcard pop can race past the not-yet-committed lookup
|
|
26
|
+
// row. Long-poll re-runs the candidate scan and picks up the row
|
|
27
|
+
// once it commits (typically within ms).
|
|
23
28
|
const res = await client
|
|
24
29
|
.queue('test-queue-v2-pop-non-empty')
|
|
25
30
|
.batch(1)
|
|
26
|
-
.wait(
|
|
31
|
+
.wait(true)
|
|
27
32
|
.pop()
|
|
28
33
|
return { success: res.length === 1 }
|
|
29
34
|
}
|
|
@@ -58,10 +63,11 @@ export async function popWithAck(client) {
|
|
|
58
63
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
59
64
|
|
|
60
65
|
|
|
66
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see popNonEmptyQueue).
|
|
61
67
|
const res = await client
|
|
62
68
|
.queue('test-queue-v2-pop-with-ack')
|
|
63
69
|
.batch(1)
|
|
64
|
-
.wait(
|
|
70
|
+
.wait(true)
|
|
65
71
|
.pop()
|
|
66
72
|
|
|
67
73
|
const resAck = await client.ack(res[0])
|
|
@@ -88,10 +94,11 @@ export async function popWithAckReconsume(client) {
|
|
|
88
94
|
.queue('test-queue-v2-pop-with-ack-reconsume')
|
|
89
95
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
90
96
|
|
|
97
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see popNonEmptyQueue).
|
|
91
98
|
const res = await client
|
|
92
99
|
.queue('test-queue-v2-pop-with-ack-reconsume')
|
|
93
100
|
.batch(1)
|
|
94
|
-
.wait(
|
|
101
|
+
.wait(true)
|
|
95
102
|
.pop()
|
|
96
103
|
|
|
97
104
|
if (res.length !== 1) {
|
|
@@ -112,3 +119,218 @@ export async function popWithAckReconsume(client) {
|
|
|
112
119
|
return { success: false, message: `Expected 1 message, got ${res2.length}` }
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// v4 Multi-Partition Pop Tests
|
|
124
|
+
// ============================================================================
|
|
125
|
+
//
|
|
126
|
+
// Exercises the .partitions(N) builder method end-to-end against the
|
|
127
|
+
// pop_unified_batch_v4 procedure:
|
|
128
|
+
// * Server drains up to N partitions per call
|
|
129
|
+
// * Each message carries its own partitionId / leaseId / partition
|
|
130
|
+
// * batch(B) is a global cap on TOTAL messages across all partitions
|
|
131
|
+
// * Default (no .partitions() call) preserves single-partition behaviour
|
|
132
|
+
|
|
133
|
+
export async function popV4MultiPartitionBasic(client) {
|
|
134
|
+
const queueName = 'test-queue-v2-v4-pop-basic'
|
|
135
|
+
const queue = await client.queue(queueName).create()
|
|
136
|
+
if (!queue.configured) {
|
|
137
|
+
return { success: false, message: 'Queue not created' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Seed 3 partitions × 2 messages each = 6 total.
|
|
141
|
+
for (let p = 0; p < 3; p++) {
|
|
142
|
+
await client
|
|
143
|
+
.queue(queueName)
|
|
144
|
+
.partition(`p${p}`)
|
|
145
|
+
.push([
|
|
146
|
+
{ data: { p, m: 0 } },
|
|
147
|
+
{ data: { p, m: 1 } },
|
|
148
|
+
])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Allow the post-push partition_lookup follow-up (PUSHPOPLOOKUPSOL)
|
|
152
|
+
// to settle before the wildcard scan.
|
|
153
|
+
await new Promise(r => setTimeout(r, 500))
|
|
154
|
+
|
|
155
|
+
const res = await client
|
|
156
|
+
.queue(queueName)
|
|
157
|
+
.batch(100)
|
|
158
|
+
.partitions(3)
|
|
159
|
+
.wait(false)
|
|
160
|
+
.pop()
|
|
161
|
+
|
|
162
|
+
if (res.length !== 6) {
|
|
163
|
+
return { success: false, message: `Expected 6 messages across 3 partitions, got ${res.length}` }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Each message must carry its own partitionId; partitionIds must
|
|
167
|
+
// span all 3 partitions; leaseIds must collapse to a single value.
|
|
168
|
+
const distinctPartitionIds = new Set(res.map(m => m.partitionId))
|
|
169
|
+
const distinctLeaseIds = new Set(res.map(m => m.leaseId).filter(Boolean))
|
|
170
|
+
|
|
171
|
+
if (distinctPartitionIds.size !== 3) {
|
|
172
|
+
return { success: false, message: `Expected 3 distinct partitionIds, got ${distinctPartitionIds.size}` }
|
|
173
|
+
}
|
|
174
|
+
if (distinctLeaseIds.size !== 1) {
|
|
175
|
+
return { success: false, message: `All messages must share a single leaseId, got ${distinctLeaseIds.size} distinct` }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { success: true, message: 'Multi-partition pop returned messages spanning 3 partitions with shared leaseId' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function popV4GlobalCap(client) {
|
|
182
|
+
const queueName = 'test-queue-v2-v4-pop-cap'
|
|
183
|
+
const queue = await client.queue(queueName).create()
|
|
184
|
+
if (!queue.configured) {
|
|
185
|
+
return { success: false, message: 'Queue not created' }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Seed 5 partitions × 100 messages each = 500 total available.
|
|
189
|
+
for (let p = 0; p < 5; p++) {
|
|
190
|
+
const payloads = []
|
|
191
|
+
for (let m = 0; m < 100; m++) payloads.push({ data: { p, m } })
|
|
192
|
+
await client.queue(queueName).partition(`p${p}`).push(payloads)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await new Promise(r => setTimeout(r, 500))
|
|
196
|
+
|
|
197
|
+
// batch=10 is the GLOBAL cap, partitions=5 lets us span partitions.
|
|
198
|
+
// We must receive EXACTLY 10 messages, not 5*10 or 5*100.
|
|
199
|
+
const res = await client
|
|
200
|
+
.queue(queueName)
|
|
201
|
+
.batch(10)
|
|
202
|
+
.partitions(5)
|
|
203
|
+
.wait(false)
|
|
204
|
+
.pop()
|
|
205
|
+
|
|
206
|
+
if (res.length !== 10) {
|
|
207
|
+
return { success: false, message: `batch(10) must be a hard global cap, got ${res.length}` }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { success: true, message: 'Global batch cap respected across multi-partition pop' }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function popV4DefaultOne(client) {
|
|
214
|
+
const queueName = 'test-queue-v2-v4-pop-default'
|
|
215
|
+
const queue = await client.queue(queueName).create()
|
|
216
|
+
if (!queue.configured) {
|
|
217
|
+
return { success: false, message: 'Queue not created' }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Seed 4 partitions × 5 messages each.
|
|
221
|
+
for (let p = 0; p < 4; p++) {
|
|
222
|
+
const payloads = []
|
|
223
|
+
for (let m = 0; m < 5; m++) payloads.push({ data: { p, m } })
|
|
224
|
+
await client.queue(queueName).partition(`p${p}`).push(payloads)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await new Promise(r => setTimeout(r, 500))
|
|
228
|
+
|
|
229
|
+
// No .partitions() → max_partitions defaults to 1 → single partition.
|
|
230
|
+
const res = await client
|
|
231
|
+
.queue(queueName)
|
|
232
|
+
.batch(100)
|
|
233
|
+
.wait(false)
|
|
234
|
+
.pop()
|
|
235
|
+
|
|
236
|
+
if (res.length !== 5) {
|
|
237
|
+
return { success: false, message: `Default max_partitions=1 should drain only one partition (5 msgs), got ${res.length}` }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const distinctPartitionIds = new Set(res.map(m => m.partitionId))
|
|
241
|
+
if (distinctPartitionIds.size !== 1) {
|
|
242
|
+
return { success: false, message: `All messages should come from a single partition, got ${distinctPartitionIds.size}` }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { success: true, message: 'Default pop preserves legacy single-partition behaviour' }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function popV4MultiPartitionAck(client) {
|
|
249
|
+
const queueName = 'test-queue-v2-v4-pop-ack'
|
|
250
|
+
const queue = await client.queue(queueName).create()
|
|
251
|
+
if (!queue.configured) {
|
|
252
|
+
return { success: false, message: 'Queue not created' }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Seed 2 partitions × 3 messages each.
|
|
256
|
+
for (let p = 0; p < 2; p++) {
|
|
257
|
+
await client.queue(queueName).partition(`p${p}`).push([
|
|
258
|
+
{ data: { p, m: 0 } },
|
|
259
|
+
{ data: { p, m: 1 } },
|
|
260
|
+
{ data: { p, m: 2 } },
|
|
261
|
+
])
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await new Promise(r => setTimeout(r, 500))
|
|
265
|
+
|
|
266
|
+
const res = await client
|
|
267
|
+
.queue(queueName)
|
|
268
|
+
.batch(100)
|
|
269
|
+
.partitions(2)
|
|
270
|
+
.wait(false)
|
|
271
|
+
.pop()
|
|
272
|
+
|
|
273
|
+
if (res.length !== 6) {
|
|
274
|
+
return { success: false, message: `Expected 6 messages across 2 partitions, got ${res.length}` }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ACK each message individually using its own per-message partitionId
|
|
278
|
+
// and the shared leaseId. The v4 procedure has set each partition's
|
|
279
|
+
// batch_size to its actual contribution (3), so once 3 ACKs land for
|
|
280
|
+
// a given partition_id, that partition's lease auto-releases.
|
|
281
|
+
const ackResult = await client.ack(res, true)
|
|
282
|
+
if (!ackResult.success) {
|
|
283
|
+
return { success: false, message: `Batch ACK failed: ${ackResult.error || 'unknown'}` }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { success: true, message: 'Per-message ACK across multi-partition pop succeeded' }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function popV4SharedLeaseRenew(client) {
|
|
290
|
+
const queueName = 'test-queue-v2-v4-pop-renew'
|
|
291
|
+
const queue = await client
|
|
292
|
+
.queue(queueName)
|
|
293
|
+
.config({ leaseTime: 60 })
|
|
294
|
+
.create()
|
|
295
|
+
if (!queue.configured) {
|
|
296
|
+
return { success: false, message: 'Queue not created' }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Seed 3 partitions × 1 message each.
|
|
300
|
+
for (let p = 0; p < 3; p++) {
|
|
301
|
+
await client.queue(queueName).partition(`p${p}`).push([{ data: { p } }])
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await new Promise(r => setTimeout(r, 500))
|
|
305
|
+
|
|
306
|
+
const res = await client
|
|
307
|
+
.queue(queueName)
|
|
308
|
+
.batch(100)
|
|
309
|
+
.partitions(3)
|
|
310
|
+
.wait(false)
|
|
311
|
+
.pop()
|
|
312
|
+
|
|
313
|
+
if (res.length !== 3) {
|
|
314
|
+
return { success: false, message: `Expected 3 messages from 3 partitions, got ${res.length}` }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// All 3 messages share one leaseId. Passing res to renew() exercises
|
|
318
|
+
// both the dedup logic AND the server-side multi-row lease extension
|
|
319
|
+
// (renew_lease_v2 filters on worker_id alone, which matches all 3
|
|
320
|
+
// partition_consumers rows).
|
|
321
|
+
const renewResult = await client.renew(res)
|
|
322
|
+
// renew() returns array when input is array. After dedup, it's a
|
|
323
|
+
// single result for the shared leaseId.
|
|
324
|
+
if (!Array.isArray(renewResult) || renewResult.length !== 1) {
|
|
325
|
+
return { success: false, message: `Expected dedup to collapse to 1 renew call, got ${Array.isArray(renewResult) ? renewResult.length : 'non-array'}` }
|
|
326
|
+
}
|
|
327
|
+
if (!renewResult[0].success) {
|
|
328
|
+
return { success: false, message: `Renew failed: ${renewResult[0].error}` }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Cleanup: release leases by acking everything.
|
|
332
|
+
await client.ack(res, true)
|
|
333
|
+
|
|
334
|
+
return { success: true, message: 'Shared leaseId renewed once and dedup confirmed' }
|
|
335
|
+
}
|
|
336
|
+
|
package/test-v2/push.js
CHANGED
|
@@ -221,10 +221,15 @@ export async function pushLargePayload(client) {
|
|
|
221
221
|
.queue('test-queue-large-payload')
|
|
222
222
|
.push([{ data: largePayload }])
|
|
223
223
|
|
|
224
|
+
// wait(true) rides out the PUSHPOPLOOKUPSOL race: partition_lookup is
|
|
225
|
+
// updated asynchronously after push commits, so an immediate
|
|
226
|
+
// wait(false) wildcard pop can race past the not-yet-committed lookup
|
|
227
|
+
// row. Long-poll re-runs the candidate scan and picks up the row
|
|
228
|
+
// once it commits (typically within ms).
|
|
224
229
|
const received = await client
|
|
225
230
|
.queue('test-queue-large-payload')
|
|
226
231
|
.batch(1)
|
|
227
|
-
.wait(
|
|
232
|
+
.wait(true)
|
|
228
233
|
.pop()
|
|
229
234
|
|
|
230
235
|
if (!received || !received[0].data || !received[0].data.array || received[0].data.array.length !== 10000) {
|
|
@@ -243,10 +248,11 @@ export async function pushNullPayload(client) {
|
|
|
243
248
|
.queue('test-queue-null-payload')
|
|
244
249
|
.push([{ data: null }])
|
|
245
250
|
|
|
251
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
246
252
|
const received = await client
|
|
247
253
|
.queue('test-queue-null-payload')
|
|
248
254
|
.batch(1)
|
|
249
|
-
.wait(
|
|
255
|
+
.wait(true)
|
|
250
256
|
.pop()
|
|
251
257
|
|
|
252
258
|
if (!received || received[0].data !== null) {
|
|
@@ -265,10 +271,11 @@ export async function pushEmptyPayload(client) {
|
|
|
265
271
|
.queue('test-queue-empty-payload')
|
|
266
272
|
.push([{ data: {} }])
|
|
267
273
|
|
|
274
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
268
275
|
const received = await client
|
|
269
276
|
.queue('test-queue-empty-payload')
|
|
270
277
|
.batch(1)
|
|
271
|
-
.wait(
|
|
278
|
+
.wait(true)
|
|
272
279
|
.pop()
|
|
273
280
|
|
|
274
281
|
if (!received || Object.keys(received[0].data).length !== 0) {
|
|
@@ -278,6 +285,283 @@ export async function pushEmptyPayload(client) {
|
|
|
278
285
|
return { success: true }
|
|
279
286
|
}
|
|
280
287
|
|
|
288
|
+
// ============================================================================
|
|
289
|
+
// push_messages_v3 coverage (see PUSH_IMPROVEMENT.md §8.1).
|
|
290
|
+
// These tests exercise the contract that the TEMP-TABLE-free rewrite must
|
|
291
|
+
// preserve: single-call semantics (new inserts, pre-existing duplicates,
|
|
292
|
+
// intra-batch duplicates, missing transactionId, empty batch, malformed
|
|
293
|
+
// traceId) plus the cross-call concurrent-race invariant.
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
function distinctCount(arr) {
|
|
297
|
+
return new Set(arr).size
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function testSimplePushRoundTrip(client) {
|
|
301
|
+
const queueName = 'test-v3-simple-roundtrip'
|
|
302
|
+
const queue = await client.queue(queueName).create()
|
|
303
|
+
if (!queue.configured) {
|
|
304
|
+
return { success: false, message: 'Queue not created' }
|
|
305
|
+
}
|
|
306
|
+
const res = await client.queue(queueName).push([{ data: { n: 1 } }])
|
|
307
|
+
if (!res[0] || res[0].status !== 'queued') {
|
|
308
|
+
return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
|
|
309
|
+
}
|
|
310
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
311
|
+
const popped = await client.queue(queueName).batch(1).wait(true).pop()
|
|
312
|
+
if (!popped || popped.length !== 1 || popped[0].data.n !== 1) {
|
|
313
|
+
return { success: false, message: 'Round-trip pop failed' }
|
|
314
|
+
}
|
|
315
|
+
return { success: true }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function testPushAutoCreatesQueueAndPartition(client) {
|
|
319
|
+
// Queue and partition are never referenced before — must be auto-created.
|
|
320
|
+
const queueName = 'test-v3-autocreate-' + crypto.randomBytes(4).toString('hex')
|
|
321
|
+
const partitionName = 'autopart-' + crypto.randomBytes(4).toString('hex')
|
|
322
|
+
const res = await client.queue(queueName).partition(partitionName)
|
|
323
|
+
.push([{ data: { hello: 'auto' } }])
|
|
324
|
+
if (!res[0] || res[0].status !== 'queued') {
|
|
325
|
+
return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
|
|
326
|
+
}
|
|
327
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
328
|
+
const popped = await client.queue(queueName).partition(partitionName)
|
|
329
|
+
.batch(1).wait(true).pop()
|
|
330
|
+
if (!popped || popped.length !== 1) {
|
|
331
|
+
return { success: false, message: 'Auto-created partition did not receive the message' }
|
|
332
|
+
}
|
|
333
|
+
return { success: true }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function testPushPreExistingDuplicate(client) {
|
|
337
|
+
const queueName = 'test-v3-pre-existing-dup'
|
|
338
|
+
const queue = await client.queue(queueName).create()
|
|
339
|
+
if (!queue.configured) {
|
|
340
|
+
return { success: false, message: 'Queue not created' }
|
|
341
|
+
}
|
|
342
|
+
const txn = 'txn-pre-' + crypto.randomBytes(4).toString('hex')
|
|
343
|
+
const first = await client.queue(queueName)
|
|
344
|
+
.push([{ transactionId: txn, data: { i: 1 } }])
|
|
345
|
+
if (first[0].status !== 'queued') {
|
|
346
|
+
return { success: false, message: `First push not queued: ${JSON.stringify(first[0])}` }
|
|
347
|
+
}
|
|
348
|
+
const firstId = first[0].message_id
|
|
349
|
+
const second = await client.queue(queueName)
|
|
350
|
+
.push([{ transactionId: txn, data: { i: 2 } }])
|
|
351
|
+
if (second[0].status !== 'duplicate') {
|
|
352
|
+
return { success: false, message: `Second push not duplicate: ${JSON.stringify(second[0])}` }
|
|
353
|
+
}
|
|
354
|
+
if (!firstId || second[0].message_id !== firstId) {
|
|
355
|
+
return { success: false, message: `Duplicate id mismatch: want ${firstId}, got ${second[0].message_id}` }
|
|
356
|
+
}
|
|
357
|
+
return { success: true }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function testPushIntraBatchDuplicatesAllNew(client) {
|
|
361
|
+
const queueName = 'test-v3-intra-batch-new'
|
|
362
|
+
const queue = await client.queue(queueName).create()
|
|
363
|
+
if (!queue.configured) {
|
|
364
|
+
return { success: false, message: 'Queue not created' }
|
|
365
|
+
}
|
|
366
|
+
const txn = 'txn-ib-' + crypto.randomBytes(4).toString('hex')
|
|
367
|
+
const res = await client.queue(queueName).push([
|
|
368
|
+
{ transactionId: txn, data: { i: 1 } },
|
|
369
|
+
{ transactionId: txn, data: { i: 2 } },
|
|
370
|
+
{ transactionId: txn, data: { i: 3 } }
|
|
371
|
+
])
|
|
372
|
+
if (!Array.isArray(res) || res.length !== 3) {
|
|
373
|
+
return { success: false, message: `Expected 3 entries, got ${res && res.length}` }
|
|
374
|
+
}
|
|
375
|
+
const queued = res.filter(r => r.status === 'queued')
|
|
376
|
+
const dupes = res.filter(r => r.status === 'duplicate')
|
|
377
|
+
if (queued.length !== 1 || dupes.length !== 2) {
|
|
378
|
+
return { success: false, message: `Want 1 queued / 2 duplicate, got ${JSON.stringify(res)}` }
|
|
379
|
+
}
|
|
380
|
+
const winnerId = queued[0].message_id
|
|
381
|
+
if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
|
|
382
|
+
return { success: false, message: `Intra-batch duplicates must share winner id ${winnerId}; got ${JSON.stringify(dupes)}` }
|
|
383
|
+
}
|
|
384
|
+
return { success: true }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function testPushIntraBatchDuplicatesMatchingPreExisting(client) {
|
|
388
|
+
const queueName = 'test-v3-intra-batch-existing'
|
|
389
|
+
const queue = await client.queue(queueName).create()
|
|
390
|
+
if (!queue.configured) {
|
|
391
|
+
return { success: false, message: 'Queue not created' }
|
|
392
|
+
}
|
|
393
|
+
const txn = 'txn-ibe-' + crypto.randomBytes(4).toString('hex')
|
|
394
|
+
// Seed an existing row.
|
|
395
|
+
const seed = await client.queue(queueName)
|
|
396
|
+
.push([{ transactionId: txn, data: { seed: true } }])
|
|
397
|
+
if (seed[0].status !== 'queued') {
|
|
398
|
+
return { success: false, message: 'Seed push failed' }
|
|
399
|
+
}
|
|
400
|
+
const existingId = seed[0].message_id
|
|
401
|
+
const res = await client.queue(queueName).push([
|
|
402
|
+
{ transactionId: txn, data: { i: 1 } },
|
|
403
|
+
{ transactionId: txn, data: { i: 2 } }
|
|
404
|
+
])
|
|
405
|
+
if (!Array.isArray(res) || res.length !== 2) {
|
|
406
|
+
return { success: false, message: `Expected 2 entries, got ${JSON.stringify(res)}` }
|
|
407
|
+
}
|
|
408
|
+
if (!res.every(r => r.status === 'duplicate' && r.message_id === existingId)) {
|
|
409
|
+
return { success: false, message: `All entries must duplicate onto ${existingId}, got ${JSON.stringify(res)}` }
|
|
410
|
+
}
|
|
411
|
+
return { success: true }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function testPushConcurrentBatchesToSameKey(client) {
|
|
415
|
+
const queueName = 'test-v3-concurrent-same-key'
|
|
416
|
+
const queue = await client.queue(queueName).create()
|
|
417
|
+
if (!queue.configured) {
|
|
418
|
+
return { success: false, message: 'Queue not created' }
|
|
419
|
+
}
|
|
420
|
+
const txn = 'txn-conc-' + crypto.randomBytes(4).toString('hex')
|
|
421
|
+
// Fire 8 concurrent single-item pushes using the same transactionId.
|
|
422
|
+
// Exactly one must win; the others must report duplicate with the winner's id.
|
|
423
|
+
const N = 8
|
|
424
|
+
const results = await Promise.all(
|
|
425
|
+
Array.from({ length: N }, (_, i) =>
|
|
426
|
+
client.queue(queueName)
|
|
427
|
+
.push([{ transactionId: txn, data: { i } }])
|
|
428
|
+
.then(r => r[0])
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
const queued = results.filter(r => r.status === 'queued')
|
|
432
|
+
const dupes = results.filter(r => r.status === 'duplicate')
|
|
433
|
+
if (queued.length !== 1) {
|
|
434
|
+
return { success: false, message: `Want exactly 1 queued, got ${queued.length}: ${JSON.stringify(results)}` }
|
|
435
|
+
}
|
|
436
|
+
if (dupes.length !== N - 1) {
|
|
437
|
+
return { success: false, message: `Want ${N - 1} duplicates, got ${dupes.length}` }
|
|
438
|
+
}
|
|
439
|
+
const winnerId = queued[0].message_id
|
|
440
|
+
if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
|
|
441
|
+
return { success: false, message: `All duplicates must report winner id ${winnerId}: ${JSON.stringify(dupes)}` }
|
|
442
|
+
}
|
|
443
|
+
return { success: true }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function testPushMissingTransactionId(client) {
|
|
447
|
+
const queueName = 'test-v3-missing-txn'
|
|
448
|
+
const queue = await client.queue(queueName).create()
|
|
449
|
+
if (!queue.configured) {
|
|
450
|
+
return { success: false, message: 'Queue not created' }
|
|
451
|
+
}
|
|
452
|
+
// 100 items, no transactionId provided. Each must get a distinct id and
|
|
453
|
+
// all should end up queued (no accidental intra-batch collisions from a
|
|
454
|
+
// volatile-function-inlining regression).
|
|
455
|
+
// NOTE: the client auto-generates a transactionId when omitted, so we
|
|
456
|
+
// bypass it via the raw HTTP push contract by supplying only `data`. The
|
|
457
|
+
// client still fills a fresh transactionId per item, which is the correct
|
|
458
|
+
// client-level contract; the server-side test below additionally proves
|
|
459
|
+
// the SP itself handles missing txn ids by bypassing the client helper.
|
|
460
|
+
const items = Array.from({ length: 100 }, (_, i) => ({ data: { i } }))
|
|
461
|
+
const res = await client.queue(queueName).push(items)
|
|
462
|
+
if (!Array.isArray(res) || res.length !== 100) {
|
|
463
|
+
return { success: false, message: `Expected 100 results, got ${res && res.length}` }
|
|
464
|
+
}
|
|
465
|
+
if (!res.every(r => r.status === 'queued')) {
|
|
466
|
+
const bad = res.find(r => r.status !== 'queued')
|
|
467
|
+
return { success: false, message: `All must queue; first bad entry ${JSON.stringify(bad)}` }
|
|
468
|
+
}
|
|
469
|
+
if (distinctCount(res.map(r => r.message_id)) !== 100) {
|
|
470
|
+
return { success: false, message: `Expected 100 distinct message ids` }
|
|
471
|
+
}
|
|
472
|
+
return { success: true }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function testPushEmptyArray(client) {
|
|
476
|
+
const queueName = 'test-v3-empty-array'
|
|
477
|
+
const queue = await client.queue(queueName).create()
|
|
478
|
+
if (!queue.configured) {
|
|
479
|
+
return { success: false, message: 'Queue not created' }
|
|
480
|
+
}
|
|
481
|
+
const res = await client.queue(queueName).push([])
|
|
482
|
+
if (!Array.isArray(res) || res.length !== 0) {
|
|
483
|
+
return { success: false, message: `Expected [], got ${JSON.stringify(res)}` }
|
|
484
|
+
}
|
|
485
|
+
return { success: true }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function testPushMalformedTraceId(client) {
|
|
489
|
+
// Per plan §5: malformed traceId aborts the batch in both v2 and v3.
|
|
490
|
+
// We only need to verify the behavior is stable (either rejected at the
|
|
491
|
+
// client validator or surfaced as a failure from the server). The client
|
|
492
|
+
// QueueBuilder currently drops invalid UUIDs silently — in which case the
|
|
493
|
+
// push still succeeds and traceId is null. Accept either outcome as long
|
|
494
|
+
// as we don't see data corruption.
|
|
495
|
+
const queueName = 'test-v3-malformed-trace'
|
|
496
|
+
const queue = await client.queue(queueName).create()
|
|
497
|
+
if (!queue.configured) {
|
|
498
|
+
return { success: false, message: 'Queue not created' }
|
|
499
|
+
}
|
|
500
|
+
let res
|
|
501
|
+
try {
|
|
502
|
+
res = await client.queue(queueName)
|
|
503
|
+
.push([{ traceId: 'not-a-uuid', data: { i: 1 } }])
|
|
504
|
+
} catch (err) {
|
|
505
|
+
// Server rejected: acceptable per plan.
|
|
506
|
+
return { success: true, message: 'Server rejected malformed traceId (expected)' }
|
|
507
|
+
}
|
|
508
|
+
if (!Array.isArray(res) || res.length !== 1) {
|
|
509
|
+
return { success: false, message: `Expected 1 result, got ${JSON.stringify(res)}` }
|
|
510
|
+
}
|
|
511
|
+
// If the client dropped the bad traceId, we'll get a queued row with
|
|
512
|
+
// trace_id=null. That is an acceptable, well-documented outcome.
|
|
513
|
+
if (res[0].status !== 'queued' && res[0].status !== 'failed') {
|
|
514
|
+
return { success: false, message: `Unexpected status: ${JSON.stringify(res[0])}` }
|
|
515
|
+
}
|
|
516
|
+
return { success: true }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function testPushProducerSubEmptyStringBecomesNull(client) {
|
|
520
|
+
// 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.
|
|
528
|
+
const queueName = 'test-v3-producer-sub-null'
|
|
529
|
+
const queue = await client.queue(queueName).create()
|
|
530
|
+
if (!queue.configured) {
|
|
531
|
+
return { success: false, message: 'Queue not created' }
|
|
532
|
+
}
|
|
533
|
+
const res = await client.queue(queueName).push([{ data: { x: 1 } }])
|
|
534
|
+
if (res[0].status !== 'queued') {
|
|
535
|
+
return { success: false, message: 'Push failed' }
|
|
536
|
+
}
|
|
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()
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
281
565
|
export async function pushEncryptedPayload(client) {
|
|
282
566
|
const queue = await client.queue('test-queue-encrypted-payload')
|
|
283
567
|
.config({ encryptionEnabled: true })
|
package/test-v2/run.js
CHANGED
|
@@ -13,6 +13,8 @@ import * as maintenanceTests from './maintenance.js'
|
|
|
13
13
|
import * as retentionTests from './retention.js'
|
|
14
14
|
import * as bootstrapTests from './bootstrap.js'
|
|
15
15
|
import * as loggerTests from './logger.js'
|
|
16
|
+
import * as watermarkTests from './watermark.js'
|
|
17
|
+
import * as authTests from './auth.js'
|
|
16
18
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
17
19
|
|
|
18
20
|
|
|
@@ -115,7 +117,9 @@ async function main() {
|
|
|
115
117
|
retentionTests,
|
|
116
118
|
maintenanceTests,
|
|
117
119
|
bootstrapTests,
|
|
118
|
-
loggerTests
|
|
120
|
+
loggerTests,
|
|
121
|
+
watermarkTests,
|
|
122
|
+
authTests
|
|
119
123
|
]
|
|
120
124
|
|
|
121
125
|
const aiTests = [
|