@kronos-ts/axon-server 0.2.10 → 0.3.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 +1 -1
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +3 -3
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshot-store.d.ts +1 -1
- package/dist/axon-server-snapshot-store.d.ts.map +1 -1
- package/dist/axon-server-snapshot-store.js +1 -1
- package/dist/axon-server-snapshot-store.js.map +1 -1
- package/dist/axon-server.d.ts +169 -43
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +191 -323
- package/dist/axon-server.js.map +1 -1
- package/dist/connection-manager.d.ts +2 -2
- package/dist/connection-manager.d.ts.map +1 -1
- package/dist/connection-manager.js +1 -1
- package/dist/connection-manager.js.map +1 -1
- package/dist/control-plane.d.ts +108 -0
- package/dist/control-plane.d.ts.map +1 -0
- package/dist/control-plane.js +96 -0
- package/dist/control-plane.js.map +1 -0
- package/dist/flow-controlled-sender.d.ts +1 -1
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/flow-controlled-sender.js +1 -1
- package/dist/flow-controlled-sender.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -8
- package/dist/index.js.map +1 -1
- package/dist/message-size.d.ts +1 -1
- package/dist/message-size.d.ts.map +1 -1
- package/dist/message-size.js +1 -1
- package/dist/message-size.js.map +1 -1
- package/dist/outbound-stream.d.ts +1 -1
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/outbound-stream.js +1 -1
- package/dist/outbound-stream.js.map +1 -1
- package/dist/platform-service.d.ts +34 -3
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/platform-service.js +113 -42
- package/dist/platform-service.js.map +1 -1
- package/dist/shutdown-latch.d.ts +1 -1
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js +1 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/package.json +14 -8
- package/src/axon-server-event-store.ts +3 -3
- package/src/axon-server-snapshot-store.ts +1 -1
- package/src/axon-server.ts +283 -367
- package/src/connection-manager.ts +2 -2
- package/src/control-plane.ts +195 -0
- package/src/flow-controlled-sender.ts +1 -1
- package/src/index.ts +19 -8
- package/src/message-size.ts +1 -1
- package/src/outbound-stream.ts +1 -1
- package/src/platform-service.ts +152 -48
- package/src/shutdown-latch.ts +1 -1
package/src/axon-server.ts
CHANGED
|
@@ -1,34 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Axon Server backend for kronos.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `axonServer(config)` is an async factory: it connects eagerly, hands back
|
|
5
|
+
* the four components it provides (eventStore, snapshotStore, commandBus,
|
|
6
|
+
* queryBus), and gives you a `start`/`close` pair. There is no lifecycle
|
|
7
|
+
* framework — the ordering that used to be encoded as `onStart("connect")` /
|
|
8
|
+
* `onStart("processors")` / `onStop("connect")` is now three lines you write
|
|
9
|
+
* in your composition root:
|
|
6
10
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const axon = await axonServer({
|
|
13
|
+
* componentName: "university-service",
|
|
14
|
+
* serializer,
|
|
15
|
+
* unitOfWorkFactory,
|
|
16
|
+
* })
|
|
17
|
+
* const app = kronos({
|
|
18
|
+
* components: { ...inMemoryComponents({ serializer, unitOfWorkFactory }), ...axon.components },
|
|
19
|
+
* modules,
|
|
20
|
+
* })
|
|
21
|
+
* await axon.start() // readiness barrier: the server can route to our handlers
|
|
22
|
+
* // …
|
|
23
|
+
* await app.stop(); await axon.close()
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Connecting before the app is built is what removes the lazy proxies and
|
|
27
|
+
* subscribe-buffering wrappers the container version needed: by the time
|
|
28
|
+
* `kronos` subscribes a handler, the gRPC streams are already live.
|
|
29
|
+
*
|
|
30
|
+
* REMOTE ADMINISTRATION IS NOT IN HERE. Processor instructions (pause / start /
|
|
31
|
+
* release / split / merge) and processor status reporting are the platform
|
|
32
|
+
* CONTROL PLANE — they are neither persistence nor transport, and lived here
|
|
33
|
+
* only because they share this gRPC connection. They are now an opt-in second
|
|
34
|
+
* object built on the platform stream this backend exposes:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* const control = await axonServerControlPlane(axon.platform, app.processors.values())
|
|
38
|
+
* ```
|
|
18
39
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* protocol invariants byte-for-byte:
|
|
40
|
+
* `start()` therefore takes NO arguments and does exactly one thing: the
|
|
41
|
+
* data-path readiness barrier. See `control-plane.ts`.
|
|
42
|
+
*
|
|
43
|
+
* Axon-specific protocol invariants are preserved byte-for-byte:
|
|
23
44
|
*
|
|
24
45
|
* - CLIENT_SUPPORTS_STREAMING capability advertised on every dispatched
|
|
25
46
|
* query via `defaultQueryInstructions(...)`;
|
|
26
47
|
* - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers built by
|
|
27
48
|
* `createAxonMetadata(...)` and attached to every outbound stream/RPC;
|
|
28
49
|
* - permits-AFTER-subscriptions stream ordering preserved on the initial
|
|
29
|
-
* handshake AND on reconnect (
|
|
30
|
-
*
|
|
31
|
-
*
|
|
50
|
+
* handshake AND on reconnect (see `ensureStreamStarted` /
|
|
51
|
+
* `reestablishStreamBody`);
|
|
52
|
+
* - shutdown ordering: busLatches → platform.stop → connection.close.
|
|
32
53
|
*/
|
|
33
54
|
import {
|
|
34
55
|
qualifiedNameToString,
|
|
@@ -39,11 +60,9 @@ import {
|
|
|
39
60
|
healthCheck,
|
|
40
61
|
type ResilienceConfig,
|
|
41
62
|
} from "@kronos-ts/common"
|
|
42
|
-
import type { App } from "@kronos-ts/app"
|
|
43
63
|
import type {
|
|
44
64
|
CommandBus,
|
|
45
65
|
CommandMessage,
|
|
46
|
-
EventProcessorModule,
|
|
47
66
|
QueryBus,
|
|
48
67
|
QueryMessage,
|
|
49
68
|
SubscriptionFilter,
|
|
@@ -51,18 +70,25 @@ import type {
|
|
|
51
70
|
UoWRunner,
|
|
52
71
|
UpdateHandler,
|
|
53
72
|
} from "@kronos-ts/messaging"
|
|
54
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
applySubscriptionFilter,
|
|
75
|
+
correlationDataDispatchInterceptor,
|
|
76
|
+
interceptingCommandBus,
|
|
77
|
+
interceptingQueryBus,
|
|
78
|
+
updateHandler,
|
|
79
|
+
runAfterCommitOrImmediately,
|
|
80
|
+
} from "@kronos-ts/messaging"
|
|
55
81
|
import { Metadata } from "nice-grpc"
|
|
56
82
|
import type { AxonServerConnectionConfig } from "./connection.js"
|
|
57
83
|
import { connectToAxonServer, type AxonServerConnection } from "./connection.js"
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
84
|
+
import { axonServerEventStore } from "./axon-server-event-store.js"
|
|
85
|
+
import { axonServerSnapshotStore } from "./axon-server-snapshot-store.js"
|
|
60
86
|
import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
|
|
61
|
-
import {
|
|
87
|
+
import { outboundStream } from "./outbound-stream.js"
|
|
62
88
|
import { mapErrorCode, AxonServerErrorCode } from "./errors.js"
|
|
63
|
-
import {
|
|
89
|
+
import { shutdownLatch, type ShutdownLatch } from "./shutdown-latch.js"
|
|
64
90
|
import {
|
|
65
|
-
|
|
91
|
+
platformConnection,
|
|
66
92
|
type PlatformConnection,
|
|
67
93
|
type PlatformServiceOptions,
|
|
68
94
|
} from "./platform-service.js"
|
|
@@ -150,7 +176,7 @@ function createAxonMetadata(config: { context: string; token: string }): Metadat
|
|
|
150
176
|
return metadata
|
|
151
177
|
}
|
|
152
178
|
|
|
153
|
-
export interface
|
|
179
|
+
export interface AxonServerConfig extends AxonServerConnectionConfig {
|
|
154
180
|
/** Flow control for the command bus channel. */
|
|
155
181
|
commandFlowControl?: FlowControlConfig
|
|
156
182
|
/** Flow control for the query bus channel. */
|
|
@@ -187,336 +213,176 @@ export interface AxonServerExtensionConfig extends AxonServerConnectionConfig {
|
|
|
187
213
|
/** Per-extension resilience config (D-100 / D-101). */
|
|
188
214
|
resilience?: Partial<ResilienceConfig>
|
|
189
215
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
216
|
+
* How long `start()` waits for Axon Server's routing tables to register the
|
|
217
|
+
* subscribe frames sent on the command/query streams. This is the entire
|
|
218
|
+
* data-path readiness barrier.
|
|
219
|
+
*
|
|
220
|
+
* It is a timed wait rather than an observed signal because nothing on the
|
|
221
|
+
* client can observe it: subscribes travel on the bus streams, and the
|
|
222
|
+
* platform stream — which is where an ack would arrive — is a different
|
|
223
|
+
* stream that Axon Server holds open silently after `register`. Default:
|
|
224
|
+
* 1000, matching the legacy enhancer. Tests against a freshly-booted server
|
|
225
|
+
* can tighten this once subscriptions are observed to land faster.
|
|
196
226
|
*/
|
|
197
227
|
busSubscriptionAckDelayMs?: number
|
|
198
228
|
}
|
|
199
229
|
|
|
230
|
+
/** The components an Axon Server backend provides. Spread into `kronos`. */
|
|
231
|
+
export interface AxonServerComponents {
|
|
232
|
+
eventStore: ReturnType<typeof axonServerEventStore>
|
|
233
|
+
snapshotStore: ReturnType<typeof axonServerSnapshotStore>
|
|
234
|
+
commandBus: CommandBus
|
|
235
|
+
queryBus: QueryBus
|
|
236
|
+
}
|
|
237
|
+
|
|
200
238
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* ```ts
|
|
205
|
-
* await kronos()
|
|
206
|
-
* .use(axonServer({ componentName: "university-service" }))
|
|
207
|
-
* .start()
|
|
208
|
-
* ```
|
|
239
|
+
* A live Axon Server backend: the components it provides plus the two calls
|
|
240
|
+
* that used to be lifecycle stages.
|
|
209
241
|
*/
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
let connection: AxonServerConnection | undefined
|
|
213
|
-
let platform: PlatformConnection | undefined
|
|
214
|
-
const busLatches: ShutdownLatch[] = []
|
|
215
|
-
|
|
216
|
-
function getConnection(): AxonServerConnection {
|
|
217
|
-
if (!connection) {
|
|
218
|
-
throw new Error(
|
|
219
|
-
"[kronos:axon-server] connection not yet established — wait for onStart('connect')",
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
|
-
return connection
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ---- Slot population (D-95) -----------------------------------------
|
|
226
|
-
//
|
|
227
|
-
// AppImpl.start() in @kronos-ts/app eagerly resolves all 8 slots and
|
|
228
|
-
// runs `commandBus.subscribe(...)` for every registered handler BEFORE
|
|
229
|
-
// any onStart('connect') hook fires (see app.ts §3 / §5c). The Axon
|
|
230
|
-
// bus factories open real gRPC streams against the live channel during
|
|
231
|
-
// construction (createAxonMetadata / connection.onReconnect / inbound
|
|
232
|
-
// stream openers), so they CANNOT run until the connect hook has
|
|
233
|
-
// populated `connection`.
|
|
234
|
-
//
|
|
235
|
-
// Solution: the slot factories return wrappers around lazily-built
|
|
236
|
-
// inner instances. EventStore/SnapshotStore use a lightweight lazy
|
|
237
|
-
// proxy because their factories never dereference `connection` at
|
|
238
|
-
// construction time (only inside method bodies). CommandBus/QueryBus
|
|
239
|
-
// use a `subscribe()`-buffering wrapper that queues subscriptions
|
|
240
|
-
// synchronously and replays them once the connect hook completes —
|
|
241
|
-
// dispatch / query calls await the same readiness promise.
|
|
242
|
-
|
|
243
|
-
/** Latches once the connect hook has populated `connection`. */
|
|
244
|
-
let resolveConnected: () => void = () => {}
|
|
245
|
-
const connected: Promise<void> = new Promise((res) => {
|
|
246
|
-
resolveConnected = res
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
app.set("eventStore", (resolved) => {
|
|
250
|
-
// Lazy proxy: createAxonServerEventStore stores `connection` in
|
|
251
|
-
// closure scope but only dereferences it inside method bodies, so
|
|
252
|
-
// a Proxy that forwards property access to getConnection() works
|
|
253
|
-
// — by the time framework code calls source/append/stream the
|
|
254
|
-
// connect hook has populated the closure.
|
|
255
|
-
const lazyConnection = new Proxy({} as AxonServerConnection, {
|
|
256
|
-
get(_t, prop) {
|
|
257
|
-
return (getConnection() as any)[prop]
|
|
258
|
-
},
|
|
259
|
-
})
|
|
260
|
-
return createAxonServerEventStore(lazyConnection, resolved.serializer)
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
app.set("snapshotStore", (resolved) => {
|
|
264
|
-
const lazyConnection = new Proxy({} as AxonServerConnection, {
|
|
265
|
-
get(_t, prop) {
|
|
266
|
-
return (getConnection() as any)[prop]
|
|
267
|
-
},
|
|
268
|
-
})
|
|
269
|
-
return createAxonServerSnapshotStore(lazyConnection, resolved.serializer)
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
app.set("commandBus", (resolved) => {
|
|
273
|
-
const latch = createShutdownLatch()
|
|
274
|
-
busLatches.push(latch)
|
|
275
|
-
|
|
276
|
-
let inner: CommandBus | undefined
|
|
277
|
-
const pendingSubs: Array<[string, (m: CommandMessage) => Promise<unknown>]> = []
|
|
278
|
-
|
|
279
|
-
// Build the real bus once the connect hook fires + replay buffered subs.
|
|
280
|
-
connected.then(() => {
|
|
281
|
-
inner = createDistributedCommandBus(
|
|
282
|
-
getConnection(),
|
|
283
|
-
resolved.unitOfWorkFactory,
|
|
284
|
-
latch,
|
|
285
|
-
resolved.serializer,
|
|
286
|
-
serverConfig.commandFlowControl,
|
|
287
|
-
serverConfig.commandLoadFactor,
|
|
288
|
-
serverConfig.resilience,
|
|
289
|
-
)
|
|
290
|
-
for (const [name, h] of pendingSubs) inner.subscribe(name, h)
|
|
291
|
-
pendingSubs.length = 0
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
const wrapper: CommandBus = {
|
|
295
|
-
async dispatch(message) {
|
|
296
|
-
await connected
|
|
297
|
-
return inner!.dispatch(message)
|
|
298
|
-
},
|
|
299
|
-
subscribe(name, handler) {
|
|
300
|
-
if (inner) inner.subscribe(name, handler)
|
|
301
|
-
else pendingSubs.push([name, handler])
|
|
302
|
-
},
|
|
303
|
-
}
|
|
304
|
-
return wrapper
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
app.set("queryBus", (resolved) => {
|
|
308
|
-
const latch = createShutdownLatch()
|
|
309
|
-
busLatches.push(latch)
|
|
310
|
-
|
|
311
|
-
let inner: QueryBus | undefined
|
|
312
|
-
const pendingSubs: Array<[string, (m: QueryMessage) => Promise<unknown>]> = []
|
|
313
|
-
|
|
314
|
-
connected.then(() => {
|
|
315
|
-
inner = createDistributedQueryBus(
|
|
316
|
-
getConnection(),
|
|
317
|
-
resolved.unitOfWorkFactory,
|
|
318
|
-
latch,
|
|
319
|
-
resolved.serializer,
|
|
320
|
-
serverConfig.queryFlowControl,
|
|
321
|
-
serverConfig.shortcutQueriesToLocalHandlers,
|
|
322
|
-
serverConfig.queryTimeoutMs,
|
|
323
|
-
serverConfig.resilience,
|
|
324
|
-
)
|
|
325
|
-
for (const [name, h] of pendingSubs) inner.subscribe(name, h)
|
|
326
|
-
pendingSubs.length = 0
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
const wrapper: QueryBus = {
|
|
330
|
-
async query(message) {
|
|
331
|
-
await connected
|
|
332
|
-
return inner!.query(message)
|
|
333
|
-
},
|
|
334
|
-
subscribe(name, handler) {
|
|
335
|
-
if (inner) inner.subscribe(name, handler)
|
|
336
|
-
else pendingSubs.push([name, handler])
|
|
337
|
-
},
|
|
338
|
-
subscriptionQuery(message, bufferSize) {
|
|
339
|
-
if (!inner) {
|
|
340
|
-
throw new Error(
|
|
341
|
-
"[kronos:axon-server] subscriptionQuery called before connect hook completed",
|
|
342
|
-
)
|
|
343
|
-
}
|
|
344
|
-
return inner.subscriptionQuery(message, bufferSize)
|
|
345
|
-
},
|
|
346
|
-
subscribeToUpdates(message, bufferSize) {
|
|
347
|
-
if (!inner) {
|
|
348
|
-
throw new Error(
|
|
349
|
-
"[kronos:axon-server] subscribeToUpdates called before connect hook completed",
|
|
350
|
-
)
|
|
351
|
-
}
|
|
352
|
-
return inner.subscribeToUpdates(message, bufferSize)
|
|
353
|
-
},
|
|
354
|
-
async emitUpdate(name, filter, update) {
|
|
355
|
-
await connected
|
|
356
|
-
return inner!.emitUpdate(name, filter, update)
|
|
357
|
-
},
|
|
358
|
-
async completeSubscription(name, filter) {
|
|
359
|
-
await connected
|
|
360
|
-
return inner!.completeSubscription(name, filter)
|
|
361
|
-
},
|
|
362
|
-
async completeSubscriptionExceptionally(name, error, filter) {
|
|
363
|
-
await connected
|
|
364
|
-
return inner!.completeSubscriptionExceptionally(name, error, filter)
|
|
365
|
-
},
|
|
366
|
-
}
|
|
367
|
-
return wrapper
|
|
368
|
-
})
|
|
242
|
+
/** Everything axonServer() needs: its own config plus the framework values it borrows. */
|
|
243
|
+
export type AxonServerOptions = AxonServerConfig & { serializer: Serializer; unitOfWorkFactory: UoWRunner }
|
|
369
244
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
245
|
+
export interface AxonServerBackend {
|
|
246
|
+
readonly components: AxonServerComponents
|
|
247
|
+
/**
|
|
248
|
+
* The platform stream. It is built here because the backend owns the gRPC
|
|
249
|
+
* connection it rides on and the `platformService` tuning that configures it.
|
|
250
|
+
*
|
|
251
|
+
* `start()` below brings it up for the DATA path — heartbeats and reconnect
|
|
252
|
+
* detection — via `platform.armConnectionMonitoring()`. What it deliberately
|
|
253
|
+
* does NOT do is arm processor status reporting or route instructions: that is
|
|
254
|
+
* remote administration, and it stays opt-in behind
|
|
255
|
+
* `axonServerControlPlane(axon.platform, …)`, which registers its handler and
|
|
256
|
+
* supplier and then calls `platform.start()` on this same live stream.
|
|
257
|
+
*
|
|
258
|
+
* An instruction that arrives before a control plane exists is buffered by the
|
|
259
|
+
* platform connection and drained on the first `onInstruction` registration,
|
|
260
|
+
* so opening the stream early costs nothing.
|
|
261
|
+
*/
|
|
262
|
+
readonly platform: PlatformConnection
|
|
263
|
+
/**
|
|
264
|
+
* DATA-PATH START. Two things, both data path:
|
|
265
|
+
*
|
|
266
|
+
* 1. arm heartbeat-driven reconnect detection on the platform stream, and
|
|
267
|
+
* 2. wait until Axon Server can route to the handlers subscribed on the bus
|
|
268
|
+
* streams.
|
|
269
|
+
*
|
|
270
|
+
* Call AFTER `kronos` — the subscribe frames must already be on the wire for
|
|
271
|
+
* the readiness wait to mean anything.
|
|
272
|
+
*
|
|
273
|
+
* Takes no arguments and arms no control-plane state.
|
|
274
|
+
*/
|
|
275
|
+
start(): Promise<void>
|
|
276
|
+
/** Drain in-flight bus work, stop the platform stream, close the connection. */
|
|
277
|
+
close(): Promise<void>
|
|
278
|
+
}
|
|
378
279
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Connect to Axon Server and build the components it backs.
|
|
282
|
+
*
|
|
283
|
+
* `serializer` and `unitOfWorkFactory` are arguments rather than slot lookups:
|
|
284
|
+
* the buses serialize payloads with the former and run every inbound command /
|
|
285
|
+
* query in the latter, so they must be the SAME instances the rest of the app
|
|
286
|
+
* uses. Pass the ones you hand to `kronos`.
|
|
287
|
+
*/
|
|
288
|
+
export async function axonServer(
|
|
289
|
+
options: AxonServerOptions,
|
|
290
|
+
): Promise<AxonServerBackend> {
|
|
291
|
+
const config = options
|
|
292
|
+
const { serializer, unitOfWorkFactory, resilience } = config
|
|
293
|
+
|
|
294
|
+
const connection = await withRetry(async () => connectToAxonServer(config), {
|
|
295
|
+
event: "initial-connect",
|
|
296
|
+
...resilience,
|
|
297
|
+
})
|
|
389
298
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
platform.onInstruction(async (instruction) => {
|
|
400
|
-
switch (instruction.kind) {
|
|
401
|
-
case "pause-processor": {
|
|
402
|
-
const proc = processorMap.get(instruction.processorName) as any
|
|
403
|
-
if (proc?.stop) proc.stop()
|
|
404
|
-
break
|
|
405
|
-
}
|
|
406
|
-
case "start-processor": {
|
|
407
|
-
const proc = processorMap.get(instruction.processorName) as any
|
|
408
|
-
if (proc?.start) await proc.start()
|
|
409
|
-
break
|
|
410
|
-
}
|
|
411
|
-
case "release-segment": {
|
|
412
|
-
const proc = processorMap.get(instruction.processorName) as any
|
|
413
|
-
if (proc?.releaseSegment) await proc.releaseSegment(instruction.segmentId)
|
|
414
|
-
break
|
|
415
|
-
}
|
|
416
|
-
case "split-segment": {
|
|
417
|
-
const proc = processorMap.get(instruction.processorName) as any
|
|
418
|
-
if (proc?.splitSegment) await proc.splitSegment(instruction.segmentId)
|
|
419
|
-
break
|
|
420
|
-
}
|
|
421
|
-
case "merge-segment": {
|
|
422
|
-
const proc = processorMap.get(instruction.processorName) as any
|
|
423
|
-
if (proc?.mergeSegment) await proc.mergeSegment(instruction.segmentId)
|
|
424
|
-
break
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
})
|
|
299
|
+
// Health-check ping with warn-then-continue (D-100). AxonServerConnection has
|
|
300
|
+
// no dedicated probe surface today; the gRPC channel itself is created
|
|
301
|
+
// eagerly in connectToAxonServer so the meaningful probe is a round-trip — we
|
|
302
|
+
// approximate via a soft no-op promise that satisfies the threshold contract.
|
|
303
|
+
// Real network failure is surfaced by the first bus call against the channel.
|
|
304
|
+
await healthCheck(async () => undefined, {
|
|
305
|
+
thresholdMs: resilience?.healthCheckThresholdMs,
|
|
306
|
+
log: resilience?.log,
|
|
307
|
+
})
|
|
428
308
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}))
|
|
461
|
-
})
|
|
309
|
+
// One latch per bus, drained in close() before the transport goes away.
|
|
310
|
+
const commandLatch = shutdownLatch()
|
|
311
|
+
const queryLatch = shutdownLatch()
|
|
312
|
+
const busLatches: ShutdownLatch[] = [commandLatch, queryLatch]
|
|
313
|
+
|
|
314
|
+
// The connection is live before anything below is built, so the buses open
|
|
315
|
+
// their gRPC streams for real and `subscribe()` reaches the wire immediately —
|
|
316
|
+
// no lazy proxy, no subscription buffering, no readiness promise.
|
|
317
|
+
const components: AxonServerComponents = {
|
|
318
|
+
eventStore: axonServerEventStore(connection, serializer),
|
|
319
|
+
snapshotStore: axonServerSnapshotStore(connection, serializer),
|
|
320
|
+
commandBus: distributedCommandBus(
|
|
321
|
+
connection,
|
|
322
|
+
unitOfWorkFactory,
|
|
323
|
+
commandLatch,
|
|
324
|
+
serializer,
|
|
325
|
+
config.commandFlowControl,
|
|
326
|
+
config.commandLoadFactor,
|
|
327
|
+
resilience,
|
|
328
|
+
),
|
|
329
|
+
queryBus: distributedQueryBus(
|
|
330
|
+
connection,
|
|
331
|
+
unitOfWorkFactory,
|
|
332
|
+
queryLatch,
|
|
333
|
+
serializer,
|
|
334
|
+
config.queryFlowControl,
|
|
335
|
+
config.shortcutQueriesToLocalHandlers,
|
|
336
|
+
config.queryTimeoutMs,
|
|
337
|
+
resilience,
|
|
338
|
+
),
|
|
339
|
+
}
|
|
462
340
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
// This MUST happen synchronously before any subsequent stage hook so
|
|
469
|
-
// register/processors-stage code sees the fully-wired buses. The
|
|
470
|
-
// microtask queue drains the `.then(...)` callbacks attached in the
|
|
471
|
-
// slot factories before this hook resolves.
|
|
472
|
-
resolveConnected()
|
|
473
|
-
await Promise.resolve()
|
|
474
|
-
})
|
|
341
|
+
// Built here, started by the control plane (or by the caller). Constructing it
|
|
342
|
+
// eagerly is what lets the control plane be a separate object at all — and it
|
|
343
|
+
// keeps `platformService` tuning and `stop()` ownership in one place, so the
|
|
344
|
+
// documented shutdown order below holds whether or not anyone opted in.
|
|
345
|
+
const platform = platformConnection(connection, config.platformService)
|
|
475
346
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
// streams. The legacy enhancer carried this same 1s wait at line 264;
|
|
508
|
-
// it cannot be derived from the platform stream because subscribes
|
|
509
|
-
// travel on a different stream entirely.
|
|
510
|
-
await new Promise((r) => setTimeout(r, serverConfig.busSubscriptionAckDelayMs ?? 1000))
|
|
511
|
-
})
|
|
347
|
+
return {
|
|
348
|
+
components,
|
|
349
|
+
platform,
|
|
350
|
+
|
|
351
|
+
async start() {
|
|
352
|
+
// RECONNECT DETECTION IS DATA PATH. The heartbeat on the platform stream
|
|
353
|
+
// is what notices a dead channel and calls `connection.reconnect()`; both
|
|
354
|
+
// buses above hook `connection.onReconnect(...)` to rebuild their own
|
|
355
|
+
// streams. Arming it used to be a side effect of `platform.start()`, which
|
|
356
|
+
// only `axonServerControlPlane(...)` calls — so a service that never opted
|
|
357
|
+
// into remote administration had NO reconnect detection at all and would
|
|
358
|
+
// sit on a dead channel forever. It is armed here, unconditionally,
|
|
359
|
+
// independent of whether anyone administers this service.
|
|
360
|
+
//
|
|
361
|
+
// `armConnectionMonitoring()` opens the stream and starts heartbeats but
|
|
362
|
+
// arms NO processor status reporting; that stays the control plane's, and
|
|
363
|
+
// a later `platform.start()` adds it to this same live stream. Both calls
|
|
364
|
+
// are idempotent, so either order works.
|
|
365
|
+
await platform.armConnectionMonitoring()
|
|
366
|
+
|
|
367
|
+
// The only thing the data path has to wait for: Axon Server's
|
|
368
|
+
// command/query routing tables registering the subscribe frames sent on
|
|
369
|
+
// the BUS streams. It cannot be derived from the platform stream, because
|
|
370
|
+
// subscribes travel on a different stream entirely — and the platform
|
|
371
|
+
// stream's own `subscriptionsAcked()` latch says nothing about them (it
|
|
372
|
+
// latches unconditionally once `register` has been flushed; see
|
|
373
|
+
// platform-service.ts). So this barrier is the settle wait, and it is
|
|
374
|
+
// deliberately independent of whether the platform stream is up at all.
|
|
375
|
+
// The legacy enhancer carried the same 1s wait.
|
|
376
|
+
await new Promise((r) => setTimeout(r, config.busSubscriptionAckDelayMs ?? 1000))
|
|
377
|
+
},
|
|
512
378
|
|
|
513
|
-
|
|
514
|
-
// busLatches drained first → platform.stop → connection.close.
|
|
515
|
-
app.onStop("connect", async () => {
|
|
379
|
+
async close() {
|
|
516
380
|
await Promise.all(busLatches.map((l) => l.initiateShutdown()))
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
381
|
+
// Idempotent, and independent of `control.close()` — a backend that was
|
|
382
|
+
// never administered still stops a platform stream someone else started.
|
|
383
|
+
platform.stop()
|
|
384
|
+
connection.close()
|
|
385
|
+
},
|
|
520
386
|
}
|
|
521
387
|
}
|
|
522
388
|
|
|
@@ -563,7 +429,36 @@ function createPayloadHelpers(serializer: Serializer) {
|
|
|
563
429
|
* routes an inbound command to this node, it's executed on the local segment
|
|
564
430
|
* within a UnitOfWork.
|
|
565
431
|
*/
|
|
566
|
-
|
|
432
|
+
/**
|
|
433
|
+
* A command bus backed by Axon Server.
|
|
434
|
+
*
|
|
435
|
+
* ## Correlation lineage and the interceptor layer
|
|
436
|
+
*
|
|
437
|
+
* The returned bus is wrapped in {@link interceptingCommandBus} carrying
|
|
438
|
+
* {@link correlationDataDispatchInterceptor}, so lineage is stamped onto the
|
|
439
|
+
* outgoing message BEFORE it is serialized onto the wire.
|
|
440
|
+
*
|
|
441
|
+
* This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
|
|
442
|
+
* holds its own `DispatchInterceptors` and dispatches as
|
|
443
|
+
* `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
|
|
444
|
+
* site, at the top, ahead of any routing; and its `doDispatch` (like this one)
|
|
445
|
+
* always goes to the server, letting Axon Server decide where the command lands.
|
|
446
|
+
* AF5 keeps the property via decorator order:
|
|
447
|
+
* `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
|
|
448
|
+
* stacks `InterceptingCommandBus → DistributedCommandBus → SimpleCommandBus`.
|
|
449
|
+
*
|
|
450
|
+
* Before this wrap, an Axon-backed service lost lineage on EVERY command: the
|
|
451
|
+
* only registration of `correlationDataDispatchInterceptor` lives in
|
|
452
|
+
* `@kronos-ts/app`'s in-memory default bus, and `components.commandBus` from
|
|
453
|
+
* this backend replaces it wholesale.
|
|
454
|
+
*
|
|
455
|
+
* No double-application risk: the local segment here is a plain handler map, not
|
|
456
|
+
* a `CommandBus`, so this is the only interceptor in the chain. Inbound commands
|
|
457
|
+
* from the server are invoked through that map directly, which matches AF —
|
|
458
|
+
* `CommandProcessingTask` runs the local segment WITHOUT re-running dispatch
|
|
459
|
+
* interceptors.
|
|
460
|
+
*/
|
|
461
|
+
export function distributedCommandBus(
|
|
567
462
|
connection: AxonServerConnection,
|
|
568
463
|
unitOfWorkRunner: UoWRunner,
|
|
569
464
|
shutdownLatch: ShutdownLatch,
|
|
@@ -581,7 +476,7 @@ function createDistributedCommandBus(
|
|
|
581
476
|
const localSegment = new Map<string, (message: CommandMessage) => Promise<unknown>>()
|
|
582
477
|
|
|
583
478
|
// Bidirectional stream for handler registration + inbound command handling
|
|
584
|
-
let outbound =
|
|
479
|
+
let outbound = outboundStream<any>()
|
|
585
480
|
let streamStarted = false
|
|
586
481
|
let permits = 0n
|
|
587
482
|
|
|
@@ -612,7 +507,7 @@ function createDistributedCommandBus(
|
|
|
612
507
|
*/
|
|
613
508
|
function reestablishStreamBody() {
|
|
614
509
|
outbound.close()
|
|
615
|
-
outbound =
|
|
510
|
+
outbound = outboundStream<any>()
|
|
616
511
|
streamStarted = false
|
|
617
512
|
permits = 0n
|
|
618
513
|
ensureStreamStarted()
|
|
@@ -726,7 +621,7 @@ function createDistributedCommandBus(
|
|
|
726
621
|
}
|
|
727
622
|
}
|
|
728
623
|
|
|
729
|
-
|
|
624
|
+
const routing: CommandBus = {
|
|
730
625
|
async dispatch(message: CommandMessage): Promise<unknown> {
|
|
731
626
|
const activity = shutdownLatch.registerActivity()
|
|
732
627
|
try {
|
|
@@ -775,6 +670,11 @@ function createDistributedCommandBus(
|
|
|
775
670
|
grantPermits()
|
|
776
671
|
},
|
|
777
672
|
}
|
|
673
|
+
|
|
674
|
+
// Interception OUTSIDE routing — see the note on this function.
|
|
675
|
+
const bus = interceptingCommandBus(routing)
|
|
676
|
+
bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
|
|
677
|
+
return bus
|
|
778
678
|
}
|
|
779
679
|
|
|
780
680
|
// ---------------------------------------------------------------------------
|
|
@@ -789,8 +689,20 @@ function createDistributedCommandBus(
|
|
|
789
689
|
* - **Local segment**: Handlers registered here are stored locally and
|
|
790
690
|
* registered with Axon Server for inbound routing. Inbound queries
|
|
791
691
|
* are executed within a UnitOfWork.
|
|
692
|
+
*
|
|
693
|
+
* Wrapped in {@link interceptingQueryBus} with
|
|
694
|
+
* {@link correlationDataDispatchInterceptor}, matching AF4's
|
|
695
|
+
* `AxonServerQueryBus`, which calls `dispatchInterceptors.intercept(...)` at the
|
|
696
|
+
* top of `query`, `streamingQuery`, `scatterGather` and `subscriptionQuery`.
|
|
697
|
+
* Because the wrap is outside, the `shortcutQueriesToLocalHandlers` branch in
|
|
698
|
+
* `query()` gets identical lineage to the remote branch.
|
|
699
|
+
*
|
|
700
|
+
* KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
|
|
701
|
+
* straight from `message.metadata`, and `interceptingQueryBus` (in
|
|
702
|
+
* `@kronos-ts/messaging`) forwards those two calls to the delegate without
|
|
703
|
+
* running the dispatch chain. Closing that needs a messaging-package change.
|
|
792
704
|
*/
|
|
793
|
-
export function
|
|
705
|
+
export function distributedQueryBus(
|
|
794
706
|
connection: AxonServerConnection,
|
|
795
707
|
unitOfWorkRunner: UoWRunner,
|
|
796
708
|
shutdownLatch: ShutdownLatch,
|
|
@@ -818,7 +730,7 @@ export function createDistributedQueryBus(
|
|
|
818
730
|
// exact subscriber.
|
|
819
731
|
const handlerSubscriptions = new Map<string, { queryName: string; payload: unknown }>()
|
|
820
732
|
|
|
821
|
-
let outbound =
|
|
733
|
+
let outbound = outboundStream<any>()
|
|
822
734
|
let streamStarted = false
|
|
823
735
|
let permits = 0n
|
|
824
736
|
|
|
@@ -847,7 +759,7 @@ export function createDistributedQueryBus(
|
|
|
847
759
|
*/
|
|
848
760
|
function reestablishStreamBody() {
|
|
849
761
|
outbound.close()
|
|
850
|
-
outbound =
|
|
762
|
+
outbound = outboundStream<any>()
|
|
851
763
|
streamStarted = false
|
|
852
764
|
permits = 0n
|
|
853
765
|
ensureStreamStarted()
|
|
@@ -1040,7 +952,7 @@ export function createDistributedQueryBus(
|
|
|
1040
952
|
}
|
|
1041
953
|
}
|
|
1042
954
|
|
|
1043
|
-
|
|
955
|
+
const routing: QueryBus = {
|
|
1044
956
|
async query(message: QueryMessage): Promise<unknown> {
|
|
1045
957
|
const activity = shutdownLatch.registerActivity()
|
|
1046
958
|
try {
|
|
@@ -1107,13 +1019,13 @@ export function createDistributedQueryBus(
|
|
|
1107
1019
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
1108
1020
|
}
|
|
1109
1021
|
|
|
1110
|
-
const
|
|
1111
|
-
subscriptions.set(queryId,
|
|
1022
|
+
const handler = updateHandler(message, bufferSize)
|
|
1023
|
+
subscriptions.set(queryId, handler)
|
|
1112
1024
|
|
|
1113
1025
|
const queryName = qualifiedNameToString(message.name)
|
|
1114
1026
|
const subscriptionId = generateIdentifier()
|
|
1115
1027
|
|
|
1116
|
-
const outboundSub =
|
|
1028
|
+
const outboundSub = outboundStream<any>()
|
|
1117
1029
|
|
|
1118
1030
|
outboundSub.send({
|
|
1119
1031
|
subscribe: {
|
|
@@ -1174,12 +1086,12 @@ export function createDistributedQueryBus(
|
|
|
1174
1086
|
}
|
|
1175
1087
|
} else if (response.update) {
|
|
1176
1088
|
const update = deserializePayload(response.update.payload?.data as Uint8Array | undefined)
|
|
1177
|
-
|
|
1089
|
+
handler.offer(update)
|
|
1178
1090
|
} else if (response.complete) {
|
|
1179
|
-
|
|
1091
|
+
handler.complete()
|
|
1180
1092
|
break
|
|
1181
1093
|
} else if (response.completeExceptionally) {
|
|
1182
|
-
|
|
1094
|
+
handler.completeExceptionally(
|
|
1183
1095
|
new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"),
|
|
1184
1096
|
)
|
|
1185
1097
|
break
|
|
@@ -1191,7 +1103,7 @@ export function createDistributedQueryBus(
|
|
|
1191
1103
|
rejectInitial(error)
|
|
1192
1104
|
initialSettled = true
|
|
1193
1105
|
}
|
|
1194
|
-
|
|
1106
|
+
handler.completeExceptionally(error)
|
|
1195
1107
|
} finally {
|
|
1196
1108
|
subscriptions.delete(queryId)
|
|
1197
1109
|
}
|
|
@@ -1199,7 +1111,7 @@ export function createDistributedQueryBus(
|
|
|
1199
1111
|
|
|
1200
1112
|
return {
|
|
1201
1113
|
initialResult,
|
|
1202
|
-
updates:
|
|
1114
|
+
updates: handler.iterable,
|
|
1203
1115
|
close: () => {
|
|
1204
1116
|
outboundSub.send({
|
|
1205
1117
|
unsubscribe: {
|
|
@@ -1208,7 +1120,7 @@ export function createDistributedQueryBus(
|
|
|
1208
1120
|
})
|
|
1209
1121
|
outboundSub.close()
|
|
1210
1122
|
subscriptions.delete(queryId)
|
|
1211
|
-
|
|
1123
|
+
handler.complete()
|
|
1212
1124
|
},
|
|
1213
1125
|
}
|
|
1214
1126
|
},
|
|
@@ -1219,14 +1131,14 @@ export function createDistributedQueryBus(
|
|
|
1219
1131
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
1220
1132
|
}
|
|
1221
1133
|
|
|
1222
|
-
const
|
|
1223
|
-
subscriptions.set(queryId,
|
|
1134
|
+
const handler = updateHandler(message, bufferSize)
|
|
1135
|
+
subscriptions.set(queryId, handler)
|
|
1224
1136
|
|
|
1225
1137
|
return {
|
|
1226
|
-
[Symbol.asyncIterator]: () =>
|
|
1138
|
+
[Symbol.asyncIterator]: () => handler.iterable[Symbol.asyncIterator](),
|
|
1227
1139
|
close: () => {
|
|
1228
1140
|
subscriptions.delete(queryId)
|
|
1229
|
-
|
|
1141
|
+
handler.complete()
|
|
1230
1142
|
},
|
|
1231
1143
|
}
|
|
1232
1144
|
},
|
|
@@ -1320,4 +1232,8 @@ export function createDistributedQueryBus(
|
|
|
1320
1232
|
})
|
|
1321
1233
|
},
|
|
1322
1234
|
}
|
|
1235
|
+
|
|
1236
|
+
const bus = interceptingQueryBus(routing)
|
|
1237
|
+
bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
|
|
1238
|
+
return bus
|
|
1323
1239
|
}
|