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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlatMapOperator — emit zero or more values per input record.
|
|
3
|
+
*
|
|
4
|
+
* The user fn returns an array of values; the operator wraps each in an
|
|
5
|
+
* envelope sharing the source message's metadata (so subsequent operators
|
|
6
|
+
* still see the original partition_id etc.).
|
|
7
|
+
*/
|
|
8
|
+
export class FlatMapOperator {
|
|
9
|
+
constructor(fn) {
|
|
10
|
+
this.kind = 'flatMap'
|
|
11
|
+
this.fn = fn
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async apply(envelope) {
|
|
15
|
+
const out = await this.fn(envelope.msg, envelope.ctx)
|
|
16
|
+
if (!Array.isArray(out)) {
|
|
17
|
+
throw new Error('flatMap fn must return an array (got ' + typeof out + ')')
|
|
18
|
+
}
|
|
19
|
+
return out.map(value => ({ ...envelope, value }))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ForeachOperator — terminal at-least-once side-effect.
|
|
3
|
+
*
|
|
4
|
+
* The runtime invokes the user fn for each emitted record after state has
|
|
5
|
+
* been computed but BEFORE the cycle commits the source ack. If `fn`
|
|
6
|
+
* resolves successfully for all records, the cycle commits as normal. If
|
|
7
|
+
* `fn` throws, the cycle is aborted and the messages will be redelivered
|
|
8
|
+
* by Queen's lease/retry mechanism.
|
|
9
|
+
*
|
|
10
|
+
* User-fn signature: `(value, ctx?) => void|Promise<void>`
|
|
11
|
+
* value: the post-reducer value (e.g. an aggregate { count, sum, ... })
|
|
12
|
+
* ctx: { partition, partitionId, key, windowKey, windowStart, windowEnd }
|
|
13
|
+
*
|
|
14
|
+
* For exactly-once external effects, use `.to(queue)` and have a separate
|
|
15
|
+
* worker drain the sink queue with a transactional ack.
|
|
16
|
+
*/
|
|
17
|
+
export class ForeachOperator {
|
|
18
|
+
constructor(fn) {
|
|
19
|
+
this.kind = 'foreach'
|
|
20
|
+
this.fn = fn
|
|
21
|
+
this.config = { kind: 'foreach' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async runEffects(entries) {
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
await this.fn(entry.value, entry.ctx)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GateOperator — per-message ALLOW/DENY decision with persistent per-key state.
|
|
3
|
+
*
|
|
4
|
+
* Used to build rate limiters, throttlers, fairness gates, circuit breakers,
|
|
5
|
+
* and any other "should this message proceed RIGHT NOW?" pattern.
|
|
6
|
+
*
|
|
7
|
+
* Semantics
|
|
8
|
+
* ---------
|
|
9
|
+
* The runtime processes a popped batch sequentially in source order. For each
|
|
10
|
+
* message, it builds a `ctx` object with mutable `state` (loaded from
|
|
11
|
+
* `queen_streams.state` for the message's key) and invokes the user fn.
|
|
12
|
+
*
|
|
13
|
+
* .gate((msg, ctx) => boolean)
|
|
14
|
+
*
|
|
15
|
+
* Returning `true` (or `{allow:true}`):
|
|
16
|
+
* - The mutated `ctx.state` is persisted as part of the cycle.
|
|
17
|
+
* - Downstream operators (sink, foreach) receive this message.
|
|
18
|
+
* - The message counts toward the cycle's ack count.
|
|
19
|
+
*
|
|
20
|
+
* Returning `false` (or `{allow:false}`):
|
|
21
|
+
* - The mutated state is DISCARDED for this message (it didn't "happen").
|
|
22
|
+
* - The runner stops processing the rest of the batch.
|
|
23
|
+
* - The cycle commits with `release_lease=false` and `ack.count = K`
|
|
24
|
+
* (the count of allowed messages BEFORE this one).
|
|
25
|
+
* - The message and its successors stay leased until natural lease
|
|
26
|
+
* expiry; the broker then redelivers them to a runner in their
|
|
27
|
+
* original partition order.
|
|
28
|
+
*
|
|
29
|
+
* If K === 0 (the very first message is denied), the runner skips the
|
|
30
|
+
* cycle entirely — no state, no push, no ack — and the lease times out
|
|
31
|
+
* naturally.
|
|
32
|
+
*
|
|
33
|
+
* Ordering
|
|
34
|
+
* --------
|
|
35
|
+
* FIFO per-partition is preserved by construction: the same partition lease
|
|
36
|
+
* is held continuously across the deny → expiry → redeliver cycle, so the
|
|
37
|
+
* denied messages never overtake messages that arrived after them.
|
|
38
|
+
*
|
|
39
|
+
* State key
|
|
40
|
+
* ---------
|
|
41
|
+
* State is keyed by the envelope's `key` (typically the source partition_id
|
|
42
|
+
* unless `.keyBy()` overrides it). For a rate limiter, the natural setup is:
|
|
43
|
+
* - source queue partitioned by the rate-limit key (e.g. tenantId)
|
|
44
|
+
* - no .keyBy() needed (key defaults to partitionId)
|
|
45
|
+
*
|
|
46
|
+
* If the key DIVERGES from the partition (cross-partition keying), the same
|
|
47
|
+
* cross-worker contention warning that applies to .reduce() applies here too:
|
|
48
|
+
* two workers may hold leases for different partitions but write the same
|
|
49
|
+
* (query_id, partition_id, key) row. For rate limiting this is almost always
|
|
50
|
+
* a configuration mistake — keep partition == limit key.
|
|
51
|
+
*
|
|
52
|
+
* Why a dedicated operator
|
|
53
|
+
* ------------------------
|
|
54
|
+
* The "stop the batch + don't release the lease" semantics has no analog in
|
|
55
|
+
* the existing operators (.map / .filter / .reduce all process the full batch).
|
|
56
|
+
* Modelling it as a return-value side effect of a regular .map would be
|
|
57
|
+
* surprising; a dedicated `.gate()` makes the intent and the cycle behaviour
|
|
58
|
+
* explicit at the chain site.
|
|
59
|
+
*/
|
|
60
|
+
export class GateOperator {
|
|
61
|
+
constructor(fn) {
|
|
62
|
+
if (typeof fn !== 'function') {
|
|
63
|
+
throw new Error('.gate(fn) requires a function')
|
|
64
|
+
}
|
|
65
|
+
this.kind = 'gate'
|
|
66
|
+
this.fn = fn
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Evaluate the gate for a single envelope. Called by the runtime, not by
|
|
71
|
+
* other operators. Returns `{ allow: boolean }`.
|
|
72
|
+
*
|
|
73
|
+
* The user fn receives `(msg, ctx)` where ctx.state is mutable and ctx
|
|
74
|
+
* also carries `streamTimeMs` (system clock) and `partitionId` for
|
|
75
|
+
* observability / time-based bucket math.
|
|
76
|
+
*/
|
|
77
|
+
async evaluate(envelope, ctx) {
|
|
78
|
+
const out = await this.fn(envelope.value, ctx)
|
|
79
|
+
if (out === true || out === false) return { allow: out }
|
|
80
|
+
if (out && typeof out === 'object' && typeof out.allow === 'boolean') {
|
|
81
|
+
return { allow: out.allow }
|
|
82
|
+
}
|
|
83
|
+
throw new Error('.gate(fn) must return boolean or { allow: boolean }')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KeyByOperator — override the implicit partition-key with a user-derived
|
|
3
|
+
* key for downstream stateful operators (windowTumbling/reduce/aggregate).
|
|
4
|
+
*
|
|
5
|
+
* The default key is the source partition_id, which gives per-partition
|
|
6
|
+
* state isolation with zero cross-worker contention. .keyBy(...) overrides
|
|
7
|
+
* to a different value per record. The Runner will warn at start time if
|
|
8
|
+
* the chain's logical key consistently differs from the partition key,
|
|
9
|
+
* because that means multiple workers may write the same logical key from
|
|
10
|
+
* different partition leases — i.e. cross-worker contention on
|
|
11
|
+
* (query_id, partition_id, key) state rows.
|
|
12
|
+
*/
|
|
13
|
+
export class KeyByOperator {
|
|
14
|
+
constructor(fn) {
|
|
15
|
+
this.kind = 'keyBy'
|
|
16
|
+
this.fn = fn
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async apply(envelope) {
|
|
20
|
+
const key = String(await this.fn(envelope.msg))
|
|
21
|
+
return [{ ...envelope, key }]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MapOperator — transform each input record. Sync or async.
|
|
3
|
+
*
|
|
4
|
+
* Operates on the runtime "envelope" object:
|
|
5
|
+
* { msg, key, value, ctx? }
|
|
6
|
+
*
|
|
7
|
+
* User-fn signature: `(msg, ctx?) => newValue`
|
|
8
|
+
*
|
|
9
|
+
* Pre-reducer:
|
|
10
|
+
* msg = the original Queen message (with .data, .partitionId, ...)
|
|
11
|
+
* ctx = undefined (Queen msg already carries the metadata)
|
|
12
|
+
*
|
|
13
|
+
* Post-reducer (after .windowTumbling + .reduce/.aggregate):
|
|
14
|
+
* msg = the aggregated value (e.g. { count, sum, min, max })
|
|
15
|
+
* ctx = { partition, partitionId, key, windowKey, windowStart, windowEnd }
|
|
16
|
+
*/
|
|
17
|
+
export class MapOperator {
|
|
18
|
+
constructor(fn) {
|
|
19
|
+
this.kind = 'map'
|
|
20
|
+
this.fn = fn
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async apply(envelope) {
|
|
24
|
+
const value = await this.fn(envelope.msg, envelope.ctx)
|
|
25
|
+
return [{ ...envelope, value }]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReduceOperator — fold envelopes within a window into a single value.
|
|
3
|
+
*
|
|
4
|
+
* Operates over a *batch* of envelopes (called once per cycle, after
|
|
5
|
+
* stateless ops + window annotation). Returns:
|
|
6
|
+
* - state mutations (upserts for still-open windows, deletes for closed)
|
|
7
|
+
* - emit values (closed windows' final accumulator) for sink/foreach
|
|
8
|
+
*
|
|
9
|
+
* The reducer expects each envelope to carry `key`, `windowStart`,
|
|
10
|
+
* `windowEnd`, and `windowKey` (from upstream KeyBy + WindowTumbling).
|
|
11
|
+
*
|
|
12
|
+
* Window-close semantics: a window is treated as closed when its
|
|
13
|
+
* windowEnd <= maxWindowEnd in this cycle's batch. Any window equal to
|
|
14
|
+
* maxWindowEnd is still open (more events may arrive on the next cycle).
|
|
15
|
+
*/
|
|
16
|
+
export class ReduceOperator {
|
|
17
|
+
constructor(fn, initial) {
|
|
18
|
+
this.kind = 'reduce'
|
|
19
|
+
this.fn = fn
|
|
20
|
+
this.initial = initial
|
|
21
|
+
this.config = { hasInitial: initial !== undefined }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run the reducer over a batch of envelopes that all share the same
|
|
26
|
+
* source partition_id (the cycle's scope).
|
|
27
|
+
*
|
|
28
|
+
* @param {{
|
|
29
|
+
* envelopes: Array<{
|
|
30
|
+
* key:string, windowStart:number, windowEnd:number, windowKey:string,
|
|
31
|
+
* value:any, operatorTag?:string, gracePeriodMs?:number
|
|
32
|
+
* }>,
|
|
33
|
+
* loadedState: Map<string, any>, // state-key -> stored accumulator
|
|
34
|
+
* streamTimeMs?: number, // partition clock (max createdAt of batch)
|
|
35
|
+
* operatorTag?: string, // window operator's structural tag
|
|
36
|
+
* gracePeriodMs?: number, // accept events for windowEnd + grace
|
|
37
|
+
* ignoreUnowned?: boolean // skip seeded rows whose tag doesn't match
|
|
38
|
+
* }} ctx
|
|
39
|
+
* @returns {Promise<{
|
|
40
|
+
* stateOps: Array<{type:'upsert'|'delete',key:string,value?:any}>,
|
|
41
|
+
* emits: Array<{key:string, windowStart:number, windowKey:string, value:any}>
|
|
42
|
+
* }>}
|
|
43
|
+
*
|
|
44
|
+
* Window-close semantics
|
|
45
|
+
* ----------------------
|
|
46
|
+
* A window is closed when the partition's stream time has advanced past
|
|
47
|
+
* its windowEnd plus the grace period:
|
|
48
|
+
*
|
|
49
|
+
* closed = (windowEnd + gracePeriodMs) <= effectiveStreamTime
|
|
50
|
+
*
|
|
51
|
+
* The caller seeds `loadedState` with ALL open windows for this partition
|
|
52
|
+
* (Runner loads via /streams/v1/state/get with keys=[]) so windows which
|
|
53
|
+
* received no traffic in this batch can still be detected as closed and
|
|
54
|
+
* flushed.
|
|
55
|
+
*
|
|
56
|
+
* State key shape (v0.2+): `${operatorTag}\u001f${windowKey}\u001f${userKey}`.
|
|
57
|
+
* Seeded state rows whose operatorTag doesn't match this reducer's are
|
|
58
|
+
* left untouched (they belong to a different window operator in the same
|
|
59
|
+
* partition shard, or are reserved internal keys like `__wm__`).
|
|
60
|
+
*/
|
|
61
|
+
async run({ envelopes, loadedState, streamTimeMs, operatorTag = '', gracePeriodMs = 0, ignoreUnowned = true }) {
|
|
62
|
+
// accumulators[stateKey] = { key, windowStart, windowEnd, windowKey, acc, touched, seeded }
|
|
63
|
+
const accumulators = new Map()
|
|
64
|
+
|
|
65
|
+
// Seed accumulators with every loaded state row owned by THIS operator.
|
|
66
|
+
// Rows owned by other operators (or internal keys like __wm__) are
|
|
67
|
+
// skipped — they're not ours to close or upsert.
|
|
68
|
+
for (const [stateKey, value] of loadedState.entries()) {
|
|
69
|
+
const parts = parseStateKey(stateKey)
|
|
70
|
+
if (!parts) continue
|
|
71
|
+
// Skip reserved internal keys.
|
|
72
|
+
if (parts.operatorTag.startsWith('__')) continue
|
|
73
|
+
// Skip rows owned by a different operator.
|
|
74
|
+
if (ignoreUnowned && operatorTag && parts.operatorTag !== operatorTag) continue
|
|
75
|
+
|
|
76
|
+
const ws = value && typeof value.windowStart === 'number'
|
|
77
|
+
? value.windowStart
|
|
78
|
+
: Date.parse(parts.windowKey) || 0
|
|
79
|
+
const we = value && typeof value.windowEnd === 'number'
|
|
80
|
+
? value.windowEnd
|
|
81
|
+
: ws
|
|
82
|
+
accumulators.set(stateKey, {
|
|
83
|
+
key: parts.userKey,
|
|
84
|
+
windowStart: ws,
|
|
85
|
+
windowEnd: we,
|
|
86
|
+
windowKey: parts.windowKey,
|
|
87
|
+
acc: value && value.acc !== undefined ? value.acc : value,
|
|
88
|
+
touched: false,
|
|
89
|
+
seeded: true
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Apply this batch's envelopes onto matching accumulators.
|
|
94
|
+
let batchMaxWindowStart = -Infinity
|
|
95
|
+
for (const env of envelopes) {
|
|
96
|
+
const tag = env.operatorTag || operatorTag || ''
|
|
97
|
+
const stateKey = stateKeyFor(tag, env.windowKey, env.key)
|
|
98
|
+
let entry = accumulators.get(stateKey)
|
|
99
|
+
if (!entry) {
|
|
100
|
+
entry = {
|
|
101
|
+
key: env.key,
|
|
102
|
+
windowStart: env.windowStart,
|
|
103
|
+
windowEnd: env.windowEnd,
|
|
104
|
+
windowKey: env.windowKey,
|
|
105
|
+
acc: this._initial(),
|
|
106
|
+
touched: false,
|
|
107
|
+
seeded: false
|
|
108
|
+
}
|
|
109
|
+
accumulators.set(stateKey, entry)
|
|
110
|
+
}
|
|
111
|
+
entry.acc = await this.fn(entry.acc, env.value !== undefined ? env.value : env.msg)
|
|
112
|
+
entry.touched = true
|
|
113
|
+
if (env.windowStart > batchMaxWindowStart) batchMaxWindowStart = env.windowStart
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Stream time = caller-provided clock OR the latest batch window's
|
|
117
|
+
// start as a conservative fallback (keeps the latest window open).
|
|
118
|
+
const effectiveStreamTime = typeof streamTimeMs === 'number' && streamTimeMs > -Infinity
|
|
119
|
+
? streamTimeMs
|
|
120
|
+
: batchMaxWindowStart
|
|
121
|
+
|
|
122
|
+
const stateOps = []
|
|
123
|
+
const emits = []
|
|
124
|
+
for (const [stateKey, entry] of accumulators.entries()) {
|
|
125
|
+
const closed = (entry.windowEnd + gracePeriodMs) <= effectiveStreamTime
|
|
126
|
+
if (closed) {
|
|
127
|
+
emits.push({
|
|
128
|
+
key: entry.key,
|
|
129
|
+
windowStart: entry.windowStart,
|
|
130
|
+
windowEnd: entry.windowEnd,
|
|
131
|
+
windowKey: entry.windowKey,
|
|
132
|
+
value: entry.acc
|
|
133
|
+
})
|
|
134
|
+
if (entry.seeded) {
|
|
135
|
+
stateOps.push({ type: 'delete', key: stateKey })
|
|
136
|
+
}
|
|
137
|
+
} else if (entry.touched) {
|
|
138
|
+
stateOps.push({
|
|
139
|
+
type: 'upsert',
|
|
140
|
+
key: stateKey,
|
|
141
|
+
value: { acc: entry.acc, windowStart: entry.windowStart, windowEnd: entry.windowEnd }
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { stateOps, emits }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_initial() {
|
|
149
|
+
if (this.initial === undefined) return undefined
|
|
150
|
+
// Deep clone so each window starts fresh
|
|
151
|
+
if (this.initial === null || typeof this.initial !== 'object') return this.initial
|
|
152
|
+
return JSON.parse(JSON.stringify(this.initial))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build a state key for a windowed reducer.
|
|
158
|
+
*
|
|
159
|
+
* Shape (v0.2+):
|
|
160
|
+
* `${operatorTag}\u001f${windowKey}\u001f${userKey}`
|
|
161
|
+
*
|
|
162
|
+
* The `operatorTag` (e.g. "tumb:60", "slide:60:10", "sess:30", "cron:minute")
|
|
163
|
+
* lets multiple window operators within one query coexist in the same
|
|
164
|
+
* `queen_streams.state` table without colliding, and lets the idle-flush
|
|
165
|
+
* scan filter to a single operator's keys via prefix LIKE matching.
|
|
166
|
+
*
|
|
167
|
+
* Reserved key prefix: state keys starting with "__" are internal (e.g.
|
|
168
|
+
* "__wm__" for per-partition watermarks). User code should never write
|
|
169
|
+
* keys starting with "__".
|
|
170
|
+
*/
|
|
171
|
+
export function stateKeyFor(operatorTag, windowKey, userKey) {
|
|
172
|
+
// Backwards-compat shim: if called with two arguments (the v0.1 shape),
|
|
173
|
+
// route through with a synthetic "legacy" tag so existing tests keep
|
|
174
|
+
// working until they explicitly pass an operatorTag.
|
|
175
|
+
if (userKey === undefined) {
|
|
176
|
+
return `${operatorTag}\u001f${windowKey}`
|
|
177
|
+
}
|
|
178
|
+
return `${operatorTag}\u001f${windowKey}\u001f${userKey}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Decompose a state key into its parts. Returns null if the key doesn't
|
|
183
|
+
* match the expected shape.
|
|
184
|
+
*/
|
|
185
|
+
export function parseStateKey(stateKey) {
|
|
186
|
+
if (typeof stateKey !== 'string') return null
|
|
187
|
+
const parts = stateKey.split('\u001f')
|
|
188
|
+
if (parts.length === 3) {
|
|
189
|
+
return { operatorTag: parts[0], windowKey: parts[1], userKey: parts[2] }
|
|
190
|
+
}
|
|
191
|
+
if (parts.length === 2) {
|
|
192
|
+
// Legacy v0.1 shape: ${windowKey}\u001f${userKey}
|
|
193
|
+
return { operatorTag: '', windowKey: parts[0], userKey: parts[1] }
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SinkOperator — terminal `.to(queue)`. Records flowing into the sink are
|
|
3
|
+
* pushed onto the destination Queen queue as part of the cycle commit.
|
|
4
|
+
*
|
|
5
|
+
* The runtime is responsible for shaping each sink push item:
|
|
6
|
+
* { queue, partition?, payload }
|
|
7
|
+
*
|
|
8
|
+
* If the sink has a partition function (opts.partition), it's called with
|
|
9
|
+
* the value to derive the partition name; otherwise the source partition
|
|
10
|
+
* name is reused (preserves entity-level co-partitioning by default).
|
|
11
|
+
*/
|
|
12
|
+
export class SinkOperator {
|
|
13
|
+
constructor(queueBuilder, opts = {}) {
|
|
14
|
+
this.kind = 'sink'
|
|
15
|
+
this.queueBuilder = queueBuilder
|
|
16
|
+
this.queueName = extractQueueName(queueBuilder)
|
|
17
|
+
this.partitionResolver = opts.partition
|
|
18
|
+
this.config = { kind: 'sink', queue: this.queueName }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build sink push items from a list of {key, value, sourceMsg} entries.
|
|
23
|
+
* Called by the Runner after upstream operators have produced final values.
|
|
24
|
+
*/
|
|
25
|
+
buildPushItems(entries, sourcePartitionName) {
|
|
26
|
+
return entries.map(({ value, sourceMsg }) => ({
|
|
27
|
+
queue: this.queueName,
|
|
28
|
+
partition: this._resolvePartition(value, sourcePartitionName),
|
|
29
|
+
payload: value === null || typeof value !== 'object' ? { value } : value
|
|
30
|
+
}))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_resolvePartition(value, sourcePartitionName) {
|
|
34
|
+
if (typeof this.partitionResolver === 'function') {
|
|
35
|
+
return String(this.partitionResolver(value))
|
|
36
|
+
}
|
|
37
|
+
if (typeof this.partitionResolver === 'string') {
|
|
38
|
+
return this.partitionResolver
|
|
39
|
+
}
|
|
40
|
+
return sourcePartitionName || 'Default'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractQueueName(qb) {
|
|
45
|
+
// QueueBuilder.name (getter) is the canonical accessor since v0.14.x.
|
|
46
|
+
// Test fakes and raw objects fall back to _queueName / queueName fields.
|
|
47
|
+
if (qb && typeof qb.name === 'string' && qb.name.length > 0) return qb.name
|
|
48
|
+
if (qb && typeof qb._queueName === 'string') return qb._queueName
|
|
49
|
+
if (qb && typeof qb.queueName === 'string') return qb.queueName
|
|
50
|
+
throw new Error(
|
|
51
|
+
'Could not extract queue name from sink target. ' +
|
|
52
|
+
'Pass either a queen.queue("name") builder or { queueName: "name" }.'
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WindowCronOperator — wall-clock-aligned tumbling windows.
|
|
3
|
+
*
|
|
4
|
+
* Bucket events by absolute UTC boundaries (minute-on-the-minute,
|
|
5
|
+
* hour-on-the-hour, day at 00:00 UTC, week starting Mon 00:00 UTC).
|
|
6
|
+
* Equivalent to WindowTumblingOperator with a fixed size, but the boundary
|
|
7
|
+
* is anchored to wall-clock instead of relative-to-epoch arithmetic.
|
|
8
|
+
*
|
|
9
|
+
* v0.2 ships only the `every:` shorthand; full cron expressions
|
|
10
|
+
* (`'*\/5 * * * *'`) are reserved for v0.3 and would add a dep.
|
|
11
|
+
*
|
|
12
|
+
* Config:
|
|
13
|
+
* every (str) 'second'|'minute'|'hour'|'day'|'week' (required)
|
|
14
|
+
* gracePeriod (sec) accept events for windowEnd + grace before closing
|
|
15
|
+
* idleFlushMs (ms) ripe-window flush cadence; default 30_000
|
|
16
|
+
* eventTime, allowedLateness, onLate — same as the other window operators
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { extractEventTime, normaliseLateOpts, everyToMs, MS_PER_SEC } from './_windowCommon.js'
|
|
20
|
+
|
|
21
|
+
const WEEK_EPOCH_OFFSET_MS = 4 * 86_400 * 1000 // Jan 1 1970 UTC was Thursday;
|
|
22
|
+
// shift so weeks align to Monday 00:00 UTC
|
|
23
|
+
|
|
24
|
+
export class WindowCronOperator {
|
|
25
|
+
constructor({
|
|
26
|
+
every,
|
|
27
|
+
gracePeriod = 0,
|
|
28
|
+
idleFlushMs = 30_000,
|
|
29
|
+
eventTime,
|
|
30
|
+
allowedLateness = 0,
|
|
31
|
+
onLate
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (typeof every !== 'string') {
|
|
34
|
+
throw new Error("windowCron requires { every: 'second'|'minute'|'hour'|'day'|'week' }")
|
|
35
|
+
}
|
|
36
|
+
this.kind = 'window'
|
|
37
|
+
this.windowKind = 'cron'
|
|
38
|
+
this.every = every
|
|
39
|
+
this.windowMs = everyToMs(every)
|
|
40
|
+
this.gracePeriodMs = Math.max(0, Math.floor(gracePeriod * MS_PER_SEC))
|
|
41
|
+
this.idleFlushMs = Math.max(0, Math.floor(idleFlushMs))
|
|
42
|
+
this.eventTimeFn = typeof eventTime === 'function' ? eventTime : null
|
|
43
|
+
this.allowedLatenessMs = Math.max(0, Math.floor(allowedLateness * MS_PER_SEC))
|
|
44
|
+
this.onLatePolicy = normaliseLateOpts(onLate)
|
|
45
|
+
this.operatorTag = `cron:${every}`
|
|
46
|
+
this.config = {
|
|
47
|
+
kind: 'window-cron',
|
|
48
|
+
every,
|
|
49
|
+
gracePeriod,
|
|
50
|
+
idleFlushMs: this.idleFlushMs,
|
|
51
|
+
eventTime: !!this.eventTimeFn,
|
|
52
|
+
allowedLateness,
|
|
53
|
+
onLate: this.onLatePolicy
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async apply(envelope) {
|
|
58
|
+
const ts = extractEventTime(envelope.msg, this.eventTimeFn)
|
|
59
|
+
if (ts === null) {
|
|
60
|
+
throw new Error('windowCron could not determine timestamp for message')
|
|
61
|
+
}
|
|
62
|
+
const windowStart = this._floorToBoundary(ts)
|
|
63
|
+
return [{
|
|
64
|
+
...envelope,
|
|
65
|
+
eventTimeMs: ts,
|
|
66
|
+
windowStart,
|
|
67
|
+
windowEnd: windowStart + this.windowMs,
|
|
68
|
+
windowKey: new Date(windowStart).toISOString(),
|
|
69
|
+
operatorTag: this.operatorTag,
|
|
70
|
+
gracePeriodMs: this.gracePeriodMs
|
|
71
|
+
}]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_floorToBoundary(ts) {
|
|
75
|
+
if (this.every === 'week') {
|
|
76
|
+
// Align to Monday 00:00 UTC.
|
|
77
|
+
const shifted = ts - WEEK_EPOCH_OFFSET_MS
|
|
78
|
+
const week = Math.floor(shifted / this.windowMs)
|
|
79
|
+
return week * this.windowMs + WEEK_EPOCH_OFFSET_MS
|
|
80
|
+
}
|
|
81
|
+
return Math.floor(ts / this.windowMs) * this.windowMs
|
|
82
|
+
}
|
|
83
|
+
}
|