@kronos-ts/axon-server 0.3.1 → 0.4.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 +9 -5
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +38 -30
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshot-store.d.ts +8 -8
- package/dist/axon-server-snapshot-store.d.ts.map +1 -1
- package/dist/axon-server-snapshot-store.js +8 -14
- package/dist/axon-server-snapshot-store.js.map +1 -1
- package/dist/axon-server.d.ts +97 -186
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +217 -318
- package/dist/axon-server.js.map +1 -1
- package/dist/connection.d.ts +128 -0
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +134 -10
- package/dist/connection.js.map +1 -1
- package/dist/context-view.d.ts +30 -0
- package/dist/context-view.d.ts.map +1 -0
- package/dist/context-view.js +19 -0
- package/dist/context-view.js.map +1 -0
- package/dist/control-plane.d.ts +14 -13
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +8 -6
- package/dist/control-plane.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/flow-controlled-sender.js.map +1 -1
- package/dist/generated/command.d.ts.map +1 -1
- package/dist/generated/command.js.map +1 -1
- package/dist/generated/common.js.map +1 -1
- package/dist/generated/control.d.ts.map +1 -1
- package/dist/generated/control.js.map +1 -1
- package/dist/generated/dcb.d.ts.map +1 -1
- package/dist/generated/dcb.js.map +1 -1
- package/dist/generated/event.d.ts.map +1 -1
- package/dist/generated/event.js.map +1 -1
- package/dist/generated/google/protobuf/empty.js.map +1 -1
- package/dist/generated/query.d.ts.map +1 -1
- package/dist/generated/query.js.map +1 -1
- package/dist/index.d.ts +4 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/message-size.d.ts.map +1 -1
- package/dist/metadata-conversion.d.ts +1 -1
- package/dist/metadata-conversion.d.ts.map +1 -1
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/package.json +4 -8
- package/src/axon-server-event-store.ts +51 -43
- package/src/axon-server-snapshot-store.ts +17 -25
- package/src/axon-server.ts +358 -512
- package/src/connection.ts +270 -18
- package/src/context-view.ts +46 -0
- package/src/control-plane.ts +17 -17
- package/src/index.ts +12 -18
- package/src/metadata-conversion.ts +1 -1
package/src/axon-server.ts
CHANGED
|
@@ -1,55 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Axon Server
|
|
2
|
+
* The Axon Server command and query buses.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `onStart("processors")` / `onStop("connect")` is now three lines you write
|
|
9
|
-
* in your composition root:
|
|
4
|
+
* Axon Server is a SMART HUB: outbound dispatch always goes to the server, and
|
|
5
|
+
* the server decides which node handles it — there is no client-side
|
|
6
|
+
* prefer-local fork here, which is the whole difference from the dumb-pipe
|
|
7
|
+
* broker in `@kronos-ts/rabbitmq`.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
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:
|
|
9
|
+
* Both buses are plain functions over the shared connection and YOUR local bus:
|
|
35
10
|
*
|
|
36
11
|
* ```ts
|
|
37
|
-
* const
|
|
12
|
+
* const commandBus = interceptingCommandBus(
|
|
13
|
+
* axonServerCommandBus(axon, simpleCommandBus(unitOfWork)), lineage)
|
|
14
|
+
* const queryBus = interceptingQueryBus(
|
|
15
|
+
* axonServerQueryBus(axon, simpleQueryBus(unitOfWork)), lineage)
|
|
38
16
|
* ```
|
|
39
17
|
*
|
|
40
|
-
* `start()` therefore takes NO arguments and does exactly one thing: the
|
|
41
|
-
* data-path readiness barrier. See `control-plane.ts`.
|
|
42
|
-
*
|
|
43
18
|
* Axon-specific protocol invariants are preserved byte-for-byte:
|
|
44
19
|
*
|
|
45
20
|
* - CLIENT_SUPPORTS_STREAMING capability advertised on every dispatched
|
|
46
21
|
* query via `defaultQueryInstructions(...)`;
|
|
47
22
|
* - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers built by
|
|
48
|
-
* `
|
|
23
|
+
* `contextView(...)` and attached to every outbound stream/RPC;
|
|
49
24
|
* - permits-AFTER-subscriptions stream ordering preserved on the initial
|
|
50
25
|
* handshake AND on reconnect (see `ensureStreamStarted` /
|
|
51
|
-
* `reestablishStreamBody`)
|
|
52
|
-
* - shutdown ordering: busLatches → platform.stop → connection.close.
|
|
26
|
+
* `reestablishStreamBody`).
|
|
53
27
|
*/
|
|
54
28
|
import {
|
|
55
29
|
qualifiedNameToString,
|
|
@@ -57,9 +31,8 @@ import {
|
|
|
57
31
|
generateIdentifier,
|
|
58
32
|
type Serializer,
|
|
59
33
|
withRetry,
|
|
60
|
-
healthCheck,
|
|
61
34
|
type ResilienceConfig,
|
|
62
|
-
} from "@kronos-ts/
|
|
35
|
+
} from "@kronos-ts/core"
|
|
63
36
|
import type {
|
|
64
37
|
CommandBus,
|
|
65
38
|
CommandMessage,
|
|
@@ -67,36 +40,32 @@ import type {
|
|
|
67
40
|
QueryMessage,
|
|
68
41
|
SubscriptionFilter,
|
|
69
42
|
SubscriptionQueryResult,
|
|
70
|
-
|
|
43
|
+
UnitOfWork,
|
|
44
|
+
Unstamped,
|
|
71
45
|
UpdateHandler,
|
|
72
|
-
} from "@kronos-ts/
|
|
46
|
+
} from "@kronos-ts/core"
|
|
73
47
|
import {
|
|
74
48
|
applySubscriptionFilter,
|
|
75
|
-
|
|
76
|
-
interceptingCommandBus,
|
|
77
|
-
interceptingQueryBus,
|
|
49
|
+
stamped,
|
|
78
50
|
updateHandler,
|
|
79
51
|
runAfterCommitOrImmediately,
|
|
80
|
-
} from "@kronos-ts/
|
|
81
|
-
import {
|
|
82
|
-
import
|
|
83
|
-
import { connectToAxonServer, type AxonServerConnection } from "./connection.js"
|
|
84
|
-
import { axonServerEventStore } from "./axon-server-event-store.js"
|
|
85
|
-
import { axonServerSnapshotStore } from "./axon-server-snapshot-store.js"
|
|
52
|
+
} from "@kronos-ts/core"
|
|
53
|
+
import type { AxonServerBusSource } from "./connection.js"
|
|
54
|
+
import { contextView } from "./context-view.js"
|
|
86
55
|
import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
|
|
87
56
|
import { outboundStream } from "./outbound-stream.js"
|
|
88
57
|
import { mapErrorCode, AxonServerErrorCode } from "./errors.js"
|
|
89
|
-
import { shutdownLatch, type ShutdownLatch } from "./shutdown-latch.js"
|
|
90
|
-
import {
|
|
91
|
-
platformConnection,
|
|
92
|
-
type PlatformConnection,
|
|
93
|
-
type PlatformServiceOptions,
|
|
94
|
-
} from "./platform-service.js"
|
|
95
58
|
|
|
96
59
|
/** Default flow control settings — aligned with Java's 5000 permits. */
|
|
97
60
|
const DEFAULT_PERMITS = 5000n
|
|
98
61
|
const DEFAULT_THRESHOLD = 2500n
|
|
99
62
|
|
|
63
|
+
/** Default query dispatch timeout — aligned with Java's one hour. */
|
|
64
|
+
const DEFAULT_QUERY_TIMEOUT_MS = 3_600_000
|
|
65
|
+
|
|
66
|
+
/** Default command handler load factor — aligned with Java's 100. */
|
|
67
|
+
const DEFAULT_LOAD_FACTOR = 100
|
|
68
|
+
|
|
100
69
|
/**
|
|
101
70
|
* Flow control configuration for a bus channel.
|
|
102
71
|
*/
|
|
@@ -120,10 +89,57 @@ export interface ProcessingInstructions {
|
|
|
120
89
|
timeoutMs?: number
|
|
121
90
|
}
|
|
122
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Tuning for {@link axonServerCommandBus}. Every field has a working default;
|
|
94
|
+
* the two arguments that carry meaning — the connection and your local bus —
|
|
95
|
+
* are positional, and this record is the trailing remainder.
|
|
96
|
+
*/
|
|
97
|
+
export interface AxonServerCommandBusOptions {
|
|
98
|
+
/** Axon Server context for this bus's stream. Default: the connection's. */
|
|
99
|
+
context?: string
|
|
100
|
+
/** Flow control for the command stream. */
|
|
101
|
+
flowControl?: FlowControlConfig
|
|
102
|
+
/**
|
|
103
|
+
* Load factor for this command handler. Signals to Axon Server how much
|
|
104
|
+
* capacity this node has — higher value = more commands routed here.
|
|
105
|
+
* Aligned with Java's `commandLoadFactor`. Default: 100.
|
|
106
|
+
*/
|
|
107
|
+
loadFactor?: number
|
|
108
|
+
/** Retry policy for stream re-establishment. Default: the connection's. */
|
|
109
|
+
resilience?: Partial<ResilienceConfig>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Tuning for {@link axonServerQueryBus}. See {@link AxonServerCommandBusOptions}.
|
|
114
|
+
*/
|
|
115
|
+
export interface AxonServerQueryBusOptions {
|
|
116
|
+
/** Axon Server context for this bus's stream. Default: the connection's. */
|
|
117
|
+
context?: string
|
|
118
|
+
/** Flow control for the query stream. */
|
|
119
|
+
flowControl?: FlowControlConfig
|
|
120
|
+
/**
|
|
121
|
+
* When true, queries are first checked against locally subscribed handlers
|
|
122
|
+
* before being dispatched through Axon Server. Avoids a network round-trip
|
|
123
|
+
* when the handler is co-located.
|
|
124
|
+
*
|
|
125
|
+
* This is NOT the rabbitmq `preferLocal` fork by another name: it is Java's
|
|
126
|
+
* `shortcutQueriesToLocalHandlers`, it is off by default, and commands have
|
|
127
|
+
* no equivalent — Axon Server routes those, always.
|
|
128
|
+
*/
|
|
129
|
+
shortcutQueriesToLocalHandlers?: boolean
|
|
130
|
+
/**
|
|
131
|
+
* Default timeout for query dispatch in ms. Default: 3600000 (1 hour).
|
|
132
|
+
* Aligned with Java's processing instruction timeout.
|
|
133
|
+
*/
|
|
134
|
+
timeoutMs?: number
|
|
135
|
+
/** Retry policy for stream re-establishment. Default: the connection's. */
|
|
136
|
+
resilience?: Partial<ResilienceConfig>
|
|
137
|
+
}
|
|
138
|
+
|
|
123
139
|
// Processing instruction keys — aligned with proto ProcessingKey enum.
|
|
124
140
|
// CLIENT_SUPPORTS_STREAMING (key=8) is an Axon-Server-specific capability
|
|
125
|
-
// advertisement that MUST survive
|
|
126
|
-
//
|
|
141
|
+
// advertisement that MUST survive verbatim — see file-level JSDoc above and
|
|
142
|
+
// `defaultQueryInstructions` below.
|
|
127
143
|
const INSTRUCTION_KEY = {
|
|
128
144
|
ROUTING_KEY: 0,
|
|
129
145
|
PRIORITY: 1,
|
|
@@ -139,10 +155,16 @@ function toProtoProcessingInstructions(instructions?: ProcessingInstructions): a
|
|
|
139
155
|
result.push({ key: INSTRUCTION_KEY.ROUTING_KEY, value: { textValue: instructions.routingKey } })
|
|
140
156
|
}
|
|
141
157
|
if (instructions.priority !== undefined) {
|
|
142
|
-
result.push({
|
|
158
|
+
result.push({
|
|
159
|
+
key: INSTRUCTION_KEY.PRIORITY,
|
|
160
|
+
value: { numberValue: BigInt(instructions.priority) },
|
|
161
|
+
})
|
|
143
162
|
}
|
|
144
163
|
if (instructions.timeoutMs !== undefined) {
|
|
145
|
-
result.push({
|
|
164
|
+
result.push({
|
|
165
|
+
key: INSTRUCTION_KEY.TIMEOUT,
|
|
166
|
+
value: { numberValue: BigInt(instructions.timeoutMs) },
|
|
167
|
+
})
|
|
146
168
|
}
|
|
147
169
|
return result
|
|
148
170
|
}
|
|
@@ -161,233 +183,8 @@ function defaultQueryInstructions(timeoutMs: number): any[] {
|
|
|
161
183
|
]
|
|
162
184
|
}
|
|
163
185
|
|
|
164
|
-
/**
|
|
165
|
-
* Build the gRPC metadata headers required by Axon Server. AxonIQ-Context
|
|
166
|
-
* is mandatory (identifies the tenant/context); AxonIQ-Access-Token is
|
|
167
|
-
* optional auth. Both must be attached to every outbound stream/RPC —
|
|
168
|
-
* preserved verbatim from the legacy enhancer.
|
|
169
|
-
*/
|
|
170
|
-
function createAxonMetadata(config: { context: string; token: string }): Metadata {
|
|
171
|
-
const metadata = new Metadata()
|
|
172
|
-
metadata.set("AxonIQ-Context", config.context)
|
|
173
|
-
if (config.token) {
|
|
174
|
-
metadata.set("AxonIQ-Access-Token", config.token)
|
|
175
|
-
}
|
|
176
|
-
return metadata
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export interface AxonServerConfig extends AxonServerConnectionConfig {
|
|
180
|
-
/** Flow control for the command bus channel. */
|
|
181
|
-
commandFlowControl?: FlowControlConfig
|
|
182
|
-
/** Flow control for the query bus channel. */
|
|
183
|
-
queryFlowControl?: FlowControlConfig
|
|
184
|
-
/** Platform service configuration (heartbeat, etc.). */
|
|
185
|
-
platformService?: PlatformServiceOptions
|
|
186
|
-
/**
|
|
187
|
-
* When true, queries are first checked against locally registered handlers
|
|
188
|
-
* before being dispatched through Axon Server. Avoids a network round-trip
|
|
189
|
-
* when the handler is co-located.
|
|
190
|
-
*
|
|
191
|
-
* Aligned with Java's `shortcutQueriesToLocalHandlers`.
|
|
192
|
-
* Default: false.
|
|
193
|
-
*/
|
|
194
|
-
shortcutQueriesToLocalHandlers?: boolean
|
|
195
|
-
/**
|
|
196
|
-
* Load factor for command handler registration.
|
|
197
|
-
* Signals to Axon Server how much capacity this handler has.
|
|
198
|
-
* Higher value = handler can take more commands.
|
|
199
|
-
*
|
|
200
|
-
* Aligned with Java's `commandLoadFactor`. Default: 100.
|
|
201
|
-
*/
|
|
202
|
-
commandLoadFactor?: number
|
|
203
|
-
/**
|
|
204
|
-
* Default timeout for command dispatch in ms. Default: 300000 (5 min).
|
|
205
|
-
* Aligned with Java's processing instruction timeout.
|
|
206
|
-
*/
|
|
207
|
-
commandTimeoutMs?: number
|
|
208
|
-
/**
|
|
209
|
-
* Default timeout for query dispatch in ms. Default: 3600000 (1 hour).
|
|
210
|
-
* Aligned with Java's processing instruction timeout.
|
|
211
|
-
*/
|
|
212
|
-
queryTimeoutMs?: number
|
|
213
|
-
/** Per-extension resilience config (D-100 / D-101). */
|
|
214
|
-
resilience?: Partial<ResilienceConfig>
|
|
215
|
-
/**
|
|
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.
|
|
226
|
-
*/
|
|
227
|
-
busSubscriptionAckDelayMs?: number
|
|
228
|
-
}
|
|
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
|
-
|
|
238
|
-
/**
|
|
239
|
-
* A live Axon Server backend: the components it provides plus the two calls
|
|
240
|
-
* that used to be lifecycle stages.
|
|
241
|
-
*/
|
|
242
|
-
/** Everything axonServer() needs: its own config plus the framework values it borrows. */
|
|
243
|
-
export type AxonServerOptions = AxonServerConfig & { serializer: Serializer; unitOfWorkFactory: UoWRunner }
|
|
244
|
-
|
|
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
|
-
}
|
|
279
|
-
|
|
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
|
-
})
|
|
298
|
-
|
|
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
|
-
})
|
|
308
|
-
|
|
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
|
-
}
|
|
340
|
-
|
|
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)
|
|
346
|
-
|
|
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
|
-
},
|
|
378
|
-
|
|
379
|
-
async close() {
|
|
380
|
-
await Promise.all(busLatches.map((l) => l.initiateShutdown()))
|
|
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
|
-
},
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
186
|
// ---------------------------------------------------------------------------
|
|
390
|
-
// Shared payload helpers
|
|
187
|
+
// Shared payload helpers
|
|
391
188
|
// ---------------------------------------------------------------------------
|
|
392
189
|
|
|
393
190
|
function createPayloadHelpers(serializer: Serializer) {
|
|
@@ -395,7 +192,11 @@ function createPayloadHelpers(serializer: Serializer) {
|
|
|
395
192
|
serializePayload(name: string, payload: unknown, revision: string = "") {
|
|
396
193
|
return serializer.serialize(payload, name, revision)
|
|
397
194
|
},
|
|
398
|
-
deserializePayload(
|
|
195
|
+
deserializePayload(
|
|
196
|
+
data: Uint8Array | undefined,
|
|
197
|
+
type: string = "",
|
|
198
|
+
revision: string = "",
|
|
199
|
+
): unknown {
|
|
399
200
|
if (!data || data.length === 0) return undefined
|
|
400
201
|
return serializer.deserialize({ data, type, revision })
|
|
401
202
|
},
|
|
@@ -403,79 +204,77 @@ function createPayloadHelpers(serializer: Serializer) {
|
|
|
403
204
|
}
|
|
404
205
|
|
|
405
206
|
// ---------------------------------------------------------------------------
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// Bus implementation moved verbatim from the legacy enhancer with TWO
|
|
409
|
-
// behavioural additions per D-97:
|
|
410
|
-
// 1) reestablishStream() body wrapped in withRetry({ event: "reconnect" })
|
|
411
|
-
// 2) inbound-stream backoff replaced by the same withRetry path
|
|
412
|
-
//
|
|
413
|
-
// Axon-specific protocol invariants preserved BYTE-FOR-BYTE:
|
|
414
|
-
// - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers via
|
|
415
|
-
// createAxonMetadata(connection.config)
|
|
416
|
-
// - permits-AFTER-subscriptions ordering on reestablishStreamBody (subs
|
|
417
|
-
// are sent BEFORE grantPermits() in the reconnect path; the initial
|
|
418
|
-
// handshake matches this — see ensureStreamStarted's grantPermits call
|
|
419
|
-
// in subscribe()).
|
|
207
|
+
// Axon Server Command Bus
|
|
420
208
|
// ---------------------------------------------------------------------------
|
|
421
209
|
|
|
422
210
|
/**
|
|
423
|
-
* A command bus backed by Axon Server.
|
|
211
|
+
* A command bus backed by Axon Server, over YOUR local bus.
|
|
424
212
|
*
|
|
425
|
-
* - **Outbound dispatch**:
|
|
426
|
-
* Axon Server routes the command to the appropriate node (which may be
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
*
|
|
213
|
+
* - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
|
|
214
|
+
* RPC. Axon Server routes the command to the appropriate node (which may be
|
|
215
|
+
* this one). There is deliberately no client-side prefer-local fork: the hub
|
|
216
|
+
* is the router, and short-circuiting it would silently defeat load factors,
|
|
217
|
+
* priorities and routing keys.
|
|
218
|
+
* - **Inbound**: a command the server routes here is dispatched into `local` —
|
|
219
|
+
* not into a privately-held handler map. That is what makes the unit-of-work
|
|
220
|
+
* policy you chose for `local` (say `postgresUnitOfWork(pg, unitOfWork)`)
|
|
221
|
+
* apply to server-routed work exactly as it applies to work this process
|
|
222
|
+
* originated. It is also why this function takes no `unitOfWork` argument:
|
|
223
|
+
* `local` carries that policy now.
|
|
224
|
+
* - **subscribe**: registers the handler on `local` AND announces the name to
|
|
225
|
+
* Axon Server, so other nodes can route to us.
|
|
434
226
|
*
|
|
435
227
|
* ## Correlation lineage and the interceptor layer
|
|
436
228
|
*
|
|
437
|
-
* The returned bus
|
|
438
|
-
*
|
|
439
|
-
*
|
|
229
|
+
* The returned bus stamps no lineage of its own. A host that wants it wraps the
|
|
230
|
+
* OUTERMOST bus:
|
|
231
|
+
*
|
|
232
|
+
* ```ts
|
|
233
|
+
* interceptingCommandBus(axonServerCommandBus(conn, local), lineage)
|
|
234
|
+
* ```
|
|
235
|
+
*
|
|
236
|
+
* so whatever a host adds runs BEFORE the message is serialized onto the wire.
|
|
237
|
+
* Lineage itself is usually already on `message.metadata` by then — `ctx.send`
|
|
238
|
+
* stamps the unit of work's correlation data before any bus sees the message.
|
|
440
239
|
*
|
|
441
240
|
* This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
|
|
442
241
|
* holds its own `DispatchInterceptors` and dispatches as
|
|
443
242
|
* `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
|
|
444
|
-
* site, at the top, ahead of any routing
|
|
445
|
-
*
|
|
446
|
-
* AF5 keeps the property via decorator order:
|
|
447
|
-
* `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
|
|
243
|
+
* site, at the top, ahead of any routing. AF5 keeps the property via decorator
|
|
244
|
+
* order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
|
|
448
245
|
* stacks `InterceptingCommandBus → DistributedCommandBus → SimpleCommandBus`.
|
|
449
246
|
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
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.
|
|
247
|
+
* If `local` is itself an intercepting bus, a server-routed command sees
|
|
248
|
+
* `lineage` twice. That is harmless: both of its fields are `??` seeds, so the
|
|
249
|
+
* second application finds them set and changes nothing.
|
|
460
250
|
*/
|
|
461
|
-
export function
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
serializer: Serializer,
|
|
466
|
-
flowControl?: FlowControlConfig,
|
|
467
|
-
commandLoadFactor?: number,
|
|
468
|
-
resilience?: Partial<ResilienceConfig>,
|
|
251
|
+
export function axonServerCommandBus(
|
|
252
|
+
conn: AxonServerBusSource,
|
|
253
|
+
local: CommandBus,
|
|
254
|
+
options: AxonServerCommandBusOptions = {},
|
|
469
255
|
): CommandBus {
|
|
470
|
-
const
|
|
256
|
+
const {
|
|
257
|
+
connection,
|
|
258
|
+
serializer,
|
|
259
|
+
metadata: axonMetadata,
|
|
260
|
+
} = contextView(conn, options.context ?? conn.connection.config.context)
|
|
261
|
+
const shutdownLatch = conn.shutdown
|
|
262
|
+
const resilience = options.resilience ?? conn.resilience
|
|
263
|
+
const metadata = axonMetadata()
|
|
471
264
|
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
|
|
472
|
-
const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
|
|
473
|
-
const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
|
|
265
|
+
const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
|
|
266
|
+
const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
|
|
267
|
+
const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR
|
|
474
268
|
|
|
475
|
-
|
|
476
|
-
|
|
269
|
+
/**
|
|
270
|
+
* The names this node announced to Axon Server. The handlers themselves live
|
|
271
|
+
* on `local`; this set exists so an inbound command for a name we never
|
|
272
|
+
* subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
|
|
273
|
+
* `local.dispatch` happens to throw — and so a reconnect can re-announce.
|
|
274
|
+
*/
|
|
275
|
+
const subscribedNames = new Set<string>()
|
|
477
276
|
|
|
478
|
-
// Bidirectional stream for handler
|
|
277
|
+
// Bidirectional stream for handler subscription + inbound command handling
|
|
479
278
|
let outbound = outboundStream<any>()
|
|
480
279
|
let streamStarted = false
|
|
481
280
|
let permits = 0n
|
|
@@ -497,8 +296,21 @@ export function distributedCommandBus(
|
|
|
497
296
|
permits += PERMITS
|
|
498
297
|
}
|
|
499
298
|
|
|
299
|
+
function sendSubscribe(commandName: string) {
|
|
300
|
+
outbound.send({
|
|
301
|
+
subscribe: {
|
|
302
|
+
messageId: generateIdentifier(),
|
|
303
|
+
command: commandName,
|
|
304
|
+
componentName: connection.config.componentName,
|
|
305
|
+
clientId: connection.config.clientId,
|
|
306
|
+
loadFactor,
|
|
307
|
+
},
|
|
308
|
+
instructionId: generateIdentifier(),
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
500
312
|
/**
|
|
501
|
-
* Re-establish the bidirectional stream and re-
|
|
313
|
+
* Re-establish the bidirectional stream and re-announce all handlers.
|
|
502
314
|
* Called on stream error or when the connection reconnects.
|
|
503
315
|
*
|
|
504
316
|
* ORDER (preserves Axon-specific invariant): subscriptions are
|
|
@@ -512,18 +324,7 @@ export function distributedCommandBus(
|
|
|
512
324
|
permits = 0n
|
|
513
325
|
ensureStreamStarted()
|
|
514
326
|
// Re-subscribe all handlers FIRST
|
|
515
|
-
for (const commandName of
|
|
516
|
-
outbound.send({
|
|
517
|
-
subscribe: {
|
|
518
|
-
messageId: generateIdentifier(),
|
|
519
|
-
command: commandName,
|
|
520
|
-
componentName: connection.config.componentName,
|
|
521
|
-
clientId: connection.config.clientId,
|
|
522
|
-
loadFactor: commandLoadFactor ?? 100,
|
|
523
|
-
},
|
|
524
|
-
instructionId: generateIdentifier(),
|
|
525
|
-
})
|
|
526
|
-
}
|
|
327
|
+
for (const commandName of subscribedNames) sendSubscribe(commandName)
|
|
527
328
|
// Permits AFTER subscriptions (Axon-specific ordering invariant)
|
|
528
329
|
grantPermits()
|
|
529
330
|
}
|
|
@@ -540,7 +341,7 @@ export function distributedCommandBus(
|
|
|
540
341
|
connection.onReconnect(() => {
|
|
541
342
|
if (!shutdownLatch.shuttingDown && streamStarted) {
|
|
542
343
|
reestablishStreamWithRetry().catch((err) => {
|
|
543
|
-
console.error("
|
|
344
|
+
console.error("Axon Server command bus: reconnect retries exhausted", err)
|
|
544
345
|
})
|
|
545
346
|
}
|
|
546
347
|
})
|
|
@@ -553,13 +354,12 @@ export function distributedCommandBus(
|
|
|
553
354
|
permits--
|
|
554
355
|
const proto = message.command
|
|
555
356
|
const commandName = proto.name
|
|
556
|
-
const handler = localSegment.get(commandName)
|
|
557
357
|
|
|
558
358
|
let resultPayload: unknown
|
|
559
359
|
let errorCode = ""
|
|
560
360
|
let errorMsg = ""
|
|
561
361
|
|
|
562
|
-
if (
|
|
362
|
+
if (subscribedNames.has(commandName)) {
|
|
563
363
|
try {
|
|
564
364
|
const commandMessage: CommandMessage = {
|
|
565
365
|
kind: "command",
|
|
@@ -570,10 +370,12 @@ export function distributedCommandBus(
|
|
|
570
370
|
timestamp: Number(proto.timestamp),
|
|
571
371
|
}
|
|
572
372
|
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
373
|
+
// Through the LOCAL BUS, so the caller's unit-of-work policy runs.
|
|
374
|
+
// AF parity is preserved: `CommandProcessingTask` runs the local
|
|
375
|
+
// segment without re-running dispatch interceptors, and a `local`
|
|
376
|
+
// that happens to carry `lineage` re-applies a pair of `??` seeds
|
|
377
|
+
// that are already set.
|
|
378
|
+
resultPayload = await local.dispatch(commandMessage)
|
|
577
379
|
} catch (err) {
|
|
578
380
|
errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
|
|
579
381
|
errorMsg = err instanceof Error ? err.message : String(err)
|
|
@@ -590,11 +392,15 @@ export function distributedCommandBus(
|
|
|
590
392
|
requestIdentifier: proto.messageIdentifier,
|
|
591
393
|
errorCode,
|
|
592
394
|
errorMessage: errorCode
|
|
593
|
-
? {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
395
|
+
? {
|
|
396
|
+
message: errorMsg,
|
|
397
|
+
location: connection.config.componentName,
|
|
398
|
+
details: [],
|
|
399
|
+
errorCode,
|
|
400
|
+
}
|
|
597
401
|
: undefined,
|
|
402
|
+
payload:
|
|
403
|
+
resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
|
|
598
404
|
metaData: {},
|
|
599
405
|
processingInstructions: [],
|
|
600
406
|
},
|
|
@@ -614,35 +420,47 @@ export function distributedCommandBus(
|
|
|
614
420
|
if (shutdownLatch.shuttingDown) return
|
|
615
421
|
if (String(err).includes("Connection dropped")) return
|
|
616
422
|
|
|
617
|
-
console.error(
|
|
423
|
+
console.error(
|
|
424
|
+
"Axon Server command bus: inbound stream error, attempting re-establishment via withRetry",
|
|
425
|
+
err,
|
|
426
|
+
)
|
|
618
427
|
await reestablishStreamWithRetry().catch((retryErr) => {
|
|
619
|
-
console.error("
|
|
428
|
+
console.error("Axon Server command bus: reconnect retries exhausted", retryErr)
|
|
620
429
|
})
|
|
621
430
|
}
|
|
622
431
|
}
|
|
623
432
|
|
|
624
|
-
|
|
625
|
-
async dispatch(
|
|
433
|
+
return {
|
|
434
|
+
async dispatch(unstamped: Unstamped<CommandMessage>): Promise<unknown> {
|
|
435
|
+
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
436
|
+
// A message that reaches the wire still {@link Unstamped} is therefore
|
|
437
|
+
// stamped from system time here — the envelope crosses a process boundary
|
|
438
|
+
// and must be fully formed. A locally-shortcut message is handed to
|
|
439
|
+
// `local` unstamped instead, so the task that handles it supplies the
|
|
440
|
+
// instant.
|
|
441
|
+
const message = stamped(unstamped, Date.now)
|
|
626
442
|
const activity = shutdownLatch.registerActivity()
|
|
627
443
|
try {
|
|
628
444
|
const commandName = qualifiedNameToString(message.name)
|
|
629
445
|
|
|
630
|
-
const response = await connection.commands.dispatch(
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
446
|
+
const response = await connection.commands.dispatch(
|
|
447
|
+
{
|
|
448
|
+
messageIdentifier: message.identifier,
|
|
449
|
+
name: commandName,
|
|
450
|
+
timestamp: BigInt(message.timestamp),
|
|
451
|
+
payload: serializePayload(commandName, message.payload),
|
|
452
|
+
metaData: metadataToProto(message.metadata),
|
|
453
|
+
processingInstructions: toProtoProcessingInstructions(
|
|
454
|
+
message.metadata?.processingInstructions as ProcessingInstructions | undefined,
|
|
455
|
+
),
|
|
456
|
+
clientId: connection.config.clientId,
|
|
457
|
+
componentName: connection.config.componentName,
|
|
458
|
+
},
|
|
459
|
+
{ metadata },
|
|
460
|
+
)
|
|
640
461
|
|
|
641
462
|
if (response.errorCode && response.errorCode !== "") {
|
|
642
|
-
throw mapErrorCode(
|
|
643
|
-
response.errorCode,
|
|
644
|
-
response.errorMessage?.message ?? "Unknown error",
|
|
645
|
-
)
|
|
463
|
+
throw mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
|
|
646
464
|
}
|
|
647
465
|
|
|
648
466
|
return deserializePayload(response.payload?.data as Uint8Array | undefined)
|
|
@@ -651,73 +469,72 @@ export function distributedCommandBus(
|
|
|
651
469
|
}
|
|
652
470
|
},
|
|
653
471
|
|
|
654
|
-
subscribe(
|
|
655
|
-
|
|
472
|
+
subscribe(
|
|
473
|
+
commandName: string,
|
|
474
|
+
handler: (message: CommandMessage, uow: UnitOfWork) => Promise<unknown>,
|
|
475
|
+
) {
|
|
476
|
+
subscribedNames.add(commandName)
|
|
477
|
+
local.subscribe(commandName, handler)
|
|
656
478
|
|
|
657
479
|
ensureStreamStarted()
|
|
658
480
|
// Subscription FIRST
|
|
659
|
-
|
|
660
|
-
subscribe: {
|
|
661
|
-
messageId: generateIdentifier(),
|
|
662
|
-
command: commandName,
|
|
663
|
-
componentName: connection.config.componentName,
|
|
664
|
-
clientId: connection.config.clientId,
|
|
665
|
-
loadFactor: commandLoadFactor ?? 100,
|
|
666
|
-
},
|
|
667
|
-
instructionId: generateIdentifier(),
|
|
668
|
-
})
|
|
481
|
+
sendSubscribe(commandName)
|
|
669
482
|
// Permits AFTER subscription (Axon-specific ordering invariant)
|
|
670
483
|
grantPermits()
|
|
671
484
|
},
|
|
672
485
|
}
|
|
673
|
-
|
|
674
|
-
// Interception OUTSIDE routing — see the note on this function.
|
|
675
|
-
const bus = interceptingCommandBus(routing)
|
|
676
|
-
bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
|
|
677
|
-
return bus
|
|
678
486
|
}
|
|
679
487
|
|
|
680
488
|
// ---------------------------------------------------------------------------
|
|
681
|
-
//
|
|
489
|
+
// Axon Server Query Bus
|
|
682
490
|
// ---------------------------------------------------------------------------
|
|
683
491
|
|
|
684
492
|
/**
|
|
685
|
-
* A query bus backed by Axon Server.
|
|
493
|
+
* A query bus backed by Axon Server, over YOUR local bus.
|
|
494
|
+
*
|
|
495
|
+
* Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
|
|
496
|
+
* through Axon Server, and a query the server routes here runs through `local`,
|
|
497
|
+
* so your unit-of-work policy applies to server-routed reads too. `subscribe`
|
|
498
|
+
* registers on `local` and announces the name to the server.
|
|
686
499
|
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
500
|
+
* The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
|
|
501
|
+
* has it for queries and not for commands, and so do we. When it is on and this
|
|
502
|
+
* node subscribed the name, `query()` goes straight to `local` and the caller's
|
|
503
|
+
* unit of work is passed through, so the local branch nests exactly as the
|
|
504
|
+
* in-process bus does.
|
|
692
505
|
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
* `
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* `query()` gets identical lineage to the remote branch.
|
|
506
|
+
* Lineage, if wanted, is `interceptingQueryBus(bus, lineage)` at the host,
|
|
507
|
+
* matching AF4's `AxonServerQueryBus`, which calls
|
|
508
|
+
* `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
|
|
509
|
+
* `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
|
|
510
|
+
* shortcut branch gets identical lineage to the remote branch.
|
|
699
511
|
*
|
|
700
512
|
* KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
|
|
701
513
|
* straight from `message.metadata`, and `interceptingQueryBus` (in
|
|
702
|
-
* `@kronos-ts/
|
|
703
|
-
* running the dispatch chain. Closing that needs a
|
|
514
|
+
* `@kronos-ts/core`) forwards those two calls to the delegate without
|
|
515
|
+
* running the dispatch chain. Closing that needs a core change.
|
|
704
516
|
*/
|
|
705
|
-
export function
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
serializer: Serializer,
|
|
710
|
-
flowControl?: FlowControlConfig,
|
|
711
|
-
shortcutQueriesToLocalHandlers?: boolean,
|
|
712
|
-
queryTimeoutMs?: number,
|
|
713
|
-
resilience?: Partial<ResilienceConfig>,
|
|
517
|
+
export function axonServerQueryBus(
|
|
518
|
+
conn: AxonServerBusSource,
|
|
519
|
+
local: QueryBus,
|
|
520
|
+
options: AxonServerQueryBusOptions = {},
|
|
714
521
|
): QueryBus {
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
522
|
+
const {
|
|
523
|
+
connection,
|
|
524
|
+
serializer,
|
|
525
|
+
metadata: axonMetadata,
|
|
526
|
+
} = contextView(conn, options.context ?? conn.connection.config.context)
|
|
527
|
+
const shutdownLatch = conn.shutdown
|
|
528
|
+
const resilience = options.resilience ?? conn.resilience
|
|
529
|
+
const metadata = axonMetadata()
|
|
530
|
+
const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
|
|
531
|
+
const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
|
|
532
|
+
const shortcutQueriesToLocalHandlers = options.shortcutQueriesToLocalHandlers ?? false
|
|
533
|
+
const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS
|
|
718
534
|
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
|
|
719
535
|
|
|
720
|
-
|
|
536
|
+
/** Query names announced to Axon Server; the handlers live on `local`. */
|
|
537
|
+
const subscribedNames = new Set<string>()
|
|
721
538
|
|
|
722
539
|
// Local subscription store — subscription queries opened by THIS instance.
|
|
723
540
|
// Inbound updates from the server are offered into these via the Subscription RPC loop.
|
|
@@ -750,8 +567,21 @@ export function distributedQueryBus(
|
|
|
750
567
|
permits += PERMITS
|
|
751
568
|
}
|
|
752
569
|
|
|
570
|
+
function sendSubscribe(queryName: string) {
|
|
571
|
+
outbound.send({
|
|
572
|
+
subscribe: {
|
|
573
|
+
messageId: generateIdentifier(),
|
|
574
|
+
query: queryName,
|
|
575
|
+
resultName: "",
|
|
576
|
+
componentName: connection.config.componentName,
|
|
577
|
+
clientId: connection.config.clientId,
|
|
578
|
+
},
|
|
579
|
+
instructionId: generateIdentifier(),
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
753
583
|
/**
|
|
754
|
-
* Re-establish the bidirectional stream and re-
|
|
584
|
+
* Re-establish the bidirectional stream and re-announce all handlers.
|
|
755
585
|
* Called on stream error or when the connection reconnects.
|
|
756
586
|
*
|
|
757
587
|
* ORDER (preserves Axon-specific invariant): subscriptions are
|
|
@@ -763,18 +593,7 @@ export function distributedQueryBus(
|
|
|
763
593
|
streamStarted = false
|
|
764
594
|
permits = 0n
|
|
765
595
|
ensureStreamStarted()
|
|
766
|
-
for (const queryName of
|
|
767
|
-
outbound.send({
|
|
768
|
-
subscribe: {
|
|
769
|
-
messageId: generateIdentifier(),
|
|
770
|
-
query: queryName,
|
|
771
|
-
resultName: "",
|
|
772
|
-
componentName: connection.config.componentName,
|
|
773
|
-
clientId: connection.config.clientId,
|
|
774
|
-
},
|
|
775
|
-
instructionId: generateIdentifier(),
|
|
776
|
-
})
|
|
777
|
-
}
|
|
596
|
+
for (const queryName of subscribedNames) sendSubscribe(queryName)
|
|
778
597
|
grantQueryPermits()
|
|
779
598
|
}
|
|
780
599
|
|
|
@@ -790,7 +609,7 @@ export function distributedQueryBus(
|
|
|
790
609
|
connection.onReconnect(() => {
|
|
791
610
|
if (!shutdownLatch.shuttingDown && streamStarted) {
|
|
792
611
|
reestablishStreamWithRetry().catch((err) => {
|
|
793
|
-
console.error("
|
|
612
|
+
console.error("Axon Server query bus: reconnect retries exhausted", err)
|
|
794
613
|
})
|
|
795
614
|
}
|
|
796
615
|
})
|
|
@@ -810,12 +629,11 @@ export function distributedQueryBus(
|
|
|
810
629
|
)
|
|
811
630
|
handlerSubscriptions.set(subId, { queryName, payload })
|
|
812
631
|
|
|
813
|
-
const handler = localSegment.get(queryName)
|
|
814
632
|
let resultPayload: unknown
|
|
815
633
|
let errorCode = ""
|
|
816
634
|
let errorMsg = ""
|
|
817
635
|
|
|
818
|
-
if (
|
|
636
|
+
if (subscribedNames.has(queryName)) {
|
|
819
637
|
try {
|
|
820
638
|
const queryMessage: QueryMessage = {
|
|
821
639
|
kind: "query",
|
|
@@ -825,9 +643,7 @@ export function distributedQueryBus(
|
|
|
825
643
|
metadata: metadataFromProto(proto.metaData ?? {}),
|
|
826
644
|
timestamp: Number(proto.timestamp),
|
|
827
645
|
}
|
|
828
|
-
resultPayload = await
|
|
829
|
-
return handler(queryMessage)
|
|
830
|
-
})
|
|
646
|
+
resultPayload = await local.query(queryMessage)
|
|
831
647
|
} catch (err) {
|
|
832
648
|
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
833
649
|
errorMsg = err instanceof Error ? err.message : String(err)
|
|
@@ -837,9 +653,8 @@ export function distributedQueryBus(
|
|
|
837
653
|
errorMsg = `No local handler for query "${queryName}"`
|
|
838
654
|
}
|
|
839
655
|
|
|
840
|
-
const responseSerialized =
|
|
841
|
-
? serializePayload("result", resultPayload)
|
|
842
|
-
: undefined
|
|
656
|
+
const responseSerialized =
|
|
657
|
+
resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined
|
|
843
658
|
|
|
844
659
|
outbound.send({
|
|
845
660
|
subscriptionQueryResponse: {
|
|
@@ -850,7 +665,12 @@ export function distributedQueryBus(
|
|
|
850
665
|
requestIdentifier: proto.messageIdentifier,
|
|
851
666
|
errorCode,
|
|
852
667
|
errorMessage: errorCode
|
|
853
|
-
? {
|
|
668
|
+
? {
|
|
669
|
+
message: errorMsg,
|
|
670
|
+
location: connection.config.componentName,
|
|
671
|
+
details: [],
|
|
672
|
+
errorCode,
|
|
673
|
+
}
|
|
854
674
|
: undefined,
|
|
855
675
|
payload: responseSerialized,
|
|
856
676
|
metaData: {},
|
|
@@ -879,13 +699,12 @@ export function distributedQueryBus(
|
|
|
879
699
|
permits--
|
|
880
700
|
const proto = message.query
|
|
881
701
|
const queryName = proto.query
|
|
882
|
-
const handler = localSegment.get(queryName)
|
|
883
702
|
|
|
884
703
|
let resultPayload: unknown
|
|
885
704
|
let errorCode = ""
|
|
886
705
|
let errorMsg = ""
|
|
887
706
|
|
|
888
|
-
if (
|
|
707
|
+
if (subscribedNames.has(queryName)) {
|
|
889
708
|
try {
|
|
890
709
|
const queryMessage: QueryMessage = {
|
|
891
710
|
kind: "query",
|
|
@@ -896,9 +715,9 @@ export function distributedQueryBus(
|
|
|
896
715
|
timestamp: Number(proto.timestamp),
|
|
897
716
|
}
|
|
898
717
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
718
|
+
// Through the LOCAL BUS: no unit of work is handed in, so `local`
|
|
719
|
+
// opens one under whatever policy the caller gave it.
|
|
720
|
+
resultPayload = await local.query(queryMessage)
|
|
902
721
|
} catch (err) {
|
|
903
722
|
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
904
723
|
errorMsg = err instanceof Error ? err.message : String(err)
|
|
@@ -914,11 +733,15 @@ export function distributedQueryBus(
|
|
|
914
733
|
requestIdentifier: proto.messageIdentifier,
|
|
915
734
|
errorCode,
|
|
916
735
|
errorMessage: errorCode
|
|
917
|
-
? {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
736
|
+
? {
|
|
737
|
+
message: errorMsg,
|
|
738
|
+
location: connection.config.componentName,
|
|
739
|
+
details: [],
|
|
740
|
+
errorCode,
|
|
741
|
+
}
|
|
921
742
|
: undefined,
|
|
743
|
+
payload:
|
|
744
|
+
resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
|
|
922
745
|
metaData: {},
|
|
923
746
|
processingInstructions: [],
|
|
924
747
|
},
|
|
@@ -945,39 +768,52 @@ export function distributedQueryBus(
|
|
|
945
768
|
if (shutdownLatch.shuttingDown) return
|
|
946
769
|
if (String(err).includes("Connection dropped")) return
|
|
947
770
|
|
|
948
|
-
console.error(
|
|
771
|
+
console.error(
|
|
772
|
+
"Axon Server query bus: inbound stream error, attempting re-establishment via withRetry",
|
|
773
|
+
err,
|
|
774
|
+
)
|
|
949
775
|
await reestablishStreamWithRetry().catch((retryErr) => {
|
|
950
|
-
console.error("
|
|
776
|
+
console.error("Axon Server query bus: reconnect retries exhausted", retryErr)
|
|
951
777
|
})
|
|
952
778
|
}
|
|
953
779
|
}
|
|
954
780
|
|
|
955
781
|
const routing: QueryBus = {
|
|
956
|
-
async query(
|
|
782
|
+
async query(unstamped: Unstamped<QueryMessage>, uow?: UnitOfWork): Promise<unknown> {
|
|
957
783
|
const activity = shutdownLatch.registerActivity()
|
|
958
784
|
try {
|
|
959
|
-
const queryName = qualifiedNameToString(
|
|
960
|
-
|
|
961
|
-
// Local shortcut — handle locally if handler is co-located
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
}
|
|
785
|
+
const queryName = qualifiedNameToString(unstamped.name)
|
|
786
|
+
|
|
787
|
+
// Local shortcut — handle locally if a handler is co-located. The
|
|
788
|
+
// caller's unit of work is passed straight through, so `local` makes the
|
|
789
|
+
// nest-or-open decision on the HANDLE exactly as it does for an
|
|
790
|
+
// in-process read: a live unit of work handed in by `ctx.query` is
|
|
791
|
+
// reused so the consulting read shares the caller's transaction.
|
|
792
|
+
if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
|
|
793
|
+
return local.query(unstamped, uow)
|
|
969
794
|
}
|
|
970
795
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
796
|
+
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
797
|
+
// A message that reaches the wire still {@link Unstamped} is therefore
|
|
798
|
+
// stamped from system time here — the envelope crosses a process boundary
|
|
799
|
+
// and must be fully formed. A locally-shortcut message is handed to
|
|
800
|
+
// `local` unstamped instead, so the task that handles it supplies the
|
|
801
|
+
// instant.
|
|
802
|
+
const message = stamped(unstamped, Date.now)
|
|
803
|
+
|
|
804
|
+
const responseStream = connection.queries.query(
|
|
805
|
+
{
|
|
806
|
+
messageIdentifier: message.identifier,
|
|
807
|
+
query: queryName,
|
|
808
|
+
timestamp: BigInt(message.timestamp),
|
|
809
|
+
payload: serializePayload(queryName, message.payload),
|
|
810
|
+
metaData: metadataToProto(message.metadata),
|
|
811
|
+
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
812
|
+
clientId: connection.config.clientId,
|
|
813
|
+
componentName: connection.config.componentName,
|
|
814
|
+
},
|
|
815
|
+
{ metadata },
|
|
816
|
+
)
|
|
981
817
|
|
|
982
818
|
for await (const response of responseStream) {
|
|
983
819
|
if (response.errorCode && response.errorCode !== "") {
|
|
@@ -995,25 +831,24 @@ export function distributedQueryBus(
|
|
|
995
831
|
}
|
|
996
832
|
},
|
|
997
833
|
|
|
998
|
-
subscribe(
|
|
999
|
-
|
|
834
|
+
subscribe(
|
|
835
|
+
queryName: string,
|
|
836
|
+
handler: (message: QueryMessage, uow: UnitOfWork) => Promise<unknown>,
|
|
837
|
+
) {
|
|
838
|
+
subscribedNames.add(queryName)
|
|
839
|
+
local.subscribe(queryName, handler)
|
|
1000
840
|
|
|
1001
841
|
ensureStreamStarted()
|
|
1002
|
-
|
|
1003
|
-
subscribe: {
|
|
1004
|
-
messageId: generateIdentifier(),
|
|
1005
|
-
query: queryName,
|
|
1006
|
-
resultName: "",
|
|
1007
|
-
componentName: connection.config.componentName,
|
|
1008
|
-
clientId: connection.config.clientId,
|
|
1009
|
-
},
|
|
1010
|
-
instructionId: generateIdentifier(),
|
|
1011
|
-
})
|
|
842
|
+
sendSubscribe(queryName)
|
|
1012
843
|
// Permits AFTER subscription (Axon-specific ordering invariant)
|
|
1013
844
|
grantQueryPermits()
|
|
1014
845
|
},
|
|
1015
846
|
|
|
1016
|
-
subscriptionQuery(
|
|
847
|
+
subscriptionQuery(
|
|
848
|
+
unstamped: Unstamped<QueryMessage>,
|
|
849
|
+
bufferSize?: number,
|
|
850
|
+
): SubscriptionQueryResult {
|
|
851
|
+
const message = stamped(unstamped, Date.now)
|
|
1017
852
|
const queryId = message.identifier
|
|
1018
853
|
if (subscriptions.has(queryId)) {
|
|
1019
854
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
@@ -1037,7 +872,7 @@ export function distributedQueryBus(
|
|
|
1037
872
|
timestamp: BigInt(message.timestamp),
|
|
1038
873
|
payload: serializePayload(queryName, message.payload),
|
|
1039
874
|
metaData: metadataToProto(message.metadata),
|
|
1040
|
-
processingInstructions: defaultQueryInstructions(queryTimeoutMs
|
|
875
|
+
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
1041
876
|
clientId: connection.config.clientId,
|
|
1042
877
|
componentName: connection.config.componentName,
|
|
1043
878
|
},
|
|
@@ -1054,7 +889,7 @@ export function distributedQueryBus(
|
|
|
1054
889
|
timestamp: BigInt(message.timestamp),
|
|
1055
890
|
payload: serializePayload(queryName, message.payload),
|
|
1056
891
|
metaData: metadataToProto(message.metadata),
|
|
1057
|
-
processingInstructions: defaultQueryInstructions(queryTimeoutMs
|
|
892
|
+
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
1058
893
|
clientId: connection.config.clientId,
|
|
1059
894
|
componentName: connection.config.componentName,
|
|
1060
895
|
},
|
|
@@ -1078,21 +913,33 @@ export function distributedQueryBus(
|
|
|
1078
913
|
const initial = response.initialResult
|
|
1079
914
|
if (!initialSettled) {
|
|
1080
915
|
if (initial.errorCode && initial.errorCode !== "") {
|
|
1081
|
-
rejectInitial(
|
|
916
|
+
rejectInitial(
|
|
917
|
+
mapErrorCode(
|
|
918
|
+
initial.errorCode,
|
|
919
|
+
initial.errorMessage?.message ?? "Unknown error",
|
|
920
|
+
),
|
|
921
|
+
)
|
|
1082
922
|
} else {
|
|
1083
|
-
resolveInitial(
|
|
923
|
+
resolveInitial(
|
|
924
|
+
deserializePayload(initial.payload?.data as Uint8Array | undefined),
|
|
925
|
+
)
|
|
1084
926
|
}
|
|
1085
927
|
initialSettled = true
|
|
1086
928
|
}
|
|
1087
929
|
} else if (response.update) {
|
|
1088
|
-
const update = deserializePayload(
|
|
930
|
+
const update = deserializePayload(
|
|
931
|
+
response.update.payload?.data as Uint8Array | undefined,
|
|
932
|
+
)
|
|
1089
933
|
handler.offer(update)
|
|
1090
934
|
} else if (response.complete) {
|
|
1091
935
|
handler.complete()
|
|
1092
936
|
break
|
|
1093
937
|
} else if (response.completeExceptionally) {
|
|
1094
938
|
handler.completeExceptionally(
|
|
1095
|
-
new Error(
|
|
939
|
+
new Error(
|
|
940
|
+
response.completeExceptionally.errorMessage?.message ??
|
|
941
|
+
"Subscription query failed",
|
|
942
|
+
),
|
|
1096
943
|
)
|
|
1097
944
|
break
|
|
1098
945
|
}
|
|
@@ -1125,7 +972,11 @@ export function distributedQueryBus(
|
|
|
1125
972
|
}
|
|
1126
973
|
},
|
|
1127
974
|
|
|
1128
|
-
subscribeToUpdates(
|
|
975
|
+
subscribeToUpdates(
|
|
976
|
+
unstamped: Unstamped<QueryMessage>,
|
|
977
|
+
bufferSize?: number,
|
|
978
|
+
): AsyncIterable<unknown> & { close(): void } {
|
|
979
|
+
const message = stamped(unstamped, Date.now)
|
|
1129
980
|
const queryId = message.identifier
|
|
1130
981
|
if (subscriptions.has(queryId)) {
|
|
1131
982
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
@@ -1174,10 +1025,7 @@ export function distributedQueryBus(
|
|
|
1174
1025
|
})
|
|
1175
1026
|
},
|
|
1176
1027
|
|
|
1177
|
-
async completeSubscription(
|
|
1178
|
-
queryName: string,
|
|
1179
|
-
filter?: SubscriptionFilter,
|
|
1180
|
-
): Promise<void> {
|
|
1028
|
+
async completeSubscription(queryName: string, filter?: SubscriptionFilter): Promise<void> {
|
|
1181
1029
|
runAfterCommitOrImmediately(() => {
|
|
1182
1030
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
1183
1031
|
if (sub.queryName !== queryName) continue
|
|
@@ -1233,7 +1081,5 @@ export function distributedQueryBus(
|
|
|
1233
1081
|
},
|
|
1234
1082
|
}
|
|
1235
1083
|
|
|
1236
|
-
|
|
1237
|
-
bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
|
|
1238
|
-
return bus
|
|
1084
|
+
return routing
|
|
1239
1085
|
}
|