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.
- package/README.md +52 -0
- package/client-v2/README.md +50 -0
- package/client-v2/builders/QueueBuilder.js +34 -0
- package/client-v2/consumer/ConsumerManager.js +92 -30
- package/client-v2/http/HttpClient.js +8 -5
- package/client-v2/reads/offsetReads.js +252 -0
- 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/reads-unit/offsetReads.test.js +380 -0
- package/test-v2/reads.js +109 -0
- package/test-v2/run.js +2 -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,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queue.fetch() and queue.changedSince() against a fake broker that answers
|
|
3
|
+
* POST /api/v1/fetch and POST /api/v1/partitions/changed the way
|
|
4
|
+
* server/src/rsm/facade/real/phase2/reads.rs does: entries answered in order,
|
|
5
|
+
* each entry's byte budget and the whole call's ceiling (the first record of an
|
|
6
|
+
* entry read whatever it weighs), a record cap per entry, OFFSET_OUT_OF_RANGE
|
|
7
|
+
* below logStartOffset and above the high watermark, UNKNOWN_TOPIC_OR_PARTITION
|
|
8
|
+
* for a queue that does not exist, discovery pages with a `next` cursor exactly
|
|
9
|
+
* when a page is full, BAD_CURSOR for a cursor it did not issue.
|
|
10
|
+
*
|
|
11
|
+
* A real node:http server records every request, so the bodies the client sends
|
|
12
|
+
* are asserted on the socket.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it } from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { createServer } from 'node:http'
|
|
18
|
+
|
|
19
|
+
import { Queen } from '../../client-v2/index.js'
|
|
20
|
+
|
|
21
|
+
const MiB = 1024 * 1024
|
|
22
|
+
|
|
23
|
+
class FakeBroker {
|
|
24
|
+
constructor({ recordsPerEntry = 10_000 } = {}) {
|
|
25
|
+
this.queues = new Map() // queue -> Map(partition -> { records, logStart, created })
|
|
26
|
+
this.recordsPerEntry = recordsPerEntry
|
|
27
|
+
this.hits = []
|
|
28
|
+
this.safeTimes = [] // answered in turn by partitions/changed, then the last one
|
|
29
|
+
this.afterFetch = null // (broker) => void, run after every fetch answer
|
|
30
|
+
this.created = 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
queue(name) {
|
|
34
|
+
if (!this.queues.has(name)) this.queues.set(name, new Map())
|
|
35
|
+
return this.queues.get(name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
write(queue, partition, payloads, { lastWriteAt = '2026-10-20T10:00:00.000000Z' } = {}) {
|
|
39
|
+
const q = this.queue(queue)
|
|
40
|
+
if (!q.has(partition)) q.set(partition, { records: [], logStart: 0, created: this.created++, lastWriteAt })
|
|
41
|
+
const p = q.get(partition)
|
|
42
|
+
for (const payload of payloads) {
|
|
43
|
+
const offset = p.records.length === 0 ? p.logStart : p.records[p.records.length - 1].offset + 1
|
|
44
|
+
p.records.push({ offset, transactionId: `${partition}-${offset}`, payload, ts: '2026-10-20T10:00:00.000000Z' })
|
|
45
|
+
}
|
|
46
|
+
p.lastWriteAt = lastWriteAt
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Retention: everything below `offset` is gone. */
|
|
50
|
+
trim(queue, partition, offset) {
|
|
51
|
+
const p = this.queue(queue).get(partition)
|
|
52
|
+
p.records = p.records.filter(r => r.offset >= offset)
|
|
53
|
+
p.logStart = offset
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fetch(body) {
|
|
57
|
+
if (body.entries.length > 1024) return [400, { error: 'too many entries' }]
|
|
58
|
+
const ceiling = body.maxBytes ?? null
|
|
59
|
+
let used = 0
|
|
60
|
+
const entries = body.entries.map(e => {
|
|
61
|
+
const out = { queue: e.queue, partition: e.partition ?? 'Default', records: [], highWatermark: 0, logStartOffset: 0 }
|
|
62
|
+
const q = this.queues.get(e.queue)
|
|
63
|
+
if (!q) return { ...out, error: 'UNKNOWN_TOPIC_OR_PARTITION' }
|
|
64
|
+
const p = q.get(out.partition)
|
|
65
|
+
if (!p) return out
|
|
66
|
+
const last = p.records.length ? p.records[p.records.length - 1].offset : p.logStart - 1
|
|
67
|
+
out.highWatermark = last + 1
|
|
68
|
+
out.logStartOffset = p.logStart
|
|
69
|
+
if (e.offset < p.logStart || e.offset > out.highWatermark) return { ...out, error: 'OFFSET_OUT_OF_RANGE' }
|
|
70
|
+
if (ceiling !== null && used >= ceiling) return out
|
|
71
|
+
const budget = Math.min(e.maxBytes ?? MiB, ceiling === null ? Infinity : ceiling - used)
|
|
72
|
+
let spent = 0
|
|
73
|
+
for (const r of p.records) {
|
|
74
|
+
if (r.offset < e.offset) continue
|
|
75
|
+
if (out.records.length >= this.recordsPerEntry) break
|
|
76
|
+
const weight = Math.max(JSON.stringify(r.payload).length, 1)
|
|
77
|
+
if (out.records.length > 0 && spent + weight > budget) break
|
|
78
|
+
spent += weight
|
|
79
|
+
out.records.push(r)
|
|
80
|
+
}
|
|
81
|
+
used += spent
|
|
82
|
+
return out
|
|
83
|
+
})
|
|
84
|
+
return [200, { entries }]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
changed(body) {
|
|
88
|
+
if (body.entries.length > 1024) return [400, { error: 'too many entries' }]
|
|
89
|
+
const safeTime = this.safeTimes.length > 1 ? this.safeTimes.shift() : (this.safeTimes[0] ?? '2026-10-20T12:00:00.000000Z')
|
|
90
|
+
const entries = body.entries.map(e => {
|
|
91
|
+
const q = this.queues.get(e.queue)
|
|
92
|
+
if (!q) return { queue: e.queue, error: 'UNKNOWN_TOPIC_OR_PARTITION' }
|
|
93
|
+
let from = 0
|
|
94
|
+
if (e.after) {
|
|
95
|
+
const m = /^p\|(\d+)$/.exec(e.after)
|
|
96
|
+
if (!m) return { queue: e.queue, error: 'BAD_CURSOR' }
|
|
97
|
+
from = Number(m[1]) + 1
|
|
98
|
+
}
|
|
99
|
+
const limit = Math.min(Math.max(e.limit ?? 1000, 1), 1000)
|
|
100
|
+
const all = [...q.entries()].sort((a, b) => a[1].created - b[1].created)
|
|
101
|
+
const partitions = []
|
|
102
|
+
let last = null
|
|
103
|
+
for (const [name, p] of all) {
|
|
104
|
+
if (p.created < from) continue
|
|
105
|
+
if (e.since && p.lastWriteAt < e.since) continue
|
|
106
|
+
const lastOffset = p.records.length ? p.records[p.records.length - 1].offset : p.logStart - 1
|
|
107
|
+
partitions.push({ name, id: `id-${name}`, lastOffset, logStart: p.logStart, lastWriteAt: p.lastWriteAt })
|
|
108
|
+
last = p.created
|
|
109
|
+
if (partitions.length >= limit) break
|
|
110
|
+
}
|
|
111
|
+
return { queue: e.queue, partitions, next: partitions.length >= limit ? `p|${last}` : null }
|
|
112
|
+
})
|
|
113
|
+
return [200, { safeTime, safeTimeDegraded: false, entries }]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async run(test) {
|
|
117
|
+
const server = createServer((req, res) => {
|
|
118
|
+
let raw = ''
|
|
119
|
+
req.on('data', c => { raw += c })
|
|
120
|
+
req.on('end', () => {
|
|
121
|
+
const body = raw ? JSON.parse(raw) : null
|
|
122
|
+
this.hits.push({ method: req.method, url: req.url, body })
|
|
123
|
+
let answer
|
|
124
|
+
if (req.method === 'POST' && req.url === '/api/v1/fetch') {
|
|
125
|
+
answer = this.fetch(body)
|
|
126
|
+
this.afterFetch?.(this)
|
|
127
|
+
} else if (req.method === 'POST' && req.url === '/api/v1/partitions/changed') {
|
|
128
|
+
answer = this.changed(body)
|
|
129
|
+
} else {
|
|
130
|
+
answer = [404, { error: 'not found' }]
|
|
131
|
+
}
|
|
132
|
+
res.writeHead(answer[0], { 'Content-Type': 'application/json' })
|
|
133
|
+
res.end(JSON.stringify(answer[1]))
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
|
137
|
+
const queen = new Queen({ url: `http://127.0.0.1:${server.address().port}`, handleSignals: false })
|
|
138
|
+
try {
|
|
139
|
+
await test(queen)
|
|
140
|
+
} finally {
|
|
141
|
+
await queen.close()
|
|
142
|
+
await new Promise(r => server.close(r))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fetches() {
|
|
147
|
+
return this.hits.filter(h => h.url === '/api/v1/fetch')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const payloads = (n, from = 0) => Array.from({ length: n }, (_, i) => ({ n: from + i }))
|
|
152
|
+
const offsetsOf = (part) => part.records.map(r => r.offset)
|
|
153
|
+
|
|
154
|
+
describe('queue.fetch()', () => {
|
|
155
|
+
it('reads every record of each partition, from offset 0, in one call when they fit', async () => {
|
|
156
|
+
const broker = new FakeBroker()
|
|
157
|
+
broker.write('tf.events', 'trace-1', payloads(3))
|
|
158
|
+
broker.write('tf.events', 'trace-2', [{ a: 1 }])
|
|
159
|
+
await broker.run(async (queen) => {
|
|
160
|
+
const read = await queen.queue('tf.events').fetch(['trace-1', 'trace-2'])
|
|
161
|
+
assert.deepEqual([...read.keys()], ['trace-1', 'trace-2'])
|
|
162
|
+
const t1 = read.get('trace-1')
|
|
163
|
+
assert.deepEqual(t1.records.map(r => r.payload), payloads(3))
|
|
164
|
+
assert.deepEqual(t1.records[0], { offset: 0, transactionId: 'trace-1-0', payload: { n: 0 }, ts: '2026-10-20T10:00:00.000000Z' })
|
|
165
|
+
assert.equal(t1.partition, 'trace-1')
|
|
166
|
+
assert.equal(t1.highWatermark, 3)
|
|
167
|
+
assert.equal(t1.logStartOffset, 0)
|
|
168
|
+
assert.equal(t1.nextOffset, 3)
|
|
169
|
+
assert.deepEqual(read.get('trace-2').records.map(r => r.payload), [{ a: 1 }])
|
|
170
|
+
|
|
171
|
+
const fetches = broker.fetches()
|
|
172
|
+
assert.equal(fetches.length, 1)
|
|
173
|
+
assert.deepEqual(fetches[0].body, {
|
|
174
|
+
entries: [
|
|
175
|
+
{ queue: 'tf.events', partition: 'trace-1', offset: 0, maxBytes: 8 * MiB },
|
|
176
|
+
{ queue: 'tf.events', partition: 'trace-2', offset: 0, maxBytes: 8 * MiB }
|
|
177
|
+
],
|
|
178
|
+
maxBytes: 8 * MiB
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('asks again from one past the last record until the high watermark of the first answer', async () => {
|
|
184
|
+
const broker = new FakeBroker({ recordsPerEntry: 3 })
|
|
185
|
+
broker.write('q', 'p', payloads(10))
|
|
186
|
+
await broker.run(async (queen) => {
|
|
187
|
+
const read = await queen.queue('q').fetch('p')
|
|
188
|
+
assert.deepEqual(offsetsOf(read.get('p')), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
|
189
|
+
assert.deepEqual(broker.fetches().map(h => h.body.entries[0].offset), [0, 3, 6, 9])
|
|
190
|
+
assert.equal(read.get('p').nextOffset, 10)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does not chase a partition written during the read', async () => {
|
|
195
|
+
const broker = new FakeBroker({ recordsPerEntry: 2 })
|
|
196
|
+
broker.write('q', 'p', payloads(4))
|
|
197
|
+
broker.afterFetch = (b) => b.write('q', 'p', [{ late: true }])
|
|
198
|
+
await broker.run(async (queen) => {
|
|
199
|
+
const read = await queen.queue('q').fetch(['p'])
|
|
200
|
+
// The first answer said the partition ended at 4: offsets 0..3 are read,
|
|
201
|
+
// and the call stops there although the partition kept growing.
|
|
202
|
+
assert.deepEqual(offsetsOf(read.get('p')), [0, 1, 2, 3])
|
|
203
|
+
assert.equal(broker.fetches().length, 2)
|
|
204
|
+
assert.equal(read.get('p').nextOffset, 4)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('asks for at most 1000 partitions per call', async () => {
|
|
209
|
+
const broker = new FakeBroker()
|
|
210
|
+
const names = Array.from({ length: 2500 }, (_, i) => `p-${i}`)
|
|
211
|
+
for (const name of names) broker.write('q', name, [{ name }])
|
|
212
|
+
await broker.run(async (queen) => {
|
|
213
|
+
const read = await queen.queue('q').fetch(names)
|
|
214
|
+
assert.deepEqual(broker.fetches().map(h => h.body.entries.length), [1000, 1000, 500])
|
|
215
|
+
assert.equal(read.size, 2500)
|
|
216
|
+
for (const name of names) assert.deepEqual(read.get(name).records.map(r => r.payload), [{ name }])
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('asks again for the partitions the call ceiling left empty', async () => {
|
|
221
|
+
const broker = new FakeBroker()
|
|
222
|
+
const fat = 'x'.repeat(100)
|
|
223
|
+
for (const name of ['a', 'b', 'c']) broker.write('q', name, [fat, fat])
|
|
224
|
+
await broker.run(async (queen) => {
|
|
225
|
+
// Each record weighs 102 bytes: one call carries two of them at most.
|
|
226
|
+
const read = await queen.queue('q').fetch(['a', 'b', 'c'], { maxBytes: 250 })
|
|
227
|
+
for (const name of ['a', 'b', 'c']) assert.deepEqual(offsetsOf(read.get(name)), [0, 1])
|
|
228
|
+
assert.ok(broker.fetches().length > 1)
|
|
229
|
+
assert.ok(broker.fetches().every(h => h.body.maxBytes === 250))
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('reads from logStartOffset when retention deleted the start', async () => {
|
|
234
|
+
const broker = new FakeBroker()
|
|
235
|
+
broker.write('q', 'p', payloads(10))
|
|
236
|
+
broker.trim('q', 'p', 6)
|
|
237
|
+
await broker.run(async (queen) => {
|
|
238
|
+
const read = await queen.queue('q').fetch(['p'])
|
|
239
|
+
assert.deepEqual(offsetsOf(read.get('p')), [6, 7, 8, 9])
|
|
240
|
+
assert.equal(read.get('p').logStartOffset, 6)
|
|
241
|
+
assert.deepEqual(broker.fetches().map(h => h.body.entries[0].offset), [0, 6])
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('starts each partition at its offset in options.offsets', async () => {
|
|
246
|
+
const broker = new FakeBroker()
|
|
247
|
+
broker.write('q', 'a', payloads(5))
|
|
248
|
+
broker.write('q', 'b', payloads(5))
|
|
249
|
+
await broker.run(async (queen) => {
|
|
250
|
+
const read = await queen.queue('q').fetch(['a', 'b'], { offsets: { a: 3 } })
|
|
251
|
+
assert.deepEqual(offsetsOf(read.get('a')), [3, 4])
|
|
252
|
+
assert.deepEqual(offsetsOf(read.get('b')), [0, 1, 2, 3, 4])
|
|
253
|
+
const again = await queen.queue('q').fetch(['a'], { offsets: new Map([['a', read.get('a').nextOffset]]) })
|
|
254
|
+
assert.deepEqual(again.get('a').records, [])
|
|
255
|
+
assert.equal(again.get('a').nextOffset, 5)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('answers no records for a missing queue, an unwritten partition and an offset past the end', async () => {
|
|
260
|
+
const broker = new FakeBroker()
|
|
261
|
+
broker.write('q', 'p', payloads(2))
|
|
262
|
+
await broker.run(async (queen) => {
|
|
263
|
+
const missing = await queen.queue('nope').fetch(['p'])
|
|
264
|
+
assert.deepEqual(missing.get('p').records, [])
|
|
265
|
+
const read = await queen.queue('q').fetch(['never', 'p'], { offsets: { p: 7 } })
|
|
266
|
+
assert.deepEqual(read.get('never').records, [])
|
|
267
|
+
assert.deepEqual(read.get('p').records, [])
|
|
268
|
+
assert.equal(read.get('p').nextOffset, 7)
|
|
269
|
+
assert.equal(read.get('p').highWatermark, 2)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('throws on an error marker it does not know', async () => {
|
|
274
|
+
const broker = new FakeBroker()
|
|
275
|
+
broker.write('q', 'p', payloads(1))
|
|
276
|
+
broker.fetch = () => [200, { entries: [{ queue: 'q', partition: 'p', records: [], highWatermark: 1, logStartOffset: 0, error: 'SOMETHING_NEW' }] }]
|
|
277
|
+
await broker.run(async (queen) => {
|
|
278
|
+
await assert.rejects(queen.queue('q').fetch(['p']), /SOMETHING_NEW/)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('stops when a call reads nothing although the partition is not at its end', async () => {
|
|
283
|
+
const broker = new FakeBroker()
|
|
284
|
+
broker.fetch = () => [200, { entries: [{ queue: 'q', partition: 'p', records: [], highWatermark: 5, logStartOffset: 0 }] }]
|
|
285
|
+
await broker.run(async (queen) => {
|
|
286
|
+
const read = await queen.queue('q').fetch(['p'])
|
|
287
|
+
assert.deepEqual(read.get('p').records, [])
|
|
288
|
+
assert.equal(broker.fetches().length, 1)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('asks once for a partition named twice and refuses a bad name or offset before any call', async () => {
|
|
293
|
+
const broker = new FakeBroker()
|
|
294
|
+
broker.write('q', 'p', payloads(1))
|
|
295
|
+
await broker.run(async (queen) => {
|
|
296
|
+
const read = await queen.queue('q').fetch(['p', 'p'])
|
|
297
|
+
assert.equal(read.size, 1)
|
|
298
|
+
assert.equal(broker.fetches()[0].body.entries.length, 1)
|
|
299
|
+
await assert.rejects(queen.queue('q').fetch(['']), /non-empty string/)
|
|
300
|
+
await assert.rejects(queen.queue('q').fetch(['p'], { offsets: { p: -1 } }), /non-negative integer/)
|
|
301
|
+
await assert.rejects(queen.queue('q').fetch(['p'], { maxBytes: 0 }), /positive integer/)
|
|
302
|
+
assert.equal(broker.fetches().length, 1)
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe('queue.changedSince()', () => {
|
|
308
|
+
it('follows the page cursor to the end of the pass, in creation order', async () => {
|
|
309
|
+
const broker = new FakeBroker()
|
|
310
|
+
for (let i = 0; i < 2300; i++) broker.write('tf.events', `t-${i}`, [{ i }])
|
|
311
|
+
await broker.run(async (queen) => {
|
|
312
|
+
const { partitions, safeTime } = await queen.queue('tf.events').changedSince('2026-10-20T09:00:00Z')
|
|
313
|
+
assert.equal(partitions.length, 2300)
|
|
314
|
+
assert.equal(partitions[0].name, 't-0')
|
|
315
|
+
assert.equal(partitions[2299].name, 't-2299')
|
|
316
|
+
assert.deepEqual(partitions[0], { name: 't-0', id: 'id-t-0', lastOffset: 0, logStart: 0, lastWriteAt: '2026-10-20T10:00:00.000000Z' })
|
|
317
|
+
assert.equal(safeTime, '2026-10-20T12:00:00.000000Z')
|
|
318
|
+
const bodies = broker.hits.map(h => h.body.entries[0])
|
|
319
|
+
assert.deepEqual(bodies.map(b => b.after ?? null), [null, 'p|999', 'p|1999'])
|
|
320
|
+
assert.ok(bodies.every(b => b.since === '2026-10-20T09:00:00Z' && b.limit === 1000 && b.queue === 'tf.events'))
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('lists only the partitions written at or after since', async () => {
|
|
325
|
+
const broker = new FakeBroker()
|
|
326
|
+
broker.write('q', 'old', [1], { lastWriteAt: '2026-10-20T08:00:00.000000Z' })
|
|
327
|
+
broker.write('q', 'new', [1], { lastWriteAt: '2026-10-20T10:30:00.000000Z' })
|
|
328
|
+
await broker.run(async (queen) => {
|
|
329
|
+
const since = new Date('2026-10-20T10:00:00Z')
|
|
330
|
+
const { partitions } = await queen.queue('q').changedSince(since)
|
|
331
|
+
assert.deepEqual(partitions.map(p => p.name), ['new'])
|
|
332
|
+
assert.equal(broker.hits[0].body.entries[0].since, '2026-10-20T10:00:00.000Z')
|
|
333
|
+
await queen.queue('q').changedSince(since.getTime())
|
|
334
|
+
assert.equal(broker.hits[1].body.entries[0].since, '2026-10-20T10:00:00.000Z')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('without since lists every partition, and sends no since', async () => {
|
|
339
|
+
const broker = new FakeBroker()
|
|
340
|
+
broker.write('q', 'a', [1], { lastWriteAt: '2020-01-01T00:00:00.000000Z' })
|
|
341
|
+
await broker.run(async (queen) => {
|
|
342
|
+
const { partitions } = await queen.queue('q').changedSince()
|
|
343
|
+
assert.deepEqual(partitions.map(p => p.name), ['a'])
|
|
344
|
+
assert.equal('since' in broker.hits[0].body.entries[0], false)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('answers the smallest safeTime of its pages', async () => {
|
|
349
|
+
const broker = new FakeBroker()
|
|
350
|
+
for (let i = 0; i < 1500; i++) broker.write('q', `p-${i}`, [1])
|
|
351
|
+
broker.safeTimes = ['2026-10-20T12:00:05.000000Z', '2026-10-20T12:00:01.000000Z']
|
|
352
|
+
await broker.run(async (queen) => {
|
|
353
|
+
const { safeTime } = await queen.queue('q').changedSince(null)
|
|
354
|
+
assert.equal(safeTime, '2026-10-20T12:00:01.000000Z')
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('answers no partitions for a queue that does not exist, and throws on BAD_CURSOR', async () => {
|
|
359
|
+
const broker = new FakeBroker()
|
|
360
|
+
await broker.run(async (queen) => {
|
|
361
|
+
const { partitions, safeTime } = await queen.queue('ghost').changedSince('2026-10-20T00:00:00Z')
|
|
362
|
+
assert.deepEqual(partitions, [])
|
|
363
|
+
assert.equal(safeTime, '2026-10-20T12:00:00.000000Z')
|
|
364
|
+
})
|
|
365
|
+
const bad = new FakeBroker()
|
|
366
|
+
bad.changed = () => [200, { safeTime: '2026-10-20T12:00:00.000000Z', safeTimeDegraded: false, entries: [{ queue: 'q', error: 'BAD_CURSOR' }] }]
|
|
367
|
+
await bad.run(async (queen) => {
|
|
368
|
+
await assert.rejects(queen.queue('q').changedSince(null), /BAD_CURSOR/)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('refuses a since that is not a time before any call', async () => {
|
|
373
|
+
const broker = new FakeBroker()
|
|
374
|
+
await broker.run(async (queen) => {
|
|
375
|
+
await assert.rejects(queen.queue('q').changedSince(new Date('nope')), /invalid Date/)
|
|
376
|
+
await assert.rejects(queen.queue('q').changedSince({}), /ISO-8601/)
|
|
377
|
+
assert.equal(broker.hits.length, 0)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
})
|
package/test-v2/reads.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads by offset (queue.fetch) and partition discovery (queue.changedSince)
|
|
3
|
+
* against a live broker.
|
|
4
|
+
*
|
|
5
|
+
* Run: node run.js fetchReadsEveryRecordByOffset
|
|
6
|
+
* node run.js changedSinceListsTheWrittenPartitions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const QUEUE_FETCH = 'test-queue-v2-reads-fetch'
|
|
10
|
+
const QUEUE_CHANGED = 'test-queue-v2-reads-changed'
|
|
11
|
+
|
|
12
|
+
export async function fetchReadsEveryRecordByOffset(client) {
|
|
13
|
+
// 1,200 partitions: more than one fetch call (1000 entries each).
|
|
14
|
+
const partitions = Array.from({ length: 1200 }, (_, i) => `entity-${i}`)
|
|
15
|
+
const perPartition = 3
|
|
16
|
+
for (let n = 0; n < perPartition; n++) {
|
|
17
|
+
const items = partitions.map(p => ({ partition: p, transactionId: `${p}-${n}`, data: { p, n } }))
|
|
18
|
+
for (let i = 0; i < items.length; i += 500) {
|
|
19
|
+
const chunk = items.slice(i, i + 500)
|
|
20
|
+
// One push per partition group keeps the per-partition order of n.
|
|
21
|
+
await Promise.all(chunk.map(x => client.queue(QUEUE_FETCH).partition(x.partition).push([{ transactionId: x.transactionId, data: x.data }])))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const read = await client.queue(QUEUE_FETCH).fetch(partitions)
|
|
26
|
+
if (read.size !== partitions.length) {
|
|
27
|
+
return { success: false, message: `fetch answered ${read.size} partitions for ${partitions.length}` }
|
|
28
|
+
}
|
|
29
|
+
for (const p of partitions) {
|
|
30
|
+
const part = read.get(p)
|
|
31
|
+
const got = part.records.map(r => `${r.offset}:${r.payload.p}:${r.payload.n}:${r.transactionId}`)
|
|
32
|
+
const want = Array.from({ length: perPartition }, (_, n) => `${n}:${p}:${n}:${p}-${n}`)
|
|
33
|
+
if (got.join(',') !== want.join(',')) {
|
|
34
|
+
return { success: false, message: `${p}: read ${got.join(',')}, expected ${want.join(',')}` }
|
|
35
|
+
}
|
|
36
|
+
if (part.highWatermark !== perPartition || part.nextOffset !== perPartition || part.logStartOffset !== 0) {
|
|
37
|
+
return { success: false, message: `${p}: bounds ${JSON.stringify(part)}` }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A later read from nextOffset answers only what came after.
|
|
42
|
+
await client.queue(QUEUE_FETCH).partition('entity-7').push([{ transactionId: 'entity-7-late', data: { late: true } }])
|
|
43
|
+
const later = await client.queue(QUEUE_FETCH).fetch(['entity-7', 'entity-8'], {
|
|
44
|
+
offsets: { 'entity-7': read.get('entity-7').nextOffset, 'entity-8': read.get('entity-8').nextOffset }
|
|
45
|
+
})
|
|
46
|
+
const late = later.get('entity-7').records
|
|
47
|
+
if (late.length !== 1 || late[0].offset !== perPartition || late[0].payload.late !== true) {
|
|
48
|
+
return { success: false, message: `read from nextOffset answered ${JSON.stringify(late)}` }
|
|
49
|
+
}
|
|
50
|
+
if (later.get('entity-8').records.length !== 0) {
|
|
51
|
+
return { success: false, message: 'a partition with nothing new answered records' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The reads took no lease and moved no cursor: a pop still gets the message.
|
|
55
|
+
const popped = await client.queue(QUEUE_FETCH).partition('entity-9').batch(10).wait(false).pop()
|
|
56
|
+
if (popped.length !== perPartition) {
|
|
57
|
+
return { success: false, message: `a pop after the fetch got ${popped.length} messages, expected ${perPartition}` }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const missing = await client.queue('test-queue-v2-reads-does-not-exist').fetch(['x'])
|
|
61
|
+
if (missing.get('x').records.length !== 0) {
|
|
62
|
+
return { success: false, message: 'a queue that does not exist answered records' }
|
|
63
|
+
}
|
|
64
|
+
return { success: true, message: `read ${partitions.length} partitions by offset` }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function changedSinceListsTheWrittenPartitions(client) {
|
|
68
|
+
const before = new Date(Date.now() - 60_000)
|
|
69
|
+
const names = Array.from({ length: 1100 }, (_, i) => `c-${i}`)
|
|
70
|
+
for (let i = 0; i < names.length; i += 100) {
|
|
71
|
+
await Promise.all(names.slice(i, i + 100).map(p => client.queue(QUEUE_CHANGED).partition(p).push([{ data: { p } }])))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 1,100 partitions: two pages.
|
|
75
|
+
const first = await client.queue(QUEUE_CHANGED).changedSince(before)
|
|
76
|
+
const listed = new Set(first.partitions.map(p => p.name))
|
|
77
|
+
const missing = names.filter(n => !listed.has(n))
|
|
78
|
+
if (missing.length > 0) {
|
|
79
|
+
return { success: false, message: `changedSince missed ${missing.length} partitions, e.g. ${missing.slice(0, 3)}` }
|
|
80
|
+
}
|
|
81
|
+
if (first.partitions.length !== names.length || typeof first.safeTime !== 'string') {
|
|
82
|
+
return { success: false, message: `changedSince answered ${first.partitions.length} partitions, safeTime ${first.safeTime}` }
|
|
83
|
+
}
|
|
84
|
+
const one = first.partitions.find(p => p.name === 'c-5')
|
|
85
|
+
if (one.lastOffset !== 0 || one.logStart !== 0 || typeof one.lastWriteAt !== 'string' || typeof one.id !== 'string') {
|
|
86
|
+
return { success: false, message: `partition fields ${JSON.stringify(one)}` }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// From the safeTime of that pass, a write to one partition lists it, and
|
|
90
|
+
// nothing written before that safeTime comes back.
|
|
91
|
+
await client.queue(QUEUE_CHANGED).partition('c-42').push([{ data: { again: true } }])
|
|
92
|
+
const next = await client.queue(QUEUE_CHANGED).changedSince(first.safeTime)
|
|
93
|
+
if (!next.partitions.some(p => p.name === 'c-42' && p.lastOffset === 1)) {
|
|
94
|
+
return { success: false, message: `c-42 not listed after its write: ${JSON.stringify(next.partitions.slice(0, 5))}` }
|
|
95
|
+
}
|
|
96
|
+
if (next.partitions.some(p => p.lastWriteAt < first.safeTime)) {
|
|
97
|
+
return { success: false, message: 'a partition written before safeTime was listed' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const all = await client.queue(QUEUE_CHANGED).changedSince()
|
|
101
|
+
if (all.partitions.length !== names.length) {
|
|
102
|
+
return { success: false, message: `changedSince() without since answered ${all.partitions.length}` }
|
|
103
|
+
}
|
|
104
|
+
const none = await client.queue('test-queue-v2-reads-does-not-exist').changedSince(before)
|
|
105
|
+
if (none.partitions.length !== 0) {
|
|
106
|
+
return { success: false, message: 'a queue that does not exist listed partitions' }
|
|
107
|
+
}
|
|
108
|
+
return { success: true, message: `listed ${names.length} partitions over two pages` }
|
|
109
|
+
}
|
package/test-v2/run.js
CHANGED
|
@@ -20,6 +20,7 @@ import * as timerTests from './timers.js'
|
|
|
20
20
|
import * as lockTests from './locks.js'
|
|
21
21
|
import * as streamTests from './stream/index.js'
|
|
22
22
|
import * as docsTests from './docs.js'
|
|
23
|
+
import * as readsTests from './reads.js'
|
|
23
24
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
24
25
|
|
|
25
26
|
|
|
@@ -117,6 +118,7 @@ async function main() {
|
|
|
117
118
|
kvTests,
|
|
118
119
|
timerTests,
|
|
119
120
|
lockTests,
|
|
121
|
+
readsTests,
|
|
120
122
|
docsTests
|
|
121
123
|
]
|
|
122
124
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing windows under a claim (broker 2.7, issue #153).
|
|
3
|
+
*
|
|
4
|
+
* A processing-time window closes when its time comes even if its partition
|
|
5
|
+
* gets no more messages: the runner claims the partition (a lease with an
|
|
6
|
+
* empty batch) and closes the window in a cycle that acks that batch. Before
|
|
7
|
+
* claims, the idle flush only knew the partitions its own process had popped
|
|
8
|
+
* in the last five minutes, so a window open at a restart never closed, and
|
|
9
|
+
* two runners could emit one window twice.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Stream } from '../../client-v2/index.js'
|
|
13
|
+
import {
|
|
14
|
+
STREAMS_URL, mkName, sleep, drainUntil,
|
|
15
|
+
expect, summarise
|
|
16
|
+
} from './_helpers.js'
|
|
17
|
+
|
|
18
|
+
function countsByPartition(messages) {
|
|
19
|
+
const by = new Map()
|
|
20
|
+
for (const m of messages) {
|
|
21
|
+
if (!by.has(m.partition)) by.set(m.partition, [])
|
|
22
|
+
by.get(m.partition).push(m.data?.count)
|
|
23
|
+
}
|
|
24
|
+
return by
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ----------------------------------------------------------------------------
|
|
28
|
+
// A session open when its runner stops closes on the next runner, with no new
|
|
29
|
+
// event on its partition.
|
|
30
|
+
// ----------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export async function claimSessionOpenAtRestartClosesWithoutNewEvent(client) {
|
|
33
|
+
const src = mkName('claimSessionOpenAtRestartClosesWithoutNewEvent', 'src')
|
|
34
|
+
const sink = mkName('claimSessionOpenAtRestartClosesWithoutNewEvent', 'sink')
|
|
35
|
+
const queryId = mkName('claimSessionOpenAtRestartClosesWithoutNewEvent', 'q')
|
|
36
|
+
|
|
37
|
+
await client.queue(src).create()
|
|
38
|
+
await client.queue(sink).create()
|
|
39
|
+
|
|
40
|
+
const build = () => Stream
|
|
41
|
+
.from(client.queue(src))
|
|
42
|
+
.windowSession({ gap: 3, idleFlushMs: 250 })
|
|
43
|
+
.aggregate({ count: () => 1 })
|
|
44
|
+
.to(client.queue(sink))
|
|
45
|
+
const opts = { queryId, url: STREAMS_URL, maxWaitMillis: 200, subscriptionMode: 'all' }
|
|
46
|
+
|
|
47
|
+
// Two events of trace-a, then a stop while its 3 s session is open.
|
|
48
|
+
const first = await build().run({ ...opts, reset: true })
|
|
49
|
+
for (let i = 0; i < 2; i++) {
|
|
50
|
+
await client.queue(src).partition('trace-a').push([{ data: { n: 1 } }])
|
|
51
|
+
await sleep(100)
|
|
52
|
+
}
|
|
53
|
+
await sleep(1200)
|
|
54
|
+
const folded = first.metrics().messagesTotal
|
|
55
|
+
await first.stop()
|
|
56
|
+
|
|
57
|
+
// Runner 2, same query. trace-b is the control: it gets an event now.
|
|
58
|
+
const second = await build().run(opts)
|
|
59
|
+
await client.queue(src).partition('trace-b').push([{ data: { n: 1 } }])
|
|
60
|
+
|
|
61
|
+
const group = `drain-claim-restart-${Date.now()}`
|
|
62
|
+
const out = await drainUntil(client, sink, {
|
|
63
|
+
group,
|
|
64
|
+
timeoutMs: 15_000,
|
|
65
|
+
until: m => m.some(x => x.partition === 'trace-a') && m.some(x => x.partition === 'trace-b')
|
|
66
|
+
})
|
|
67
|
+
// Nothing more comes for either partition.
|
|
68
|
+
await sleep(2000)
|
|
69
|
+
const late = await drainUntil(client, sink, { group, timeoutMs: 1000, until: () => false })
|
|
70
|
+
await second.stop()
|
|
71
|
+
|
|
72
|
+
const by = countsByPartition([...out, ...late])
|
|
73
|
+
return summarise('claimSessionOpenAtRestartClosesWithoutNewEvent', [
|
|
74
|
+
expect(folded, '===', 2, 'runner 1 folded both events'),
|
|
75
|
+
expect(JSON.stringify(by.get('trace-a') || []), '===', '[2]', "trace-a's session closes once, with both events"),
|
|
76
|
+
expect(JSON.stringify(by.get('trace-b') || []), '===', '[1]', "trace-b's session closes once"),
|
|
77
|
+
expect(by.size, '===', 2, 'emits on trace-a and trace-b only')
|
|
78
|
+
])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ----------------------------------------------------------------------------
|
|
82
|
+
// Two runners on one query close every session exactly once: each close runs
|
|
83
|
+
// under its partition's lease, and the broker refuses a close whose lease is
|
|
84
|
+
// gone.
|
|
85
|
+
// ----------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export async function claimTwoRunnersCloseEachSessionOnce(client) {
|
|
88
|
+
const src = mkName('claimTwoRunnersCloseEachSessionOnce', 'src')
|
|
89
|
+
const sink = mkName('claimTwoRunnersCloseEachSessionOnce', 'sink')
|
|
90
|
+
const queryId = mkName('claimTwoRunnersCloseEachSessionOnce', 'q')
|
|
91
|
+
|
|
92
|
+
await client.queue(src).create()
|
|
93
|
+
await client.queue(sink).create()
|
|
94
|
+
|
|
95
|
+
const build = () => Stream
|
|
96
|
+
.from(client.queue(src))
|
|
97
|
+
.windowSession({ gap: 2, idleFlushMs: 100 })
|
|
98
|
+
.aggregate({ count: () => 1 })
|
|
99
|
+
.to(client.queue(sink))
|
|
100
|
+
const opts = { queryId, url: STREAMS_URL, maxWaitMillis: 100, maxPartitions: 2, subscriptionMode: 'all' }
|
|
101
|
+
const a = await build().run({ ...opts, reset: true })
|
|
102
|
+
const b = await build().run(opts)
|
|
103
|
+
|
|
104
|
+
const PARTITIONS = 20
|
|
105
|
+
for (let round = 0; round < 2; round++) {
|
|
106
|
+
for (let p = 0; p < PARTITIONS; p++) {
|
|
107
|
+
await client.queue(src).partition(`s-${p}`).push([{ data: { round } }])
|
|
108
|
+
}
|
|
109
|
+
await sleep(300)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const group = `drain-claim-two-${Date.now()}`
|
|
113
|
+
const out = await drainUntil(client, sink, {
|
|
114
|
+
group, timeoutMs: 20_000, until: m => m.length >= PARTITIONS
|
|
115
|
+
})
|
|
116
|
+
// Anything emitted twice would arrive within a few flush ticks.
|
|
117
|
+
await sleep(2000)
|
|
118
|
+
const late = await drainUntil(client, sink, { group, timeoutMs: 1000, until: () => false })
|
|
119
|
+
await a.stop()
|
|
120
|
+
await b.stop()
|
|
121
|
+
|
|
122
|
+
const by = countsByPartition([...out, ...late])
|
|
123
|
+
const wrong = [...by.entries()].filter(([, counts]) => JSON.stringify(counts) !== '[2]')
|
|
124
|
+
return summarise('claimTwoRunnersCloseEachSessionOnce', [
|
|
125
|
+
expect(by.size, '===', PARTITIONS, 'every partition emitted'),
|
|
126
|
+
expect(JSON.stringify(wrong), '===', '[]', 'partitions not emitted exactly once with count 2'),
|
|
127
|
+
expect(a.metrics().messagesTotal + b.metrics().messagesTotal, '===', 2 * PARTITIONS, 'each event folded once')
|
|
128
|
+
])
|
|
129
|
+
}
|
package/test-v2/stream/index.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* throughput.js — multi-partition / many-window throughput
|
|
14
14
|
* combined.js — full pipelines + concurrent streams
|
|
15
15
|
* gate.js — .gate() partial acks against the broker
|
|
16
|
+
* claims.js — quiet windows closed under a claim (broker 2.7)
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
export * from './operators.js'
|
|
@@ -25,3 +26,4 @@ export * from './recovery.js'
|
|
|
25
26
|
export * from './throughput.js'
|
|
26
27
|
export * from './combined.js'
|
|
27
28
|
export * from './gate.js'
|
|
29
|
+
export * from './claims.js'
|