@kronos-ts/axon-server 0.4.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 +0 -16
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +73 -45
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshotting-event-store.d.ts +30 -0
- package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -0
- package/dist/axon-server-snapshotting-event-store.js +154 -0
- package/dist/axon-server-snapshotting-event-store.js.map +1 -0
- package/dist/axon-server.d.ts +46 -65
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +490 -317
- 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-manager.d.ts +2 -2
- package/dist/connection-manager.d.ts.map +1 -1
- package/dist/connection.d.ts +36 -21
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +84 -45
- package/dist/connection.js.map +1 -1
- package/dist/context-view.d.ts +2 -2
- package/dist/context-view.d.ts.map +1 -1
- package/dist/control-plane.d.ts +14 -24
- 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 +7 -25
- 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 +2 -2
- 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/generated/command.d.ts +20 -20
- package/dist/generated/command.d.ts.map +1 -1
- package/dist/generated/common.d.ts +20 -20
- package/dist/generated/common.d.ts.map +1 -1
- package/dist/generated/control.d.ts +36 -40
- package/dist/generated/control.d.ts.map +1 -1
- package/dist/generated/dcb.d.ts +88 -94
- package/dist/generated/dcb.d.ts.map +1 -1
- package/dist/generated/event.d.ts +81 -84
- package/dist/generated/event.d.ts.map +1 -1
- package/dist/generated/google/protobuf/empty.d.ts +3 -4
- package/dist/generated/google/protobuf/empty.d.ts.map +1 -1
- package/dist/generated/query.d.ts +40 -40
- package/dist/generated/query.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/message-size.d.ts +2 -2
- package/dist/message-size.d.ts.map +1 -1
- package/dist/outbound-stream.d.ts +7 -11
- 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 +14 -5
- 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 +62 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +103 -0
- package/dist/resilience.js.map +1 -0
- package/dist/shutdown-latch.d.ts +5 -4
- 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 +3 -3
- package/src/axon-server-event-store.ts +77 -47
- package/src/axon-server-snapshotting-event-store.ts +194 -0
- package/src/axon-server.ts +472 -374
- package/src/bounded-read.ts +43 -0
- package/src/connection-manager.ts +1 -1
- package/src/connection.ts +93 -59
- package/src/context-view.ts +1 -1
- package/src/control-plane.ts +22 -66
- package/src/event-processor-info.ts +23 -44
- package/src/flow-controlled-sender.ts +34 -15
- package/src/generated/command.ts +10 -10
- package/src/generated/common.ts +10 -10
- package/src/generated/control.ts +20 -20
- package/src/generated/dcb.ts +47 -47
- package/src/generated/event.ts +42 -42
- package/src/generated/google/protobuf/empty.ts +2 -2
- package/src/generated/query.ts +20 -20
- package/src/index.ts +1 -2
- package/src/message-size.ts +1 -1
- package/src/outbound-stream.ts +58 -28
- package/src/platform-service.ts +77 -13
- package/src/resilience.ts +149 -0
- package/src/shutdown-latch.ts +16 -3
- package/src/stream-recovery.ts +72 -0
- package/dist/axon-server-snapshot-store.d.ts +0 -12
- package/dist/axon-server-snapshot-store.d.ts.map +0 -1
- package/dist/axon-server-snapshot-store.js +0 -82
- package/dist/axon-server-snapshot-store.js.map +0 -1
- package/src/axon-server-snapshot-store.ts +0 -110
package/dist/axon-server.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
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
|
*
|
|
4
6
|
* Axon Server is a SMART HUB: outbound dispatch always goes to the server, and
|
|
5
7
|
* the server decides which node handles it — there is no client-side
|
|
6
|
-
* prefer-
|
|
8
|
+
* prefer-next fork here, which is the whole difference from the dumb-pipe
|
|
7
9
|
* broker in `@kronos-ts/rabbitmq`.
|
|
8
10
|
*
|
|
9
|
-
* Both buses are plain functions over the shared connection and YOUR
|
|
11
|
+
* Both buses are plain functions over the shared connection and YOUR next bus:
|
|
10
12
|
*
|
|
11
13
|
* ```ts
|
|
12
14
|
* const commandBus = interceptingCommandBus(
|
|
13
|
-
* axonServerCommandBus(
|
|
15
|
+
* axonServerCommandBus(localCommandBus(unitOfWork), axon), correlation)
|
|
14
16
|
* const queryBus = interceptingQueryBus(
|
|
15
|
-
* axonServerQueryBus(
|
|
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(...)`;
|
|
@@ -25,17 +27,17 @@
|
|
|
25
27
|
* handshake AND on reconnect (see `ensureStreamStarted` /
|
|
26
28
|
* `reestablishStreamBody`).
|
|
27
29
|
*/
|
|
28
|
-
import { qualifiedNameToString, qualifiedNameFromString, generateIdentifier,
|
|
29
|
-
import {
|
|
30
|
+
import { qualifiedNameToString, qualifiedNameFromString, generateIdentifier, } from "@kronos-ts/core";
|
|
31
|
+
import {} from "./resilience.js";
|
|
32
|
+
import { applySubscriptionFilter, updateHandler, runAfterCommitOrImmediately, } from "@kronos-ts/core";
|
|
30
33
|
import { contextView } from "./context-view.js";
|
|
31
34
|
import { metadataToProto, metadataFromProto } from "./metadata-conversion.js";
|
|
32
35
|
import { outboundStream } from "./outbound-stream.js";
|
|
33
36
|
import { mapErrorCode, AxonServerErrorCode } from "./errors.js";
|
|
34
37
|
/** Default flow control settings — aligned with Java's 5000 permits. */
|
|
35
38
|
const DEFAULT_PERMITS = 5000n;
|
|
36
|
-
const DEFAULT_THRESHOLD = 2500n;
|
|
37
39
|
/** Default query dispatch timeout — aligned with Java's one hour. */
|
|
38
|
-
const DEFAULT_QUERY_TIMEOUT_MS =
|
|
40
|
+
const DEFAULT_QUERY_TIMEOUT_MS = 30_000;
|
|
39
41
|
/** Default command handler load factor — aligned with Java's 100. */
|
|
40
42
|
const DEFAULT_LOAD_FACTOR = 100;
|
|
41
43
|
// Processing instruction keys — aligned with proto ProcessingKey enum.
|
|
@@ -102,33 +104,33 @@ function createPayloadHelpers(serializer) {
|
|
|
102
104
|
// Axon Server Command Bus
|
|
103
105
|
// ---------------------------------------------------------------------------
|
|
104
106
|
/**
|
|
105
|
-
* A command bus backed by Axon Server, over YOUR
|
|
107
|
+
* A command bus backed by Axon Server, over YOUR next bus.
|
|
106
108
|
*
|
|
107
109
|
* - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
|
|
108
110
|
* RPC. Axon Server routes the command to the appropriate node (which may be
|
|
109
|
-
* this one). There is deliberately no client-side prefer-
|
|
111
|
+
* this one). There is deliberately no client-side prefer-next fork: the hub
|
|
110
112
|
* is the router, and short-circuiting it would silently defeat load factors,
|
|
111
113
|
* priorities and routing keys.
|
|
112
|
-
* - **Inbound**: a command the server routes here is dispatched into `
|
|
114
|
+
* - **Inbound**: a command the server routes here is dispatched into `next` —
|
|
113
115
|
* not into a privately-held handler map. That is what makes the unit-of-work
|
|
114
|
-
* policy you chose for `
|
|
116
|
+
* policy you chose for `next` (say `postgresUnitOfWork(unitOfWork, pg)`)
|
|
115
117
|
* apply to server-routed work exactly as it applies to work this process
|
|
116
118
|
* originated. It is also why this function takes no `unitOfWork` argument:
|
|
117
|
-
* `
|
|
118
|
-
* - **subscribe**: registers the handler on `
|
|
119
|
+
* `next` carries that policy now.
|
|
120
|
+
* - **subscribe**: registers the handler on `next` AND announces the name to
|
|
119
121
|
* Axon Server, so other nodes can route to us.
|
|
120
122
|
*
|
|
121
|
-
* ##
|
|
123
|
+
* ## correlation and the interceptor layer
|
|
122
124
|
*
|
|
123
|
-
* The returned bus stamps no
|
|
125
|
+
* The returned bus stamps no correlation of its own. A host that wants it wraps the
|
|
124
126
|
* OUTERMOST bus:
|
|
125
127
|
*
|
|
126
128
|
* ```ts
|
|
127
|
-
* interceptingCommandBus(axonServerCommandBus(
|
|
129
|
+
* interceptingCommandBus(axonServerCommandBus(next, conn), correlation)
|
|
128
130
|
* ```
|
|
129
131
|
*
|
|
130
132
|
* so whatever a host adds runs BEFORE the message is serialized onto the wire.
|
|
131
|
-
*
|
|
133
|
+
* Correlation itself is usually already on `message.metadata` by then — `ctx.send`
|
|
132
134
|
* stamps the unit of work's correlation data before any bus sees the message.
|
|
133
135
|
*
|
|
134
136
|
* This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
|
|
@@ -136,39 +138,48 @@ function createPayloadHelpers(serializer) {
|
|
|
136
138
|
* `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
|
|
137
139
|
* site, at the top, ahead of any routing. AF5 keeps the property via decorator
|
|
138
140
|
* order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
|
|
139
|
-
* stacks `InterceptingCommandBus → DistributedCommandBus →
|
|
141
|
+
* stacks `InterceptingCommandBus → DistributedCommandBus → LocalCommandBus`.
|
|
140
142
|
*
|
|
141
|
-
* If `
|
|
142
|
-
* `
|
|
143
|
+
* If `next` is itself an intercepting bus, a server-routed command sees
|
|
144
|
+
* `correlation` twice. That is harmless: both of its fields are `??` seeds, so the
|
|
143
145
|
* second application finds them set and changes nothing.
|
|
144
146
|
*/
|
|
145
|
-
export function axonServerCommandBus(
|
|
147
|
+
export function axonServerCommandBus(next, conn, options = {}) {
|
|
146
148
|
const { connection, serializer, metadata: axonMetadata, } = contextView(conn, options.context ?? conn.connection.config.context);
|
|
147
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);
|
|
148
155
|
const resilience = options.resilience ?? conn.resilience;
|
|
149
156
|
const metadata = axonMetadata();
|
|
150
157
|
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer);
|
|
151
|
-
const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS));
|
|
152
|
-
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");
|
|
153
162
|
const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR;
|
|
154
163
|
/**
|
|
155
164
|
* The names this node announced to Axon Server. The handlers themselves live
|
|
156
|
-
* on `
|
|
165
|
+
* on `next`; this set exists so an inbound command for a name we never
|
|
157
166
|
* subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
|
|
158
|
-
* `
|
|
167
|
+
* `next.dispatch` happens to throw — and so a reconnect can re-announce.
|
|
159
168
|
*/
|
|
160
169
|
const subscribedNames = new Set();
|
|
161
170
|
// Bidirectional stream for handler subscription + inbound command handling
|
|
162
171
|
let outbound = outboundStream();
|
|
163
172
|
let streamStarted = false;
|
|
173
|
+
let providerAbort = new AbortController();
|
|
174
|
+
connection.onDisconnect?.(() => { providerAbort.abort(); outbound.close(); });
|
|
164
175
|
let permits = 0n;
|
|
165
176
|
function ensureStreamStarted() {
|
|
166
177
|
if (streamStarted)
|
|
167
178
|
return;
|
|
168
179
|
streamStarted = true;
|
|
169
180
|
// Open stream using connection.commands (always gets current client after reconnect)
|
|
170
|
-
const inbound = connection.commands.openStream(outbound.iterable, { metadata });
|
|
171
|
-
processInboundCommands(inbound);
|
|
181
|
+
const inbound = connection.commands.openStream(outbound.iterable, { metadata, signal: providerAbort.signal });
|
|
182
|
+
void processInboundCommands(inbound, outbound);
|
|
172
183
|
}
|
|
173
184
|
function grantPermits() {
|
|
174
185
|
outbound.send({
|
|
@@ -198,6 +209,8 @@ export function axonServerCommandBus(conn, local, options = {}) {
|
|
|
198
209
|
* trigger a server-side stream error.
|
|
199
210
|
*/
|
|
200
211
|
function reestablishStreamBody() {
|
|
212
|
+
providerAbort.abort();
|
|
213
|
+
providerAbort = new AbortController();
|
|
201
214
|
outbound.close();
|
|
202
215
|
outbound = outboundStream();
|
|
203
216
|
streamStarted = false;
|
|
@@ -209,111 +222,122 @@ export function axonServerCommandBus(conn, local, options = {}) {
|
|
|
209
222
|
// Permits AFTER subscriptions (Axon-specific ordering invariant)
|
|
210
223
|
grantPermits();
|
|
211
224
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return;
|
|
215
|
-
await withRetry(async () => reestablishStreamBody(), {
|
|
216
|
-
event: "reconnect",
|
|
217
|
-
...resilience,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
225
|
+
const recovery = streamRecovery(reestablishStreamBody, () => !shutdownLatch.shuttingDown && connection.state !== "closed" && connection.state !== "disconnected" && connection.state !== "reconnecting", resilience);
|
|
226
|
+
shutdownLatch.onShutdown(recovery.stop);
|
|
220
227
|
// Auto-reestablish when the connection reconnects (e.g., after heartbeat timeout)
|
|
221
228
|
connection.onReconnect(() => {
|
|
222
229
|
if (!shutdownLatch.shuttingDown && streamStarted) {
|
|
223
|
-
|
|
224
|
-
console.error("Axon Server command bus: reconnect retries exhausted", err);
|
|
225
|
-
});
|
|
230
|
+
recovery.restart();
|
|
226
231
|
}
|
|
227
232
|
});
|
|
228
|
-
async function
|
|
233
|
+
async function handleInboundCommand(proto, responses) {
|
|
234
|
+
let activity;
|
|
235
|
+
let admission;
|
|
236
|
+
let responseSerialized;
|
|
237
|
+
let errorCode = "";
|
|
238
|
+
let errorMsg = "";
|
|
229
239
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// Through the LOCAL BUS, so the caller's unit-of-work policy runs.
|
|
250
|
-
// AF parity is preserved: `CommandProcessingTask` runs the local
|
|
251
|
-
// segment without re-running dispatch interceptors, and a `local`
|
|
252
|
-
// that happens to carry `lineage` re-applies a pair of `??` seeds
|
|
253
|
-
// that are already set.
|
|
254
|
-
resultPayload = await local.dispatch(commandMessage);
|
|
255
|
-
}
|
|
256
|
-
catch (err) {
|
|
257
|
-
errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR;
|
|
258
|
-
errorMsg = err instanceof Error ? err.message : String(err);
|
|
259
|
-
}
|
|
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;
|
|
260
259
|
}
|
|
261
260
|
else {
|
|
262
261
|
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_COMMAND;
|
|
263
|
-
errorMsg = `No
|
|
262
|
+
errorMsg = `No next handler for command "${proto.name}"`;
|
|
264
263
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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);
|
|
284
314
|
});
|
|
285
|
-
// Refill permits when running low
|
|
286
|
-
if (permits <= THRESHOLD) {
|
|
287
|
-
outbound.send({
|
|
288
|
-
flowControl: { clientId: connection.config.clientId, permits: PERMITS },
|
|
289
|
-
instructionId: "",
|
|
290
|
-
});
|
|
291
|
-
permits += PERMITS;
|
|
292
|
-
}
|
|
293
315
|
}
|
|
316
|
+
if (responses === outbound && !shutdownLatch.shuttingDown)
|
|
317
|
+
throw new Error("Inbound provider stream ended unexpectedly");
|
|
294
318
|
}
|
|
295
319
|
catch (err) {
|
|
296
|
-
if (shutdownLatch.shuttingDown)
|
|
320
|
+
if (responses !== outbound || shutdownLatch.shuttingDown)
|
|
297
321
|
return;
|
|
298
|
-
if (
|
|
322
|
+
if (connection.state === "reconnecting" || connection.state === "closed" || connection.state === "disconnected")
|
|
299
323
|
return;
|
|
300
|
-
|
|
301
|
-
await reestablishStreamWithRetry().catch((retryErr) => {
|
|
302
|
-
console.error("Axon Server command bus: reconnect retries exhausted", retryErr);
|
|
303
|
-
});
|
|
324
|
+
recovery.failed(err);
|
|
304
325
|
}
|
|
305
326
|
}
|
|
306
327
|
return {
|
|
307
328
|
async dispatch(unstamped) {
|
|
308
329
|
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
309
|
-
// A message that reaches the wire
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
const message = stamped(unstamped, Date.now);
|
|
330
|
+
// A message that reaches the wire with no instant yet gets one from system
|
|
331
|
+
// time here — the envelope crosses a process boundary and must be fully
|
|
332
|
+
// formed. A locally-shortcut message is handed to `next` untouched
|
|
333
|
+
// instead, so the task that handles it supplies the instant.
|
|
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,19 +348,21 @@ export function axonServerCommandBus(conn, local, 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
|
},
|
|
337
363
|
subscribe(commandName, handler) {
|
|
338
364
|
subscribedNames.add(commandName);
|
|
339
|
-
|
|
365
|
+
next.subscribe(commandName, handler);
|
|
340
366
|
ensureStreamStarted();
|
|
341
367
|
// Subscription FIRST
|
|
342
368
|
sendSubscribe(commandName);
|
|
@@ -349,41 +375,49 @@ export function axonServerCommandBus(conn, local, options = {}) {
|
|
|
349
375
|
// Axon Server Query Bus
|
|
350
376
|
// ---------------------------------------------------------------------------
|
|
351
377
|
/**
|
|
352
|
-
* A query bus backed by Axon Server, over YOUR
|
|
378
|
+
* A query bus backed by Axon Server, over YOUR next bus.
|
|
353
379
|
*
|
|
354
380
|
* Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
|
|
355
|
-
* through Axon Server, and a query the server routes here runs through `
|
|
381
|
+
* through Axon Server, and a query the server routes here runs through `next`,
|
|
356
382
|
* so your unit-of-work policy applies to server-routed reads too. `subscribe`
|
|
357
|
-
* registers on `
|
|
383
|
+
* registers on `next` and announces the name to the server.
|
|
358
384
|
*
|
|
359
385
|
* The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
|
|
360
386
|
* has it for queries and not for commands, and so do we. When it is on and this
|
|
361
|
-
* node subscribed the name, `query()` goes straight to `
|
|
362
|
-
* unit of work is passed through, so the
|
|
387
|
+
* node subscribed the name, `query()` goes straight to `next` and the caller's
|
|
388
|
+
* unit of work is passed through, so the next branch nests exactly as the
|
|
363
389
|
* in-process bus does.
|
|
364
390
|
*
|
|
365
|
-
*
|
|
391
|
+
* Correlation, if wanted, is `interceptingQueryBus(bus, correlation)` at the host,
|
|
366
392
|
* matching AF4's `AxonServerQueryBus`, which calls
|
|
367
393
|
* `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
|
|
368
394
|
* `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
|
|
369
|
-
* shortcut branch gets identical
|
|
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
|
-
export function axonServerQueryBus(
|
|
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);
|
|
386
|
-
/** Query names announced to Axon Server; the handlers live on `
|
|
420
|
+
/** Query names announced to Axon Server; the handlers live on `next`. */
|
|
387
421
|
const subscribedNames = new Set();
|
|
388
422
|
// Local subscription store — subscription queries opened by THIS instance.
|
|
389
423
|
// Inbound updates from the server are offered into these via the Subscription RPC loop.
|
|
@@ -394,15 +428,47 @@ export function axonServerQueryBus(conn, local, 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(conn, local, 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,191 +508,217 @@ export function axonServerQueryBus(conn, local, 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 `local`
|
|
550
|
-
// opens one under whatever policy the caller gave it.
|
|
551
|
-
resultPayload = await local.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
|
-
// caller's unit of work is passed straight through, so `
|
|
709
|
+
// caller's unit of work is passed straight through, so `next` makes the
|
|
615
710
|
// nest-or-open decision on the HANDLE exactly as it does for an
|
|
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
|
|
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
|
-
// A message that reaches the wire
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
const message = stamped(unstamped, Date.now);
|
|
717
|
+
// A message that reaches the wire with no instant yet gets one from system
|
|
718
|
+
// time here — the envelope crosses a process boundary and must be fully
|
|
719
|
+
// formed. A locally-shortcut message is handed to `next` untouched
|
|
720
|
+
// instead, so the task that handles it supplies the instant.
|
|
721
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
|
|
628
722
|
const responseStream = connection.queries.query({
|
|
629
723
|
messageIdentifier: message.identifier,
|
|
630
724
|
query: queryName,
|
|
@@ -634,47 +728,77 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
634
728
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
635
729
|
clientId: connection.config.clientId,
|
|
636
730
|
componentName: connection.config.componentName,
|
|
637
|
-
}, { 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;
|
|
638
738
|
for await (const response of responseStream) {
|
|
739
|
+
if (received)
|
|
740
|
+
continue;
|
|
741
|
+
received = true;
|
|
639
742
|
if (response.errorCode && response.errorCode !== "") {
|
|
640
|
-
|
|
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
|
+
}
|
|
641
752
|
}
|
|
642
|
-
return deserializePayload(response.payload?.data);
|
|
643
753
|
}
|
|
754
|
+
if (responseError)
|
|
755
|
+
throw responseError;
|
|
756
|
+
if (received)
|
|
757
|
+
return result;
|
|
644
758
|
throw new Error(`No response for query "${queryName}"`);
|
|
645
759
|
}
|
|
646
760
|
finally {
|
|
761
|
+
deadline?.close();
|
|
762
|
+
admission?.end();
|
|
647
763
|
activity.end();
|
|
648
764
|
}
|
|
649
765
|
},
|
|
650
766
|
subscribe(queryName, handler) {
|
|
651
767
|
subscribedNames.add(queryName);
|
|
652
|
-
|
|
768
|
+
next.subscribe(queryName, handler);
|
|
653
769
|
ensureStreamStarted();
|
|
654
770
|
sendSubscribe(queryName);
|
|
655
771
|
// Permits AFTER subscription (Axon-specific ordering invariant)
|
|
656
772
|
grantQueryPermits();
|
|
657
773
|
},
|
|
658
774
|
subscriptionQuery(unstamped, bufferSize) {
|
|
659
|
-
|
|
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");
|
|
779
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
|
|
660
780
|
const queryId = message.identifier;
|
|
661
781
|
if (subscriptions.has(queryId)) {
|
|
662
782
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`);
|
|
663
783
|
}
|
|
664
|
-
const handler = updateHandler(message, bufferSize);
|
|
665
|
-
subscriptions.set(queryId, handler);
|
|
784
|
+
const handler = updateHandler(message, bufferSize, () => subscriptions.delete(queryId));
|
|
666
785
|
const queryName = qualifiedNameToString(message.name);
|
|
786
|
+
const serialized = serializePayload(queryName, message.payload);
|
|
667
787
|
const subscriptionId = generateIdentifier();
|
|
668
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;
|
|
669
793
|
outboundSub.send({
|
|
670
794
|
subscribe: {
|
|
671
795
|
subscriptionIdentifier: subscriptionId,
|
|
672
|
-
numberOfPermits: BigInt(
|
|
796
|
+
numberOfPermits: BigInt(window),
|
|
673
797
|
queryRequest: {
|
|
674
798
|
messageIdentifier: message.identifier,
|
|
675
799
|
query: queryName,
|
|
676
800
|
timestamp: BigInt(message.timestamp),
|
|
677
|
-
payload:
|
|
801
|
+
payload: serialized,
|
|
678
802
|
metaData: metadataToProto(message.metadata),
|
|
679
803
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
680
804
|
clientId: connection.config.clientId,
|
|
@@ -682,6 +806,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
682
806
|
},
|
|
683
807
|
},
|
|
684
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) } });
|
|
685
812
|
outboundSub.send({
|
|
686
813
|
getInitialResult: {
|
|
687
814
|
subscriptionIdentifier: subscriptionId,
|
|
@@ -690,7 +817,7 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
690
817
|
messageIdentifier: message.identifier,
|
|
691
818
|
query: queryName,
|
|
692
819
|
timestamp: BigInt(message.timestamp),
|
|
693
|
-
payload:
|
|
820
|
+
payload: serialized,
|
|
694
821
|
metaData: metadataToProto(message.metadata),
|
|
695
822
|
processingInstructions: defaultQueryInstructions(queryTimeoutMs),
|
|
696
823
|
clientId: connection.config.clientId,
|
|
@@ -698,7 +825,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
698
825
|
},
|
|
699
826
|
},
|
|
700
827
|
});
|
|
701
|
-
const
|
|
828
|
+
const subscriptionController = new AbortController();
|
|
829
|
+
const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata, signal: subscriptionController.signal });
|
|
830
|
+
subscriptions.set(queryId, handler);
|
|
702
831
|
let resolveInitial;
|
|
703
832
|
let rejectInitial;
|
|
704
833
|
const initialResult = new Promise((resolve, reject) => {
|
|
@@ -706,70 +835,114 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
706
835
|
rejectInitial = reject;
|
|
707
836
|
});
|
|
708
837
|
let initialSettled = false;
|
|
709
|
-
|
|
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 () => {
|
|
710
867
|
try {
|
|
711
868
|
for await (const response of responseStream) {
|
|
869
|
+
if (subscriptionClosed)
|
|
870
|
+
break;
|
|
712
871
|
if (response.initialResult) {
|
|
713
872
|
const initial = response.initialResult;
|
|
714
873
|
if (!initialSettled) {
|
|
715
|
-
if (initial.errorCode
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
else {
|
|
719
|
-
resolveInitial(deserializePayload(initial.payload?.data));
|
|
874
|
+
if (initial.errorCode) {
|
|
875
|
+
throw mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error");
|
|
720
876
|
}
|
|
877
|
+
clearTimeout(initialTimer);
|
|
878
|
+
resolveInitial(deserializePayload(initial.payload?.data, initial.payload?.type, initial.payload?.revision));
|
|
721
879
|
initialSettled = true;
|
|
722
880
|
}
|
|
723
881
|
}
|
|
724
882
|
else if (response.update) {
|
|
725
|
-
const update = deserializePayload(response.update.payload?.data);
|
|
726
|
-
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
|
+
}
|
|
727
893
|
}
|
|
728
894
|
else if (response.complete) {
|
|
729
|
-
|
|
895
|
+
explicitlyCompleted = true;
|
|
730
896
|
break;
|
|
731
897
|
}
|
|
732
898
|
else if (response.completeExceptionally) {
|
|
733
|
-
|
|
734
|
-
"Subscription query failed"));
|
|
735
|
-
break;
|
|
899
|
+
throw new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed");
|
|
736
900
|
}
|
|
737
901
|
}
|
|
738
902
|
}
|
|
739
903
|
catch (err) {
|
|
740
|
-
|
|
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;
|
|
741
909
|
if (!initialSettled) {
|
|
742
|
-
rejectInitial(
|
|
910
|
+
rejectInitial(new Error("Subscription stream ended before initial result"));
|
|
743
911
|
initialSettled = true;
|
|
744
912
|
}
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
finally {
|
|
748
|
-
subscriptions.delete(queryId);
|
|
913
|
+
closeSubscription(!missingInitial && !explicitlyCompleted && !subscriptionClosed ? new Error("Subscription stream ended unexpectedly") : undefined);
|
|
749
914
|
}
|
|
750
915
|
})();
|
|
751
916
|
return {
|
|
752
917
|
initialResult,
|
|
753
|
-
updates:
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
+
},
|
|
763
929
|
},
|
|
930
|
+
close: () => closeSubscription(),
|
|
764
931
|
};
|
|
765
932
|
},
|
|
766
933
|
subscribeToUpdates(unstamped, bufferSize) {
|
|
767
|
-
|
|
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");
|
|
938
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() };
|
|
768
939
|
const queryId = message.identifier;
|
|
769
940
|
if (subscriptions.has(queryId)) {
|
|
770
941
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`);
|
|
771
942
|
}
|
|
772
|
-
|
|
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")));
|
|
773
946
|
subscriptions.set(queryId, handler);
|
|
774
947
|
return {
|
|
775
948
|
[Symbol.asyncIterator]: () => handler.iterable[Symbol.asyncIterator](),
|
|
@@ -779,7 +952,7 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
779
952
|
},
|
|
780
953
|
};
|
|
781
954
|
},
|
|
782
|
-
async emitUpdate(queryName, filter, update) {
|
|
955
|
+
async emitUpdate(queryName, filter, update, uow) {
|
|
783
956
|
runAfterCommitOrImmediately(() => {
|
|
784
957
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
785
958
|
if (sub.queryName !== queryName)
|
|
@@ -804,9 +977,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
804
977
|
instructionId: "",
|
|
805
978
|
});
|
|
806
979
|
}
|
|
807
|
-
});
|
|
980
|
+
}, uow);
|
|
808
981
|
},
|
|
809
|
-
async completeSubscription(queryName, filter) {
|
|
982
|
+
async completeSubscription(queryName, filter, uow) {
|
|
810
983
|
runAfterCommitOrImmediately(() => {
|
|
811
984
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
812
985
|
if (sub.queryName !== queryName)
|
|
@@ -826,9 +999,9 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
826
999
|
});
|
|
827
1000
|
handlerSubscriptions.delete(subId);
|
|
828
1001
|
}
|
|
829
|
-
});
|
|
1002
|
+
}, uow);
|
|
830
1003
|
},
|
|
831
|
-
async completeSubscriptionExceptionally(queryName, error, filter) {
|
|
1004
|
+
async completeSubscriptionExceptionally(queryName, error, filter, uow) {
|
|
832
1005
|
runAfterCommitOrImmediately(() => {
|
|
833
1006
|
for (const [subId, sub] of handlerSubscriptions) {
|
|
834
1007
|
if (sub.queryName !== queryName)
|
|
@@ -855,7 +1028,7 @@ export function axonServerQueryBus(conn, local, options = {}) {
|
|
|
855
1028
|
});
|
|
856
1029
|
handlerSubscriptions.delete(subId);
|
|
857
1030
|
}
|
|
858
|
-
});
|
|
1031
|
+
}, uow);
|
|
859
1032
|
},
|
|
860
1033
|
};
|
|
861
1034
|
return routing;
|