queen-mq 2.6.0 → 2.7.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/client-v2/consumer/ConsumerManager.js +92 -30
- package/client-v2/http/HttpClient.js +8 -5
- package/client-v2/streams/operators/ReduceOperator.js +15 -3
- package/client-v2/streams/operators/WindowSessionOperator.js +9 -2
- package/client-v2/streams/operators/WindowTumblingOperator.js +9 -2
- package/client-v2/streams/runtime/Runner.js +356 -20
- package/package.json +2 -2
- package/test-v2/consumer-unit/transientPop.test.js +361 -0
- package/test-v2/stream/claims.js +129 -0
- package/test-v2/stream/index.js +2 -0
- package/test-v2/streams-unit/claims.test.js +246 -0
- package/test-v2/streams-unit/fakeServer.js +41 -2
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing quiet windows under a claim (broker 2.7, issue #153).
|
|
3
|
+
*
|
|
4
|
+
* - The window operators say when the earliest window still open closes:
|
|
5
|
+
* the Runner claims the partition then.
|
|
6
|
+
* - Against the fake streams server: a window a stopped runner left open is
|
|
7
|
+
* found by the next runner's sweep, claimed, and closed in a cycle that
|
|
8
|
+
* acks the claim's empty batch; a busy partition is tried again later;
|
|
9
|
+
* without the claim routes (an older broker) and for event-time windows
|
|
10
|
+
* the old unleased flush runs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
|
|
16
|
+
import { Stream } from '../../client-v2/streams/Stream.js'
|
|
17
|
+
import { WindowTumblingOperator } from '../../client-v2/streams/operators/WindowTumblingOperator.js'
|
|
18
|
+
import { WindowSlidingOperator } from '../../client-v2/streams/operators/WindowSlidingOperator.js'
|
|
19
|
+
import { WindowSessionOperator } from '../../client-v2/streams/operators/WindowSessionOperator.js'
|
|
20
|
+
import { ReduceOperator, stateKeyFor } from '../../client-v2/streams/operators/ReduceOperator.js'
|
|
21
|
+
import { createFakeStreamsServer, createFakeSource, fakeMessage } from './fakeServer.js'
|
|
22
|
+
|
|
23
|
+
const iso = ms => new Date(ms).toISOString()
|
|
24
|
+
const counter = () => new ReduceOperator((acc, _v) => acc + 1, 0)
|
|
25
|
+
|
|
26
|
+
async function annotate(window, times, key = 'k') {
|
|
27
|
+
const out = []
|
|
28
|
+
for (const t of times) {
|
|
29
|
+
out.push(...await window.apply({ msg: { createdAt: iso(t), data: 1 }, key, value: 1 }))
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('next close — tumbling / sliding (ReduceOperator.run)', () => {
|
|
35
|
+
it('is the earliest open window end plus grace, then the next, then none', async () => {
|
|
36
|
+
const w = new WindowTumblingOperator({ seconds: 10, gracePeriod: 2 })
|
|
37
|
+
const envs = await annotate(w, [5_000, 15_000])
|
|
38
|
+
const run = clock => counter().run({
|
|
39
|
+
envelopes: envs, loadedState: new Map(), streamTimeMs: clock,
|
|
40
|
+
operatorTag: w.operatorTag, gracePeriodMs: w.gracePeriodMs
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
let out = await run(9_000)
|
|
44
|
+
assert.equal(out.emits.length, 0)
|
|
45
|
+
assert.equal(out.nextCloseMs, 12_000, 'first window: end 10 s + 2 s grace')
|
|
46
|
+
|
|
47
|
+
// The first closed: the second is next.
|
|
48
|
+
out = await run(12_000)
|
|
49
|
+
assert.equal(out.emits.length, 1)
|
|
50
|
+
assert.equal(out.nextCloseMs, 22_000)
|
|
51
|
+
|
|
52
|
+
// Both closed: nothing to come back for.
|
|
53
|
+
out = await run(22_000)
|
|
54
|
+
assert.equal(out.emits.length, 2)
|
|
55
|
+
assert.equal(out.nextCloseMs, null)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('counts a stored window the batch did not touch', async () => {
|
|
59
|
+
const loaded = new Map([[
|
|
60
|
+
stateKeyFor('tumb:10', iso(10_000), 'k'),
|
|
61
|
+
{ acc: 1, windowStart: 10_000, windowEnd: 20_000 }
|
|
62
|
+
]])
|
|
63
|
+
// No events at all: the stored window alone says when to come back.
|
|
64
|
+
let out = await counter().run({
|
|
65
|
+
envelopes: [], loadedState: loaded, streamTimeMs: 15_000, operatorTag: 'tumb:10', gracePeriodMs: 0
|
|
66
|
+
})
|
|
67
|
+
assert.equal(out.emits.length, 0)
|
|
68
|
+
assert.equal(out.nextCloseMs, 20_000)
|
|
69
|
+
|
|
70
|
+
// A later window gets an event: the stored one still closes first.
|
|
71
|
+
const w = new WindowTumblingOperator({ seconds: 10 })
|
|
72
|
+
out = await counter().run({
|
|
73
|
+
envelopes: await annotate(w, [25_000]), loadedState: loaded, streamTimeMs: 19_000,
|
|
74
|
+
operatorTag: 'tumb:10', gracePeriodMs: 0
|
|
75
|
+
})
|
|
76
|
+
assert.equal(out.nextCloseMs, 20_000)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('takes the earliest of the sliding windows an event falls in', async () => {
|
|
80
|
+
const w = new WindowSlidingOperator({ size: 10, slide: 5 })
|
|
81
|
+
const envs = await annotate(w, [7_000]) // [0, 10 s) and [5 s, 15 s)
|
|
82
|
+
const run = clock => counter().run({
|
|
83
|
+
envelopes: envs, loadedState: new Map(), streamTimeMs: clock, operatorTag: w.operatorTag, gracePeriodMs: 0
|
|
84
|
+
})
|
|
85
|
+
let out = await run(7_000)
|
|
86
|
+
assert.equal(out.nextCloseMs, 10_000)
|
|
87
|
+
out = await run(10_000)
|
|
88
|
+
assert.equal(out.emits.length, 1)
|
|
89
|
+
assert.equal(out.nextCloseMs, 15_000)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('next close — session (WindowSessionOperator.runSession)', () => {
|
|
94
|
+
const runSession = (w, envelopes, loadedState, nowMs) => w.runSession({
|
|
95
|
+
envelopes, loadedState, reducerFn: (acc, _v) => acc + 1, reducerInitial: () => 0, nowMs
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is the last event plus gap and grace, and none once it closes', async () => {
|
|
99
|
+
const w = new WindowSessionOperator({ gap: 30 })
|
|
100
|
+
const envs = await annotate(w, [0, 10_000], 'u1')
|
|
101
|
+
let out = await runSession(w, envs, new Map(), 11_000)
|
|
102
|
+
assert.equal(out.emits.length, 0)
|
|
103
|
+
assert.equal(out.nextCloseMs, 40_000)
|
|
104
|
+
|
|
105
|
+
// The idle close of the stored session: nothing left open after it.
|
|
106
|
+
const stored = new Map([[w.openSessionStateKey('u1'), { acc: 2, sessionStart: 0, lastEventTime: 10_000 }]])
|
|
107
|
+
out = await runSession(w, [], stored, 39_999)
|
|
108
|
+
assert.equal(out.emits.length, 0)
|
|
109
|
+
assert.equal(out.nextCloseMs, 40_000)
|
|
110
|
+
out = await runSession(w, [], stored, 40_000)
|
|
111
|
+
assert.equal(out.emits.length, 1)
|
|
112
|
+
assert.equal(out.nextCloseMs, null)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('adds the grace, and takes the earliest of several keys', async () => {
|
|
116
|
+
const w = new WindowSessionOperator({ gap: 30, gracePeriod: 5 })
|
|
117
|
+
const envs = [
|
|
118
|
+
...await annotate(w, [1_000], 'a'),
|
|
119
|
+
...await annotate(w, [4_000], 'b')
|
|
120
|
+
]
|
|
121
|
+
const out = await runSession(w, envs, new Map(), 4_000)
|
|
122
|
+
assert.equal(out.nextCloseMs, 1_000 + 30_000 + 5_000)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------- Runner
|
|
127
|
+
|
|
128
|
+
const PART_A = '101'
|
|
129
|
+
|
|
130
|
+
function windowedStream(source, { eventTime } = {}) {
|
|
131
|
+
return Stream
|
|
132
|
+
.from(source)
|
|
133
|
+
.windowTumbling({ seconds: 1, idleFlushMs: 100, ...(eventTime ? { eventTime } : {}) })
|
|
134
|
+
.aggregate({ count: () => 1 })
|
|
135
|
+
.to({ queueName: 'sink' })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function waitUntil(fn, timeoutMs = 5000) {
|
|
139
|
+
const start = Date.now()
|
|
140
|
+
while (!fn() && Date.now() - start < timeoutMs) {
|
|
141
|
+
await new Promise(r => setTimeout(r, 20))
|
|
142
|
+
}
|
|
143
|
+
return fn()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const closingCycle = c => c.ack && c.ack.count === 0 && (c.push_items || []).length > 0
|
|
147
|
+
|
|
148
|
+
describe('Runner — closes under a claim (fake server)', () => {
|
|
149
|
+
it('a window left open by a stopped runner is swept, claimed and closed with a count-0 ack', async () => {
|
|
150
|
+
const server = await createFakeStreamsServer({ claims: { names: { [PART_A]: 'cust-1' } } })
|
|
151
|
+
try {
|
|
152
|
+
// Runner 1 folds one event, and stops while its 1 s window is open
|
|
153
|
+
// (stamped a second ahead, so the window cannot end before the stop).
|
|
154
|
+
const first = await windowedStream(createFakeSource('src', [[
|
|
155
|
+
fakeMessage({ partitionId: PART_A, partitionName: 'cust-1', data: { n: 1 }, createdAt: iso(Date.now() + 1000) })
|
|
156
|
+
]])).run({ queryId: 'claim-restart', url: server.url })
|
|
157
|
+
assert.ok(await waitUntil(() => server.recorded.cycles.some(c => c.ack && c.ack.count === 1)), 'runner 1 committed its cycle')
|
|
158
|
+
await first.stop()
|
|
159
|
+
assert.equal(server.recorded.cycles.filter(closingCycle).length, 0, 'runner 1 closed nothing')
|
|
160
|
+
|
|
161
|
+
// Runner 2, same query, no new events.
|
|
162
|
+
const second = await windowedStream(createFakeSource('src', [])).run({ queryId: 'claim-restart', url: server.url })
|
|
163
|
+
const closed = await waitUntil(() => server.recorded.cycles.find(closingCycle), 5000)
|
|
164
|
+
await second.stop()
|
|
165
|
+
assert.ok(closed, 'runner 2 closed the window')
|
|
166
|
+
|
|
167
|
+
// Its first sweep took every partition holding state.
|
|
168
|
+
const sweeps = server.recorded.statePartitions
|
|
169
|
+
assert.ok(sweeps.length >= 2, 'each runner sweeps at start')
|
|
170
|
+
assert.equal(sweeps[sweeps.length - 1].untouched_for_ms, undefined)
|
|
171
|
+
|
|
172
|
+
const claim = server.recorded.claims.find(c => c.leaseId === closed.ack.leaseId)
|
|
173
|
+
assert.ok(claim, 'the close names the lease of a claim')
|
|
174
|
+
assert.deepEqual(claim.request.partition_ids, [PART_A])
|
|
175
|
+
assert.equal(claim.request.consumer_group, 'streams.claim-restart')
|
|
176
|
+
assert.equal(claim.request.lease_seconds, 30)
|
|
177
|
+
|
|
178
|
+
assert.equal(closed.partition_id, PART_A)
|
|
179
|
+
assert.equal(closed.release_lease, true)
|
|
180
|
+
assert.deepEqual(closed.ack, { transactionId: '', leaseId: claim.leaseId, status: 'completed', count: 0 })
|
|
181
|
+
assert.equal(closed.push_items.length, 1)
|
|
182
|
+
assert.equal(closed.push_items[0].partition, 'cust-1')
|
|
183
|
+
assert.deepEqual(closed.push_items[0].payload, { count: 1 })
|
|
184
|
+
assert.deepEqual(closed.state_ops.map(o => o.type), ['delete'])
|
|
185
|
+
assert.equal(server.recorded.cycles.filter(closingCycle).length, 1, 'closed once')
|
|
186
|
+
assert.equal(second.metrics().flushCyclesTotal, 1)
|
|
187
|
+
} finally {
|
|
188
|
+
await server.close()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('a busy partition is claimed again no sooner than a second later', async () => {
|
|
193
|
+
let calls = 0
|
|
194
|
+
const server = await createFakeStreamsServer({
|
|
195
|
+
claims: { status: () => (++calls === 1 ? 'busy' : 'claimed') }
|
|
196
|
+
})
|
|
197
|
+
try {
|
|
198
|
+
// The window is already past its end: due at the first tick.
|
|
199
|
+
const old = Date.now() - 5_000
|
|
200
|
+
const handle = await windowedStream(createFakeSource('src', [[
|
|
201
|
+
fakeMessage({ partitionId: PART_A, partitionName: 'cust-1', data: { n: 1 }, createdAt: iso(old) })
|
|
202
|
+
]])).run({ queryId: 'claim-busy', url: server.url })
|
|
203
|
+
const closed = await waitUntil(() => server.recorded.cycles.find(closingCycle), 5000)
|
|
204
|
+
await handle.stop()
|
|
205
|
+
assert.ok(closed, 'closed once the partition was free')
|
|
206
|
+
const claims = server.recorded.claims.filter(c => c.request.partition_ids.includes(PART_A))
|
|
207
|
+
assert.ok(claims.length >= 2)
|
|
208
|
+
assert.ok(claims[1].at - claims[0].at >= 900, `retried after ${claims[1].at - claims[0].at} ms`)
|
|
209
|
+
// The claim answered no name: the one the cycle saw is used.
|
|
210
|
+
assert.equal(closed.push_items[0].partition, 'cust-1')
|
|
211
|
+
} finally {
|
|
212
|
+
await server.close()
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('falls back to the unleased flush on a broker without the claim routes', async () => {
|
|
217
|
+
const server = await createFakeStreamsServer() // the claim routes answer 404
|
|
218
|
+
try {
|
|
219
|
+
const handle = await windowedStream(createFakeSource('src', [[
|
|
220
|
+
fakeMessage({ partitionId: PART_A, partitionName: 'cust-1', data: { n: 1 }, createdAt: iso(Date.now()) })
|
|
221
|
+
]])).run({ queryId: 'claim-old-broker', url: server.url })
|
|
222
|
+
const flushed = await waitUntil(() => server.recorded.cycles.find(c => c.ack === null && c.push_items.length > 0), 5000)
|
|
223
|
+
await handle.stop()
|
|
224
|
+
assert.ok(flushed, 'the old flush closed the window, with no ack')
|
|
225
|
+
assert.equal(handle._claims, 'no')
|
|
226
|
+
} finally {
|
|
227
|
+
await server.close()
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('event-time windows keep the old flush and never claim', async () => {
|
|
232
|
+
const server = await createFakeStreamsServer({ claims: {} })
|
|
233
|
+
try {
|
|
234
|
+
const handle = await windowedStream(createFakeSource('src', [[
|
|
235
|
+
fakeMessage({ partitionId: PART_A, partitionName: 'cust-1', data: { ts: 1_000 } })
|
|
236
|
+
]]), { eventTime: m => m.data.ts }).run({ queryId: 'claim-event-time', url: server.url })
|
|
237
|
+
assert.ok(await waitUntil(() => server.recorded.cycles.length === 1), 'the cycle committed')
|
|
238
|
+
await new Promise(r => setTimeout(r, 300))
|
|
239
|
+
await handle.stop()
|
|
240
|
+
assert.equal(server.recorded.statePartitions.length, 0)
|
|
241
|
+
assert.equal(server.recorded.claims.length, 0)
|
|
242
|
+
} finally {
|
|
243
|
+
await server.close()
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
})
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
import { createServer } from 'node:http'
|
|
7
7
|
import { randomUUID } from 'node:crypto'
|
|
8
8
|
|
|
9
|
-
export function createFakeStreamsServer({ stateSeeds } = {}) {
|
|
9
|
+
export function createFakeStreamsServer({ stateSeeds, claims } = {}) {
|
|
10
10
|
const recorded = {
|
|
11
11
|
registers: [],
|
|
12
12
|
cycles: [],
|
|
13
|
-
stateGets: []
|
|
13
|
+
stateGets: [],
|
|
14
|
+
statePartitions: [],
|
|
15
|
+
claims: []
|
|
14
16
|
}
|
|
15
17
|
const queries = new Map() // name -> { id, config_hash }
|
|
16
18
|
// state: `${queryId}|${partitionId}|${key}` -> value
|
|
@@ -82,6 +84,43 @@ export function createFakeStreamsServer({ stateSeeds } = {}) {
|
|
|
82
84
|
return send(200, { success: true, rows })
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// The claim routes of broker 2.7, only when the test asks for them
|
|
88
|
+
// (`claims: { names?, status?, nowMs? }`); without, they answer 404 like
|
|
89
|
+
// an older broker. One page, no untouched_for_ms filter.
|
|
90
|
+
if (claims && req.method === 'POST' && req.url === '/streams/v1/state/partitions') {
|
|
91
|
+
recorded.statePartitions.push(parsed)
|
|
92
|
+
const pids = new Set()
|
|
93
|
+
for (const k of state.keys()) {
|
|
94
|
+
const [qid, pid] = k.split('|')
|
|
95
|
+
if (qid === parsed.query_id) pids.add(pid)
|
|
96
|
+
}
|
|
97
|
+
const partitions = [...pids].sort().map(pid => ({
|
|
98
|
+
partition_id: pid,
|
|
99
|
+
partition: (claims.names && claims.names[pid]) || '',
|
|
100
|
+
queue: 'src',
|
|
101
|
+
updated_at: new Date().toISOString()
|
|
102
|
+
}))
|
|
103
|
+
return send(200, { success: true, partitions, next: null })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (claims && req.method === 'POST' && req.url === '/streams/v1/claim') {
|
|
107
|
+
const leaseId = randomUUID()
|
|
108
|
+
recorded.claims.push({ request: parsed, leaseId, at: Date.now() })
|
|
109
|
+
const partitions = (parsed.partition_ids || []).map(pid => {
|
|
110
|
+
const status = claims.status ? claims.status(pid) : 'claimed'
|
|
111
|
+
const p = { partition_id: pid, partition: (claims.names && claims.names[pid]) || '', status }
|
|
112
|
+
if (status === 'claimed') p.lease_expires_at = new Date(Date.now() + 30_000).toISOString()
|
|
113
|
+
return p
|
|
114
|
+
})
|
|
115
|
+
return send(200, {
|
|
116
|
+
success: true,
|
|
117
|
+
lease_id: leaseId,
|
|
118
|
+
lease_seconds: parsed.lease_seconds || 30,
|
|
119
|
+
now_ms: claims.nowMs ? claims.nowMs() : Date.now(),
|
|
120
|
+
partitions
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
if (req.method === 'POST' && req.url === '/streams/v1/cycle') {
|
|
86
125
|
recorded.cycles.push(parsed)
|
|
87
126
|
const queryId = parsed.query_id
|