@kronos-ts/axon-server 0.5.0 → 0.7.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 (68) hide show
  1. package/dist/axon-server-event-store.d.ts.map +1 -1
  2. package/dist/axon-server-event-store.js +72 -77
  3. package/dist/axon-server-event-store.js.map +1 -1
  4. package/dist/axon-server-snapshotting-event-store.d.ts +3 -3
  5. package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -1
  6. package/dist/axon-server-snapshotting-event-store.js +3 -2
  7. package/dist/axon-server-snapshotting-event-store.js.map +1 -1
  8. package/dist/axon-server.d.ts +15 -7
  9. package/dist/axon-server.d.ts.map +1 -1
  10. package/dist/axon-server.js +447 -275
  11. package/dist/axon-server.js.map +1 -1
  12. package/dist/bounded-read.d.ts +19 -0
  13. package/dist/bounded-read.d.ts.map +1 -0
  14. package/dist/bounded-read.js +39 -0
  15. package/dist/bounded-read.js.map +1 -0
  16. package/dist/connection.d.ts +13 -0
  17. package/dist/connection.d.ts.map +1 -1
  18. package/dist/connection.js +78 -40
  19. package/dist/connection.js.map +1 -1
  20. package/dist/control-plane.d.ts +9 -19
  21. package/dist/control-plane.d.ts.map +1 -1
  22. package/dist/control-plane.js +6 -37
  23. package/dist/control-plane.js.map +1 -1
  24. package/dist/event-processor-info.d.ts +5 -23
  25. package/dist/event-processor-info.d.ts.map +1 -1
  26. package/dist/event-processor-info.js +16 -20
  27. package/dist/event-processor-info.js.map +1 -1
  28. package/dist/flow-controlled-sender.d.ts.map +1 -1
  29. package/dist/flow-controlled-sender.js +37 -17
  30. package/dist/flow-controlled-sender.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/outbound-stream.d.ts +5 -9
  35. package/dist/outbound-stream.d.ts.map +1 -1
  36. package/dist/outbound-stream.js +65 -16
  37. package/dist/outbound-stream.js.map +1 -1
  38. package/dist/platform-service.d.ts +10 -1
  39. package/dist/platform-service.d.ts.map +1 -1
  40. package/dist/platform-service.js +70 -10
  41. package/dist/platform-service.js.map +1 -1
  42. package/dist/resilience.d.ts +2 -0
  43. package/dist/resilience.d.ts.map +1 -1
  44. package/dist/resilience.js +2 -1
  45. package/dist/resilience.js.map +1 -1
  46. package/dist/shutdown-latch.d.ts +1 -0
  47. package/dist/shutdown-latch.d.ts.map +1 -1
  48. package/dist/shutdown-latch.js +20 -1
  49. package/dist/shutdown-latch.js.map +1 -1
  50. package/dist/stream-recovery.d.ts +9 -0
  51. package/dist/stream-recovery.d.ts.map +1 -0
  52. package/dist/stream-recovery.js +70 -0
  53. package/dist/stream-recovery.js.map +1 -0
  54. package/package.json +2 -2
  55. package/src/axon-server-event-store.ts +77 -79
  56. package/src/axon-server-snapshotting-event-store.ts +10 -7
  57. package/src/axon-server.ts +417 -316
  58. package/src/bounded-read.ts +43 -0
  59. package/src/connection.ts +77 -45
  60. package/src/control-plane.ts +19 -63
  61. package/src/event-processor-info.ts +22 -43
  62. package/src/flow-controlled-sender.ts +33 -14
  63. package/src/index.ts +0 -1
  64. package/src/outbound-stream.ts +57 -27
  65. package/src/platform-service.ts +75 -11
  66. package/src/resilience.ts +6 -2
  67. package/src/shutdown-latch.ts +14 -1
  68. package/src/stream-recovery.ts +72 -0
@@ -1,3 +1,5 @@
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
  *
@@ -15,7 +17,7 @@
15
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(...)`;
@@ -31,11 +33,12 @@ import {
31
33
  generateIdentifier,
32
34
  type Serializer,
33
35
  } from "@kronos-ts/core"
34
- import { withRetry, type ResilienceConfig } from "./resilience.js"
36
+ import { type ResilienceConfig } from "./resilience.js"
35
37
  import type {
36
38
  CommandBus,
37
39
  CommandMessage,
38
40
  QueryBus,
41
+ SubscriptionCapableQueryBus,
39
42
  QueryMessage,
40
43
  SubscriptionFilter,
41
44
  SubscriptionQueryResult,
@@ -50,15 +53,16 @@ import {
50
53
  import type { AxonServerBusSource } from "./connection.js"
51
54
  import { contextView } from "./context-view.js"
52
55
  import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
53
- 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"
54
59
  import { mapErrorCode, AxonServerErrorCode } from "./errors.js"
55
60
 
56
61
  /** Default flow control settings — aligned with Java's 5000 permits. */
57
62
  const DEFAULT_PERMITS = 5000n
58
- const DEFAULT_THRESHOLD = 2500n
59
63
 
60
64
  /** Default query dispatch timeout — aligned with Java's one hour. */
61
- const DEFAULT_QUERY_TIMEOUT_MS = 3_600_000
65
+ const DEFAULT_QUERY_TIMEOUT_MS = 30_000
62
66
 
63
67
  /** Default command handler load factor — aligned with Java's 100. */
64
68
  const DEFAULT_LOAD_FACTOR = 100
@@ -92,6 +96,8 @@ export type ProcessingInstructions = {
92
96
  * are positional, and this record is the trailing remainder.
93
97
  */
94
98
  export type AxonServerCommandBusOptions = {
99
+ /** Client-side request deadline. Default: 30000ms. */
100
+ timeoutMs?: number
95
101
  /** Axon Server context for this bus's stream. Default: the connection's. */
96
102
  context?: string
97
103
  /** Flow control for the command stream. */
@@ -104,6 +110,9 @@ export type AxonServerCommandBusOptions = {
104
110
  loadFactor?: number
105
111
  /** Retry policy for stream re-establishment. Default: the connection's. */
106
112
  resilience?: Partial<ResilienceConfig>
113
+ /** Bounded admission; excess nested work receives an overload error. */
114
+ limits?: MessagingLimits
115
+
107
116
  }
108
117
 
109
118
  /**
@@ -125,12 +134,15 @@ export type AxonServerQueryBusOptions = {
125
134
  */
126
135
  shortcutQueriesToLocalHandlers?: boolean
127
136
  /**
128
- * Default timeout for query dispatch in ms. Default: 3600000 (1 hour).
137
+ * Default timeout for query dispatch in ms. Default: 30000ms.
129
138
  * Aligned with Java's processing instruction timeout.
130
139
  */
131
140
  timeoutMs?: number
132
141
  /** Retry policy for stream re-establishment. Default: the connection's. */
133
142
  resilience?: Partial<ResilienceConfig>
143
+ /** Bounded admission; excess nested work receives an overload error. */
144
+ limits?: MessagingLimits
145
+
134
146
  }
135
147
 
136
148
  // Processing instruction keys — aligned with proto ProcessingKey enum.
@@ -256,11 +268,16 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
256
268
  metadata: axonMetadata,
257
269
  } = contextView(conn, options.context ?? conn.connection.config.context)
258
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)
259
275
  const resilience = options.resilience ?? conn.resilience
260
276
  const metadata = axonMetadata()
261
277
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
262
- const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
263
- 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")
264
281
  const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR
265
282
 
266
283
  /**
@@ -274,6 +291,8 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
274
291
  // Bidirectional stream for handler subscription + inbound command handling
275
292
  let outbound = outboundStream<any>()
276
293
  let streamStarted = false
294
+ let providerAbort = new AbortController()
295
+ connection.onDisconnect?.(() => { providerAbort.abort(); outbound.close() })
277
296
  let permits = 0n
278
297
 
279
298
  function ensureStreamStarted() {
@@ -281,8 +300,8 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
281
300
  streamStarted = true
282
301
 
283
302
  // Open stream using connection.commands (always gets current client after reconnect)
284
- const inbound = connection.commands.openStream(outbound.iterable, { metadata })
285
- processInboundCommands(inbound)
303
+ const inbound = connection.commands.openStream(outbound.iterable, { metadata, signal: providerAbort.signal })
304
+ void processInboundCommands(inbound, outbound)
286
305
  }
287
306
 
288
307
  function grantPermits() {
@@ -315,6 +334,8 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
315
334
  * trigger a server-side stream error.
316
335
  */
317
336
  function reestablishStreamBody() {
337
+ providerAbort.abort()
338
+ providerAbort = new AbortController()
318
339
  outbound.close()
319
340
  outbound = outboundStream<any>()
320
341
  streamStarted = false
@@ -326,104 +347,106 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
326
347
  grantPermits()
327
348
  }
328
349
 
329
- async function reestablishStreamWithRetry() {
330
- if (shutdownLatch.shuttingDown) return
331
- await withRetry(async () => reestablishStreamBody(), {
332
- event: "reconnect",
333
- ...resilience,
334
- })
335
- }
350
+ const recovery = streamRecovery(reestablishStreamBody,
351
+ () => !shutdownLatch.shuttingDown && connection.state !== "closed" && connection.state !== "disconnected" && connection.state !== "reconnecting",
352
+ resilience)
353
+ shutdownLatch.onShutdown(recovery.stop)
336
354
 
337
355
  // Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
338
356
  connection.onReconnect(() => {
339
357
  if (!shutdownLatch.shuttingDown && streamStarted) {
340
- reestablishStreamWithRetry().catch((err) => {
341
- console.error("Axon Server command bus: reconnect retries exhausted", err)
342
- })
358
+ recovery.restart()
343
359
  }
344
360
  })
345
361
 
346
- async function processInboundCommands(inbound: AsyncIterable<any>) {
347
- try {
348
- for await (const message of inbound) {
349
- 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 = ""
350
368
 
351
- permits--
352
- const proto = message.command
353
- const commandName = proto.name
354
-
355
- let resultPayload: unknown
356
- let errorCode = ""
357
- let errorMsg = ""
358
-
359
- if (subscribedNames.has(commandName)) {
360
- try {
361
- const commandMessage: CommandMessage = {
362
- kind: "command",
363
- identifier: proto.messageIdentifier,
364
- name: qualifiedNameFromString(commandName),
365
- payload: deserializePayload(proto.payload?.data as Uint8Array | undefined),
366
- metadata: metadataFromProto(proto.metaData),
367
- timestamp: Number(proto.timestamp),
368
- }
369
-
370
- // Through the LOCAL BUS, so the caller's unit-of-work policy runs.
371
- // AF parity is preserved: `CommandProcessingTask` runs the next
372
- // segment without re-running dispatch interceptors, and a `next`
373
- // that happens to carry `correlation` re-applies a pair of `??` seeds
374
- // that are already set.
375
- resultPayload = await next.dispatch(commandMessage)
376
- } catch (err) {
377
- errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
378
- 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),
379
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
380
390
  } else {
381
391
  errorCode = AxonServerErrorCode.NO_HANDLER_FOR_COMMAND
382
- errorMsg = `No next handler for command "${commandName}"`
392
+ errorMsg = `No next handler for command "${proto.name}"`
383
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
+ }
384
400
 
385
- // Send response back to Axon Server
386
- outbound.send({
387
- commandResponse: {
388
- messageIdentifier: generateIdentifier(),
389
- requestIdentifier: proto.messageIdentifier,
390
- errorCode,
391
- errorMessage: errorCode
392
- ? {
393
- message: errorMsg,
394
- location: connection.config.componentName,
395
- details: [],
396
- errorCode,
397
- }
398
- : undefined,
399
- payload:
400
- resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
401
- metaData: {},
402
- processingInstructions: [],
403
- },
404
- instructionId: "",
405
- })
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
+ }
406
423
 
407
- // Refill permits when running low
408
- if (permits <= THRESHOLD) {
409
- outbound.send({
410
- flowControl: { clientId: connection.config.clientId, permits: PERMITS },
411
- instructionId: "",
412
- })
413
- permits += PERMITS
414
- }
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
+ })
415
443
  }
444
+ if (responses === outbound && !shutdownLatch.shuttingDown) throw new Error("Inbound provider stream ended unexpectedly")
416
445
  } catch (err) {
417
- if (shutdownLatch.shuttingDown) return
418
- if (String(err).includes("Connection dropped")) return
419
-
420
- console.error(
421
- "Axon Server command bus: inbound stream error, attempting re-establishment via withRetry",
422
- err,
423
- )
424
- await reestablishStreamWithRetry().catch((retryErr) => {
425
- console.error("Axon Server command bus: reconnect retries exhausted", retryErr)
426
- })
446
+ if (responses !== outbound || shutdownLatch.shuttingDown) return
447
+ if (connection.state === "reconnecting" || connection.state === "closed" || connection.state === "disconnected") return
448
+
449
+ recovery.failed(err)
427
450
  }
428
451
  }
429
452
 
@@ -436,7 +459,11 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
436
459
  // instead, so the task that handles it supplies the instant.
437
460
  const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
438
461
  const activity = shutdownLatch.registerActivity()
462
+ let admission: ReturnType<typeof outboundAdmission.enter> | undefined
463
+ let deadline: ReturnType<typeof messagingDeadline> | undefined
439
464
  try {
465
+ admission = outboundAdmission.enter(unstamped.identifier)
466
+ deadline = messagingDeadline(requestTimeoutMs)
440
467
  const commandName = qualifiedNameToString(message.name)
441
468
 
442
469
  const response = await connection.commands.dispatch(
@@ -452,15 +479,17 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
452
479
  clientId: connection.config.clientId,
453
480
  componentName: connection.config.componentName,
454
481
  },
455
- { metadata },
482
+ { metadata, signal: deadline.signal },
456
483
  )
457
484
 
458
485
  if (response.errorCode && response.errorCode !== "") {
459
486
  throw mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
460
487
  }
461
488
 
462
- return deserializePayload(response.payload?.data as Uint8Array | undefined)
489
+ return deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision)
463
490
  } finally {
491
+ deadline?.close()
492
+ admission?.end()
464
493
  activity.end()
465
494
  }
466
495
  },
@@ -505,26 +534,32 @@ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
505
534
  * `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
506
535
  * shortcut branch gets identical correlation to the remote branch.
507
536
  *
508
- * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
509
- * straight from `message.metadata`, and `interceptingQueryBus` (in
510
- * `@kronos-ts/core`) forwards those two calls to the delegate without
511
- * 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`).
512
542
  */
513
543
  export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
514
544
  next: QueryBus<U>,
515
545
  conn: AxonServerBusSource,
516
546
  options: AxonServerQueryBusOptions = {},
517
- ): QueryBus<U> {
547
+ ): SubscriptionCapableQueryBus<U> {
518
548
  const {
519
549
  connection,
520
550
  serializer,
521
551
  metadata: axonMetadata,
522
552
  } = contextView(conn, options.context ?? conn.connection.config.context)
523
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)
524
558
  const resilience = options.resilience ?? conn.resilience
525
559
  const metadata = axonMetadata()
526
- const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
527
- 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")
528
563
  const shortcutQueriesToLocalHandlers = options.shortcutQueriesToLocalHandlers ?? false
529
564
  const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS
530
565
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
@@ -542,17 +577,46 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
542
577
  // to decide which subscriber IDs to target; the server forwards each response to the
543
578
  // exact subscriber.
544
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())
545
607
 
546
608
  let outbound = outboundStream<any>()
547
609
  let streamStarted = false
610
+ let providerAbort = new AbortController()
611
+ connection.onDisconnect?.(() => { cancelResponseCredits(); providerAbort.abort(); outbound.close() })
548
612
  let permits = 0n
549
613
 
550
614
  function ensureStreamStarted() {
551
615
  if (streamStarted) return
552
616
  streamStarted = true
553
617
 
554
- const inbound = connection.queries.openStream(outbound.iterable, { metadata })
555
- processInboundQueries(inbound)
618
+ const inbound = connection.queries.openStream(outbound.iterable, { metadata, signal: providerAbort.signal })
619
+ void processInboundQueries(inbound, outbound)
556
620
  }
557
621
 
558
622
  function grantQueryPermits() {
@@ -584,6 +648,8 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
584
648
  * re-emitted BEFORE the permits frame.
585
649
  */
586
650
  function reestablishStreamBody() {
651
+ cancelResponseCredits()
652
+ handlerSubscriptions.clear()
587
653
  outbound.close()
588
654
  outbound = outboundStream<any>()
589
655
  streamStarted = false
@@ -593,200 +659,186 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
593
659
  grantQueryPermits()
594
660
  }
595
661
 
596
- async function reestablishStreamWithRetry() {
597
- if (shutdownLatch.shuttingDown) return
598
- await withRetry(async () => reestablishStreamBody(), {
599
- event: "reconnect",
600
- ...resilience,
601
- })
602
- }
662
+ const recovery = streamRecovery(reestablishStreamBody,
663
+ () => !shutdownLatch.shuttingDown && connection.state !== "closed" && connection.state !== "disconnected" && connection.state !== "reconnecting",
664
+ resilience)
665
+ shutdownLatch.onShutdown(recovery.stop)
603
666
 
604
667
  // Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
605
668
  connection.onReconnect(() => {
606
669
  if (!shutdownLatch.shuttingDown && streamStarted) {
607
- reestablishStreamWithRetry().catch((err) => {
608
- console.error("Axon Server query bus: reconnect retries exhausted", err)
609
- })
670
+ recovery.restart()
610
671
  }
611
672
  })
612
673
 
613
- async function handleSubscriptionQueryRequest(req: any): Promise<void> {
614
- if (req.subscribe) {
615
- const sub = req.subscribe
616
- const subId: string = sub.subscriptionIdentifier
617
- const proto = sub.queryRequest
618
- if (!subId || !proto) return
619
-
620
- const queryName: string = proto.query
621
- const payload = deserializePayload(
622
- proto.payload?.data as Uint8Array | undefined,
623
- proto.payload?.type,
624
- proto.payload?.revision,
625
- )
626
- handlerSubscriptions.set(subId, { queryName, payload })
627
-
628
- let resultPayload: unknown
629
- let errorCode = ""
630
- let errorMsg = ""
631
-
632
- if (subscribedNames.has(queryName)) {
633
- 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)) {
634
694
  const queryMessage: QueryMessage = {
635
695
  kind: "query",
636
696
  identifier: proto.messageIdentifier,
637
- name: qualifiedNameFromString(queryName),
638
- payload,
697
+ name: qualifiedNameFromString(proto.query),
698
+ payload: deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision),
639
699
  metadata: metadataFromProto(proto.metaData ?? {}),
640
700
  timestamp: Number(proto.timestamp),
641
701
  }
642
- resultPayload = await next.query(queryMessage)
643
- } catch (err) {
644
- errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
645
- 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}"`
646
712
  }
647
- } else {
648
- errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
649
- errorMsg = `No next handler for query "${queryName}"`
713
+ } catch (err) {
714
+ errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
715
+ errorMsg = err instanceof Error ? err.message : String(err)
650
716
  }
651
-
652
- const responseSerialized =
653
- resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined
654
-
655
- outbound.send({
656
- subscriptionQueryResponse: {
657
- messageIdentifier: generateIdentifier(),
658
- subscriptionIdentifier: subId,
659
- initialResult: {
660
- messageIdentifier: generateIdentifier(),
661
- requestIdentifier: proto.messageIdentifier,
662
- errorCode,
663
- errorMessage: errorCode
664
- ? {
665
- message: errorMsg,
666
- location: connection.config.componentName,
667
- details: [],
668
- errorCode,
669
- }
670
- : undefined,
671
- payload: responseSerialized,
672
- metaData: {},
673
- 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,
674
739
  },
675
- },
676
- instructionId: "",
677
- })
678
- return
679
- }
680
- if (req.unsubscribe) {
681
- 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()
682
755
  }
683
- // flowControl + getInitialResult are not tracked per-sub; ignored for now.
684
756
  }
685
757
 
686
- async function processInboundQueries(inbound: AsyncIterable<any>) {
758
+ async function processInboundQueries(inbound: AsyncIterable<any>, responses: OutboundStream<any>) {
687
759
  try {
688
760
  for await (const message of inbound) {
689
- if (message.subscriptionQueryRequest) {
690
- await handleSubscriptionQueryRequest(message.subscriptionQueryRequest)
691
- continue
692
- }
693
- if (!message.query) continue
694
-
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.
695
767
  permits--
696
- const proto = message.query
697
- const queryName = proto.query
698
-
699
- let resultPayload: unknown
700
- let errorCode = ""
701
- let errorMsg = ""
702
-
703
- if (subscribedNames.has(queryName)) {
704
- try {
705
- const queryMessage: QueryMessage = {
706
- kind: "query",
707
- identifier: proto.messageIdentifier,
708
- name: qualifiedNameFromString(queryName),
709
- payload: deserializePayload(proto.payload?.data as Uint8Array | undefined),
710
- metadata: metadataFromProto(proto.metaData),
711
- 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
+ })
712
802
  }
713
-
714
- // Through the LOCAL BUS: no unit of work is handed in, so `next`
715
- // opens one under whatever policy the caller gave it.
716
- resultPayload = await next.query(queryMessage)
717
- } catch (err) {
718
- errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
719
- errorMsg = err instanceof Error ? err.message : String(err)
720
803
  }
721
- } else {
722
- errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
723
- errorMsg = `No next 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
724
813
  }
725
-
726
- outbound.send({
727
- queryResponse: {
728
- messageIdentifier: generateIdentifier(),
729
- requestIdentifier: proto.messageIdentifier,
730
- errorCode,
731
- errorMessage: errorCode
732
- ? {
733
- message: errorMsg,
734
- location: connection.config.componentName,
735
- details: [],
736
- errorCode,
737
- }
738
- : undefined,
739
- payload:
740
- resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
741
- metaData: {},
742
- processingInstructions: [],
743
- },
744
- instructionId: "",
745
- })
746
-
747
- outbound.send({
748
- queryComplete: {
749
- messageId: generateIdentifier(),
750
- requestId: proto.messageIdentifier,
751
- },
752
- 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)
753
817
  })
754
-
755
- if (permits <= THRESHOLD) {
756
- outbound.send({
757
- flowControl: { clientId: connection.config.clientId, permits: PERMITS },
758
- instructionId: "",
759
- })
760
- permits += PERMITS
761
- }
762
818
  }
819
+ if (responses === outbound && !shutdownLatch.shuttingDown) throw new Error("Inbound provider stream ended unexpectedly")
763
820
  } catch (err) {
764
- if (shutdownLatch.shuttingDown) return
765
- if (String(err).includes("Connection dropped")) return
766
-
767
- console.error(
768
- "Axon Server query bus: inbound stream error, attempting re-establishment via withRetry",
769
- err,
770
- )
771
- await reestablishStreamWithRetry().catch((retryErr) => {
772
- console.error("Axon Server query bus: reconnect retries exhausted", retryErr)
773
- })
821
+ if (responses !== outbound || shutdownLatch.shuttingDown) return
822
+ if (connection.state === "reconnecting" || connection.state === "closed" || connection.state === "disconnected") return
823
+ recovery.failed(err)
774
824
  }
775
825
  }
776
826
 
777
- const routing: QueryBus<U> = {
778
- async query(unstamped: QueryMessage, uow?: UnitOfWork): Promise<unknown> {
827
+ const routing: SubscriptionCapableQueryBus<U> = {
828
+ async query(unstamped: QueryMessage): Promise<unknown> {
779
829
  const activity = shutdownLatch.registerActivity()
830
+ let admission: ReturnType<typeof outboundAdmission.enter> | undefined
831
+ let deadline: ReturnType<typeof messagingDeadline> | undefined
780
832
  try {
833
+ admission = outboundAdmission.enter(unstamped.identifier)
834
+ deadline = messagingDeadline(requestTimeoutMs)
781
835
  const queryName = qualifiedNameToString(unstamped.name)
782
836
 
783
837
  // Local shortcut — handle locally if a handler is co-located. The
784
- // caller's unit of work is passed straight through, so `next` makes the
785
- // nest-or-open decision on the HANDLE exactly as it does for an
786
- // in-process read: a live unit of work handed in by `ctx.query` is
787
- // reused so the consulting read shares the caller's transaction.
838
+ // co-located handler answers on a task of its own, exactly as a remote
839
+ // one would `next` mints it.
788
840
  if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
789
- return next.query(unstamped, uow)
841
+ return await next.query(unstamped)
790
842
  }
791
843
 
792
844
  // A transport is not a task: it has no unit of work, so it has no clock.
@@ -807,21 +859,32 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
807
859
  clientId: connection.config.clientId,
808
860
  componentName: connection.config.componentName,
809
861
  },
810
- { metadata },
862
+ { metadata, signal: deadline.signal },
811
863
  )
812
864
 
865
+ // NR_OF_RESULTS is one. Drain trailers before returning so transport
866
+ // failures cannot be mistaken for a successful result. The RPC deadline
867
+ // also bounds a stream that sends a response but never completes.
868
+ let received = false
869
+ let result: unknown
870
+ let responseError: Error | undefined
813
871
  for await (const response of responseStream) {
872
+ if (received) continue
873
+ received = true
814
874
  if (response.errorCode && response.errorCode !== "") {
815
- throw mapErrorCode(
816
- response.errorCode,
817
- response.errorMessage?.message ?? "Unknown error",
818
- )
875
+ responseError = mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
876
+ } else {
877
+ try { result = deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision) }
878
+ catch (error) { responseError = error instanceof Error ? error : new Error(String(error)) }
819
879
  }
820
- return deserializePayload(response.payload?.data as Uint8Array | undefined)
821
880
  }
881
+ if (responseError) throw responseError
882
+ if (received) return result
822
883
 
823
884
  throw new Error(`No response for query "${queryName}"`)
824
885
  } finally {
886
+ deadline?.close()
887
+ admission?.end()
825
888
  activity.end()
826
889
  }
827
890
  },
@@ -843,29 +906,36 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
843
906
  unstamped: QueryMessage,
844
907
  bufferSize?: number,
845
908
  ): SubscriptionQueryResult {
909
+ if (shutdownLatch.shuttingDown) throw new Error("Messaging shutdown in progress")
910
+ if (subscriptions.size >= 1024) throw new Error("Subscription capacity 1024 exhausted")
846
911
  const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
847
912
  const queryId = message.identifier
848
913
  if (subscriptions.has(queryId)) {
849
914
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
850
915
  }
851
916
 
852
- const handler = updateHandler(message, bufferSize)
853
- subscriptions.set(queryId, handler)
917
+ const handler = updateHandler(message, bufferSize, () => subscriptions.delete(queryId))
854
918
 
855
919
  const queryName = qualifiedNameToString(message.name)
920
+ const serialized = serializePayload(queryName, message.payload)
856
921
  const subscriptionId = generateIdentifier()
857
922
 
858
923
  const outboundSub = outboundStream<any>()
859
924
 
925
+ const window = Math.min(1024, Math.max(256, Math.floor(bufferSize ?? 256)))
926
+ const refillBatch = Math.max(1, Math.floor(window / 4))
927
+ let consumedSinceRefill = 0
928
+ let subscriptionClosed = false
929
+
860
930
  outboundSub.send({
861
931
  subscribe: {
862
932
  subscriptionIdentifier: subscriptionId,
863
- numberOfPermits: BigInt(bufferSize ?? 256),
933
+ numberOfPermits: BigInt(window),
864
934
  queryRequest: {
865
935
  messageIdentifier: message.identifier,
866
936
  query: queryName,
867
937
  timestamp: BigInt(message.timestamp),
868
- payload: serializePayload(queryName, message.payload),
938
+ payload: serialized,
869
939
  metaData: metadataToProto(message.metadata),
870
940
  processingInstructions: defaultQueryInstructions(queryTimeoutMs),
871
941
  clientId: connection.config.clientId,
@@ -874,6 +944,10 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
874
944
  },
875
945
  })
876
946
 
947
+ // Subscribe does not grant update credits on Axon Server; a separate
948
+ // FlowControl frame initializes the subscription stream's update window.
949
+ outboundSub.send({ flowControl: { numberOfPermits: BigInt(window) } })
950
+
877
951
  outboundSub.send({
878
952
  getInitialResult: {
879
953
  subscriptionIdentifier: subscriptionId,
@@ -882,7 +956,7 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
882
956
  messageIdentifier: message.identifier,
883
957
  query: queryName,
884
958
  timestamp: BigInt(message.timestamp),
885
- payload: serializePayload(queryName, message.payload),
959
+ payload: serialized,
886
960
  metaData: metadataToProto(message.metadata),
887
961
  processingInstructions: defaultQueryInstructions(queryTimeoutMs),
888
962
  clientId: connection.config.clientId,
@@ -891,7 +965,9 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
891
965
  },
892
966
  })
893
967
 
894
- const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata })
968
+ const subscriptionController = new AbortController()
969
+ const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata, signal: subscriptionController.signal })
970
+ subscriptions.set(queryId, handler)
895
971
 
896
972
  let resolveInitial!: (value: unknown) => void
897
973
  let rejectInitial!: (error: Error) => void
@@ -900,70 +976,89 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
900
976
  rejectInitial = reject
901
977
  })
902
978
  let initialSettled = false
979
+ let explicitlyCompleted = false
980
+ const initialTimer = setTimeout(() => closeSubscription(new Error("Subscription initial result timed out")), requestTimeoutMs)
981
+ const removeShutdown = shutdownLatch.onShutdown(() => closeSubscription(new Error("Messaging shutdown in progress")))
982
+ // Callers may consume updates without awaiting the initial result. Keep
983
+ // the original promise rejectable without an unhandled rejection on close.
984
+ void initialResult.catch(() => {})
985
+
986
+ function closeSubscription(error?: Error) {
987
+ if (subscriptionClosed) return
988
+ subscriptionClosed = true
989
+ clearTimeout(initialTimer)
990
+ removeShutdown()
991
+ if (!initialSettled) {
992
+ rejectInitial(error ?? new Error("Subscription query closed before initial result"))
993
+ initialSettled = true
994
+ }
995
+ if (error) handler.completeExceptionally(error)
996
+ else handler.complete()
997
+ try { outboundSub.send({ unsubscribe: { subscriptionIdentifier: subscriptionId } }) } catch { /* Broken stream; local teardown still must finish. */ }
998
+ outboundSub.close()
999
+ subscriptionController.abort()
1000
+ subscriptions.delete(queryId)
1001
+ }
903
1002
 
904
- ;(async () => {
1003
+ void (async () => {
905
1004
  try {
906
1005
  for await (const response of responseStream) {
1006
+ if (subscriptionClosed) break
907
1007
  if (response.initialResult) {
908
1008
  const initial = response.initialResult
909
1009
  if (!initialSettled) {
910
- if (initial.errorCode && initial.errorCode !== "") {
911
- rejectInitial(
912
- mapErrorCode(
913
- initial.errorCode,
914
- initial.errorMessage?.message ?? "Unknown error",
915
- ),
916
- )
917
- } else {
918
- resolveInitial(
919
- deserializePayload(initial.payload?.data as Uint8Array | undefined),
920
- )
1010
+ if (initial.errorCode) {
1011
+ throw mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error")
921
1012
  }
1013
+ clearTimeout(initialTimer)
1014
+ resolveInitial(deserializePayload(initial.payload?.data, initial.payload?.type, initial.payload?.revision))
922
1015
  initialSettled = true
923
1016
  }
924
1017
  } else if (response.update) {
925
- const update = deserializePayload(
926
- response.update.payload?.data as Uint8Array | undefined,
927
- )
928
- handler.offer(update)
1018
+ const update = deserializePayload(response.update.payload?.data, response.update.payload?.type, response.update.payload?.revision)
1019
+ if (!handler.offer(update)) throw new Error("Subscription query update buffer overflow")
1020
+ consumedSinceRefill++
1021
+ if (consumedSinceRefill >= refillBatch) {
1022
+ outboundSub.send({
1023
+ flowControl: { subscriptionIdentifier: subscriptionId, numberOfPermits: BigInt(consumedSinceRefill) },
1024
+ })
1025
+ consumedSinceRefill = 0
1026
+ }
929
1027
  } else if (response.complete) {
930
- handler.complete()
1028
+ explicitlyCompleted = true
931
1029
  break
932
1030
  } else if (response.completeExceptionally) {
933
- handler.completeExceptionally(
934
- new Error(
935
- response.completeExceptionally.errorMessage?.message ??
936
- "Subscription query failed",
937
- ),
938
- )
939
- break
1031
+ throw new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed")
940
1032
  }
941
1033
  }
942
1034
  } catch (err) {
943
- const error = err instanceof Error ? err : new Error(String(err))
1035
+ closeSubscription(err instanceof Error ? err : new Error(String(err)))
1036
+ } finally {
1037
+ // EOF and completion frames must settle BOTH faces of a subscription.
1038
+ const missingInitial = !initialSettled
944
1039
  if (!initialSettled) {
945
- rejectInitial(error)
1040
+ rejectInitial(new Error("Subscription stream ended before initial result"))
946
1041
  initialSettled = true
947
1042
  }
948
- handler.completeExceptionally(error)
949
- } finally {
950
- subscriptions.delete(queryId)
1043
+ closeSubscription(!missingInitial && !explicitlyCompleted && !subscriptionClosed ? new Error("Subscription stream ended unexpectedly") : undefined)
951
1044
  }
952
1045
  })()
953
1046
 
954
1047
  return {
955
1048
  initialResult,
956
- updates: handler.iterable,
957
- close: () => {
958
- outboundSub.send({
959
- unsubscribe: {
960
- subscriptionIdentifier: subscriptionId,
961
- },
962
- })
963
- outboundSub.close()
964
- subscriptions.delete(queryId)
965
- handler.complete()
1049
+ updates: {
1050
+ [Symbol.asyncIterator]() {
1051
+ const iterator = handler.iterable[Symbol.asyncIterator]()
1052
+ return {
1053
+ next: () => iterator.next(),
1054
+ async return() {
1055
+ closeSubscription()
1056
+ return iterator.return ? iterator.return() : { value: undefined, done: true as const }
1057
+ },
1058
+ }
1059
+ },
966
1060
  },
1061
+ close: () => closeSubscription(),
967
1062
  }
968
1063
  },
969
1064
 
@@ -971,13 +1066,17 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
971
1066
  unstamped: QueryMessage,
972
1067
  bufferSize?: number,
973
1068
  ): AsyncIterable<unknown> & { close(): void } {
1069
+ if (shutdownLatch.shuttingDown) throw new Error("Messaging shutdown in progress")
1070
+ if (subscriptions.size >= 1024) throw new Error("Subscription capacity 1024 exhausted")
974
1071
  const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
975
1072
  const queryId = message.identifier
976
1073
  if (subscriptions.has(queryId)) {
977
1074
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
978
1075
  }
979
1076
 
980
- const handler = updateHandler(message, bufferSize)
1077
+ let removeShutdown: (() => void) | undefined
1078
+ const handler = updateHandler(message, bufferSize, () => { subscriptions.delete(queryId); removeShutdown?.() })
1079
+ removeShutdown = shutdownLatch.onShutdown(() => handler.completeExceptionally(new Error("Messaging shutdown in progress")))
981
1080
  subscriptions.set(queryId, handler)
982
1081
 
983
1082
  return {
@@ -993,6 +1092,7 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
993
1092
  queryName: string,
994
1093
  filter: SubscriptionFilter,
995
1094
  update: unknown,
1095
+ uow?: UnitOfWork,
996
1096
  ): Promise<void> {
997
1097
  runAfterCommitOrImmediately(() => {
998
1098
  for (const [subId, sub] of handlerSubscriptions) {
@@ -1017,10 +1117,10 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
1017
1117
  instructionId: "",
1018
1118
  })
1019
1119
  }
1020
- })
1120
+ }, uow)
1021
1121
  },
1022
1122
 
1023
- async completeSubscription(queryName: string, filter?: SubscriptionFilter): Promise<void> {
1123
+ async completeSubscription(queryName: string, filter?: SubscriptionFilter, uow?: UnitOfWork): Promise<void> {
1024
1124
  runAfterCommitOrImmediately(() => {
1025
1125
  for (const [subId, sub] of handlerSubscriptions) {
1026
1126
  if (sub.queryName !== queryName) continue
@@ -1039,13 +1139,14 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
1039
1139
  })
1040
1140
  handlerSubscriptions.delete(subId)
1041
1141
  }
1042
- })
1142
+ }, uow)
1043
1143
  },
1044
1144
 
1045
1145
  async completeSubscriptionExceptionally(
1046
1146
  queryName: string,
1047
1147
  error: Error,
1048
1148
  filter?: SubscriptionFilter,
1149
+ uow?: UnitOfWork,
1049
1150
  ): Promise<void> {
1050
1151
  runAfterCommitOrImmediately(() => {
1051
1152
  for (const [subId, sub] of handlerSubscriptions) {
@@ -1072,7 +1173,7 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
1072
1173
  })
1073
1174
  handlerSubscriptions.delete(subId)
1074
1175
  }
1075
- })
1176
+ }, uow)
1076
1177
  },
1077
1178
  }
1078
1179