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.
- package/client-v2/consumer/ConsumerManager.js +92 -30
- package/client-v2/http/HttpClient.js +8 -5
- package/client-v2/streams/operators/ReduceOperator.js +15 -3
- package/client-v2/streams/operators/WindowSessionOperator.js +9 -2
- package/client-v2/streams/operators/WindowTumblingOperator.js +9 -2
- package/client-v2/streams/runtime/Runner.js +356 -20
- package/package.json +2 -2
- package/test-v2/consumer-unit/transientPop.test.js +361 -0
- package/test-v2/stream/claims.js +129 -0
- package/test-v2/stream/index.js +2 -0
- package/test-v2/streams-unit/claims.test.js +246 -0
- package/test-v2/streams-unit/fakeServer.js +41 -2
|
@@ -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
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
//
|
|
347
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
|
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 <=
|
|
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'
|