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