queen-mq 0.14.1 → 0.16.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -10
- package/client-v2/Queen.js +5 -2
- package/client-v2/README.md +21 -7
- 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/client-v2/utils/logger.js +50 -11
- package/package.json +15 -5
- 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
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Queen MQ is a PostgreSQL-backed message queue system with a powerful feature set
|
|
|
22
22
|
- **Consumer Groups** - Kafka-style consumer groups for scalability
|
|
23
23
|
- **Flexible Semantics** - Exactly-once, at-least-once, and at-most-once delivery
|
|
24
24
|
- **Transactions** - Atomic operations across push and ack
|
|
25
|
-
- **High Performance**
|
|
25
|
+
- **High Performance** — 104K msg/s push, 165K msg/s fan-out with consumer groups on a single 32-core node ([benchmarks](https://github.com/queen-mq/queen/tree/master/benchmark-queen/2026-04-26))
|
|
26
26
|
- **Subscription Modes** - Process from beginning, new messages only, or from timestamp
|
|
27
27
|
- **Dead Letter Queue** - Automatic failure handling and monitoring
|
|
28
28
|
- **Message Tracing** - Debug distributed workflows with trace timelines
|
|
@@ -179,7 +179,7 @@ const queen = new Queen({
|
|
|
179
179
|
urls: ['http://server1:6632', 'http://server2:6632'],
|
|
180
180
|
timeoutMillis: 30000,
|
|
181
181
|
retryAttempts: 3,
|
|
182
|
-
loadBalancingStrategy: '
|
|
182
|
+
loadBalancingStrategy: 'affinity', // or 'round-robin', 'session'
|
|
183
183
|
enableFailover: true
|
|
184
184
|
})
|
|
185
185
|
```
|
|
@@ -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
|
|
@@ -522,8 +564,10 @@ await queen.close() // Flush buffers and close connections
|
|
|
522
564
|
timeoutMillis: 30000,
|
|
523
565
|
retryAttempts: 3,
|
|
524
566
|
retryDelayMillis: 1000,
|
|
525
|
-
loadBalancingStrategy: 'round-robin'
|
|
526
|
-
|
|
567
|
+
loadBalancingStrategy: 'affinity', // 'affinity' | 'round-robin' | 'session'
|
|
568
|
+
affinityHashRing: 128,
|
|
569
|
+
enableFailover: true,
|
|
570
|
+
healthRetryAfterMillis: 5000
|
|
527
571
|
}
|
|
528
572
|
```
|
|
529
573
|
|
|
@@ -611,17 +655,18 @@ const messages: Message<OrderData>[] = await queen.queue('orders').pop()
|
|
|
611
655
|
|
|
612
656
|
## Documentation
|
|
613
657
|
|
|
614
|
-
- **[Complete V2 Guide](client-v2/README.md)**
|
|
615
|
-
- **[HTTP API Reference](https://github.com/
|
|
616
|
-
- **[Server Guide](https://github.com/
|
|
617
|
-
- **[Architecture
|
|
658
|
+
- **[Complete V2 Guide](client-v2/README.md)** — full tutorial with all features
|
|
659
|
+
- **[HTTP API Reference](https://github.com/queen-mq/queen/blob/master/server/API.md)** — raw HTTP endpoints
|
|
660
|
+
- **[Server Guide](https://github.com/queen-mq/queen/blob/master/server/README.md)** — server setup and configuration
|
|
661
|
+
- **[Architecture & internals](https://queenmq.com/architecture.html)** — published architecture overview
|
|
662
|
+
- **[libqueen design notes](https://github.com/queen-mq/queen/blob/master/cdocs/LIBQUEEN_IMPROVEMENTS.md)** — adaptive engine deep-dive
|
|
618
663
|
|
|
619
664
|
---
|
|
620
665
|
|
|
621
666
|
## Support
|
|
622
667
|
|
|
623
|
-
- **GitHub:** [
|
|
624
|
-
- **Issues:** [GitHub Issues](https://github.com/
|
|
668
|
+
- **GitHub:** [queen-mq/queen](https://github.com/queen-mq/queen)
|
|
669
|
+
- **Issues:** [GitHub Issues](https://github.com/queen-mq/queen/issues)
|
|
625
670
|
- **LinkedIn:** [Smartness](https://www.linkedin.com/company/smartness-com/)
|
|
626
671
|
|
|
627
672
|
---
|
package/client-v2/Queen.js
CHANGED
|
@@ -23,9 +23,12 @@ export class Queen {
|
|
|
23
23
|
#admin = null
|
|
24
24
|
|
|
25
25
|
constructor(config = {}) {
|
|
26
|
-
// Configure custom logger before anything else
|
|
26
|
+
// Configure custom logger before anything else.
|
|
27
|
+
// Opt into structured field logging with `structuredLogs: true` (the logger
|
|
28
|
+
// must accept a leading merge object, e.g. pino/bunyan); defaults to the
|
|
29
|
+
// backward-compatible single-string format.
|
|
27
30
|
if (config && typeof config === 'object' && !Array.isArray(config) && config.logger) {
|
|
28
|
-
logger.configure(config.logger)
|
|
31
|
+
logger.configure(config.logger, { structured: config.structuredLogs === true })
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
logger.log('Queen.constructor', { config: typeof config === 'object' && !Array.isArray(config) ? { ...config, urls: config.urls?.length || 0 } : { type: typeof config } })
|
package/client-v2/README.md
CHANGED
|
@@ -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
|
|
|
@@ -1777,11 +1788,13 @@ await queen.close()
|
|
|
1777
1788
|
### Client Defaults
|
|
1778
1789
|
```javascript
|
|
1779
1790
|
{
|
|
1780
|
-
timeoutMillis: 30000,
|
|
1791
|
+
timeoutMillis: 30000, // 30 seconds
|
|
1781
1792
|
retryAttempts: 3,
|
|
1782
1793
|
retryDelayMillis: 1000,
|
|
1783
|
-
loadBalancingStrategy: 'round-robin'
|
|
1784
|
-
|
|
1794
|
+
loadBalancingStrategy: 'affinity', // 'affinity' | 'round-robin' | 'session'
|
|
1795
|
+
affinityHashRing: 128,
|
|
1796
|
+
enableFailover: true,
|
|
1797
|
+
healthRetryAfterMillis: 5000
|
|
1785
1798
|
}
|
|
1786
1799
|
```
|
|
1787
1800
|
|
|
@@ -1863,7 +1876,7 @@ Example log output:
|
|
|
1863
1876
|
Here's a complete example showing many features together:
|
|
1864
1877
|
|
|
1865
1878
|
```javascript
|
|
1866
|
-
import { Queen } from './
|
|
1879
|
+
import { Queen } from './index.js'
|
|
1867
1880
|
|
|
1868
1881
|
const queen = new Queen('http://localhost:6632')
|
|
1869
1882
|
|
|
@@ -1982,9 +1995,10 @@ process.on('SIGINT', async () => {
|
|
|
1982
1995
|
You now know everything about Queen v2! 🎉
|
|
1983
1996
|
|
|
1984
1997
|
**Additional resources:**
|
|
1985
|
-
- [API Documentation](
|
|
1986
|
-
- [Test Examples](../test-v2/)
|
|
1987
|
-
- [Architecture
|
|
1998
|
+
- [API Documentation](../../../server/API.md) — complete HTTP API reference
|
|
1999
|
+
- [Test Examples](../test-v2/) — runnable test cases
|
|
2000
|
+
- [Architecture overview](https://queenmq.com/architecture.html) — published deep-dive
|
|
2001
|
+
- [libqueen design notes](../../../cdocs/LIBQUEEN_IMPROVEMENTS.md) — server internals
|
|
1988
2002
|
|
|
1989
2003
|
**Need help?** Check out the test files in `test-v2/` - they're full of working examples!
|
|
1990
2004
|
|
|
@@ -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
|
+
}
|