@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.
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +72 -77
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshotting-event-store.d.ts +3 -3
- package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -1
- package/dist/axon-server-snapshotting-event-store.js +3 -2
- package/dist/axon-server-snapshotting-event-store.js.map +1 -1
- package/dist/axon-server.d.ts +15 -7
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +447 -275
- package/dist/axon-server.js.map +1 -1
- package/dist/bounded-read.d.ts +19 -0
- package/dist/bounded-read.d.ts.map +1 -0
- package/dist/bounded-read.js +39 -0
- package/dist/bounded-read.js.map +1 -0
- package/dist/connection.d.ts +13 -0
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +78 -40
- package/dist/connection.js.map +1 -1
- package/dist/control-plane.d.ts +9 -19
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +6 -37
- package/dist/control-plane.js.map +1 -1
- package/dist/event-processor-info.d.ts +5 -23
- package/dist/event-processor-info.d.ts.map +1 -1
- package/dist/event-processor-info.js +16 -20
- package/dist/event-processor-info.js.map +1 -1
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/flow-controlled-sender.js +37 -17
- package/dist/flow-controlled-sender.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outbound-stream.d.ts +5 -9
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/outbound-stream.js +65 -16
- package/dist/outbound-stream.js.map +1 -1
- package/dist/platform-service.d.ts +10 -1
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/platform-service.js +70 -10
- package/dist/platform-service.js.map +1 -1
- package/dist/resilience.d.ts +2 -0
- package/dist/resilience.d.ts.map +1 -1
- package/dist/resilience.js +2 -1
- package/dist/resilience.js.map +1 -1
- package/dist/shutdown-latch.d.ts +1 -0
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js +20 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/dist/stream-recovery.d.ts +9 -0
- package/dist/stream-recovery.d.ts.map +1 -0
- package/dist/stream-recovery.js +70 -0
- package/dist/stream-recovery.js.map +1 -0
- package/package.json +2 -2
- package/src/axon-server-event-store.ts +77 -79
- package/src/axon-server-snapshotting-event-store.ts +10 -7
- package/src/axon-server.ts +417 -316
- package/src/bounded-read.ts +43 -0
- package/src/connection.ts +77 -45
- package/src/control-plane.ts +19 -63
- package/src/event-processor-info.ts +22 -43
- package/src/flow-controlled-sender.ts +33 -14
- package/src/index.ts +0 -1
- package/src/outbound-stream.ts +57 -27
- package/src/platform-service.ts +75 -11
- package/src/resilience.ts +6 -2
- package/src/shutdown-latch.ts +14 -1
- package/src/stream-recovery.ts +72 -0
package/src/axon-server.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
341
|
-
console.error("Axon Server command bus: reconnect retries exhausted", err)
|
|
342
|
-
})
|
|
358
|
+
recovery.restart()
|
|
343
359
|
}
|
|
344
360
|
})
|
|
345
361
|
|
|
346
|
-
async function
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 "${
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 (
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
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
|
-
):
|
|
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(
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
608
|
-
console.error("Axon Server query bus: reconnect retries exhausted", err)
|
|
609
|
-
})
|
|
670
|
+
recovery.restart()
|
|
610
671
|
}
|
|
611
672
|
})
|
|
612
673
|
|
|
613
|
-
async function
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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(
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
}
|
|
648
|
-
errorCode = AxonServerErrorCode.
|
|
649
|
-
errorMsg =
|
|
713
|
+
} catch (err) {
|
|
714
|
+
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
715
|
+
errorMsg = err instanceof Error ? err.message : String(err)
|
|
650
716
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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 (
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
727
|
-
|
|
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 (
|
|
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:
|
|
778
|
-
async query(unstamped: QueryMessage
|
|
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
|
-
//
|
|
785
|
-
//
|
|
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
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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(
|
|
933
|
+
numberOfPermits: BigInt(window),
|
|
864
934
|
queryRequest: {
|
|
865
935
|
messageIdentifier: message.identifier,
|
|
866
936
|
query: queryName,
|
|
867
937
|
timestamp: BigInt(message.timestamp),
|
|
868
|
-
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
911
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
1028
|
+
explicitlyCompleted = true
|
|
931
1029
|
break
|
|
932
1030
|
} else if (response.completeExceptionally) {
|
|
933
|
-
|
|
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
|
-
|
|
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(
|
|
1040
|
+
rejectInitial(new Error("Subscription stream ended before initial result"))
|
|
946
1041
|
initialSettled = true
|
|
947
1042
|
}
|
|
948
|
-
|
|
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:
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
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
|
|