@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.
- 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 +444 -270
- 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/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 +414 -311
- 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/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,191 +659,179 @@ 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:
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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(
|
|
935
|
+
numberOfPermits: BigInt(window),
|
|
864
936
|
queryRequest: {
|
|
865
937
|
messageIdentifier: message.identifier,
|
|
866
938
|
query: queryName,
|
|
867
939
|
timestamp: BigInt(message.timestamp),
|
|
868
|
-
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
)
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
1030
|
+
explicitlyCompleted = true
|
|
931
1031
|
break
|
|
932
1032
|
} else if (response.completeExceptionally) {
|
|
933
|
-
|
|
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
|
-
|
|
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(
|
|
1042
|
+
rejectInitial(new Error("Subscription stream ended before initial result"))
|
|
946
1043
|
initialSettled = true
|
|
947
1044
|
}
|
|
948
|
-
|
|
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:
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
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
|
|