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 CHANGED
@@ -799,6 +799,48 @@ other than the lost precondition still throws.
799
799
 
800
800
  ---
801
801
 
802
+ ## Reads by Offset
803
+
804
+ Two queue methods read a partition's log by position, over `POST /api/v1/fetch` and
805
+ `POST /api/v1/partitions/changed`. Neither takes a lease, moves a cursor or needs an ack: two
806
+ readers asking the same question get the same answer, and no consumer group notices them. They
807
+ suit a replay, an audit, a sink that keeps its own position, and a service that rebuilds an entity
808
+ from its partition.
809
+
810
+ ```javascript
811
+ // Every record of these partitions, from the oldest one still kept.
812
+ const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
813
+ const part = read.get('customer-42')
814
+ part.records // [{ offset, transactionId, payload, ts }, ...] in offset order
815
+ part.nextOffset // where to start next time to read only what is new
816
+ part.highWatermark // the next offset the partition will assign
817
+ part.logStartOffset // the oldest offset retention kept
818
+
819
+ // Only what came after an earlier read.
820
+ const later = await queen.queue('orders').fetch(['customer-42'], {
821
+ offsets: { 'customer-42': part.nextOffset }
822
+ })
823
+
824
+ // The partitions written at or after a time, every page of them, in creation order.
825
+ const { partitions, safeTime } = await queen.queue('orders').changedSince(new Date(Date.now() - 600_000))
826
+ partitions.map(p => p.name) // each is { name, id, lastOffset, logStart, lastWriteAt }
827
+
828
+ // Next time, only what changed after that answer.
829
+ const next = await queen.queue('orders').changedSince(safeTime)
830
+ ```
831
+
832
+ `fetch` reads each partition up to where it ended when the call first read it: it asks for 1000
833
+ partitions per request, and asks again until each one is read. A partition whose start retention
834
+ deleted is read from its oldest kept record. It answers a `Map` in the order the partitions were
835
+ named; a partition nobody wrote, or of a queue that does not exist, has no records.
836
+ `options.maxBytes` caps the payload bytes of one request (default 8 MiB).
837
+
838
+ `changedSince` takes a `Date`, epoch milliseconds or an ISO-8601 string; without one it lists every
839
+ partition of the queue. `safeTime` is the broker's time up to which every write is counted in the
840
+ answer: pass it as the next `since`, and never compare it with your own clock.
841
+
842
+ ---
843
+
802
844
  ## Examples
803
845
 
804
846
  ### Complete Pipeline with Consumer Groups
@@ -934,6 +976,16 @@ throws before any request when the builder has `.commitOnDelivery()`.
934
976
 
935
977
  `.autoAck()` is `consume()`'s ack after your handler and has no effect on a pop.
936
978
 
979
+ ### Reads by Offset
980
+
981
+ ```javascript
982
+ const read = await queen.queue('q').fetch(['p1', 'p2']) // Map: partition -> { records, nextOffset, ... }
983
+ const later = await queen.queue('q').fetch(['p1'], { offsets: { p1: 42 }, maxBytes: 1 << 20 })
984
+ const { partitions, safeTime } = await queen.queue('q').changedSince(since) // Date, epoch ms, ISO string; null = all
985
+ ```
986
+
987
+ No lease, no cursor, nothing to ack. See [Reads by Offset](#reads-by-offset).
988
+
937
989
  ### Consume
938
990
 
939
991
  ```javascript
@@ -21,6 +21,7 @@ Welcome to Queen client! This is your friendly guide to mastering message queues
21
21
  - [Part 13: Callbacks & Error Handling](#part-13-callbacks--error-handling)
22
22
  - [Part 14: Graceful Shutdown](#part-14-graceful-shutdown)
23
23
  - [Part 15: Binary Push, Compression & Framed Pop](#part-15-binary-push-compression--framed-pop)
24
+ - [Part 16: Reads by Offset - Replay Without a Lease](#part-16-reads-by-offset---replay-without-a-lease)
24
25
  - [Cheat Sheet](#cheat-sheet)
25
26
 
26
27
  ---
@@ -1669,6 +1670,55 @@ answers JSON.
1669
1670
 
1670
1671
  ---
1671
1672
 
1673
+ ## Part 16: Reads by Offset - Replay Without a Lease
1674
+
1675
+ A pop hands messages to one consumer and waits for its ack. Sometimes you only
1676
+ want to read: rebuild an entity from its partition, replay a day for an audit,
1677
+ copy a queue somewhere else. Two queue methods do that, and neither takes a
1678
+ lease, moves a cursor or needs an ack. Two readers asking the same question get
1679
+ the same answer, and no consumer group notices them.
1680
+
1681
+ ```javascript
1682
+ // Every record of these partitions, from the oldest one still kept
1683
+ const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
1684
+
1685
+ for (const [partition, part] of read) {
1686
+ console.log(partition, part.records.map(r => r.payload))
1687
+ // part.records: [{ offset, transactionId, payload, ts }, ...]
1688
+ // part.nextOffset: where to start next time to read only what is new
1689
+ }
1690
+
1691
+ // Only what came after that read
1692
+ const later = await queen.queue('orders').fetch(['customer-42'], {
1693
+ offsets: { 'customer-42': read.get('customer-42').nextOffset }
1694
+ })
1695
+ ```
1696
+
1697
+ `fetch` reads each partition up to where it ended when the call first read it.
1698
+ It asks for 1000 partitions per request and asks again until each one is read,
1699
+ and a partition whose start retention deleted is read from its oldest kept
1700
+ record. A partition nobody wrote, or of a queue that does not exist, has no
1701
+ records. `maxBytes` caps the payload bytes of one request (default 8 MiB).
1702
+
1703
+ To find out which partitions to read, ask which ones were written since a time:
1704
+
1705
+ ```javascript
1706
+ const { partitions, safeTime } = await queen.queue('orders')
1707
+ .changedSince(new Date(Date.now() - 10 * 60_000))
1708
+
1709
+ partitions.map(p => p.name) // each { name, id, lastOffset, logStart, lastWriteAt }
1710
+
1711
+ // Next time, only what changed after that answer
1712
+ const next = await queen.queue('orders').changedSince(safeTime)
1713
+ ```
1714
+
1715
+ `changedSince` follows every page of the answer and returns the partitions in
1716
+ the order they were created; without a time it lists all of them. `safeTime` is
1717
+ the broker's time up to which every write is counted in the answer: pass it as
1718
+ the next `since`, and never compare it with your own clock.
1719
+
1720
+ ---
1721
+
1672
1722
  ## Cheat Sheet
1673
1723
 
1674
1724
  ### Connection
@@ -14,6 +14,7 @@ import { durableAddress } from '../buffer/sinks.js'
14
14
  import { QUEUE_DEFAULTS, CONSUME_DEFAULTS, POP_DEFAULTS } from '../utils/defaults.js'
15
15
  import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/conflation.js'
16
16
  import { popSizing, parseAutopilotDecision } from '../utils/autopilot.js'
17
+ import { fetchPartitions, partitionsChangedSince } from '../reads/offsetReads.js'
17
18
  import * as logger from '../utils/logger.js'
18
19
 
19
20
  // Per-item push statuses that mean the broker took the message: `queued`
@@ -632,6 +633,39 @@ export class QueueBuilder {
632
633
  await this.#bufferManager.flushBuffer(queueAddress)
633
634
  }
634
635
 
636
+ // ===========================
637
+ // Reads by offset
638
+ // ===========================
639
+
640
+ /**
641
+ * Every record of these partitions, read by offset: no lease, no cursor,
642
+ * nothing to ack. Answers a Map of partition name to
643
+ * `{ partition, records, highWatermark, logStartOffset, nextOffset }`.
644
+ *
645
+ * const read = await queen.queue('orders').fetch(['customer-42', 'customer-7'])
646
+ * read.get('customer-42').records.map(r => r.payload)
647
+ *
648
+ * `options.offsets` ({ name: offset } or a Map) starts a partition later than
649
+ * its oldest kept record, e.g. at the `nextOffset` of an earlier read.
650
+ * `options.maxBytes` caps the payload bytes of one call (default 8 MiB).
651
+ * See reads/offsetReads.js.
652
+ */
653
+ async fetch(partitions, options = {}) {
654
+ return fetchPartitions(this.#httpClient, this.#queueName, partitions, options)
655
+ }
656
+
657
+ /**
658
+ * The partitions written at or after `since` (a Date, epoch milliseconds or
659
+ * an ISO-8601 string; null lists them all): `{ partitions, safeTime }`.
660
+ * Pass `safeTime` as the next `since`.
661
+ *
662
+ * const { partitions } = await queen.queue('orders').changedSince(new Date(Date.now() - 600_000))
663
+ * partitions.map(p => p.name)
664
+ */
665
+ async changedSince(since = null) {
666
+ return partitionsChangedSince(this.#httpClient, this.#queueName, since)
667
+ }
668
+
635
669
  // ===========================
636
670
  // Dead Letter Queue Methods
637
671
  // ===========================
@@ -22,6 +22,59 @@ function pause(ms, signal) {
22
22
  })
23
23
  }
24
24
 
25
+ // After a pop that failed for a reason waiting can fix, the next one waits:
26
+ // the broker's Retry-After when it sent one (1 s on its 503s), else 1 s
27
+ // doubling with each failure in a row. Never more than 30 s, so a consumer
28
+ // resumes within 30 s of the broker answering again; ±20 % so a fleet of
29
+ // workers does not come back in step.
30
+ const TRANSIENT_BASE_MILLIS = 1000
31
+ const TRANSIENT_CAP_MILLIS = 30000
32
+
33
+ /**
34
+ * Whether a failed pop is worth polling again, because the broker or the way
35
+ * to it could not answer for a while and waiting changes that:
36
+ *
37
+ * - HTTP 408, 429 or any 5xx. The broker's own retryable answers are all a 503
38
+ * with Retry-After: no leader (`no_leader`, `retry`), an elapsed deadline
39
+ * (`timeout`, "the request deadline elapsed"), a leader change mid-commit
40
+ * (`outcome_unknown`), a standby cluster (`standby`). A 500 on a pop comes
41
+ * from a node shutting down or a roll between versions; a 502 or 504 from a
42
+ * gateway in front of a node that is restarting.
43
+ * - No HTTP answer at all: this client's request timeout, or a connection that
44
+ * could not be made or was cut before or while the answer was read.
45
+ *
46
+ * Not worth it, so the consumer stops as before: a 503
47
+ * `raft_phase1_unsupported` (the broker cannot serve the route at all), every
48
+ * other 4xx (a request or a credential refused the same way next time), a stop
49
+ * by the caller, and an error without an HTTP status that is not a transport
50
+ * fault (a bug, a `hostHeader` this runtime cannot send).
51
+ */
52
+ export function isTransientPopError(error) {
53
+ if (!error || error.aborted) return false
54
+ if (typeof error.status === 'number') {
55
+ if (error.code === 'raft_phase1_unsupported') return false
56
+ return error.status === 408 || error.status === 429 || error.status >= 500
57
+ }
58
+ const message = typeof error.message === 'string' ? error.message : ''
59
+ return error.name === 'AbortError' || // this client's request timeout
60
+ message.includes('fetch failed') || // no connection, or it dropped before the answer
61
+ message === 'terminated' || // it dropped while the answer was read
62
+ message.includes('ECONNREFUSED') || error.code === 'ECONNREFUSED'
63
+ }
64
+
65
+ /**
66
+ * The wait before the next pop, after `failures` transient failures in a row.
67
+ * `random` stands in for Math.random in tests.
68
+ */
69
+ export function transientDelayMillis(failures, retryAfterSeconds, random = Math.random) {
70
+ const hasRetryAfter = typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
71
+ const base = hasRetryAfter
72
+ ? retryAfterSeconds * 1000
73
+ : TRANSIENT_BASE_MILLIS * 2 ** Math.max(0, failures - 1)
74
+ const jittered = Math.min(base, TRANSIENT_CAP_MILLIS) * (0.8 + 0.4 * random())
75
+ return Math.round(Math.min(jittered, TRANSIENT_CAP_MILLIS))
76
+ }
77
+
25
78
  export class ConsumerManager {
26
79
  #httpClient
27
80
  #queen
@@ -154,6 +207,9 @@ export class ConsumerManager {
154
207
 
155
208
  let processedCount = 0
156
209
  let lastMessageTime = idleMillis ? Date.now() : null
210
+ // Pops that failed in a row for a reason waiting can fix, and since when.
211
+ let transientFailures = 0
212
+ let failingSince = null
157
213
 
158
214
  while (true) {
159
215
  // Check abort signal
@@ -187,6 +243,12 @@ export class ConsumerManager {
187
243
  // it would only sit on until the lease expires.
188
244
  const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null, signal)
189
245
 
246
+ if (transientFailures > 0) {
247
+ logger.warn('ConsumerManager.worker', { workerId, status: 'pop-recovered', failedPops: transientFailures, failingMillis: Date.now() - failingSince })
248
+ transientFailures = 0
249
+ failingSince = null
250
+ }
251
+
190
252
  // Degrade-loudly (PLAN_CONFLATION §4). Checked BEFORE the empty-response
191
253
  // branch on purpose: a pre-1.1.0 broker answers an empty pop with a
192
254
  // bodiless 204 (result === null), which is the first thing a consumer
@@ -299,37 +361,35 @@ export class ConsumerManager {
299
361
  throw error
300
362
  }
301
363
 
302
- // Check if this is a timeout error (expected for long polling)
303
- const isTimeoutError = error.name === 'AbortError' ||
304
- error.message?.includes('timeout')
305
-
306
- if (isTimeoutError && wait) {
307
- continue // Retry on timeout
308
- }
309
-
310
- // 429 (rate limited): HttpClient already retries this internally
311
- // with backoff (unbounded for wait=true pop, per retry429 policy) --
312
- // this branch is a defensive fallback for the case where an explicit
313
- // retry429.maxAttempts override got exhausted. Back off and keep
314
- // polling instead of hot-looping or rethrowing/dying.
315
- if (error.status === 429) {
316
- const retryAfterMs = typeof error.retryAfterSeconds === 'number' && error.retryAfterSeconds >= 0
317
- ? error.retryAfterSeconds * 1000
318
- : 1000
319
- logger.warn('ConsumerManager.worker', { workerId, status: 'rate-limited', code: error.code, retryAfterMs })
320
- await pause(retryAfterMs, signal)
364
+ // This client's own timeout on a long poll: the poll already waited
365
+ // its whole time, so poll again at once. Checked on the error's kind,
366
+ // not its text: a 4xx whose message says "timeout" is a refusal.
367
+ if (wait && error.name === 'AbortError' && error.status === undefined) {
321
368
  continue
322
369
  }
323
370
 
324
- // Check if network error
325
- const isNetworkError = error.message?.includes('fetch failed') ||
326
- error.message?.includes('ECONNREFUSED') ||
327
- error.code === 'ECONNREFUSED'
328
-
329
- if (isNetworkError) {
330
- logger.warn('ConsumerManager.worker', { workerId, error: 'network', message: error.message })
331
- // Wait before retry
332
- await pause(1000, signal)
371
+ // The broker, or the way to it, could not answer for a while: a
372
+ // leader election, a node restarting, a gateway timing out, a lost
373
+ // connection, a 429 the HttpClient stopped retrying. Wait, then poll
374
+ // again, for as long as it takes (see isTransientPopError). Before
375
+ // this only a 429 and a refused connection were retried here: on
376
+ // 2026-10-29 the 503s of a leader election stopped every consumer of
377
+ // a service whose process stayed up, and nothing was consumed until
378
+ // it was restarted by hand.
379
+ if (isTransientPopError(error)) {
380
+ transientFailures++
381
+ if (failingSince === null) failingSince = Date.now()
382
+ const delay = transientDelayMillis(transientFailures, error.retryAfterSeconds)
383
+ logger.warn('ConsumerManager.worker', {
384
+ workerId,
385
+ status: 'pop-failed-retrying',
386
+ httpStatus: error.status,
387
+ code: error.code,
388
+ error: error.message,
389
+ failedPops: transientFailures,
390
+ retryInMillis: delay
391
+ })
392
+ await pause(delay, signal)
333
393
  continue
334
394
  }
335
395
 
@@ -343,8 +403,10 @@ export class ConsumerManager {
343
403
  throw error
344
404
  }
345
405
 
346
- // Other errors - rethrow
347
- logger.error('ConsumerManager.worker', { workerId, error: error.message, code: error.code })
406
+ // Anything else stops this worker and rejects consume(): another 4xx
407
+ // (refused the same way next time), a 503 raft_phase1_unsupported, or
408
+ // an error that is not a transport fault.
409
+ logger.error('ConsumerManager.worker', { workerId, error: error.message, httpStatus: error.status, code: error.code })
348
410
  throw error
349
411
  }
350
412
  }
@@ -468,11 +468,14 @@ export class HttpClient {
468
468
  // 'storage_quota_exceeded' | 'feature_gated' | 'forbidden' }. `.code`
469
469
  // is attached whenever the body has one so callers can branch without
470
470
  // string-matching `.message`.
471
- if (response.status === 429) {
472
- const retryAfterHeader = response.headers.get('retry-after')
473
- const retryAfterSeconds = retryAfterHeader != null ? Number(retryAfterHeader) : NaN
474
- error.retryAfterSeconds = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : null
475
- }
471
+ //
472
+ // Retry-After is read on every error, not only a 429: the broker also
473
+ // sends it (1 s) with each 503 it means to be retried (no leader, a
474
+ // leader change, an elapsed deadline), and consume() waits that long
475
+ // before its next pop. null when absent or not a number of seconds.
476
+ const retryAfterHeader = response.headers.get('retry-after')
477
+ const retryAfterSeconds = retryAfterHeader != null ? Number(retryAfterHeader) : NaN
478
+ error.retryAfterSeconds = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : null
476
479
 
477
480
  try {
478
481
  const text = await response.text()
@@ -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
+ }
@@ -38,9 +38,16 @@ export class ReduceOperator {
38
38
  * }} ctx
39
39
  * @returns {Promise<{
40
40
  * stateOps: Array<{type:'upsert'|'delete',key:string,value?:any}>,
41
- * emits: Array<{key:string, windowStart:number, windowKey:string, value:any}>
41
+ * emits: Array<{key:string, windowStart:number, windowKey:string, value:any}>,
42
+ * nextCloseMs: number|null
42
43
  * }>}
43
44
  *
45
+ * `nextCloseMs` is when the earliest window still open closes
46
+ * (`windowEnd + gracePeriodMs`, on the clock the windows are kept in),
47
+ * counting the stored windows this batch did not touch; null when nothing
48
+ * stays open. The Runner claims the partition then to close it, even if
49
+ * no more messages come.
50
+ *
44
51
  * Window-close semantics
45
52
  * ----------------------
46
53
  * A window is closed when the partition's stream time has advanced past
@@ -121,8 +128,13 @@ export class ReduceOperator {
121
128
 
122
129
  const stateOps = []
123
130
  const emits = []
131
+ let nextCloseMs = null
124
132
  for (const [stateKey, entry] of accumulators.entries()) {
125
- const closed = (entry.windowEnd + gracePeriodMs) <= effectiveStreamTime
133
+ const closesAt = entry.windowEnd + gracePeriodMs
134
+ const closed = closesAt <= effectiveStreamTime
135
+ if (!closed && (nextCloseMs === null || closesAt < nextCloseMs)) {
136
+ nextCloseMs = closesAt
137
+ }
126
138
  if (closed) {
127
139
  emits.push({
128
140
  key: entry.key,
@@ -142,7 +154,7 @@ export class ReduceOperator {
142
154
  })
143
155
  }
144
156
  }
145
- return { stateOps, emits }
157
+ return { stateOps, emits, nextCloseMs }
146
158
  }
147
159
 
148
160
  _initial() {