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.
@@ -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()
@@ -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() {
@@ -187,10 +187,17 @@ export class WindowSessionOperator {
187
187
  }
188
188
  }
189
189
 
190
- // Build state ops.
190
+ // Build state ops, and note when the earliest session still open closes
191
+ // (`lastEventTime + gap + grace`): the Runner claims the partition then
192
+ // to close it, even if no more messages come. null: nothing is open.
191
193
  const stateOps = []
194
+ let nextCloseMs = null
192
195
  for (const [userKey, s] of sessions.entries()) {
193
196
  const stateKey = this.openSessionStateKey(userKey)
197
+ if (!s.closed && s.lastEventTime !== null) {
198
+ const closesAt = s.lastEventTime + this.gapMs + this.gracePeriodMs
199
+ if (nextCloseMs === null || closesAt < nextCloseMs) nextCloseMs = closesAt
200
+ }
194
201
  if (s.closed) {
195
202
  if (s.seeded) stateOps.push({ type: 'delete', key: stateKey })
196
203
  // Newly-opened sessions that closed in the same batch were never
@@ -208,7 +215,7 @@ export class WindowSessionOperator {
208
215
  }
209
216
  }
210
217
 
211
- return { stateOps, emits: closedEmits }
218
+ return { stateOps, emits: closedEmits, nextCloseMs }
212
219
  }
213
220
 
214
221
  _buildEmit(userKey, s) {
@@ -14,7 +14,13 @@
14
14
  * seconds (req) window size in seconds
15
15
  * gracePeriod (sec) accept events for windowEnd + grace before closing (default 0)
16
16
  * idleFlushMs (ms) Runner closes ripe windows on quiet partitions every
17
- * this many ms. Default 5000. Pass 0 to disable.
17
+ * this many ms. Default 5000. Pass 0 to disable. On a
18
+ * broker with POST /streams/v1/claim (2.7) a
19
+ * processing-time window closes within this interval
20
+ * of its end, under a lease on its partition, also
21
+ * after a restart and with several runners; on an
22
+ * older broker, only on the partitions this process
23
+ * read in the last five minutes.
18
24
  * eventTime (fn) msg => epoch_ms; if set, switches to event-time mode
19
25
  * allowedLateness (sec) drop events older than wm - allowedLateness (default 0)
20
26
  * onLate (str) 'drop' (default) | 'include' — what to do with
@@ -29,7 +35,8 @@
29
35
  * processing-time: windowEnd + grace <= max(createdAt) seen in batch
30
36
  * OR latest streamTime fed by Runner
31
37
  * event-time: windowEnd + grace <= partition watermark
32
- * idle flush: windowEnd + grace <= Date.now() (per Runner timer)
38
+ * idle flush: windowEnd + grace <= the broker's clock at the claim
39
+ * (Date.now() on a broker without claims)
33
40
  */
34
41
 
35
42
  import { extractEventTime, normaliseLateOpts, MS_PER_SEC } from './_windowCommon.js'