@kronos-ts/axon-server 0.5.0 → 0.7.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/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +72 -77
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshotting-event-store.d.ts +3 -3
- package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -1
- package/dist/axon-server-snapshotting-event-store.js +3 -2
- package/dist/axon-server-snapshotting-event-store.js.map +1 -1
- package/dist/axon-server.d.ts +15 -7
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +447 -275
- package/dist/axon-server.js.map +1 -1
- package/dist/bounded-read.d.ts +19 -0
- package/dist/bounded-read.d.ts.map +1 -0
- package/dist/bounded-read.js +39 -0
- package/dist/bounded-read.js.map +1 -0
- package/dist/connection.d.ts +13 -0
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +78 -40
- package/dist/connection.js.map +1 -1
- package/dist/control-plane.d.ts +9 -19
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +6 -37
- package/dist/control-plane.js.map +1 -1
- package/dist/event-processor-info.d.ts +5 -23
- package/dist/event-processor-info.d.ts.map +1 -1
- package/dist/event-processor-info.js +16 -20
- package/dist/event-processor-info.js.map +1 -1
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/flow-controlled-sender.js +37 -17
- package/dist/flow-controlled-sender.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outbound-stream.d.ts +5 -9
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/outbound-stream.js +65 -16
- package/dist/outbound-stream.js.map +1 -1
- package/dist/platform-service.d.ts +10 -1
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/platform-service.js +70 -10
- package/dist/platform-service.js.map +1 -1
- package/dist/resilience.d.ts +2 -0
- package/dist/resilience.d.ts.map +1 -1
- package/dist/resilience.js +2 -1
- package/dist/resilience.js.map +1 -1
- package/dist/shutdown-latch.d.ts +1 -0
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js +20 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/dist/stream-recovery.d.ts +9 -0
- package/dist/stream-recovery.d.ts.map +1 -0
- package/dist/stream-recovery.js +70 -0
- package/dist/stream-recovery.js.map +1 -0
- package/package.json +2 -2
- package/src/axon-server-event-store.ts +77 -79
- package/src/axon-server-snapshotting-event-store.ts +10 -7
- package/src/axon-server.ts +417 -316
- package/src/bounded-read.ts +43 -0
- package/src/connection.ts +77 -45
- package/src/control-plane.ts +19 -63
- package/src/event-processor-info.ts +22 -43
- package/src/flow-controlled-sender.ts +33 -14
- package/src/index.ts +0 -1
- package/src/outbound-stream.ts +57 -27
- package/src/platform-service.ts +75 -11
- package/src/resilience.ts +6 -2
- package/src/shutdown-latch.ts +14 -1
- package/src/stream-recovery.ts +72 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read that never answers is cancelled and asked once more.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Under Bun, a gRPC call whose response has fully arrived —
|
|
5
|
+
* headers, the message, trailers carrying an OK status, the HTTP/2 stream
|
|
6
|
+
* closed cleanly — can still never complete on the client. grpc-js releases
|
|
7
|
+
* an OK status only once the stream's `end` event has fired, and Bun's http2
|
|
8
|
+
* client intermittently never emits it (Node does). The Axon integration
|
|
9
|
+
* suites hit it about one run in four on a loaded machine, on `source` and on
|
|
10
|
+
* the snapshot store's `getLast`; the command handler above the read then
|
|
11
|
+
* never replies, and Axon Server cancels the command at its own 300 s timeout.
|
|
12
|
+
*
|
|
13
|
+
* A deadline is the one lever this side of grpc-js: a cancelled call DOES
|
|
14
|
+
* surface, because a non-OK status needs no `end`. The reads this guards are
|
|
15
|
+
* idempotent, so the answer to a lost one is simply to ask again. A second
|
|
16
|
+
* loss is reported rather than retried forever.
|
|
17
|
+
*/
|
|
18
|
+
export async function boundedRead<T>(
|
|
19
|
+
deadlineMs: number,
|
|
20
|
+
read: (signal: AbortSignal) => Promise<T>,
|
|
21
|
+
): Promise<T> {
|
|
22
|
+
for (let attempt = 1; ; attempt++) {
|
|
23
|
+
const cancel = new AbortController()
|
|
24
|
+
const timer = setTimeout(() => cancel.abort(), deadlineMs)
|
|
25
|
+
try {
|
|
26
|
+
return await read(cancel.signal)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (!cancel.signal.aborted) throw err
|
|
29
|
+
if (attempt === 2) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Axon Server read did not complete within ${deadlineMs} ms, twice in a row`,
|
|
32
|
+
{ cause: err },
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
console.warn(
|
|
36
|
+
`Axon Server read did not complete within ${deadlineMs} ms; cancelled it and asking again ` +
|
|
37
|
+
"(a known Bun http2 client fault: the stream's end event is lost, see bounded-read.ts)",
|
|
38
|
+
)
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/connection.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { withMessagingTimeout } from "@kronos-ts/core"
|
|
1
2
|
import {
|
|
2
3
|
createChannel,
|
|
3
|
-
createClient,
|
|
4
|
+
waitForChannelReady, createClient,
|
|
4
5
|
type Channel,
|
|
5
6
|
type Client,
|
|
6
7
|
type ChannelCredentials,
|
|
@@ -71,6 +72,17 @@ export type AxonServerConnectionConfig = {
|
|
|
71
72
|
* Default: true.
|
|
72
73
|
*/
|
|
73
74
|
keepAlivePermitWithoutCalls?: boolean
|
|
75
|
+
/**
|
|
76
|
+
* How long a read (`source`, the snapshot store's `getLast`, `getHead`) may
|
|
77
|
+
* take before it is cancelled and asked once more. A read whose answer has
|
|
78
|
+
* fully arrived can still never complete under Bun — its http2 client
|
|
79
|
+
* intermittently loses the stream's `end` event, and grpc-js releases an OK
|
|
80
|
+
* status only after it — so without a bound a command handler parks on the
|
|
81
|
+
* read until Axon Server cancels the command (300 s). Reads are idempotent;
|
|
82
|
+
* a lost one is simply repeated. Raise this if a single sourcing read of
|
|
83
|
+
* yours legitimately takes longer. Default: 15000.
|
|
84
|
+
*/
|
|
85
|
+
readTimeoutMs?: number
|
|
74
86
|
|
|
75
87
|
/**
|
|
76
88
|
* TLS/SSL configuration. When enabled, the connection uses a secure gRPC channel.
|
|
@@ -165,6 +177,7 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
|
|
|
165
177
|
keepAliveTimeMs: config.keepAliveTimeMs ?? 30000,
|
|
166
178
|
keepAliveTimeoutMs: config.keepAliveTimeoutMs ?? 10000,
|
|
167
179
|
keepAlivePermitWithoutCalls: config.keepAlivePermitWithoutCalls ?? true,
|
|
180
|
+
readTimeoutMs: config.readTimeoutMs ?? 15000,
|
|
168
181
|
servers: config.servers,
|
|
169
182
|
ssl: config.ssl,
|
|
170
183
|
}
|
|
@@ -189,6 +202,12 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
|
|
|
189
202
|
"grpc.keepalive_time_ms": config.keepAliveTimeMs ?? 30000,
|
|
190
203
|
"grpc.keepalive_timeout_ms": config.keepAliveTimeoutMs ?? 10000,
|
|
191
204
|
"grpc.keepalive_permit_without_calls": (config.keepAlivePermitWithoutCalls ?? true) ? 1 : 0,
|
|
205
|
+
// Own subchannel pool per channel. grpc-js pools subchannels PROCESS-WIDE by
|
|
206
|
+
// target address by default, so a later connection to the same host:port —
|
|
207
|
+
// a fresh container on a reused mapped port, in a test process that already
|
|
208
|
+
// talked to another one — can be handed the previous server's HTTP/2
|
|
209
|
+
// session, and its unary calls hang until the server-side timeout.
|
|
210
|
+
"grpc.use_local_subchannel_pool": 1,
|
|
192
211
|
}
|
|
193
212
|
|
|
194
213
|
// Build server address list for failover
|
|
@@ -208,6 +227,9 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
|
|
|
208
227
|
|
|
209
228
|
let channel = createGrpcChannel()
|
|
210
229
|
let state: ConnectionState = "connected"
|
|
230
|
+
let reconnectPromise: Promise<void> | undefined
|
|
231
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
|
232
|
+
let finishRetryDelay: (() => void) | undefined
|
|
211
233
|
|
|
212
234
|
const reconnectCallbacks: Array<() => void> = []
|
|
213
235
|
const disconnectCallbacks: Array<(error?: Error) => void> = []
|
|
@@ -258,52 +280,59 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
|
|
|
258
280
|
},
|
|
259
281
|
|
|
260
282
|
close() {
|
|
283
|
+
if (state === "closed") return
|
|
261
284
|
state = "closed"
|
|
285
|
+
for (const callback of disconnectCallbacks) {
|
|
286
|
+
try { callback() } catch { /* Listener failures cannot prevent teardown. */ }
|
|
287
|
+
}
|
|
288
|
+
clearTimeout(retryTimer)
|
|
289
|
+
finishRetryDelay?.()
|
|
262
290
|
channel.close()
|
|
263
291
|
},
|
|
264
292
|
|
|
265
|
-
|
|
266
|
-
if (state === "closed")
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
} catch {
|
|
289
|
-
/* ignore listener errors */
|
|
293
|
+
reconnect() {
|
|
294
|
+
if (state === "closed") return Promise.reject(new Error("Connection is permanently closed"))
|
|
295
|
+
if (reconnectPromise) return reconnectPromise
|
|
296
|
+
reconnectPromise = (async () => {
|
|
297
|
+
state = "reconnecting"
|
|
298
|
+
channel.close()
|
|
299
|
+
for (const callback of disconnectCallbacks) {
|
|
300
|
+
try { callback() } catch { /* Listener failures cannot prevent recovery. */ }
|
|
301
|
+
}
|
|
302
|
+
let attempt = 0
|
|
303
|
+
while (state === "reconnecting") {
|
|
304
|
+
attempt++
|
|
305
|
+
try {
|
|
306
|
+
currentServerIndex++
|
|
307
|
+
channel = createGrpcChannel()
|
|
308
|
+
// Channel construction is lazy. Only report recovery once the new
|
|
309
|
+
// transport is ready, otherwise a dead server falsely "succeeds".
|
|
310
|
+
await waitForChannelReady(channel, new Date(Date.now() + resolvedConfig.keepAliveTimeoutMs))
|
|
311
|
+
if ((state as ConnectionState) === "closed") return
|
|
312
|
+
clients = createClients()
|
|
313
|
+
state = "connected"
|
|
314
|
+
for (const callback of reconnectCallbacks) {
|
|
315
|
+
try { callback() } catch { /* Listener failures are isolated. */ }
|
|
290
316
|
}
|
|
317
|
+
return
|
|
318
|
+
} catch (error) {
|
|
319
|
+
channel.close()
|
|
320
|
+
if ((state as ConnectionState) === "closed") return
|
|
321
|
+
if (resolvedConfig.maxReconnectAttempts > 0 && attempt >= resolvedConfig.maxReconnectAttempts) {
|
|
322
|
+
state = "disconnected"
|
|
323
|
+
throw new Error(`Failed to reconnect after ${attempt} attempts: ${error}`)
|
|
324
|
+
}
|
|
325
|
+
const backoff = Math.min(resolvedConfig.reconnectIntervalMs * 2 ** (attempt - 1), 30000)
|
|
326
|
+
await new Promise<void>((resolve) => {
|
|
327
|
+
finishRetryDelay = resolve
|
|
328
|
+
retryTimer = setTimeout(resolve, backoff * (0.75 + Math.random() * 0.5))
|
|
329
|
+
})
|
|
330
|
+
finishRetryDelay = undefined
|
|
291
331
|
}
|
|
292
|
-
return
|
|
293
|
-
} catch (err) {
|
|
294
|
-
if (maxAttempts > 0 && attempt >= maxAttempts) {
|
|
295
|
-
state = "disconnected"
|
|
296
|
-
throw new Error(`Failed to reconnect after ${attempt} attempts: ${err}`)
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Exponential backoff: base interval * 2^attempt, capped at 30s
|
|
300
|
-
const delay = Math.min(
|
|
301
|
-
resolvedConfig.reconnectIntervalMs * Math.pow(2, attempt - 1),
|
|
302
|
-
30000,
|
|
303
|
-
)
|
|
304
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
305
332
|
}
|
|
306
|
-
}
|
|
333
|
+
})().finally(() => { reconnectPromise = undefined })
|
|
334
|
+
return reconnectPromise
|
|
335
|
+
|
|
307
336
|
},
|
|
308
337
|
}
|
|
309
338
|
|
|
@@ -326,6 +355,8 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
|
|
|
326
355
|
* their honest two-argument shapes.
|
|
327
356
|
*/
|
|
328
357
|
export type AxonServerConnectionOptions = AxonServerConnectionConfig & {
|
|
358
|
+
/** Maximum graceful drain time; transport closes even when this expires. Default: 30000ms. */
|
|
359
|
+
shutdownTimeoutMs?: number
|
|
329
360
|
/** Payload codec for every message this client exchanges with Axon Server. */
|
|
330
361
|
serializer: Serializer
|
|
331
362
|
/** Retry / health-check policy for the initial connect and stream re-establishment. */
|
|
@@ -516,11 +547,12 @@ export async function axonServerConnection(
|
|
|
516
547
|
},
|
|
517
548
|
|
|
518
549
|
async close() {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
550
|
+
try {
|
|
551
|
+
await withMessagingTimeout(shutdown.initiateShutdown(), options.shutdownTimeoutMs ?? 30000, "Messaging shutdown")
|
|
552
|
+
} finally {
|
|
553
|
+
platform.stop()
|
|
554
|
+
connection.close()
|
|
555
|
+
}
|
|
524
556
|
},
|
|
525
557
|
}
|
|
526
558
|
}
|
package/src/control-plane.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* Axon Server's admin surface needs from a client:
|
|
7
7
|
*
|
|
8
8
|
* 1. inbound — Axon Server pushes processor instructions (pause-processor,
|
|
9
|
-
* start-processor
|
|
10
|
-
*
|
|
9
|
+
* start-processor) which have to be routed to the live processor of that
|
|
10
|
+
* name; segment instructions (split, merge, release) are ignored, a kronos
|
|
11
|
+
* processor has one lane;
|
|
11
12
|
* 2. outbound — the client periodically reports each processor's status so
|
|
12
13
|
* the Axon Dashboard can render it.
|
|
13
14
|
*
|
|
@@ -39,19 +40,12 @@
|
|
|
39
40
|
* caller to get wrong.
|
|
40
41
|
*/
|
|
41
42
|
import type { AxonServerPlatformSource } from "./connection.js"
|
|
42
|
-
import type { ProcessorStatus
|
|
43
|
+
import type { ProcessorStatus } from "./event-processor-info.js"
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
* A processor Axon Server is allowed to observe and control.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `any` before poking at `start` / `stop` / `releaseSegment` / … — this is that
|
|
49
|
-
* cast, written down. Both `TrackingEventProcessor` and
|
|
50
|
-
* `StreamingEventProcessor` satisfy it structurally; anything else that can
|
|
51
|
-
* name itself and answer some of these calls does too. Every member past the
|
|
52
|
-
* name is optional because Axon Server asks for things a given processor kind
|
|
53
|
-
* may not implement (a subscribing processor has no segments), and the
|
|
54
|
-
* instruction handler simply skips what is absent.
|
|
46
|
+
* A processor Axon Server is allowed to observe and control. `RunningProcessor`
|
|
47
|
+
* satisfies it structurally; everything past `name` is optional so a foreign
|
|
48
|
+
* processor-shaped object is skipped rather than crashed on.
|
|
55
49
|
*/
|
|
56
50
|
export type ManagedEventProcessor = {
|
|
57
51
|
readonly name: string
|
|
@@ -60,19 +54,12 @@ export type ManagedEventProcessor = {
|
|
|
60
54
|
readonly position?: bigint
|
|
61
55
|
start?(): Promise<void> | void
|
|
62
56
|
stop?(): void
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
readonly replaying?: boolean
|
|
70
|
-
readonly error?: Error
|
|
71
|
-
}
|
|
72
|
-
>
|
|
73
|
-
releaseSegment?(segmentId: number): Promise<unknown> | unknown
|
|
74
|
-
splitSegment?(segmentId: number): Promise<unknown> | unknown
|
|
75
|
-
mergeSegment?(segmentId: number): Promise<unknown> | unknown
|
|
57
|
+
status?(): {
|
|
58
|
+
readonly caughtUp?: boolean
|
|
59
|
+
readonly replaying?: boolean
|
|
60
|
+
readonly position?: bigint
|
|
61
|
+
readonly error?: Error
|
|
62
|
+
}
|
|
76
63
|
}
|
|
77
64
|
|
|
78
65
|
/**
|
|
@@ -92,37 +79,14 @@ export type AxonServerControlPlane = {
|
|
|
92
79
|
/** Map the managed processors into the status shape the platform stream reports. */
|
|
93
80
|
function processorStatuses(processors: Iterable<ManagedEventProcessor>): ProcessorStatus[] {
|
|
94
81
|
return Array.from(processors, (proc) => {
|
|
95
|
-
const
|
|
96
|
-
const perSegment = proc.processingStatus?.()
|
|
97
|
-
const segments: SegmentStatus[] = perSegment
|
|
98
|
-
? Array.from(perSegment.entries()).map(([segmentId, status]) => ({
|
|
99
|
-
segmentId,
|
|
100
|
-
caughtUp: status.caughtUp ?? false,
|
|
101
|
-
replaying: status.replaying ?? false,
|
|
102
|
-
onePartOf: 1,
|
|
103
|
-
tokenPosition: status.position ?? 0n,
|
|
104
|
-
errorState: status.error?.message ?? "",
|
|
105
|
-
}))
|
|
106
|
-
: [
|
|
107
|
-
{
|
|
108
|
-
segmentId: 0,
|
|
109
|
-
caughtUp: true,
|
|
110
|
-
replaying: proc.replaying ?? false,
|
|
111
|
-
onePartOf: 1,
|
|
112
|
-
tokenPosition: proc.position ?? 0n,
|
|
113
|
-
errorState: "",
|
|
114
|
-
},
|
|
115
|
-
]
|
|
82
|
+
const status = proc.status?.()
|
|
116
83
|
return {
|
|
117
84
|
name: proc.name,
|
|
118
85
|
running: proc.running ?? false,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
error: false,
|
|
124
|
-
tokenStoreIdentifier: "",
|
|
125
|
-
segments,
|
|
86
|
+
caughtUp: status?.caughtUp ?? true,
|
|
87
|
+
replaying: status?.replaying ?? proc.replaying ?? false,
|
|
88
|
+
position: status?.position ?? proc.position ?? 0n,
|
|
89
|
+
error: status?.error?.message,
|
|
126
90
|
}
|
|
127
91
|
})
|
|
128
92
|
}
|
|
@@ -167,15 +131,7 @@ export async function axonServerControlPlane(
|
|
|
167
131
|
case "start-processor":
|
|
168
132
|
await byName.get(instruction.processorName)?.start?.()
|
|
169
133
|
break
|
|
170
|
-
|
|
171
|
-
await byName.get(instruction.processorName)?.releaseSegment?.(instruction.segmentId)
|
|
172
|
-
break
|
|
173
|
-
case "split-segment":
|
|
174
|
-
await byName.get(instruction.processorName)?.splitSegment?.(instruction.segmentId)
|
|
175
|
-
break
|
|
176
|
-
case "merge-segment":
|
|
177
|
-
await byName.get(instruction.processorName)?.mergeSegment?.(instruction.segmentId)
|
|
178
|
-
break
|
|
134
|
+
// release/split/merge-segment: a kronos processor has one lane. Ignored.
|
|
179
135
|
}
|
|
180
136
|
})
|
|
181
137
|
|
|
@@ -1,62 +1,41 @@
|
|
|
1
|
-
import type { EventProcessorInfo
|
|
1
|
+
import type { EventProcessorInfo } from "./generated/control.js"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* What a processor reports about itself. SIX FIELDS — see the kronosdb
|
|
5
|
+
* package's twin for why the wire's thread counts, token-store identifier and
|
|
6
|
+
* per-segment list are filled with constants here rather than carried.
|
|
6
7
|
*/
|
|
7
8
|
export type ProcessorStatus = {
|
|
8
9
|
readonly name: string
|
|
9
10
|
readonly running: boolean
|
|
10
|
-
readonly mode: "Tracking" | "Subscribing"
|
|
11
|
-
readonly isStreamingProcessor: boolean
|
|
12
|
-
readonly activeThreads: number
|
|
13
|
-
readonly availableThreads: number
|
|
14
|
-
readonly error: boolean
|
|
15
|
-
readonly errorMessage?: string
|
|
16
|
-
readonly tokenStoreIdentifier: string
|
|
17
|
-
readonly segments: SegmentStatus[]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type SegmentStatus = {
|
|
21
|
-
readonly segmentId: number
|
|
22
11
|
readonly caughtUp: boolean
|
|
23
12
|
readonly replaying: boolean
|
|
24
|
-
readonly
|
|
25
|
-
readonly
|
|
26
|
-
readonly errorState: string
|
|
13
|
+
readonly position: bigint
|
|
14
|
+
readonly error?: string
|
|
27
15
|
}
|
|
28
16
|
|
|
29
|
-
/**
|
|
30
|
-
* Converts a ProcessorStatus to the proto EventProcessorInfo format.
|
|
31
|
-
*/
|
|
32
17
|
export function toEventProcessorInfo(status: ProcessorStatus): EventProcessorInfo {
|
|
33
18
|
return {
|
|
34
19
|
processorName: status.name,
|
|
35
|
-
mode:
|
|
36
|
-
activeThreads: status.
|
|
20
|
+
mode: "Tracking",
|
|
21
|
+
activeThreads: status.running ? 1 : 0,
|
|
37
22
|
running: status.running,
|
|
38
|
-
error: status.error,
|
|
39
|
-
segmentStatus:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
23
|
+
error: status.error !== undefined,
|
|
24
|
+
segmentStatus: [
|
|
25
|
+
{
|
|
26
|
+
segmentId: 0,
|
|
27
|
+
caughtUp: status.caughtUp,
|
|
28
|
+
replaying: status.replaying,
|
|
29
|
+
onePartOf: 1,
|
|
30
|
+
tokenPosition: status.position,
|
|
31
|
+
errorState: status.error ?? "",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
availableThreads: 0,
|
|
35
|
+
tokenStoreIdentifier: "",
|
|
36
|
+
isStreamingProcessor: true,
|
|
43
37
|
loadBalancingStrategyName: "",
|
|
44
38
|
}
|
|
45
39
|
}
|
|
46
40
|
|
|
47
|
-
function toSegmentStatus(seg: SegmentStatus): EventProcessorInfo_SegmentStatus {
|
|
48
|
-
return {
|
|
49
|
-
segmentId: seg.segmentId,
|
|
50
|
-
caughtUp: seg.caughtUp,
|
|
51
|
-
replaying: seg.replaying,
|
|
52
|
-
onePartOf: seg.onePartOf,
|
|
53
|
-
tokenPosition: seg.tokenPosition,
|
|
54
|
-
errorState: seg.errorState,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Supplier function that returns the current status of all event processors.
|
|
60
|
-
* Registered with the platform connection for periodic reporting.
|
|
61
|
-
*/
|
|
62
41
|
export type ProcessorStatusSupplier = () => ProcessorStatus[]
|
|
@@ -33,29 +33,49 @@ export function flowControlledSender<T>(
|
|
|
33
33
|
onError?: (error: Error) => void,
|
|
34
34
|
maxBufferSize: number = 256,
|
|
35
35
|
): FlowControlledSender<T> {
|
|
36
|
+
if (!Number.isSafeInteger(maxBufferSize) || maxBufferSize <= 0) throw new RangeError("maxBufferSize must be a positive integer")
|
|
36
37
|
const buffer: T[] = []
|
|
37
38
|
let permits = 0
|
|
38
39
|
let isActive = true
|
|
40
|
+
let completionRequested = false
|
|
41
|
+
|
|
42
|
+
function fail(error: Error) {
|
|
43
|
+
if (!isActive) return
|
|
44
|
+
isActive = false
|
|
45
|
+
buffer.length = 0
|
|
46
|
+
onError?.(error)
|
|
47
|
+
}
|
|
48
|
+
function sendOne(value: T) {
|
|
49
|
+
try { send(value) }
|
|
50
|
+
catch (error) {
|
|
51
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function finishIfDrained() {
|
|
56
|
+
if (isActive && completionRequested && !buffer.length) {
|
|
57
|
+
isActive = false
|
|
58
|
+
onComplete?.()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
|
|
40
63
|
function drain() {
|
|
41
64
|
while (permits > 0 && buffer.length > 0 && isActive) {
|
|
42
65
|
const value = buffer.shift()!
|
|
43
66
|
permits--
|
|
44
|
-
|
|
45
|
-
send(value)
|
|
46
|
-
} catch (err) {
|
|
47
|
-
console.warn("FlowControlledSender: send error", err)
|
|
48
|
-
}
|
|
67
|
+
sendOne(value)
|
|
49
68
|
}
|
|
69
|
+
finishIfDrained()
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
return {
|
|
53
73
|
offer(value: T): boolean {
|
|
54
|
-
if (!isActive) return false
|
|
74
|
+
if (!isActive || completionRequested) return false
|
|
55
75
|
|
|
56
76
|
if (permits > 0) {
|
|
57
77
|
permits--
|
|
58
|
-
|
|
78
|
+
sendOne(value)
|
|
59
79
|
return true
|
|
60
80
|
}
|
|
61
81
|
|
|
@@ -68,24 +88,23 @@ export function flowControlledSender<T>(
|
|
|
68
88
|
},
|
|
69
89
|
|
|
70
90
|
addPermits(count: number) {
|
|
91
|
+
if (!Number.isSafeInteger(count) || count <= 0 || !Number.isSafeInteger(permits + count)) throw new RangeError("Permits must be positive safe integers")
|
|
92
|
+
if (!isActive) return
|
|
71
93
|
permits += count
|
|
72
94
|
drain()
|
|
73
95
|
},
|
|
74
96
|
|
|
75
97
|
complete() {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (onComplete) onComplete()
|
|
98
|
+
completionRequested = true
|
|
99
|
+
finishIfDrained()
|
|
79
100
|
},
|
|
80
101
|
|
|
81
102
|
completeExceptionally(error: Error) {
|
|
82
|
-
|
|
83
|
-
buffer.length = 0
|
|
84
|
-
if (onError) onError(error)
|
|
103
|
+
fail(error)
|
|
85
104
|
},
|
|
86
105
|
|
|
87
106
|
get active() {
|
|
88
|
-
return isActive
|
|
107
|
+
return isActive && !completionRequested
|
|
89
108
|
},
|
|
90
109
|
}
|
|
91
110
|
}
|
package/src/index.ts
CHANGED
package/src/outbound-stream.ts
CHANGED
|
@@ -1,52 +1,82 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A queue-backed async iterable for feeding outbound messages to a
|
|
3
|
-
* bidirectional gRPC stream. Buffers messages when the stream isn't
|
|
4
|
-
* consuming, and resolves promises when the stream is waiting.
|
|
5
|
-
*/
|
|
1
|
+
/** Bounded single-consumer queue feeding a gRPC request stream. */
|
|
6
2
|
export type OutboundStream<T> = {
|
|
7
|
-
/** Send a message into the stream. */
|
|
8
3
|
send(message: T): void
|
|
9
|
-
/** The async iterable to pass to the gRPC client. */
|
|
10
4
|
readonly iterable: AsyncIterable<T>
|
|
11
|
-
/**
|
|
5
|
+
/** Wait until the consumer has requested the next frame after this batch. Not a server acknowledgement. */
|
|
6
|
+
flush(): Promise<void>
|
|
7
|
+
readonly buffered: number
|
|
12
8
|
close(): void
|
|
13
9
|
}
|
|
14
10
|
|
|
15
|
-
export function outboundStream<T>(): OutboundStream<T> {
|
|
16
|
-
|
|
11
|
+
export function outboundStream<T>(maxBuffered = 4096): OutboundStream<T> {
|
|
12
|
+
if (!Number.isSafeInteger(maxBuffered) || maxBuffered <= 0) throw new RangeError("maxBuffered must be a positive integer")
|
|
13
|
+
let resolve: ((value: IteratorResult<T>) => void) | undefined
|
|
17
14
|
const queue: T[] = []
|
|
18
15
|
let closed = false
|
|
19
|
-
|
|
16
|
+
let claimed = false
|
|
17
|
+
let handingOff = false
|
|
18
|
+
let flushed: { promise: Promise<void>; resolve(): void; reject(error: Error): void } | undefined
|
|
19
|
+
function acknowledgeRead() {
|
|
20
|
+
handingOff = false
|
|
21
|
+
if (!queue.length) { flushed?.resolve(); flushed = undefined }
|
|
22
|
+
}
|
|
23
|
+
const done = (): IteratorResult<T> => ({ value: undefined, done: true })
|
|
24
|
+
function close() {
|
|
25
|
+
closed = true
|
|
26
|
+
if (!queue.length && !handingOff) { flushed?.resolve(); flushed = undefined }
|
|
27
|
+
resolve?.(done())
|
|
28
|
+
resolve = undefined
|
|
29
|
+
}
|
|
20
30
|
return {
|
|
21
|
-
|
|
31
|
+
get buffered() { return queue.length },
|
|
32
|
+
flush() {
|
|
33
|
+
if (!queue.length && !handingOff) return Promise.resolve()
|
|
34
|
+
if (closed) return Promise.reject(new Error("Outbound stream closed before flush"))
|
|
35
|
+
if (!flushed) {
|
|
36
|
+
let resolve!: () => void, reject!: (error: Error) => void
|
|
37
|
+
const promise = new Promise<void>((a, b) => { resolve = a; reject = b })
|
|
38
|
+
flushed = { promise, resolve, reject }
|
|
39
|
+
}
|
|
40
|
+
return flushed.promise
|
|
41
|
+
},
|
|
42
|
+
send(message) {
|
|
43
|
+
if (closed) throw new Error("Outbound stream is closed")
|
|
22
44
|
if (resolve) {
|
|
23
|
-
const
|
|
24
|
-
resolve =
|
|
25
|
-
|
|
45
|
+
const waiter = resolve
|
|
46
|
+
resolve = undefined
|
|
47
|
+
handingOff = true
|
|
48
|
+
waiter({ value: message, done: false })
|
|
26
49
|
} else {
|
|
50
|
+
if (queue.length >= maxBuffered) throw new Error("Outbound stream buffer overflow")
|
|
27
51
|
queue.push(message)
|
|
28
52
|
}
|
|
29
53
|
},
|
|
30
|
-
|
|
31
54
|
iterable: {
|
|
32
55
|
[Symbol.asyncIterator]() {
|
|
56
|
+
if (claimed) throw new Error("Outbound stream supports one consumer")
|
|
57
|
+
claimed = true
|
|
33
58
|
return {
|
|
34
59
|
next(): Promise<IteratorResult<T>> {
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
60
|
+
acknowledgeRead()
|
|
61
|
+
if (queue.length) {
|
|
62
|
+
handingOff = true
|
|
63
|
+
return Promise.resolve({ value: queue.shift()!, done: false })
|
|
64
|
+
}
|
|
65
|
+
if (closed) return Promise.resolve(done())
|
|
66
|
+
if (resolve) return Promise.reject(new Error("Concurrent outbound stream reads are not supported"))
|
|
38
67
|
return new Promise((r) => { resolve = r })
|
|
39
68
|
},
|
|
69
|
+
return(): Promise<IteratorResult<T>> {
|
|
70
|
+
queue.length = 0
|
|
71
|
+
flushed?.reject(new Error("Outbound stream cancelled before flush"))
|
|
72
|
+
flushed = undefined
|
|
73
|
+
handingOff = false
|
|
74
|
+
close()
|
|
75
|
+
return Promise.resolve(done())
|
|
76
|
+
},
|
|
40
77
|
}
|
|
41
78
|
},
|
|
42
79
|
},
|
|
43
|
-
|
|
44
|
-
close() {
|
|
45
|
-
closed = true
|
|
46
|
-
if (resolve) {
|
|
47
|
-
resolve({ value: undefined as any, done: true })
|
|
48
|
-
resolve = null
|
|
49
|
-
}
|
|
50
|
-
},
|
|
80
|
+
close,
|
|
51
81
|
}
|
|
52
82
|
}
|