@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/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,175 +508,202 @@ 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
700
|
async query(unstamped, uow) {
|
|
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
709
|
// caller's unit of work is passed straight through, so `next` makes the
|
|
@@ -616,7 +711,7 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
616
711
|
// in-process read: a live unit of work handed in by `ctx.query` is
|
|
617
712
|
// reused so the consulting read shares the caller's transaction.
|
|
618
713
|
if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
|
|
619
|
-
return next.query(unstamped, uow);
|
|
714
|
+
return await next.query(unstamped, uow);
|
|
620
715
|
}
|
|
621
716
|
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
622
717
|
// A message that reaches the wire with no instant yet gets one from system
|
|
@@ -633,16 +728,38 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
633
728
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
634
729
|
clientId: connection.config.clientId,
|
|
635
730
|
componentName: connection.config.componentName,
|
|
636
|
-
}, { metadata });
|
|
731
|
+
}, { metadata, signal: deadline.signal });
|
|
732
|
+
// NR_OF_RESULTS is one. Drain trailers before returning so transport
|
|
733
|
+
// failures cannot be mistaken for a successful result. The RPC deadline
|
|
734
|
+
// also bounds a stream that sends a response but never completes.
|
|
735
|
+
let received = false;
|
|
736
|
+
let result;
|
|
737
|
+
let responseError;
|
|
637
738
|
for await (const response of responseStream) {
|
|
739
|
+
if (received)
|
|
740
|
+
continue;
|
|
741
|
+
received = true;
|
|
638
742
|
if (response.errorCode && response.errorCode !== "") {
|
|
639
|
-
|
|
743
|
+
responseError = mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error");
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
try {
|
|
747
|
+
result = deserializePayload(response.payload?.data, response.payload?.type, response.payload?.revision);
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
responseError = error instanceof Error ? error : new Error(String(error));
|
|
751
|
+
}
|
|
640
752
|
}
|
|
641
|
-
return deserializePayload(response.payload?.data);
|
|
642
753
|
}
|
|
754
|
+
if (responseError)
|
|
755
|
+
throw responseError;
|
|
756
|
+
if (received)
|
|
757
|
+
return result;
|
|
643
758
|
throw new Error(`No response for query "${queryName}"`);
|
|
644
759
|
}
|
|
645
760
|
finally {
|
|
761
|
+
deadline?.close();
|
|
762
|
+
admission?.end();
|
|
646
763
|
activity.end();
|
|
647
764
|
}
|
|
648
765
|
},
|
|
@@ -655,25 +772,33 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
655
772
|
grantQueryPermits();
|
|
656
773
|
},
|
|
657
774
|
subscriptionQuery(unstamped, bufferSize) {
|
|
775
|
+
if (shutdownLatch.shuttingDown)
|
|
776
|
+
throw new Error("Messaging shutdown in progress");
|
|
777
|
+
if (subscriptions.size >= 1024)
|
|
778
|
+
throw new Error("Subscription capacity 1024 exhausted");
|
|
658
779
|
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
|
|
659
780
|
const queryId = message.identifier;
|
|
660
781
|
if (subscriptions.has(queryId)) {
|
|
661
782
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`);
|
|
662
783
|
}
|
|
663
|
-
const handler = updateHandler(message, bufferSize);
|
|
664
|
-
subscriptions.set(queryId, handler);
|
|
784
|
+
const handler = updateHandler(message, bufferSize, () => subscriptions.delete(queryId));
|
|
665
785
|
const queryName = qualifiedNameToString(message.name);
|
|
786
|
+
const serialized = serializePayload(queryName, message.payload);
|
|
666
787
|
const subscriptionId = generateIdentifier();
|
|
667
788
|
const outboundSub = outboundStream();
|
|
789
|
+
const window = Math.min(1024, Math.max(256, Math.floor(bufferSize ?? 256)));
|
|
790
|
+
const refillBatch = Math.max(1, Math.floor(window / 4));
|
|
791
|
+
let consumedSinceRefill = 0;
|
|
792
|
+
let subscriptionClosed = false;
|
|
668
793
|
outboundSub.send({
|
|
669
794
|
subscribe: {
|
|
670
795
|
subscriptionIdentifier: subscriptionId,
|
|
671
|
-
numberOfPermits: BigInt(
|
|
796
|
+
numberOfPermits: BigInt(window),
|
|
672
797
|
queryRequest: {
|
|
673
798
|
messageIdentifier: message.identifier,
|
|
674
799
|
query: queryName,
|
|
675
800
|
timestamp: BigInt(message.timestamp),
|
|
676
|
-
payload:
|
|
801
|
+
payload: serialized,
|
|
677
802
|
metaData: metadataToProto(message.metadata),
|
|
678
803
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
679
804
|
clientId: connection.config.clientId,
|
|
@@ -681,6 +806,9 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
681
806
|
},
|
|
682
807
|
},
|
|
683
808
|
});
|
|
809
|
+
// Subscribe does not grant update credits on Axon Server; a separate
|
|
810
|
+
// FlowControl frame initializes the subscription stream's update window.
|
|
811
|
+
outboundSub.send({ flowControl: { numberOfPermits: BigInt(window) } });
|
|
684
812
|
outboundSub.send({
|
|
685
813
|
getInitialResult: {
|
|
686
814
|
subscriptionIdentifier: subscriptionId,
|
|
@@ -689,7 +817,7 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
689
817
|
messageIdentifier: message.identifier,
|
|
690
818
|
query: queryName,
|
|
691
819
|
timestamp: BigInt(message.timestamp),
|
|
692
|
-
payload:
|
|
820
|
+
payload: serialized,
|
|
693
821
|
metaData: metadataToProto(message.metadata),
|
|
694
822
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
695
823
|
clientId: connection.config.clientId,
|
|
@@ -697,7 +825,9 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
697
825
|
},
|
|
698
826
|
},
|
|
699
827
|
});
|
|
700
|
-
const
|
|
828
|
+
const subscriptionController = new AbortController();
|
|
829
|
+
const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata, signal: subscriptionController.signal });
|
|
830
|
+
subscriptions.set(queryId, handler);
|
|
701
831
|
let resolveInitial;
|
|
702
832
|
let rejectInitial;
|
|
703
833
|
const initialResult = new Promise((resolve, reject) => {
|
|
@@ -705,70 +835,114 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
705
835
|
rejectInitial = reject;
|
|
706
836
|
});
|
|
707
837
|
let initialSettled = false;
|
|
708
|
-
|
|
838
|
+
let explicitlyCompleted = false;
|
|
839
|
+
const initialTimer = setTimeout(() => closeSubscription(new Error("Subscription initial result timed out")), requestTimeoutMs);
|
|
840
|
+
const removeShutdown = shutdownLatch.onShutdown(() => closeSubscription(new Error("Messaging shutdown in progress")));
|
|
841
|
+
// Callers may consume updates without awaiting the initial result. Keep
|
|
842
|
+
// the original promise rejectable without an unhandled rejection on close.
|
|
843
|
+
void initialResult.catch(() => { });
|
|
844
|
+
function closeSubscription(error) {
|
|
845
|
+
if (subscriptionClosed)
|
|
846
|
+
return;
|
|
847
|
+
subscriptionClosed = true;
|
|
848
|
+
clearTimeout(initialTimer);
|
|
849
|
+
removeShutdown();
|
|
850
|
+
if (!initialSettled) {
|
|
851
|
+
rejectInitial(error ?? new Error("Subscription query closed before initial result"));
|
|
852
|
+
initialSettled = true;
|
|
853
|
+
}
|
|
854
|
+
if (error)
|
|
855
|
+
handler.completeExceptionally(error);
|
|
856
|
+
else
|
|
857
|
+
handler.complete();
|
|
858
|
+
try {
|
|
859
|
+
outboundSub.send({ unsubscribe: { subscriptionIdentifier: subscriptionId } });
|
|
860
|
+
}
|
|
861
|
+
catch { /* Broken stream; local teardown still must finish. */ }
|
|
862
|
+
outboundSub.close();
|
|
863
|
+
subscriptionController.abort();
|
|
864
|
+
subscriptions.delete(queryId);
|
|
865
|
+
}
|
|
866
|
+
void (async () => {
|
|
709
867
|
try {
|
|
710
868
|
for await (const response of responseStream) {
|
|
869
|
+
if (subscriptionClosed)
|
|
870
|
+
break;
|
|
711
871
|
if (response.initialResult) {
|
|
712
872
|
const initial = response.initialResult;
|
|
713
873
|
if (!initialSettled) {
|
|
714
|
-
if (initial.errorCode
|
|
715
|
-
|
|
716
|
-
}
|
|
717
|
-
else {
|
|
718
|
-
resolveInitial(deserializePayload(initial.payload?.data));
|
|
874
|
+
if (initial.errorCode) {
|
|
875
|
+
throw mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error");
|
|
719
876
|
}
|
|
877
|
+
clearTimeout(initialTimer);
|
|
878
|
+
resolveInitial(deserializePayload(initial.payload?.data, initial.payload?.type, initial.payload?.revision));
|
|
720
879
|
initialSettled = true;
|
|
721
880
|
}
|
|
722
881
|
}
|
|
723
882
|
else if (response.update) {
|
|
724
|
-
const update = deserializePayload(response.update.payload?.data);
|
|
725
|
-
handler.offer(update)
|
|
883
|
+
const update = deserializePayload(response.update.payload?.data, response.update.payload?.type, response.update.payload?.revision);
|
|
884
|
+
if (!handler.offer(update))
|
|
885
|
+
throw new Error("Subscription query update buffer overflow");
|
|
886
|
+
consumedSinceRefill++;
|
|
887
|
+
if (consumedSinceRefill >= refillBatch) {
|
|
888
|
+
outboundSub.send({
|
|
889
|
+
flowControl: { subscriptionIdentifier: subscriptionId, numberOfPermits: BigInt(consumedSinceRefill) },
|
|
890
|
+
});
|
|
891
|
+
consumedSinceRefill = 0;
|
|
892
|
+
}
|
|
726
893
|
}
|
|
727
894
|
else if (response.complete) {
|
|
728
|
-
|
|
895
|
+
explicitlyCompleted = true;
|
|
729
896
|
break;
|
|
730
897
|
}
|
|
731
898
|
else if (response.completeExceptionally) {
|
|
732
|
-
|
|
733
|
-
"Subscription query failed"));
|
|
734
|
-
break;
|
|
899
|
+
throw new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed");
|
|
735
900
|
}
|
|
736
901
|
}
|
|
737
902
|
}
|
|
738
903
|
catch (err) {
|
|
739
|
-
|
|
904
|
+
closeSubscription(err instanceof Error ? err : new Error(String(err)));
|
|
905
|
+
}
|
|
906
|
+
finally {
|
|
907
|
+
// EOF and completion frames must settle BOTH faces of a subscription.
|
|
908
|
+
const missingInitial = !initialSettled;
|
|
740
909
|
if (!initialSettled) {
|
|
741
|
-
rejectInitial(
|
|
910
|
+
rejectInitial(new Error("Subscription stream ended before initial result"));
|
|
742
911
|
initialSettled = true;
|
|
743
912
|
}
|
|
744
|
-
|
|
745
|
-
}
|
|
746
|
-
finally {
|
|
747
|
-
subscriptions.delete(queryId);
|
|
913
|
+
closeSubscription(!missingInitial && !explicitlyCompleted && !subscriptionClosed ? new Error("Subscription stream ended unexpectedly") : undefined);
|
|
748
914
|
}
|
|
749
915
|
})();
|
|
750
916
|
return {
|
|
751
917
|
initialResult,
|
|
752
|
-
updates:
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
918
|
+
updates: {
|
|
919
|
+
[Symbol.asyncIterator]() {
|
|
920
|
+
const iterator = handler.iterable[Symbol.asyncIterator]();
|
|
921
|
+
return {
|
|
922
|
+
next: () => iterator.next(),
|
|
923
|
+
async return() {
|
|
924
|
+
closeSubscription();
|
|
925
|
+
return iterator.return ? iterator.return() : { value: undefined, done: true };
|
|
926
|
+
},
|
|
927
|
+
};
|
|
928
|
+
},
|
|
762
929
|
},
|
|
930
|
+
close: () => closeSubscription(),
|
|
763
931
|
};
|
|
764
932
|
},
|
|
765
933
|
subscribeToUpdates(unstamped, bufferSize) {
|
|
934
|
+
if (shutdownLatch.shuttingDown)
|
|
935
|
+
throw new Error("Messaging shutdown in progress");
|
|
936
|
+
if (subscriptions.size >= 1024)
|
|
937
|
+
throw new Error("Subscription capacity 1024 exhausted");
|
|
766
938
|
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
|
|
767
939
|
const queryId = message.identifier;
|
|
768
940
|
if (subscriptions.has(queryId)) {
|
|
769
941
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`);
|
|
770
942
|
}
|
|
771
|
-
|
|
943
|
+
let removeShutdown;
|
|
944
|
+
const handler = updateHandler(message, bufferSize, () => { subscriptions.delete(queryId); removeShutdown?.(); });
|
|
945
|
+
removeShutdown = shutdownLatch.onShutdown(() => handler.completeExceptionally(new Error("Messaging shutdown in progress")));
|
|
772
946
|
subscriptions.set(queryId, handler);
|
|
773
947
|
return {
|
|
774
948
|
[Symbol.asyncIterator]: () => handler.iterable[Symbol.asyncIterator](),
|
|
@@ -778,7 +952,7 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
778
952
|
},
|
|
779
953
|
};
|
|
780
954
|
},
|
|
781
|
-
async emitUpdate(queryName, filter, update) {
|
|
955
|
+
async emitUpdate(queryName, filter, update, uow) {
|
|
782
956
|
runAfterCommitOrImmediately(() => {
|
|
783
957
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
784
958
|
if (sub.queryName !== queryName)
|
|
@@ -803,9 +977,9 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
803
977
|
instructionId: "",
|
|
804
978
|
});
|
|
805
979
|
}
|
|
806
|
-
});
|
|
980
|
+
}, uow);
|
|
807
981
|
},
|
|
808
|
-
async completeSubscription(queryName, filter) {
|
|
982
|
+
async completeSubscription(queryName, filter, uow) {
|
|
809
983
|
runAfterCommitOrImmediately(() => {
|
|
810
984
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
811
985
|
if (sub.queryName !== queryName)
|
|
@@ -825,9 +999,9 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
825
999
|
});
|
|
826
1000
|
handlerSubscriptions.delete(subId);
|
|
827
1001
|
}
|
|
828
|
-
});
|
|
1002
|
+
}, uow);
|
|
829
1003
|
},
|
|
830
|
-
async completeSubscriptionExceptionally(queryName, error, filter) {
|
|
1004
|
+
async completeSubscriptionExceptionally(queryName, error, filter, uow) {
|
|
831
1005
|
runAfterCommitOrImmediately(() => {
|
|
832
1006
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
833
1007
|
if (sub.queryName !== queryName)
|
|
@@ -854,7 +1028,7 @@ export function axonServerQueryBus(next, conn, options = {}) {
|
|
|
854
1028
|
});
|
|
855
1029
|
handlerSubscriptions.delete(subId);
|
|
856
1030
|
}
|
|
857
|
-
});
|
|
1031
|
+
}, uow);
|
|
858
1032
|
},
|
|
859
1033
|
};
|
|
860
1034
|
return routing;
|