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.
@@ -0,0 +1,361 @@
1
+ /**
2
+ * consume() — a pop the broker could not answer for a while is polled again.
3
+ *
4
+ * The rule: a consumer stops only for a fault that waiting cannot fix (a 4xx
5
+ * the broker answers the same way next time, a conflation it cannot apply, a
6
+ * stop by its caller). What the broker, or the way to it, answers while it
7
+ * cannot serve (the 503s of a leader election, a 500 from a node shutting
8
+ * down, a gateway's 502 or 504, a timeout, a lost connection) is waited out,
9
+ * paced by Retry-After, and the consumer goes on.
10
+ *
11
+ * Before: only a 429 and a refused connection were retried. On 2026-10-29 the
12
+ * prod cluster lost its leader twice, pops were answered 503 "the request
13
+ * deadline elapsed" for a few minutes, and every consumer of a service
14
+ * stopped. Its process stayed up, so nothing restarted it, and hours of
15
+ * messages piled up.
16
+ */
17
+
18
+ import { describe, it } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import { createServer } from 'node:http'
21
+
22
+ import { Queen } from '../../client-v2/index.js'
23
+ import { isTransientPopError, transientDelayMillis } from '../../client-v2/consumer/ConsumerManager.js'
24
+ import { configure as configureLogger } from '../../client-v2/utils/logger.js'
25
+
26
+ const GROUP = 'workers'
27
+
28
+ const message = (n) => ({
29
+ id: `msg-${n}`,
30
+ transactionId: `tx-${n}`,
31
+ partitionId: '7',
32
+ partition: 'p1',
33
+ leaseId: 'lease-1',
34
+ consumerGroup: GROUP,
35
+ data: { n },
36
+ createdAt: '2026-10-29T17:40:00.000Z'
37
+ })
38
+
39
+ // The broker's own answers while it has no leader (server/src/handlers/raft.rs).
40
+ const brokerBusy = (code, error, retryAfter = 0) => ({
41
+ status: 503,
42
+ retryAfter,
43
+ body: JSON.stringify({ error, code })
44
+ })
45
+ const deadlineElapsed = (retryAfter = 0) => brokerBusy('timeout', 'the request deadline elapsed', retryAfter)
46
+ const noLeader = (retryAfter = 0) => brokerBusy('no_leader', 'no leader is known; retry after', retryAfter)
47
+
48
+ /**
49
+ * A fake broker. Each pop takes the next step of `script`:
50
+ * - `{ status, body, retryAfter }`: answered with that status;
51
+ * - `{ messages }`: answered 200 with those messages;
52
+ * - `'drop-mid-answer'`: the headers go out, the connection is cut halfway
53
+ * through the body;
54
+ * - `'hold'`: never answered.
55
+ * Once the script runs out, a pop is held open, like a long poll on an idle
56
+ * queue. Acks are accepted.
57
+ */
58
+ async function startBroker(script) {
59
+ const steps = [...script]
60
+ const requests = []
61
+ const held = []
62
+ const server = createServer((req, res) => {
63
+ let raw = ''
64
+ req.on('data', chunk => { raw += chunk })
65
+ req.on('end', () => {
66
+ const path = req.url.split('?')[0]
67
+ const record = { method: req.method, path, body: raw ? JSON.parse(raw) : null, at: performance.now() }
68
+ requests.push(record)
69
+ if (req.method === 'GET' && path.startsWith('/api/v1/pop')) {
70
+ const step = steps.shift()
71
+ if (step === undefined || step === 'hold') {
72
+ held.push(res)
73
+ return
74
+ }
75
+ if (step === 'drop-mid-answer') {
76
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '4096' })
77
+ res.write('{"success":true,"messages":[')
78
+ setTimeout(() => res.socket.destroy(), 20)
79
+ return
80
+ }
81
+ if (step.messages) {
82
+ res.writeHead(200, { 'Content-Type': 'application/json' })
83
+ res.end(JSON.stringify({ success: true, consumerGroup: GROUP, messages: step.messages }))
84
+ return
85
+ }
86
+ const headers = { 'Content-Type': step.contentType || 'application/json' }
87
+ if (step.retryAfter != null) headers['Retry-After'] = String(step.retryAfter)
88
+ res.writeHead(step.status, headers)
89
+ res.end(step.body ?? '')
90
+ return
91
+ }
92
+ if (req.method === 'POST' && (path === '/api/v1/ack' || path === '/api/v1/ack/batch')) {
93
+ const acks = record.body.acknowledgments || [record.body]
94
+ res.writeHead(200, { 'Content-Type': 'application/json' })
95
+ res.end(JSON.stringify(acks.map((a, i) => ({ index: i, transactionId: a.transactionId, success: true, error: null, leaseReleased: true }))))
96
+ return
97
+ }
98
+ res.writeHead(404, { 'Content-Type': 'application/json' })
99
+ res.end('{"error":"not found"}')
100
+ })
101
+ })
102
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
103
+ return {
104
+ url: `http://127.0.0.1:${server.address().port}`,
105
+ pops: () => requests.filter(r => r.method === 'GET' && r.path.startsWith('/api/v1/pop')),
106
+ acks: () => requests.filter(r => r.method === 'POST' && r.path.startsWith('/api/v1/ack')),
107
+ async stop() {
108
+ for (const res of held) if (!res.writableEnded && !res.destroyed) { res.writeHead(204); res.end() }
109
+ server.closeAllConnections()
110
+ await new Promise(resolve => server.close(resolve))
111
+ }
112
+ }
113
+ }
114
+
115
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
116
+
117
+ /** Resolves with 'resolved', the rejection's error, or 'pending' if it took longer than `ms`. */
118
+ const settleWithin = (promise, ms) => Promise.race([
119
+ Promise.resolve(promise).then(() => 'resolved', error => error),
120
+ sleep(ms).then(() => 'pending')
121
+ ])
122
+
123
+ async function until(condition, ms, what) {
124
+ const deadline = performance.now() + ms
125
+ while (!condition()) {
126
+ if (performance.now() > deadline) assert.fail(`timed out after ${ms} ms waiting for ${what}`)
127
+ await sleep(10)
128
+ }
129
+ }
130
+
131
+ const settledStatuses = (acks) => acks.flatMap(a => (a.body.acknowledgments || [a.body]).map(x => `${x.transactionId}:${x.status}`))
132
+
133
+ /** A custom logger that keeps every structured line it is given, by level. */
134
+ function captureLogs() {
135
+ const lines = { info: [], warn: [], error: [], debug: [] }
136
+ const logger = Object.fromEntries(Object.keys(lines).map(level => [level, (fields) => { lines[level].push(fields) }]))
137
+ return { logger, lines }
138
+ }
139
+
140
+ /**
141
+ * A consumer on `broker` whose handler records the transaction ids it is
142
+ * given. `stop()` aborts it and closes the client; it runs once, so a test's
143
+ * `finally` can call it again to clean up after a failed assertion.
144
+ */
145
+ function startConsumer(broker, { configure = q => q, queenOptions = {} } = {}) {
146
+ const queen = new Queen({ url: broker.url, handleSignals: false, retryAttempts: 1, ...queenOptions })
147
+ const handled = []
148
+ const ac = new AbortController()
149
+ const running = Promise.resolve(
150
+ configure(queen.queue('orders').group(GROUP))
151
+ .consume(async (messages) => { handled.push(...[messages].flat().map(m => m.transactionId)) }, { signal: ac.signal })
152
+ )
153
+ running.catch(() => {}) // a test that expects a rejection reads it through settleWithin
154
+ let stopping = null
155
+ return {
156
+ handled,
157
+ running,
158
+ stop() {
159
+ stopping ??= (async () => {
160
+ ac.abort()
161
+ const outcome = await settleWithin(running, 2000)
162
+ await queen.close()
163
+ return outcome
164
+ })()
165
+ return stopping
166
+ }
167
+ }
168
+ }
169
+
170
+ describe('consume() — waits out what the broker could not answer for a while', () => {
171
+ it('a leader election: 503s, then a message — it is handled and the consumer goes on', async () => {
172
+ const broker = await startBroker([deadlineElapsed(), deadlineElapsed(), deadlineElapsed(), { messages: [message(1)] }])
173
+ const logs = captureLogs()
174
+ const consumer = startConsumer(broker, { queenOptions: { logger: logs.logger, structuredLogs: true } })
175
+ try {
176
+ // 3 refused pops, the one that brings the message, then the next long poll.
177
+ await until(() => broker.pops().length === 5, 3000, 'the consumer to poll again after the message')
178
+
179
+ assert.deepEqual(consumer.handled, ['tx-1'])
180
+ assert.deepEqual(settledStatuses(broker.acks()), ['tx-1:completed'])
181
+ assert.equal(await settleWithin(consumer.running, 50), 'pending', 'consume() is still running')
182
+
183
+ const retries = logs.lines.warn.filter(l => l.status === 'pop-failed-retrying')
184
+ assert.deepEqual(retries.map(l => [l.httpStatus, l.code, l.failedPops]), [[503, 'timeout', 1], [503, 'timeout', 2], [503, 'timeout', 3]])
185
+ const recovered = logs.lines.warn.filter(l => l.status === 'pop-recovered')
186
+ assert.equal(recovered.length, 1, 'one line when the pops are answered again')
187
+ assert.equal(recovered[0].failedPops, 3)
188
+ assert.equal(await consumer.stop(), 'resolved')
189
+ } finally {
190
+ await consumer.stop()
191
+ configureLogger(null)
192
+ await broker.stop()
193
+ }
194
+ })
195
+
196
+ it('waits the Retry-After the broker sent before the next pop', async () => {
197
+ const broker = await startBroker([noLeader(1), { messages: [message(1)] }])
198
+ const consumer = startConsumer(broker)
199
+ try {
200
+ await until(() => broker.acks().length === 1, 4000, 'the message to be acked')
201
+ const [refused, served] = broker.pops()
202
+ const gap = served.at - refused.at
203
+ assert.ok(gap >= 750 && gap < 2000, `polled again after ${Math.round(gap)} ms, the Retry-After was 1 s`)
204
+ assert.deepEqual(consumer.handled, ['tx-1'])
205
+ assert.equal(await consumer.stop(), 'resolved')
206
+ } finally {
207
+ await consumer.stop()
208
+ await broker.stop()
209
+ }
210
+ })
211
+
212
+ it('a 500 from a node shutting down, and a gateway 502 that is not JSON', async () => {
213
+ const broker = await startBroker([
214
+ { status: 500, retryAfter: 0, body: JSON.stringify({ error: 'planner channel closed', code: 'internal' }) },
215
+ { status: 502, retryAfter: 0, contentType: 'text/html', body: '<html><body>502 Bad Gateway</body></html>' },
216
+ { messages: [message(1)] }
217
+ ])
218
+ const consumer = startConsumer(broker)
219
+ try {
220
+ await until(() => broker.acks().length === 1, 3000, 'the message to be acked')
221
+ assert.deepEqual(consumer.handled, ['tx-1'])
222
+ assert.equal(await consumer.stop(), 'resolved')
223
+ } finally {
224
+ await consumer.stop()
225
+ await broker.stop()
226
+ }
227
+ })
228
+
229
+ it('a connection cut while the answer was being read', async () => {
230
+ const broker = await startBroker(['drop-mid-answer', { messages: [message(1)] }])
231
+ const consumer = startConsumer(broker)
232
+ try {
233
+ await until(() => broker.acks().length === 1, 4000, 'the message to be acked')
234
+ assert.deepEqual(consumer.handled, ['tx-1'])
235
+ assert.equal(await consumer.stop(), 'resolved')
236
+ } finally {
237
+ await consumer.stop()
238
+ await broker.stop()
239
+ }
240
+ })
241
+
242
+ it('wait(false): a pop the client timed out', async () => {
243
+ const broker = await startBroker(['hold', { messages: [message(1)] }])
244
+ const consumer = startConsumer(broker, { configure: q => q.wait(false).timeoutMillis(200) })
245
+ try {
246
+ await until(() => broker.acks().length === 1, 4000, 'the message to be acked')
247
+ assert.deepEqual(consumer.handled, ['tx-1'])
248
+ assert.equal(await consumer.stop(), 'resolved')
249
+ } finally {
250
+ await consumer.stop()
251
+ await broker.stop()
252
+ }
253
+ })
254
+
255
+ it('a stop during the wait ends consume() at once', async () => {
256
+ const broker = await startBroker([noLeader(30)])
257
+ const consumer = startConsumer(broker)
258
+ try {
259
+ await until(() => broker.pops().length === 1, 2000, 'the first pop')
260
+ await sleep(100) // the answer is read and the consumer is waiting
261
+ const started = performance.now()
262
+ const outcome = await consumer.stop()
263
+ const elapsed = performance.now() - started
264
+ assert.equal(outcome, 'resolved')
265
+ assert.ok(elapsed < 250, `stopping took ${Math.round(elapsed)} ms, not the 30 s wait`)
266
+ assert.equal(broker.pops().length, 1, 'no pop after the stop')
267
+ } finally {
268
+ await consumer.stop()
269
+ await broker.stop()
270
+ }
271
+ })
272
+ })
273
+
274
+ describe('consume() — still stops on what waiting cannot fix', () => {
275
+ const refusals = [
276
+ { name: 'a 400 the broker refuses the same way next time', status: 400, body: { error: 'subscriptionFrom is not a timestamp', code: 'rejected' } },
277
+ { name: 'a 503 raft_phase1_unsupported: the route is not served at all', status: 503, body: { error: 'raft phase 1: the message path is routed but not yet wired to the state machine', code: 'raft_phase1_unsupported' } }
278
+ ]
279
+ for (const refusal of refusals) {
280
+ it(refusal.name, async () => {
281
+ const broker = await startBroker([{ status: refusal.status, retryAfter: refusal.status === 503 ? 1 : null, body: JSON.stringify(refusal.body) }])
282
+ const consumer = startConsumer(broker)
283
+ try {
284
+ const outcome = await settleWithin(consumer.running, 2000)
285
+ assert.ok(outcome instanceof Error, `consume() rejects, got ${outcome}`)
286
+ assert.equal(outcome.status, refusal.status)
287
+ assert.equal(outcome.code, refusal.body.code)
288
+ assert.equal(broker.pops().length, 1, 'not polled again')
289
+ assert.deepEqual(consumer.handled, [])
290
+ } finally {
291
+ await consumer.stop()
292
+ await broker.stop()
293
+ }
294
+ })
295
+ }
296
+ })
297
+
298
+ describe('isTransientPopError', () => {
299
+ const http = (status, code) => Object.assign(new Error(`HTTP ${status}`), { status, code })
300
+ const named = (name, text, extra = {}) => Object.assign(new Error(text), { name, ...extra })
301
+
302
+ it('is true for what waiting can fix', () => {
303
+ const transient = {
304
+ '503 timeout (the request deadline elapsed)': http(503, 'timeout'),
305
+ '503 no_leader': http(503, 'no_leader'),
306
+ '503 retry (not the leader)': http(503, 'retry'),
307
+ '503 outcome_unknown': http(503, 'outcome_unknown'),
308
+ '503 standby': http(503, 'standby'),
309
+ '500 internal': http(500, 'internal'),
310
+ '502 from a gateway': http(502),
311
+ '504 from a gateway': http(504),
312
+ '507 storage_full': http(507, 'storage_full'),
313
+ '408 from a gateway': http(408),
314
+ '429 the HttpClient stopped retrying': http(429, 'overloaded'),
315
+ "this client's request timeout": named('AbortError', 'Request timeout after 35000ms', { timeout: 35000 }),
316
+ 'a refused connection': new TypeError('fetch failed (ECONNREFUSED)'),
317
+ 'a connection reset': new TypeError('fetch failed (ECONNRESET)'),
318
+ 'a connection cut mid-answer': new TypeError('terminated')
319
+ }
320
+ for (const [what, error] of Object.entries(transient)) assert.equal(isTransientPopError(error), true, what)
321
+ })
322
+
323
+ it('is false for what waiting cannot fix', () => {
324
+ const terminal = {
325
+ '400 rejected': http(400, 'rejected'),
326
+ '401': http(401),
327
+ '403 cluster_suspended': http(403, 'cluster_suspended'),
328
+ '404': http(404),
329
+ '413 name_too_long': http(413, 'name_too_long'),
330
+ '503 raft_phase1_unsupported': http(503, 'raft_phase1_unsupported'),
331
+ 'a stop by the caller': named('AbortError', 'GET /api/v1/pop/queue/orders aborted by the caller', { aborted: true }),
332
+ 'a hostHeader this runtime cannot send': new Error("hostHeader 'acme:6711' cannot be honored here: no undici dispatcher available"),
333
+ 'a bug': new TypeError("Cannot read properties of undefined (reading 'messages')"),
334
+ 'nothing': null
335
+ }
336
+ for (const [what, error] of Object.entries(terminal)) assert.equal(isTransientPopError(error), false, what)
337
+ })
338
+ })
339
+
340
+ describe('transientDelayMillis', () => {
341
+ const middle = () => 0.5
342
+ const lowest = () => 0
343
+ const highest = () => 0.999999
344
+
345
+ it("waits the broker's Retry-After, ±20 %, however many pops failed", () => {
346
+ assert.equal(transientDelayMillis(1, 1, middle), 1000)
347
+ assert.equal(transientDelayMillis(9, 1, middle), 1000)
348
+ assert.equal(transientDelayMillis(1, 1, lowest), 800)
349
+ assert.equal(transientDelayMillis(1, 1, highest), 1200)
350
+ })
351
+
352
+ it('without a Retry-After: 1 s, doubling with each failure, up to 30 s', () => {
353
+ assert.deepEqual([1, 2, 3, 4, 5, 6, 7, 60].map(n => transientDelayMillis(n, null, middle)),
354
+ [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000])
355
+ })
356
+
357
+ it('never more than 30 s, whatever the Retry-After', () => {
358
+ assert.equal(transientDelayMillis(1, 3600, highest), 30000)
359
+ assert.equal(transientDelayMillis(1, 3600, lowest), 24000)
360
+ })
361
+ })
@@ -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
+ }
@@ -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'