@kronos-ts/axon-server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/axon-server-event-store.d.ts +0 -16
  2. package/dist/axon-server-event-store.d.ts.map +1 -1
  3. package/dist/axon-server-event-store.js +73 -45
  4. package/dist/axon-server-event-store.js.map +1 -1
  5. package/dist/axon-server-snapshotting-event-store.d.ts +30 -0
  6. package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -0
  7. package/dist/axon-server-snapshotting-event-store.js +154 -0
  8. package/dist/axon-server-snapshotting-event-store.js.map +1 -0
  9. package/dist/axon-server.d.ts +46 -65
  10. package/dist/axon-server.d.ts.map +1 -1
  11. package/dist/axon-server.js +490 -317
  12. package/dist/axon-server.js.map +1 -1
  13. package/dist/bounded-read.d.ts +19 -0
  14. package/dist/bounded-read.d.ts.map +1 -0
  15. package/dist/bounded-read.js +39 -0
  16. package/dist/bounded-read.js.map +1 -0
  17. package/dist/connection-manager.d.ts +2 -2
  18. package/dist/connection-manager.d.ts.map +1 -1
  19. package/dist/connection.d.ts +36 -21
  20. package/dist/connection.d.ts.map +1 -1
  21. package/dist/connection.js +84 -45
  22. package/dist/connection.js.map +1 -1
  23. package/dist/context-view.d.ts +2 -2
  24. package/dist/context-view.d.ts.map +1 -1
  25. package/dist/control-plane.d.ts +14 -24
  26. package/dist/control-plane.d.ts.map +1 -1
  27. package/dist/control-plane.js +6 -37
  28. package/dist/control-plane.js.map +1 -1
  29. package/dist/event-processor-info.d.ts +7 -25
  30. package/dist/event-processor-info.d.ts.map +1 -1
  31. package/dist/event-processor-info.js +16 -20
  32. package/dist/event-processor-info.js.map +1 -1
  33. package/dist/flow-controlled-sender.d.ts +2 -2
  34. package/dist/flow-controlled-sender.d.ts.map +1 -1
  35. package/dist/flow-controlled-sender.js +37 -17
  36. package/dist/flow-controlled-sender.js.map +1 -1
  37. package/dist/generated/command.d.ts +20 -20
  38. package/dist/generated/command.d.ts.map +1 -1
  39. package/dist/generated/common.d.ts +20 -20
  40. package/dist/generated/common.d.ts.map +1 -1
  41. package/dist/generated/control.d.ts +36 -40
  42. package/dist/generated/control.d.ts.map +1 -1
  43. package/dist/generated/dcb.d.ts +88 -94
  44. package/dist/generated/dcb.d.ts.map +1 -1
  45. package/dist/generated/event.d.ts +81 -84
  46. package/dist/generated/event.d.ts.map +1 -1
  47. package/dist/generated/google/protobuf/empty.d.ts +3 -4
  48. package/dist/generated/google/protobuf/empty.d.ts.map +1 -1
  49. package/dist/generated/query.d.ts +40 -40
  50. package/dist/generated/query.d.ts.map +1 -1
  51. package/dist/index.d.ts +2 -2
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/message-size.d.ts +2 -2
  56. package/dist/message-size.d.ts.map +1 -1
  57. package/dist/outbound-stream.d.ts +7 -11
  58. package/dist/outbound-stream.d.ts.map +1 -1
  59. package/dist/outbound-stream.js +65 -16
  60. package/dist/outbound-stream.js.map +1 -1
  61. package/dist/platform-service.d.ts +14 -5
  62. package/dist/platform-service.d.ts.map +1 -1
  63. package/dist/platform-service.js +70 -10
  64. package/dist/platform-service.js.map +1 -1
  65. package/dist/resilience.d.ts +62 -0
  66. package/dist/resilience.d.ts.map +1 -0
  67. package/dist/resilience.js +103 -0
  68. package/dist/resilience.js.map +1 -0
  69. package/dist/shutdown-latch.d.ts +5 -4
  70. package/dist/shutdown-latch.d.ts.map +1 -1
  71. package/dist/shutdown-latch.js +20 -1
  72. package/dist/shutdown-latch.js.map +1 -1
  73. package/dist/stream-recovery.d.ts +9 -0
  74. package/dist/stream-recovery.d.ts.map +1 -0
  75. package/dist/stream-recovery.js +70 -0
  76. package/dist/stream-recovery.js.map +1 -0
  77. package/package.json +3 -3
  78. package/src/axon-server-event-store.ts +77 -47
  79. package/src/axon-server-snapshotting-event-store.ts +194 -0
  80. package/src/axon-server.ts +472 -374
  81. package/src/bounded-read.ts +43 -0
  82. package/src/connection-manager.ts +1 -1
  83. package/src/connection.ts +93 -59
  84. package/src/context-view.ts +1 -1
  85. package/src/control-plane.ts +22 -66
  86. package/src/event-processor-info.ts +23 -44
  87. package/src/flow-controlled-sender.ts +34 -15
  88. package/src/generated/command.ts +10 -10
  89. package/src/generated/common.ts +10 -10
  90. package/src/generated/control.ts +20 -20
  91. package/src/generated/dcb.ts +47 -47
  92. package/src/generated/event.ts +42 -42
  93. package/src/generated/google/protobuf/empty.ts +2 -2
  94. package/src/generated/query.ts +20 -20
  95. package/src/index.ts +1 -2
  96. package/src/message-size.ts +1 -1
  97. package/src/outbound-stream.ts +58 -28
  98. package/src/platform-service.ts +77 -13
  99. package/src/resilience.ts +149 -0
  100. package/src/shutdown-latch.ts +16 -3
  101. package/src/stream-recovery.ts +72 -0
  102. package/dist/axon-server-snapshot-store.d.ts +0 -12
  103. package/dist/axon-server-snapshot-store.d.ts.map +0 -1
  104. package/dist/axon-server-snapshot-store.js +0 -82
  105. package/dist/axon-server-snapshot-store.js.map +0 -1
  106. package/src/axon-server-snapshot-store.ts +0 -110
@@ -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-local fork here, which is the whole difference from the dumb-pipe
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 local bus:
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(axon, simpleCommandBus(unitOfWork)), lineage)
15
+ * axonServerCommandBus(localCommandBus(unitOfWork), axon), correlation)
14
16
  * const queryBus = interceptingQueryBus(
15
- * axonServerQueryBus(axon, simpleQueryBus(unitOfWork)), lineage)
17
+ * axonServerQueryBus(localQueryBus(unitOfWork), axon), correlation)
16
18
  * ```
17
19
  *
18
- * Axon-specific protocol invariants are preserved byte-for-byte:
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 = 3_600_000
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 interface FlowControlConfig {
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 interface ProcessingInstructions {
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 local bus —
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 interface AxonServerCommandBusOptions {
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 interface AxonServerQueryBusOptions {
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: 3600000 (1 hour).
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 local bus.
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-local fork: the hub
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 `local` —
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 `local` (say `postgresUnitOfWork(pg, unitOfWork)`)
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
- * `local` carries that policy now.
224
- * - **subscribe**: registers the handler on `local` AND announces the name to
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
- * ## Correlation lineage and the interceptor layer
236
+ * ## correlation and the interceptor layer
228
237
  *
229
- * The returned bus stamps no lineage of its own. A host that wants it wraps the
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(conn, local), lineage)
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
- * Lineage itself is usually already on `message.metadata` by then — `ctx.send`
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 → SimpleCommandBus`.
254
+ * stacks `InterceptingCommandBus → DistributedCommandBus → LocalCommandBus`.
246
255
  *
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
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(DEFAULT_THRESHOLD))
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 `local`; this set exists so an inbound command for a name we never
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
- * `local.dispatch` happens to throw — and so a reconnect can re-announce.
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
- async function reestablishStreamWithRetry() {
333
- if (shutdownLatch.shuttingDown) return
334
- await withRetry(async () => reestablishStreamBody(), {
335
- event: "reconnect",
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
- reestablishStreamWithRetry().catch((err) => {
344
- console.error("Axon Server command bus: reconnect retries exhausted", err)
345
- })
358
+ recovery.restart()
346
359
  }
347
360
  })
348
361
 
349
- async function processInboundCommands(inbound: AsyncIterable<any>) {
350
- try {
351
- for await (const message of inbound) {
352
- if (!message.command) continue
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
- permits--
355
- const proto = message.command
356
- const commandName = proto.name
357
-
358
- let resultPayload: unknown
359
- let errorCode = ""
360
- let errorMsg = ""
361
-
362
- if (subscribedNames.has(commandName)) {
363
- try {
364
- const commandMessage: CommandMessage = {
365
- kind: "command",
366
- identifier: proto.messageIdentifier,
367
- name: qualifiedNameFromString(commandName),
368
- payload: deserializePayload(proto.payload?.data as Uint8Array | undefined),
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 local handler for command "${commandName}"`
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
- // Send response back to Axon Server
389
- outbound.send({
390
- commandResponse: {
391
- messageIdentifier: generateIdentifier(),
392
- requestIdentifier: proto.messageIdentifier,
393
- errorCode,
394
- errorMessage: errorCode
395
- ? {
396
- message: errorMsg,
397
- location: connection.config.componentName,
398
- details: [],
399
- errorCode,
400
- }
401
- : undefined,
402
- payload:
403
- resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
404
- metaData: {},
405
- processingInstructions: [],
406
- },
407
- instructionId: "",
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
- // Refill permits when running low
411
- if (permits <= THRESHOLD) {
412
- outbound.send({
413
- flowControl: { clientId: connection.config.clientId, permits: PERMITS },
414
- instructionId: "",
415
- })
416
- permits += PERMITS
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 (String(err).includes("Connection dropped")) return
422
-
423
- console.error(
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: Unstamped<CommandMessage>): Promise<unknown> {
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 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)
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 as Uint8Array | undefined)
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: UnitOfWork) => Promise<unknown>,
499
+ handler: (message: CommandMessage, uow: U) => Promise<unknown>,
475
500
  ) {
476
501
  subscribedNames.add(commandName)
477
- local.subscribe(commandName, handler)
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 local bus.
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 `local`,
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 `local` and announces the name to the server.
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 `local` and the caller's
503
- * unit of work is passed through, so the local branch nests exactly as 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
- * Lineage, if wanted, is `interceptingQueryBus(bus, lineage)` at the host,
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 lineage to the remote branch.
535
+ * shortcut branch gets identical correlation to the remote branch.
511
536
  *
512
- * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
513
- * straight from `message.metadata`, and `interceptingQueryBus` (in
514
- * `@kronos-ts/core`) forwards those two calls to the delegate without
515
- * running the dispatch chain. Closing that needs a core change.
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
- ): QueryBus {
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(DEFAULT_THRESHOLD))
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 `local`. */
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
- async function reestablishStreamWithRetry() {
601
- if (shutdownLatch.shuttingDown) return
602
- await withRetry(async () => reestablishStreamBody(), {
603
- event: "reconnect",
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
- reestablishStreamWithRetry().catch((err) => {
612
- console.error("Axon Server query bus: reconnect retries exhausted", err)
613
- })
670
+ recovery.restart()
614
671
  }
615
672
  })
616
673
 
617
- async function handleSubscriptionQueryRequest(req: any): Promise<void> {
618
- if (req.subscribe) {
619
- const sub = req.subscribe
620
- const subId: string = sub.subscriptionIdentifier
621
- const proto = sub.queryRequest
622
- if (!subId || !proto) return
623
-
624
- const queryName: string = proto.query
625
- const payload = deserializePayload(
626
- proto.payload?.data as Uint8Array | undefined,
627
- proto.payload?.type,
628
- proto.payload?.revision,
629
- )
630
- handlerSubscriptions.set(subId, { queryName, payload })
631
-
632
- let resultPayload: unknown
633
- let errorCode = ""
634
- let errorMsg = ""
635
-
636
- if (subscribedNames.has(queryName)) {
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(queryName),
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
- resultPayload = await local.query(queryMessage)
647
- } catch (err) {
648
- errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
649
- errorMsg = err instanceof Error ? err.message : String(err)
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
- } else {
652
- errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
653
- errorMsg = `No local handler for query "${queryName}"`
713
+ } catch (err) {
714
+ errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
715
+ errorMsg = err instanceof Error ? err.message : String(err)
654
716
  }
655
-
656
- const responseSerialized =
657
- resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined
658
-
659
- outbound.send({
660
- subscriptionQueryResponse: {
661
- messageIdentifier: generateIdentifier(),
662
- subscriptionIdentifier: subId,
663
- initialResult: {
664
- messageIdentifier: generateIdentifier(),
665
- requestIdentifier: proto.messageIdentifier,
666
- errorCode,
667
- errorMessage: errorCode
668
- ? {
669
- message: errorMsg,
670
- location: connection.config.componentName,
671
- details: [],
672
- errorCode,
673
- }
674
- : undefined,
675
- payload: responseSerialized,
676
- metaData: {},
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
- instructionId: "",
681
- })
682
- return
683
- }
684
- if (req.unsubscribe) {
685
- handlerSubscriptions.delete(req.unsubscribe.subscriptionIdentifier)
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 (message.subscriptionQueryRequest) {
694
- await handleSubscriptionQueryRequest(message.subscriptionQueryRequest)
695
- continue
696
- }
697
- if (!message.query) continue
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
- const proto = message.query
701
- const queryName = proto.query
702
-
703
- let resultPayload: unknown
704
- let errorCode = ""
705
- let errorMsg = ""
706
-
707
- if (subscribedNames.has(queryName)) {
708
- try {
709
- const queryMessage: QueryMessage = {
710
- kind: "query",
711
- identifier: proto.messageIdentifier,
712
- name: qualifiedNameFromString(queryName),
713
- payload: deserializePayload(proto.payload?.data as Uint8Array | undefined),
714
- metadata: metadataFromProto(proto.metaData),
715
- timestamp: Number(proto.timestamp),
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
- } else {
726
- errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
727
- errorMsg = `No local handler for query "${queryName}"`
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
- outbound.send({
731
- queryResponse: {
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 (String(err).includes("Connection dropped")) return
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: QueryBus = {
782
- async query(unstamped: Unstamped<QueryMessage>, uow?: UnitOfWork): Promise<unknown> {
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 `local` makes the
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 local.query(unstamped, uow)
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 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)
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
- throw mapErrorCode(
821
- response.errorCode,
822
- response.errorMessage?.message ?? "Unknown error",
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: UnitOfWork) => Promise<unknown>,
896
+ handler: (message: QueryMessage, uow: U) => Promise<unknown>,
837
897
  ) {
838
898
  subscribedNames.add(queryName)
839
- local.subscribe(queryName, handler)
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: Unstamped<QueryMessage>,
908
+ unstamped: QueryMessage,
849
909
  bufferSize?: number,
850
910
  ): SubscriptionQueryResult {
851
- const message = stamped(unstamped, Date.now)
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(bufferSize ?? 256),
935
+ numberOfPermits: BigInt(window),
869
936
  queryRequest: {
870
937
  messageIdentifier: message.identifier,
871
938
  query: queryName,
872
939
  timestamp: BigInt(message.timestamp),
873
- payload: serializePayload(queryName, message.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: serializePayload(queryName, message.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 responseStream = connection.queries.subscription(outboundSub.iterable, { metadata })
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
- ;(async () => {
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 && initial.errorCode !== "") {
916
- rejectInitial(
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
- response.update.payload?.data as Uint8Array | undefined,
932
- )
933
- handler.offer(update)
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
- handler.complete()
1030
+ explicitlyCompleted = true
936
1031
  break
937
1032
  } else if (response.completeExceptionally) {
938
- handler.completeExceptionally(
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
- const error = err instanceof Error ? err : new Error(String(err))
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(error)
1042
+ rejectInitial(new Error("Subscription stream ended before initial result"))
951
1043
  initialSettled = true
952
1044
  }
953
- handler.completeExceptionally(error)
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: handler.iterable,
962
- close: () => {
963
- outboundSub.send({
964
- unsubscribe: {
965
- subscriptionIdentifier: subscriptionId,
966
- },
967
- })
968
- outboundSub.close()
969
- subscriptions.delete(queryId)
970
- handler.complete()
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: Unstamped<QueryMessage>,
1068
+ unstamped: QueryMessage,
977
1069
  bufferSize?: number,
978
1070
  ): AsyncIterable<unknown> & { close(): void } {
979
- const message = stamped(unstamped, Date.now)
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
- const handler = updateHandler(message, bufferSize)
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