@kronos-ts/axon-server 0.5.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 (63) 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 +444 -270
  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/shutdown-latch.d.ts +1 -0
  43. package/dist/shutdown-latch.d.ts.map +1 -1
  44. package/dist/shutdown-latch.js +20 -1
  45. package/dist/shutdown-latch.js.map +1 -1
  46. package/dist/stream-recovery.d.ts +9 -0
  47. package/dist/stream-recovery.d.ts.map +1 -0
  48. package/dist/stream-recovery.js +70 -0
  49. package/dist/stream-recovery.js.map +1 -0
  50. package/package.json +2 -2
  51. package/src/axon-server-event-store.ts +77 -79
  52. package/src/axon-server-snapshotting-event-store.ts +10 -7
  53. package/src/axon-server.ts +414 -311
  54. package/src/bounded-read.ts +43 -0
  55. package/src/connection.ts +77 -45
  56. package/src/control-plane.ts +19 -63
  57. package/src/event-processor-info.ts +22 -43
  58. package/src/flow-controlled-sender.ts +33 -14
  59. package/src/index.ts +0 -1
  60. package/src/outbound-stream.ts +57 -27
  61. package/src/platform-service.ts +75 -11
  62. package/src/shutdown-latch.ts +14 -1
  63. 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,191 +659,179 @@ 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> = {
827
+ const routing: SubscriptionCapableQueryBus<U> = {
778
828
  async query(unstamped: QueryMessage, uow?: UnitOfWork): 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
@@ -786,7 +840,7 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
786
840
  // in-process read: a live unit of work handed in by `ctx.query` is
787
841
  // reused so the consulting read shares the caller's transaction.
788
842
  if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
789
- return next.query(unstamped, uow)
843
+ return await next.query(unstamped, uow)
790
844
  }
791
845
 
792
846
  // A transport is not a task: it has no unit of work, so it has no clock.
@@ -807,21 +861,32 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
807
861
  clientId: connection.config.clientId,
808
862
  componentName: connection.config.componentName,
809
863
  },
810
- { metadata },
864
+ { metadata, signal: deadline.signal },
811
865
  )
812
866
 
867
+ // NR_OF_RESULTS is one. Drain trailers before returning so transport
868
+ // failures cannot be mistaken for a successful result. The RPC deadline
869
+ // also bounds a stream that sends a response but never completes.
870
+ let received = false
871
+ let result: unknown
872
+ let responseError: Error | undefined
813
873
  for await (const response of responseStream) {
874
+ if (received) continue
875
+ received = true
814
876
  if (response.errorCode && response.errorCode !== "") {
815
- throw mapErrorCode(
816
- response.errorCode,
817
- response.errorMessage?.message ?? "Unknown error",
818
- )
877
+ responseError = mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
878
+ } else {
879
+ try { result = deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision) }
880
+ catch (error) { responseError = error instanceof Error ? error : new Error(String(error)) }
819
881
  }
820
- return deserializePayload(response.payload?.data as Uint8Array | undefined)
821
882
  }
883
+ if (responseError) throw responseError
884
+ if (received) return result
822
885
 
823
886
  throw new Error(`No response for query "${queryName}"`)
824
887
  } finally {
888
+ deadline?.close()
889
+ admission?.end()
825
890
  activity.end()
826
891
  }
827
892
  },
@@ -843,29 +908,36 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
843
908
  unstamped: QueryMessage,
844
909
  bufferSize?: number,
845
910
  ): SubscriptionQueryResult {
911
+ if (shutdownLatch.shuttingDown) throw new Error("Messaging shutdown in progress")
912
+ if (subscriptions.size >= 1024) throw new Error("Subscription capacity 1024 exhausted")
846
913
  const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
847
914
  const queryId = message.identifier
848
915
  if (subscriptions.has(queryId)) {
849
916
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
850
917
  }
851
918
 
852
- const handler = updateHandler(message, bufferSize)
853
- subscriptions.set(queryId, handler)
919
+ const handler = updateHandler(message, bufferSize, () => subscriptions.delete(queryId))
854
920
 
855
921
  const queryName = qualifiedNameToString(message.name)
922
+ const serialized = serializePayload(queryName, message.payload)
856
923
  const subscriptionId = generateIdentifier()
857
924
 
858
925
  const outboundSub = outboundStream<any>()
859
926
 
927
+ const window = Math.min(1024, Math.max(256, Math.floor(bufferSize ?? 256)))
928
+ const refillBatch = Math.max(1, Math.floor(window / 4))
929
+ let consumedSinceRefill = 0
930
+ let subscriptionClosed = false
931
+
860
932
  outboundSub.send({
861
933
  subscribe: {
862
934
  subscriptionIdentifier: subscriptionId,
863
- numberOfPermits: BigInt(bufferSize ?? 256),
935
+ numberOfPermits: BigInt(window),
864
936
  queryRequest: {
865
937
  messageIdentifier: message.identifier,
866
938
  query: queryName,
867
939
  timestamp: BigInt(message.timestamp),
868
- payload: serializePayload(queryName, message.payload),
940
+ payload: serialized,
869
941
  metaData: metadataToProto(message.metadata),
870
942
  processingInstructions: defaultQueryInstructions(queryTimeoutMs),
871
943
  clientId: connection.config.clientId,
@@ -874,6 +946,10 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
874
946
  },
875
947
  })
876
948
 
949
+ // Subscribe does not grant update credits on Axon Server; a separate
950
+ // FlowControl frame initializes the subscription stream's update window.
951
+ outboundSub.send({ flowControl: { numberOfPermits: BigInt(window) } })
952
+
877
953
  outboundSub.send({
878
954
  getInitialResult: {
879
955
  subscriptionIdentifier: subscriptionId,
@@ -882,7 +958,7 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
882
958
  messageIdentifier: message.identifier,
883
959
  query: queryName,
884
960
  timestamp: BigInt(message.timestamp),
885
- payload: serializePayload(queryName, message.payload),
961
+ payload: serialized,
886
962
  metaData: metadataToProto(message.metadata),
887
963
  processingInstructions: defaultQueryInstructions(queryTimeoutMs),
888
964
  clientId: connection.config.clientId,
@@ -891,7 +967,9 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
891
967
  },
892
968
  })
893
969
 
894
- const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata })
970
+ const subscriptionController = new AbortController()
971
+ const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata, signal: subscriptionController.signal })
972
+ subscriptions.set(queryId, handler)
895
973
 
896
974
  let resolveInitial!: (value: unknown) => void
897
975
  let rejectInitial!: (error: Error) => void
@@ -900,70 +978,89 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
900
978
  rejectInitial = reject
901
979
  })
902
980
  let initialSettled = false
981
+ let explicitlyCompleted = false
982
+ const initialTimer = setTimeout(() => closeSubscription(new Error("Subscription initial result timed out")), requestTimeoutMs)
983
+ const removeShutdown = shutdownLatch.onShutdown(() => closeSubscription(new Error("Messaging shutdown in progress")))
984
+ // Callers may consume updates without awaiting the initial result. Keep
985
+ // the original promise rejectable without an unhandled rejection on close.
986
+ void initialResult.catch(() => {})
987
+
988
+ function closeSubscription(error?: Error) {
989
+ if (subscriptionClosed) return
990
+ subscriptionClosed = true
991
+ clearTimeout(initialTimer)
992
+ removeShutdown()
993
+ if (!initialSettled) {
994
+ rejectInitial(error ?? new Error("Subscription query closed before initial result"))
995
+ initialSettled = true
996
+ }
997
+ if (error) handler.completeExceptionally(error)
998
+ else handler.complete()
999
+ try { outboundSub.send({ unsubscribe: { subscriptionIdentifier: subscriptionId } }) } catch { /* Broken stream; local teardown still must finish. */ }
1000
+ outboundSub.close()
1001
+ subscriptionController.abort()
1002
+ subscriptions.delete(queryId)
1003
+ }
903
1004
 
904
- ;(async () => {
1005
+ void (async () => {
905
1006
  try {
906
1007
  for await (const response of responseStream) {
1008
+ if (subscriptionClosed) break
907
1009
  if (response.initialResult) {
908
1010
  const initial = response.initialResult
909
1011
  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
- )
1012
+ if (initial.errorCode) {
1013
+ throw mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error")
921
1014
  }
1015
+ clearTimeout(initialTimer)
1016
+ resolveInitial(deserializePayload(initial.payload?.data, initial.payload?.type, initial.payload?.revision))
922
1017
  initialSettled = true
923
1018
  }
924
1019
  } else if (response.update) {
925
- const update = deserializePayload(
926
- response.update.payload?.data as Uint8Array | undefined,
927
- )
928
- handler.offer(update)
1020
+ const update = deserializePayload(response.update.payload?.data, response.update.payload?.type, response.update.payload?.revision)
1021
+ if (!handler.offer(update)) throw new Error("Subscription query update buffer overflow")
1022
+ consumedSinceRefill++
1023
+ if (consumedSinceRefill >= refillBatch) {
1024
+ outboundSub.send({
1025
+ flowControl: { subscriptionIdentifier: subscriptionId, numberOfPermits: BigInt(consumedSinceRefill) },
1026
+ })
1027
+ consumedSinceRefill = 0
1028
+ }
929
1029
  } else if (response.complete) {
930
- handler.complete()
1030
+ explicitlyCompleted = true
931
1031
  break
932
1032
  } else if (response.completeExceptionally) {
933
- handler.completeExceptionally(
934
- new Error(
935
- response.completeExceptionally.errorMessage?.message ??
936
- "Subscription query failed",
937
- ),
938
- )
939
- break
1033
+ throw new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed")
940
1034
  }
941
1035
  }
942
1036
  } catch (err) {
943
- const error = err instanceof Error ? err : new Error(String(err))
1037
+ closeSubscription(err instanceof Error ? err : new Error(String(err)))
1038
+ } finally {
1039
+ // EOF and completion frames must settle BOTH faces of a subscription.
1040
+ const missingInitial = !initialSettled
944
1041
  if (!initialSettled) {
945
- rejectInitial(error)
1042
+ rejectInitial(new Error("Subscription stream ended before initial result"))
946
1043
  initialSettled = true
947
1044
  }
948
- handler.completeExceptionally(error)
949
- } finally {
950
- subscriptions.delete(queryId)
1045
+ closeSubscription(!missingInitial && !explicitlyCompleted && !subscriptionClosed ? new Error("Subscription stream ended unexpectedly") : undefined)
951
1046
  }
952
1047
  })()
953
1048
 
954
1049
  return {
955
1050
  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()
1051
+ updates: {
1052
+ [Symbol.asyncIterator]() {
1053
+ const iterator = handler.iterable[Symbol.asyncIterator]()
1054
+ return {
1055
+ next: () => iterator.next(),
1056
+ async return() {
1057
+ closeSubscription()
1058
+ return iterator.return ? iterator.return() : { value: undefined, done: true as const }
1059
+ },
1060
+ }
1061
+ },
966
1062
  },
1063
+ close: () => closeSubscription(),
967
1064
  }
968
1065
  },
969
1066
 
@@ -971,13 +1068,17 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
971
1068
  unstamped: QueryMessage,
972
1069
  bufferSize?: number,
973
1070
  ): AsyncIterable<unknown> & { close(): void } {
1071
+ if (shutdownLatch.shuttingDown) throw new Error("Messaging shutdown in progress")
1072
+ if (subscriptions.size >= 1024) throw new Error("Subscription capacity 1024 exhausted")
974
1073
  const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
975
1074
  const queryId = message.identifier
976
1075
  if (subscriptions.has(queryId)) {
977
1076
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
978
1077
  }
979
1078
 
980
- const handler = updateHandler(message, bufferSize)
1079
+ let removeShutdown: (() => void) | undefined
1080
+ const handler = updateHandler(message, bufferSize, () => { subscriptions.delete(queryId); removeShutdown?.() })
1081
+ removeShutdown = shutdownLatch.onShutdown(() => handler.completeExceptionally(new Error("Messaging shutdown in progress")))
981
1082
  subscriptions.set(queryId, handler)
982
1083
 
983
1084
  return {
@@ -993,6 +1094,7 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
993
1094
  queryName: string,
994
1095
  filter: SubscriptionFilter,
995
1096
  update: unknown,
1097
+ uow?: UnitOfWork,
996
1098
  ): Promise<void> {
997
1099
  runAfterCommitOrImmediately(() => {
998
1100
  for (const [subId, sub] of handlerSubscriptions) {
@@ -1017,10 +1119,10 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
1017
1119
  instructionId: "",
1018
1120
  })
1019
1121
  }
1020
- })
1122
+ }, uow)
1021
1123
  },
1022
1124
 
1023
- async completeSubscription(queryName: string, filter?: SubscriptionFilter): Promise<void> {
1125
+ async completeSubscription(queryName: string, filter?: SubscriptionFilter, uow?: UnitOfWork): Promise<void> {
1024
1126
  runAfterCommitOrImmediately(() => {
1025
1127
  for (const [subId, sub] of handlerSubscriptions) {
1026
1128
  if (sub.queryName !== queryName) continue
@@ -1039,13 +1141,14 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
1039
1141
  })
1040
1142
  handlerSubscriptions.delete(subId)
1041
1143
  }
1042
- })
1144
+ }, uow)
1043
1145
  },
1044
1146
 
1045
1147
  async completeSubscriptionExceptionally(
1046
1148
  queryName: string,
1047
1149
  error: Error,
1048
1150
  filter?: SubscriptionFilter,
1151
+ uow?: UnitOfWork,
1049
1152
  ): Promise<void> {
1050
1153
  runAfterCommitOrImmediately(() => {
1051
1154
  for (const [subId, sub] of handlerSubscriptions) {
@@ -1072,7 +1175,7 @@ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
1072
1175
  })
1073
1176
  handlerSubscriptions.delete(subId)
1074
1177
  }
1075
- })
1178
+ }, uow)
1076
1179
  },
1077
1180
  }
1078
1181