queen-mq 0.14.1 → 0.15.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 (48) hide show
  1. package/README.md +42 -0
  2. package/client-v2/README.md +12 -1
  3. package/client-v2/builders/QueueBuilder.js +10 -0
  4. package/client-v2/index.js +15 -0
  5. package/client-v2/streams/Stream.js +392 -0
  6. package/client-v2/streams/helpers/rateLimiter.js +138 -0
  7. package/client-v2/streams/operators/AggregateOperator.js +87 -0
  8. package/client-v2/streams/operators/FilterOperator.js +18 -0
  9. package/client-v2/streams/operators/FlatMapOperator.js +21 -0
  10. package/client-v2/streams/operators/ForeachOperator.js +29 -0
  11. package/client-v2/streams/operators/GateOperator.js +85 -0
  12. package/client-v2/streams/operators/KeyByOperator.js +23 -0
  13. package/client-v2/streams/operators/MapOperator.js +27 -0
  14. package/client-v2/streams/operators/ReduceOperator.js +196 -0
  15. package/client-v2/streams/operators/SinkOperator.js +54 -0
  16. package/client-v2/streams/operators/WindowCronOperator.js +83 -0
  17. package/client-v2/streams/operators/WindowSessionOperator.js +223 -0
  18. package/client-v2/streams/operators/WindowSlidingOperator.js +103 -0
  19. package/client-v2/streams/operators/WindowTumblingOperator.js +94 -0
  20. package/client-v2/streams/operators/_windowCommon.js +77 -0
  21. package/client-v2/streams/operators/index.js +18 -0
  22. package/client-v2/streams/runtime/Runner.js +955 -0
  23. package/client-v2/streams/runtime/StreamsHttpClient.js +83 -0
  24. package/client-v2/streams/runtime/cycle.js +36 -0
  25. package/client-v2/streams/runtime/register.js +48 -0
  26. package/client-v2/streams/runtime/state.js +33 -0
  27. package/client-v2/streams/util/backoff.js +23 -0
  28. package/client-v2/streams/util/configHash.js +33 -0
  29. package/client-v2/streams/util/logger.js +29 -0
  30. package/package.json +14 -4
  31. package/test-v2/run.js +26 -2
  32. package/test-v2/stream/_helpers.js +190 -0
  33. package/test-v2/stream/combined.js +148 -0
  34. package/test-v2/stream/cron.js +98 -0
  35. package/test-v2/stream/eventTime.js +220 -0
  36. package/test-v2/stream/index.js +25 -0
  37. package/test-v2/stream/operators.js +207 -0
  38. package/test-v2/stream/recovery.js +141 -0
  39. package/test-v2/stream/session.js +144 -0
  40. package/test-v2/stream/sliding.js +75 -0
  41. package/test-v2/stream/throughput.js +153 -0
  42. package/test-v2/stream/tumbling.js +315 -0
  43. package/test-v2/streams-unit/configHash.test.js +102 -0
  44. package/test-v2/streams-unit/cycle.test.js +200 -0
  45. package/test-v2/streams-unit/e2e.test.js +221 -0
  46. package/test-v2/streams-unit/eventTime.test.js +149 -0
  47. package/test-v2/streams-unit/fakeServer.js +165 -0
  48. package/test-v2/streams-unit/operators.test.js +476 -0
package/README.md CHANGED
@@ -250,6 +250,47 @@ for (const message of messages) {
250
250
  }
251
251
  ```
252
252
 
253
+ ### Multi-Partition Pop (Drain Many Partitions Per Call)
254
+
255
+ ```javascript
256
+ // One round-trip drains up to 200 messages spread across up to 50 partitions.
257
+ // batch(200) is the GLOBAL cap on total messages; partitions(50) is the
258
+ // hard cap on partitions claimed. All claimed partitions share one leaseId
259
+ // — a single renew() call extends every partition's lease atomically.
260
+ const messages = await queen.queue('events')
261
+ .batch(200)
262
+ .partitions(50)
263
+ .wait(true)
264
+ .pop()
265
+
266
+ // Each message carries its own partition info (per-message partitionId,
267
+ // partition name, leaseId, consumerGroup) — ACK and renew always work
268
+ // message-by-message regardless of how many partitions the batch spans.
269
+ for (const m of messages) {
270
+ console.log(`from ${m.partition}:`, m.data)
271
+ }
272
+
273
+ // Same builder works on .consume() for long-running workers
274
+ await queen.queue('events')
275
+ .batch(100)
276
+ .partitions(8)
277
+ .consume(async (msgs) => {
278
+ for (const m of msgs) await process(m.data)
279
+ })
280
+ ```
281
+
282
+ **When to use:** queues with many partitions where each partition only has
283
+ a handful of new messages per polling interval (per-customer event streams,
284
+ per-tenant work queues, per-device telemetry). Reduces network round-trips
285
+ from O(P) to O(P / N) while preserving per-partition FIFO ordering.
286
+
287
+ **When not to use:** few partitions, or each one busy enough to fill
288
+ `batch(B)` on its own. Default is `partitions(1)` which preserves the
289
+ legacy single-partition behaviour.
290
+
291
+ `.partitions(N)` only applies to **wildcard** pops; specifying
292
+ `.partition('name')` ignores the cap.
293
+
253
294
  ### Transactions (Atomic Operations)
254
295
 
255
296
  ```javascript
@@ -451,6 +492,7 @@ await queen.queue('q').buffer({ messageCount: 100, timeMillis: 1000 }).push([...
451
492
  const msgs = await queen.queue('q').pop()
452
493
  const msgs = await queen.queue('q').batch(10).pop()
453
494
  const msgs = await queen.queue('q').batch(10).wait(true).pop()
495
+ const msgs = await queen.queue('q').batch(200).partitions(50).pop() // multi-partition pop
454
496
  ```
455
497
 
456
498
  ### Consume
@@ -1605,6 +1605,11 @@ const msgs = await queen.queue('q').batch(10).wait(true).pop()
1605
1605
 
1606
1606
  // Pop from partition
1607
1607
  const msgs = await queen.queue('q').partition('p1').pop()
1608
+
1609
+ // Multi-partition pop: drain up to 50 partitions in one round-trip,
1610
+ // capped at 200 total messages. All partitions share one leaseId.
1611
+ // Each returned message carries its own partitionId / partition / leaseId.
1612
+ const msgs = await queen.queue('q').batch(200).partitions(50).pop()
1608
1613
  ```
1609
1614
 
1610
1615
  ### Consume
@@ -1625,6 +1630,12 @@ await queen.queue('q').concurrency(5).consume(async (msg) => { /* 5 parallel wor
1625
1630
  // Consume from partition
1626
1631
  await queen.queue('q').partition('p1').consume(async (msg) => { /* process */ })
1627
1632
 
1633
+ // Multi-partition consume: drain up to 8 partitions per poll.
1634
+ // batch(100) is a global cap on total messages across all claimed partitions.
1635
+ await queen.queue('q').batch(100).partitions(8).consume(async (msgs) => {
1636
+ for (const m of msgs) await process(m.data) // m.partitionId / m.partition baked in
1637
+ })
1638
+
1628
1639
  // Consume with consumer group
1629
1640
  await queen.queue('q').group('my-group').consume(async (msg) => { /* process */ })
1630
1641
 
@@ -1863,7 +1874,7 @@ Example log output:
1863
1874
  Here's a complete example showing many features together:
1864
1875
 
1865
1876
  ```javascript
1866
- import { Queen } from './client-js/client-v2/index.js'
1877
+ import { Queen } from './index.js'
1867
1878
 
1868
1879
  const queen = new Queen('http://localhost:6632')
1869
1880
 
@@ -49,6 +49,16 @@ export class QueueBuilder {
49
49
  this.#queueName = queueName
50
50
  }
51
51
 
52
+ /**
53
+ * Public read-only accessor for the queue name. Useful for tooling that
54
+ * holds a QueueBuilder reference and needs to know the underlying name —
55
+ * e.g. @queenmq/streams resolves sink-queue names from the QueueBuilder
56
+ * passed to .to(...).
57
+ */
58
+ get name() {
59
+ return this.#queueName
60
+ }
61
+
52
62
  // ===========================
53
63
  // Affinity Key Generation
54
64
  // ===========================
@@ -1,8 +1,23 @@
1
1
  /**
2
2
  * Queen Message Queue Client - Entry Point
3
+ *
4
+ * One npm package for everything:
5
+ * import { Queen, Stream, tokenBucketGate, slidingWindowGate } from 'queen-mq'
6
+ *
7
+ * The broker client (`Queen`, `Admin`) and the fluent streaming SDK
8
+ * (`Stream`, `tokenBucketGate`, `slidingWindowGate`, plus all operator
9
+ * classes) ship in the same package. ESM named exports + tree-shaking
10
+ * mean bundlers (Vite, Rollup, webpack with `sideEffects:false`) can
11
+ * drop the streaming code from your output if you only import broker
12
+ * symbols.
13
+ *
14
+ * Streaming source lives under `./streams/` of this package.
3
15
  */
4
16
 
5
17
  export { Queen } from './Queen.js'
6
18
  export { Admin } from './admin/Admin.js'
7
19
  export { CLIENT_DEFAULTS, QUEUE_DEFAULTS, CONSUME_DEFAULTS, POP_DEFAULTS, BUFFER_DEFAULTS } from './utils/defaults.js'
8
20
 
21
+ // Streaming SDK — full source under ./streams/.
22
+ export { Stream } from './streams/Stream.js'
23
+ export { tokenBucketGate, slidingWindowGate } from './streams/helpers/rateLimiter.js'
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Stream — fluent builder for streaming pipelines.
3
+ *
4
+ * A Stream is an immutable chain of Operators that gets compiled and run
5
+ * by a Runner. Each combinator (`.map`, `.filter`, ...) returns a NEW Stream
6
+ * with the operator appended; the chain is finalized by `.run({ queryId })`.
7
+ *
8
+ * The chain shape (operator types + their stateful config) is fingerprinted
9
+ * via configHash so that re-deploying with a different chain under the same
10
+ * queryId is rejected at registration unless `{ reset: true }` is passed.
11
+ */
12
+
13
+ import { MapOperator } from './operators/MapOperator.js'
14
+ import { FilterOperator } from './operators/FilterOperator.js'
15
+ import { FlatMapOperator } from './operators/FlatMapOperator.js'
16
+ import { KeyByOperator } from './operators/KeyByOperator.js'
17
+ import { WindowTumblingOperator } from './operators/WindowTumblingOperator.js'
18
+ import { WindowSlidingOperator } from './operators/WindowSlidingOperator.js'
19
+ import { WindowSessionOperator } from './operators/WindowSessionOperator.js'
20
+ import { WindowCronOperator } from './operators/WindowCronOperator.js'
21
+ import { ReduceOperator } from './operators/ReduceOperator.js'
22
+ import { AggregateOperator } from './operators/AggregateOperator.js'
23
+ import { GateOperator } from './operators/GateOperator.js'
24
+ import { SinkOperator } from './operators/SinkOperator.js'
25
+ import { ForeachOperator } from './operators/ForeachOperator.js'
26
+ import { Runner } from './runtime/Runner.js'
27
+ import { configHashOf } from './util/configHash.js'
28
+
29
+ export class Stream {
30
+ /**
31
+ * Build a Stream sourced from a Queen QueueBuilder.
32
+ *
33
+ * @param {object} queueBuilder - the result of queen.queue('name') — must
34
+ * expose .pop() / .ack() / .push() and the queue name.
35
+ * @param {object} [options]
36
+ * @returns {Stream}
37
+ */
38
+ static from(queueBuilder, options = {}) {
39
+ return new Stream({
40
+ source: queueBuilder,
41
+ operators: [],
42
+ sourceOptions: options
43
+ })
44
+ }
45
+
46
+ /**
47
+ * @param {object} args
48
+ * @param {object} args.source - source QueueBuilder
49
+ * @param {object} [args.sourceOptions]
50
+ * @param {Array} args.operators - operator chain
51
+ */
52
+ constructor({ source, operators, sourceOptions = {} }) {
53
+ this._source = source
54
+ this._sourceOptions = sourceOptions
55
+ this._operators = operators
56
+ }
57
+
58
+ // ---------------------------------------------------------------- Stateless
59
+
60
+ /** @param {(msg:object)=>any|Promise<any>} fn */
61
+ map(fn) {
62
+ return this._extend(new MapOperator(fn))
63
+ }
64
+
65
+ /** @param {(msg:object)=>boolean|Promise<boolean>} predicate */
66
+ filter(predicate) {
67
+ return this._extend(new FilterOperator(predicate))
68
+ }
69
+
70
+ /** @param {(msg:object)=>(any[]|Promise<any[]>)} fn */
71
+ flatMap(fn) {
72
+ return this._extend(new FlatMapOperator(fn))
73
+ }
74
+
75
+ // -------------------------------------------------------------------- Keying
76
+
77
+ /**
78
+ * Override the implicit partition-key for downstream stateful operators.
79
+ * If omitted, stateful operators use the source partition_id as the key,
80
+ * which is the natural fit for queues partitioned by entity.
81
+ *
82
+ * Warning: when the key differs from the partition, multiple workers
83
+ * holding different partition leases may write the same logical key,
84
+ * causing cross-worker contention on (query_id, partition_id, key) state
85
+ * rows. The repartition pattern (push to a co-keyed intermediate queue,
86
+ * then process) avoids this.
87
+ *
88
+ * @param {(msg:object)=>string} fn
89
+ */
90
+ keyBy(fn) {
91
+ return this._extend(new KeyByOperator(fn))
92
+ }
93
+
94
+ // -------------------------------------------------------------------- Windows
95
+
96
+ /**
97
+ * Fixed-size, non-overlapping windows.
98
+ * @param {{
99
+ * seconds: number,
100
+ * gracePeriod?: number, // accept events for windowEnd + grace before closing
101
+ * idleFlushMs?: number, // close ripe windows on quiet partitions every N ms (default 5000)
102
+ * eventTime?: (msg:any)=>(number|Date|string), // event-time mode if set
103
+ * allowedLateness?: number, // event-time only: drop events older than wm - allowedLateness
104
+ * onLate?: 'drop' | 'include'
105
+ * }} opts
106
+ */
107
+ windowTumbling(opts) {
108
+ return this._extend(new WindowTumblingOperator(opts))
109
+ }
110
+
111
+ /**
112
+ * Overlapping fixed-size windows that hop every `slide` seconds.
113
+ * Each event creates `size/slide` state rows per key.
114
+ * @param {{size:number, slide:number, gracePeriod?:number, idleFlushMs?:number,
115
+ * eventTime?:(msg:any)=>any, allowedLateness?:number, onLate?:string}} opts
116
+ */
117
+ windowSliding(opts) {
118
+ return this._extend(new WindowSlidingOperator(opts))
119
+ }
120
+
121
+ /**
122
+ * Per-key activity-based windows. A session for a key extends as long
123
+ * as events keep arriving within `gap` seconds of each other.
124
+ * @param {{gap:number, gracePeriod?:number, idleFlushMs?:number,
125
+ * eventTime?:(msg:any)=>any, allowedLateness?:number, onLate?:string}} opts
126
+ */
127
+ windowSession(opts) {
128
+ return this._extend(new WindowSessionOperator(opts))
129
+ }
130
+
131
+ /**
132
+ * Wall-clock-aligned windows. Currently supports the `every:` shorthand
133
+ * (`'second'|'minute'|'hour'|'day'|'week'`); full cron syntax is reserved
134
+ * for v0.3.
135
+ * @param {{every:string, gracePeriod?:number, idleFlushMs?:number,
136
+ * eventTime?:(msg:any)=>any, allowedLateness?:number, onLate?:string}} opts
137
+ */
138
+ windowCron(opts) {
139
+ return this._extend(new WindowCronOperator(opts))
140
+ }
141
+
142
+ // -------------------------------------------------------------------- Reduce
143
+
144
+ /**
145
+ * @template T
146
+ * @param {(acc:T, msg:object)=>T|Promise<T>} fn
147
+ * @param {T} initial
148
+ */
149
+ reduce(fn, initial) {
150
+ return this._extend(new ReduceOperator(fn, initial))
151
+ }
152
+
153
+ /**
154
+ * Sugar over reduce. Each provided extractor produces one named field on
155
+ * the aggregate value.
156
+ *
157
+ * Example:
158
+ * .aggregate({ count: () => 1, sum: m => m.data.amount })
159
+ *
160
+ * Output value shape: { count: number, sum: number, ... }
161
+ *
162
+ * @param {Record<string,(msg:object)=>number>} extractors
163
+ */
164
+ aggregate(extractors) {
165
+ return this._extend(new AggregateOperator(extractors))
166
+ }
167
+
168
+ // ---------------------------------------------------------------------- Gate
169
+
170
+ /**
171
+ * Per-message ALLOW/DENY decision with persistent per-key state.
172
+ *
173
+ * Used to build rate limiters, throttlers, fairness gates, circuit
174
+ * breakers — anything where the semantics is "should this message
175
+ * proceed RIGHT NOW given the recent history of this key?".
176
+ *
177
+ * The user fn receives `(value, ctx)` where:
178
+ * - value: the message payload (post any pre-stage map/filter)
179
+ * - ctx.state: mutable per-key state (loaded from queen_streams.state,
180
+ * persisted only if you return ALLOW for this message)
181
+ * - ctx.streamTimeMs: system clock for the cycle (use for refill math)
182
+ * - ctx.partitionId: source partition_id (= state shard)
183
+ *
184
+ * Return `true` (or `{allow:true}`) to let the message through.
185
+ * Return `false` (or `{allow:false}`) to halt the batch here. The runner
186
+ * commits an ack for the prefix that was allowed and DOES NOT release the
187
+ * source lease, so the denied message and its successors get redelivered
188
+ * in their original order when the lease expires. FIFO per partition is
189
+ * preserved without any deferred queue.
190
+ *
191
+ * Example (token bucket rate limiter):
192
+ *
193
+ * .gate((req, ctx) => {
194
+ * const cfg = { capacity: 10, refillPerSec: 5 }
195
+ * const now = ctx.streamTimeMs
196
+ * ctx.state.tokens ??= cfg.capacity
197
+ * ctx.state.lastRefillAt ??= now
198
+ * const elapsedSec = (now - ctx.state.lastRefillAt) / 1000
199
+ * ctx.state.tokens = Math.min(
200
+ * cfg.capacity,
201
+ * ctx.state.tokens + elapsedSec * cfg.refillPerSec
202
+ * )
203
+ * ctx.state.lastRefillAt = now
204
+ * if (ctx.state.tokens >= 1) {
205
+ * ctx.state.tokens -= 1
206
+ * return true
207
+ * }
208
+ * return false // halt batch, lease expires, redelivered in order
209
+ * })
210
+ *
211
+ * Constraints
212
+ * - At most one .gate() per stream (multiple gates would compose
213
+ * awkwardly with the partial-ack semantics; chain serially via two
214
+ * streams if needed).
215
+ * - .gate() is incompatible with windowing/reducing in the same stream.
216
+ * The window+reducer model assumes the FULL batch is consumed atomically;
217
+ * gating breaks that. Run them as two separate streams.
218
+ *
219
+ * @param {(value:any, ctx:{state:object, streamTimeMs:number, partitionId:string}) => boolean | {allow:boolean} | Promise<boolean | {allow:boolean}>} fn
220
+ */
221
+ gate(fn) {
222
+ return this._extend(new GateOperator(fn))
223
+ }
224
+
225
+ // ---------------------------------------------------------------------- Sink
226
+
227
+ /**
228
+ * Sink to a Queen queue. The cycle commits state + push + ack atomically.
229
+ *
230
+ * @param {object} sinkQueueBuilder - `queen.queue('sink-name')`
231
+ * @param {{partition?: string|((value:any)=>string)}} [opts]
232
+ */
233
+ to(sinkQueueBuilder, opts = {}) {
234
+ return this._extend(new SinkOperator(sinkQueueBuilder, opts))
235
+ }
236
+
237
+ /**
238
+ * Terminal at-least-once side-effect. The cycle acks the source only after
239
+ * `fn` has resolved successfully, so a crash mid-`fn` will redeliver. If
240
+ * you need exactly-once external effects, write to a sink queue and have
241
+ * a dedicated worker consume it.
242
+ *
243
+ * @param {(value:any)=>void|Promise<void>} fn
244
+ */
245
+ foreach(fn) {
246
+ return this._extend(new ForeachOperator(fn))
247
+ }
248
+
249
+ // ----------------------------------------------------------------- Compile/run
250
+
251
+ /**
252
+ * Start the streaming runner. Returns a handle exposing:
253
+ * - stop(): graceful drain
254
+ * - metrics(): cycle/throughput/lag stats
255
+ *
256
+ * @param {object} runOptions
257
+ * @param {string} runOptions.queryId - durable identity of this query
258
+ * @param {number} [runOptions.batchSize=200] - messages per cycle
259
+ * @param {number} [runOptions.maxPartitions=4] - lease up to N partitions/cycle
260
+ * @param {number} [runOptions.maxWaitMillis=1000] - long-poll wait for source pop
261
+ * @param {string} [runOptions.subscriptionMode] - 'all' (default) | 'new'
262
+ * @param {string} [runOptions.subscriptionFrom] - ISO timestamp or 'now'
263
+ * @param {boolean} [runOptions.reset=false] - wipe state on config_hash mismatch
264
+ * @param {(err:Error, ctx:object)=>void} [runOptions.onError] - cycle error hook
265
+ * @param {AbortSignal} [runOptions.abortSignal] - external cancellation
266
+ */
267
+ async run(runOptions) {
268
+ if (!runOptions || !runOptions.queryId) {
269
+ throw new Error('run({ queryId }) is required')
270
+ }
271
+ const compiled = this._compile()
272
+ const runner = new Runner({
273
+ ...runOptions,
274
+ stream: compiled
275
+ })
276
+ await runner.start()
277
+ return runner
278
+ }
279
+
280
+ // ===== internals =========================================================
281
+
282
+ /** @returns {Stream} */
283
+ _extend(operator) {
284
+ return new Stream({
285
+ source: this._source,
286
+ sourceOptions: this._sourceOptions,
287
+ operators: [...this._operators, operator]
288
+ })
289
+ }
290
+
291
+ /**
292
+ * Compile the operator chain into a runnable description: stages, sink,
293
+ * and the configHash. Exposed for the Runner.
294
+ */
295
+ _compile() {
296
+ const sink = this._operators.find(op => op.kind === 'sink' || op.kind === 'foreach')
297
+ const sinkIdx = sink ? this._operators.indexOf(sink) : this._operators.length
298
+
299
+ // Verify the sink (if present) is the last operator.
300
+ if (sink && sinkIdx !== this._operators.length - 1) {
301
+ throw new Error(
302
+ 'sink operators (.to / .foreach) must be the last in the chain; ' +
303
+ `found ${sink.kind} at position ${sinkIdx} of ${this._operators.length}`
304
+ )
305
+ }
306
+
307
+ const upstream = sink ? this._operators.slice(0, sinkIdx) : this._operators
308
+
309
+ // Decompose into stages:
310
+ // pre: [stateless...] applied to each source record
311
+ // keyBy: optional KeyByOperator (operates on source record)
312
+ // window: optional WindowTumblingOperator (annotates envelopes)
313
+ // reducer: optional ReduceOperator/AggregateOperator
314
+ // post: [stateless...] applied to each emit value (post-reducer)
315
+ // sink: optional SinkOperator/ForeachOperator (terminal)
316
+ //
317
+ // "phase" walks from `pre` -> `keyed` -> `window` -> `reducer` -> `post`.
318
+ // Stateless operators belong to whichever side of the reducer they're
319
+ // declared on. A reducer is required to switch to `post`; without one,
320
+ // all stateless ops are pre-stage.
321
+ const stages = {
322
+ pre: [],
323
+ keyBy: null,
324
+ window: null,
325
+ reducer: null,
326
+ gate: null,
327
+ post: [],
328
+ sink
329
+ }
330
+ let phase = 'pre'
331
+ for (const op of upstream) {
332
+ if (op.kind === 'map' || op.kind === 'filter' || op.kind === 'flatMap') {
333
+ if (phase === 'pre' || phase === 'keyed') {
334
+ stages.pre.push(op)
335
+ } else if (phase === 'window') {
336
+ // Stateless ops between window and reducer are unusual; treat as pre
337
+ // (they see the windowed envelope but have no reducer state to act on).
338
+ stages.pre.push(op)
339
+ } else {
340
+ // post-reducer or post-gate: operates on each emit value.
341
+ stages.post.push(op)
342
+ }
343
+ } else if (op.kind === 'keyBy') {
344
+ if (stages.keyBy) throw new Error('only one .keyBy() per stream')
345
+ if (phase === 'reducer' || phase === 'gate') {
346
+ throw new Error('.keyBy() must come before window/reduce/gate')
347
+ }
348
+ stages.keyBy = op
349
+ if (phase === 'pre') phase = 'keyed'
350
+ } else if (op.kind === 'window') {
351
+ if (stages.window) throw new Error('only one window operator per stream')
352
+ if (phase === 'reducer') throw new Error('window must come before reduce/aggregate')
353
+ if (stages.gate) throw new Error('window+reduce is incompatible with .gate() in the same stream')
354
+ stages.window = op
355
+ phase = 'window'
356
+ } else if (op.kind === 'reduce' || op.kind === 'aggregate') {
357
+ if (stages.reducer) throw new Error('only one reduce/aggregate per stream')
358
+ if (!stages.window) {
359
+ throw new Error('reduce/aggregate requires a preceding window operator')
360
+ }
361
+ if (stages.gate) throw new Error('reduce/aggregate is incompatible with .gate() in the same stream')
362
+ stages.reducer = op
363
+ phase = 'reducer'
364
+ } else if (op.kind === 'gate') {
365
+ if (stages.gate) throw new Error('only one .gate() per stream')
366
+ if (stages.window || stages.reducer) {
367
+ throw new Error('.gate() is incompatible with windowing/reduce in the same stream')
368
+ }
369
+ stages.gate = op
370
+ phase = 'gate'
371
+ } else {
372
+ throw new Error(`unsupported operator: ${op.kind}`)
373
+ }
374
+ }
375
+ // Backwards-compat: keep the old name `stateless` aliasing `pre` so any
376
+ // existing code paths that referenced stages.stateless keep working.
377
+ stages.stateless = stages.pre
378
+
379
+ // Compute config_hash from the chain shape (operator kinds + their
380
+ // structural config; user functions are intentionally not hashed since
381
+ // we cannot stably serialise closures).
382
+ const config_hash = configHashOf(this._operators)
383
+
384
+ return {
385
+ source: this._source,
386
+ sourceOptions: this._sourceOptions,
387
+ stages,
388
+ operators: this._operators,
389
+ config_hash
390
+ }
391
+ }
392
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Rate-limiter helpers — composable token-bucket / sliding-window gate
3
+ * factories that return a function suitable for `.gate(fn)` on a Stream.
4
+ *
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
8
+ * come from the underlying Stream `.gate()` runtime — these factories only
9
+ * decide HOW each request consumes from the bucket.
10
+ *
11
+ * All four common rate-limit shapes are expressible by varying `costFn`:
12
+ *
13
+ * A) "100 req/sec, batches arbitrary": costFn = () => 1 on a queue of REQUESTS
14
+ * B) "100 msg/sec, in any number of req": costFn = () => 1 on a queue of MESSAGES
15
+ * C) "100 req/sec, exactly 1 msg/req": costFn = () => 1 same as B (degenerate)
16
+ * D) "100 weight/sec, cost varies": costFn = req => req.weight on a queue of REQUESTS
17
+ *
18
+ * Two gates can be chained on the same pipeline (req/s + msg/s simultaneously
19
+ * required by some OTAs) by running them as two consecutive Stream queries
20
+ * with an intermediate sink queue.
21
+ *
22
+ * Sizing rule (also enforced by tokenBucketGate at start time, and warned
23
+ * by the assertion below): the maximum sustainable rate the bucket can
24
+ * deliver is `min(refillPerSec, capacity / leaseSec)`. Pick capacity and
25
+ * leaseSec so the second term doesn't artificially cap you below
26
+ * refillPerSec — usually `capacity ≈ refillPerSec × leaseSec`.
27
+ */
28
+
29
+ /**
30
+ * Token-bucket gate factory.
31
+ *
32
+ * Returns a `(msg, ctx) => boolean` function suitable for `.gate(fn)`.
33
+ * The bucket state lives in `ctx.state` (which the runtime persists per-key
34
+ * in queen_streams.state on every ALLOWED message).
35
+ *
36
+ * @param {object} opts
37
+ * @param {number} opts.capacity Max tokens in the bucket (= max burst).
38
+ * @param {number} opts.refillPerSec Steady-state refill rate (tokens / sec).
39
+ * @param {(msg:any)=>number} [opts.costFn] Maps a message to its cost in tokens.
40
+ * Defaults to `() => 1` (one token per message).
41
+ * @param {boolean} [opts.allowZeroCost] If true, costFn() may return 0 to mean
42
+ * "always allow, no token consumed". Defaults true.
43
+ * @returns {(msg:any, ctx:object)=>boolean}
44
+ */
45
+ export function tokenBucketGate({
46
+ capacity,
47
+ refillPerSec,
48
+ costFn = () => 1,
49
+ allowZeroCost = true
50
+ } = {}) {
51
+ if (!Number.isFinite(capacity) || capacity <= 0) {
52
+ throw new Error('tokenBucketGate: capacity must be a positive number')
53
+ }
54
+ if (!Number.isFinite(refillPerSec) || refillPerSec <= 0) {
55
+ throw new Error('tokenBucketGate: refillPerSec must be a positive number')
56
+ }
57
+ if (typeof costFn !== 'function') {
58
+ throw new Error('tokenBucketGate: costFn must be a function')
59
+ }
60
+ return function tokenBucket(msg, ctx) {
61
+ const now = ctx.streamTimeMs
62
+ if (typeof ctx.state.tokens !== 'number') ctx.state.tokens = capacity
63
+ if (typeof ctx.state.lastRefillAt !== 'number') ctx.state.lastRefillAt = now
64
+ // Refill based on elapsed wall-clock time. Capped at capacity so we
65
+ // never exceed the configured burst.
66
+ const elapsedSec = Math.max(0, (now - ctx.state.lastRefillAt) / 1000)
67
+ ctx.state.tokens = Math.min(capacity, ctx.state.tokens + elapsedSec * refillPerSec)
68
+ ctx.state.lastRefillAt = now
69
+
70
+ let cost = costFn(msg)
71
+ if (!Number.isFinite(cost) || cost < 0) cost = 1
72
+ if (cost === 0) return allowZeroCost // never consume, never block
73
+
74
+ if (ctx.state.tokens >= cost) {
75
+ ctx.state.tokens -= cost
76
+ // Lightweight observability: the SDK persists ctx.state on ALLOW only,
77
+ // so these counters automatically reflect "what the bucket actually let
78
+ // through" and can be inspected via SQL on queen_streams.state.
79
+ ctx.state.allowedTotal = (ctx.state.allowedTotal || 0) + 1
80
+ ctx.state.consumedTotal = (ctx.state.consumedTotal || 0) + cost
81
+ return true
82
+ }
83
+ // On deny, ctx.state mutations are discarded by the runtime — so
84
+ // deniedSeen is "lost" and would only be useful if the runtime
85
+ // persisted on every eval. We leave it OUT here so callers don't
86
+ // misinterpret the persisted counters.
87
+ return false
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Sliding-window approximation gate (for "max N events in last W seconds"
93
+ * style limits — e.g. SendGrid daily quotas, OTA hourly quotas).
94
+ *
95
+ * IMPORTANT: this is NOT a precise sliding window. It's a 2-bucket
96
+ * approximation (current + previous) that's accurate within ±1× rate at
97
+ * the boundary. For exact sliding window, you'd need to keep per-event
98
+ * timestamps in state — usually overkill for rate-limiting purposes.
99
+ *
100
+ * @param {object} opts
101
+ * @param {number} opts.limit Max events per window.
102
+ * @param {number} opts.windowSec Window length in seconds.
103
+ * @param {(msg:any)=>number} [opts.costFn] Cost per event (default 1).
104
+ */
105
+ export function slidingWindowGate({ limit, windowSec, costFn = () => 1 } = {}) {
106
+ if (!Number.isFinite(limit) || limit <= 0) {
107
+ throw new Error('slidingWindowGate: limit must be a positive number')
108
+ }
109
+ if (!Number.isFinite(windowSec) || windowSec <= 0) {
110
+ throw new Error('slidingWindowGate: windowSec must be a positive number')
111
+ }
112
+ const windowMs = windowSec * 1000
113
+ return function slidingWindow(msg, ctx) {
114
+ const now = ctx.streamTimeMs
115
+ const currentWindow = Math.floor(now / windowMs)
116
+ const elapsedInWindow = (now % windowMs) / windowMs // 0..1
117
+
118
+ if (ctx.state.window !== currentWindow) {
119
+ // Roll: previous window becomes "previous", current resets.
120
+ ctx.state.previousCount = ctx.state.window === currentWindow - 1
121
+ ? (ctx.state.currentCount || 0)
122
+ : 0
123
+ ctx.state.currentCount = 0
124
+ ctx.state.window = currentWindow
125
+ }
126
+ // 2-bucket sliding estimate.
127
+ const estimated = (ctx.state.previousCount || 0) * (1 - elapsedInWindow)
128
+ + (ctx.state.currentCount || 0)
129
+ let cost = costFn(msg)
130
+ if (!Number.isFinite(cost) || cost < 0) cost = 1
131
+
132
+ if (estimated + cost <= limit) {
133
+ ctx.state.currentCount = (ctx.state.currentCount || 0) + cost
134
+ return true
135
+ }
136
+ return false
137
+ }
138
+ }