queen-mq 2.1.0 → 2.6.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,252 @@
1
+ /**
2
+ * Reads by offset and partition discovery, as two queue methods:
3
+ *
4
+ * const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
5
+ * read.get('customer-42').records // [{ offset, transactionId, payload, ts }, ...]
6
+ *
7
+ * const { partitions, safeTime } = await queen.queue('orders').changedSince(since)
8
+ *
9
+ * `fetch` is `POST /api/v1/fetch` and `changedSince` is
10
+ * `POST /api/v1/partitions/changed`. Neither takes a lease, moves a cursor or
11
+ * needs an ack: two readers asking the same question get the same answer, and
12
+ * no consumer group notices them. Each is served from the answering node's own
13
+ * applied state.
14
+ *
15
+ * Both do the loops a caller of the raw routes has to write:
16
+ *
17
+ * - fetch asks for at most 1000 partitions per call, and asks again from one
18
+ * past the last record until each partition reaches the high watermark its
19
+ * first answer reported. A partition whose start was deleted by retention is
20
+ * read from its oldest kept record (an offset below `logStartOffset` answers
21
+ * OFFSET_OUT_OF_RANGE and no records, so a loop that does not restart there
22
+ * silently reads nothing).
23
+ * - changedSince follows the page cursor (`next`) until the broker says the
24
+ * pass is over, and answers the smallest `safeTime` of its pages.
25
+ */
26
+
27
+ // The broker refuses more than 1024 entries in one call; 1000 leaves room.
28
+ const ENTRIES_PER_CALL = 1000
29
+
30
+ // Bytes of payload one call may carry: each entry's budget and the ceiling of
31
+ // the whole call. 8 MiB is the most the broker reads for one entry.
32
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
33
+
34
+ // Partitions per discovery page: the broker's default and its ceiling.
35
+ const CHANGED_PAGE = 1000
36
+
37
+ const OFFSET_OUT_OF_RANGE = 'OFFSET_OUT_OF_RANGE'
38
+ const UNKNOWN_TOPIC_OR_PARTITION = 'UNKNOWN_TOPIC_OR_PARTITION'
39
+
40
+ // Every call of one read goes to the same node: a follower answers from what it
41
+ // has applied, so two nodes can disagree about where a partition ends.
42
+ const readAffinity = (queue) => `${queue}:*:__READ__`
43
+
44
+ function partitionNames(partitions) {
45
+ const list = typeof partitions === 'string' ? [partitions] : partitions
46
+ if (!Array.isArray(list)) {
47
+ throw new Error('fetch: partitions must be a partition name or an array of names')
48
+ }
49
+ const names = []
50
+ const seen = new Set()
51
+ for (const p of list) {
52
+ const name = typeof p === 'number' && Number.isInteger(p) ? String(p) : p
53
+ if (typeof name !== 'string' || name.length === 0) {
54
+ throw new Error(`fetch: a partition name must be a non-empty string, got ${JSON.stringify(p)}`)
55
+ }
56
+ if (!seen.has(name)) {
57
+ seen.add(name)
58
+ names.push(name)
59
+ }
60
+ }
61
+ return names
62
+ }
63
+
64
+ function startOffsets(offsets) {
65
+ if (offsets == null) return new Map()
66
+ const entries = offsets instanceof Map ? [...offsets.entries()] : Object.entries(offsets)
67
+ const out = new Map()
68
+ for (const [name, offset] of entries) {
69
+ if (!Number.isSafeInteger(offset) || offset < 0) {
70
+ throw new Error(`fetch: offsets['${name}'] must be a non-negative integer, got ${JSON.stringify(offset)}`)
71
+ }
72
+ out.set(String(name), offset)
73
+ }
74
+ return out
75
+ }
76
+
77
+ function positiveInteger(value, what) {
78
+ if (!Number.isSafeInteger(value) || value < 1) {
79
+ throw new Error(`${what} must be a positive integer, got ${JSON.stringify(value)}`)
80
+ }
81
+ return value
82
+ }
83
+
84
+ /**
85
+ * Every record of `partitions`, read by offset: from each partition's offset in
86
+ * `options.offsets` (default 0, the oldest record still kept) up to where the
87
+ * partition ended when it was first read.
88
+ *
89
+ * Answers a Map, in the order the partitions were named, of
90
+ * `{ partition, records, highWatermark, logStartOffset, nextOffset }`:
91
+ * - `records`: `{ offset, transactionId, payload, ts }` in offset order. `ts` is
92
+ * the time the push that wrote the record was stored.
93
+ * - `highWatermark`: the next offset the partition will assign;
94
+ * `logStartOffset`: the oldest one retention kept.
95
+ * - `nextOffset`: where to start next time to read only what is new.
96
+ *
97
+ * A partition nobody has written, or of a queue that does not exist, answers no
98
+ * records. So does an offset past the end of the partition (`nextOffset` above
99
+ * `highWatermark` says so: the partition was deleted and written again).
100
+ */
101
+ export async function fetchPartitions(httpClient, queue, partitions, options = {}) {
102
+ if (!queue) throw new Error('Queue name is required for fetch')
103
+ const names = partitionNames(partitions)
104
+ const offsets = startOffsets(options.offsets)
105
+ const maxBytes = positiveInteger(options.maxBytes ?? DEFAULT_MAX_BYTES, 'fetch: maxBytes')
106
+
107
+ const state = new Map()
108
+ for (const name of names) {
109
+ state.set(name, {
110
+ partition: name,
111
+ records: [],
112
+ highWatermark: 0,
113
+ logStartOffset: 0,
114
+ nextOffset: offsets.get(name) ?? 0,
115
+ // The high watermark of the first answer: the read ends there.
116
+ end: null
117
+ })
118
+ }
119
+
120
+ let pending = names
121
+ while (pending.length > 0) {
122
+ const again = []
123
+ for (let i = 0; i < pending.length; i += ENTRIES_PER_CALL) {
124
+ const chunk = pending.slice(i, i + ENTRIES_PER_CALL)
125
+ const body = {
126
+ entries: chunk.map(name => ({
127
+ queue,
128
+ partition: name,
129
+ offset: state.get(name).nextOffset,
130
+ maxBytes
131
+ })),
132
+ maxBytes
133
+ }
134
+ const answer = await httpClient.post('/api/v1/fetch', body, null, readAffinity(queue))
135
+ const entries = answer?.entries
136
+ if (!Array.isArray(entries) || entries.length !== chunk.length) {
137
+ throw new Error(`fetch ${queue}: the broker answered ${Array.isArray(entries) ? entries.length : 'no'} entries for ${chunk.length} asked`)
138
+ }
139
+
140
+ let delivered = 0
141
+ // Entries that came back empty while short of their end: the call's byte
142
+ // ceiling was spent by the entries before them.
143
+ const starved = []
144
+ chunk.forEach((name, j) => {
145
+ const entry = entries[j]
146
+ const s = state.get(name)
147
+ s.highWatermark = entry.highWatermark ?? s.highWatermark
148
+ s.logStartOffset = entry.logStartOffset ?? s.logStartOffset
149
+ if (entry.error) {
150
+ if (entry.error === OFFSET_OUT_OF_RANGE && s.nextOffset < s.logStartOffset) {
151
+ // Retention deleted what was asked for: go on from the oldest kept.
152
+ s.nextOffset = s.logStartOffset
153
+ if (s.end === null) s.end = s.highWatermark
154
+ if (s.nextOffset < s.end) again.push(name)
155
+ return
156
+ }
157
+ if (entry.error === OFFSET_OUT_OF_RANGE || entry.error === UNKNOWN_TOPIC_OR_PARTITION) {
158
+ return
159
+ }
160
+ throw new Error(`fetch ${queue}/${name}: the broker answered ${entry.error}`)
161
+ }
162
+ if (s.end === null) s.end = s.highWatermark
163
+ const records = Array.isArray(entry.records) ? entry.records : []
164
+ if (records.length > 0) {
165
+ delivered += records.length
166
+ for (const r of records) {
167
+ s.records.push({ offset: r.offset, transactionId: r.transactionId, payload: r.payload, ts: r.ts })
168
+ }
169
+ s.nextOffset = records[records.length - 1].offset + 1
170
+ if (s.nextOffset < s.end) again.push(name)
171
+ } else if (s.nextOffset < s.end) {
172
+ starved.push(name)
173
+ }
174
+ })
175
+ // An empty entry is asked again only when its call read something: a
176
+ // call that read nothing at all would answer the same next time.
177
+ if (delivered > 0) again.push(...starved)
178
+ }
179
+ pending = again
180
+ }
181
+
182
+ const out = new Map()
183
+ for (const name of names) {
184
+ const { partition, records, highWatermark, logStartOffset, nextOffset } = state.get(name)
185
+ out.set(name, { partition, records, highWatermark, logStartOffset, nextOffset })
186
+ }
187
+ return out
188
+ }
189
+
190
+ function sinceIso(since) {
191
+ if (since == null) return null
192
+ if (since instanceof Date) {
193
+ if (Number.isNaN(since.getTime())) throw new Error('changedSince: since is an invalid Date')
194
+ return since.toISOString()
195
+ }
196
+ if (typeof since === 'number') {
197
+ const d = new Date(since)
198
+ if (Number.isNaN(d.getTime())) throw new Error(`changedSince: since ${since} is not a time`)
199
+ return d.toISOString()
200
+ }
201
+ if (typeof since === 'string' && since.length > 0) return since
202
+ throw new Error(`changedSince: since must be a Date, epoch milliseconds or an ISO-8601 string, got ${JSON.stringify(since)}`)
203
+ }
204
+
205
+ /**
206
+ * The partitions of the queue written at or after `since`, in the order they
207
+ * were created: `{ partitions, safeTime }`. Each partition is
208
+ * `{ name, id, lastOffset, logStart, lastWriteAt }`; `lastOffset` is the offset
209
+ * of its last record (-1 when none).
210
+ *
211
+ * `since` is a Date, epoch milliseconds or an ISO-8601 string; null or absent
212
+ * lists every partition of the queue. A queue that does not exist has none.
213
+ *
214
+ * `safeTime` is the broker's time up to which every write is counted in this
215
+ * answer: pass it as the next `since` to see only what changed after this
216
+ * call. It comes from the broker's clock, never compare it with the local one.
217
+ */
218
+ export async function partitionsChangedSince(httpClient, queue, since) {
219
+ if (!queue) throw new Error('Queue name is required for changedSince')
220
+ const from = sinceIso(since)
221
+ const partitions = []
222
+ let safeTime = null
223
+ let after = null
224
+ for (;;) {
225
+ const entry = { queue, limit: CHANGED_PAGE }
226
+ if (from !== null) entry.since = from
227
+ if (after !== null) entry.after = after
228
+ const answer = await httpClient.post('/api/v1/partitions/changed', { entries: [entry] }, null, readAffinity(queue))
229
+ // Pages served by different nodes: the smallest is the one that holds for
230
+ // all of them. The broker spells every one the same way, so the strings
231
+ // compare in time order.
232
+ if (typeof answer?.safeTime === 'string' && (safeTime === null || answer.safeTime < safeTime)) {
233
+ safeTime = answer.safeTime
234
+ }
235
+ const result = answer?.entries?.[0]
236
+ if (!result) throw new Error(`changedSince ${queue}: the broker answered no entry`)
237
+ if (result.error === UNKNOWN_TOPIC_OR_PARTITION) break
238
+ if (result.error) throw new Error(`changedSince ${queue}: the broker answered ${result.error}`)
239
+ for (const p of result.partitions || []) {
240
+ partitions.push({
241
+ name: p.name,
242
+ id: p.id ?? null,
243
+ lastOffset: p.lastOffset,
244
+ logStart: p.logStart,
245
+ lastWriteAt: p.lastWriteAt
246
+ })
247
+ }
248
+ if (!result.next) break
249
+ after = result.next
250
+ }
251
+ return { partitions, safeTime }
252
+ }
@@ -36,7 +36,23 @@ export const CLIENT_DEFAULTS = {
36
36
  // retries unboundedly (paced by backoff) unless maxAttempts is set here, in
37
37
  // which case it applies to both. baseMs (500) / capMs (30000) size the
38
38
  // exponential backoff used when the server doesn't send Retry-After.
39
- retry429: undefined
39
+ retry429: undefined,
40
+ // The three below need a broker that has the binary push body and the framed
41
+ // pop answer. Off, the client sends and reads the JSON every broker knows.
42
+ //
43
+ // binaryPush: a push goes out as the binary push body (http/pushBinary.js)
44
+ // instead of JSON. Items with a trace id stay JSON.
45
+ binaryPush: false,
46
+ // pushCompression (with binaryPush): the client frames the messages as the
47
+ // broker stores them, mints their ids and zstd-compresses each partition's
48
+ // batch once; the broker keeps those bytes as they are. A transaction that
49
+ // pushes sends its pushes the same way, and the rest of the bundle as JSON
50
+ // in front of them.
51
+ pushCompression: false,
52
+ // framedPop: pops ask for the messages as the stored records (frames=true)
53
+ // and unpack them here (http/popFrames.js). A broker that does not know the
54
+ // parameter answers JSON, which is read as always.
55
+ framedPop: false
40
56
  }
41
57
 
42
58
  export const QUEUE_DEFAULTS = {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "2.1.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "Partitioned message queue on a replicated broker log — broker client + fluent streaming SDK (windows, joins, gates) in one package",
6
6
  "main": "client-v2/index.js",
7
7
  "scripts": {
8
8
  "test": "npm run test:unit && node test-v2/run.js human",
9
- "test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/kv-unit/locksWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js test-v2/consumer-unit/supervision.test.js",
9
+ "test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/kv-unit/locksWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js test-v2/consumer-unit/supervision.test.js test-v2/wire-unit/pushBinary.test.js test-v2/wire-unit/popFrames.test.js test-v2/wire-unit/transactionFrames.test.js test-v2/reads-unit/offsetReads.test.js",
10
10
  "test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
11
11
  "test:integration": "node test-v2/run.js human",
12
12
  "test:streams": "node test-v2/run.js stream",
@@ -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
+ })