queen-mq 2.3.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.
@@ -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