@kronos-ts/axon-server 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/axon-server-event-store.d.ts +0 -16
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +73 -45
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshotting-event-store.d.ts +30 -0
- package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -0
- package/dist/axon-server-snapshotting-event-store.js +154 -0
- package/dist/axon-server-snapshotting-event-store.js.map +1 -0
- package/dist/axon-server.d.ts +46 -65
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +490 -317
- package/dist/axon-server.js.map +1 -1
- package/dist/bounded-read.d.ts +19 -0
- package/dist/bounded-read.d.ts.map +1 -0
- package/dist/bounded-read.js +39 -0
- package/dist/bounded-read.js.map +1 -0
- package/dist/connection-manager.d.ts +2 -2
- package/dist/connection-manager.d.ts.map +1 -1
- package/dist/connection.d.ts +36 -21
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +84 -45
- package/dist/connection.js.map +1 -1
- package/dist/context-view.d.ts +2 -2
- package/dist/context-view.d.ts.map +1 -1
- package/dist/control-plane.d.ts +14 -24
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +6 -37
- package/dist/control-plane.js.map +1 -1
- package/dist/event-processor-info.d.ts +7 -25
- package/dist/event-processor-info.d.ts.map +1 -1
- package/dist/event-processor-info.js +16 -20
- package/dist/event-processor-info.js.map +1 -1
- package/dist/flow-controlled-sender.d.ts +2 -2
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/flow-controlled-sender.js +37 -17
- package/dist/flow-controlled-sender.js.map +1 -1
- package/dist/generated/command.d.ts +20 -20
- package/dist/generated/command.d.ts.map +1 -1
- package/dist/generated/common.d.ts +20 -20
- package/dist/generated/common.d.ts.map +1 -1
- package/dist/generated/control.d.ts +36 -40
- package/dist/generated/control.d.ts.map +1 -1
- package/dist/generated/dcb.d.ts +88 -94
- package/dist/generated/dcb.d.ts.map +1 -1
- package/dist/generated/event.d.ts +81 -84
- package/dist/generated/event.d.ts.map +1 -1
- package/dist/generated/google/protobuf/empty.d.ts +3 -4
- package/dist/generated/google/protobuf/empty.d.ts.map +1 -1
- package/dist/generated/query.d.ts +40 -40
- package/dist/generated/query.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/message-size.d.ts +2 -2
- package/dist/message-size.d.ts.map +1 -1
- package/dist/outbound-stream.d.ts +7 -11
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/outbound-stream.js +65 -16
- package/dist/outbound-stream.js.map +1 -1
- package/dist/platform-service.d.ts +14 -5
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/platform-service.js +70 -10
- package/dist/platform-service.js.map +1 -1
- package/dist/resilience.d.ts +62 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +103 -0
- package/dist/resilience.js.map +1 -0
- package/dist/shutdown-latch.d.ts +5 -4
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js +20 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/dist/stream-recovery.d.ts +9 -0
- package/dist/stream-recovery.d.ts.map +1 -0
- package/dist/stream-recovery.js +70 -0
- package/dist/stream-recovery.js.map +1 -0
- package/package.json +3 -3
- package/src/axon-server-event-store.ts +77 -47
- package/src/axon-server-snapshotting-event-store.ts +194 -0
- package/src/axon-server.ts +472 -374
- package/src/bounded-read.ts +43 -0
- package/src/connection-manager.ts +1 -1
- package/src/connection.ts +93 -59
- package/src/context-view.ts +1 -1
- package/src/control-plane.ts +22 -66
- package/src/event-processor-info.ts +23 -44
- package/src/flow-controlled-sender.ts +34 -15
- package/src/generated/command.ts +10 -10
- package/src/generated/common.ts +10 -10
- package/src/generated/control.ts +20 -20
- package/src/generated/dcb.ts +47 -47
- package/src/generated/event.ts +42 -42
- package/src/generated/google/protobuf/empty.ts +2 -2
- package/src/generated/query.ts +20 -20
- package/src/index.ts +1 -2
- package/src/message-size.ts +1 -1
- package/src/outbound-stream.ts +58 -28
- package/src/platform-service.ts +77 -13
- package/src/resilience.ts +149 -0
- package/src/shutdown-latch.ts +16 -3
- package/src/stream-recovery.ts +72 -0
- package/dist/axon-server-snapshot-store.d.ts +0 -12
- package/dist/axon-server-snapshot-store.d.ts.map +0 -1
- package/dist/axon-server-snapshot-store.js +0 -82
- package/dist/axon-server-snapshot-store.js.map +0 -1
- package/src/axon-server-snapshot-store.ts +0 -110
package/src/axon-server.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
+
import { streamRecovery } from "./stream-recovery.js"
|
|
2
|
+
import { messagingAdmission, messagingDeadline, positiveInteger, type MessagingLimits } from "@kronos-ts/core"
|
|
1
3
|
/**
|
|
2
4
|
* The Axon Server command and query buses.
|
|
3
5
|
*
|
|
4
6
|
* Axon Server is a SMART HUB: outbound dispatch always goes to the server, and
|
|
5
7
|
* the server decides which node handles it — there is no client-side
|
|
6
|
-
* prefer-
|
|
8
|
+
* prefer-next fork here, which is the whole difference from the dumb-pipe
|
|
7
9
|
* broker in `@kronos-ts/rabbitmq`.
|
|
8
10
|
*
|
|
9
|
-
* Both buses are plain functions over the shared connection and YOUR
|
|
11
|
+
* Both buses are plain functions over the shared connection and YOUR next bus:
|
|
10
12
|
*
|
|
11
13
|
* ```ts
|
|
12
14
|
* const commandBus = interceptingCommandBus(
|
|
13
|
-
* axonServerCommandBus(
|
|
15
|
+
* axonServerCommandBus(localCommandBus(unitOfWork), axon), correlation)
|
|
14
16
|
* const queryBus = interceptingQueryBus(
|
|
15
|
-
* axonServerQueryBus(
|
|
17
|
+
* axonServerQueryBus(localQueryBus(unitOfWork), axon), correlation)
|
|
16
18
|
* ```
|
|
17
19
|
*
|
|
18
|
-
* Axon-specific protocol invariants
|
|
20
|
+
* Axon-specific protocol invariants:
|
|
19
21
|
*
|
|
20
22
|
* - CLIENT_SUPPORTS_STREAMING capability advertised on every dispatched
|
|
21
23
|
* query via `defaultQueryInstructions(...)`;
|
|
@@ -30,38 +32,37 @@ import {
|
|
|
30
32
|
qualifiedNameFromString,
|
|
31
33
|
generateIdentifier,
|
|
32
34
|
type Serializer,
|
|
33
|
-
withRetry,
|
|
34
|
-
type ResilienceConfig,
|
|
35
35
|
} from "@kronos-ts/core"
|
|
36
|
+
import { type ResilienceConfig } from "./resilience.js"
|
|
36
37
|
import type {
|
|
37
38
|
CommandBus,
|
|
38
39
|
CommandMessage,
|
|
39
40
|
QueryBus,
|
|
41
|
+
SubscriptionCapableQueryBus,
|
|
40
42
|
QueryMessage,
|
|
41
43
|
SubscriptionFilter,
|
|
42
44
|
SubscriptionQueryResult,
|
|
43
45
|
UnitOfWork,
|
|
44
|
-
Unstamped,
|
|
45
46
|
UpdateHandler,
|
|
46
47
|
} from "@kronos-ts/core"
|
|
47
48
|
import {
|
|
48
49
|
applySubscriptionFilter,
|
|
49
|
-
stamped,
|
|
50
50
|
updateHandler,
|
|
51
51
|
runAfterCommitOrImmediately,
|
|
52
52
|
} from "@kronos-ts/core"
|
|
53
53
|
import type { AxonServerBusSource } from "./connection.js"
|
|
54
54
|
import { contextView } from "./context-view.js"
|
|
55
55
|
import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
|
|
56
|
-
import { outboundStream } from "./outbound-stream.js"
|
|
56
|
+
import { outboundStream, type OutboundStream } from "./outbound-stream.js"
|
|
57
|
+
import type { Command } from "./generated/command.js"
|
|
58
|
+
import type { ShutdownLatch } from "./shutdown-latch.js"
|
|
57
59
|
import { mapErrorCode, AxonServerErrorCode } from "./errors.js"
|
|
58
60
|
|
|
59
61
|
/** Default flow control settings — aligned with Java's 5000 permits. */
|
|
60
62
|
const DEFAULT_PERMITS = 5000n
|
|
61
|
-
const DEFAULT_THRESHOLD = 2500n
|
|
62
63
|
|
|
63
64
|
/** Default query dispatch timeout — aligned with Java's one hour. */
|
|
64
|
-
const DEFAULT_QUERY_TIMEOUT_MS =
|
|
65
|
+
const DEFAULT_QUERY_TIMEOUT_MS = 30_000
|
|
65
66
|
|
|
66
67
|
/** Default command handler load factor — aligned with Java's 100. */
|
|
67
68
|
const DEFAULT_LOAD_FACTOR = 100
|
|
@@ -69,7 +70,7 @@ const DEFAULT_LOAD_FACTOR = 100
|
|
|
69
70
|
/**
|
|
70
71
|
* Flow control configuration for a bus channel.
|
|
71
72
|
*/
|
|
72
|
-
export
|
|
73
|
+
export type FlowControlConfig = {
|
|
73
74
|
/** Initial permits granted to Axon Server. Default: 5000 (aligned with Java). */
|
|
74
75
|
permits?: number
|
|
75
76
|
/** Threshold at which to request more permits. Default: 2500 (aligned with Java). */
|
|
@@ -80,7 +81,7 @@ export interface FlowControlConfig {
|
|
|
80
81
|
* Processing instructions attached to outbound messages.
|
|
81
82
|
* Controls routing, priority, and timeout behavior on Axon Server.
|
|
82
83
|
*/
|
|
83
|
-
export
|
|
84
|
+
export type ProcessingInstructions = {
|
|
84
85
|
/** Routing key for consistent hashing (e.g., aggregate ID). */
|
|
85
86
|
routingKey?: string
|
|
86
87
|
/** Priority (higher = processed first). Default: 0 */
|
|
@@ -91,10 +92,12 @@ export interface ProcessingInstructions {
|
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* Tuning for {@link axonServerCommandBus}. Every field has a working default;
|
|
94
|
-
* the two arguments that carry meaning — the connection and your
|
|
95
|
+
* the two arguments that carry meaning — the connection and your next bus —
|
|
95
96
|
* are positional, and this record is the trailing remainder.
|
|
96
97
|
*/
|
|
97
|
-
export
|
|
98
|
+
export type AxonServerCommandBusOptions = {
|
|
99
|
+
/** Client-side request deadline. Default: 30000ms. */
|
|
100
|
+
timeoutMs?: number
|
|
98
101
|
/** Axon Server context for this bus's stream. Default: the connection's. */
|
|
99
102
|
context?: string
|
|
100
103
|
/** Flow control for the command stream. */
|
|
@@ -107,12 +110,15 @@ export interface AxonServerCommandBusOptions {
|
|
|
107
110
|
loadFactor?: number
|
|
108
111
|
/** Retry policy for stream re-establishment. Default: the connection's. */
|
|
109
112
|
resilience?: Partial<ResilienceConfig>
|
|
113
|
+
/** Bounded admission; excess nested work receives an overload error. */
|
|
114
|
+
limits?: MessagingLimits
|
|
115
|
+
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/**
|
|
113
119
|
* Tuning for {@link axonServerQueryBus}. See {@link AxonServerCommandBusOptions}.
|
|
114
120
|
*/
|
|
115
|
-
export
|
|
121
|
+
export type AxonServerQueryBusOptions = {
|
|
116
122
|
/** Axon Server context for this bus's stream. Default: the connection's. */
|
|
117
123
|
context?: string
|
|
118
124
|
/** Flow control for the query stream. */
|
|
@@ -128,12 +134,15 @@ export interface AxonServerQueryBusOptions {
|
|
|
128
134
|
*/
|
|
129
135
|
shortcutQueriesToLocalHandlers?: boolean
|
|
130
136
|
/**
|
|
131
|
-
* Default timeout for query dispatch in ms. Default:
|
|
137
|
+
* Default timeout for query dispatch in ms. Default: 30000ms.
|
|
132
138
|
* Aligned with Java's processing instruction timeout.
|
|
133
139
|
*/
|
|
134
140
|
timeoutMs?: number
|
|
135
141
|
/** Retry policy for stream re-establishment. Default: the connection's. */
|
|
136
142
|
resilience?: Partial<ResilienceConfig>
|
|
143
|
+
/** Bounded admission; excess nested work receives an overload error. */
|
|
144
|
+
limits?: MessagingLimits
|
|
145
|
+
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
// Processing instruction keys — aligned with proto ProcessingKey enum.
|
|
@@ -208,33 +217,33 @@ function createPayloadHelpers(serializer: Serializer) {
|
|
|
208
217
|
// ---------------------------------------------------------------------------
|
|
209
218
|
|
|
210
219
|
/**
|
|
211
|
-
* A command bus backed by Axon Server, over YOUR
|
|
220
|
+
* A command bus backed by Axon Server, over YOUR next bus.
|
|
212
221
|
*
|
|
213
222
|
* - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
|
|
214
223
|
* RPC. Axon Server routes the command to the appropriate node (which may be
|
|
215
|
-
* this one). There is deliberately no client-side prefer-
|
|
224
|
+
* this one). There is deliberately no client-side prefer-next fork: the hub
|
|
216
225
|
* is the router, and short-circuiting it would silently defeat load factors,
|
|
217
226
|
* priorities and routing keys.
|
|
218
|
-
* - **Inbound**: a command the server routes here is dispatched into `
|
|
227
|
+
* - **Inbound**: a command the server routes here is dispatched into `next` —
|
|
219
228
|
* not into a privately-held handler map. That is what makes the unit-of-work
|
|
220
|
-
* policy you chose for `
|
|
229
|
+
* policy you chose for `next` (say `postgresUnitOfWork(unitOfWork, pg)`)
|
|
221
230
|
* apply to server-routed work exactly as it applies to work this process
|
|
222
231
|
* originated. It is also why this function takes no `unitOfWork` argument:
|
|
223
|
-
* `
|
|
224
|
-
* - **subscribe**: registers the handler on `
|
|
232
|
+
* `next` carries that policy now.
|
|
233
|
+
* - **subscribe**: registers the handler on `next` AND announces the name to
|
|
225
234
|
* Axon Server, so other nodes can route to us.
|
|
226
235
|
*
|
|
227
|
-
* ##
|
|
236
|
+
* ## correlation and the interceptor layer
|
|
228
237
|
*
|
|
229
|
-
* The returned bus stamps no
|
|
238
|
+
* The returned bus stamps no correlation of its own. A host that wants it wraps the
|
|
230
239
|
* OUTERMOST bus:
|
|
231
240
|
*
|
|
232
241
|
* ```ts
|
|
233
|
-
* interceptingCommandBus(axonServerCommandBus(
|
|
242
|
+
* interceptingCommandBus(axonServerCommandBus(next, conn), correlation)
|
|
234
243
|
* ```
|
|
235
244
|
*
|
|
236
245
|
* so whatever a host adds runs BEFORE the message is serialized onto the wire.
|
|
237
|
-
*
|
|
246
|
+
* Correlation itself is usually already on `message.metadata` by then — `ctx.send`
|
|
238
247
|
* stamps the unit of work's correlation data before any bus sees the message.
|
|
239
248
|
*
|
|
240
249
|
* This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
|
|
@@ -242,41 +251,48 @@ function createPayloadHelpers(serializer: Serializer) {
|
|
|
242
251
|
* `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
|
|
243
252
|
* site, at the top, ahead of any routing. AF5 keeps the property via decorator
|
|
244
253
|
* order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
|
|
245
|
-
* stacks `InterceptingCommandBus → DistributedCommandBus →
|
|
254
|
+
* stacks `InterceptingCommandBus → DistributedCommandBus → LocalCommandBus`.
|
|
246
255
|
*
|
|
247
|
-
* If `
|
|
248
|
-
* `
|
|
256
|
+
* If `next` is itself an intercepting bus, a server-routed command sees
|
|
257
|
+
* `correlation` twice. That is harmless: both of its fields are `??` seeds, so the
|
|
249
258
|
* second application finds them set and changes nothing.
|
|
250
259
|
*/
|
|
251
|
-
export function axonServerCommandBus(
|
|
260
|
+
export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
|
|
261
|
+
next: CommandBus<U>,
|
|
252
262
|
conn: AxonServerBusSource,
|
|
253
|
-
local: CommandBus,
|
|
254
263
|
options: AxonServerCommandBusOptions = {},
|
|
255
|
-
): CommandBus {
|
|
264
|
+
): CommandBus<U> {
|
|
256
265
|
const {
|
|
257
266
|
connection,
|
|
258
267
|
serializer,
|
|
259
268
|
metadata: axonMetadata,
|
|
260
269
|
} = contextView(conn, options.context ?? conn.connection.config.context)
|
|
261
270
|
const shutdownLatch = conn.shutdown
|
|
271
|
+
const requestTimeoutMs = positiveInteger(options.timeoutMs ?? 30000, "timeoutMs")
|
|
272
|
+
if (requestTimeoutMs > 2_147_483_647) throw new RangeError("timeoutMs exceeds the timer range")
|
|
273
|
+
const inboundAdmission = messagingAdmission("inbound handlers", options.limits?.maxConcurrentHandlers ?? 128, options.limits?.observe)
|
|
274
|
+
const outboundAdmission = messagingAdmission("pending requests", options.limits?.maxPendingRequests ?? 1024, options.limits?.observe)
|
|
262
275
|
const resilience = options.resilience ?? conn.resilience
|
|
263
276
|
const metadata = axonMetadata()
|
|
264
277
|
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
|
|
265
|
-
const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
|
|
266
|
-
const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(
|
|
278
|
+
const PERMITS = BigInt(positiveInteger(options.flowControl?.permits ?? Number(DEFAULT_PERMITS), "flowControl.permits"))
|
|
279
|
+
const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Math.floor(Number(PERMITS) / 2))
|
|
280
|
+
if (THRESHOLD < 0n || THRESHOLD >= PERMITS) throw new RangeError("refillThreshold must be between zero and permits - 1")
|
|
267
281
|
const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR
|
|
268
282
|
|
|
269
283
|
/**
|
|
270
284
|
* The names this node announced to Axon Server. The handlers themselves live
|
|
271
|
-
* on `
|
|
285
|
+
* on `next`; this set exists so an inbound command for a name we never
|
|
272
286
|
* subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
|
|
273
|
-
* `
|
|
287
|
+
* `next.dispatch` happens to throw — and so a reconnect can re-announce.
|
|
274
288
|
*/
|
|
275
289
|
const subscribedNames = new Set<string>()
|
|
276
290
|
|
|
277
291
|
// Bidirectional stream for handler subscription + inbound command handling
|
|
278
292
|
let outbound = outboundStream<any>()
|
|
279
293
|
let streamStarted = false
|
|
294
|
+
let providerAbort = new AbortController()
|
|
295
|
+
connection.onDisconnect?.(() => { providerAbort.abort(); outbound.close() })
|
|
280
296
|
let permits = 0n
|
|
281
297
|
|
|
282
298
|
function ensureStreamStarted() {
|
|
@@ -284,8 +300,8 @@ export function axonServerCommandBus(
|
|
|
284
300
|
streamStarted = true
|
|
285
301
|
|
|
286
302
|
// Open stream using connection.commands (always gets current client after reconnect)
|
|
287
|
-
const inbound = connection.commands.openStream(outbound.iterable, { metadata })
|
|
288
|
-
processInboundCommands(inbound)
|
|
303
|
+
const inbound = connection.commands.openStream(outbound.iterable, { metadata, signal: providerAbort.signal })
|
|
304
|
+
void processInboundCommands(inbound, outbound)
|
|
289
305
|
}
|
|
290
306
|
|
|
291
307
|
function grantPermits() {
|
|
@@ -318,6 +334,8 @@ export function axonServerCommandBus(
|
|
|
318
334
|
* trigger a server-side stream error.
|
|
319
335
|
*/
|
|
320
336
|
function reestablishStreamBody() {
|
|
337
|
+
providerAbort.abort()
|
|
338
|
+
providerAbort = new AbortController()
|
|
321
339
|
outbound.close()
|
|
322
340
|
outbound = outboundStream<any>()
|
|
323
341
|
streamStarted = false
|
|
@@ -329,118 +347,123 @@ export function axonServerCommandBus(
|
|
|
329
347
|
grantPermits()
|
|
330
348
|
}
|
|
331
349
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
...resilience,
|
|
337
|
-
})
|
|
338
|
-
}
|
|
350
|
+
const recovery = streamRecovery(reestablishStreamBody,
|
|
351
|
+
() => !shutdownLatch.shuttingDown && connection.state !== "closed" && connection.state !== "disconnected" && connection.state !== "reconnecting",
|
|
352
|
+
resilience)
|
|
353
|
+
shutdownLatch.onShutdown(recovery.stop)
|
|
339
354
|
|
|
340
355
|
// Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
|
|
341
356
|
connection.onReconnect(() => {
|
|
342
357
|
if (!shutdownLatch.shuttingDown && streamStarted) {
|
|
343
|
-
|
|
344
|
-
console.error("Axon Server command bus: reconnect retries exhausted", err)
|
|
345
|
-
})
|
|
358
|
+
recovery.restart()
|
|
346
359
|
}
|
|
347
360
|
})
|
|
348
361
|
|
|
349
|
-
async function
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
362
|
+
async function handleInboundCommand(proto: Command, responses: OutboundStream<any>) {
|
|
363
|
+
let activity: ReturnType<ShutdownLatch["registerActivity"]> | undefined
|
|
364
|
+
let admission: ReturnType<typeof inboundAdmission.enter> | undefined
|
|
365
|
+
let responseSerialized: ReturnType<typeof serializePayload> | undefined
|
|
366
|
+
let errorCode = ""
|
|
367
|
+
let errorMsg = ""
|
|
353
368
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
metadata: metadataFromProto(proto.metaData),
|
|
370
|
-
timestamp: Number(proto.timestamp),
|
|
371
|
-
}
|
|
372
|
-
|
|
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)
|
|
379
|
-
} catch (err) {
|
|
380
|
-
errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
|
|
381
|
-
errorMsg = err instanceof Error ? err.message : String(err)
|
|
369
|
+
try {
|
|
370
|
+
try {
|
|
371
|
+
// Remote callers have no outbound dispatch activity on this adapter.
|
|
372
|
+
// Track the entire handling, including result serialization and enqueue.
|
|
373
|
+
// Registration also rejects new work once shutdown has begun.
|
|
374
|
+
activity = shutdownLatch.registerActivity()
|
|
375
|
+
admission = inboundAdmission.enter()
|
|
376
|
+
if (subscribedNames.has(proto.name)) {
|
|
377
|
+
const commandMessage: CommandMessage = {
|
|
378
|
+
kind: "command",
|
|
379
|
+
identifier: proto.messageIdentifier,
|
|
380
|
+
name: qualifiedNameFromString(proto.name),
|
|
381
|
+
payload: deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision),
|
|
382
|
+
metadata: metadataFromProto(proto.metaData ?? {}),
|
|
383
|
+
timestamp: Number(proto.timestamp),
|
|
382
384
|
}
|
|
385
|
+
|
|
386
|
+
// The local bus opens a fresh unit of work for EVERY wire command,
|
|
387
|
+
// including children of handlers running on this same connection.
|
|
388
|
+
const result = await next.dispatch(commandMessage)
|
|
389
|
+
responseSerialized = result !== undefined ? serializePayload("result", result) : undefined
|
|
383
390
|
} else {
|
|
384
391
|
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_COMMAND
|
|
385
|
-
errorMsg = `No
|
|
392
|
+
errorMsg = `No next handler for command "${proto.name}"`
|
|
386
393
|
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
// Decode, handler, and result-encoding failures belong to this request;
|
|
396
|
+
// none should terminate the receive loop or reconnect the stream.
|
|
397
|
+
errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
|
|
398
|
+
errorMsg = err instanceof Error ? err.message : String(err)
|
|
399
|
+
}
|
|
387
400
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
401
|
+
// Capture the originating stream: a late handler must not send an old
|
|
402
|
+
// request's response on a replacement stream after reconnect.
|
|
403
|
+
responses.send({
|
|
404
|
+
commandResponse: {
|
|
405
|
+
messageIdentifier: generateIdentifier(),
|
|
406
|
+
requestIdentifier: proto.messageIdentifier,
|
|
407
|
+
errorCode,
|
|
408
|
+
errorMessage: errorCode
|
|
409
|
+
? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
|
|
410
|
+
: undefined,
|
|
411
|
+
payload: responseSerialized,
|
|
412
|
+
metaData: {},
|
|
413
|
+
processingInstructions: [],
|
|
414
|
+
},
|
|
415
|
+
instructionId: "",
|
|
416
|
+
})
|
|
417
|
+
await responses.flush()
|
|
418
|
+
} finally {
|
|
419
|
+
admission?.end()
|
|
420
|
+
activity?.end()
|
|
421
|
+
}
|
|
422
|
+
}
|
|
409
423
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
424
|
+
async function processInboundCommands(inbound: AsyncIterable<any>, responses: OutboundStream<any>) {
|
|
425
|
+
try {
|
|
426
|
+
for await (const message of inbound) {
|
|
427
|
+
if (responses !== outbound) return
|
|
428
|
+
recovery.received()
|
|
429
|
+
if (message.instructionId) responses.send({ ack: { instructionId: message.instructionId, success: true }, instructionId: "" })
|
|
430
|
+
permits--
|
|
431
|
+
// Credits bound delivery batches, not unfinished handlers. Replenish
|
|
432
|
+
// on receipt: completion-based credits or a fixed handler semaphore can
|
|
433
|
+
// deadlock when every admitted parent is waiting for a queued child.
|
|
434
|
+
if (permits <= THRESHOLD && !shutdownLatch.shuttingDown) grantPermits()
|
|
435
|
+
|
|
436
|
+
if (!message.command) continue
|
|
437
|
+
|
|
438
|
+
// Each invocation owns its response and shutdown activity. Keep reading
|
|
439
|
+
// while it awaits work so nested dispatch can return on this connection.
|
|
440
|
+
void handleInboundCommand(message.command, responses).catch((err) => {
|
|
441
|
+
console.error("Axon Server command bus: inbound response failed", err)
|
|
442
|
+
})
|
|
418
443
|
}
|
|
444
|
+
if (responses === outbound && !shutdownLatch.shuttingDown) throw new Error("Inbound provider stream ended unexpectedly")
|
|
419
445
|
} catch (err) {
|
|
420
|
-
if (shutdownLatch.shuttingDown) return
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"Axon Server command bus: inbound stream error, attempting re-establishment via withRetry",
|
|
425
|
-
err,
|
|
426
|
-
)
|
|
427
|
-
await reestablishStreamWithRetry().catch((retryErr) => {
|
|
428
|
-
console.error("Axon Server command bus: reconnect retries exhausted", retryErr)
|
|
429
|
-
})
|
|
446
|
+
if (responses !== outbound || shutdownLatch.shuttingDown) return
|
|
447
|
+
if (connection.state === "reconnecting" || connection.state === "closed" || connection.state === "disconnected") return
|
|
448
|
+
|
|
449
|
+
recovery.failed(err)
|
|
430
450
|
}
|
|
431
451
|
}
|
|
432
452
|
|
|
433
453
|
return {
|
|
434
|
-
async dispatch(unstamped:
|
|
454
|
+
async dispatch(unstamped: CommandMessage): Promise<unknown> {
|
|
435
455
|
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
436
|
-
// A message that reaches the wire
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
const message = stamped(unstamped, Date.now)
|
|
456
|
+
// A message that reaches the wire with no instant yet gets one from system
|
|
457
|
+
// time here — the envelope crosses a process boundary and must be fully
|
|
458
|
+
// formed. A locally-shortcut message is handed to `next` untouched
|
|
459
|
+
// instead, so the task that handles it supplies the instant.
|
|
460
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
442
461
|
const activity = shutdownLatch.registerActivity()
|
|
462
|
+
let admission: ReturnType<typeof outboundAdmission.enter> | undefined
|
|
463
|
+
let deadline: ReturnType<typeof messagingDeadline> | undefined
|
|
443
464
|
try {
|
|
465
|
+
admission = outboundAdmission.enter(unstamped.identifier)
|
|
466
|
+
deadline = messagingDeadline(requestTimeoutMs)
|
|
444
467
|
const commandName = qualifiedNameToString(message.name)
|
|
445
468
|
|
|
446
469
|
const response = await connection.commands.dispatch(
|
|
@@ -456,25 +479,27 @@ export function axonServerCommandBus(
|
|
|
456
479
|
clientId: connection.config.clientId,
|
|
457
480
|
componentName: connection.config.componentName,
|
|
458
481
|
},
|
|
459
|
-
{ metadata },
|
|
482
|
+
{ metadata, signal: deadline.signal },
|
|
460
483
|
)
|
|
461
484
|
|
|
462
485
|
if (response.errorCode && response.errorCode !== "") {
|
|
463
486
|
throw mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
|
|
464
487
|
}
|
|
465
488
|
|
|
466
|
-
return deserializePayload(response.payload?.data
|
|
489
|
+
return deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision)
|
|
467
490
|
} finally {
|
|
491
|
+
deadline?.close()
|
|
492
|
+
admission?.end()
|
|
468
493
|
activity.end()
|
|
469
494
|
}
|
|
470
495
|
},
|
|
471
496
|
|
|
472
497
|
subscribe(
|
|
473
498
|
commandName: string,
|
|
474
|
-
handler: (message: CommandMessage, uow:
|
|
499
|
+
handler: (message: CommandMessage, uow: U) => Promise<unknown>,
|
|
475
500
|
) {
|
|
476
501
|
subscribedNames.add(commandName)
|
|
477
|
-
|
|
502
|
+
next.subscribe(commandName, handler)
|
|
478
503
|
|
|
479
504
|
ensureStreamStarted()
|
|
480
505
|
// Subscription FIRST
|
|
@@ -490,50 +515,56 @@ export function axonServerCommandBus(
|
|
|
490
515
|
// ---------------------------------------------------------------------------
|
|
491
516
|
|
|
492
517
|
/**
|
|
493
|
-
* A query bus backed by Axon Server, over YOUR
|
|
518
|
+
* A query bus backed by Axon Server, over YOUR next bus.
|
|
494
519
|
*
|
|
495
520
|
* Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
|
|
496
|
-
* through Axon Server, and a query the server routes here runs through `
|
|
521
|
+
* through Axon Server, and a query the server routes here runs through `next`,
|
|
497
522
|
* so your unit-of-work policy applies to server-routed reads too. `subscribe`
|
|
498
|
-
* registers on `
|
|
523
|
+
* registers on `next` and announces the name to the server.
|
|
499
524
|
*
|
|
500
525
|
* The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
|
|
501
526
|
* 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 `
|
|
503
|
-
* unit of work is passed through, so the
|
|
527
|
+
* node subscribed the name, `query()` goes straight to `next` and the caller's
|
|
528
|
+
* unit of work is passed through, so the next branch nests exactly as the
|
|
504
529
|
* in-process bus does.
|
|
505
530
|
*
|
|
506
|
-
*
|
|
531
|
+
* Correlation, if wanted, is `interceptingQueryBus(bus, correlation)` at the host,
|
|
507
532
|
* matching AF4's `AxonServerQueryBus`, which calls
|
|
508
533
|
* `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
|
|
509
534
|
* `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
|
|
510
|
-
* shortcut branch gets identical
|
|
535
|
+
* shortcut branch gets identical correlation to the remote branch.
|
|
511
536
|
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
537
|
+
* Subscription queries run the dispatch chain: `interceptingQueryBus` wraps
|
|
538
|
+
* `subscriptionQuery` / `subscribeToUpdates` with the same intercept the
|
|
539
|
+
* primary `query` gets, so the proto built from `message.metadata` already
|
|
540
|
+
* carries whatever the host's intercept stamped (pinned in core by
|
|
541
|
+
* `interception/__tests__/subscription-interception.test.ts`).
|
|
516
542
|
*/
|
|
517
|
-
export function axonServerQueryBus(
|
|
543
|
+
export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
|
|
544
|
+
next: QueryBus<U>,
|
|
518
545
|
conn: AxonServerBusSource,
|
|
519
|
-
local: QueryBus,
|
|
520
546
|
options: AxonServerQueryBusOptions = {},
|
|
521
|
-
):
|
|
547
|
+
): SubscriptionCapableQueryBus<U> {
|
|
522
548
|
const {
|
|
523
549
|
connection,
|
|
524
550
|
serializer,
|
|
525
551
|
metadata: axonMetadata,
|
|
526
552
|
} = contextView(conn, options.context ?? conn.connection.config.context)
|
|
527
553
|
const shutdownLatch = conn.shutdown
|
|
554
|
+
const requestTimeoutMs = positiveInteger(options.timeoutMs ?? 30000, "timeoutMs")
|
|
555
|
+
if (requestTimeoutMs > 2_147_483_647) throw new RangeError("timeoutMs exceeds the timer range")
|
|
556
|
+
const inboundAdmission = messagingAdmission("inbound handlers", options.limits?.maxConcurrentHandlers ?? 128, options.limits?.observe)
|
|
557
|
+
const outboundAdmission = messagingAdmission("pending requests", options.limits?.maxPendingRequests ?? 1024, options.limits?.observe)
|
|
528
558
|
const resilience = options.resilience ?? conn.resilience
|
|
529
559
|
const metadata = axonMetadata()
|
|
530
|
-
const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
|
|
531
|
-
const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(
|
|
560
|
+
const PERMITS = BigInt(positiveInteger(options.flowControl?.permits ?? Number(DEFAULT_PERMITS), "flowControl.permits"))
|
|
561
|
+
const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Math.floor(Number(PERMITS) / 2))
|
|
562
|
+
if (THRESHOLD < 0n || THRESHOLD >= PERMITS) throw new RangeError("refillThreshold must be between zero and permits - 1")
|
|
532
563
|
const shortcutQueriesToLocalHandlers = options.shortcutQueriesToLocalHandlers ?? false
|
|
533
564
|
const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS
|
|
534
565
|
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
|
|
535
566
|
|
|
536
|
-
/** Query names announced to Axon Server; the handlers live on `
|
|
567
|
+
/** Query names announced to Axon Server; the handlers live on `next`. */
|
|
537
568
|
const subscribedNames = new Set<string>()
|
|
538
569
|
|
|
539
570
|
// Local subscription store — subscription queries opened by THIS instance.
|
|
@@ -546,17 +577,46 @@ export function axonServerQueryBus(
|
|
|
546
577
|
// to decide which subscriber IDs to target; the server forwards each response to the
|
|
547
578
|
// exact subscriber.
|
|
548
579
|
const handlerSubscriptions = new Map<string, { queryName: string; payload: unknown }>()
|
|
580
|
+
type ResponseCredit = { ready: Promise<void>; grant(): void; cancel(): void; cancelled: boolean; timer: ReturnType<typeof setTimeout> }
|
|
581
|
+
const responseCredits = new Map<string, ResponseCredit>()
|
|
582
|
+
function cancelResponseCredits() {
|
|
583
|
+
for (const credit of responseCredits.values()) { clearTimeout(credit.timer); credit.cancel() }
|
|
584
|
+
responseCredits.clear()
|
|
585
|
+
}
|
|
586
|
+
function responseCredit(identifier: string): ResponseCredit | undefined {
|
|
587
|
+
const existing = responseCredits.get(identifier)
|
|
588
|
+
if (existing) return existing
|
|
589
|
+
if (responseCredits.size >= (options.limits?.maxPendingRequests ?? 1024)) return undefined
|
|
590
|
+
let grant!: () => void
|
|
591
|
+
const ready = new Promise<void>((resolve) => { grant = resolve })
|
|
592
|
+
const credit: ResponseCredit = {
|
|
593
|
+
ready, grant, cancelled: false,
|
|
594
|
+
cancel() { this.cancelled = true; grant() },
|
|
595
|
+
timer: setTimeout(() => {
|
|
596
|
+
credit.cancel()
|
|
597
|
+
if (responseCredits.get(identifier) === credit) responseCredits.delete(identifier)
|
|
598
|
+
}, requestTimeoutMs),
|
|
599
|
+
}
|
|
600
|
+
credit.timer.unref?.()
|
|
601
|
+
responseCredits.set(identifier, credit)
|
|
602
|
+
return credit
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
shutdownLatch.onShutdown(() => handlerSubscriptions.clear())
|
|
549
607
|
|
|
550
608
|
let outbound = outboundStream<any>()
|
|
551
609
|
let streamStarted = false
|
|
610
|
+
let providerAbort = new AbortController()
|
|
611
|
+
connection.onDisconnect?.(() => { cancelResponseCredits(); providerAbort.abort(); outbound.close() })
|
|
552
612
|
let permits = 0n
|
|
553
613
|
|
|
554
614
|
function ensureStreamStarted() {
|
|
555
615
|
if (streamStarted) return
|
|
556
616
|
streamStarted = true
|
|
557
617
|
|
|
558
|
-
const inbound = connection.queries.openStream(outbound.iterable, { metadata })
|
|
559
|
-
processInboundQueries(inbound)
|
|
618
|
+
const inbound = connection.queries.openStream(outbound.iterable, { metadata, signal: providerAbort.signal })
|
|
619
|
+
void processInboundQueries(inbound, outbound)
|
|
560
620
|
}
|
|
561
621
|
|
|
562
622
|
function grantQueryPermits() {
|
|
@@ -588,6 +648,8 @@ export function axonServerQueryBus(
|
|
|
588
648
|
* re-emitted BEFORE the permits frame.
|
|
589
649
|
*/
|
|
590
650
|
function reestablishStreamBody() {
|
|
651
|
+
cancelResponseCredits()
|
|
652
|
+
handlerSubscriptions.clear()
|
|
591
653
|
outbound.close()
|
|
592
654
|
outbound = outboundStream<any>()
|
|
593
655
|
streamStarted = false
|
|
@@ -597,209 +659,196 @@ export function axonServerQueryBus(
|
|
|
597
659
|
grantQueryPermits()
|
|
598
660
|
}
|
|
599
661
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
...resilience,
|
|
605
|
-
})
|
|
606
|
-
}
|
|
662
|
+
const recovery = streamRecovery(reestablishStreamBody,
|
|
663
|
+
() => !shutdownLatch.shuttingDown && connection.state !== "closed" && connection.state !== "disconnected" && connection.state !== "reconnecting",
|
|
664
|
+
resilience)
|
|
665
|
+
shutdownLatch.onShutdown(recovery.stop)
|
|
607
666
|
|
|
608
667
|
// Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
|
|
609
668
|
connection.onReconnect(() => {
|
|
610
669
|
if (!shutdownLatch.shuttingDown && streamStarted) {
|
|
611
|
-
|
|
612
|
-
console.error("Axon Server query bus: reconnect retries exhausted", err)
|
|
613
|
-
})
|
|
670
|
+
recovery.restart()
|
|
614
671
|
}
|
|
615
672
|
})
|
|
616
673
|
|
|
617
|
-
async function
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
try {
|
|
674
|
+
async function handleInboundQuery(proto: any, responses: OutboundStream<any>, subId?: string) {
|
|
675
|
+
let activity: ReturnType<ShutdownLatch["registerActivity"]> | undefined
|
|
676
|
+
let admission: ReturnType<typeof inboundAdmission.enter> | undefined
|
|
677
|
+
let payload: ReturnType<typeof serializePayload> | undefined
|
|
678
|
+
let subscriptionEntry: { queryName: string; payload: unknown } | undefined
|
|
679
|
+
let credit: ResponseCredit | undefined
|
|
680
|
+
const supports = (key: number) => proto.processingInstructions?.some((instruction: any) => instruction.key === key && instruction.value?.booleanValue)
|
|
681
|
+
if (!subId && supports(7) && supports(8)) {
|
|
682
|
+
// Response credits may precede the query on Axon's provider stream.
|
|
683
|
+
// Retain those credits by request ID, within the same bounded table.
|
|
684
|
+
credit = responseCredit(proto.messageIdentifier)
|
|
685
|
+
if (!credit) { responses.close(); return }
|
|
686
|
+
}
|
|
687
|
+
let errorCode = ""
|
|
688
|
+
let errorMsg = ""
|
|
689
|
+
try {
|
|
690
|
+
try {
|
|
691
|
+
activity = shutdownLatch.registerActivity()
|
|
692
|
+
admission = inboundAdmission.enter()
|
|
693
|
+
if (subscribedNames.has(proto.query)) {
|
|
638
694
|
const queryMessage: QueryMessage = {
|
|
639
695
|
kind: "query",
|
|
640
696
|
identifier: proto.messageIdentifier,
|
|
641
|
-
name: qualifiedNameFromString(
|
|
642
|
-
payload,
|
|
697
|
+
name: qualifiedNameFromString(proto.query),
|
|
698
|
+
payload: deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision),
|
|
643
699
|
metadata: metadataFromProto(proto.metaData ?? {}),
|
|
644
700
|
timestamp: Number(proto.timestamp),
|
|
645
701
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
702
|
+
if (subId) {
|
|
703
|
+
subscriptionEntry = handlerSubscriptions.get(subId)
|
|
704
|
+
if (!subscriptionEntry) return
|
|
705
|
+
}
|
|
706
|
+
// Every wire query enters the local bus with a fresh unit of work.
|
|
707
|
+
const result = await next.query(queryMessage)
|
|
708
|
+
payload = result !== undefined ? serializePayload("result", result) : undefined
|
|
709
|
+
} else {
|
|
710
|
+
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
|
|
711
|
+
errorMsg = `No next handler for query "${proto.query}"`
|
|
650
712
|
}
|
|
651
|
-
}
|
|
652
|
-
errorCode = AxonServerErrorCode.
|
|
653
|
-
errorMsg =
|
|
713
|
+
} catch (err) {
|
|
714
|
+
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
715
|
+
errorMsg = err instanceof Error ? err.message : String(err)
|
|
654
716
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
processingInstructions: [],
|
|
717
|
+
// An unsubscribe or completion can overtake a slow initial handler.
|
|
718
|
+
if (subId && subscriptionEntry && handlerSubscriptions.get(subId) !== subscriptionEntry) return
|
|
719
|
+
if (subId && errorCode) handlerSubscriptions.delete(subId)
|
|
720
|
+
if (credit) {
|
|
721
|
+
await credit.ready
|
|
722
|
+
if (credit.cancelled) return
|
|
723
|
+
}
|
|
724
|
+
const response = {
|
|
725
|
+
messageIdentifier: generateIdentifier(),
|
|
726
|
+
requestIdentifier: proto.messageIdentifier,
|
|
727
|
+
errorCode,
|
|
728
|
+
errorMessage: errorCode
|
|
729
|
+
? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
|
|
730
|
+
: undefined,
|
|
731
|
+
payload,
|
|
732
|
+
metaData: {},
|
|
733
|
+
processingInstructions: [],
|
|
734
|
+
}
|
|
735
|
+
if (subId) {
|
|
736
|
+
responses.send({
|
|
737
|
+
subscriptionQueryResponse: {
|
|
738
|
+
messageIdentifier: generateIdentifier(), subscriptionIdentifier: subId, initialResult: response,
|
|
678
739
|
},
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
740
|
+
instructionId: "",
|
|
741
|
+
})
|
|
742
|
+
} else {
|
|
743
|
+
responses.send({ queryResponse: response, instructionId: "" })
|
|
744
|
+
responses.send({
|
|
745
|
+
queryComplete: { messageId: generateIdentifier(), requestId: proto.messageIdentifier },
|
|
746
|
+
instructionId: "",
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
await responses.flush()
|
|
750
|
+
} finally {
|
|
751
|
+
if (credit) clearTimeout(credit.timer)
|
|
752
|
+
if (credit && responseCredits.get(proto.messageIdentifier) === credit) responseCredits.delete(proto.messageIdentifier)
|
|
753
|
+
admission?.end()
|
|
754
|
+
activity?.end()
|
|
686
755
|
}
|
|
687
|
-
// flowControl + getInitialResult are not tracked per-sub; ignored for now.
|
|
688
756
|
}
|
|
689
757
|
|
|
690
|
-
async function processInboundQueries(inbound: AsyncIterable<any>) {
|
|
758
|
+
async function processInboundQueries(inbound: AsyncIterable<any>, responses: OutboundStream<any>) {
|
|
691
759
|
try {
|
|
692
760
|
for await (const message of inbound) {
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
761
|
+
if (responses !== outbound) return
|
|
762
|
+
recovery.received()
|
|
763
|
+
if (message.instructionId) responses.send({ ack: { instructionId: message.instructionId, success: true }, instructionId: "" })
|
|
764
|
+
// Every Axon query instruction consumes a provider credit, including
|
|
765
|
+
// acknowledgements. Ignoring a late subscription ack can exhaust a
|
|
766
|
+
// one-credit window between otherwise successful requests.
|
|
699
767
|
permits--
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
768
|
+
if (permits <= THRESHOLD && !shutdownLatch.shuttingDown) grantQueryPermits()
|
|
769
|
+
if (message.queryFlowControl?.permits > 0n) {
|
|
770
|
+
const identifier = message.queryFlowControl.queryReference?.requestId
|
|
771
|
+
if (identifier) {
|
|
772
|
+
const credit = responseCredit(identifier)
|
|
773
|
+
if (!credit) { responses.close(); return }
|
|
774
|
+
credit.grant()
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (message.queryCancel) responseCredits.get(message.queryCancel.requestId)?.cancel()
|
|
778
|
+
const request = message.subscriptionQueryRequest
|
|
779
|
+
if (request) {
|
|
780
|
+
if (request.unsubscribe) handlerSubscriptions.delete(request.unsubscribe.subscriptionIdentifier)
|
|
781
|
+
const sub = request.subscribe
|
|
782
|
+
if (sub?.subscriptionIdentifier && sub.queryRequest) {
|
|
783
|
+
try {
|
|
784
|
+
if (shutdownLatch.shuttingDown) throw new Error("Shutdown in progress")
|
|
785
|
+
if (handlerSubscriptions.size >= 1024 && !handlerSubscriptions.has(sub.subscriptionIdentifier)) throw new Error("Provider subscription capacity exhausted")
|
|
786
|
+
const proto = sub.queryRequest
|
|
787
|
+
handlerSubscriptions.set(sub.subscriptionIdentifier, {
|
|
788
|
+
queryName: proto.query,
|
|
789
|
+
payload: deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision),
|
|
790
|
+
})
|
|
791
|
+
} catch (err) {
|
|
792
|
+
responses.send({
|
|
793
|
+
subscriptionQueryResponse: {
|
|
794
|
+
messageIdentifier: generateIdentifier(), subscriptionIdentifier: sub.subscriptionIdentifier,
|
|
795
|
+
completeExceptionally: {
|
|
796
|
+
errorCode: AxonServerErrorCode.QUERY_EXECUTION_ERROR,
|
|
797
|
+
errorMessage: { message: err instanceof Error ? err.message : String(err) },
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
instructionId: "",
|
|
801
|
+
})
|
|
716
802
|
}
|
|
717
|
-
|
|
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)
|
|
721
|
-
} catch (err) {
|
|
722
|
-
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
723
|
-
errorMsg = err instanceof Error ? err.message : String(err)
|
|
724
803
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
804
|
+
// Axon separates update registration from requesting the initial
|
|
805
|
+
// result. Running the handler on Subscribe answers the wrong phase.
|
|
806
|
+
const initial = request.getInitialResult
|
|
807
|
+
if (initial?.subscriptionIdentifier && initial.queryRequest) {
|
|
808
|
+
void handleInboundQuery(initial.queryRequest, responses, initial.subscriptionIdentifier).catch((err) => {
|
|
809
|
+
console.error("Axon Server query bus: inbound subscription response failed", err)
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
continue
|
|
728
813
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
messageIdentifier: generateIdentifier(),
|
|
733
|
-
requestIdentifier: proto.messageIdentifier,
|
|
734
|
-
errorCode,
|
|
735
|
-
errorMessage: errorCode
|
|
736
|
-
? {
|
|
737
|
-
message: errorMsg,
|
|
738
|
-
location: connection.config.componentName,
|
|
739
|
-
details: [],
|
|
740
|
-
errorCode,
|
|
741
|
-
}
|
|
742
|
-
: undefined,
|
|
743
|
-
payload:
|
|
744
|
-
resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
|
|
745
|
-
metaData: {},
|
|
746
|
-
processingInstructions: [],
|
|
747
|
-
},
|
|
748
|
-
instructionId: "",
|
|
749
|
-
})
|
|
750
|
-
|
|
751
|
-
outbound.send({
|
|
752
|
-
queryComplete: {
|
|
753
|
-
messageId: generateIdentifier(),
|
|
754
|
-
requestId: proto.messageIdentifier,
|
|
755
|
-
},
|
|
756
|
-
instructionId: "",
|
|
814
|
+
if (!message.query) continue
|
|
815
|
+
void handleInboundQuery(message.query, responses).catch((err) => {
|
|
816
|
+
console.error("Axon Server query bus: inbound response failed", err)
|
|
757
817
|
})
|
|
758
|
-
|
|
759
|
-
if (permits <= THRESHOLD) {
|
|
760
|
-
outbound.send({
|
|
761
|
-
flowControl: { clientId: connection.config.clientId, permits: PERMITS },
|
|
762
|
-
instructionId: "",
|
|
763
|
-
})
|
|
764
|
-
permits += PERMITS
|
|
765
|
-
}
|
|
766
818
|
}
|
|
819
|
+
if (responses === outbound && !shutdownLatch.shuttingDown) throw new Error("Inbound provider stream ended unexpectedly")
|
|
767
820
|
} catch (err) {
|
|
768
|
-
if (shutdownLatch.shuttingDown) return
|
|
769
|
-
if (
|
|
770
|
-
|
|
771
|
-
console.error(
|
|
772
|
-
"Axon Server query bus: inbound stream error, attempting re-establishment via withRetry",
|
|
773
|
-
err,
|
|
774
|
-
)
|
|
775
|
-
await reestablishStreamWithRetry().catch((retryErr) => {
|
|
776
|
-
console.error("Axon Server query bus: reconnect retries exhausted", retryErr)
|
|
777
|
-
})
|
|
821
|
+
if (responses !== outbound || shutdownLatch.shuttingDown) return
|
|
822
|
+
if (connection.state === "reconnecting" || connection.state === "closed" || connection.state === "disconnected") return
|
|
823
|
+
recovery.failed(err)
|
|
778
824
|
}
|
|
779
825
|
}
|
|
780
826
|
|
|
781
|
-
const routing:
|
|
782
|
-
async query(unstamped:
|
|
827
|
+
const routing: SubscriptionCapableQueryBus<U> = {
|
|
828
|
+
async query(unstamped: QueryMessage, uow?: UnitOfWork): Promise<unknown> {
|
|
783
829
|
const activity = shutdownLatch.registerActivity()
|
|
830
|
+
let admission: ReturnType<typeof outboundAdmission.enter> | undefined
|
|
831
|
+
let deadline: ReturnType<typeof messagingDeadline> | undefined
|
|
784
832
|
try {
|
|
833
|
+
admission = outboundAdmission.enter(unstamped.identifier)
|
|
834
|
+
deadline = messagingDeadline(requestTimeoutMs)
|
|
785
835
|
const queryName = qualifiedNameToString(unstamped.name)
|
|
786
836
|
|
|
787
837
|
// Local shortcut — handle locally if a handler is co-located. The
|
|
788
|
-
// caller's unit of work is passed straight through, so `
|
|
838
|
+
// caller's unit of work is passed straight through, so `next` makes the
|
|
789
839
|
// nest-or-open decision on the HANDLE exactly as it does for an
|
|
790
840
|
// in-process read: a live unit of work handed in by `ctx.query` is
|
|
791
841
|
// reused so the consulting read shares the caller's transaction.
|
|
792
842
|
if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
|
|
793
|
-
return
|
|
843
|
+
return await next.query(unstamped, uow)
|
|
794
844
|
}
|
|
795
845
|
|
|
796
846
|
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
797
|
-
// A message that reaches the wire
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
const message = stamped(unstamped, Date.now)
|
|
847
|
+
// A message that reaches the wire with no instant yet gets one from system
|
|
848
|
+
// time here — the envelope crosses a process boundary and must be fully
|
|
849
|
+
// formed. A locally-shortcut message is handed to `next` untouched
|
|
850
|
+
// instead, so the task that handles it supplies the instant.
|
|
851
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
803
852
|
|
|
804
853
|
const responseStream = connection.queries.query(
|
|
805
854
|
{
|
|
@@ -812,31 +861,42 @@ export function axonServerQueryBus(
|
|
|
812
861
|
clientId: connection.config.clientId,
|
|
813
862
|
componentName: connection.config.componentName,
|
|
814
863
|
},
|
|
815
|
-
{ metadata },
|
|
864
|
+
{ metadata, signal: deadline.signal },
|
|
816
865
|
)
|
|
817
866
|
|
|
867
|
+
// NR_OF_RESULTS is one. Drain trailers before returning so transport
|
|
868
|
+
// failures cannot be mistaken for a successful result. The RPC deadline
|
|
869
|
+
// also bounds a stream that sends a response but never completes.
|
|
870
|
+
let received = false
|
|
871
|
+
let result: unknown
|
|
872
|
+
let responseError: Error | undefined
|
|
818
873
|
for await (const response of responseStream) {
|
|
874
|
+
if (received) continue
|
|
875
|
+
received = true
|
|
819
876
|
if (response.errorCode && response.errorCode !== "") {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
)
|
|
877
|
+
responseError = mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
|
|
878
|
+
} else {
|
|
879
|
+
try { result = deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision) }
|
|
880
|
+
catch (error) { responseError = error instanceof Error ? error : new Error(String(error)) }
|
|
824
881
|
}
|
|
825
|
-
return deserializePayload(response.payload?.data as Uint8Array | undefined)
|
|
826
882
|
}
|
|
883
|
+
if (responseError) throw responseError
|
|
884
|
+
if (received) return result
|
|
827
885
|
|
|
828
886
|
throw new Error(`No response for query "${queryName}"`)
|
|
829
887
|
} finally {
|
|
888
|
+
deadline?.close()
|
|
889
|
+
admission?.end()
|
|
830
890
|
activity.end()
|
|
831
891
|
}
|
|
832
892
|
},
|
|
833
893
|
|
|
834
894
|
subscribe(
|
|
835
895
|
queryName: string,
|
|
836
|
-
handler: (message: QueryMessage, uow:
|
|
896
|
+
handler: (message: QueryMessage, uow: U) => Promise<unknown>,
|
|
837
897
|
) {
|
|
838
898
|
subscribedNames.add(queryName)
|
|
839
|
-
|
|
899
|
+
next.subscribe(queryName, handler)
|
|
840
900
|
|
|
841
901
|
ensureStreamStarted()
|
|
842
902
|
sendSubscribe(queryName)
|
|
@@ -845,32 +905,39 @@ export function axonServerQueryBus(
|
|
|
845
905
|
},
|
|
846
906
|
|
|
847
907
|
subscriptionQuery(
|
|
848
|
-
unstamped:
|
|
908
|
+
unstamped: QueryMessage,
|
|
849
909
|
bufferSize?: number,
|
|
850
910
|
): SubscriptionQueryResult {
|
|
851
|
-
|
|
911
|
+
if (shutdownLatch.shuttingDown) throw new Error("Messaging shutdown in progress")
|
|
912
|
+
if (subscriptions.size >= 1024) throw new Error("Subscription capacity 1024 exhausted")
|
|
913
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
852
914
|
const queryId = message.identifier
|
|
853
915
|
if (subscriptions.has(queryId)) {
|
|
854
916
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
855
917
|
}
|
|
856
918
|
|
|
857
|
-
const handler = updateHandler(message, bufferSize)
|
|
858
|
-
subscriptions.set(queryId, handler)
|
|
919
|
+
const handler = updateHandler(message, bufferSize, () => subscriptions.delete(queryId))
|
|
859
920
|
|
|
860
921
|
const queryName = qualifiedNameToString(message.name)
|
|
922
|
+
const serialized = serializePayload(queryName, message.payload)
|
|
861
923
|
const subscriptionId = generateIdentifier()
|
|
862
924
|
|
|
863
925
|
const outboundSub = outboundStream<any>()
|
|
864
926
|
|
|
927
|
+
const window = Math.min(1024, Math.max(256, Math.floor(bufferSize ?? 256)))
|
|
928
|
+
const refillBatch = Math.max(1, Math.floor(window / 4))
|
|
929
|
+
let consumedSinceRefill = 0
|
|
930
|
+
let subscriptionClosed = false
|
|
931
|
+
|
|
865
932
|
outboundSub.send({
|
|
866
933
|
subscribe: {
|
|
867
934
|
subscriptionIdentifier: subscriptionId,
|
|
868
|
-
numberOfPermits: BigInt(
|
|
935
|
+
numberOfPermits: BigInt(window),
|
|
869
936
|
queryRequest: {
|
|
870
937
|
messageIdentifier: message.identifier,
|
|
871
938
|
query: queryName,
|
|
872
939
|
timestamp: BigInt(message.timestamp),
|
|
873
|
-
payload:
|
|
940
|
+
payload: serialized,
|
|
874
941
|
metaData: metadataToProto(message.metadata),
|
|
875
942
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
876
943
|
clientId: connection.config.clientId,
|
|
@@ -879,6 +946,10 @@ export function axonServerQueryBus(
|
|
|
879
946
|
},
|
|
880
947
|
})
|
|
881
948
|
|
|
949
|
+
// Subscribe does not grant update credits on Axon Server; a separate
|
|
950
|
+
// FlowControl frame initializes the subscription stream's update window.
|
|
951
|
+
outboundSub.send({ flowControl: { numberOfPermits: BigInt(window) } })
|
|
952
|
+
|
|
882
953
|
outboundSub.send({
|
|
883
954
|
getInitialResult: {
|
|
884
955
|
subscriptionIdentifier: subscriptionId,
|
|
@@ -887,7 +958,7 @@ export function axonServerQueryBus(
|
|
|
887
958
|
messageIdentifier: message.identifier,
|
|
888
959
|
query: queryName,
|
|
889
960
|
timestamp: BigInt(message.timestamp),
|
|
890
|
-
payload:
|
|
961
|
+
payload: serialized,
|
|
891
962
|
metaData: metadataToProto(message.metadata),
|
|
892
963
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
893
964
|
clientId: connection.config.clientId,
|
|
@@ -896,7 +967,9 @@ export function axonServerQueryBus(
|
|
|
896
967
|
},
|
|
897
968
|
})
|
|
898
969
|
|
|
899
|
-
const
|
|
970
|
+
const subscriptionController = new AbortController()
|
|
971
|
+
const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata, signal: subscriptionController.signal })
|
|
972
|
+
subscriptions.set(queryId, handler)
|
|
900
973
|
|
|
901
974
|
let resolveInitial!: (value: unknown) => void
|
|
902
975
|
let rejectInitial!: (error: Error) => void
|
|
@@ -905,84 +978,107 @@ export function axonServerQueryBus(
|
|
|
905
978
|
rejectInitial = reject
|
|
906
979
|
})
|
|
907
980
|
let initialSettled = false
|
|
981
|
+
let explicitlyCompleted = false
|
|
982
|
+
const initialTimer = setTimeout(() => closeSubscription(new Error("Subscription initial result timed out")), requestTimeoutMs)
|
|
983
|
+
const removeShutdown = shutdownLatch.onShutdown(() => closeSubscription(new Error("Messaging shutdown in progress")))
|
|
984
|
+
// Callers may consume updates without awaiting the initial result. Keep
|
|
985
|
+
// the original promise rejectable without an unhandled rejection on close.
|
|
986
|
+
void initialResult.catch(() => {})
|
|
987
|
+
|
|
988
|
+
function closeSubscription(error?: Error) {
|
|
989
|
+
if (subscriptionClosed) return
|
|
990
|
+
subscriptionClosed = true
|
|
991
|
+
clearTimeout(initialTimer)
|
|
992
|
+
removeShutdown()
|
|
993
|
+
if (!initialSettled) {
|
|
994
|
+
rejectInitial(error ?? new Error("Subscription query closed before initial result"))
|
|
995
|
+
initialSettled = true
|
|
996
|
+
}
|
|
997
|
+
if (error) handler.completeExceptionally(error)
|
|
998
|
+
else handler.complete()
|
|
999
|
+
try { outboundSub.send({ unsubscribe: { subscriptionIdentifier: subscriptionId } }) } catch { /* Broken stream; local teardown still must finish. */ }
|
|
1000
|
+
outboundSub.close()
|
|
1001
|
+
subscriptionController.abort()
|
|
1002
|
+
subscriptions.delete(queryId)
|
|
1003
|
+
}
|
|
908
1004
|
|
|
909
|
-
|
|
1005
|
+
void (async () => {
|
|
910
1006
|
try {
|
|
911
1007
|
for await (const response of responseStream) {
|
|
1008
|
+
if (subscriptionClosed) break
|
|
912
1009
|
if (response.initialResult) {
|
|
913
1010
|
const initial = response.initialResult
|
|
914
1011
|
if (!initialSettled) {
|
|
915
|
-
if (initial.errorCode
|
|
916
|
-
|
|
917
|
-
mapErrorCode(
|
|
918
|
-
initial.errorCode,
|
|
919
|
-
initial.errorMessage?.message ?? "Unknown error",
|
|
920
|
-
),
|
|
921
|
-
)
|
|
922
|
-
} else {
|
|
923
|
-
resolveInitial(
|
|
924
|
-
deserializePayload(initial.payload?.data as Uint8Array | undefined),
|
|
925
|
-
)
|
|
1012
|
+
if (initial.errorCode) {
|
|
1013
|
+
throw mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error")
|
|
926
1014
|
}
|
|
1015
|
+
clearTimeout(initialTimer)
|
|
1016
|
+
resolveInitial(deserializePayload(initial.payload?.data, initial.payload?.type, initial.payload?.revision))
|
|
927
1017
|
initialSettled = true
|
|
928
1018
|
}
|
|
929
1019
|
} else if (response.update) {
|
|
930
|
-
const update = deserializePayload(
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1020
|
+
const update = deserializePayload(response.update.payload?.data, response.update.payload?.type, response.update.payload?.revision)
|
|
1021
|
+
if (!handler.offer(update)) throw new Error("Subscription query update buffer overflow")
|
|
1022
|
+
consumedSinceRefill++
|
|
1023
|
+
if (consumedSinceRefill >= refillBatch) {
|
|
1024
|
+
outboundSub.send({
|
|
1025
|
+
flowControl: { subscriptionIdentifier: subscriptionId, numberOfPermits: BigInt(consumedSinceRefill) },
|
|
1026
|
+
})
|
|
1027
|
+
consumedSinceRefill = 0
|
|
1028
|
+
}
|
|
934
1029
|
} else if (response.complete) {
|
|
935
|
-
|
|
1030
|
+
explicitlyCompleted = true
|
|
936
1031
|
break
|
|
937
1032
|
} else if (response.completeExceptionally) {
|
|
938
|
-
|
|
939
|
-
new Error(
|
|
940
|
-
response.completeExceptionally.errorMessage?.message ??
|
|
941
|
-
"Subscription query failed",
|
|
942
|
-
),
|
|
943
|
-
)
|
|
944
|
-
break
|
|
1033
|
+
throw new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed")
|
|
945
1034
|
}
|
|
946
1035
|
}
|
|
947
1036
|
} catch (err) {
|
|
948
|
-
|
|
1037
|
+
closeSubscription(err instanceof Error ? err : new Error(String(err)))
|
|
1038
|
+
} finally {
|
|
1039
|
+
// EOF and completion frames must settle BOTH faces of a subscription.
|
|
1040
|
+
const missingInitial = !initialSettled
|
|
949
1041
|
if (!initialSettled) {
|
|
950
|
-
rejectInitial(
|
|
1042
|
+
rejectInitial(new Error("Subscription stream ended before initial result"))
|
|
951
1043
|
initialSettled = true
|
|
952
1044
|
}
|
|
953
|
-
|
|
954
|
-
} finally {
|
|
955
|
-
subscriptions.delete(queryId)
|
|
1045
|
+
closeSubscription(!missingInitial && !explicitlyCompleted && !subscriptionClosed ? new Error("Subscription stream ended unexpectedly") : undefined)
|
|
956
1046
|
}
|
|
957
1047
|
})()
|
|
958
1048
|
|
|
959
1049
|
return {
|
|
960
1050
|
initialResult,
|
|
961
|
-
updates:
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1051
|
+
updates: {
|
|
1052
|
+
[Symbol.asyncIterator]() {
|
|
1053
|
+
const iterator = handler.iterable[Symbol.asyncIterator]()
|
|
1054
|
+
return {
|
|
1055
|
+
next: () => iterator.next(),
|
|
1056
|
+
async return() {
|
|
1057
|
+
closeSubscription()
|
|
1058
|
+
return iterator.return ? iterator.return() : { value: undefined, done: true as const }
|
|
1059
|
+
},
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
971
1062
|
},
|
|
1063
|
+
close: () => closeSubscription(),
|
|
972
1064
|
}
|
|
973
1065
|
},
|
|
974
1066
|
|
|
975
1067
|
subscribeToUpdates(
|
|
976
|
-
unstamped:
|
|
1068
|
+
unstamped: QueryMessage,
|
|
977
1069
|
bufferSize?: number,
|
|
978
1070
|
): AsyncIterable<unknown> & { close(): void } {
|
|
979
|
-
|
|
1071
|
+
if (shutdownLatch.shuttingDown) throw new Error("Messaging shutdown in progress")
|
|
1072
|
+
if (subscriptions.size >= 1024) throw new Error("Subscription capacity 1024 exhausted")
|
|
1073
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
980
1074
|
const queryId = message.identifier
|
|
981
1075
|
if (subscriptions.has(queryId)) {
|
|
982
1076
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
983
1077
|
}
|
|
984
1078
|
|
|
985
|
-
|
|
1079
|
+
let removeShutdown: (() => void) | undefined
|
|
1080
|
+
const handler = updateHandler(message, bufferSize, () => { subscriptions.delete(queryId); removeShutdown?.() })
|
|
1081
|
+
removeShutdown = shutdownLatch.onShutdown(() => handler.completeExceptionally(new Error("Messaging shutdown in progress")))
|
|
986
1082
|
subscriptions.set(queryId, handler)
|
|
987
1083
|
|
|
988
1084
|
return {
|
|
@@ -998,6 +1094,7 @@ export function axonServerQueryBus(
|
|
|
998
1094
|
queryName: string,
|
|
999
1095
|
filter: SubscriptionFilter,
|
|
1000
1096
|
update: unknown,
|
|
1097
|
+
uow?: UnitOfWork,
|
|
1001
1098
|
): Promise<void> {
|
|
1002
1099
|
runAfterCommitOrImmediately(() => {
|
|
1003
1100
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
@@ -1022,10 +1119,10 @@ export function axonServerQueryBus(
|
|
|
1022
1119
|
instructionId: "",
|
|
1023
1120
|
})
|
|
1024
1121
|
}
|
|
1025
|
-
})
|
|
1122
|
+
}, uow)
|
|
1026
1123
|
},
|
|
1027
1124
|
|
|
1028
|
-
async completeSubscription(queryName: string, filter?: SubscriptionFilter): Promise<void> {
|
|
1125
|
+
async completeSubscription(queryName: string, filter?: SubscriptionFilter, uow?: UnitOfWork): Promise<void> {
|
|
1029
1126
|
runAfterCommitOrImmediately(() => {
|
|
1030
1127
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
1031
1128
|
if (sub.queryName !== queryName) continue
|
|
@@ -1044,13 +1141,14 @@ export function axonServerQueryBus(
|
|
|
1044
1141
|
})
|
|
1045
1142
|
handlerSubscriptions.delete(subId)
|
|
1046
1143
|
}
|
|
1047
|
-
})
|
|
1144
|
+
}, uow)
|
|
1048
1145
|
},
|
|
1049
1146
|
|
|
1050
1147
|
async completeSubscriptionExceptionally(
|
|
1051
1148
|
queryName: string,
|
|
1052
1149
|
error: Error,
|
|
1053
1150
|
filter?: SubscriptionFilter,
|
|
1151
|
+
uow?: UnitOfWork,
|
|
1054
1152
|
): Promise<void> {
|
|
1055
1153
|
runAfterCommitOrImmediately(() => {
|
|
1056
1154
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
@@ -1077,7 +1175,7 @@ export function axonServerQueryBus(
|
|
|
1077
1175
|
})
|
|
1078
1176
|
handlerSubscriptions.delete(subId)
|
|
1079
1177
|
}
|
|
1080
|
-
})
|
|
1178
|
+
}, uow)
|
|
1081
1179
|
},
|
|
1082
1180
|
}
|
|
1083
1181
|
|