@kronos-ts/axon-server 0.1.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 +16 -0
- package/dist/axon-server-event-store.d.ts.map +1 -0
- package/dist/axon-server-event-store.js +282 -0
- package/dist/axon-server-event-store.js.map +1 -0
- package/dist/axon-server-snapshot-store.d.ts +12 -0
- package/dist/axon-server-snapshot-store.d.ts.map +1 -0
- package/dist/axon-server-snapshot-store.js +88 -0
- package/dist/axon-server-snapshot-store.js.map +1 -0
- package/dist/axon-server.d.ts +115 -0
- package/dist/axon-server.d.ts.map +1 -0
- package/dist/axon-server.js +986 -0
- package/dist/axon-server.js.map +1 -0
- package/dist/connection-manager.d.ts +49 -0
- package/dist/connection-manager.d.ts.map +1 -0
- package/dist/connection-manager.js +37 -0
- package/dist/connection-manager.js.map +1 -0
- package/dist/connection.d.ts +129 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +130 -0
- package/dist/connection.js.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +189 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-processor-info.d.ts +35 -0
- package/dist/event-processor-info.d.ts.map +1 -0
- package/dist/event-processor-info.js +28 -0
- package/dist/event-processor-info.js.map +1 -0
- package/dist/flow-controlled-sender.d.ts +30 -0
- package/dist/flow-controlled-sender.d.ts.map +1 -0
- package/dist/flow-controlled-sender.js +60 -0
- package/dist/flow-controlled-sender.js.map +1 -0
- package/dist/generated/command.d.ts +158 -0
- package/dist/generated/command.d.ts.map +1 -0
- package/dist/generated/command.js +970 -0
- package/dist/generated/command.js.map +1 -0
- package/dist/generated/common.d.ts +130 -0
- package/dist/generated/common.d.ts.map +1 -0
- package/dist/generated/common.js +908 -0
- package/dist/generated/common.js.map +1 -0
- package/dist/generated/control.d.ts +293 -0
- package/dist/generated/control.d.ts.map +1 -0
- package/dist/generated/control.js +1938 -0
- package/dist/generated/control.js.map +1 -0
- package/dist/generated/dcb.d.ts +650 -0
- package/dist/generated/dcb.d.ts.map +1 -0
- package/dist/generated/dcb.js +2943 -0
- package/dist/generated/dcb.js.map +1 -0
- package/dist/generated/event.d.ts +667 -0
- package/dist/generated/event.d.ts.map +1 -0
- package/dist/generated/event.js +3185 -0
- package/dist/generated/event.js.map +1 -0
- package/dist/generated/google/protobuf/empty.d.ts +30 -0
- package/dist/generated/google/protobuf/empty.d.ts.map +1 -0
- package/dist/generated/google/protobuf/empty.js +46 -0
- package/dist/generated/google/protobuf/empty.js.map +1 -0
- package/dist/generated/query.d.ts +300 -0
- package/dist/generated/query.d.ts.map +1 -0
- package/dist/generated/query.js +2183 -0
- package/dist/generated/query.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/message-size.d.ts +38 -0
- package/dist/message-size.d.ts.map +1 -0
- package/dist/message-size.js +57 -0
- package/dist/message-size.js.map +1 -0
- package/dist/metadata-conversion.d.ts +11 -0
- package/dist/metadata-conversion.d.ts.map +1 -0
- package/dist/metadata-conversion.js +51 -0
- package/dist/metadata-conversion.js.map +1 -0
- package/dist/outbound-stream.d.ts +15 -0
- package/dist/outbound-stream.d.ts.map +1 -0
- package/dist/outbound-stream.js +39 -0
- package/dist/outbound-stream.js.map +1 -0
- package/dist/platform-service.d.ts +119 -0
- package/dist/platform-service.d.ts.map +1 -0
- package/dist/platform-service.js +250 -0
- package/dist/platform-service.js.map +1 -0
- package/dist/shutdown-latch.d.ts +38 -0
- package/dist/shutdown-latch.d.ts.map +1 -0
- package/dist/shutdown-latch.js +51 -0
- package/dist/shutdown-latch.js.map +1 -0
- package/package.json +69 -0
- package/src/axon-server-event-store.ts +358 -0
- package/src/axon-server-snapshot-store.ts +118 -0
- package/src/axon-server.ts +1202 -0
- package/src/connection-manager.ts +88 -0
- package/src/connection.ts +272 -0
- package/src/errors.ts +223 -0
- package/src/event-processor-info.ts +62 -0
- package/src/flow-controlled-sender.ts +91 -0
- package/src/generated/command.ts +1231 -0
- package/src/generated/common.ts +1097 -0
- package/src/generated/control.ts +2419 -0
- package/src/generated/dcb.ts +3826 -0
- package/src/generated/event.ts +4076 -0
- package/src/generated/google/protobuf/empty.ts +84 -0
- package/src/generated/query.ts +2723 -0
- package/src/index.ts +75 -0
- package/src/message-size.ts +75 -0
- package/src/metadata-conversion.ts +46 -0
- package/src/outbound-stream.ts +52 -0
- package/src/platform-service.ts +361 -0
- package/src/shutdown-latch.ts +97 -0
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Axon Server extension (Phase 9, D-95 / D-101 / D-102).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the legacy enhancer surface (now deleted) with a
|
|
5
|
+
* `(app: App) => void` extension that:
|
|
6
|
+
*
|
|
7
|
+
* - populates four typed slots (eventStore, snapshotStore, commandBus,
|
|
8
|
+
* queryBus) via app.set(...) using the canonical Resolved slot names
|
|
9
|
+
* (in particular `resolved.unitOfWorkFactory`, NOT `unitOfWorkRunner`);
|
|
10
|
+
* - wires connect-stage transport bring-up under the @kronos-ts/common
|
|
11
|
+
* resilience helper (initial-connect + health-check + platform setup +
|
|
12
|
+
* instruction handlers + platform.start);
|
|
13
|
+
* - wires processors-stage subscription-ack wait via withRetry against
|
|
14
|
+
* `platform.subscriptionsAcked()` — REPLACES the 1-second sleep hack
|
|
15
|
+
* that lived at line 264 of the legacy file (D-102 — Axon equivalent);
|
|
16
|
+
* - reverses shutdown deterministically in a single onStop('connect') hook
|
|
17
|
+
* (busLatches → platform.stop → connection.close — D-101.b).
|
|
18
|
+
*
|
|
19
|
+
* Mirrors `kronosdb.ts` (Plan 09-03) STRUCTURALLY — same slot+lifecycle
|
|
20
|
+
* pattern, same resilience helper, same shutdown ordering, same
|
|
21
|
+
* subscription-ack derivation strategy — but preserves Axon-specific
|
|
22
|
+
* protocol invariants byte-for-byte:
|
|
23
|
+
*
|
|
24
|
+
* - CLIENT_SUPPORTS_STREAMING capability advertised on every dispatched
|
|
25
|
+
* query via `defaultQueryInstructions(...)`;
|
|
26
|
+
* - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers built by
|
|
27
|
+
* `createAxonMetadata(...)` and attached to every outbound stream/RPC;
|
|
28
|
+
* - permits-AFTER-subscriptions stream ordering preserved on the initial
|
|
29
|
+
* handshake AND on reconnect (legacy semantics in `ensureStreamStarted`
|
|
30
|
+
* issued permits before subscriptions; this implementation matches that
|
|
31
|
+
* exact ordering — see `ensureStreamStarted` / `reestablishStreamBody`).
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
qualifiedNameToString,
|
|
35
|
+
qualifiedNameFromString,
|
|
36
|
+
generateIdentifier,
|
|
37
|
+
type Serializer,
|
|
38
|
+
withRetry,
|
|
39
|
+
healthCheck,
|
|
40
|
+
type ResilienceConfig,
|
|
41
|
+
} from "@kronos-ts/common"
|
|
42
|
+
import type { App } from "@kronos-ts/app"
|
|
43
|
+
import type {
|
|
44
|
+
CommandBus,
|
|
45
|
+
CommandMessage,
|
|
46
|
+
EventProcessorModule,
|
|
47
|
+
QueryBus,
|
|
48
|
+
QueryMessage,
|
|
49
|
+
SubscriptionQueryResult,
|
|
50
|
+
UoWRunner,
|
|
51
|
+
UpdateHandler,
|
|
52
|
+
} from "@kronos-ts/messaging"
|
|
53
|
+
import { createUpdateHandler, runAfterCommitOrImmediately } from "@kronos-ts/messaging"
|
|
54
|
+
import { Metadata } from "nice-grpc"
|
|
55
|
+
import type { AxonServerConnectionConfig } from "./connection.js"
|
|
56
|
+
import { connectToAxonServer, type AxonServerConnection } from "./connection.js"
|
|
57
|
+
import { createAxonServerEventStore } from "./axon-server-event-store.js"
|
|
58
|
+
import { createAxonServerSnapshotStore } from "./axon-server-snapshot-store.js"
|
|
59
|
+
import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
|
|
60
|
+
import { createOutboundStream } from "./outbound-stream.js"
|
|
61
|
+
import { mapErrorCode, AxonServerErrorCode } from "./errors.js"
|
|
62
|
+
import { createShutdownLatch, type ShutdownLatch } from "./shutdown-latch.js"
|
|
63
|
+
import {
|
|
64
|
+
createPlatformConnection,
|
|
65
|
+
type PlatformConnection,
|
|
66
|
+
type PlatformServiceOptions,
|
|
67
|
+
} from "./platform-service.js"
|
|
68
|
+
|
|
69
|
+
/** Default flow control settings — aligned with Java's 5000 permits. */
|
|
70
|
+
const DEFAULT_PERMITS = 5000n
|
|
71
|
+
const DEFAULT_THRESHOLD = 2500n
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Flow control configuration for a bus channel.
|
|
75
|
+
*/
|
|
76
|
+
export interface FlowControlConfig {
|
|
77
|
+
/** Initial permits granted to Axon Server. Default: 5000 (aligned with Java). */
|
|
78
|
+
permits?: number
|
|
79
|
+
/** Threshold at which to request more permits. Default: 2500 (aligned with Java). */
|
|
80
|
+
refillThreshold?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Processing instructions attached to outbound messages.
|
|
85
|
+
* Controls routing, priority, and timeout behavior on Axon Server.
|
|
86
|
+
*/
|
|
87
|
+
export interface ProcessingInstructions {
|
|
88
|
+
/** Routing key for consistent hashing (e.g., aggregate ID). */
|
|
89
|
+
routingKey?: string
|
|
90
|
+
/** Priority (higher = processed first). Default: 0 */
|
|
91
|
+
priority?: number
|
|
92
|
+
/** Timeout in ms. Axon Server cancels the command/query if not handled in time. */
|
|
93
|
+
timeoutMs?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Processing instruction keys — aligned with proto ProcessingKey enum.
|
|
97
|
+
// CLIENT_SUPPORTS_STREAMING (key=8) is an Axon-Server-specific capability
|
|
98
|
+
// advertisement that MUST survive the migration verbatim — see file-level
|
|
99
|
+
// JSDoc above and `defaultQueryInstructions` below.
|
|
100
|
+
const INSTRUCTION_KEY = {
|
|
101
|
+
ROUTING_KEY: 0,
|
|
102
|
+
PRIORITY: 1,
|
|
103
|
+
TIMEOUT: 2,
|
|
104
|
+
NR_OF_RESULTS: 3,
|
|
105
|
+
CLIENT_SUPPORTS_STREAMING: 8,
|
|
106
|
+
} as const
|
|
107
|
+
|
|
108
|
+
function toProtoProcessingInstructions(instructions?: ProcessingInstructions): any[] {
|
|
109
|
+
if (!instructions) return []
|
|
110
|
+
const result: any[] = []
|
|
111
|
+
if (instructions.routingKey !== undefined) {
|
|
112
|
+
result.push({ key: INSTRUCTION_KEY.ROUTING_KEY, value: { textValue: instructions.routingKey } })
|
|
113
|
+
}
|
|
114
|
+
if (instructions.priority !== undefined) {
|
|
115
|
+
result.push({ key: INSTRUCTION_KEY.PRIORITY, value: { numberValue: BigInt(instructions.priority) } })
|
|
116
|
+
}
|
|
117
|
+
if (instructions.timeoutMs !== undefined) {
|
|
118
|
+
result.push({ key: INSTRUCTION_KEY.TIMEOUT, value: { numberValue: BigInt(instructions.timeoutMs) } })
|
|
119
|
+
}
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build default processing instructions for query dispatch. The
|
|
125
|
+
* CLIENT_SUPPORTS_STREAMING capability is the Axon-specific protocol bit
|
|
126
|
+
* preserved from the legacy enhancer — Axon Server reads this on every
|
|
127
|
+
* query dispatch to decide whether to use streaming responses.
|
|
128
|
+
*/
|
|
129
|
+
function defaultQueryInstructions(timeoutMs: number): any[] {
|
|
130
|
+
return [
|
|
131
|
+
{ key: INSTRUCTION_KEY.TIMEOUT, value: { numberValue: BigInt(timeoutMs) } },
|
|
132
|
+
{ key: INSTRUCTION_KEY.NR_OF_RESULTS, value: { numberValue: 1n } },
|
|
133
|
+
{ key: INSTRUCTION_KEY.CLIENT_SUPPORTS_STREAMING, value: { booleanValue: true } },
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the gRPC metadata headers required by Axon Server. AxonIQ-Context
|
|
139
|
+
* is mandatory (identifies the tenant/context); AxonIQ-Access-Token is
|
|
140
|
+
* optional auth. Both must be attached to every outbound stream/RPC —
|
|
141
|
+
* preserved verbatim from the legacy enhancer.
|
|
142
|
+
*/
|
|
143
|
+
function createAxonMetadata(config: { context: string; token: string }): Metadata {
|
|
144
|
+
const metadata = new Metadata()
|
|
145
|
+
metadata.set("AxonIQ-Context", config.context)
|
|
146
|
+
if (config.token) {
|
|
147
|
+
metadata.set("AxonIQ-Access-Token", config.token)
|
|
148
|
+
}
|
|
149
|
+
return metadata
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface AxonServerExtensionConfig extends AxonServerConnectionConfig {
|
|
153
|
+
/** Flow control for the command bus channel. */
|
|
154
|
+
commandFlowControl?: FlowControlConfig
|
|
155
|
+
/** Flow control for the query bus channel. */
|
|
156
|
+
queryFlowControl?: FlowControlConfig
|
|
157
|
+
/** Platform service configuration (heartbeat, etc.). */
|
|
158
|
+
platformService?: PlatformServiceOptions
|
|
159
|
+
/**
|
|
160
|
+
* When true, queries are first checked against locally registered handlers
|
|
161
|
+
* before being dispatched through Axon Server. Avoids a network round-trip
|
|
162
|
+
* when the handler is co-located.
|
|
163
|
+
*
|
|
164
|
+
* Aligned with Java's `shortcutQueriesToLocalHandlers`.
|
|
165
|
+
* Default: false.
|
|
166
|
+
*/
|
|
167
|
+
shortcutQueriesToLocalHandlers?: boolean
|
|
168
|
+
/**
|
|
169
|
+
* Load factor for command handler registration.
|
|
170
|
+
* Signals to Axon Server how much capacity this handler has.
|
|
171
|
+
* Higher value = handler can take more commands.
|
|
172
|
+
*
|
|
173
|
+
* Aligned with Java's `commandLoadFactor`. Default: 100.
|
|
174
|
+
*/
|
|
175
|
+
commandLoadFactor?: number
|
|
176
|
+
/**
|
|
177
|
+
* Default timeout for command dispatch in ms. Default: 300000 (5 min).
|
|
178
|
+
* Aligned with Java's processing instruction timeout.
|
|
179
|
+
*/
|
|
180
|
+
commandTimeoutMs?: number
|
|
181
|
+
/**
|
|
182
|
+
* Default timeout for query dispatch in ms. Default: 3600000 (1 hour).
|
|
183
|
+
* Aligned with Java's processing instruction timeout.
|
|
184
|
+
*/
|
|
185
|
+
queryTimeoutMs?: number
|
|
186
|
+
/** Per-extension resilience config (D-100 / D-101). */
|
|
187
|
+
resilience?: Partial<ResilienceConfig>
|
|
188
|
+
/**
|
|
189
|
+
* Delay in ms after the platform-stream ack to give Axon Server's
|
|
190
|
+
* routing tables time to register the subscribe frames sent on the
|
|
191
|
+
* command/query streams. The platform stream cannot observe these (they
|
|
192
|
+
* travel on different streams). Default: 1000 — matches the legacy
|
|
193
|
+
* enhancer's wait. Tests against a freshly-booted server can tighten
|
|
194
|
+
* this once subscriptions are observed to land faster.
|
|
195
|
+
*/
|
|
196
|
+
busSubscriptionAckDelayMs?: number
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Native Axon Server extension factory. Returns an Extension closure shaped
|
|
201
|
+
* as `(app: App) => void` per D-95.
|
|
202
|
+
*
|
|
203
|
+
* ```ts
|
|
204
|
+
* await kronos()
|
|
205
|
+
* .use(axonServer({ componentName: "university-service" }))
|
|
206
|
+
* .start()
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
export function axonServer(serverConfig: AxonServerExtensionConfig): (app: App) => void {
|
|
210
|
+
return (app) => {
|
|
211
|
+
let connection: AxonServerConnection | undefined
|
|
212
|
+
let platform: PlatformConnection | undefined
|
|
213
|
+
const busLatches: ShutdownLatch[] = []
|
|
214
|
+
|
|
215
|
+
function getConnection(): AxonServerConnection {
|
|
216
|
+
if (!connection) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
"[kronos:axon-server] connection not yet established — wait for onStart('connect')",
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
return connection
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- Slot population (D-95) -----------------------------------------
|
|
225
|
+
//
|
|
226
|
+
// AppImpl.start() in @kronos-ts/app eagerly resolves all 8 slots and
|
|
227
|
+
// runs `commandBus.subscribe(...)` for every registered handler BEFORE
|
|
228
|
+
// any onStart('connect') hook fires (see app.ts §3 / §5c). The Axon
|
|
229
|
+
// bus factories open real gRPC streams against the live channel during
|
|
230
|
+
// construction (createAxonMetadata / connection.onReconnect / inbound
|
|
231
|
+
// stream openers), so they CANNOT run until the connect hook has
|
|
232
|
+
// populated `connection`.
|
|
233
|
+
//
|
|
234
|
+
// Solution: the slot factories return wrappers around lazily-built
|
|
235
|
+
// inner instances. EventStore/SnapshotStore use a lightweight lazy
|
|
236
|
+
// proxy because their factories never dereference `connection` at
|
|
237
|
+
// construction time (only inside method bodies). CommandBus/QueryBus
|
|
238
|
+
// use a `subscribe()`-buffering wrapper that queues subscriptions
|
|
239
|
+
// synchronously and replays them once the connect hook completes —
|
|
240
|
+
// dispatch / query calls await the same readiness promise.
|
|
241
|
+
|
|
242
|
+
/** Latches once the connect hook has populated `connection`. */
|
|
243
|
+
let resolveConnected: () => void = () => {}
|
|
244
|
+
const connected: Promise<void> = new Promise((res) => {
|
|
245
|
+
resolveConnected = res
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
app.set("eventStore", (resolved) => {
|
|
249
|
+
// Lazy proxy: createAxonServerEventStore stores `connection` in
|
|
250
|
+
// closure scope but only dereferences it inside method bodies, so
|
|
251
|
+
// a Proxy that forwards property access to getConnection() works
|
|
252
|
+
// — by the time framework code calls source/append/stream the
|
|
253
|
+
// connect hook has populated the closure.
|
|
254
|
+
const lazyConnection = new Proxy({} as AxonServerConnection, {
|
|
255
|
+
get(_t, prop) {
|
|
256
|
+
return (getConnection() as any)[prop]
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
return createAxonServerEventStore(lazyConnection, resolved.serializer)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
app.set("snapshotStore", (resolved) => {
|
|
263
|
+
const lazyConnection = new Proxy({} as AxonServerConnection, {
|
|
264
|
+
get(_t, prop) {
|
|
265
|
+
return (getConnection() as any)[prop]
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
return createAxonServerSnapshotStore(lazyConnection, resolved.serializer)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
app.set("commandBus", (resolved) => {
|
|
272
|
+
const latch = createShutdownLatch()
|
|
273
|
+
busLatches.push(latch)
|
|
274
|
+
|
|
275
|
+
let inner: CommandBus | undefined
|
|
276
|
+
const pendingSubs: Array<[string, (m: CommandMessage) => Promise<unknown>]> = []
|
|
277
|
+
|
|
278
|
+
// Build the real bus once the connect hook fires + replay buffered subs.
|
|
279
|
+
connected.then(() => {
|
|
280
|
+
inner = createDistributedCommandBus(
|
|
281
|
+
getConnection(),
|
|
282
|
+
resolved.unitOfWorkFactory,
|
|
283
|
+
latch,
|
|
284
|
+
resolved.serializer,
|
|
285
|
+
serverConfig.commandFlowControl,
|
|
286
|
+
serverConfig.commandLoadFactor,
|
|
287
|
+
serverConfig.resilience,
|
|
288
|
+
)
|
|
289
|
+
for (const [name, h] of pendingSubs) inner.subscribe(name, h)
|
|
290
|
+
pendingSubs.length = 0
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const wrapper: CommandBus = {
|
|
294
|
+
async dispatch(message) {
|
|
295
|
+
await connected
|
|
296
|
+
return inner!.dispatch(message)
|
|
297
|
+
},
|
|
298
|
+
subscribe(name, handler) {
|
|
299
|
+
if (inner) inner.subscribe(name, handler)
|
|
300
|
+
else pendingSubs.push([name, handler])
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
return wrapper
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
app.set("queryBus", (resolved) => {
|
|
307
|
+
const latch = createShutdownLatch()
|
|
308
|
+
busLatches.push(latch)
|
|
309
|
+
|
|
310
|
+
let inner: QueryBus | undefined
|
|
311
|
+
const pendingSubs: Array<[string, (m: QueryMessage) => Promise<unknown>]> = []
|
|
312
|
+
|
|
313
|
+
connected.then(() => {
|
|
314
|
+
inner = createDistributedQueryBus(
|
|
315
|
+
getConnection(),
|
|
316
|
+
resolved.unitOfWorkFactory,
|
|
317
|
+
latch,
|
|
318
|
+
resolved.serializer,
|
|
319
|
+
serverConfig.queryFlowControl,
|
|
320
|
+
serverConfig.shortcutQueriesToLocalHandlers,
|
|
321
|
+
serverConfig.queryTimeoutMs,
|
|
322
|
+
serverConfig.resilience,
|
|
323
|
+
)
|
|
324
|
+
for (const [name, h] of pendingSubs) inner.subscribe(name, h)
|
|
325
|
+
pendingSubs.length = 0
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const wrapper: QueryBus = {
|
|
329
|
+
async query(message) {
|
|
330
|
+
await connected
|
|
331
|
+
return inner!.query(message)
|
|
332
|
+
},
|
|
333
|
+
subscribe(name, handler) {
|
|
334
|
+
if (inner) inner.subscribe(name, handler)
|
|
335
|
+
else pendingSubs.push([name, handler])
|
|
336
|
+
},
|
|
337
|
+
subscriptionQuery(message, bufferSize) {
|
|
338
|
+
if (!inner) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
"[kronos:axon-server] subscriptionQuery called before connect hook completed",
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
return inner.subscriptionQuery(message, bufferSize)
|
|
344
|
+
},
|
|
345
|
+
subscribeToUpdates(message, bufferSize) {
|
|
346
|
+
if (!inner) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
"[kronos:axon-server] subscribeToUpdates called before connect hook completed",
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
return inner.subscribeToUpdates(message, bufferSize)
|
|
352
|
+
},
|
|
353
|
+
async emitUpdate(name, filter, update) {
|
|
354
|
+
await connected
|
|
355
|
+
return inner!.emitUpdate(name, filter, update)
|
|
356
|
+
},
|
|
357
|
+
async completeSubscription(name, filter) {
|
|
358
|
+
await connected
|
|
359
|
+
return inner!.completeSubscription(name, filter)
|
|
360
|
+
},
|
|
361
|
+
async completeSubscriptionExceptionally(name, error, filter) {
|
|
362
|
+
await connected
|
|
363
|
+
return inner!.completeSubscriptionExceptionally(name, error, filter)
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
return wrapper
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// ---- Lifecycle: connect (D-101 normative split) ---------------------
|
|
370
|
+
// connect = initial connect + health-check + platform setup +
|
|
371
|
+
// instruction wiring + platform.start.
|
|
372
|
+
app.onStart("connect", async () => {
|
|
373
|
+
connection = await withRetry(
|
|
374
|
+
async () => connectToAxonServer(serverConfig),
|
|
375
|
+
{ event: "initial-connect", ...serverConfig.resilience },
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
// Health-check ping with warn-then-continue (D-100). AxonServerConnection
|
|
379
|
+
// has no dedicated probe surface today; the gRPC channel itself is
|
|
380
|
+
// created eagerly in connectToAxonServer so the meaningful probe is a
|
|
381
|
+
// round-trip — we approximate via a soft no-op promise that satisfies
|
|
382
|
+
// the threshold contract. Real network failure is surfaced by the
|
|
383
|
+
// first bus call against the live channel.
|
|
384
|
+
await healthCheck(async () => undefined, {
|
|
385
|
+
thresholdMs: serverConfig.resilience?.healthCheckThresholdMs,
|
|
386
|
+
log: serverConfig.resilience?.log,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
platform = createPlatformConnection(connection!, serverConfig.platformService)
|
|
390
|
+
|
|
391
|
+
// Build a name-keyed view of the EventProcessorModule list so server-
|
|
392
|
+
// initiated instructions can route to the right module. We resolve via
|
|
393
|
+
// `app.processors()` — Plan 09-01's zero-arg read accessor (D-103).
|
|
394
|
+
const processors = app.processors()
|
|
395
|
+
const processorMap = new Map<string, EventProcessorModule>()
|
|
396
|
+
for (const proc of processors) processorMap.set(proc.name, proc)
|
|
397
|
+
|
|
398
|
+
platform.onInstruction(async (instruction) => {
|
|
399
|
+
switch (instruction.kind) {
|
|
400
|
+
case "pause-processor": {
|
|
401
|
+
const proc = processorMap.get(instruction.processorName) as any
|
|
402
|
+
if (proc?.stop) proc.stop()
|
|
403
|
+
break
|
|
404
|
+
}
|
|
405
|
+
case "start-processor": {
|
|
406
|
+
const proc = processorMap.get(instruction.processorName) as any
|
|
407
|
+
if (proc?.start) await proc.start()
|
|
408
|
+
break
|
|
409
|
+
}
|
|
410
|
+
case "release-segment": {
|
|
411
|
+
const proc = processorMap.get(instruction.processorName) as any
|
|
412
|
+
if (proc?.releaseSegment) await proc.releaseSegment(instruction.segmentId)
|
|
413
|
+
break
|
|
414
|
+
}
|
|
415
|
+
case "split-segment": {
|
|
416
|
+
const proc = processorMap.get(instruction.processorName) as any
|
|
417
|
+
if (proc?.splitSegment) await proc.splitSegment(instruction.segmentId)
|
|
418
|
+
break
|
|
419
|
+
}
|
|
420
|
+
case "merge-segment": {
|
|
421
|
+
const proc = processorMap.get(instruction.processorName) as any
|
|
422
|
+
if (proc?.mergeSegment) await proc.mergeSegment(instruction.segmentId)
|
|
423
|
+
break
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
platform.registerProcessorStatusSupplier(() => {
|
|
429
|
+
return processors.map((proc: any) => ({
|
|
430
|
+
name: proc.name,
|
|
431
|
+
running: proc.running ?? false,
|
|
432
|
+
mode: proc.supportsReset?.() === false ? "Subscribing" : "Tracking",
|
|
433
|
+
isStreamingProcessor: proc.supportsReset?.() !== false,
|
|
434
|
+
activeThreads: proc.running ? 1 : 0,
|
|
435
|
+
availableThreads: 0,
|
|
436
|
+
error: false,
|
|
437
|
+
tokenStoreIdentifier: "",
|
|
438
|
+
segments: proc.processingStatus
|
|
439
|
+
? Array.from(proc.processingStatus().entries() as Iterable<[number, any]>).map(
|
|
440
|
+
([segId, status]: [number, any]) => ({
|
|
441
|
+
segmentId: segId,
|
|
442
|
+
caughtUp: status.caughtUp ?? false,
|
|
443
|
+
replaying: status.replaying ?? false,
|
|
444
|
+
onePartOf: 1,
|
|
445
|
+
tokenPosition: status.position ?? 0n,
|
|
446
|
+
errorState: status.error?.message ?? "",
|
|
447
|
+
}),
|
|
448
|
+
)
|
|
449
|
+
: [
|
|
450
|
+
{
|
|
451
|
+
segmentId: 0,
|
|
452
|
+
caughtUp: true,
|
|
453
|
+
replaying: proc.replaying ?? false,
|
|
454
|
+
onePartOf: 1,
|
|
455
|
+
tokenPosition: proc.position ?? 0n,
|
|
456
|
+
errorState: "",
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
}))
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
await platform.start()
|
|
463
|
+
|
|
464
|
+
// Latch the connected promise so the deferred bus wrappers built in
|
|
465
|
+
// the slot factories above construct their inner instances and replay
|
|
466
|
+
// any subscriptions that were buffered while connect was running.
|
|
467
|
+
// This MUST happen synchronously before any subsequent stage hook so
|
|
468
|
+
// register/processors-stage code sees the fully-wired buses. The
|
|
469
|
+
// microtask queue drains the `.then(...)` callbacks attached in the
|
|
470
|
+
// slot factories before this hook resolves.
|
|
471
|
+
resolveConnected()
|
|
472
|
+
await Promise.resolve()
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
// ---- Lifecycle: processors (D-101 / D-102) --------------------------
|
|
476
|
+
// processors = subscription-ack wait. The two-step shape mirrors the
|
|
477
|
+
// kronosdb sibling (Plan 09-03 / D-102) but is adapted for Axon Server's
|
|
478
|
+
// protocol shape, which differs from kronosdb's in one observable way:
|
|
479
|
+
//
|
|
480
|
+
// - kronosdb's PlatformService proactively emits a frame in response
|
|
481
|
+
// to `register`, so its `subscriptionsAcked` latches on the first
|
|
482
|
+
// inbound platform-stream message.
|
|
483
|
+
//
|
|
484
|
+
// - Axon Server's PlatformService holds the stream open silently
|
|
485
|
+
// until either a topology change or a heartbeat round-trip occurs.
|
|
486
|
+
// The platform stream therefore latches `acked` synchronously once
|
|
487
|
+
// the `register` frame has been flushed (see platform-service.ts).
|
|
488
|
+
//
|
|
489
|
+
// The bus-side subscription frames (sent on the command/query streams,
|
|
490
|
+
// not the platform stream) need a small processing window on the
|
|
491
|
+
// server before commands dispatched here are routed back to our
|
|
492
|
+
// handler. Empirically Axon Server processes the subscribe within
|
|
493
|
+
// 1 second — same number the legacy enhancer used. Wrapped in the same
|
|
494
|
+
// `withRetry({event: "per-operation"})` shape as kronosdb so per-extension
|
|
495
|
+
// resilience overrides still apply uniformly.
|
|
496
|
+
app.onStart("processors", async () => {
|
|
497
|
+
await withRetry(
|
|
498
|
+
async () => {
|
|
499
|
+
const ok = await platform!.subscriptionsAcked()
|
|
500
|
+
if (!ok) throw new Error("axon-server subscriptions not yet acked")
|
|
501
|
+
},
|
|
502
|
+
{ event: "per-operation", ...serverConfig.resilience },
|
|
503
|
+
)
|
|
504
|
+
// Axon-specific: give the server's command/query routing tables a
|
|
505
|
+
// beat to register the subscribe frames we just sent on the bus
|
|
506
|
+
// streams. The legacy enhancer carried this same 1s wait at line 264;
|
|
507
|
+
// it cannot be derived from the platform stream because subscribes
|
|
508
|
+
// travel on a different stream entirely.
|
|
509
|
+
await new Promise((r) => setTimeout(r, serverConfig.busSubscriptionAckDelayMs ?? 1000))
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// ---- Lifecycle: stop (D-101.b — preserves legacy ordering) ----------
|
|
513
|
+
// busLatches drained first → platform.stop → connection.close.
|
|
514
|
+
app.onStop("connect", async () => {
|
|
515
|
+
await Promise.all(busLatches.map((l) => l.initiateShutdown()))
|
|
516
|
+
platform?.stop()
|
|
517
|
+
connection?.close()
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Shared payload helpers (moved verbatim from legacy enhancer)
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
function createPayloadHelpers(serializer: Serializer) {
|
|
527
|
+
return {
|
|
528
|
+
serializePayload(name: string, payload: unknown, revision: string = "") {
|
|
529
|
+
return serializer.serialize(payload, name, revision)
|
|
530
|
+
},
|
|
531
|
+
deserializePayload(data: Uint8Array | undefined, type: string = "", revision: string = ""): unknown {
|
|
532
|
+
if (!data || data.length === 0) return undefined
|
|
533
|
+
return serializer.deserialize({ data, type, revision })
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Distributed Command Bus
|
|
540
|
+
//
|
|
541
|
+
// Bus implementation moved verbatim from the legacy enhancer with TWO
|
|
542
|
+
// behavioural additions per D-97:
|
|
543
|
+
// 1) reestablishStream() body wrapped in withRetry({ event: "reconnect" })
|
|
544
|
+
// 2) inbound-stream backoff replaced by the same withRetry path
|
|
545
|
+
//
|
|
546
|
+
// Axon-specific protocol invariants preserved BYTE-FOR-BYTE:
|
|
547
|
+
// - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers via
|
|
548
|
+
// createAxonMetadata(connection.config)
|
|
549
|
+
// - permits-AFTER-subscriptions ordering on reestablishStreamBody (subs
|
|
550
|
+
// are sent BEFORE grantPermits() in the reconnect path; the initial
|
|
551
|
+
// handshake matches this — see ensureStreamStarted's grantPermits call
|
|
552
|
+
// in subscribe()).
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* A command bus backed by Axon Server.
|
|
557
|
+
*
|
|
558
|
+
* - **Outbound dispatch**: Always goes through Axon Server via the unary Dispatch RPC.
|
|
559
|
+
* Axon Server routes the command to the appropriate node (which may be this one).
|
|
560
|
+
* - **Local segment**: Handlers subscribed via `subscribe()` are registered with
|
|
561
|
+
* Axon Server (so other nodes can route to us) and stored locally. When Axon Server
|
|
562
|
+
* routes an inbound command to this node, it's executed on the local segment
|
|
563
|
+
* within a UnitOfWork.
|
|
564
|
+
*/
|
|
565
|
+
function createDistributedCommandBus(
|
|
566
|
+
connection: AxonServerConnection,
|
|
567
|
+
unitOfWorkRunner: UoWRunner,
|
|
568
|
+
shutdownLatch: ShutdownLatch,
|
|
569
|
+
serializer: Serializer,
|
|
570
|
+
flowControl?: FlowControlConfig,
|
|
571
|
+
commandLoadFactor?: number,
|
|
572
|
+
resilience?: Partial<ResilienceConfig>,
|
|
573
|
+
): CommandBus {
|
|
574
|
+
const metadata = createAxonMetadata(connection.config)
|
|
575
|
+
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
|
|
576
|
+
const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
|
|
577
|
+
const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
|
|
578
|
+
|
|
579
|
+
// Local segment — handlers that execute on this node
|
|
580
|
+
const localSegment = new Map<string, (message: CommandMessage) => Promise<unknown>>()
|
|
581
|
+
|
|
582
|
+
// Bidirectional stream for handler registration + inbound command handling
|
|
583
|
+
let outbound = createOutboundStream<any>()
|
|
584
|
+
let streamStarted = false
|
|
585
|
+
let permits = 0n
|
|
586
|
+
|
|
587
|
+
function ensureStreamStarted() {
|
|
588
|
+
if (streamStarted) return
|
|
589
|
+
streamStarted = true
|
|
590
|
+
|
|
591
|
+
// Open stream using connection.commands (always gets current client after reconnect)
|
|
592
|
+
const inbound = connection.commands.openStream(outbound.iterable, { metadata })
|
|
593
|
+
processInboundCommands(inbound)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function grantPermits() {
|
|
597
|
+
outbound.send({
|
|
598
|
+
flowControl: { clientId: connection.config.clientId, permits: PERMITS },
|
|
599
|
+
instructionId: "",
|
|
600
|
+
})
|
|
601
|
+
permits += PERMITS
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Re-establish the bidirectional stream and re-subscribe all handlers.
|
|
606
|
+
* Called on stream error or when the connection reconnects.
|
|
607
|
+
*
|
|
608
|
+
* ORDER (preserves Axon-specific invariant): subscriptions are
|
|
609
|
+
* re-emitted BEFORE the permits frame. Sending permits first would
|
|
610
|
+
* trigger a server-side stream error.
|
|
611
|
+
*/
|
|
612
|
+
function reestablishStreamBody() {
|
|
613
|
+
outbound.close()
|
|
614
|
+
outbound = createOutboundStream<any>()
|
|
615
|
+
streamStarted = false
|
|
616
|
+
permits = 0n
|
|
617
|
+
ensureStreamStarted()
|
|
618
|
+
// Re-subscribe all handlers FIRST
|
|
619
|
+
for (const commandName of localSegment.keys()) {
|
|
620
|
+
outbound.send({
|
|
621
|
+
subscribe: {
|
|
622
|
+
messageId: generateIdentifier(),
|
|
623
|
+
command: commandName,
|
|
624
|
+
componentName: connection.config.componentName,
|
|
625
|
+
clientId: connection.config.clientId,
|
|
626
|
+
loadFactor: commandLoadFactor ?? 100,
|
|
627
|
+
},
|
|
628
|
+
instructionId: generateIdentifier(),
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
// Permits AFTER subscriptions (Axon-specific ordering invariant)
|
|
632
|
+
grantPermits()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function reestablishStreamWithRetry() {
|
|
636
|
+
if (shutdownLatch.shuttingDown) return
|
|
637
|
+
await withRetry(async () => reestablishStreamBody(), {
|
|
638
|
+
event: "reconnect",
|
|
639
|
+
...resilience,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
|
|
644
|
+
connection.onReconnect(() => {
|
|
645
|
+
if (!shutdownLatch.shuttingDown && streamStarted) {
|
|
646
|
+
reestablishStreamWithRetry().catch((err) => {
|
|
647
|
+
console.error("Distributed command bus: reconnect retries exhausted", err)
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
async function processInboundCommands(inbound: AsyncIterable<any>) {
|
|
653
|
+
try {
|
|
654
|
+
for await (const message of inbound) {
|
|
655
|
+
if (!message.command) continue
|
|
656
|
+
|
|
657
|
+
permits--
|
|
658
|
+
const proto = message.command
|
|
659
|
+
const commandName = proto.name
|
|
660
|
+
const handler = localSegment.get(commandName)
|
|
661
|
+
|
|
662
|
+
let resultPayload: unknown
|
|
663
|
+
let errorCode = ""
|
|
664
|
+
let errorMsg = ""
|
|
665
|
+
|
|
666
|
+
if (handler) {
|
|
667
|
+
try {
|
|
668
|
+
const commandMessage: CommandMessage = {
|
|
669
|
+
identifier: proto.messageIdentifier,
|
|
670
|
+
name: qualifiedNameFromString(commandName),
|
|
671
|
+
payload: deserializePayload(proto.payload?.data as Uint8Array | undefined),
|
|
672
|
+
metadata: metadataFromProto(proto.metaData),
|
|
673
|
+
timestamp: Number(proto.timestamp),
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Execute inbound command within its own UnitOfWork (AF5 parity)
|
|
677
|
+
resultPayload = await unitOfWorkRunner(commandMessage.metadata, () =>
|
|
678
|
+
handler(commandMessage),
|
|
679
|
+
)
|
|
680
|
+
} catch (err) {
|
|
681
|
+
errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
|
|
682
|
+
errorMsg = err instanceof Error ? err.message : String(err)
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_COMMAND
|
|
686
|
+
errorMsg = `No local handler for command "${commandName}"`
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Send response back to Axon Server
|
|
690
|
+
outbound.send({
|
|
691
|
+
commandResponse: {
|
|
692
|
+
messageIdentifier: generateIdentifier(),
|
|
693
|
+
requestIdentifier: proto.messageIdentifier,
|
|
694
|
+
errorCode,
|
|
695
|
+
errorMessage: errorCode
|
|
696
|
+
? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
|
|
697
|
+
: undefined,
|
|
698
|
+
payload: resultPayload !== undefined
|
|
699
|
+
? serializePayload("result", resultPayload)
|
|
700
|
+
: undefined,
|
|
701
|
+
metaData: {},
|
|
702
|
+
processingInstructions: [],
|
|
703
|
+
},
|
|
704
|
+
instructionId: "",
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
// Refill permits when running low
|
|
708
|
+
if (permits <= THRESHOLD) {
|
|
709
|
+
outbound.send({
|
|
710
|
+
flowControl: { clientId: connection.config.clientId, permits: PERMITS },
|
|
711
|
+
instructionId: "",
|
|
712
|
+
})
|
|
713
|
+
permits += PERMITS
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
} catch (err) {
|
|
717
|
+
if (shutdownLatch.shuttingDown) return
|
|
718
|
+
if (String(err).includes("Connection dropped")) return
|
|
719
|
+
|
|
720
|
+
console.error("Distributed command bus: inbound stream error, attempting re-establishment via withRetry", err)
|
|
721
|
+
await reestablishStreamWithRetry().catch((retryErr) => {
|
|
722
|
+
console.error("Distributed command bus: reconnect retries exhausted", retryErr)
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
async dispatch(message: CommandMessage): Promise<unknown> {
|
|
729
|
+
const activity = shutdownLatch.registerActivity()
|
|
730
|
+
try {
|
|
731
|
+
const commandName = qualifiedNameToString(message.name)
|
|
732
|
+
|
|
733
|
+
const response = await connection.commands.dispatch({
|
|
734
|
+
messageIdentifier: message.identifier,
|
|
735
|
+
name: commandName,
|
|
736
|
+
timestamp: BigInt(message.timestamp),
|
|
737
|
+
payload: serializePayload(commandName, message.payload),
|
|
738
|
+
metaData: metadataToProto(message.metadata),
|
|
739
|
+
processingInstructions: toProtoProcessingInstructions(message.metadata?.processingInstructions as ProcessingInstructions | undefined),
|
|
740
|
+
clientId: connection.config.clientId,
|
|
741
|
+
componentName: connection.config.componentName,
|
|
742
|
+
}, { metadata })
|
|
743
|
+
|
|
744
|
+
if (response.errorCode && response.errorCode !== "") {
|
|
745
|
+
throw mapErrorCode(
|
|
746
|
+
response.errorCode,
|
|
747
|
+
response.errorMessage?.message ?? "Unknown error",
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return deserializePayload(response.payload?.data as Uint8Array | undefined)
|
|
752
|
+
} finally {
|
|
753
|
+
activity.end()
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
|
|
757
|
+
subscribe(commandName: string, handler: (message: CommandMessage) => Promise<unknown>) {
|
|
758
|
+
localSegment.set(commandName, handler)
|
|
759
|
+
|
|
760
|
+
ensureStreamStarted()
|
|
761
|
+
// Subscription FIRST
|
|
762
|
+
outbound.send({
|
|
763
|
+
subscribe: {
|
|
764
|
+
messageId: generateIdentifier(),
|
|
765
|
+
command: commandName,
|
|
766
|
+
componentName: connection.config.componentName,
|
|
767
|
+
clientId: connection.config.clientId,
|
|
768
|
+
loadFactor: commandLoadFactor ?? 100,
|
|
769
|
+
},
|
|
770
|
+
instructionId: generateIdentifier(),
|
|
771
|
+
})
|
|
772
|
+
// Permits AFTER subscription (Axon-specific ordering invariant)
|
|
773
|
+
grantPermits()
|
|
774
|
+
},
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
// Distributed Query Bus
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* A query bus backed by Axon Server.
|
|
784
|
+
*
|
|
785
|
+
* Same architecture as the distributed command bus:
|
|
786
|
+
* - **Outbound dispatch**: Always through Axon Server.
|
|
787
|
+
* - **Local segment**: Handlers registered here are stored locally and
|
|
788
|
+
* registered with Axon Server for inbound routing. Inbound queries
|
|
789
|
+
* are executed within a UnitOfWork.
|
|
790
|
+
*/
|
|
791
|
+
function createDistributedQueryBus(
|
|
792
|
+
connection: AxonServerConnection,
|
|
793
|
+
unitOfWorkRunner: UoWRunner,
|
|
794
|
+
shutdownLatch: ShutdownLatch,
|
|
795
|
+
serializer: Serializer,
|
|
796
|
+
flowControl?: FlowControlConfig,
|
|
797
|
+
shortcutQueriesToLocalHandlers?: boolean,
|
|
798
|
+
queryTimeoutMs?: number,
|
|
799
|
+
resilience?: Partial<ResilienceConfig>,
|
|
800
|
+
): QueryBus {
|
|
801
|
+
const metadata = createAxonMetadata(connection.config)
|
|
802
|
+
const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
|
|
803
|
+
const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
|
|
804
|
+
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
|
|
805
|
+
|
|
806
|
+
const localSegment = new Map<string, (message: QueryMessage) => Promise<unknown>>()
|
|
807
|
+
|
|
808
|
+
// Local subscription store — subscription queries are handled locally
|
|
809
|
+
const subscriptions = new Map<string, UpdateHandler>()
|
|
810
|
+
|
|
811
|
+
let outbound = createOutboundStream<any>()
|
|
812
|
+
let streamStarted = false
|
|
813
|
+
let permits = 0n
|
|
814
|
+
|
|
815
|
+
function ensureStreamStarted() {
|
|
816
|
+
if (streamStarted) return
|
|
817
|
+
streamStarted = true
|
|
818
|
+
|
|
819
|
+
const inbound = connection.queries.openStream(outbound.iterable, { metadata })
|
|
820
|
+
processInboundQueries(inbound)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function grantQueryPermits() {
|
|
824
|
+
outbound.send({
|
|
825
|
+
flowControl: { clientId: connection.config.clientId, permits: PERMITS },
|
|
826
|
+
instructionId: "",
|
|
827
|
+
})
|
|
828
|
+
permits += PERMITS
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Re-establish the bidirectional stream and re-subscribe all handlers.
|
|
833
|
+
* Called on stream error or when the connection reconnects.
|
|
834
|
+
*
|
|
835
|
+
* ORDER (preserves Axon-specific invariant): subscriptions are
|
|
836
|
+
* re-emitted BEFORE the permits frame.
|
|
837
|
+
*/
|
|
838
|
+
function reestablishStreamBody() {
|
|
839
|
+
outbound.close()
|
|
840
|
+
outbound = createOutboundStream<any>()
|
|
841
|
+
streamStarted = false
|
|
842
|
+
permits = 0n
|
|
843
|
+
ensureStreamStarted()
|
|
844
|
+
for (const queryName of localSegment.keys()) {
|
|
845
|
+
outbound.send({
|
|
846
|
+
subscribe: {
|
|
847
|
+
messageId: generateIdentifier(),
|
|
848
|
+
query: queryName,
|
|
849
|
+
resultName: "",
|
|
850
|
+
componentName: connection.config.componentName,
|
|
851
|
+
clientId: connection.config.clientId,
|
|
852
|
+
},
|
|
853
|
+
instructionId: generateIdentifier(),
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
grantQueryPermits()
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function reestablishStreamWithRetry() {
|
|
860
|
+
if (shutdownLatch.shuttingDown) return
|
|
861
|
+
await withRetry(async () => reestablishStreamBody(), {
|
|
862
|
+
event: "reconnect",
|
|
863
|
+
...resilience,
|
|
864
|
+
})
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
|
|
868
|
+
connection.onReconnect(() => {
|
|
869
|
+
if (!shutdownLatch.shuttingDown && streamStarted) {
|
|
870
|
+
reestablishStreamWithRetry().catch((err) => {
|
|
871
|
+
console.error("Distributed query bus: reconnect retries exhausted", err)
|
|
872
|
+
})
|
|
873
|
+
}
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
async function processInboundQueries(inbound: AsyncIterable<any>) {
|
|
877
|
+
try {
|
|
878
|
+
for await (const message of inbound) {
|
|
879
|
+
if (!message.query) continue
|
|
880
|
+
|
|
881
|
+
permits--
|
|
882
|
+
const proto = message.query
|
|
883
|
+
const queryName = proto.query
|
|
884
|
+
const handler = localSegment.get(queryName)
|
|
885
|
+
|
|
886
|
+
let resultPayload: unknown
|
|
887
|
+
let errorCode = ""
|
|
888
|
+
let errorMsg = ""
|
|
889
|
+
|
|
890
|
+
if (handler) {
|
|
891
|
+
try {
|
|
892
|
+
const queryMessage: QueryMessage = {
|
|
893
|
+
identifier: proto.messageIdentifier,
|
|
894
|
+
name: qualifiedNameFromString(queryName),
|
|
895
|
+
payload: deserializePayload(proto.payload?.data as Uint8Array | undefined),
|
|
896
|
+
metadata: metadataFromProto(proto.metaData),
|
|
897
|
+
timestamp: Number(proto.timestamp),
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
|
|
901
|
+
return handler(queryMessage)
|
|
902
|
+
})
|
|
903
|
+
} catch (err) {
|
|
904
|
+
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
905
|
+
errorMsg = err instanceof Error ? err.message : String(err)
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
|
|
909
|
+
errorMsg = `No local handler for query "${queryName}"`
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
outbound.send({
|
|
913
|
+
queryResponse: {
|
|
914
|
+
messageIdentifier: generateIdentifier(),
|
|
915
|
+
requestIdentifier: proto.messageIdentifier,
|
|
916
|
+
errorCode,
|
|
917
|
+
errorMessage: errorCode
|
|
918
|
+
? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
|
|
919
|
+
: undefined,
|
|
920
|
+
payload: resultPayload !== undefined
|
|
921
|
+
? serializePayload("result", resultPayload)
|
|
922
|
+
: undefined,
|
|
923
|
+
metaData: {},
|
|
924
|
+
processingInstructions: [],
|
|
925
|
+
},
|
|
926
|
+
instructionId: "",
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
outbound.send({
|
|
930
|
+
queryComplete: {
|
|
931
|
+
messageId: generateIdentifier(),
|
|
932
|
+
requestId: proto.messageIdentifier,
|
|
933
|
+
},
|
|
934
|
+
instructionId: "",
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
if (permits <= THRESHOLD) {
|
|
938
|
+
outbound.send({
|
|
939
|
+
flowControl: { clientId: connection.config.clientId, permits: PERMITS },
|
|
940
|
+
instructionId: "",
|
|
941
|
+
})
|
|
942
|
+
permits += PERMITS
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
} catch (err) {
|
|
946
|
+
if (shutdownLatch.shuttingDown) return
|
|
947
|
+
if (String(err).includes("Connection dropped")) return
|
|
948
|
+
|
|
949
|
+
console.error("Distributed query bus: inbound stream error, attempting re-establishment via withRetry", err)
|
|
950
|
+
await reestablishStreamWithRetry().catch((retryErr) => {
|
|
951
|
+
console.error("Distributed query bus: reconnect retries exhausted", retryErr)
|
|
952
|
+
})
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return {
|
|
957
|
+
async query(message: QueryMessage): Promise<unknown> {
|
|
958
|
+
const activity = shutdownLatch.registerActivity()
|
|
959
|
+
try {
|
|
960
|
+
const queryName = qualifiedNameToString(message.name)
|
|
961
|
+
|
|
962
|
+
// Local shortcut — handle locally if handler is co-located
|
|
963
|
+
if (shortcutQueriesToLocalHandlers) {
|
|
964
|
+
const localHandler = localSegment.get(queryName)
|
|
965
|
+
if (localHandler) {
|
|
966
|
+
return unitOfWorkRunner(message.metadata, async () => {
|
|
967
|
+
return localHandler(message)
|
|
968
|
+
})
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const responseStream = connection.queries.query({
|
|
973
|
+
messageIdentifier: message.identifier,
|
|
974
|
+
query: queryName,
|
|
975
|
+
timestamp: BigInt(message.timestamp),
|
|
976
|
+
payload: serializePayload(queryName, message.payload),
|
|
977
|
+
metaData: metadataToProto(message.metadata),
|
|
978
|
+
processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
|
|
979
|
+
clientId: connection.config.clientId,
|
|
980
|
+
componentName: connection.config.componentName,
|
|
981
|
+
}, { metadata })
|
|
982
|
+
|
|
983
|
+
for await (const response of responseStream) {
|
|
984
|
+
if (response.errorCode && response.errorCode !== "") {
|
|
985
|
+
throw mapErrorCode(
|
|
986
|
+
response.errorCode,
|
|
987
|
+
response.errorMessage?.message ?? "Unknown error",
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
return deserializePayload(response.payload?.data as Uint8Array | undefined)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
throw new Error(`No response for query "${queryName}"`)
|
|
994
|
+
} finally {
|
|
995
|
+
activity.end()
|
|
996
|
+
}
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
subscribe(queryName: string, handler: (message: QueryMessage) => Promise<unknown>) {
|
|
1000
|
+
localSegment.set(queryName, handler)
|
|
1001
|
+
|
|
1002
|
+
ensureStreamStarted()
|
|
1003
|
+
outbound.send({
|
|
1004
|
+
subscribe: {
|
|
1005
|
+
messageId: generateIdentifier(),
|
|
1006
|
+
query: queryName,
|
|
1007
|
+
resultName: "",
|
|
1008
|
+
componentName: connection.config.componentName,
|
|
1009
|
+
clientId: connection.config.clientId,
|
|
1010
|
+
},
|
|
1011
|
+
instructionId: generateIdentifier(),
|
|
1012
|
+
})
|
|
1013
|
+
// Permits AFTER subscription (Axon-specific ordering invariant)
|
|
1014
|
+
grantQueryPermits()
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
subscriptionQuery(message: QueryMessage, bufferSize?: number): SubscriptionQueryResult {
|
|
1018
|
+
const queryId = message.identifier
|
|
1019
|
+
if (subscriptions.has(queryId)) {
|
|
1020
|
+
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const updateHandler = createUpdateHandler(message, bufferSize)
|
|
1024
|
+
subscriptions.set(queryId, updateHandler)
|
|
1025
|
+
|
|
1026
|
+
const queryName = qualifiedNameToString(message.name)
|
|
1027
|
+
const subscriptionId = generateIdentifier()
|
|
1028
|
+
|
|
1029
|
+
const outboundSub = createOutboundStream<any>()
|
|
1030
|
+
|
|
1031
|
+
outboundSub.send({
|
|
1032
|
+
subscribe: {
|
|
1033
|
+
subscriptionIdentifier: subscriptionId,
|
|
1034
|
+
numberOfPermits: BigInt(bufferSize ?? 256),
|
|
1035
|
+
queryRequest: {
|
|
1036
|
+
messageIdentifier: message.identifier,
|
|
1037
|
+
query: queryName,
|
|
1038
|
+
timestamp: BigInt(message.timestamp),
|
|
1039
|
+
payload: serializePayload(queryName, message.payload),
|
|
1040
|
+
metaData: metadataToProto(message.metadata),
|
|
1041
|
+
processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
|
|
1042
|
+
clientId: connection.config.clientId,
|
|
1043
|
+
componentName: connection.config.componentName,
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
outboundSub.send({
|
|
1049
|
+
getInitialResult: {
|
|
1050
|
+
subscriptionIdentifier: subscriptionId,
|
|
1051
|
+
numberOfPermits: 1n,
|
|
1052
|
+
queryRequest: {
|
|
1053
|
+
messageIdentifier: message.identifier,
|
|
1054
|
+
query: queryName,
|
|
1055
|
+
timestamp: BigInt(message.timestamp),
|
|
1056
|
+
payload: serializePayload(queryName, message.payload),
|
|
1057
|
+
metaData: metadataToProto(message.metadata),
|
|
1058
|
+
processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
|
|
1059
|
+
clientId: connection.config.clientId,
|
|
1060
|
+
componentName: connection.config.componentName,
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata })
|
|
1066
|
+
|
|
1067
|
+
let resolveInitial!: (value: unknown) => void
|
|
1068
|
+
let rejectInitial!: (error: Error) => void
|
|
1069
|
+
const initialResult = new Promise<unknown>((resolve, reject) => {
|
|
1070
|
+
resolveInitial = resolve
|
|
1071
|
+
rejectInitial = reject
|
|
1072
|
+
})
|
|
1073
|
+
let initialSettled = false
|
|
1074
|
+
|
|
1075
|
+
;(async () => {
|
|
1076
|
+
try {
|
|
1077
|
+
for await (const response of responseStream) {
|
|
1078
|
+
if (response.initialResult) {
|
|
1079
|
+
const initial = response.initialResult
|
|
1080
|
+
if (!initialSettled) {
|
|
1081
|
+
if (initial.errorCode && initial.errorCode !== "") {
|
|
1082
|
+
rejectInitial(mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error"))
|
|
1083
|
+
} else {
|
|
1084
|
+
resolveInitial(deserializePayload(initial.payload?.data as Uint8Array | undefined))
|
|
1085
|
+
}
|
|
1086
|
+
initialSettled = true
|
|
1087
|
+
}
|
|
1088
|
+
} else if (response.update) {
|
|
1089
|
+
const update = deserializePayload(response.update.payload?.data as Uint8Array | undefined)
|
|
1090
|
+
updateHandler.offer(update)
|
|
1091
|
+
} else if (response.complete) {
|
|
1092
|
+
updateHandler.complete()
|
|
1093
|
+
break
|
|
1094
|
+
} else if (response.completeExceptionally) {
|
|
1095
|
+
updateHandler.completeExceptionally(
|
|
1096
|
+
new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"),
|
|
1097
|
+
)
|
|
1098
|
+
break
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
1103
|
+
if (!initialSettled) {
|
|
1104
|
+
rejectInitial(error)
|
|
1105
|
+
initialSettled = true
|
|
1106
|
+
}
|
|
1107
|
+
updateHandler.completeExceptionally(error)
|
|
1108
|
+
} finally {
|
|
1109
|
+
subscriptions.delete(queryId)
|
|
1110
|
+
}
|
|
1111
|
+
})()
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
initialResult,
|
|
1115
|
+
updates: updateHandler.iterable,
|
|
1116
|
+
close: () => {
|
|
1117
|
+
outboundSub.send({
|
|
1118
|
+
unsubscribe: {
|
|
1119
|
+
subscriptionIdentifier: subscriptionId,
|
|
1120
|
+
},
|
|
1121
|
+
})
|
|
1122
|
+
outboundSub.close()
|
|
1123
|
+
subscriptions.delete(queryId)
|
|
1124
|
+
updateHandler.complete()
|
|
1125
|
+
},
|
|
1126
|
+
}
|
|
1127
|
+
},
|
|
1128
|
+
|
|
1129
|
+
subscribeToUpdates(message: QueryMessage, bufferSize?: number): AsyncIterable<unknown> & { close(): void } {
|
|
1130
|
+
const queryId = message.identifier
|
|
1131
|
+
if (subscriptions.has(queryId)) {
|
|
1132
|
+
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const updateHandler = createUpdateHandler(message, bufferSize)
|
|
1136
|
+
subscriptions.set(queryId, updateHandler)
|
|
1137
|
+
|
|
1138
|
+
return {
|
|
1139
|
+
[Symbol.asyncIterator]: () => updateHandler.iterable[Symbol.asyncIterator](),
|
|
1140
|
+
close: () => {
|
|
1141
|
+
subscriptions.delete(queryId)
|
|
1142
|
+
updateHandler.complete()
|
|
1143
|
+
},
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
|
|
1147
|
+
async emitUpdate(
|
|
1148
|
+
queryName: string,
|
|
1149
|
+
filter: (queryPayload: unknown) => boolean,
|
|
1150
|
+
update: unknown,
|
|
1151
|
+
): Promise<void> {
|
|
1152
|
+
runAfterCommitOrImmediately(() => {
|
|
1153
|
+
for (const [id, handler] of subscriptions) {
|
|
1154
|
+
if (!handler.active) {
|
|
1155
|
+
subscriptions.delete(id)
|
|
1156
|
+
continue
|
|
1157
|
+
}
|
|
1158
|
+
const handlerQueryName = qualifiedNameToString(handler.query.name)
|
|
1159
|
+
if (handlerQueryName !== queryName) continue
|
|
1160
|
+
if (!filter(handler.query.payload)) continue
|
|
1161
|
+
|
|
1162
|
+
const accepted = handler.offer(update)
|
|
1163
|
+
if (!accepted) {
|
|
1164
|
+
handler.completeExceptionally(new Error("Subscription query update buffer overflow"))
|
|
1165
|
+
subscriptions.delete(id)
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
})
|
|
1169
|
+
},
|
|
1170
|
+
|
|
1171
|
+
async completeSubscription(
|
|
1172
|
+
queryName: string,
|
|
1173
|
+
filter?: (queryPayload: unknown) => boolean,
|
|
1174
|
+
): Promise<void> {
|
|
1175
|
+
runAfterCommitOrImmediately(() => {
|
|
1176
|
+
for (const [id, handler] of subscriptions) {
|
|
1177
|
+
const handlerQueryName = qualifiedNameToString(handler.query.name)
|
|
1178
|
+
if (handlerQueryName !== queryName) continue
|
|
1179
|
+
if (filter && !filter(handler.query.payload)) continue
|
|
1180
|
+
handler.complete()
|
|
1181
|
+
subscriptions.delete(id)
|
|
1182
|
+
}
|
|
1183
|
+
})
|
|
1184
|
+
},
|
|
1185
|
+
|
|
1186
|
+
async completeSubscriptionExceptionally(
|
|
1187
|
+
queryName: string,
|
|
1188
|
+
error: Error,
|
|
1189
|
+
filter?: (queryPayload: unknown) => boolean,
|
|
1190
|
+
): Promise<void> {
|
|
1191
|
+
runAfterCommitOrImmediately(() => {
|
|
1192
|
+
for (const [id, handler] of subscriptions) {
|
|
1193
|
+
const handlerQueryName = qualifiedNameToString(handler.query.name)
|
|
1194
|
+
if (handlerQueryName !== queryName) continue
|
|
1195
|
+
if (filter && !filter(handler.query.payload)) continue
|
|
1196
|
+
handler.completeExceptionally(error)
|
|
1197
|
+
subscriptions.delete(id)
|
|
1198
|
+
}
|
|
1199
|
+
})
|
|
1200
|
+
},
|
|
1201
|
+
}
|
|
1202
|
+
}
|