queen-mq 1.3.0 → 2.0.2
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 +99 -21
- package/client-v2/Queen.js +47 -15
- package/client-v2/README.md +34 -13
- package/client-v2/admin/Admin.js +30 -55
- package/client-v2/builders/QueueBuilder.js +106 -34
- package/client-v2/builders/TimerBuilder.js +2 -2
- package/client-v2/builders/TransactionBuilder.js +35 -9
- package/client-v2/consumer/ConsumerManager.js +79 -53
- package/client-v2/ephemeral/Ephemeral.js +47 -17
- 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/client-v2/utils/defaults.js +3 -2
- package/package.json +5 -8
- package/test-v2/_kvtimers.js +12 -13
- package/test-v2/ackwindow.js +12 -192
- package/test-v2/admin-unit/removedRoutes.test.js +63 -0
- 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/consumer-unit/nackScope.test.js +88 -0
- package/test-v2/docs.js +5 -4
- package/test-v2/ephemeral-unit/ephemeralWire.test.js +24 -1
- 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-unit/popDefaults.test.js +236 -0
- package/test-v2/pop.js +96 -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
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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consumer group a popped message belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Every pop answers each message with the group it was claimed under
|
|
5
|
+
* (`consumerGroup`), and the lease an ack has to match belongs to that group.
|
|
6
|
+
* An ack that names no group is judged in queue mode, so a message popped by a
|
|
7
|
+
* group and acked without one is refused: `rejected_ack` from a transaction,
|
|
8
|
+
* `invalid or expired lease` from /ack. Reading the group off the message is
|
|
9
|
+
* what lets `ack(message)` work without the caller repeating `.group(...)`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The broker's name for "no consumer group" (queue mode). */
|
|
13
|
+
export const QUEUE_MODE_GROUP = '__QUEUE_MODE__'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The group to send with an ack of `message`, or null for queue mode.
|
|
17
|
+
*
|
|
18
|
+
* Null for queue mode on purpose: an ack with no group IS a queue-mode ack, so
|
|
19
|
+
* leaving the key out keeps those requests byte-identical to what this client
|
|
20
|
+
* always sent. Also null for anything that is not a popped message (a bare
|
|
21
|
+
* transactionId string, a hand-built `{transactionId, partitionId}`).
|
|
22
|
+
*/
|
|
23
|
+
export function consumerGroupOf(message) {
|
|
24
|
+
if (message === null || typeof message !== 'object') return null
|
|
25
|
+
const group = message.consumerGroup
|
|
26
|
+
if (typeof group !== 'string' || group.length === 0 || group === QUEUE_MODE_GROUP) return null
|
|
27
|
+
return group
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one group to send with a batch ack of `messages`, or null for queue mode.
|
|
32
|
+
*
|
|
33
|
+
* /api/v1/ack/batch carries ONE consumer group for the whole request, so a
|
|
34
|
+
* batch whose messages were popped under different groups cannot be expressed
|
|
35
|
+
* as one call: it throws rather than ack some of them under the wrong group.
|
|
36
|
+
* Messages that state no group (hand-built ones) take whatever the others say.
|
|
37
|
+
*/
|
|
38
|
+
export function sharedConsumerGroupOf(messages) {
|
|
39
|
+
const named = new Set()
|
|
40
|
+
for (const message of messages) {
|
|
41
|
+
if (message !== null && typeof message === 'object' &&
|
|
42
|
+
typeof message.consumerGroup === 'string' && message.consumerGroup.length > 0) {
|
|
43
|
+
named.add(message.consumerGroup)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (named.size > 1) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Cannot ack messages from different consumer groups in one call (${[...named].join(', ')}): ` +
|
|
49
|
+
'a batch ack carries one consumer group. Ack each group separately, or pass { group }.'
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
const [group] = named
|
|
53
|
+
return group && group !== QUEUE_MODE_GROUP ? group : null
|
|
54
|
+
}
|
|
@@ -81,9 +81,10 @@ export const CONSUME_DEFAULTS = {
|
|
|
81
81
|
// As in CONSUME_DEFAULTS, batch is the autopilot-OFF default.
|
|
82
82
|
export const POP_DEFAULTS = {
|
|
83
83
|
batch: 1, // One message (autopilot off only)
|
|
84
|
-
wait:
|
|
84
|
+
wait: true, // Long polling, as every pop has done; .wait(false) returns at once
|
|
85
85
|
timeoutMillis: 30000, // 30 seconds if wait=true
|
|
86
|
-
autoAck: false
|
|
86
|
+
autoAck: false, // Never sent: autoAck() is consume()'s ack after the handler
|
|
87
|
+
commitOnDelivery: false // Leased; true sends autoAck=true, the broker's at-most-once commit at delivery
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export const BUFFER_DEFAULTS = {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queen-mq",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Partitioned message queue on
|
|
5
|
+
"description": "Partitioned message queue on a replicated broker log — broker client + fluent streaming SDK (windows, joins, gates) in one package",
|
|
6
6
|
"main": "client-v2/index.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "
|
|
9
|
-
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js",
|
|
8
|
+
"test": "npm run test:unit && node test-v2/run.js human",
|
|
9
|
+
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js",
|
|
10
10
|
"test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
|
|
11
11
|
"test:integration": "node test-v2/run.js human",
|
|
12
12
|
"test:streams": "node test-v2/run.js stream",
|
|
@@ -19,12 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"axios": "^1.12.2",
|
|
22
|
-
"pg": "^8.16.3",
|
|
23
22
|
"undici": "^6.21.0",
|
|
24
23
|
"uuid": "^13.0.0"
|
|
25
24
|
},
|
|
26
25
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
26
|
+
"node": ">=24.0.0"
|
|
28
27
|
},
|
|
29
28
|
"author": "Smartness",
|
|
30
29
|
"license": "Apache-2.0",
|
|
@@ -39,8 +38,6 @@
|
|
|
39
38
|
"keywords": [
|
|
40
39
|
"message-queue",
|
|
41
40
|
"queue",
|
|
42
|
-
"postgres",
|
|
43
|
-
"postgresql",
|
|
44
41
|
"broker",
|
|
45
42
|
"message-queue-system",
|
|
46
43
|
"fifo",
|
package/test-v2/_kvtimers.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* says nothing, and that was only tolerable while the 404 was legitimate.
|
|
20
20
|
*
|
|
21
21
|
* What still exists is the operator's RUNTIME kill switch (`kv_enabled`,
|
|
22
|
-
* `timers_schedule_enabled`, `timers_fire_enabled
|
|
23
|
-
*
|
|
22
|
+
* `timers_schedule_enabled`, `timers_fire_enabled`, set through
|
|
23
|
+
* `POST /api/v1/system/kv-timers`) --
|
|
24
|
+
* a lever pulled live during an incident. It answers 503
|
|
24
25
|
* with `Retry-After` on the kv/timer routes and 403 on a `kv`/`timers` rider
|
|
25
26
|
* inside a transaction, never 404. If a run here ever goes red against that,
|
|
26
27
|
* the switch is down on the rig and somebody pulled it; it is not something
|
|
@@ -35,20 +36,18 @@ export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
|
|
35
36
|
/**
|
|
36
37
|
* Poll a queue until `match` accepts a message, or the deadline passes.
|
|
37
38
|
*
|
|
38
|
-
* WHY THIS IS A POLL AND NOT A LONG POLL
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* arriving at +30.7 s, and a 20 s long poll timed out with the message already
|
|
46
|
-
* committed in the log.
|
|
39
|
+
* WHY THIS IS A POLL AND NOT A LONG POLL. A timer's delivery is not a client
|
|
40
|
+
* push: the broker's own fire step appends it (the leader plans due timers in
|
|
41
|
+
* a step of its own loop, each fire's message append and timer removal in one
|
|
42
|
+
* entry), so it never passes through the push handler, and when it becomes
|
|
43
|
+
* visible is bounded by that step, not by the delay under test. A short poll
|
|
44
|
+
* with a deadline asserts the arrival without depending on whether, or when,
|
|
45
|
+
* a parked long poll is woken by a fire.
|
|
47
46
|
*
|
|
48
47
|
* That is a broker property, not a client one, and it is why these tests
|
|
49
48
|
* assert ARRIVAL and never latency: `deliverAt` is "not before", never
|
|
50
|
-
* "exactly at". The deadline here is generous on purpose -- it is sized
|
|
51
|
-
*
|
|
49
|
+
* "exactly at". The deadline here is generous on purpose -- it is sized for a
|
|
50
|
+
* slow rig, not against the delay under test.
|
|
52
51
|
*/
|
|
53
52
|
export async function popUntil(client, queueName, match, timeoutMs = 60000) {
|
|
54
53
|
const deadline = Date.now() + timeoutMs
|