queen-mq 0.14.0 → 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.
- package/client-v2/builders/QueueBuilder.js +10 -0
- package/client-v2/index.js +15 -0
- package/client-v2/streams/Stream.js +392 -0
- package/client-v2/streams/helpers/rateLimiter.js +138 -0
- package/client-v2/streams/operators/AggregateOperator.js +87 -0
- package/client-v2/streams/operators/FilterOperator.js +18 -0
- package/client-v2/streams/operators/FlatMapOperator.js +21 -0
- package/client-v2/streams/operators/ForeachOperator.js +29 -0
- package/client-v2/streams/operators/GateOperator.js +85 -0
- package/client-v2/streams/operators/KeyByOperator.js +23 -0
- package/client-v2/streams/operators/MapOperator.js +27 -0
- package/client-v2/streams/operators/ReduceOperator.js +196 -0
- package/client-v2/streams/operators/SinkOperator.js +54 -0
- package/client-v2/streams/operators/WindowCronOperator.js +83 -0
- package/client-v2/streams/operators/WindowSessionOperator.js +223 -0
- package/client-v2/streams/operators/WindowSlidingOperator.js +103 -0
- package/client-v2/streams/operators/WindowTumblingOperator.js +94 -0
- package/client-v2/streams/operators/_windowCommon.js +77 -0
- package/client-v2/streams/operators/index.js +18 -0
- package/client-v2/streams/runtime/Runner.js +955 -0
- package/client-v2/streams/runtime/StreamsHttpClient.js +83 -0
- package/client-v2/streams/runtime/cycle.js +36 -0
- package/client-v2/streams/runtime/register.js +48 -0
- package/client-v2/streams/runtime/state.js +33 -0
- package/client-v2/streams/util/backoff.js +23 -0
- package/client-v2/streams/util/configHash.js +33 -0
- package/client-v2/streams/util/logger.js +29 -0
- package/package.json +14 -4
- package/test-v2/run.js +26 -2
- package/test-v2/stream/_helpers.js +190 -0
- package/test-v2/stream/combined.js +148 -0
- package/test-v2/stream/cron.js +98 -0
- package/test-v2/stream/eventTime.js +220 -0
- package/test-v2/stream/index.js +25 -0
- package/test-v2/stream/operators.js +207 -0
- package/test-v2/stream/recovery.js +141 -0
- package/test-v2/stream/session.js +144 -0
- package/test-v2/stream/sliding.js +75 -0
- package/test-v2/stream/throughput.js +153 -0
- package/test-v2/stream/tumbling.js +315 -0
- package/test-v2/streams-unit/configHash.test.js +102 -0
- package/test-v2/streams-unit/cycle.test.js +200 -0
- package/test-v2/streams-unit/e2e.test.js +221 -0
- package/test-v2/streams-unit/eventTime.test.js +149 -0
- package/test-v2/streams-unit/fakeServer.js +165 -0
- package/test-v2/streams-unit/operators.test.js +476 -0
|
@@ -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
|
// ===========================
|
package/client-v2/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AggregateOperator — sugar over reduce. Each named extractor produces one
|
|
3
|
+
* field of the per-window aggregate value.
|
|
4
|
+
*
|
|
5
|
+
* Supported extractor names:
|
|
6
|
+
* count: () => 1 // scalar count contribution
|
|
7
|
+
* sum: m => m.data.amount // running sum
|
|
8
|
+
* min: m => m.data.value // running min (ignores nulls)
|
|
9
|
+
* max: m => m.data.value // running max (ignores nulls)
|
|
10
|
+
* avg: m => m.data.value // running average (sum + count internally)
|
|
11
|
+
*
|
|
12
|
+
* Output shape per window:
|
|
13
|
+
* { count?: number, sum?: number, min?: number, max?: number, avg?: number, ... }
|
|
14
|
+
*
|
|
15
|
+
* Custom keys (any name not in the list above) are also supported and
|
|
16
|
+
* treated as `sum`-like (numeric running total). Use plain `.reduce(...)`
|
|
17
|
+
* if you need non-additive aggregates.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ReduceOperator } from './ReduceOperator.js'
|
|
21
|
+
|
|
22
|
+
const RESERVED = new Set(['count', 'sum', 'min', 'max', 'avg'])
|
|
23
|
+
|
|
24
|
+
export class AggregateOperator extends ReduceOperator {
|
|
25
|
+
constructor(extractors) {
|
|
26
|
+
if (!extractors || typeof extractors !== 'object') {
|
|
27
|
+
throw new Error('aggregate requires an object of named extractors')
|
|
28
|
+
}
|
|
29
|
+
const fields = Object.keys(extractors).map(name => {
|
|
30
|
+
const fn = extractors[name]
|
|
31
|
+
if (typeof fn !== 'function') {
|
|
32
|
+
throw new Error(`aggregate.${name} must be a function`)
|
|
33
|
+
}
|
|
34
|
+
return { name, fn }
|
|
35
|
+
})
|
|
36
|
+
if (fields.length === 0) {
|
|
37
|
+
throw new Error('aggregate requires at least one extractor')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const initial = {}
|
|
41
|
+
for (const { name } of fields) {
|
|
42
|
+
if (name === 'min') initial[name] = null
|
|
43
|
+
else if (name === 'max') initial[name] = null
|
|
44
|
+
else if (name === 'avg') { initial.__avg_sum = 0; initial.__avg_count = 0; initial.avg = 0 }
|
|
45
|
+
else initial[name] = 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fn = async (acc, msg) => {
|
|
49
|
+
const next = { ...acc }
|
|
50
|
+
for (const { name, fn: ex } of fields) {
|
|
51
|
+
const v = await ex(msg)
|
|
52
|
+
if (name === 'count') {
|
|
53
|
+
next.count = (next.count || 0) + (typeof v === 'number' ? v : 1)
|
|
54
|
+
} else if (name === 'sum') {
|
|
55
|
+
next.sum = (next.sum || 0) + (typeof v === 'number' ? v : 0)
|
|
56
|
+
} else if (name === 'min') {
|
|
57
|
+
if (typeof v === 'number') {
|
|
58
|
+
next.min = next.min === null || next.min === undefined ? v : Math.min(next.min, v)
|
|
59
|
+
}
|
|
60
|
+
} else if (name === 'max') {
|
|
61
|
+
if (typeof v === 'number') {
|
|
62
|
+
next.max = next.max === null || next.max === undefined ? v : Math.max(next.max, v)
|
|
63
|
+
}
|
|
64
|
+
} else if (name === 'avg') {
|
|
65
|
+
if (typeof v === 'number') {
|
|
66
|
+
next.__avg_sum = (next.__avg_sum || 0) + v
|
|
67
|
+
next.__avg_count = (next.__avg_count || 0) + 1
|
|
68
|
+
next.avg = next.__avg_count > 0 ? next.__avg_sum / next.__avg_count : 0
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// Custom user-named field; treat as numeric running total.
|
|
72
|
+
next[name] = (next[name] || 0) + (typeof v === 'number' ? v : 0)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return next
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
super(fn, initial)
|
|
79
|
+
this.kind = 'aggregate'
|
|
80
|
+
this.fields = fields.map(f => f.name)
|
|
81
|
+
// For configHash: expose the extractor field names + presence of reserved keys.
|
|
82
|
+
this.config = { fields: fields.map(f => f.name) }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Re-export the constant in case downstream wants the supported list.
|
|
87
|
+
export const RESERVED_AGGREGATE_FIELDS = Array.from(RESERVED)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterOperator — drop envelopes where predicate returns false.
|
|
3
|
+
*
|
|
4
|
+
* The predicate sees the original Queen `msg` (msg.data, msg.partitionId, ...)
|
|
5
|
+
* to keep filtering decisions tied to the source record, not to a possibly
|
|
6
|
+
* already-mapped value.
|
|
7
|
+
*/
|
|
8
|
+
export class FilterOperator {
|
|
9
|
+
constructor(predicate) {
|
|
10
|
+
this.kind = 'filter'
|
|
11
|
+
this.fn = predicate
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async apply(envelope) {
|
|
15
|
+
const keep = await this.fn(envelope.msg, envelope.ctx)
|
|
16
|
+
return keep ? [envelope] : []
|
|
17
|
+
}
|
|
18
|
+
}
|