queen-mq 1.3.0 → 2.0.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 +71 -18
- package/client-v2/Queen.js +45 -13
- package/client-v2/README.md +26 -11
- package/client-v2/admin/Admin.js +0 -47
- package/client-v2/builders/QueueBuilder.js +33 -10
- package/client-v2/builders/TimerBuilder.js +2 -2
- package/client-v2/builders/TransactionBuilder.js +35 -9
- package/client-v2/consumer/ConsumerManager.js +67 -46
- package/client-v2/ephemeral/Ephemeral.js +7 -9
- package/client-v2/kv/Kv.js +4 -4
- package/client-v2/kv/expiry.js +1 -1
- package/client-v2/streams/Stream.js +8 -3
- package/client-v2/streams/helpers/rateLimiter.js +4 -4
- package/client-v2/streams/operators/GateOperator.js +9 -2
- package/client-v2/streams/operators/ReduceOperator.js +2 -2
- package/client-v2/streams/operators/WindowSessionOperator.js +3 -3
- package/client-v2/streams/runtime/Runner.js +111 -54
- package/client-v2/streams/runtime/cycle.js +4 -4
- package/client-v2/streams/runtime/register.js +1 -1
- package/client-v2/utils/conflation.js +0 -6
- package/client-v2/utils/consumerGroup.js +54 -0
- package/package.json +5 -8
- package/test-v2/_kvtimers.js +12 -13
- package/test-v2/ackwindow.js +12 -192
- package/test-v2/bootstrap.js +2 -2
- package/test-v2/conflation-unit/conflationWire.test.js +0 -12
- package/test-v2/consume.js +41 -1
- package/test-v2/consumer-unit/handlerError.test.js +161 -0
- package/test-v2/docs.js +5 -4
- package/test-v2/http-unit/pushStatus.test.js +142 -0
- package/test-v2/http-unit/renew.test.js +96 -0
- package/test-v2/kv-unit/timerWire.test.js +1 -1
- package/test-v2/kv-unit/txnWire.test.js +101 -2
- package/test-v2/kv.js +6 -5
- package/test-v2/pop.js +28 -1
- package/test-v2/run.js +50 -114
- package/test-v2/runner-unit/fatalExit.test.js +64 -0
- package/test-v2/semantics.js +20 -34
- package/test-v2/stream/_helpers.js +10 -19
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/gate.js +54 -0
- package/test-v2/stream/index.js +2 -0
- package/test-v2/stream/tumbling.js +3 -3
- package/test-v2/streams-unit/ack.test.js +61 -0
- package/test-v2/streams-unit/cycle.test.js +1 -1
- package/test-v2/streams-unit/e2e.test.js +6 -10
- package/test-v2/streams-unit/gate.test.js +189 -0
- package/test-v2/timers.js +6 -5
- package/test-v2/transaction.js +169 -0
- package/test-v2/watermark.js +38 -176
- package/test-v2/maintenance.js +0 -277
|
@@ -222,7 +222,7 @@ export class ConsumerManager {
|
|
|
222
222
|
// failed message: everything after it in this popped batch WILL
|
|
223
223
|
// be redelivered. Processing it now would only produce duplicates
|
|
224
224
|
// and rejected acks — abandon the rest of the batch.
|
|
225
|
-
if (
|
|
225
|
+
if (!ok) {
|
|
226
226
|
logger.warn('ConsumerManager.worker', { workerId, status: 'batch-abandoned-after-nack', remaining: messages.length - messages.indexOf(message) - 1 })
|
|
227
227
|
break
|
|
228
228
|
}
|
|
@@ -316,62 +316,83 @@ export class ConsumerManager {
|
|
|
316
316
|
async #processMessage(message, handler, autoAck, group) {
|
|
317
317
|
try {
|
|
318
318
|
await handler(message)
|
|
319
|
-
|
|
320
|
-
// Auto-ack on success if enabled
|
|
321
|
-
if (autoAck) {
|
|
322
|
-
const context = group ? { group } : {}
|
|
323
|
-
const res = await this.#queen.ack(message, true, context)
|
|
324
|
-
if (res && res.success === false) {
|
|
325
|
-
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'ack-rejected', error: res.error })
|
|
326
|
-
} else {
|
|
327
|
-
logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return true
|
|
331
319
|
} catch (error) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
320
|
+
await this.#nackFailed(message, error, autoAck, group, 'ConsumerManager.processMessage')
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Auto-ack on success if enabled
|
|
325
|
+
if (autoAck) {
|
|
326
|
+
const context = group ? { group } : {}
|
|
327
|
+
const res = await this.#queen.ack(message, true, context)
|
|
328
|
+
if (res && res.success === false) {
|
|
329
|
+
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'ack-rejected', error: res.error })
|
|
330
|
+
} else {
|
|
331
|
+
logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
|
|
343
332
|
}
|
|
344
|
-
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message })
|
|
345
|
-
throw error
|
|
346
333
|
}
|
|
334
|
+
return true
|
|
347
335
|
}
|
|
348
336
|
|
|
349
337
|
async #processBatch(messages, handler, autoAck, group) {
|
|
350
338
|
try {
|
|
351
339
|
await handler(messages)
|
|
340
|
+
} catch (error) {
|
|
341
|
+
await this.#nackFailed(messages, error, autoAck, group, 'ConsumerManager.processBatch')
|
|
342
|
+
return
|
|
343
|
+
}
|
|
352
344
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
345
|
+
// Auto-ack on success if enabled
|
|
346
|
+
if (autoAck) {
|
|
347
|
+
const context = group ? { group } : {}
|
|
348
|
+
const res = await this.#queen.ack(messages, true, context)
|
|
349
|
+
if (res && res.success === false) {
|
|
350
|
+
logger.error('ConsumerManager.processBatch', { count: messages.length, status: 'ack-rejected', error: res.error })
|
|
351
|
+
} else {
|
|
352
|
+
logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
|
|
362
353
|
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A handler threw: nack what it was given, and let the worker keep
|
|
359
|
+
* consuming. The same with `autoAck(false)`, which hands the SUCCESS path to
|
|
360
|
+
* the handler and not the failure path: a handler that threw did not get to
|
|
361
|
+
* settle its messages, and leaving them leased would hold the partition
|
|
362
|
+
* until the lease expires. Before this, a throw under autoAck(false) left
|
|
363
|
+
* the worker loop and stopped the consumer (the others kept running behind a
|
|
364
|
+
* consume() promise that had already rejected).
|
|
365
|
+
*
|
|
366
|
+
* The nack goes through the broker's retry budget like any other: the
|
|
367
|
+
* message is redelivered, and lands in the DLQ once the queue's retryLimit
|
|
368
|
+
* is spent. A message the handler already acked before throwing is already
|
|
369
|
+
* settled, so the broker refuses its nack and nothing changes for it. A
|
|
370
|
+
* handler that wants to decide for itself (ack, nack, DLQ, or stop)
|
|
371
|
+
* declares `.onError()`, which catches the error before it gets here.
|
|
372
|
+
*/
|
|
373
|
+
async #nackFailed(messageOrMessages, error, autoAck, group, where) {
|
|
374
|
+
const batch = Array.isArray(messageOrMessages)
|
|
375
|
+
const subject = batch ? { count: messageOrMessages.length } : { transactionId: messageOrMessages.transactionId }
|
|
376
|
+
// A handler can throw anything, not only an Error.
|
|
377
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
378
|
+
logger.error(where, { ...subject, error: reason, status: 'handler-failed', autoAck })
|
|
379
|
+
|
|
380
|
+
const context = group ? { group, error: reason } : { error: reason }
|
|
381
|
+
try {
|
|
382
|
+
const res = await this.#queen.ack(messageOrMessages, false, context)
|
|
383
|
+
if (res && res.success === false) {
|
|
384
|
+
// Under autoAck(false) this is usually a handler that acked before it
|
|
385
|
+
// threw; with autoAck it means the lease ran out under the handler.
|
|
386
|
+
const log = autoAck ? logger.error : logger.warn
|
|
387
|
+
log(where, { ...subject, status: 'nack-rejected', error: res.error })
|
|
388
|
+
} else {
|
|
389
|
+
logger.log(where, { ...subject, status: 'nacked' })
|
|
372
390
|
}
|
|
373
|
-
|
|
374
|
-
|
|
391
|
+
} catch (nackError) {
|
|
392
|
+
// A message this client cannot even address (no partitionId) cannot be
|
|
393
|
+
// nacked; its lease expires and the broker redelivers it. Never a reason
|
|
394
|
+
// to stop the consumer.
|
|
395
|
+
logger.error(where, { ...subject, status: 'nack-failed', error: nackError.message })
|
|
375
396
|
}
|
|
376
397
|
}
|
|
377
398
|
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* WHAT THIS CLASS IS ABOUT, BEFORE ANY SIGNATURE: contents survive NOTHING
|
|
12
12
|
* (§1.2). Not a restart, not a crash, not a deploy, not the ownership move that
|
|
13
13
|
* a membership change causes. Treat a failover like a Redis restart. Declared
|
|
14
|
-
* CONFIGURATION is durable -- it lives in
|
|
15
|
-
* configured and EMPTY. There is no replay, no history, no subscriptionMode and
|
|
14
|
+
* CONFIGURATION is durable -- it lives in the broker's replicated log and comes
|
|
15
|
+
* back after a restart, as configured and EMPTY. There is no replay, no history, no subscriptionMode and
|
|
16
16
|
* no DLQ, because none of those concepts has a referent when there is no
|
|
17
17
|
* history to have.
|
|
18
18
|
*
|
|
@@ -252,9 +252,9 @@ export class Ephemeral {
|
|
|
252
252
|
// ------------------------------------------------------------ declaration
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
|
-
* Declare a queue and its bounds. Persists the OPTIONS in
|
|
256
|
-
* configuration survives a restart, the contents
|
|
257
|
-
* comes back declared and empty.
|
|
255
|
+
* Declare a queue and its bounds. Persists the OPTIONS in the broker's
|
|
256
|
+
* replicated log (§1.1): the configuration survives a restart, the contents
|
|
257
|
+
* never do, and the queue comes back declared and empty.
|
|
258
258
|
*
|
|
259
259
|
* Optional in every sense -- a push or a pop that names an unknown queue
|
|
260
260
|
* creates it implicitly with the tenant defaults (§1.1). Declare when you
|
|
@@ -289,7 +289,7 @@ export class Ephemeral {
|
|
|
289
289
|
return this.#call('POST', '/api/v1/ephemeral/reset', { queue }, { queue })
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
/** Delete the queue: contents, cursors, and the declared configuration
|
|
292
|
+
/** Delete the queue: contents, cursors, and the declared configuration. */
|
|
293
293
|
async delete(queue) {
|
|
294
294
|
requireQueue(queue)
|
|
295
295
|
logger.log('Ephemeral.delete', { queue })
|
|
@@ -482,9 +482,7 @@ export class Ephemeral {
|
|
|
482
482
|
/**
|
|
483
483
|
* Every ephemeral queue this tenant currently has, declared and implicit.
|
|
484
484
|
*
|
|
485
|
-
* Free to poll: the gauges are read out of the broker's own memory
|
|
486
|
-
* database behind them -- unlike the durable meter, whose 1s poll is
|
|
487
|
-
* load-bearing on PG.
|
|
485
|
+
* Free to poll: the gauges are read out of the broker's own memory.
|
|
488
486
|
*/
|
|
489
487
|
async queues() {
|
|
490
488
|
logger.log('Ephemeral.queues', {})
|
package/client-v2/kv/Kv.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* The KV surface (PLAN_KV_TIMERS.md §5, §8.1).
|
|
3
3
|
*
|
|
4
4
|
* Seven operations, five code paths: get, getMany, getPrefix, put,
|
|
5
|
-
* putIfAbsent (an alias that desugars to put with expect:0 inside the
|
|
6
|
-
*
|
|
5
|
+
* putIfAbsent (an alias that desugars to put with expect:0 inside the
|
|
6
|
+
* broker), delete, incr. Plus two conveniences this client owns: `once`,
|
|
7
7
|
* which is the idempotency marker written the way people actually reach for
|
|
8
8
|
* it, and `listAll`, which walks the keyset cursor of getPrefix.
|
|
9
9
|
*
|
|
@@ -249,7 +249,7 @@ export class Kv {
|
|
|
249
249
|
if (!results) {
|
|
250
250
|
throw new Error('kv: unexpected response envelope — expected {"results":[...]}')
|
|
251
251
|
}
|
|
252
|
-
// The
|
|
252
|
+
// The broker answers one result per op and fails the call rather than
|
|
253
253
|
// returning a short array (§6.4). A short one here means something between
|
|
254
254
|
// the two rewrote the answer, and attributing result i to op j is how a
|
|
255
255
|
// caller ends up trusting the wrong verdict.
|
|
@@ -293,7 +293,7 @@ export class Kv {
|
|
|
293
293
|
* One key. Returns the ROW, not the value:
|
|
294
294
|
* `{found, key, value?, version?, expiresAt?, updatedAt?}`.
|
|
295
295
|
*
|
|
296
|
-
* `found` is separate from `value` because `null` is a legal
|
|
296
|
+
* `found` is separate from `value` because `null` is a legal JSON value
|
|
297
297
|
* (§5.5): `{found:true, value:null}` and `{found:false}` are different
|
|
298
298
|
* things, and an SDK that returned "the value or null" would collapse them.
|
|
299
299
|
*/
|
package/client-v2/kv/expiry.js
CHANGED
|
@@ -95,7 +95,7 @@ function instantMs(value) {
|
|
|
95
95
|
*
|
|
96
96
|
* The empty answer is deliberate and is the reason this function validates so
|
|
97
97
|
* little: §5.1's rule -- exactly one of `ttlSeconds` and `forever:true`, zero
|
|
98
|
-
* or two being the same error -- lives in
|
|
98
|
+
* or two being the same error -- lives in the broker, so that all seven
|
|
99
99
|
* clients AND the embedded broker (which never passes through an HTTP handler)
|
|
100
100
|
* inherit it without a line of their own. Re-implementing it here would give
|
|
101
101
|
* the product two places that can disagree about when a key dies. What IS
|
|
@@ -176,7 +176,7 @@ export class Stream {
|
|
|
176
176
|
*
|
|
177
177
|
* The user fn receives `(value, ctx)` where:
|
|
178
178
|
* - value: the message payload (post any pre-stage map/filter)
|
|
179
|
-
* - ctx.state: mutable per-key state (loaded from
|
|
179
|
+
* - ctx.state: mutable per-key state (loaded from the broker,
|
|
180
180
|
* persisted only if you return ALLOW for this message)
|
|
181
181
|
* - ctx.streamTimeMs: system clock for the cycle (use for refill math)
|
|
182
182
|
* - ctx.partitionId: source partition_id (= state shard)
|
|
@@ -186,7 +186,9 @@ export class Stream {
|
|
|
186
186
|
* commits an ack for the prefix that was allowed and DOES NOT release the
|
|
187
187
|
* source lease, so the denied message and its successors get redelivered
|
|
188
188
|
* in their original order when the lease expires. FIFO per partition is
|
|
189
|
-
* preserved without any deferred queue.
|
|
189
|
+
* preserved without any deferred queue. The prefix is counted in source
|
|
190
|
+
* messages: after a `.flatMap()`, a message is allowed only if every value
|
|
191
|
+
* it produced is allowed.
|
|
190
192
|
*
|
|
191
193
|
* Example (token bucket rate limiter):
|
|
192
194
|
*
|
|
@@ -258,7 +260,10 @@ export class Stream {
|
|
|
258
260
|
* @param {number} [runOptions.batchSize=200] - messages per cycle
|
|
259
261
|
* @param {number} [runOptions.maxPartitions=4] - lease up to N partitions/cycle
|
|
260
262
|
* @param {number} [runOptions.maxWaitMillis=1000] - long-poll wait for source pop
|
|
261
|
-
* @param {string} [runOptions.subscriptionMode] - '
|
|
263
|
+
* @param {string} [runOptions.subscriptionMode] - 'new' | 'all'. Unset, the
|
|
264
|
+
* broker's default applies, which is 'new' (DEFAULT_SUBSCRIPTION_MODE): a
|
|
265
|
+
* query registered after its source already holds messages starts at the
|
|
266
|
+
* tail and skips them. Pass 'all' to process the backlog.
|
|
262
267
|
* @param {string} [runOptions.subscriptionFrom] - ISO timestamp or 'now'
|
|
263
268
|
* @param {boolean} [runOptions.conflation=false] - last-value delivery on the
|
|
264
269
|
* source pop: each cycle sees only the newest visible message per partition
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* factories that return a function suitable for `.gate(fn)` on a Stream.
|
|
4
4
|
*
|
|
5
5
|
* The point of these helpers is purely ergonomic: the user shouldn't have to
|
|
6
|
-
* re-write the refill math every time. The semantics (per-key state in
|
|
7
|
-
*
|
|
6
|
+
* re-write the refill math every time. The semantics (per-key state in the
|
|
7
|
+
* broker, partial-ack on deny, FIFO order on lease expiry) all
|
|
8
8
|
* come from the underlying Stream `.gate()` runtime — these factories only
|
|
9
9
|
* decide HOW each request consumes from the bucket.
|
|
10
10
|
*
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*
|
|
32
32
|
* Returns a `(msg, ctx) => boolean` function suitable for `.gate(fn)`.
|
|
33
33
|
* The bucket state lives in `ctx.state` (which the runtime persists per-key
|
|
34
|
-
* in
|
|
34
|
+
* in the broker on every ALLOWED message).
|
|
35
35
|
*
|
|
36
36
|
* @param {object} opts
|
|
37
37
|
* @param {number} opts.capacity Max tokens in the bucket (= max burst).
|
|
@@ -75,7 +75,7 @@ export function tokenBucketGate({
|
|
|
75
75
|
ctx.state.tokens -= cost
|
|
76
76
|
// Lightweight observability: the SDK persists ctx.state on ALLOW only,
|
|
77
77
|
// so these counters automatically reflect "what the bucket actually let
|
|
78
|
-
// through"
|
|
78
|
+
// through".
|
|
79
79
|
ctx.state.allowedTotal = (ctx.state.allowedTotal || 0) + 1
|
|
80
80
|
ctx.state.consumedTotal = (ctx.state.consumedTotal || 0) + cost
|
|
81
81
|
return true
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* Semantics
|
|
8
8
|
* ---------
|
|
9
9
|
* The runtime processes a popped batch sequentially in source order. For each
|
|
10
|
-
* message, it builds a `ctx` object with mutable `state` (loaded from
|
|
11
|
-
*
|
|
10
|
+
* message, it builds a `ctx` object with mutable `state` (loaded from the
|
|
11
|
+
* broker for the message's key) and invokes the user fn.
|
|
12
12
|
*
|
|
13
13
|
* .gate((msg, ctx) => boolean)
|
|
14
14
|
*
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
* cycle entirely — no state, no push, no ack — and the lease times out
|
|
31
31
|
* naturally.
|
|
32
32
|
*
|
|
33
|
+
* K counts SOURCE MESSAGES, because the ack is an offset commit. A pre-stage
|
|
34
|
+
* can turn one message into several values (`.flatMap()`) or into none
|
|
35
|
+
* (`.filter()`): a message is allowed only when every value it produced is
|
|
36
|
+
* allowed, a denied value rolls back what its whole message did to the
|
|
37
|
+
* state, and a message the pre-stages dropped is settled with the prefix.
|
|
38
|
+
* A redelivery therefore replays whole messages, never half of one.
|
|
39
|
+
*
|
|
33
40
|
* Ordering
|
|
34
41
|
* --------
|
|
35
42
|
* FIFO per-partition is preserved by construction: the same partition lease
|
|
@@ -161,8 +161,8 @@ export class ReduceOperator {
|
|
|
161
161
|
*
|
|
162
162
|
* The `operatorTag` (e.g. "tumb:60", "slide:60:10", "sess:30", "cron:minute")
|
|
163
163
|
* lets multiple window operators within one query coexist in the same
|
|
164
|
-
*
|
|
165
|
-
*
|
|
164
|
+
* state store without colliding, and lets the idle-flush scan filter to a
|
|
165
|
+
* single operator's keys by key prefix.
|
|
166
166
|
*
|
|
167
167
|
* Reserved key prefix: state keys starting with "__" are internal (e.g.
|
|
168
168
|
* "__wm__" for per-partition watermarks). User code should never write
|
|
@@ -109,7 +109,7 @@ export class WindowSessionOperator {
|
|
|
109
109
|
// events in this batch.
|
|
110
110
|
const sessions = new Map() // userKey -> { acc, sessionStart, lastEventTime, dirty, seeded }
|
|
111
111
|
|
|
112
|
-
// Seed from
|
|
112
|
+
// Seed from the loaded state.
|
|
113
113
|
for (const [stateKey, value] of loadedState.entries()) {
|
|
114
114
|
// Expected shape: `${operatorTag}\u001fopen\u001f${userKey}`
|
|
115
115
|
const parts = stateKey.split('\u001f')
|
|
@@ -193,8 +193,8 @@ export class WindowSessionOperator {
|
|
|
193
193
|
const stateKey = this.openSessionStateKey(userKey)
|
|
194
194
|
if (s.closed) {
|
|
195
195
|
if (s.seeded) stateOps.push({ type: 'delete', key: stateKey })
|
|
196
|
-
// Newly-opened sessions that closed in the same batch never
|
|
197
|
-
//
|
|
196
|
+
// Newly-opened sessions that closed in the same batch were never
|
|
197
|
+
// persisted, so no delete needed.
|
|
198
198
|
} else if (s.dirty) {
|
|
199
199
|
stateOps.push({
|
|
200
200
|
type: 'upsert',
|
|
@@ -99,7 +99,7 @@ export class Runner {
|
|
|
99
99
|
// two in-process paths removes the race at its source.
|
|
100
100
|
this._partitionMutexes = new Map() // partitionId -> tail promise
|
|
101
101
|
this._recentPartitions = new Map() // partitionId -> { partitionName, touchedAt }
|
|
102
|
-
this._partitionWatermarks = new Map() // partitionId -> wmMs (cache;
|
|
102
|
+
this._partitionWatermarks = new Map() // partitionId -> wmMs (cache; the broker is source of truth)
|
|
103
103
|
this._stats = {
|
|
104
104
|
cyclesTotal: 0,
|
|
105
105
|
flushCyclesTotal: 0,
|
|
@@ -323,8 +323,14 @@ export class Runner {
|
|
|
323
323
|
const partitionId = group.partitionId
|
|
324
324
|
const partitionName = group.partitionName
|
|
325
325
|
|
|
326
|
-
//
|
|
326
|
+
// Partition FIFO order. A 2.x broker answers each message with its offset,
|
|
327
|
+
// which IS that order, and the one the gate's partial ack counts in.
|
|
328
|
+
// (createdAt, id) is only the fallback for brokers that send no offset:
|
|
329
|
+
// createdAt is rendered to the millisecond, and pushes from concurrent
|
|
330
|
+
// producers share one with their ids out of offset order (measured
|
|
331
|
+
// 2026-10-02 on 2.0.0-beta.6: 69 of 400 positions disagreed).
|
|
327
332
|
const orderedMessages = [...group.messages].sort((a, b) => {
|
|
333
|
+
if (Number.isFinite(a.offset) && Number.isFinite(b.offset)) return a.offset - b.offset
|
|
328
334
|
const ta = Date.parse(a.createdAt || '') || 0
|
|
329
335
|
const tb = Date.parse(b.createdAt || '') || 0
|
|
330
336
|
if (ta !== tb) return ta - tb
|
|
@@ -396,9 +402,9 @@ export class Runner {
|
|
|
396
402
|
}
|
|
397
403
|
|
|
398
404
|
// 6. Build the source ack — advance the cursor to the LAST message
|
|
399
|
-
// and tell the
|
|
400
|
-
// so
|
|
401
|
-
//
|
|
405
|
+
// and tell the broker how many messages were in this cycle's batch
|
|
406
|
+
// so its acked count / lease release logic uses the correct count
|
|
407
|
+
// (the cycle is atomic across the full batch).
|
|
402
408
|
const lastMsg = orderedMessages[orderedMessages.length - 1]
|
|
403
409
|
const ack = lastMsg
|
|
404
410
|
? {
|
|
@@ -431,13 +437,43 @@ export class Runner {
|
|
|
431
437
|
* release_lease=false so the un-acked tail returns when the lease expires
|
|
432
438
|
* (preserving FIFO per partition without a deferred queue).
|
|
433
439
|
*
|
|
440
|
+
* The gate settles SOURCE MESSAGES, not envelopes. A pre-stage runs before
|
|
441
|
+
* it and breaks the one-to-one correspondence: `.filter()` leaves a message
|
|
442
|
+
* with no envelope, `.flatMap()` leaves it with several. The partial ack is
|
|
443
|
+
* an offset commit -- the broker advances `count` messages from the head of
|
|
444
|
+
* the leased batch -- so counting envelopes acks the wrong prefix: past a
|
|
445
|
+
* denied message (which is then never redelivered) after a flatMap, short of
|
|
446
|
+
* the allowed ones (which are then gated and pushed again) after a filter. A
|
|
447
|
+
* message is settled only when every envelope it produced was allowed, so a
|
|
448
|
+
* redelivery replays the whole message and never half of it.
|
|
449
|
+
*
|
|
434
450
|
* State model: load ALL state rows for the partition once, evaluate the
|
|
435
451
|
* gate in order, mutate in-memory state, then upsert only the keys whose
|
|
436
|
-
* ALLOWED messages mutated them.
|
|
452
|
+
* ALLOWED messages mutated them. What a denied message did to the state on
|
|
453
|
+
* its way to being denied is rolled back: a denied message did not happen.
|
|
437
454
|
*/
|
|
438
455
|
async _processGateCycle({ stages, envelopes, orderedMessages, group, partitionId, partitionName }) {
|
|
439
456
|
const gate = stages.gate
|
|
440
457
|
|
|
458
|
+
// One bucket of envelopes per claimed message, in partition order (the
|
|
459
|
+
// sort in _processPartitionCycleInner). Every pre-stage operator carries
|
|
460
|
+
// the source message through as `env.msg`.
|
|
461
|
+
const indexOf = new Map(orderedMessages.map((m, i) => [m, i]))
|
|
462
|
+
const buckets = orderedMessages.map(() => [])
|
|
463
|
+
for (const env of envelopes) {
|
|
464
|
+
const i = indexOf.get(env.msg)
|
|
465
|
+
if (i === undefined) {
|
|
466
|
+
// Not traceable to a claimed message, so not settleable by an offset
|
|
467
|
+
// commit. Hold the batch rather than ack past it: the lease lapses and
|
|
468
|
+
// the whole batch comes back.
|
|
469
|
+
this._reportError(new Error('.gate(): an envelope does not come from a message of this batch; holding the batch'), {
|
|
470
|
+
phase: 'gate-eval', partition: partitionId
|
|
471
|
+
})
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
buckets[i].push(env)
|
|
475
|
+
}
|
|
476
|
+
|
|
441
477
|
// Load all state rows for this partition. Cheap: usually one row per
|
|
442
478
|
// (rate-limit key) — for the canonical rate-limiter pattern that's a
|
|
443
479
|
// single row, since partition == limit key.
|
|
@@ -465,46 +501,65 @@ export class Runner {
|
|
|
465
501
|
}
|
|
466
502
|
|
|
467
503
|
const streamTimeMs = Date.now()
|
|
468
|
-
let
|
|
469
|
-
let
|
|
504
|
+
let settled = 0 // messages whose every envelope was allowed
|
|
505
|
+
let denied = false
|
|
470
506
|
const allowedEnvelopes = []
|
|
471
507
|
|
|
472
|
-
for (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
508
|
+
messages: for (const bucket of buckets) {
|
|
509
|
+
// What this message does to the state, to undo it if one of its
|
|
510
|
+
// envelopes is denied: key -> its value before this message
|
|
511
|
+
// (undefined: no entry yet), and the keys it touched first.
|
|
512
|
+
const before = new Map()
|
|
513
|
+
const newlyTouched = []
|
|
514
|
+
for (const env of bucket) {
|
|
515
|
+
const key = env.key
|
|
516
|
+
if (!before.has(key)) {
|
|
517
|
+
before.set(key, liveState.has(key) ? this._cloneState(liveState.get(key)) : undefined)
|
|
518
|
+
}
|
|
519
|
+
const stateForKey = ensureState(key)
|
|
520
|
+
const ctx = {
|
|
521
|
+
state: stateForKey,
|
|
522
|
+
streamTimeMs,
|
|
523
|
+
partitionId,
|
|
524
|
+
partition: partitionName,
|
|
525
|
+
key
|
|
526
|
+
}
|
|
527
|
+
let decision
|
|
528
|
+
try {
|
|
529
|
+
decision = await gate.evaluate(env, ctx)
|
|
530
|
+
} catch (err) {
|
|
531
|
+
// Gate threw: treat as a transient cycle error. Don't commit
|
|
532
|
+
// anything. The lease will time out and the broker redelivers.
|
|
533
|
+
this._reportError(err, { phase: 'gate-eval', partition: partitionId, message: env.msg })
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
if (!decision.allow) {
|
|
537
|
+
for (const [k, value] of before) {
|
|
538
|
+
if (value === undefined) liveState.delete(k)
|
|
539
|
+
else liveState.set(k, value)
|
|
540
|
+
}
|
|
541
|
+
for (const k of newlyTouched) touchedKeys.delete(k)
|
|
542
|
+
denied = true
|
|
543
|
+
break messages
|
|
544
|
+
}
|
|
493
545
|
// Mutated state on this key is committable.
|
|
494
|
-
touchedKeys.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
firstDenyIdx = i
|
|
499
|
-
break
|
|
546
|
+
if (!touchedKeys.has(key)) {
|
|
547
|
+
touchedKeys.add(key)
|
|
548
|
+
newlyTouched.push(key)
|
|
549
|
+
}
|
|
500
550
|
}
|
|
551
|
+
// Settled, including a message the pre-stages dropped entirely: it
|
|
552
|
+
// has nothing to gate, and holding it would hold the whole batch.
|
|
553
|
+
allowedEnvelopes.push(...bucket)
|
|
554
|
+
settled++
|
|
501
555
|
}
|
|
502
556
|
|
|
503
|
-
if (
|
|
504
|
-
// Nothing to commit: skip the cycle
|
|
505
|
-
// by us until it naturally expires;
|
|
506
|
-
// redelivered (to us or another worker) in
|
|
507
|
-
|
|
557
|
+
if (settled === 0) {
|
|
558
|
+
// The first message was denied. Nothing to commit: skip the cycle
|
|
559
|
+
// entirely. The lease stays held by us until it naturally expires;
|
|
560
|
+
// the messages are then redelivered (to us or another worker) in
|
|
561
|
+
// their original order.
|
|
562
|
+
this._stats.gateDenialsTotal = (this._stats.gateDenialsTotal || 0) + orderedMessages.length
|
|
508
563
|
return
|
|
509
564
|
}
|
|
510
565
|
|
|
@@ -540,24 +595,26 @@ export class Runner {
|
|
|
540
595
|
})))
|
|
541
596
|
}
|
|
542
597
|
|
|
543
|
-
// Ack the LAST
|
|
544
|
-
//
|
|
545
|
-
|
|
598
|
+
// Ack the LAST settled message (cursor advances to it). The count tells
|
|
599
|
+
// the broker how many messages this commit covers, counted from the head
|
|
600
|
+
// of the leased batch: on a partial ack it is the offset the cursor
|
|
601
|
+
// moves to, so it has to be the number of settled MESSAGES.
|
|
602
|
+
const lastSettled = orderedMessages[settled - 1]
|
|
546
603
|
const ack = {
|
|
547
|
-
transactionId:
|
|
548
|
-
leaseId:
|
|
604
|
+
transactionId: lastSettled.transactionId,
|
|
605
|
+
leaseId: lastSettled.leaseId || group.leaseId,
|
|
549
606
|
status: 'completed',
|
|
550
|
-
count:
|
|
607
|
+
count: settled
|
|
551
608
|
}
|
|
552
609
|
|
|
553
|
-
|
|
554
|
-
|
|
610
|
+
// Holding the lease on a partial ack is what preserves FIFO: the denied
|
|
611
|
+
// tail is not claimable by another worker, so it cannot be overtaken.
|
|
612
|
+
const releaseLease = !denied
|
|
555
613
|
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
this._stats.gateDenialsTotal = (this._stats.gateDenialsTotal || 0) + tailUnacked
|
|
614
|
+
if (denied) {
|
|
615
|
+
this._stats.gateDenialsTotal = (this._stats.gateDenialsTotal || 0) + (orderedMessages.length - settled)
|
|
559
616
|
}
|
|
560
|
-
this._stats.gateAllowsTotal = (this._stats.gateAllowsTotal || 0) +
|
|
617
|
+
this._stats.gateAllowsTotal = (this._stats.gateAllowsTotal || 0) + settled
|
|
561
618
|
|
|
562
619
|
this._stats.stateOpsTotal += stateOps.length
|
|
563
620
|
this._stats.pushItemsTotal += pushItems.length
|
|
@@ -990,8 +1047,8 @@ export class Runner {
|
|
|
990
1047
|
}
|
|
991
1048
|
|
|
992
1049
|
/**
|
|
993
|
-
* Use the
|
|
994
|
-
*
|
|
1050
|
+
* Use the state route's key_prefix + ripe_at_or_before filters to scope
|
|
1051
|
+
* the fetch.
|
|
995
1052
|
*/
|
|
996
1053
|
async _fetchRipeStateRows(partitionId, keyPrefix, ripeAtMs) {
|
|
997
1054
|
const body = {
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* - push_items: sink emissions (queue/partition/payload triples)
|
|
7
7
|
* - ack: source ack (transactionId, leaseId, status, optional error)
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
10
|
-
* SDK considers the source messages safely processed. On failure
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The broker commits the cycle as one command, all or nothing; on success the
|
|
10
|
+
* SDK considers the source messages safely processed. On failure, nothing of
|
|
11
|
+
* the cycle is applied and the source messages remain visible for redelivery
|
|
12
|
+
* via Queen's existing lease/retry.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export async function commitCycle({ http, queryId, partitionId, consumerGroup, stateOps, pushItems, ack, releaseLease }) {
|
|
@@ -26,7 +26,7 @@ export async function registerQuery({ http, name, sourceQueue, sinkQueue, config
|
|
|
26
26
|
const msg = err.body.error || 'config_hash mismatch'
|
|
27
27
|
const hint =
|
|
28
28
|
'\n\nHint: pass `reset: true` to Stream.run({...}) to wipe the existing ' +
|
|
29
|
-
'
|
|
29
|
+
'stream state for this queryId, or pick a new queryId.'
|
|
30
30
|
const e = new Error(msg + hint)
|
|
31
31
|
e.code = 'STREAMS_CONFIG_HASH_MISMATCH'
|
|
32
32
|
throw e
|
|
@@ -93,12 +93,6 @@ export function checkConflationResponse(result, ctx) {
|
|
|
93
93
|
|
|
94
94
|
if (result && result.conflation === true) return
|
|
95
95
|
|
|
96
|
-
// Pop maintenance: the broker refused the pop before it reached the claim
|
|
97
|
-
// path, so there is no policy to echo and nothing to conclude from the
|
|
98
|
-
// absence of one. Reading it as "old broker" would stop every conflating
|
|
99
|
-
// consumer in the fleet the moment an operator pauses pops.
|
|
100
|
-
if (result && result.paused === true) return
|
|
101
|
-
|
|
102
96
|
// The group is known to disagree with us; this response is that same
|
|
103
97
|
// conflict, seen through a body that could not carry the flag.
|
|
104
98
|
if (knownConflicts.has(key)) return
|