queen-mq 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -18
- package/client-v2/Queen.js +45 -13
- package/client-v2/README.md +26 -11
- package/client-v2/admin/Admin.js +0 -47
- package/client-v2/builders/QueueBuilder.js +33 -10
- package/client-v2/builders/TimerBuilder.js +2 -2
- package/client-v2/builders/TransactionBuilder.js +35 -9
- package/client-v2/consumer/ConsumerManager.js +67 -46
- package/client-v2/ephemeral/Ephemeral.js +7 -9
- package/client-v2/kv/Kv.js +4 -4
- package/client-v2/kv/expiry.js +1 -1
- package/client-v2/streams/Stream.js +8 -3
- package/client-v2/streams/helpers/rateLimiter.js +4 -4
- package/client-v2/streams/operators/GateOperator.js +9 -2
- package/client-v2/streams/operators/ReduceOperator.js +2 -2
- package/client-v2/streams/operators/WindowSessionOperator.js +3 -3
- package/client-v2/streams/runtime/Runner.js +111 -54
- package/client-v2/streams/runtime/cycle.js +4 -4
- package/client-v2/streams/runtime/register.js +1 -1
- package/client-v2/utils/conflation.js +0 -6
- package/client-v2/utils/consumerGroup.js +54 -0
- package/package.json +5 -8
- package/test-v2/_kvtimers.js +12 -13
- package/test-v2/ackwindow.js +12 -192
- package/test-v2/bootstrap.js +2 -2
- package/test-v2/conflation-unit/conflationWire.test.js +0 -12
- package/test-v2/consume.js +41 -1
- package/test-v2/consumer-unit/handlerError.test.js +161 -0
- package/test-v2/docs.js +5 -4
- package/test-v2/http-unit/pushStatus.test.js +142 -0
- package/test-v2/http-unit/renew.test.js +96 -0
- package/test-v2/kv-unit/timerWire.test.js +1 -1
- package/test-v2/kv-unit/txnWire.test.js +101 -2
- package/test-v2/kv.js +6 -5
- package/test-v2/pop.js +28 -1
- package/test-v2/run.js +50 -114
- package/test-v2/runner-unit/fatalExit.test.js +64 -0
- package/test-v2/semantics.js +20 -34
- package/test-v2/stream/_helpers.js +10 -19
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/gate.js +54 -0
- package/test-v2/stream/index.js +2 -0
- package/test-v2/stream/tumbling.js +3 -3
- package/test-v2/streams-unit/ack.test.js +61 -0
- package/test-v2/streams-unit/cycle.test.js +1 -1
- package/test-v2/streams-unit/e2e.test.js +6 -10
- package/test-v2/streams-unit/gate.test.js +189 -0
- package/test-v2/timers.js +6 -5
- package/test-v2/transaction.js +169 -0
- package/test-v2/watermark.js +38 -176
- package/test-v2/maintenance.js +0 -277
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { describe, it } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const suiteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
|
9
|
+
|
|
10
|
+
// A loopback port with nothing listening on it: bind an ephemeral port, then
|
|
11
|
+
// release it. Not port 1: fetch() rejects the Fetch-spec "bad ports" (1 is one
|
|
12
|
+
// of them) before it ever connects, so the preflight would fail with "bad port"
|
|
13
|
+
// and never exercise a refused connection.
|
|
14
|
+
function closedLoopbackPort() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const server = net.createServer()
|
|
17
|
+
server.once('error', reject)
|
|
18
|
+
server.listen(0, '127.0.0.1', () => {
|
|
19
|
+
const { port } = server.address()
|
|
20
|
+
server.close(error => (error ? reject(error) : resolve(port)))
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function runWithUnreachableBroker(brokerUrl) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const env = { ...process.env, QUEEN_SERVER_URL: brokerUrl }
|
|
28
|
+
// TEST_CONFIG=multiple would point run.js at its fixed localhost URLs
|
|
29
|
+
// instead of QUEEN_SERVER_URL.
|
|
30
|
+
delete env.TEST_CONFIG
|
|
31
|
+
const child = spawn(process.execPath, ['test-v2/run.js', 'human'], {
|
|
32
|
+
cwd: suiteRoot,
|
|
33
|
+
env,
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
let output = ''
|
|
38
|
+
const deadline = setTimeout(() => {
|
|
39
|
+
child.kill('SIGKILL')
|
|
40
|
+
reject(new Error(`integration runner did not exit within 5s:\n${output}`))
|
|
41
|
+
}, 5000)
|
|
42
|
+
child.stdout.on('data', chunk => { output += chunk })
|
|
43
|
+
child.stderr.on('data', chunk => { output += chunk })
|
|
44
|
+
child.once('error', error => {
|
|
45
|
+
clearTimeout(deadline)
|
|
46
|
+
reject(error)
|
|
47
|
+
})
|
|
48
|
+
child.once('close', (code, signal) => {
|
|
49
|
+
clearTimeout(deadline)
|
|
50
|
+
resolve({ code, signal, output })
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('integration runner lifecycle', () => {
|
|
56
|
+
it('returns a failure status when the broker preflight fails', async () => {
|
|
57
|
+
const port = await closedLoopbackPort()
|
|
58
|
+
const result = await runWithUnreachableBroker(`http://127.0.0.1:${port}`)
|
|
59
|
+
|
|
60
|
+
assert.equal(result.signal, null)
|
|
61
|
+
assert.equal(result.code, 1, result.output)
|
|
62
|
+
assert.match(result.output, /Main error: broker preflight .*ECONNREFUSED/)
|
|
63
|
+
})
|
|
64
|
+
})
|
package/test-v2/semantics.js
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* AFTER the group registered (durable subscription record).
|
|
17
17
|
* 8. Queue-mode pops never skip backlog, even when carrying subscriptionMode.
|
|
18
18
|
* 9. Empty polls while another worker holds the lease never strand backlog
|
|
19
|
-
* (
|
|
20
|
-
* 10. Push-only (never-configured) queues
|
|
21
|
-
* resources, discoverable by namespace pop.
|
|
19
|
+
* (an empty answer must not hide the partition once the lease lapses).
|
|
20
|
+
* 10. Push-only (never-configured) queues are registered by the push: visible
|
|
21
|
+
* in resources, discoverable by namespace pop.
|
|
22
22
|
* 11. GET /api/v1/messages/:pid/:txn keeps the full v0.16.0 shape (including
|
|
23
23
|
* consumerGroups[].name — not .group).
|
|
24
24
|
* 12. Transactions honor `retry` and `dlq` ack statuses (not collapsed to a
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* Run: node run.js <testName> e.g. node run.js implicitAckCompletesBatch
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
import {
|
|
42
|
+
import { TEST_CONFIG } from './run.js'
|
|
43
43
|
|
|
44
44
|
// Lazy: run.js imports this module before TEST_CONFIG is initialized (TDZ),
|
|
45
45
|
// so the base URL must be resolved at call time, not module-eval time.
|
|
@@ -369,7 +369,7 @@ export async function queueModePopNeverSkipsBacklog(client) {
|
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
// ============================================================================
|
|
372
|
-
// 9.
|
|
372
|
+
// 9. Empty polls during a held lease must not strand backlog
|
|
373
373
|
// ============================================================================
|
|
374
374
|
export async function leasedBacklogNotStrandedByEmptyPolls(client) {
|
|
375
375
|
const queue = uniq('watermark-lease')
|
|
@@ -385,34 +385,26 @@ export async function leasedBacklogNotStrandedByEmptyPolls(client) {
|
|
|
385
385
|
if (a.length !== 1) return { success: false, message: 'Worker A did not get the message' }
|
|
386
386
|
|
|
387
387
|
// Worker B (same group) polls empty repeatedly while A holds the lease.
|
|
388
|
-
//
|
|
389
|
-
//
|
|
388
|
+
// The backlog is invisible here ONLY because of A's lease: a broker that
|
|
389
|
+
// remembers these empty answers as "nothing to deliver" keeps hiding the
|
|
390
|
+
// partition after the lease lapses.
|
|
390
391
|
for (let i = 0; i < 3; i++) {
|
|
391
392
|
await client.queue(queue).group(group).batch(1).wait(false).pop()
|
|
392
393
|
await sleep(100)
|
|
393
394
|
}
|
|
394
395
|
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
// Queue identity is now the queen.queues id (log_queues merged away).
|
|
398
|
-
await dbPool.query(`
|
|
399
|
-
UPDATE queen.log_partitions p
|
|
400
|
-
SET last_write_at = last_write_at - interval '10 minutes'
|
|
401
|
-
FROM queen.queues q
|
|
402
|
-
WHERE p.queue_id = q.id AND q.name = $1
|
|
403
|
-
`, [queue])
|
|
404
|
-
|
|
405
|
-
// Wait for A's lease to lapse. NO new push arrives.
|
|
396
|
+
// Wait for A's lease to lapse. NO new push arrives, so nothing but the
|
|
397
|
+
// lease expiry can make the message deliverable again.
|
|
406
398
|
await sleep(3500)
|
|
407
399
|
|
|
408
400
|
const b = await popRetry(client, queue, { group, tries: 10 })
|
|
409
401
|
if (b.length !== 1) {
|
|
410
402
|
return {
|
|
411
403
|
success: false,
|
|
412
|
-
message: 'Backlog stranded: empty polls during a held lease
|
|
404
|
+
message: 'Backlog stranded: after empty polls during a held lease, the message never came back once the lease lapsed'
|
|
413
405
|
}
|
|
414
406
|
}
|
|
415
|
-
return { success: true, message: 'Backlog survived lease churn + empty polls
|
|
407
|
+
return { success: true, message: 'Backlog survived lease churn + empty polls' }
|
|
416
408
|
}
|
|
417
409
|
|
|
418
410
|
// ============================================================================
|
|
@@ -433,7 +425,7 @@ export async function pushOnlyQueueIsDiscoverable(client) {
|
|
|
433
425
|
const list = Array.isArray(body) ? body : (body.queues || [])
|
|
434
426
|
const entry = list.find(q => (q.name || q.queue) === queue)
|
|
435
427
|
if (!entry) {
|
|
436
|
-
return { success: false, message: 'Push-only queue missing from /api/v1/resources/queues (
|
|
428
|
+
return { success: false, message: 'Push-only queue missing from /api/v1/resources/queues (never registered by the push?)' }
|
|
437
429
|
}
|
|
438
430
|
const entryNs = entry.namespace ?? entry.ns
|
|
439
431
|
if (entryNs !== ns) {
|
|
@@ -455,19 +447,13 @@ export async function pushOnlyQueueIsDiscoverable(client) {
|
|
|
455
447
|
if (!found) await sleep(200)
|
|
456
448
|
}
|
|
457
449
|
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
WHERE q.name = $1
|
|
466
|
-
),
|
|
467
|
-
d1 AS (DELETE FROM queen.log_txns WHERE partition_id IN (SELECT id FROM parts)),
|
|
468
|
-
d2 AS (DELETE FROM queen.log_dlq WHERE partition_id IN (SELECT id FROM parts))
|
|
469
|
-
SELECT 1`, [queue])
|
|
470
|
-
await dbPool.query(`DELETE FROM queen.queues WHERE name = $1`, [queue])
|
|
450
|
+
// Best-effort cleanup through the public API. The lane's broker is thrown
|
|
451
|
+
// away after the run anyway, so a failed delete is not this test's verdict.
|
|
452
|
+
try {
|
|
453
|
+
await client.queue(queue).delete()
|
|
454
|
+
} catch (e) {
|
|
455
|
+
// Ignore
|
|
456
|
+
}
|
|
471
457
|
|
|
472
458
|
if (!found) {
|
|
473
459
|
return { success: false, message: 'Namespace discovery pop never found the push-only queue' }
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utilities for streaming tests.
|
|
3
3
|
*
|
|
4
|
-
* Tests in test-v2/stream/* are run live against a Queen
|
|
5
|
-
* Postgres database. Each test:
|
|
4
|
+
* Tests in test-v2/stream/* are run live against a Queen broker. Each test:
|
|
6
5
|
* - generates a unique queue/query name to avoid collisions across runs
|
|
7
6
|
* - sets up source + sink queues
|
|
8
7
|
* - runs a Stream pipeline for a bounded time
|
|
9
8
|
* - asserts on the resulting sink-queue contents and/or runner metrics
|
|
10
|
-
* - stops the stream
|
|
9
|
+
* - stops the stream (the lane's broker is thrown away after the run)
|
|
11
10
|
*
|
|
12
11
|
* Test-name uniqueness is achieved with the test function's name + a
|
|
13
12
|
* monotonic counter so the same suite can be re-run without state bleed.
|
|
@@ -20,7 +19,7 @@ let _nameCounter = 0
|
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* Build a unique queue/query name scoped to a test. The prefix is always
|
|
23
|
-
* "test-stream-"
|
|
22
|
+
* "test-stream-", the suite's test-name convention.
|
|
24
23
|
*
|
|
25
24
|
* @param {string} testName - e.g. fn.name
|
|
26
25
|
* @param {string} suffix - 'src' | 'sink' | 'query' | etc.
|
|
@@ -89,12 +88,12 @@ export async function pushSpread(client, queueName, items) {
|
|
|
89
88
|
* the broker default of 'new' its cursor would be seeded at the sink's tail
|
|
90
89
|
* and the emits under test would be invisible.
|
|
91
90
|
*
|
|
92
|
-
* NOTE on batch size: we use batch=1 so that each ack
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
91
|
+
* NOTE on batch size: we use batch=1 so that each ack reaches the end of
|
|
92
|
+
* the leased batch and the lease is released between pops. With a larger
|
|
93
|
+
* batch the lease would stay held until the acks reached the end of the
|
|
94
|
+
* full batch (often never on intermittent emits), and subsequent pops would
|
|
95
|
+
* skip the partition as "leased by another worker." Worth the round-trip
|
|
96
|
+
* overhead in tests.
|
|
98
97
|
*/
|
|
99
98
|
export async function drainSink(client, queueName, { timeoutMs = 5000, group } = {}) {
|
|
100
99
|
const cg = group || `drain-${Date.now()}-${Math.floor(Math.random() * 1e6)}`
|
|
@@ -110,7 +109,7 @@ export async function drainSink(client, queueName, { timeoutMs = 5000, group } =
|
|
|
110
109
|
.pop()
|
|
111
110
|
if (!popped || popped.length === 0) break
|
|
112
111
|
for (const m of popped) out.push(m)
|
|
113
|
-
// Pass the consumer group so the
|
|
112
|
+
// Pass the consumer group so the broker validates the lease against OUR
|
|
114
113
|
// group, not the default __QUEUE_MODE__. queen-mq uses `context.group`
|
|
115
114
|
// (NOT `consumerGroup`) for the ack-context shape.
|
|
116
115
|
await client.ack(popped, true, { group: cg })
|
|
@@ -187,11 +186,3 @@ export async function runStreamFor(streamFactory, runMs) {
|
|
|
187
186
|
await handle.stop()
|
|
188
187
|
return handle
|
|
189
188
|
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Helpful guard for tests that need a clean queen_streams.queries row for
|
|
193
|
-
* a given query name. Drops it (state cascades) before the test runs.
|
|
194
|
-
*/
|
|
195
|
-
export async function dropStreamQuery(dbPool, queryName) {
|
|
196
|
-
await dbPool.query(`DELETE FROM queen_streams.queries WHERE name = $1`, [queryName])
|
|
197
|
-
}
|
package/test-v2/stream/cron.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 'second' | 'minute' | 'hour' | 'day' | 'week'
|
|
4
4
|
*
|
|
5
5
|
* Boundaries are anchored to UTC. Day = midnight UTC; week = Monday 00:00
|
|
6
|
-
* UTC. Tests here exercise live behaviour (
|
|
6
|
+
* UTC. Tests here exercise live behaviour (broker-side state) for the second
|
|
7
7
|
* granularity (fast enough to test in seconds rather than hours).
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .gate() against a live broker: the partial ack counts SOURCE MESSAGES.
|
|
3
|
+
*
|
|
4
|
+
* The gate's partial ack is an offset commit -- the broker advances
|
|
5
|
+
* `ack.count` messages from the head of the leased batch and keeps the lease
|
|
6
|
+
* -- and the runner used to count the envelopes the gate saw instead. With a
|
|
7
|
+
* .flatMap() in front, a denied message was acked together with its
|
|
8
|
+
* predecessor and never came back (2026-10-02, 2.0.0-beta.6: 4 of 6 sink
|
|
9
|
+
* items, message 2 lost). The unit tests pin the cycle body
|
|
10
|
+
* (streams-unit/gate.test.js); this one pins the broker's side of it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Stream } from '../../client-v2/index.js'
|
|
14
|
+
import { STREAMS_URL, mkName, drainUntil, expect, summarise } from './_helpers.js'
|
|
15
|
+
|
|
16
|
+
export async function streamGateFlatMapPartialAck(client) {
|
|
17
|
+
const src = mkName('streamGateFlatMapPartialAck', 'src')
|
|
18
|
+
const sink = mkName('streamGateFlatMapPartialAck', 'sink')
|
|
19
|
+
const queryId = mkName('streamGateFlatMapPartialAck', 'q')
|
|
20
|
+
|
|
21
|
+
// A short lease: the denied tail comes back when it expires.
|
|
22
|
+
await client.queue(src).config({ leaseTime: 2 }).create()
|
|
23
|
+
await client.queue(sink).create()
|
|
24
|
+
await client.queue(src).partition('tenant-1').push([{ data: { n: 1 } }, { data: { n: 2 } }, { data: { n: 3 } }])
|
|
25
|
+
|
|
26
|
+
// Every message becomes two values; message 2 is denied the first time it
|
|
27
|
+
// is seen and allowed after its redelivery.
|
|
28
|
+
let deniedOnce = false
|
|
29
|
+
const handle = await Stream
|
|
30
|
+
.from(client.queue(src))
|
|
31
|
+
.flatMap(m => [m.data, m.data])
|
|
32
|
+
.gate((v) => {
|
|
33
|
+
if (v.n === 2 && !deniedOnce) {
|
|
34
|
+
deniedOnce = true
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
return true
|
|
38
|
+
})
|
|
39
|
+
.to(client.queue(sink))
|
|
40
|
+
.run({ queryId, url: STREAMS_URL, batchSize: 10, maxPartitions: 1, reset: true, subscriptionMode: 'all' })
|
|
41
|
+
|
|
42
|
+
const drained = await drainUntil(client, sink, { until: out => out.length >= 6, timeoutMs: 15000 })
|
|
43
|
+
await handle.stop()
|
|
44
|
+
|
|
45
|
+
const count = (n) => drained.filter(m => m.data.n === n).length
|
|
46
|
+
const checks = [
|
|
47
|
+
expect(deniedOnce, '===', true, 'the gate denied message 2 once'),
|
|
48
|
+
expect(count(1), '===', 2, 'message 1 on the sink'),
|
|
49
|
+
expect(count(2), '===', 2, 'message 2 on the sink after its redelivery'),
|
|
50
|
+
expect(count(3), '===', 2, 'message 3 on the sink'),
|
|
51
|
+
expect(handle.metrics().errorsTotal, '===', 0, 'no errors')
|
|
52
|
+
]
|
|
53
|
+
return summarise('streamGateFlatMapPartialAck', checks)
|
|
54
|
+
}
|
package/test-v2/stream/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* recovery.js — config_hash mismatch + reset + mid-stream resume
|
|
13
13
|
* throughput.js — multi-partition / many-window throughput
|
|
14
14
|
* combined.js — full pipelines + concurrent streams
|
|
15
|
+
* gate.js — .gate() partial acks against the broker
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
export * from './operators.js'
|
|
@@ -23,3 +24,4 @@ export * from './eventTime.js'
|
|
|
23
24
|
export * from './recovery.js'
|
|
24
25
|
export * from './throughput.js'
|
|
25
26
|
export * from './combined.js'
|
|
27
|
+
export * from './gate.js'
|
|
@@ -294,9 +294,9 @@ export async function tumblingForeachCtxHasWindowAndPartition(client) {
|
|
|
294
294
|
const queryId = mkName('tumblingForeachCtxHasWindowAndPartition', 'q')
|
|
295
295
|
|
|
296
296
|
await client.queue(src).create()
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
297
|
+
// One push, so all three get the same broker createdAt: three pushes can
|
|
298
|
+
// straddle a second and split across two 1 s windows (count 1, then 2).
|
|
299
|
+
await client.queue(src).partition('myKey').push([0, 1, 2].map(v => ({ data: { v } })))
|
|
300
300
|
|
|
301
301
|
const captured = []
|
|
302
302
|
const handle = await Stream
|
|
@@ -201,3 +201,64 @@ describe('Queen.ack batch (/api/v1/ack/batch)', () => {
|
|
|
201
201
|
)
|
|
202
202
|
})
|
|
203
203
|
})
|
|
204
|
+
|
|
205
|
+
// The consumer group of an ack. /ack and /ack/batch judge an ack that names no
|
|
206
|
+
// group in queue mode, so a message popped by a group and acked without one
|
|
207
|
+
// came back `invalid or expired lease` (found 2026-10-02 against
|
|
208
|
+
// 2.0.0-beta.6). Every pop answers each message with its `consumerGroup`.
|
|
209
|
+
describe('Queen.ack — the group the message was popped under', () => {
|
|
210
|
+
const okFor = (body) => (body.acknowledgments || [body]).map((a, i) => ackResultItem(i, a.transactionId, true))
|
|
211
|
+
const popped = (n, group) => ({ transactionId: `tx-${n}`, partitionId: 'p-1', leaseId: 'lease-1', consumerGroup: group })
|
|
212
|
+
|
|
213
|
+
it('single: takes consumerGroup from the message when the caller names none', async () => {
|
|
214
|
+
await withAckServer(okFor, async (client, requests) => {
|
|
215
|
+
await client.ack(popped(1, 'workers'), true)
|
|
216
|
+
assert.equal(requests[0].body.consumerGroup, 'workers')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('single: a queue-mode or hand-built message still sends consumerGroup null', async () => {
|
|
221
|
+
await withAckServer(okFor, async (client, requests) => {
|
|
222
|
+
await client.ack(popped(1, '__QUEUE_MODE__'), true)
|
|
223
|
+
await client.ack(MSG, true)
|
|
224
|
+
assert.equal(requests[0].body.consumerGroup, null)
|
|
225
|
+
assert.equal(requests[1].body.consumerGroup, null)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('single: an explicit { group } wins', async () => {
|
|
230
|
+
await withAckServer(okFor, async (client, requests) => {
|
|
231
|
+
await client.ack(popped(1, 'workers'), false, { group: 'auditors' })
|
|
232
|
+
assert.equal(requests[0].body.consumerGroup, 'auditors')
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('batch: the group the messages share', async () => {
|
|
237
|
+
await withAckServer(okFor, async (client, requests) => {
|
|
238
|
+
const result = await client.ack([popped(1, 'workers'), popped(2, 'workers')], true)
|
|
239
|
+
assert.equal(result.success, true)
|
|
240
|
+
assert.equal(requests[0].path, '/api/v1/ack/batch')
|
|
241
|
+
assert.equal(requests[0].body.consumerGroup, 'workers')
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('batch: queue-mode messages still send consumerGroup null', async () => {
|
|
246
|
+
await withAckServer(okFor, async (client, requests) => {
|
|
247
|
+
await client.ack([popped(1, '__QUEUE_MODE__'), popped(2, '__QUEUE_MODE__')], true)
|
|
248
|
+
assert.equal(requests[0].body.consumerGroup, null)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('batch: messages from two groups are refused before anything is sent', async () => {
|
|
253
|
+
await withAckServer(okFor, async (client, requests) => {
|
|
254
|
+
await assert.rejects(
|
|
255
|
+
() => client.ack([popped(1, 'a'), popped(2, 'b')], true),
|
|
256
|
+
/different consumer groups in one call \(a, b\)/
|
|
257
|
+
)
|
|
258
|
+
assert.equal(requests.length, 0)
|
|
259
|
+
// ... unless the caller names the group, which is then theirs to get right.
|
|
260
|
+
await client.ack([popped(1, 'a'), popped(2, 'b')], true, { group: 'a' })
|
|
261
|
+
assert.equal(requests[0].body.consumerGroup, 'a')
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -100,7 +100,7 @@ describe('Runner — windowed aggregation', () => {
|
|
|
100
100
|
// The 10:00 window is closed (created and closed in the same batch
|
|
101
101
|
// since the third message crosses the minute boundary). The 10:01
|
|
102
102
|
// window stays open. Because the 10:00 window was never persisted to
|
|
103
|
-
//
|
|
103
|
+
// the broker (created+closed in one cycle), no delete state op is emitted —
|
|
104
104
|
// only the closed-window push and the open-window upsert.
|
|
105
105
|
assert.equal(c.push_items.length, 1, 'one closed-window emit')
|
|
106
106
|
assert.deepEqual(c.push_items[0].payload, { count: 2, sum: 30 })
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* End-to-end test for queen-streams against a live Queen server
|
|
2
|
+
* End-to-end test for queen-streams against a live Queen server.
|
|
3
3
|
*
|
|
4
4
|
* What it covers (per the plan's Definition of Done):
|
|
5
5
|
* 1. 10,000 messages across 50 partitions
|
|
@@ -8,14 +8,10 @@
|
|
|
8
8
|
* 4. Exactly-once on the sink queue
|
|
9
9
|
* 5. Per-partition state isolation
|
|
10
10
|
* 6. Downstream consumer of sink wakes up immediately
|
|
11
|
-
* (validates inline update_partition_lookup_v1 in streams_cycle_v1)
|
|
12
11
|
*
|
|
13
12
|
* Prerequisites:
|
|
14
13
|
* - Queen running on QUEEN_URL (default http://localhost:6632)
|
|
15
|
-
* -
|
|
16
|
-
* 002_streams_schema, 007_log_streams, 008_streams_register_query_v1,
|
|
17
|
-
* 009_streams_state_get_v1 (the retired rows-era streams cycle's role
|
|
18
|
-
* now lives in 007_log_streams' log_streams_cycle_v1)
|
|
14
|
+
* - A broker serving the /streams/v1 routes (register, state/get, cycle)
|
|
19
15
|
*
|
|
20
16
|
* Run:
|
|
21
17
|
* QUEEN_URL=http://localhost:6632 node --test test/e2e.test.js
|
|
@@ -129,7 +125,7 @@ describe('queen-streams e2e (live Queen)', { skip: !SHOULD_RUN }, () => {
|
|
|
129
125
|
|
|
130
126
|
// 3. Mid-stream kill: after 1.5s, stop the runner abruptly to simulate
|
|
131
127
|
// a worker crash, then restart it. Cursor + state should reconcile
|
|
132
|
-
// via Queen's lease/retry +
|
|
128
|
+
// via Queen's lease/retry + the per-partition streams state key.
|
|
133
129
|
setTimeout(async () => {
|
|
134
130
|
if (!stopped) {
|
|
135
131
|
await handle.stop()
|
|
@@ -205,8 +201,8 @@ describe('queen-streams e2e (live Queen)', { skip: !SHOULD_RUN }, () => {
|
|
|
205
201
|
const winKey = m.data && m.data.__winKey
|
|
206
202
|
// The aggregate value doesn't carry windowKey by default; we check for
|
|
207
203
|
// duplicates by (partition + transactionId) instead, which Queen
|
|
208
|
-
// guarantees unique across a partition. The
|
|
209
|
-
//
|
|
204
|
+
// guarantees unique across a partition. The streams cycle's sink
|
|
205
|
+
// pushes go through the SAME transactionId dedup as any push.
|
|
210
206
|
const dedupKey = `${m.partition}|${m.transactionId}`
|
|
211
207
|
assert.ok(!seen.has(dedupKey), 'duplicate sink message for ' + dedupKey)
|
|
212
208
|
seen.add(dedupKey)
|
|
@@ -214,7 +210,7 @@ describe('queen-streams e2e (live Queen)', { skip: !SHOULD_RUN }, () => {
|
|
|
214
210
|
|
|
215
211
|
// 6. Per-partition state isolation: every cycle the runner committed
|
|
216
212
|
// must touch state rows ONLY for its own partition. We can't easily
|
|
217
|
-
// observe this from the client side (
|
|
213
|
+
// observe this from the client side (the stored state is private), but the
|
|
218
214
|
// aggregate-shape correctness in (5) above implies it (a leaked
|
|
219
215
|
// cross-partition write would produce wrong sums).
|
|
220
216
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .gate() settles SOURCE MESSAGES, not envelopes.
|
|
3
|
+
*
|
|
4
|
+
* The gate's partial ack is an offset commit: a 2.x broker advances the
|
|
5
|
+
* cursor `ack.count` messages from the head of the leased batch and keeps the
|
|
6
|
+
* lease (release_lease=false). A pre-stage breaks the one-to-one map between
|
|
7
|
+
* messages and the envelopes the gate sees -- `.filter()` leaves a message
|
|
8
|
+
* with none, `.flatMap()` with several -- and the runner counted envelopes:
|
|
9
|
+
*
|
|
10
|
+
* flatMap x2, message 2 denied count 2 = messages 1 AND 2 acked; the
|
|
11
|
+
* denied message was never redelivered
|
|
12
|
+
* (reproduced live on 2.0.0-beta.6: 4 of 6
|
|
13
|
+
* sink items, message 2 lost)
|
|
14
|
+
* filter drops every message nothing committed; the batch came back on
|
|
15
|
+
* every lease expiry, forever
|
|
16
|
+
*
|
|
17
|
+
* Same fix as the Rust client (clients/client-rust/src/streams/runner.rs,
|
|
18
|
+
* gate_cycle): a message is settled only when every envelope it produced is
|
|
19
|
+
* allowed, and what a denied message did to the state is rolled back.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, before, after } from 'node:test'
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
|
|
25
|
+
import { Stream } from '../../client-v2/streams/Stream.js'
|
|
26
|
+
import { createFakeStreamsServer, createFakeSource, fakeMessage } from './fakeServer.js'
|
|
27
|
+
|
|
28
|
+
const PART = '00000000-0000-0000-0000-00000000ga7e'
|
|
29
|
+
|
|
30
|
+
const batch = (...ns) => ns.map((n, i) => fakeMessage({
|
|
31
|
+
partitionId: PART,
|
|
32
|
+
partitionName: 'tenant-1',
|
|
33
|
+
data: { n },
|
|
34
|
+
createdAt: `2026-10-02T10:00:0${i}.000Z`
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
const quiet = { info() {}, warn() {}, error() {}, debug() {} }
|
|
38
|
+
|
|
39
|
+
// Run until `cycles` cycles were committed (or the time is up), then stop.
|
|
40
|
+
async function runFor(server, stream, { cycles = 1, timeoutMs = 1500, queryId }) {
|
|
41
|
+
const baseline = server.recorded.cycles.length
|
|
42
|
+
const handle = await stream.run({ queryId, url: server.url, logger: quiet })
|
|
43
|
+
const start = Date.now()
|
|
44
|
+
while (server.recorded.cycles.length - baseline < cycles && Date.now() - start < timeoutMs) {
|
|
45
|
+
await new Promise(r => setTimeout(r, 20))
|
|
46
|
+
}
|
|
47
|
+
await handle.stop()
|
|
48
|
+
return { handle, cycles: server.recorded.cycles.slice(baseline) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('.gate() — the partial ack counts source messages', () => {
|
|
52
|
+
let server
|
|
53
|
+
before(async () => { server = await createFakeStreamsServer() })
|
|
54
|
+
after(async () => { await server.close() })
|
|
55
|
+
|
|
56
|
+
it('flatMap before the gate: a denied message is not acked with its predecessor', async () => {
|
|
57
|
+
const msgs = batch(1, 2, 3)
|
|
58
|
+
const stream = Stream.from(createFakeSource('src', [msgs]))
|
|
59
|
+
.flatMap(m => [m.data, m.data])
|
|
60
|
+
.gate(v => v.n !== 2)
|
|
61
|
+
.to({ _queueName: 'sink' })
|
|
62
|
+
|
|
63
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.flatmap' })
|
|
64
|
+
assert.equal(cycles.length, 1)
|
|
65
|
+
const c = cycles[0]
|
|
66
|
+
assert.equal(c.ack.count, 1, 'one MESSAGE settled; two envelopes acked messages 1 and 2')
|
|
67
|
+
assert.equal(c.ack.transactionId, msgs[0].transactionId)
|
|
68
|
+
assert.equal(c.release_lease, false, 'the denied tail stays leased, in order')
|
|
69
|
+
assert.deepEqual(c.push_items.map(p => p.payload.n), [1, 1], 'only the settled message reaches the sink')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('flatMap before the gate: a message half allowed is not settled at all', async () => {
|
|
73
|
+
let seen = 0
|
|
74
|
+
const stream = Stream.from(createFakeSource('src', [batch(1, 2)]))
|
|
75
|
+
.flatMap(m => [m.data, m.data])
|
|
76
|
+
.gate(() => ++seen === 1) // the first value of message 1 passes, its second does not
|
|
77
|
+
|
|
78
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.half', timeoutMs: 400 })
|
|
79
|
+
assert.equal(cycles.length, 0, 'nothing settled, nothing committed: the lease brings both back')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('filter before the gate: dropped messages are settled with the prefix', async () => {
|
|
83
|
+
const msgs = batch(1, 2, 3)
|
|
84
|
+
const stream = Stream.from(createFakeSource('src', [msgs]))
|
|
85
|
+
.filter(m => m.data.n === 3)
|
|
86
|
+
.gate(() => true)
|
|
87
|
+
.to({ _queueName: 'sink' })
|
|
88
|
+
|
|
89
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.filter' })
|
|
90
|
+
assert.equal(cycles.length, 1)
|
|
91
|
+
assert.equal(cycles[0].ack.count, 3, 'all three messages are done; one envelope is not one message')
|
|
92
|
+
assert.equal(cycles[0].ack.transactionId, msgs[2].transactionId)
|
|
93
|
+
assert.equal(cycles[0].release_lease, true)
|
|
94
|
+
assert.deepEqual(cycles[0].push_items.map(p => p.payload.n), [3])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('filter that drops the whole batch: the batch is acked, not held forever', async () => {
|
|
98
|
+
const msgs = batch(1, 2)
|
|
99
|
+
const stream = Stream.from(createFakeSource('src', [msgs]))
|
|
100
|
+
.filter(() => false)
|
|
101
|
+
.gate(() => true)
|
|
102
|
+
.to({ _queueName: 'sink' })
|
|
103
|
+
|
|
104
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.allfiltered' })
|
|
105
|
+
assert.equal(cycles.length, 1, 'a cycle is committed even though the gate saw nothing')
|
|
106
|
+
assert.equal(cycles[0].ack.count, 2)
|
|
107
|
+
assert.equal(cycles[0].release_lease, true)
|
|
108
|
+
assert.equal(cycles[0].push_items.length, 0)
|
|
109
|
+
assert.equal(cycles[0].state_ops.length, 0)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('filter, then a deny: the count stops before the denied message', async () => {
|
|
113
|
+
const msgs = batch(1, 2, 3, 4)
|
|
114
|
+
const stream = Stream.from(createFakeSource('src', [msgs]))
|
|
115
|
+
.filter(m => m.data.n !== 2) // message 2 produces nothing
|
|
116
|
+
.gate(v => v.n !== 3) // message 3 is denied
|
|
117
|
+
|
|
118
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.filterdeny' })
|
|
119
|
+
assert.equal(cycles.length, 1)
|
|
120
|
+
assert.equal(cycles[0].ack.count, 2, 'messages 1 and 2 settled; the gate allowed one envelope')
|
|
121
|
+
assert.equal(cycles[0].ack.transactionId, msgs[1].transactionId)
|
|
122
|
+
assert.equal(cycles[0].release_lease, false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('a denied first message still commits nothing', async () => {
|
|
126
|
+
const stream = Stream.from(createFakeSource('src', [batch(1, 2)]))
|
|
127
|
+
.gate(() => false)
|
|
128
|
+
const { cycles, handle } = await runFor(server, stream, { queryId: 'gate.firstdeny', timeoutMs: 400 })
|
|
129
|
+
assert.equal(cycles.length, 0)
|
|
130
|
+
assert.equal(handle.metrics().gateDenialsTotal, 2, 'denials are counted in messages')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('.gate() — a denied message did not happen', () => {
|
|
135
|
+
let server
|
|
136
|
+
before(async () => { server = await createFakeStreamsServer() })
|
|
137
|
+
after(async () => { await server.close() })
|
|
138
|
+
|
|
139
|
+
it('what a denied message did to the state is not persisted', async () => {
|
|
140
|
+
// A counter the gate bumps BEFORE deciding: message 1 is allowed at 1,
|
|
141
|
+
// message 2 bumps it to 2 and is denied. The state that commits is 1.
|
|
142
|
+
const stream = Stream.from(createFakeSource('src', [batch(1, 2)]))
|
|
143
|
+
.gate((v, ctx) => {
|
|
144
|
+
ctx.state.count = (ctx.state.count || 0) + 1
|
|
145
|
+
return v.n === 1
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.rollback' })
|
|
149
|
+
assert.equal(cycles.length, 1)
|
|
150
|
+
assert.equal(cycles[0].ack.count, 1)
|
|
151
|
+
assert.deepEqual(cycles[0].state_ops, [{ type: 'upsert', key: PART, value: { count: 1 } }])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('a key only a denied message touched is not written at all', async () => {
|
|
155
|
+
const stream = Stream.from(createFakeSource('src', [batch(1, 2)]))
|
|
156
|
+
.keyBy(m => `k${m.data.n}`)
|
|
157
|
+
.gate((v, ctx) => {
|
|
158
|
+
ctx.state.seen = true
|
|
159
|
+
return v.n === 1
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.untouched' })
|
|
163
|
+
assert.equal(cycles.length, 1)
|
|
164
|
+
assert.deepEqual(cycles[0].state_ops.map(op => op.key), ['k1'])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('.gate() — the prefix is the broker\'s, in offset order', () => {
|
|
169
|
+
let server
|
|
170
|
+
before(async () => { server = await createFakeStreamsServer() })
|
|
171
|
+
after(async () => { await server.close() })
|
|
172
|
+
|
|
173
|
+
it('orders by offset when the broker sends one, not by (createdAt, id)', async () => {
|
|
174
|
+
// Two producers pushed in the same millisecond: same createdAt, and the
|
|
175
|
+
// message the broker stored FIRST carries the higher id. Sorting by
|
|
176
|
+
// (createdAt, id) put it second, so a deny on it acked it anyway.
|
|
177
|
+
const createdAt = '2026-10-02T10:00:00.000Z'
|
|
178
|
+
const first = { ...fakeMessage({ partitionId: PART, data: { n: 'first' }, createdAt }), id: 'msg-z', offset: 10 }
|
|
179
|
+
const second = { ...fakeMessage({ partitionId: PART, data: { n: 'second' }, createdAt }), id: 'msg-a', offset: 11 }
|
|
180
|
+
const stream = Stream.from(createFakeSource('src', [[second, first]]))
|
|
181
|
+
.gate(v => v.n === 'first')
|
|
182
|
+
|
|
183
|
+
const { cycles } = await runFor(server, stream, { queryId: 'gate.offsetorder' })
|
|
184
|
+
assert.equal(cycles.length, 1)
|
|
185
|
+
assert.equal(cycles[0].ack.count, 1)
|
|
186
|
+
assert.equal(cycles[0].ack.transactionId, first.transactionId, 'the settled prefix is offset 10, the one the broker acks')
|
|
187
|
+
assert.equal(cycles[0].release_lease, false)
|
|
188
|
+
})
|
|
189
|
+
})
|