@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 } 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(...)`;
@@ -25,17 +27,17 @@
25
27
  * handshake AND on reconnect (see `ensureStreamStarted` /
26
28
  * `reestablishStreamBody`).
27
29
  */
28
- import { qualifiedNameToString, qualifiedNameFromString, generateIdentifier, withRetry, } from "@kronos-ts/core";
29
- import { applySubscriptionFilter, stamped, updateHandler, runAfterCommitOrImmediately, } from "@kronos-ts/core";
30
+ import { qualifiedNameToString, qualifiedNameFromString, generateIdentifier, } from "@kronos-ts/core";
31
+ import {} from "./resilience.js";
32
+ import { applySubscriptionFilter, updateHandler, runAfterCommitOrImmediately, } from "@kronos-ts/core";
30
33
  import { contextView } from "./context-view.js";
31
34
  import { metadataToProto, metadataFromProto } from "./metadata-conversion.js";
32
35
  import { outboundStream } from "./outbound-stream.js";
33
36
  import { mapErrorCode, AxonServerErrorCode } from "./errors.js";
34
37
  /** Default flow control settings — aligned with Java's 5000 permits. */
35
38
  const DEFAULT_PERMITS = 5000n;
36
- const DEFAULT_THRESHOLD = 2500n;
37
39
  /** Default query dispatch timeout — aligned with Java's one hour. */
38
- const DEFAULT_QUERY_TIMEOUT_MS = 3_600_000;
40
+ const DEFAULT_QUERY_TIMEOUT_MS = 30_000;
39
41
  /** Default command handler load factor — aligned with Java's 100. */
40
42
  const DEFAULT_LOAD_FACTOR = 100;
41
43
  // Processing instruction keys — aligned with proto ProcessingKey enum.
@@ -102,33 +104,33 @@ function createPayloadHelpers(serializer) {
102
104
  // Axon Server Command Bus
103
105
  // ---------------------------------------------------------------------------
104
106
  /**
105
- * A command bus backed by Axon Server, over YOUR local bus.
107
+ * A command bus backed by Axon Server, over YOUR next bus.
106
108
  *
107
109
  * - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
108
110
  * RPC. Axon Server routes the command to the appropriate node (which may be
109
- * this one). There is deliberately no client-side prefer-local fork: the hub
111
+ * this one). There is deliberately no client-side prefer-next fork: the hub
110
112
  * is the router, and short-circuiting it would silently defeat load factors,
111
113
  * priorities and routing keys.
112
- * - **Inbound**: a command the server routes here is dispatched into `local` —
114
+ * - **Inbound**: a command the server routes here is dispatched into `next` —
113
115
  * not into a privately-held handler map. That is what makes the unit-of-work
114
- * policy you chose for `local` (say `postgresUnitOfWork(pg, unitOfWork)`)
116
+ * policy you chose for `next` (say `postgresUnitOfWork(unitOfWork, pg)`)
115
117
  * apply to server-routed work exactly as it applies to work this process
116
118
  * originated. It is also why this function takes no `unitOfWork` argument:
117
- * `local` carries that policy now.
118
- * - **subscribe**: registers the handler on `local` AND announces the name to
119
+ * `next` carries that policy now.
120
+ * - **subscribe**: registers the handler on `next` AND announces the name to
119
121
  * Axon Server, so other nodes can route to us.
120
122
  *
121
- * ## Correlation lineage and the interceptor layer
123
+ * ## correlation and the interceptor layer
122
124
  *
123
- * The returned bus stamps no lineage of its own. A host that wants it wraps the
125
+ * The returned bus stamps no correlation of its own. A host that wants it wraps the
124
126
  * OUTERMOST bus:
125
127
  *
126
128
  * ```ts
127
- * interceptingCommandBus(axonServerCommandBus(conn, local), lineage)
129
+ * interceptingCommandBus(axonServerCommandBus(next, conn), correlation)
128
130
  * ```
129
131
  *
130
132
  * so whatever a host adds runs BEFORE the message is serialized onto the wire.
131
- * Lineage itself is usually already on `message.metadata` by then — `ctx.send`
133
+ * Correlation itself is usually already on `message.metadata` by then — `ctx.send`
132
134
  * stamps the unit of work's correlation data before any bus sees the message.
133
135
  *
134
136
  * This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
@@ -136,39 +138,48 @@ function createPayloadHelpers(serializer) {
136
138
  * `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
137
139
  * site, at the top, ahead of any routing. AF5 keeps the property via decorator
138
140
  * order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
139
- * stacks `InterceptingCommandBus → DistributedCommandBus → SimpleCommandBus`.
141
+ * stacks `InterceptingCommandBus → DistributedCommandBus → LocalCommandBus`.
140
142
  *
141
- * If `local` is itself an intercepting bus, a server-routed command sees
142
- * `lineage` twice. That is harmless: both of its fields are `??` seeds, so the
143
+ * If `next` is itself an intercepting bus, a server-routed command sees
144
+ * `correlation` twice. That is harmless: both of its fields are `??` seeds, so the
143
145
  * second application finds them set and changes nothing.
144
146
  */
145
- export function axonServerCommandBus(conn, local, options = {}) {
147
+ export function axonServerCommandBus(next, conn, options = {}) {
146
148
  const { connection, serializer, metadata: axonMetadata, } = contextView(conn, options.context ?? conn.connection.config.context);
147
149
  const shutdownLatch = conn.shutdown;
150
+ const requestTimeoutMs = positiveInteger(options.timeoutMs ?? 30000, "timeoutMs");
151
+ if (requestTimeoutMs > 2_147_483_647)
152
+ throw new RangeError("timeoutMs exceeds the timer range");
153
+ const inboundAdmission = messagingAdmission("inbound handlers", options.limits?.maxConcurrentHandlers ?? 128, options.limits?.observe);
154
+ const outboundAdmission = messagingAdmission("pending requests", options.limits?.maxPendingRequests ?? 1024, options.limits?.observe);
148
155
  const resilience = options.resilience ?? conn.resilience;
149
156
  const metadata = axonMetadata();
150
157
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer);
151
- const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS));
152
- const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD));
158
+ const PERMITS = BigInt(positiveInteger(options.flowControl?.permits ?? Number(DEFAULT_PERMITS), "flowControl.permits"));
159
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Math.floor(Number(PERMITS) / 2));
160
+ if (THRESHOLD < 0n || THRESHOLD >= PERMITS)
161
+ throw new RangeError("refillThreshold must be between zero and permits - 1");
153
162
  const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR;
154
163
  /**
155
164
  * The names this node announced to Axon Server. The handlers themselves live
156
- * on `local`; this set exists so an inbound command for a name we never
165
+ * on `next`; this set exists so an inbound command for a name we never
157
166
  * subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
158
- * `local.dispatch` happens to throw — and so a reconnect can re-announce.
167
+ * `next.dispatch` happens to throw — and so a reconnect can re-announce.
159
168
  */
160
169
  const subscribedNames = new Set();
161
170
  // Bidirectional stream for handler subscription + inbound command handling
162
171
  let outbound = outboundStream();
163
172
  let streamStarted = false;
173
+ let providerAbort = new AbortController();
174
+ connection.onDisconnect?.(() => { providerAbort.abort(); outbound.close(); });
164
175
  let permits = 0n;
165
176
  function ensureStreamStarted() {
166
177
  if (streamStarted)
167
178
  return;
168
179
  streamStarted = true;
169
180
  // Open stream using connection.commands (always gets current client after reconnect)
170
- const inbound = connection.commands.openStream(outbound.iterable, { metadata });
171
- processInboundCommands(inbound);
181
+ const inbound = connection.commands.openStream(outbound.iterable, { metadata, signal: providerAbort.signal });
182
+ void processInboundCommands(inbound, outbound);
172
183
  }
173
184
  function grantPermits() {
174
185
  outbound.send({
@@ -198,6 +209,8 @@ export function axonServerCommandBus(conn, local, options = {}) {
198
209
  * trigger a server-side stream error.
199
210
  */
200
211
  function reestablishStreamBody() {
212
+ providerAbort.abort();
213
+ providerAbort = new AbortController();
201
214
  outbound.close();
202
215
  outbound = outboundStream();
203
216
  streamStarted = false;
@@ -209,111 +222,122 @@ export function axonServerCommandBus(conn, local, options = {}) {
209
222
  // Permits AFTER subscriptions (Axon-specific ordering invariant)
210
223
  grantPermits();
211
224
  }
212
- async function reestablishStreamWithRetry() {
213
- if (shutdownLatch.shuttingDown)
214
- return;
215
- await withRetry(async () => reestablishStreamBody(), {
216
- event: "reconnect",
217
- ...resilience,
218
- });
219
- }
225
+ const recovery = streamRecovery(reestablishStreamBody, () => !shutdownLatch.shuttingDown && connection.state !== "closed" && connection.state !== "disconnected" && connection.state !== "reconnecting", resilience);
226
+ shutdownLatch.onShutdown(recovery.stop);
220
227
  // Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
221
228
  connection.onReconnect(() => {
222
229
  if (!shutdownLatch.shuttingDown && streamStarted) {
223
- reestablishStreamWithRetry().catch((err) => {
224
- console.error("Axon Server command bus: reconnect retries exhausted", err);
225
- });
230
+ recovery.restart();
226
231
  }
227
232
  });
228
- async function processInboundCommands(inbound) {
233
+ async function handleInboundCommand(proto, responses) {
234
+ let activity;
235
+ let admission;
236
+ let responseSerialized;
237
+ let errorCode = "";
238
+ let errorMsg = "";
229
239
  try {
230
- for await (const message of inbound) {
231
- if (!message.command)
232
- continue;
233
- permits--;
234
- const proto = message.command;
235
- const commandName = proto.name;
236
- let resultPayload;
237
- let errorCode = "";
238
- let errorMsg = "";
239
- if (subscribedNames.has(commandName)) {
240
- try {
241
- const commandMessage = {
242
- kind: "command",
243
- identifier: proto.messageIdentifier,
244
- name: qualifiedNameFromString(commandName),
245
- payload: deserializePayload(proto.payload?.data),
246
- metadata: metadataFromProto(proto.metaData),
247
- timestamp: Number(proto.timestamp),
248
- };
249
- // Through the LOCAL BUS, so the caller's unit-of-work policy runs.
250
- // AF parity is preserved: `CommandProcessingTask` runs the local
251
- // segment without re-running dispatch interceptors, and a `local`
252
- // that happens to carry `lineage` re-applies a pair of `??` seeds
253
- // that are already set.
254
- resultPayload = await local.dispatch(commandMessage);
255
- }
256
- catch (err) {
257
- errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR;
258
- errorMsg = err instanceof Error ? err.message : String(err);
259
- }
240
+ try {
241
+ // Remote callers have no outbound dispatch activity on this adapter.
242
+ // Track the entire handling, including result serialization and enqueue.
243
+ // Registration also rejects new work once shutdown has begun.
244
+ activity = shutdownLatch.registerActivity();
245
+ admission = inboundAdmission.enter();
246
+ if (subscribedNames.has(proto.name)) {
247
+ const commandMessage = {
248
+ kind: "command",
249
+ identifier: proto.messageIdentifier,
250
+ name: qualifiedNameFromString(proto.name),
251
+ payload: deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision),
252
+ metadata: metadataFromProto(proto.metaData ?? {}),
253
+ timestamp: Number(proto.timestamp),
254
+ };
255
+ // The local bus opens a fresh unit of work for EVERY wire command,
256
+ // including children of handlers running on this same connection.
257
+ const result = await next.dispatch(commandMessage);
258
+ responseSerialized = result !== undefined ? serializePayload("result", result) : undefined;
260
259
  }
261
260
  else {
262
261
  errorCode = AxonServerErrorCode.NO_HANDLER_FOR_COMMAND;
263
- errorMsg = `No local handler for command "${commandName}"`;
262
+ errorMsg = `No next handler for command "${proto.name}"`;
264
263
  }
265
- // Send response back to Axon Server
266
- outbound.send({
267
- commandResponse: {
268
- messageIdentifier: generateIdentifier(),
269
- requestIdentifier: proto.messageIdentifier,
270
- errorCode,
271
- errorMessage: errorCode
272
- ? {
273
- message: errorMsg,
274
- location: connection.config.componentName,
275
- details: [],
276
- errorCode,
277
- }
278
- : undefined,
279
- payload: resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
280
- metaData: {},
281
- processingInstructions: [],
282
- },
283
- instructionId: "",
264
+ }
265
+ catch (err) {
266
+ // Decode, handler, and result-encoding failures belong to this request;
267
+ // none should terminate the receive loop or reconnect the stream.
268
+ errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR;
269
+ errorMsg = err instanceof Error ? err.message : String(err);
270
+ }
271
+ // Capture the originating stream: a late handler must not send an old
272
+ // request's response on a replacement stream after reconnect.
273
+ responses.send({
274
+ commandResponse: {
275
+ messageIdentifier: generateIdentifier(),
276
+ requestIdentifier: proto.messageIdentifier,
277
+ errorCode,
278
+ errorMessage: errorCode
279
+ ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
280
+ : undefined,
281
+ payload: responseSerialized,
282
+ metaData: {},
283
+ processingInstructions: [],
284
+ },
285
+ instructionId: "",
286
+ });
287
+ await responses.flush();
288
+ }
289
+ finally {
290
+ admission?.end();
291
+ activity?.end();
292
+ }
293
+ }
294
+ async function processInboundCommands(inbound, responses) {
295
+ try {
296
+ for await (const message of inbound) {
297
+ if (responses !== outbound)
298
+ return;
299
+ recovery.received();
300
+ if (message.instructionId)
301
+ responses.send({ ack: { instructionId: message.instructionId, success: true }, instructionId: "" });
302
+ permits--;
303
+ // Credits bound delivery batches, not unfinished handlers. Replenish
304
+ // on receipt: completion-based credits or a fixed handler semaphore can
305
+ // deadlock when every admitted parent is waiting for a queued child.
306
+ if (permits <= THRESHOLD && !shutdownLatch.shuttingDown)
307
+ grantPermits();
308
+ if (!message.command)
309
+ continue;
310
+ // Each invocation owns its response and shutdown activity. Keep reading
311
+ // while it awaits work so nested dispatch can return on this connection.
312
+ void handleInboundCommand(message.command, responses).catch((err) => {
313
+ console.error("Axon Server command bus: inbound response failed", err);
284
314
  });
285
- // Refill permits when running low
286
- if (permits <= THRESHOLD) {
287
- outbound.send({
288
- flowControl: { clientId: connection.config.clientId, permits: PERMITS },
289
- instructionId: "",
290
- });
291
- permits += PERMITS;
292
- }
293
315
  }
316
+ if (responses === outbound && !shutdownLatch.shuttingDown)
317
+ throw new Error("Inbound provider stream ended unexpectedly");
294
318
  }
295
319
  catch (err) {
296
- if (shutdownLatch.shuttingDown)
320
+ if (responses !== outbound || shutdownLatch.shuttingDown)
297
321
  return;
298
- if (String(err).includes("Connection dropped"))
322
+ if (connection.state === "reconnecting" || connection.state === "closed" || connection.state === "disconnected")
299
323
  return;
300
- console.error("Axon Server command bus: inbound stream error, attempting re-establishment via withRetry", err);
301
- await reestablishStreamWithRetry().catch((retryErr) => {
302
- console.error("Axon Server command bus: reconnect retries exhausted", retryErr);
303
- });
324
+ recovery.failed(err);
304
325
  }
305
326
  }
306
327
  return {
307
328
  async dispatch(unstamped) {
308
329
  // A transport is not a task: it has no unit of work, so it has no clock.
309
- // A message that reaches the wire still {@link Unstamped} is therefore
310
- // stamped from system time here — the envelope crosses a process boundary
311
- // and must be fully formed. A locally-shortcut message is handed to
312
- // `local` unstamped instead, so the task that handles it supplies the
313
- // instant.
314
- const message = stamped(unstamped, Date.now);
330
+ // A message that reaches the wire with no instant yet gets one from system
331
+ // time here — the envelope crosses a process boundary and must be fully
332
+ // formed. A locally-shortcut message is handed to `next` untouched
333
+ // instead, so the task that handles it supplies the instant.
334
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
315
335
  const activity = shutdownLatch.registerActivity();
336
+ let admission;
337
+ let deadline;
316
338
  try {
339
+ admission = outboundAdmission.enter(unstamped.identifier);
340
+ deadline = messagingDeadline(requestTimeoutMs);
317
341
  const commandName = qualifiedNameToString(message.name);
318
342
  const response = await connection.commands.dispatch({
319
343
  messageIdentifier: message.identifier,
@@ -324,19 +348,21 @@ export function axonServerCommandBus(conn, local, options = {}) {
324
348
  processingInstructions: toProtoProcessingInstructions(message.metadata?.processingInstructions),
325
349
  clientId: connection.config.clientId,
326
350
  componentName: connection.config.componentName,
327
- }, { metadata });
351
+ }, { metadata, signal: deadline.signal });
328
352
  if (response.errorCode && response.errorCode !== "") {
329
353
  throw mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error");
330
354
  }
331
- return deserializePayload(response.payload?.data);
355
+ return deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision);
332
356
  }
333
357
  finally {
358
+ deadline?.close();
359
+ admission?.end();
334
360
  activity.end();
335
361
  }
336
362
  },
337
363
  subscribe(commandName, handler) {
338
364
  subscribedNames.add(commandName);
339
- local.subscribe(commandName, handler);
365
+ next.subscribe(commandName, handler);
340
366
  ensureStreamStarted();
341
367
  // Subscription FIRST
342
368
  sendSubscribe(commandName);
@@ -349,41 +375,49 @@ export function axonServerCommandBus(conn, local, options = {}) {
349
375
  // Axon Server Query Bus
350
376
  // ---------------------------------------------------------------------------
351
377
  /**
352
- * A query bus backed by Axon Server, over YOUR local bus.
378
+ * A query bus backed by Axon Server, over YOUR next bus.
353
379
  *
354
380
  * Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
355
- * through Axon Server, and a query the server routes here runs through `local`,
381
+ * through Axon Server, and a query the server routes here runs through `next`,
356
382
  * so your unit-of-work policy applies to server-routed reads too. `subscribe`
357
- * registers on `local` and announces the name to the server.
383
+ * registers on `next` and announces the name to the server.
358
384
  *
359
385
  * The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
360
386
  * has it for queries and not for commands, and so do we. When it is on and this
361
- * node subscribed the name, `query()` goes straight to `local` and the caller's
362
- * unit of work is passed through, so the local branch nests exactly as the
387
+ * node subscribed the name, `query()` goes straight to `next` and the caller's
388
+ * unit of work is passed through, so the next branch nests exactly as the
363
389
  * in-process bus does.
364
390
  *
365
- * Lineage, if wanted, is `interceptingQueryBus(bus, lineage)` at the host,
391
+ * Correlation, if wanted, is `interceptingQueryBus(bus, correlation)` at the host,
366
392
  * matching AF4's `AxonServerQueryBus`, which calls
367
393
  * `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
368
394
  * `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
369
- * shortcut branch gets identical lineage to the remote branch.
395
+ * shortcut branch gets identical correlation to the remote branch.
370
396
  *
371
- * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
372
- * straight from `message.metadata`, and `interceptingQueryBus` (in
373
- * `@kronos-ts/core`) forwards those two calls to the delegate without
374
- * running the dispatch chain. Closing that needs a core change.
397
+ * Subscription queries run the dispatch chain: `interceptingQueryBus` wraps
398
+ * `subscriptionQuery` / `subscribeToUpdates` with the same intercept the
399
+ * primary `query` gets, so the proto built from `message.metadata` already
400
+ * carries whatever the host's intercept stamped (pinned in core by
401
+ * `interception/__tests__/subscription-interception.test.ts`).
375
402
  */
376
- export function axonServerQueryBus(conn, local, options = {}) {
403
+ export function axonServerQueryBus(next, conn, options = {}) {
377
404
  const { connection, serializer, metadata: axonMetadata, } = contextView(conn, options.context ?? conn.connection.config.context);
378
405
  const shutdownLatch = conn.shutdown;
406
+ const requestTimeoutMs = positiveInteger(options.timeoutMs ?? 30000, "timeoutMs");
407
+ if (requestTimeoutMs > 2_147_483_647)
408
+ throw new RangeError("timeoutMs exceeds the timer range");
409
+ const inboundAdmission = messagingAdmission("inbound handlers", options.limits?.maxConcurrentHandlers ?? 128, options.limits?.observe);
410
+ const outboundAdmission = messagingAdmission("pending requests", options.limits?.maxPendingRequests ?? 1024, options.limits?.observe);
379
411
  const resilience = options.resilience ?? conn.resilience;
380
412
  const metadata = axonMetadata();
381
- const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS));
382
- const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD));
413
+ const PERMITS = BigInt(positiveInteger(options.flowControl?.permits ?? Number(DEFAULT_PERMITS), "flowControl.permits"));
414
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Math.floor(Number(PERMITS) / 2));
415
+ if (THRESHOLD < 0n || THRESHOLD >= PERMITS)
416
+ throw new RangeError("refillThreshold must be between zero and permits - 1");
383
417
  const shortcutQueriesToLocalHandlers = options.shortcutQueriesToLocalHandlers ?? false;
384
418
  const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
385
419
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer);
386
- /** Query names announced to Axon Server; the handlers live on `local`. */
420
+ /** Query names announced to Axon Server; the handlers live on `next`. */
387
421
  const subscribedNames = new Set();
388
422
  // Local subscription store — subscription queries opened by THIS instance.
389
423
  // Inbound updates from the server are offered into these via the Subscription RPC loop.
@@ -394,15 +428,47 @@ export function axonServerQueryBus(conn, local, options = {}) {
394
428
  // to decide which subscriber IDs to target; the server forwards each response to the
395
429
  // exact subscriber.
396
430
  const handlerSubscriptions = new Map();
431
+ const responseCredits = new Map();
432
+ function cancelResponseCredits() {
433
+ for (const credit of responseCredits.values()) {
434
+ clearTimeout(credit.timer);
435
+ credit.cancel();
436
+ }
437
+ responseCredits.clear();
438
+ }
439
+ function responseCredit(identifier) {
440
+ const existing = responseCredits.get(identifier);
441
+ if (existing)
442
+ return existing;
443
+ if (responseCredits.size >= (options.limits?.maxPendingRequests ?? 1024))
444
+ return undefined;
445
+ let grant;
446
+ const ready = new Promise((resolve) => { grant = resolve; });
447
+ const credit = {
448
+ ready, grant, cancelled: false,
449
+ cancel() { this.cancelled = true; grant(); },
450
+ timer: setTimeout(() => {
451
+ credit.cancel();
452
+ if (responseCredits.get(identifier) === credit)
453
+ responseCredits.delete(identifier);
454
+ }, requestTimeoutMs),
455
+ };
456
+ credit.timer.unref?.();
457
+ responseCredits.set(identifier, credit);
458
+ return credit;
459
+ }
460
+ shutdownLatch.onShutdown(() => handlerSubscriptions.clear());
397
461
  let outbound = outboundStream();
398
462
  let streamStarted = false;
463
+ let providerAbort = new AbortController();
464
+ connection.onDisconnect?.(() => { cancelResponseCredits(); providerAbort.abort(); outbound.close(); });
399
465
  let permits = 0n;
400
466
  function ensureStreamStarted() {
401
467
  if (streamStarted)
402
468
  return;
403
469
  streamStarted = true;
404
- const inbound = connection.queries.openStream(outbound.iterable, { metadata });
405
- processInboundQueries(inbound);
470
+ const inbound = connection.queries.openStream(outbound.iterable, { metadata, signal: providerAbort.signal });
471
+ void processInboundQueries(inbound, outbound);
406
472
  }
407
473
  function grantQueryPermits() {
408
474
  outbound.send({
@@ -431,6 +497,8 @@ export function axonServerQueryBus(conn, local, options = {}) {
431
497
  * re-emitted BEFORE the permits frame.
432
498
  */
433
499
  function reestablishStreamBody() {
500
+ cancelResponseCredits();
501
+ handlerSubscriptions.clear();
434
502
  outbound.close();
435
503
  outbound = outboundStream();
436
504
  streamStarted = false;
@@ -440,191 +508,217 @@ export function axonServerQueryBus(conn, local, options = {}) {
440
508
  sendSubscribe(queryName);
441
509
  grantQueryPermits();
442
510
  }
443
- async function reestablishStreamWithRetry() {
444
- if (shutdownLatch.shuttingDown)
445
- return;
446
- await withRetry(async () => reestablishStreamBody(), {
447
- event: "reconnect",
448
- ...resilience,
449
- });
450
- }
511
+ const recovery = streamRecovery(reestablishStreamBody, () => !shutdownLatch.shuttingDown && connection.state !== "closed" && connection.state !== "disconnected" && connection.state !== "reconnecting", resilience);
512
+ shutdownLatch.onShutdown(recovery.stop);
451
513
  // Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
452
514
  connection.onReconnect(() => {
453
515
  if (!shutdownLatch.shuttingDown && streamStarted) {
454
- reestablishStreamWithRetry().catch((err) => {
455
- console.error("Axon Server query bus: reconnect retries exhausted", err);
456
- });
516
+ recovery.restart();
457
517
  }
458
518
  });
459
- async function handleSubscriptionQueryRequest(req) {
460
- if (req.subscribe) {
461
- const sub = req.subscribe;
462
- const subId = sub.subscriptionIdentifier;
463
- const proto = sub.queryRequest;
464
- if (!subId || !proto)
519
+ async function handleInboundQuery(proto, responses, subId) {
520
+ let activity;
521
+ let admission;
522
+ let payload;
523
+ let subscriptionEntry;
524
+ let credit;
525
+ const supports = (key) => proto.processingInstructions?.some((instruction) => instruction.key === key && instruction.value?.booleanValue);
526
+ if (!subId && supports(7) && supports(8)) {
527
+ // Response credits may precede the query on Axon's provider stream.
528
+ // Retain those credits by request ID, within the same bounded table.
529
+ credit = responseCredit(proto.messageIdentifier);
530
+ if (!credit) {
531
+ responses.close();
465
532
  return;
466
- const queryName = proto.query;
467
- const payload = deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision);
468
- handlerSubscriptions.set(subId, { queryName, payload });
469
- let resultPayload;
470
- let errorCode = "";
471
- let errorMsg = "";
472
- if (subscribedNames.has(queryName)) {
473
- try {
533
+ }
534
+ }
535
+ let errorCode = "";
536
+ let errorMsg = "";
537
+ try {
538
+ try {
539
+ activity = shutdownLatch.registerActivity();
540
+ admission = inboundAdmission.enter();
541
+ if (subscribedNames.has(proto.query)) {
474
542
  const queryMessage = {
475
543
  kind: "query",
476
544
  identifier: proto.messageIdentifier,
477
- name: qualifiedNameFromString(queryName),
478
- payload,
545
+ name: qualifiedNameFromString(proto.query),
546
+ payload: deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision),
479
547
  metadata: metadataFromProto(proto.metaData ?? {}),
480
548
  timestamp: Number(proto.timestamp),
481
549
  };
482
- resultPayload = await local.query(queryMessage);
550
+ if (subId) {
551
+ subscriptionEntry = handlerSubscriptions.get(subId);
552
+ if (!subscriptionEntry)
553
+ return;
554
+ }
555
+ // Every wire query enters the local bus with a fresh unit of work.
556
+ const result = await next.query(queryMessage);
557
+ payload = result !== undefined ? serializePayload("result", result) : undefined;
483
558
  }
484
- catch (err) {
485
- errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR;
486
- errorMsg = err instanceof Error ? err.message : String(err);
559
+ else {
560
+ errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY;
561
+ errorMsg = `No next handler for query "${proto.query}"`;
487
562
  }
488
563
  }
489
- else {
490
- errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY;
491
- errorMsg = `No local handler for query "${queryName}"`;
564
+ catch (err) {
565
+ errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR;
566
+ errorMsg = err instanceof Error ? err.message : String(err);
492
567
  }
493
- const responseSerialized = resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined;
494
- outbound.send({
495
- subscriptionQueryResponse: {
496
- messageIdentifier: generateIdentifier(),
497
- subscriptionIdentifier: subId,
498
- initialResult: {
499
- messageIdentifier: generateIdentifier(),
500
- requestIdentifier: proto.messageIdentifier,
501
- errorCode,
502
- errorMessage: errorCode
503
- ? {
504
- message: errorMsg,
505
- location: connection.config.componentName,
506
- details: [],
507
- errorCode,
508
- }
509
- : undefined,
510
- payload: responseSerialized,
511
- metaData: {},
512
- processingInstructions: [],
568
+ // An unsubscribe or completion can overtake a slow initial handler.
569
+ if (subId && subscriptionEntry && handlerSubscriptions.get(subId) !== subscriptionEntry)
570
+ return;
571
+ if (subId && errorCode)
572
+ handlerSubscriptions.delete(subId);
573
+ if (credit) {
574
+ await credit.ready;
575
+ if (credit.cancelled)
576
+ return;
577
+ }
578
+ const response = {
579
+ messageIdentifier: generateIdentifier(),
580
+ requestIdentifier: proto.messageIdentifier,
581
+ errorCode,
582
+ errorMessage: errorCode
583
+ ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
584
+ : undefined,
585
+ payload,
586
+ metaData: {},
587
+ processingInstructions: [],
588
+ };
589
+ if (subId) {
590
+ responses.send({
591
+ subscriptionQueryResponse: {
592
+ messageIdentifier: generateIdentifier(), subscriptionIdentifier: subId, initialResult: response,
513
593
  },
514
- },
515
- instructionId: "",
516
- });
517
- return;
594
+ instructionId: "",
595
+ });
596
+ }
597
+ else {
598
+ responses.send({ queryResponse: response, instructionId: "" });
599
+ responses.send({
600
+ queryComplete: { messageId: generateIdentifier(), requestId: proto.messageIdentifier },
601
+ instructionId: "",
602
+ });
603
+ }
604
+ await responses.flush();
518
605
  }
519
- if (req.unsubscribe) {
520
- handlerSubscriptions.delete(req.unsubscribe.subscriptionIdentifier);
606
+ finally {
607
+ if (credit)
608
+ clearTimeout(credit.timer);
609
+ if (credit && responseCredits.get(proto.messageIdentifier) === credit)
610
+ responseCredits.delete(proto.messageIdentifier);
611
+ admission?.end();
612
+ activity?.end();
521
613
  }
522
- // flowControl + getInitialResult are not tracked per-sub; ignored for now.
523
614
  }
524
- async function processInboundQueries(inbound) {
615
+ async function processInboundQueries(inbound, responses) {
525
616
  try {
526
617
  for await (const message of inbound) {
527
- if (message.subscriptionQueryRequest) {
528
- await handleSubscriptionQueryRequest(message.subscriptionQueryRequest);
529
- continue;
530
- }
531
- if (!message.query)
532
- continue;
618
+ if (responses !== outbound)
619
+ return;
620
+ recovery.received();
621
+ if (message.instructionId)
622
+ responses.send({ ack: { instructionId: message.instructionId, success: true }, instructionId: "" });
623
+ // Every Axon query instruction consumes a provider credit, including
624
+ // acknowledgements. Ignoring a late subscription ack can exhaust a
625
+ // one-credit window between otherwise successful requests.
533
626
  permits--;
534
- const proto = message.query;
535
- const queryName = proto.query;
536
- let resultPayload;
537
- let errorCode = "";
538
- let errorMsg = "";
539
- if (subscribedNames.has(queryName)) {
540
- try {
541
- const queryMessage = {
542
- kind: "query",
543
- identifier: proto.messageIdentifier,
544
- name: qualifiedNameFromString(queryName),
545
- payload: deserializePayload(proto.payload?.data),
546
- metadata: metadataFromProto(proto.metaData),
547
- timestamp: Number(proto.timestamp),
548
- };
549
- // Through the LOCAL BUS: no unit of work is handed in, so `local`
550
- // opens one under whatever policy the caller gave it.
551
- resultPayload = await local.query(queryMessage);
552
- }
553
- catch (err) {
554
- errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR;
555
- errorMsg = err instanceof Error ? err.message : String(err);
627
+ if (permits <= THRESHOLD && !shutdownLatch.shuttingDown)
628
+ grantQueryPermits();
629
+ if (message.queryFlowControl?.permits > 0n) {
630
+ const identifier = message.queryFlowControl.queryReference?.requestId;
631
+ if (identifier) {
632
+ const credit = responseCredit(identifier);
633
+ if (!credit) {
634
+ responses.close();
635
+ return;
636
+ }
637
+ credit.grant();
556
638
  }
557
639
  }
558
- else {
559
- errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY;
560
- errorMsg = `No local handler for query "${queryName}"`;
640
+ if (message.queryCancel)
641
+ responseCredits.get(message.queryCancel.requestId)?.cancel();
642
+ const request = message.subscriptionQueryRequest;
643
+ if (request) {
644
+ if (request.unsubscribe)
645
+ handlerSubscriptions.delete(request.unsubscribe.subscriptionIdentifier);
646
+ const sub = request.subscribe;
647
+ if (sub?.subscriptionIdentifier && sub.queryRequest) {
648
+ try {
649
+ if (shutdownLatch.shuttingDown)
650
+ throw new Error("Shutdown in progress");
651
+ if (handlerSubscriptions.size >= 1024 && !handlerSubscriptions.has(sub.subscriptionIdentifier))
652
+ throw new Error("Provider subscription capacity exhausted");
653
+ const proto = sub.queryRequest;
654
+ handlerSubscriptions.set(sub.subscriptionIdentifier, {
655
+ queryName: proto.query,
656
+ payload: deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision),
657
+ });
658
+ }
659
+ catch (err) {
660
+ responses.send({
661
+ subscriptionQueryResponse: {
662
+ messageIdentifier: generateIdentifier(), subscriptionIdentifier: sub.subscriptionIdentifier,
663
+ completeExceptionally: {
664
+ errorCode: AxonServerErrorCode.QUERY_EXECUTION_ERROR,
665
+ errorMessage: { message: err instanceof Error ? err.message : String(err) },
666
+ },
667
+ },
668
+ instructionId: "",
669
+ });
670
+ }
671
+ }
672
+ // Axon separates update registration from requesting the initial
673
+ // result. Running the handler on Subscribe answers the wrong phase.
674
+ const initial = request.getInitialResult;
675
+ if (initial?.subscriptionIdentifier && initial.queryRequest) {
676
+ void handleInboundQuery(initial.queryRequest, responses, initial.subscriptionIdentifier).catch((err) => {
677
+ console.error("Axon Server query bus: inbound subscription response failed", err);
678
+ });
679
+ }
680
+ continue;
561
681
  }
562
- outbound.send({
563
- queryResponse: {
564
- messageIdentifier: generateIdentifier(),
565
- requestIdentifier: proto.messageIdentifier,
566
- errorCode,
567
- errorMessage: errorCode
568
- ? {
569
- message: errorMsg,
570
- location: connection.config.componentName,
571
- details: [],
572
- errorCode,
573
- }
574
- : undefined,
575
- payload: resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
576
- metaData: {},
577
- processingInstructions: [],
578
- },
579
- instructionId: "",
580
- });
581
- outbound.send({
582
- queryComplete: {
583
- messageId: generateIdentifier(),
584
- requestId: proto.messageIdentifier,
585
- },
586
- instructionId: "",
682
+ if (!message.query)
683
+ continue;
684
+ void handleInboundQuery(message.query, responses).catch((err) => {
685
+ console.error("Axon Server query bus: inbound response failed", err);
587
686
  });
588
- if (permits <= THRESHOLD) {
589
- outbound.send({
590
- flowControl: { clientId: connection.config.clientId, permits: PERMITS },
591
- instructionId: "",
592
- });
593
- permits += PERMITS;
594
- }
595
687
  }
688
+ if (responses === outbound && !shutdownLatch.shuttingDown)
689
+ throw new Error("Inbound provider stream ended unexpectedly");
596
690
  }
597
691
  catch (err) {
598
- if (shutdownLatch.shuttingDown)
692
+ if (responses !== outbound || shutdownLatch.shuttingDown)
599
693
  return;
600
- if (String(err).includes("Connection dropped"))
694
+ if (connection.state === "reconnecting" || connection.state === "closed" || connection.state === "disconnected")
601
695
  return;
602
- console.error("Axon Server query bus: inbound stream error, attempting re-establishment via withRetry", err);
603
- await reestablishStreamWithRetry().catch((retryErr) => {
604
- console.error("Axon Server query bus: reconnect retries exhausted", retryErr);
605
- });
696
+ recovery.failed(err);
606
697
  }
607
698
  }
608
699
  const routing = {
609
700
  async query(unstamped, uow) {
610
701
  const activity = shutdownLatch.registerActivity();
702
+ let admission;
703
+ let deadline;
611
704
  try {
705
+ admission = outboundAdmission.enter(unstamped.identifier);
706
+ deadline = messagingDeadline(requestTimeoutMs);
612
707
  const queryName = qualifiedNameToString(unstamped.name);
613
708
  // Local shortcut — handle locally if a handler is co-located. The
614
- // caller's unit of work is passed straight through, so `local` makes the
709
+ // caller's unit of work is passed straight through, so `next` makes the
615
710
  // nest-or-open decision on the HANDLE exactly as it does for an
616
711
  // in-process read: a live unit of work handed in by `ctx.query` is
617
712
  // reused so the consulting read shares the caller's transaction.
618
713
  if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
619
- return local.query(unstamped, uow);
714
+ return await next.query(unstamped, uow);
620
715
  }
621
716
  // A transport is not a task: it has no unit of work, so it has no clock.
622
- // A message that reaches the wire still {@link Unstamped} is therefore
623
- // stamped from system time here — the envelope crosses a process boundary
624
- // and must be fully formed. A locally-shortcut message is handed to
625
- // `local` unstamped instead, so the task that handles it supplies the
626
- // instant.
627
- const message = stamped(unstamped, Date.now);
717
+ // A message that reaches the wire with no instant yet gets one from system
718
+ // time here — the envelope crosses a process boundary and must be fully
719
+ // formed. A locally-shortcut message is handed to `next` untouched
720
+ // instead, so the task that handles it supplies the instant.
721
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
628
722
  const responseStream = connection.queries.query({
629
723
  messageIdentifier: message.identifier,
630
724
  query: queryName,
@@ -634,47 +728,77 @@ export function axonServerQueryBus(conn, local, options = {}) {
634
728
  processingInstructions: defaultQueryInstructions(queryTimeoutMs),
635
729
  clientId: connection.config.clientId,
636
730
  componentName: connection.config.componentName,
637
- }, { metadata });
731
+ }, { metadata, signal: deadline.signal });
732
+ // NR_OF_RESULTS is one. Drain trailers before returning so transport
733
+ // failures cannot be mistaken for a successful result. The RPC deadline
734
+ // also bounds a stream that sends a response but never completes.
735
+ let received = false;
736
+ let result;
737
+ let responseError;
638
738
  for await (const response of responseStream) {
739
+ if (received)
740
+ continue;
741
+ received = true;
639
742
  if (response.errorCode && response.errorCode !== "") {
640
- throw mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error");
743
+ responseError = mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error");
744
+ }
745
+ else {
746
+ try {
747
+ result = deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision);
748
+ }
749
+ catch (error) {
750
+ responseError = error instanceof Error ? error : new Error(String(error));
751
+ }
641
752
  }
642
- return deserializePayload(response.payload?.data);
643
753
  }
754
+ if (responseError)
755
+ throw responseError;
756
+ if (received)
757
+ return result;
644
758
  throw new Error(`No response for query "${queryName}"`);
645
759
  }
646
760
  finally {
761
+ deadline?.close();
762
+ admission?.end();
647
763
  activity.end();
648
764
  }
649
765
  },
650
766
  subscribe(queryName, handler) {
651
767
  subscribedNames.add(queryName);
652
- local.subscribe(queryName, handler);
768
+ next.subscribe(queryName, handler);
653
769
  ensureStreamStarted();
654
770
  sendSubscribe(queryName);
655
771
  // Permits AFTER subscription (Axon-specific ordering invariant)
656
772
  grantQueryPermits();
657
773
  },
658
774
  subscriptionQuery(unstamped, bufferSize) {
659
- const message = stamped(unstamped, Date.now);
775
+ if (shutdownLatch.shuttingDown)
776
+ throw new Error("Messaging shutdown in progress");
777
+ if (subscriptions.size >= 1024)
778
+ throw new Error("Subscription capacity 1024 exhausted");
779
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
660
780
  const queryId = message.identifier;
661
781
  if (subscriptions.has(queryId)) {
662
782
  throw new Error(`Subscription query already registered for identifier "${queryId}"`);
663
783
  }
664
- const handler = updateHandler(message, bufferSize);
665
- subscriptions.set(queryId, handler);
784
+ const handler = updateHandler(message, bufferSize, () => subscriptions.delete(queryId));
666
785
  const queryName = qualifiedNameToString(message.name);
786
+ const serialized = serializePayload(queryName, message.payload);
667
787
  const subscriptionId = generateIdentifier();
668
788
  const outboundSub = outboundStream();
789
+ const window = Math.min(1024, Math.max(256, Math.floor(bufferSize ?? 256)));
790
+ const refillBatch = Math.max(1, Math.floor(window / 4));
791
+ let consumedSinceRefill = 0;
792
+ let subscriptionClosed = false;
669
793
  outboundSub.send({
670
794
  subscribe: {
671
795
  subscriptionIdentifier: subscriptionId,
672
- numberOfPermits: BigInt(bufferSize ?? 256),
796
+ numberOfPermits: BigInt(window),
673
797
  queryRequest: {
674
798
  messageIdentifier: message.identifier,
675
799
  query: queryName,
676
800
  timestamp: BigInt(message.timestamp),
677
- payload: serializePayload(queryName, message.payload),
801
+ payload: serialized,
678
802
  metaData: metadataToProto(message.metadata),
679
803
  processingInstructions: defaultQueryInstructions(queryTimeoutMs),
680
804
  clientId: connection.config.clientId,
@@ -682,6 +806,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
682
806
  },
683
807
  },
684
808
  });
809
+ // Subscribe does not grant update credits on Axon Server; a separate
810
+ // FlowControl frame initializes the subscription stream's update window.
811
+ outboundSub.send({ flowControl: { numberOfPermits: BigInt(window) } });
685
812
  outboundSub.send({
686
813
  getInitialResult: {
687
814
  subscriptionIdentifier: subscriptionId,
@@ -690,7 +817,7 @@ export function axonServerQueryBus(conn, local, options = {}) {
690
817
  messageIdentifier: message.identifier,
691
818
  query: queryName,
692
819
  timestamp: BigInt(message.timestamp),
693
- payload: serializePayload(queryName, message.payload),
820
+ payload: serialized,
694
821
  metaData: metadataToProto(message.metadata),
695
822
  processingInstructions: defaultQueryInstructions(queryTimeoutMs),
696
823
  clientId: connection.config.clientId,
@@ -698,7 +825,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
698
825
  },
699
826
  },
700
827
  });
701
- const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata });
828
+ const subscriptionController = new AbortController();
829
+ const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata, signal: subscriptionController.signal });
830
+ subscriptions.set(queryId, handler);
702
831
  let resolveInitial;
703
832
  let rejectInitial;
704
833
  const initialResult = new Promise((resolve, reject) => {
@@ -706,70 +835,114 @@ export function axonServerQueryBus(conn, local, options = {}) {
706
835
  rejectInitial = reject;
707
836
  });
708
837
  let initialSettled = false;
709
- (async () => {
838
+ let explicitlyCompleted = false;
839
+ const initialTimer = setTimeout(() => closeSubscription(new Error("Subscription initial result timed out")), requestTimeoutMs);
840
+ const removeShutdown = shutdownLatch.onShutdown(() => closeSubscription(new Error("Messaging shutdown in progress")));
841
+ // Callers may consume updates without awaiting the initial result. Keep
842
+ // the original promise rejectable without an unhandled rejection on close.
843
+ void initialResult.catch(() => { });
844
+ function closeSubscription(error) {
845
+ if (subscriptionClosed)
846
+ return;
847
+ subscriptionClosed = true;
848
+ clearTimeout(initialTimer);
849
+ removeShutdown();
850
+ if (!initialSettled) {
851
+ rejectInitial(error ?? new Error("Subscription query closed before initial result"));
852
+ initialSettled = true;
853
+ }
854
+ if (error)
855
+ handler.completeExceptionally(error);
856
+ else
857
+ handler.complete();
858
+ try {
859
+ outboundSub.send({ unsubscribe: { subscriptionIdentifier: subscriptionId } });
860
+ }
861
+ catch { /* Broken stream; local teardown still must finish. */ }
862
+ outboundSub.close();
863
+ subscriptionController.abort();
864
+ subscriptions.delete(queryId);
865
+ }
866
+ void (async () => {
710
867
  try {
711
868
  for await (const response of responseStream) {
869
+ if (subscriptionClosed)
870
+ break;
712
871
  if (response.initialResult) {
713
872
  const initial = response.initialResult;
714
873
  if (!initialSettled) {
715
- if (initial.errorCode && initial.errorCode !== "") {
716
- rejectInitial(mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error"));
717
- }
718
- else {
719
- resolveInitial(deserializePayload(initial.payload?.data));
874
+ if (initial.errorCode) {
875
+ throw mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error");
720
876
  }
877
+ clearTimeout(initialTimer);
878
+ resolveInitial(deserializePayload(initial.payload?.data, initial.payload?.type, initial.payload?.revision));
721
879
  initialSettled = true;
722
880
  }
723
881
  }
724
882
  else if (response.update) {
725
- const update = deserializePayload(response.update.payload?.data);
726
- handler.offer(update);
883
+ const update = deserializePayload(response.update.payload?.data, response.update.payload?.type, response.update.payload?.revision);
884
+ if (!handler.offer(update))
885
+ throw new Error("Subscription query update buffer overflow");
886
+ consumedSinceRefill++;
887
+ if (consumedSinceRefill >= refillBatch) {
888
+ outboundSub.send({
889
+ flowControl: { subscriptionIdentifier: subscriptionId, numberOfPermits: BigInt(consumedSinceRefill) },
890
+ });
891
+ consumedSinceRefill = 0;
892
+ }
727
893
  }
728
894
  else if (response.complete) {
729
- handler.complete();
895
+ explicitlyCompleted = true;
730
896
  break;
731
897
  }
732
898
  else if (response.completeExceptionally) {
733
- handler.completeExceptionally(new Error(response.completeExceptionally.errorMessage?.message ??
734
- "Subscription query failed"));
735
- break;
899
+ throw new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed");
736
900
  }
737
901
  }
738
902
  }
739
903
  catch (err) {
740
- const error = err instanceof Error ? err : new Error(String(err));
904
+ closeSubscription(err instanceof Error ? err : new Error(String(err)));
905
+ }
906
+ finally {
907
+ // EOF and completion frames must settle BOTH faces of a subscription.
908
+ const missingInitial = !initialSettled;
741
909
  if (!initialSettled) {
742
- rejectInitial(error);
910
+ rejectInitial(new Error("Subscription stream ended before initial result"));
743
911
  initialSettled = true;
744
912
  }
745
- handler.completeExceptionally(error);
746
- }
747
- finally {
748
- subscriptions.delete(queryId);
913
+ closeSubscription(!missingInitial && !explicitlyCompleted && !subscriptionClosed ? new Error("Subscription stream ended unexpectedly") : undefined);
749
914
  }
750
915
  })();
751
916
  return {
752
917
  initialResult,
753
- updates: handler.iterable,
754
- close: () => {
755
- outboundSub.send({
756
- unsubscribe: {
757
- subscriptionIdentifier: subscriptionId,
758
- },
759
- });
760
- outboundSub.close();
761
- subscriptions.delete(queryId);
762
- handler.complete();
918
+ updates: {
919
+ [Symbol.asyncIterator]() {
920
+ const iterator = handler.iterable[Symbol.asyncIterator]();
921
+ return {
922
+ next: () => iterator.next(),
923
+ async return() {
924
+ closeSubscription();
925
+ return iterator.return ? iterator.return() : { value: undefined, done: true };
926
+ },
927
+ };
928
+ },
763
929
  },
930
+ close: () => closeSubscription(),
764
931
  };
765
932
  },
766
933
  subscribeToUpdates(unstamped, bufferSize) {
767
- const message = stamped(unstamped, Date.now);
934
+ if (shutdownLatch.shuttingDown)
935
+ throw new Error("Messaging shutdown in progress");
936
+ if (subscriptions.size >= 1024)
937
+ throw new Error("Subscription capacity 1024 exhausted");
938
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
768
939
  const queryId = message.identifier;
769
940
  if (subscriptions.has(queryId)) {
770
941
  throw new Error(`Subscription query already registered for identifier "${queryId}"`);
771
942
  }
772
- const handler = updateHandler(message, bufferSize);
943
+ let removeShutdown;
944
+ const handler = updateHandler(message, bufferSize, () => { subscriptions.delete(queryId); removeShutdown?.(); });
945
+ removeShutdown = shutdownLatch.onShutdown(() => handler.completeExceptionally(new Error("Messaging shutdown in progress")));
773
946
  subscriptions.set(queryId, handler);
774
947
  return {
775
948
  [Symbol.asyncIterator]: () => handler.iterable[Symbol.asyncIterator](),
@@ -779,7 +952,7 @@ export function axonServerQueryBus(conn, local, options = {}) {
779
952
  },
780
953
  };
781
954
  },
782
- async emitUpdate(queryName, filter, update) {
955
+ async emitUpdate(queryName, filter, update, uow) {
783
956
  runAfterCommitOrImmediately(() => {
784
957
  for (const [subId, sub] of handlerSubscriptions) {
785
958
  if (sub.queryName !== queryName)
@@ -804,9 +977,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
804
977
  instructionId: "",
805
978
  });
806
979
  }
807
- });
980
+ }, uow);
808
981
  },
809
- async completeSubscription(queryName, filter) {
982
+ async completeSubscription(queryName, filter, uow) {
810
983
  runAfterCommitOrImmediately(() => {
811
984
  for (const [subId, sub] of handlerSubscriptions) {
812
985
  if (sub.queryName !== queryName)
@@ -826,9 +999,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
826
999
  });
827
1000
  handlerSubscriptions.delete(subId);
828
1001
  }
829
- });
1002
+ }, uow);
830
1003
  },
831
- async completeSubscriptionExceptionally(queryName, error, filter) {
1004
+ async completeSubscriptionExceptionally(queryName, error, filter, uow) {
832
1005
  runAfterCommitOrImmediately(() => {
833
1006
  for (const [subId, sub] of handlerSubscriptions) {
834
1007
  if (sub.queryName !== queryName)
@@ -855,7 +1028,7 @@ export function axonServerQueryBus(conn, local, options = {}) {
855
1028
  });
856
1029
  handlerSubscriptions.delete(subId);
857
1030
  }
858
- });
1031
+ }, uow);
859
1032
  },
860
1033
  };
861
1034
  return routing;