@kronos-ts/axon-server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/axon-server-event-store.d.ts +0 -16
  2. package/dist/axon-server-event-store.d.ts.map +1 -1
  3. package/dist/axon-server-event-store.js +73 -45
  4. package/dist/axon-server-event-store.js.map +1 -1
  5. package/dist/axon-server-snapshotting-event-store.d.ts +30 -0
  6. package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -0
  7. package/dist/axon-server-snapshotting-event-store.js +154 -0
  8. package/dist/axon-server-snapshotting-event-store.js.map +1 -0
  9. package/dist/axon-server.d.ts +46 -65
  10. package/dist/axon-server.d.ts.map +1 -1
  11. package/dist/axon-server.js +490 -317
  12. package/dist/axon-server.js.map +1 -1
  13. package/dist/bounded-read.d.ts +19 -0
  14. package/dist/bounded-read.d.ts.map +1 -0
  15. package/dist/bounded-read.js +39 -0
  16. package/dist/bounded-read.js.map +1 -0
  17. package/dist/connection-manager.d.ts +2 -2
  18. package/dist/connection-manager.d.ts.map +1 -1
  19. package/dist/connection.d.ts +36 -21
  20. package/dist/connection.d.ts.map +1 -1
  21. package/dist/connection.js +84 -45
  22. package/dist/connection.js.map +1 -1
  23. package/dist/context-view.d.ts +2 -2
  24. package/dist/context-view.d.ts.map +1 -1
  25. package/dist/control-plane.d.ts +14 -24
  26. package/dist/control-plane.d.ts.map +1 -1
  27. package/dist/control-plane.js +6 -37
  28. package/dist/control-plane.js.map +1 -1
  29. package/dist/event-processor-info.d.ts +7 -25
  30. package/dist/event-processor-info.d.ts.map +1 -1
  31. package/dist/event-processor-info.js +16 -20
  32. package/dist/event-processor-info.js.map +1 -1
  33. package/dist/flow-controlled-sender.d.ts +2 -2
  34. package/dist/flow-controlled-sender.d.ts.map +1 -1
  35. package/dist/flow-controlled-sender.js +37 -17
  36. package/dist/flow-controlled-sender.js.map +1 -1
  37. package/dist/generated/command.d.ts +20 -20
  38. package/dist/generated/command.d.ts.map +1 -1
  39. package/dist/generated/common.d.ts +20 -20
  40. package/dist/generated/common.d.ts.map +1 -1
  41. package/dist/generated/control.d.ts +36 -40
  42. package/dist/generated/control.d.ts.map +1 -1
  43. package/dist/generated/dcb.d.ts +88 -94
  44. package/dist/generated/dcb.d.ts.map +1 -1
  45. package/dist/generated/event.d.ts +81 -84
  46. package/dist/generated/event.d.ts.map +1 -1
  47. package/dist/generated/google/protobuf/empty.d.ts +3 -4
  48. package/dist/generated/google/protobuf/empty.d.ts.map +1 -1
  49. package/dist/generated/query.d.ts +40 -40
  50. package/dist/generated/query.d.ts.map +1 -1
  51. package/dist/index.d.ts +2 -2
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/message-size.d.ts +2 -2
  56. package/dist/message-size.d.ts.map +1 -1
  57. package/dist/outbound-stream.d.ts +7 -11
  58. package/dist/outbound-stream.d.ts.map +1 -1
  59. package/dist/outbound-stream.js +65 -16
  60. package/dist/outbound-stream.js.map +1 -1
  61. package/dist/platform-service.d.ts +14 -5
  62. package/dist/platform-service.d.ts.map +1 -1
  63. package/dist/platform-service.js +70 -10
  64. package/dist/platform-service.js.map +1 -1
  65. package/dist/resilience.d.ts +62 -0
  66. package/dist/resilience.d.ts.map +1 -0
  67. package/dist/resilience.js +103 -0
  68. package/dist/resilience.js.map +1 -0
  69. package/dist/shutdown-latch.d.ts +5 -4
  70. package/dist/shutdown-latch.d.ts.map +1 -1
  71. package/dist/shutdown-latch.js +20 -1
  72. package/dist/shutdown-latch.js.map +1 -1
  73. package/dist/stream-recovery.d.ts +9 -0
  74. package/dist/stream-recovery.d.ts.map +1 -0
  75. package/dist/stream-recovery.js +70 -0
  76. package/dist/stream-recovery.js.map +1 -0
  77. package/package.json +3 -3
  78. package/src/axon-server-event-store.ts +77 -47
  79. package/src/axon-server-snapshotting-event-store.ts +194 -0
  80. package/src/axon-server.ts +472 -374
  81. package/src/bounded-read.ts +43 -0
  82. package/src/connection-manager.ts +1 -1
  83. package/src/connection.ts +93 -59
  84. package/src/context-view.ts +1 -1
  85. package/src/control-plane.ts +22 -66
  86. package/src/event-processor-info.ts +23 -44
  87. package/src/flow-controlled-sender.ts +34 -15
  88. package/src/generated/command.ts +10 -10
  89. package/src/generated/common.ts +10 -10
  90. package/src/generated/control.ts +20 -20
  91. package/src/generated/dcb.ts +47 -47
  92. package/src/generated/event.ts +42 -42
  93. package/src/generated/google/protobuf/empty.ts +2 -2
  94. package/src/generated/query.ts +20 -20
  95. package/src/index.ts +1 -2
  96. package/src/message-size.ts +1 -1
  97. package/src/outbound-stream.ts +58 -28
  98. package/src/platform-service.ts +77 -13
  99. package/src/resilience.ts +149 -0
  100. package/src/shutdown-latch.ts +16 -3
  101. package/src/stream-recovery.ts +72 -0
  102. package/dist/axon-server-snapshot-store.d.ts +0 -12
  103. package/dist/axon-server-snapshot-store.d.ts.map +0 -1
  104. package/dist/axon-server-snapshot-store.js +0 -82
  105. package/dist/axon-server-snapshot-store.js.map +0 -1
  106. package/src/axon-server-snapshot-store.ts +0 -110
@@ -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
+ }
@@ -22,7 +22,7 @@ import { connectToAxonServer } from "./connection.js"
22
22
  * await manager.disconnectAll()
23
23
  * ```
24
24
  */
25
- export interface AxonServerConnectionManager {
25
+ export type AxonServerConnectionManager = {
26
26
  /**
27
27
  * Get or create a connection for the given context.
28
28
  * Connections are created lazily and cached.
package/src/connection.ts CHANGED
@@ -1,13 +1,15 @@
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,
7
8
  } from "nice-grpc"
8
9
  import { ChannelCredentials as GrpcChannelCredentials } from "@grpc/grpc-js"
9
10
  import { readFileSync } from "node:fs"
10
- import { withRetry, healthCheck, type ResilienceConfig, type Serializer } from "@kronos-ts/core"
11
+ import type { Serializer } from "@kronos-ts/core"
12
+ import { withRetry, healthCheck, type ResilienceConfig } from "./resilience.js"
11
13
  import { PlatformServiceDefinition } from "./generated/control.js"
12
14
  import { CommandServiceDefinition } from "./generated/command.js"
13
15
  import { QueryServiceDefinition } from "./generated/query.js"
@@ -22,7 +24,7 @@ import {
22
24
  /**
23
25
  * Configuration for connecting to Axon Server.
24
26
  */
25
- export interface AxonServerConnectionConfig {
27
+ export type AxonServerConnectionConfig = {
26
28
  /**
27
29
  * Host of the Axon Server. Defaults to "localhost".
28
30
  * For single-server setups.
@@ -70,6 +72,17 @@ export interface AxonServerConnectionConfig {
70
72
  * Default: true.
71
73
  */
72
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
73
86
 
74
87
  /**
75
88
  * TLS/SSL configuration. When enabled, the connection uses a secure gRPC channel.
@@ -107,7 +120,7 @@ export type ConnectionState =
107
120
  * An active connection to Axon Server, providing typed gRPC clients
108
121
  * for all services. Supports reconnection on failure.
109
122
  */
110
- export interface AxonServerConnection {
123
+ export type AxonServerConnection = {
111
124
  /** The underlying gRPC channel. */
112
125
  readonly channel: Channel
113
126
  /** Platform service — connection management, topology. */
@@ -164,6 +177,7 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
164
177
  keepAliveTimeMs: config.keepAliveTimeMs ?? 30000,
165
178
  keepAliveTimeoutMs: config.keepAliveTimeoutMs ?? 10000,
166
179
  keepAlivePermitWithoutCalls: config.keepAlivePermitWithoutCalls ?? true,
180
+ readTimeoutMs: config.readTimeoutMs ?? 15000,
167
181
  servers: config.servers,
168
182
  ssl: config.ssl,
169
183
  }
@@ -188,6 +202,12 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
188
202
  "grpc.keepalive_time_ms": config.keepAliveTimeMs ?? 30000,
189
203
  "grpc.keepalive_timeout_ms": config.keepAliveTimeoutMs ?? 10000,
190
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,
191
211
  }
192
212
 
193
213
  // Build server address list for failover
@@ -207,6 +227,9 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
207
227
 
208
228
  let channel = createGrpcChannel()
209
229
  let state: ConnectionState = "connected"
230
+ let reconnectPromise: Promise<void> | undefined
231
+ let retryTimer: ReturnType<typeof setTimeout> | undefined
232
+ let finishRetryDelay: (() => void) | undefined
210
233
 
211
234
  const reconnectCallbacks: Array<() => void> = []
212
235
  const disconnectCallbacks: Array<(error?: Error) => void> = []
@@ -257,52 +280,59 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
257
280
  },
258
281
 
259
282
  close() {
283
+ if (state === "closed") return
260
284
  state = "closed"
285
+ for (const callback of disconnectCallbacks) {
286
+ try { callback() } catch { /* Listener failures cannot prevent teardown. */ }
287
+ }
288
+ clearTimeout(retryTimer)
289
+ finishRetryDelay?.()
261
290
  channel.close()
262
291
  },
263
292
 
264
- async reconnect() {
265
- if (state === "closed") {
266
- throw new Error("Connection is permanently closed")
267
- }
268
- if (state === "connected" || state === "connecting") return
269
-
270
- state = "reconnecting"
271
- const maxAttempts = resolvedConfig.maxReconnectAttempts
272
- let attempt = 0
273
-
274
- while (state === "reconnecting") {
275
- attempt++
276
- try {
277
- // Try the next server in the list on each reconnect attempt
278
- currentServerIndex++
279
- channel = createGrpcChannel()
280
- clients = createClients()
281
- state = "connected"
282
-
283
- // Notify listeners that we're back
284
- for (const cb of reconnectCallbacks) {
285
- try {
286
- cb()
287
- } catch {
288
- /* 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. */ }
289
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
290
331
  }
291
- return
292
- } catch (err) {
293
- if (maxAttempts > 0 && attempt >= maxAttempts) {
294
- state = "disconnected"
295
- throw new Error(`Failed to reconnect after ${attempt} attempts: ${err}`)
296
- }
297
-
298
- // Exponential backoff: base interval * 2^attempt, capped at 30s
299
- const delay = Math.min(
300
- resolvedConfig.reconnectIntervalMs * Math.pow(2, attempt - 1),
301
- 30000,
302
- )
303
- await new Promise((r) => setTimeout(r, delay))
304
332
  }
305
- }
333
+ })().finally(() => { reconnectPromise = undefined })
334
+ return reconnectPromise
335
+
306
336
  },
307
337
  }
308
338
 
@@ -321,10 +351,12 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
321
351
  * of a bus. Every event, snapshot, command payload and query result this
322
352
  * process exchanges with Axon Server goes through the same codec, so it is
323
353
  * named once, here — which is also what leaves
324
- * `axonServerEventStore(conn, context)` and `axonServerCommandBus(conn, local)`
354
+ * `axonServerEventStore(conn, context)` and `axonServerCommandBus(local, conn)`
325
355
  * their honest two-argument shapes.
326
356
  */
327
- export interface AxonServerConnectionOptions extends AxonServerConnectionConfig {
357
+ export type AxonServerConnectionOptions = AxonServerConnectionConfig & {
358
+ /** Maximum graceful drain time; transport closes even when this expires. Default: 30000ms. */
359
+ shutdownTimeoutMs?: number
328
360
  /** Payload codec for every message this client exchanges with Axon Server. */
329
361
  serializer: Serializer
330
362
  /** Retry / health-check policy for the initial connect and stream re-establishment. */
@@ -350,13 +382,13 @@ export interface AxonServerConnectionOptions extends AxonServerConnectionConfig
350
382
  * the codec. Narrower than the handle on purpose — a test can drive a store
351
383
  * with a fake `connection` and nothing else.
352
384
  */
353
- export interface AxonServerStoreSource {
385
+ export type AxonServerStoreSource = {
354
386
  readonly connection: AxonServerConnection
355
387
  readonly serializer: Serializer
356
388
  }
357
389
 
358
390
  /** What a BUS borrows: a store's two, plus the drain latch and the retry policy. */
359
- export interface AxonServerBusSource extends AxonServerStoreSource {
391
+ export type AxonServerBusSource = AxonServerStoreSource & {
360
392
  /**
361
393
  * The connection-wide drain latch. In-flight dispatches register on it and
362
394
  * `close()` waits for them, so a bus never has the transport pulled out from
@@ -367,7 +399,7 @@ export interface AxonServerBusSource extends AxonServerStoreSource {
367
399
  }
368
400
 
369
401
  /** What the CONTROL PLANE borrows: the platform stream, and only that. */
370
- export interface AxonServerPlatformSource {
402
+ export type AxonServerPlatformSource = {
371
403
  readonly platform: PlatformConnection
372
404
  }
373
405
 
@@ -376,12 +408,12 @@ export interface AxonServerPlatformSource {
376
408
  * stream riding on it, and the lifecycle pair.
377
409
  *
378
410
  * The stores and buses are NOT on it — `axonServerEventStore(conn, context)`,
379
- * `axonServerCommandBus(conn, local)` and friends are plain functions over
411
+ * `axonServerCommandBus(local, conn)` and friends are plain functions over
380
412
  * this, and a caller who wants only commands builds only that one. Multiple
381
413
  * contexts share this ONE channel: the per-call `AxonIQ-Context` header is what
382
414
  * separates them.
383
415
  */
384
- export interface AxonServerConnectionHandle extends AxonServerBusSource, AxonServerPlatformSource {
416
+ export type AxonServerConnectionHandle = AxonServerBusSource & AxonServerPlatformSource & {
385
417
  /** Config after defaults — the resolved host, context, client id. */
386
418
  readonly config: AxonServerConnection["config"]
387
419
  /**
@@ -410,13 +442,14 @@ export interface AxonServerConnectionHandle extends AxonServerBusSource, AxonSer
410
442
  * serializer: jsonSerializer(),
411
443
  * })
412
444
  * const eventStore = axonServerEventStore(axon, "default")
413
- * const snapshotStore = axonServerSnapshotStore(axon, "default")
445
+ * const eventStore = axonServerSnapshottingEventStore(
446
+ * axonServerEventStore(axon, "default"), axon, "default")
414
447
  * const commandBus = interceptingCommandBus(
415
- * axonServerCommandBus(axon, simpleCommandBus(unitOfWork)), lineage)
448
+ * axonServerCommandBus(localCommandBus(unitOfWork), axon), correlation)
416
449
  * const queryBus = interceptingQueryBus(
417
- * axonServerQueryBus(axon, simpleQueryBus(unitOfWork)), lineage)
450
+ * axonServerQueryBus(localQueryBus(unitOfWork), axon), correlation)
418
451
  *
419
- * const app = kronos({ states, commandHandlers, queryHandlers })
452
+ * const app = kronos({ commandHandlers, queryHandlers })
420
453
  * await axon.start() // readiness barrier: the server can route to us
421
454
  * // opt in to remote administration
422
455
  * const control = await axonServerControlPlane(axon, app.processors.values())
@@ -514,11 +547,12 @@ export async function axonServerConnection(
514
547
  },
515
548
 
516
549
  async close() {
517
- await shutdown.initiateShutdown()
518
- // Idempotent, and independent of `control.close()` a connection that was
519
- // never administered still stops a platform stream someone else started.
520
- platform.stop()
521
- connection.close()
550
+ try {
551
+ await withMessagingTimeout(shutdown.initiateShutdown(), options.shutdownTimeoutMs ?? 30000, "Messaging shutdown")
552
+ } finally {
553
+ platform.stop()
554
+ connection.close()
555
+ }
522
556
  },
523
557
  }
524
558
  }
@@ -12,7 +12,7 @@ import type { AxonServerStoreSource } from "./connection.js"
12
12
  * channel, the codec or the drain latch differs between them; only this header
13
13
  * does, which is why the whole difference fits in one small record.
14
14
  */
15
- export interface AxonServerContextView extends AxonServerStoreSource {
15
+ export type AxonServerContextView = AxonServerStoreSource & {
16
16
  /** The Axon Server context every call through this view addresses. */
17
17
  readonly context: string
18
18
  /**
@@ -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, release-segment, split-segment, merge-segment) which
10
- * have to be routed to the live processor of that name;
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
  *
@@ -19,7 +20,7 @@
19
20
  *
20
21
  * ```ts
21
22
  * const axon = await axonServerConnection({ ... })
22
- * const app = kronos({ states, commandHandlers, queryHandlers, eventHandlers })
23
+ * const app = kronos({ commandHandlers, queryHandlers, eventHandlers })
23
24
  * await axon.start() // data path only
24
25
  *
25
26
  * // opt in to remote administration
@@ -39,47 +40,33 @@
39
40
  * caller to get wrong.
40
41
  */
41
42
  import type { AxonServerPlatformSource } from "./connection.js"
42
- import type { ProcessorStatus, SegmentStatus } from "./event-processor-info.js"
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
- * The container version reached for `app.processors()` and cast the result to
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
- export interface ManagedEventProcessor {
50
+ export type ManagedEventProcessor = {
57
51
  readonly name: string
58
52
  readonly running?: boolean
59
53
  readonly replaying?: boolean
60
54
  readonly position?: bigint
61
55
  start?(): Promise<void> | void
62
56
  stop?(): void
63
- supportsReset?(): boolean
64
- processingStatus?(): ReadonlyMap<
65
- number,
66
- {
67
- readonly position?: bigint
68
- readonly caughtUp?: boolean
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
  /**
79
66
  * A running control plane. The platform stream is live; instructions are being
80
67
  * routed and status is being reported until `close()`.
81
68
  */
82
- export interface AxonServerControlPlane {
69
+ export type AxonServerControlPlane = {
83
70
  /**
84
71
  * The processors this control plane addresses, keyed by name — the snapshot
85
72
  * taken at construction. Exposed for introspection and tests.
@@ -92,37 +79,14 @@ export interface 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 isStreamingProcessor = proc.supportsReset?.() !== false
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
- mode: isStreamingProcessor ? "Tracking" : "Subscribing",
120
- isStreamingProcessor,
121
- activeThreads: proc.running ? 1 : 0,
122
- availableThreads: 0,
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
- case "release-segment":
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, EventProcessorInfo_SegmentStatus } from "./generated/control.js"
1
+ import type { EventProcessorInfo } from "./generated/control.js"
2
2
 
3
3
  /**
4
- * Status of a single event processor, reported to Axon Server.
5
- * Aligned with Java's EventProcessorInfo proto message.
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
- export interface ProcessorStatus {
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 interface SegmentStatus {
21
- readonly segmentId: number
22
11
  readonly caughtUp: boolean
23
12
  readonly replaying: boolean
24
- readonly onePartOf: number
25
- readonly tokenPosition: bigint
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: status.mode,
36
- activeThreads: status.activeThreads,
20
+ mode: "Tracking",
21
+ activeThreads: status.running ? 1 : 0,
37
22
  running: status.running,
38
- error: status.error,
39
- segmentStatus: status.segments.map(toSegmentStatus),
40
- availableThreads: status.availableThreads,
41
- tokenStoreIdentifier: status.tokenStoreIdentifier,
42
- isStreamingProcessor: status.isStreamingProcessor,
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[]
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Aligned with Java's `FlowControlledResponseSender`.
9
9
  */
10
- export interface FlowControlledSender<T> {
10
+ export type FlowControlledSender<T> = {
11
11
  /** Send an update. Buffers if no permits available. */
12
12
  offer(value: T): boolean
13
13
  /** Grant additional permits to the sender. */
@@ -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
- try {
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
- send(value)
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
- isActive = false
77
- buffer.length = 0
78
- if (onComplete) onComplete()
98
+ completionRequested = true
99
+ finishIfDrained()
79
100
  },
80
101
 
81
102
  completeExceptionally(error: Error) {
82
- isActive = false
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
  }