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.
Files changed (51) hide show
  1. package/README.md +71 -18
  2. package/client-v2/Queen.js +45 -13
  3. package/client-v2/README.md +26 -11
  4. package/client-v2/admin/Admin.js +0 -47
  5. package/client-v2/builders/QueueBuilder.js +33 -10
  6. package/client-v2/builders/TimerBuilder.js +2 -2
  7. package/client-v2/builders/TransactionBuilder.js +35 -9
  8. package/client-v2/consumer/ConsumerManager.js +67 -46
  9. package/client-v2/ephemeral/Ephemeral.js +7 -9
  10. package/client-v2/kv/Kv.js +4 -4
  11. package/client-v2/kv/expiry.js +1 -1
  12. package/client-v2/streams/Stream.js +8 -3
  13. package/client-v2/streams/helpers/rateLimiter.js +4 -4
  14. package/client-v2/streams/operators/GateOperator.js +9 -2
  15. package/client-v2/streams/operators/ReduceOperator.js +2 -2
  16. package/client-v2/streams/operators/WindowSessionOperator.js +3 -3
  17. package/client-v2/streams/runtime/Runner.js +111 -54
  18. package/client-v2/streams/runtime/cycle.js +4 -4
  19. package/client-v2/streams/runtime/register.js +1 -1
  20. package/client-v2/utils/conflation.js +0 -6
  21. package/client-v2/utils/consumerGroup.js +54 -0
  22. package/package.json +5 -8
  23. package/test-v2/_kvtimers.js +12 -13
  24. package/test-v2/ackwindow.js +12 -192
  25. package/test-v2/bootstrap.js +2 -2
  26. package/test-v2/conflation-unit/conflationWire.test.js +0 -12
  27. package/test-v2/consume.js +41 -1
  28. package/test-v2/consumer-unit/handlerError.test.js +161 -0
  29. package/test-v2/docs.js +5 -4
  30. package/test-v2/http-unit/pushStatus.test.js +142 -0
  31. package/test-v2/http-unit/renew.test.js +96 -0
  32. package/test-v2/kv-unit/timerWire.test.js +1 -1
  33. package/test-v2/kv-unit/txnWire.test.js +101 -2
  34. package/test-v2/kv.js +6 -5
  35. package/test-v2/pop.js +28 -1
  36. package/test-v2/run.js +50 -114
  37. package/test-v2/runner-unit/fatalExit.test.js +64 -0
  38. package/test-v2/semantics.js +20 -34
  39. package/test-v2/stream/_helpers.js +10 -19
  40. package/test-v2/stream/cron.js +1 -1
  41. package/test-v2/stream/gate.js +54 -0
  42. package/test-v2/stream/index.js +2 -0
  43. package/test-v2/stream/tumbling.js +3 -3
  44. package/test-v2/streams-unit/ack.test.js +61 -0
  45. package/test-v2/streams-unit/cycle.test.js +1 -1
  46. package/test-v2/streams-unit/e2e.test.js +6 -10
  47. package/test-v2/streams-unit/gate.test.js +189 -0
  48. package/test-v2/timers.js +6 -5
  49. package/test-v2/transaction.js +169 -0
  50. package/test-v2/watermark.js +38 -176
  51. 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 (autoAck && !ok) {
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
- // Auto-nack on error if enabled
333
- if (autoAck) {
334
- const context = group ? { group, error: error.message } : { error: error.message }
335
- const res = await this.#queen.ack(message, false, context)
336
- if (res && res.success === false) {
337
- logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'nack-rejected', error: res.error })
338
- }
339
- logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message, status: 'nacked' })
340
- // Don't rethrow when autoAck is enabled - NACK was already sent
341
- // This allows the consumer to continue and retry
342
- return false
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
- // Auto-ack on success if enabled
354
- if (autoAck) {
355
- const context = group ? { group } : {}
356
- const res = await this.#queen.ack(messages, true, context)
357
- if (res && res.success === false) {
358
- logger.error('ConsumerManager.processBatch', { count: messages.length, status: 'ack-rejected', error: res.error })
359
- } else {
360
- logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
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
- } catch (error) {
364
- // Auto-nack on error if enabled
365
- if (autoAck) {
366
- const context = group ? { group, error: error.message } : { error: error.message }
367
- await this.#queen.ack(messages, false, context)
368
- logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message, status: 'nacked' })
369
- // Don't rethrow when autoAck is enabled - NACK was already sent
370
- // This allows the consumer to continue and retry
371
- return
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
- logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message })
374
- throw error
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 PG and comes back after a restart, as
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 PG (§1.1): the
256
- * configuration survives a restart, the contents never do, and the queue
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 in PG. */
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, with no
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', {})
@@ -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 stored
6
- * procedure), delete, incr. Plus two conveniences this client owns: `once`,
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 stored procedure guarantees one result per op and raises rather than
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 JSONB value
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
  */
@@ -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 `kv_apply_v1`, so that all seven
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 queen_streams.state,
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] - 'all' (default) | 'new'
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
- * queen_streams.state, partial-ack on deny, FIFO order on lease expiry) all
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 queen_streams.state on every ALLOWED message).
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" and can be inspected via SQL on queen_streams.state.
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
- * `queen_streams.state` for the message's key) and invokes the user fn.
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
- * `queen_streams.state` table without colliding, and lets the idle-flush
165
- * scan filter to a single operator's keys via prefix LIKE matching.
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 PG state.
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 made
197
- // it to PG, so no delete needed.
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; PG is source of truth)
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
- // Sort messages by (createdAt, id) to preserve partition FIFO order.
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 SP how many messages were in this cycle's batch
400
- // so partition_consumers.acked_count / lease release logic uses
401
- // the correct count (the cycle is atomic across the full batch).
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 allowedCount = 0
469
- let firstDenyIdx = -1
504
+ let settled = 0 // messages whose every envelope was allowed
505
+ let denied = false
470
506
  const allowedEnvelopes = []
471
507
 
472
- for (let i = 0; i < envelopes.length; i++) {
473
- const env = envelopes[i]
474
- const key = env.key
475
- const stateForKey = ensureState(key)
476
- const ctx = {
477
- state: stateForKey,
478
- streamTimeMs,
479
- partitionId,
480
- partition: partitionName,
481
- key
482
- }
483
- let decision
484
- try {
485
- decision = await gate.evaluate(env, ctx)
486
- } catch (err) {
487
- // Gate threw: treat as a transient cycle error. Don't commit
488
- // anything. The lease will time out and the broker redelivers.
489
- this._reportError(err, { phase: 'gate-eval', partition: partitionId, message: env.msg })
490
- return
491
- }
492
- if (decision.allow) {
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.add(key)
495
- allowedCount++
496
- allowedEnvelopes.push(env)
497
- } else {
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 (allowedCount === 0) {
504
- // Nothing to commit: skip the cycle entirely. The lease stays held
505
- // by us until it naturally expires; the messages are then
506
- // redelivered (to us or another worker) in their original order.
507
- this._stats.gateDenialsTotal = (this._stats.gateDenialsTotal || 0) + envelopes.length
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 allowed message (cursor advances to it). The count
544
- // tells the SP how many messages this commit covers.
545
- const lastAllowedSrc = allowedEnvelopes[allowedEnvelopes.length - 1].msg
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: lastAllowedSrc.transactionId,
548
- leaseId: lastAllowedSrc.leaseId || group.leaseId,
604
+ transactionId: lastSettled.transactionId,
605
+ leaseId: lastSettled.leaseId || group.leaseId,
549
606
  status: 'completed',
550
- count: allowedCount
607
+ count: settled
551
608
  }
552
609
 
553
- const partial = firstDenyIdx >= 0 // some message was denied
554
- const releaseLease = !partial
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 (firstDenyIdx >= 0) {
557
- const tailUnacked = envelopes.length - allowedCount
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) + allowedCount
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 streams_state_get_v1 SP's key_prefix + ripe_at_or_before
994
- * filters to scope the fetch.
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 server runs streams_cycle_v1 in one transaction; on success the
10
- * SDK considers the source messages safely processed. On failure (including
11
- * EXCEPTION inside the SP), the entire cycle is rolled back and the source
12
- * messages remain visible for redelivery via Queen's existing lease/retry.
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
- 'queen_streams.state for this queryId, or pick a new queryId.'
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