@kronos-ts/axon-server 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +72 -77
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshotting-event-store.d.ts +3 -3
- package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -1
- package/dist/axon-server-snapshotting-event-store.js +3 -2
- package/dist/axon-server-snapshotting-event-store.js.map +1 -1
- package/dist/axon-server.d.ts +15 -7
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +447 -275
- package/dist/axon-server.js.map +1 -1
- package/dist/bounded-read.d.ts +19 -0
- package/dist/bounded-read.d.ts.map +1 -0
- package/dist/bounded-read.js +39 -0
- package/dist/bounded-read.js.map +1 -0
- package/dist/connection.d.ts +13 -0
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +78 -40
- package/dist/connection.js.map +1 -1
- package/dist/control-plane.d.ts +9 -19
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +6 -37
- package/dist/control-plane.js.map +1 -1
- package/dist/event-processor-info.d.ts +5 -23
- package/dist/event-processor-info.d.ts.map +1 -1
- package/dist/event-processor-info.js +16 -20
- package/dist/event-processor-info.js.map +1 -1
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/flow-controlled-sender.js +37 -17
- package/dist/flow-controlled-sender.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outbound-stream.d.ts +5 -9
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/outbound-stream.js +65 -16
- package/dist/outbound-stream.js.map +1 -1
- package/dist/platform-service.d.ts +10 -1
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/platform-service.js +70 -10
- package/dist/platform-service.js.map +1 -1
- package/dist/resilience.d.ts +2 -0
- package/dist/resilience.d.ts.map +1 -1
- package/dist/resilience.js +2 -1
- package/dist/resilience.js.map +1 -1
- package/dist/shutdown-latch.d.ts +1 -0
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js +20 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/dist/stream-recovery.d.ts +9 -0
- package/dist/stream-recovery.d.ts.map +1 -0
- package/dist/stream-recovery.js +70 -0
- package/dist/stream-recovery.js.map +1 -0
- package/package.json +2 -2
- package/src/axon-server-event-store.ts +77 -79
- package/src/axon-server-snapshotting-event-store.ts +10 -7
- package/src/axon-server.ts +417 -316
- package/src/bounded-read.ts +43 -0
- package/src/connection.ts +77 -45
- package/src/control-plane.ts +19 -63
- package/src/event-processor-info.ts +22 -43
- package/src/flow-controlled-sender.ts +33 -14
- package/src/index.ts +0 -1
- package/src/outbound-stream.ts +57 -27
- package/src/platform-service.ts +75 -11
- package/src/resilience.ts +6 -2
- package/src/shutdown-latch.ts +14 -1
- package/src/stream-recovery.ts +72 -0
package/src/platform-service.ts
CHANGED
|
@@ -105,7 +105,16 @@ export type PlatformConnection = {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export type PlatformServiceOptions = {
|
|
108
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Heartbeat interval in ms. Default: 2500.
|
|
110
|
+
*
|
|
111
|
+
* Axon Server's `client-heartbeat-timeout` defaults to 5000 ms and is
|
|
112
|
+
* refreshed ONLY by heartbeats the client sends (`HeartbeatMonitor`), checked
|
|
113
|
+
* every second. A 10 s cadence — Axon Framework's own default — survives
|
|
114
|
+
* there only because the framework also answers every heartbeat the SERVER
|
|
115
|
+
* sends; this client does both, and beats at half the server's window so a
|
|
116
|
+
* paused runner still fits.
|
|
117
|
+
*/
|
|
109
118
|
heartbeatIntervalMs?: number
|
|
110
119
|
/** Heartbeat timeout in ms. If no response within this window, reconnect. Default: 7500 */
|
|
111
120
|
heartbeatTimeoutMs?: number
|
|
@@ -133,7 +142,7 @@ export function platformConnection(
|
|
|
133
142
|
connection: AxonServerConnection,
|
|
134
143
|
options?: PlatformServiceOptions,
|
|
135
144
|
): PlatformConnection {
|
|
136
|
-
const heartbeatIntervalMs = options?.heartbeatIntervalMs ??
|
|
145
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 2500
|
|
137
146
|
const heartbeatTimeoutMs = options?.heartbeatTimeoutMs ?? 7500
|
|
138
147
|
const processorsNotificationRateMs = options?.processorsNotificationRateMs ?? 500
|
|
139
148
|
const processorsNotificationInitialDelayMs = options?.processorsNotificationInitialDelayMs ?? 5000
|
|
@@ -154,12 +163,22 @@ export function platformConnection(
|
|
|
154
163
|
*/
|
|
155
164
|
const pendingInstructions: PlatformInstruction[] = []
|
|
156
165
|
let isConnected = false
|
|
166
|
+
let monitoringArmed = false
|
|
167
|
+
let recoveryTimer: ReturnType<typeof setTimeout> | undefined
|
|
157
168
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
158
169
|
let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
|
170
|
+
let processorStatusInitialTimer: ReturnType<typeof setTimeout> | undefined
|
|
159
171
|
let processorStatusTimer: ReturnType<typeof setInterval> | null = null
|
|
160
172
|
/** Guards against arming the status-report timer twice — see `startProcessorStatusReporting`. */
|
|
161
173
|
let processorStatusArmed = false
|
|
162
174
|
let lastHeartbeatResponse = Date.now()
|
|
175
|
+
// Whether this server has EVER sent a heartbeat. Axon Server only beats at
|
|
176
|
+
// clients whose registered framework version it recognises (4.2.1+); a client
|
|
177
|
+
// it does not beat at cannot be judged by the silence — that judgement fell
|
|
178
|
+
// every window and tore down a healthy stream, every 10 s, in production.
|
|
179
|
+
// Dead-channel detection for a server that never beats is gRPC keepalive's
|
|
180
|
+
// job (see `connection.ts`), not this timer's.
|
|
181
|
+
let serverBeats = false
|
|
163
182
|
let outbound: ReturnType<typeof outboundStream<PlatformInboundInstruction>> | null = null
|
|
164
183
|
/**
|
|
165
184
|
* Latches once Axon Server sends its first inbound message after
|
|
@@ -176,13 +195,37 @@ export function platformConnection(
|
|
|
176
195
|
grpcMetadata.set("AxonIQ-Access-Token", connection.config.token)
|
|
177
196
|
}
|
|
178
197
|
|
|
179
|
-
|
|
198
|
+
function scheduleRecovery() {
|
|
199
|
+
if (!monitoringArmed || recoveryTimer || connection.state === "closed" || connection.state === "reconnecting") return
|
|
200
|
+
recoveryTimer = setTimeout(() => {
|
|
201
|
+
recoveryTimer = undefined
|
|
202
|
+
if (!monitoringArmed || connection.state === "closed") return
|
|
203
|
+
void connection.reconnect().catch((error) => console.error("Platform recovery failed", error))
|
|
204
|
+
}, connection.config.reconnectIntervalMs ?? 2000)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
connection.onDisconnect?.(() => {
|
|
208
|
+
isConnected = false
|
|
209
|
+
outbound?.close()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
connection.onReconnect(() => {
|
|
213
|
+
if (!monitoringArmed) return
|
|
214
|
+
clearTimeout(recoveryTimer)
|
|
215
|
+
recoveryTimer = undefined
|
|
216
|
+
isConnected = false
|
|
217
|
+
openPlatformStream()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
async function processInboundInstructions(inbound: AsyncIterable<any>, stream: typeof outbound) {
|
|
180
221
|
try {
|
|
181
222
|
for await (const message of inbound) {
|
|
223
|
+
if (stream !== outbound || !monitoringArmed) return
|
|
182
224
|
// First inbound message after start() = the platform has accepted our
|
|
183
225
|
// registration and is talking back. Latch the ack flag (mirror of
|
|
184
226
|
// kronosdb Plan 09-03 / D-102 — replaces the legacy 1s sleep).
|
|
185
227
|
acked = true
|
|
228
|
+
if (message.requestReconnect) scheduleRecovery()
|
|
186
229
|
// Parse instruction type
|
|
187
230
|
const instruction = parseInstruction(message)
|
|
188
231
|
if (instruction) {
|
|
@@ -200,16 +243,27 @@ export function platformConnection(
|
|
|
200
243
|
}
|
|
201
244
|
}
|
|
202
245
|
|
|
203
|
-
//
|
|
246
|
+
// A heartbeat from the server is BOTH the liveness signal this side
|
|
247
|
+
// tracks and a request: Axon Server measures client inactivity by the
|
|
248
|
+
// heartbeats it receives, so every one it sends is answered at once
|
|
249
|
+
// (what Axon Framework's connector does). Without the echo, the only
|
|
250
|
+
// activity the server ever sees is the periodic beat below.
|
|
204
251
|
if (message.heartbeat) {
|
|
252
|
+
serverBeats = true
|
|
205
253
|
lastHeartbeatResponse = Date.now()
|
|
254
|
+
outbound?.send({ heartbeat: { clientId: connection.config.clientId }, instructionId: "" })
|
|
206
255
|
}
|
|
207
256
|
}
|
|
208
257
|
} catch (err) {
|
|
209
|
-
if (isConnected) {
|
|
258
|
+
if (stream === outbound && monitoringArmed && isConnected) {
|
|
210
259
|
console.error("Platform stream error:", err)
|
|
211
260
|
isConnected = false
|
|
212
261
|
}
|
|
262
|
+
} finally {
|
|
263
|
+
if (stream === outbound && monitoringArmed) {
|
|
264
|
+
isConnected = false
|
|
265
|
+
scheduleRecovery()
|
|
266
|
+
}
|
|
213
267
|
}
|
|
214
268
|
}
|
|
215
269
|
|
|
@@ -266,10 +320,13 @@ export function platformConnection(
|
|
|
266
320
|
|
|
267
321
|
heartbeatTimer = setInterval(() => {
|
|
268
322
|
if (!isConnected || !outbound) return
|
|
323
|
+
// Not before the server has acknowledged the registration: a heartbeat
|
|
324
|
+
// from a stream it has not filed yet is an error on its side, not a beat.
|
|
325
|
+
if (!acked) return
|
|
269
326
|
|
|
270
|
-
//
|
|
327
|
+
// Judge silence only from a server that has beaten before.
|
|
271
328
|
const timeSinceLastResponse = Date.now() - lastHeartbeatResponse
|
|
272
|
-
if (timeSinceLastResponse > heartbeatTimeoutMs) {
|
|
329
|
+
if (serverBeats && timeSinceLastResponse > heartbeatTimeoutMs) {
|
|
273
330
|
console.warn(
|
|
274
331
|
`Platform heartbeat timeout: no response in ${timeSinceLastResponse}ms ` +
|
|
275
332
|
`(threshold: ${heartbeatTimeoutMs}ms). Marking connection as lost.`,
|
|
@@ -302,9 +359,9 @@ export function platformConnection(
|
|
|
302
359
|
if (processorStatusTimer) clearInterval(processorStatusTimer)
|
|
303
360
|
|
|
304
361
|
// Initial delay before first report
|
|
305
|
-
setTimeout(() => {
|
|
306
|
-
if (!
|
|
307
|
-
reportProcessorStatus()
|
|
362
|
+
processorStatusInitialTimer = setTimeout(() => {
|
|
363
|
+
if (!monitoringArmed) return
|
|
364
|
+
if (isConnected) void reportProcessorStatus()
|
|
308
365
|
|
|
309
366
|
// Then report at the configured rate
|
|
310
367
|
processorStatusTimer = setInterval(() => {
|
|
@@ -352,6 +409,7 @@ export function platformConnection(
|
|
|
352
409
|
|
|
353
410
|
// Re-arm the ack latch so a stop/start cycle correctly re-waits.
|
|
354
411
|
acked = false
|
|
412
|
+
serverBeats = false
|
|
355
413
|
outbound = outboundStream<PlatformInboundInstruction>()
|
|
356
414
|
|
|
357
415
|
// Register with Axon Server
|
|
@@ -375,7 +433,7 @@ export function platformConnection(
|
|
|
375
433
|
// timeout, which is what the command/query buses hang their stream
|
|
376
434
|
// re-establishment off. It belongs to every service, administered or not.
|
|
377
435
|
startHeartbeat()
|
|
378
|
-
processInboundInstructions(inbound)
|
|
436
|
+
void processInboundInstructions(inbound, outbound)
|
|
379
437
|
|
|
380
438
|
// Axon Server's PlatformService does NOT proactively emit an inbound
|
|
381
439
|
// frame in response to `register` — the stream is held open silently
|
|
@@ -399,15 +457,21 @@ export function platformConnection(
|
|
|
399
457
|
|
|
400
458
|
return {
|
|
401
459
|
async armConnectionMonitoring() {
|
|
460
|
+
monitoringArmed = true
|
|
402
461
|
openPlatformStream()
|
|
403
462
|
},
|
|
404
463
|
|
|
405
464
|
async start() {
|
|
465
|
+
monitoringArmed = true
|
|
406
466
|
openPlatformStream()
|
|
407
467
|
startProcessorStatusReporting()
|
|
408
468
|
},
|
|
409
469
|
|
|
410
470
|
stop() {
|
|
471
|
+
monitoringArmed = false
|
|
472
|
+
clearTimeout(processorStatusInitialTimer)
|
|
473
|
+
clearTimeout(recoveryTimer)
|
|
474
|
+
recoveryTimer = undefined
|
|
411
475
|
isConnected = false
|
|
412
476
|
// A stopped stream's un-routed backlog is stale — do not replay it if a
|
|
413
477
|
// handler registers later.
|
package/src/resilience.ts
CHANGED
|
@@ -39,9 +39,11 @@ export type ResilienceConfig = {
|
|
|
39
39
|
log?: (msg: string) => void
|
|
40
40
|
/** Per-attempt classification: returns false to short-circuit (terminal error). */
|
|
41
41
|
isRetryable?: (err: unknown) => boolean
|
|
42
|
+
/** How a retry waits. Defaults to `setTimeout`; a test injects one to observe delays without touching globals. */
|
|
43
|
+
sleep?: (ms: number) => Promise<void>
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
const DEFAULTS: Omit<ResilienceConfig, "log" | "isRetryable"> = {
|
|
46
|
+
const DEFAULTS: Omit<ResilienceConfig, "log" | "isRetryable" | "sleep"> = {
|
|
45
47
|
initialDelayMs: 100,
|
|
46
48
|
maxDelayMs: 30_000,
|
|
47
49
|
maxAttempts: 10,
|
|
@@ -105,12 +107,14 @@ export async function withRetry<T>(
|
|
|
105
107
|
(err as Error)?.message ?? String(err)
|
|
106
108
|
}`,
|
|
107
109
|
)
|
|
108
|
-
await
|
|
110
|
+
await (cfg.sleep ?? defaultSleep)(delay)
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
throw lastErr
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
117
|
+
|
|
114
118
|
/**
|
|
115
119
|
* Run a health-check probe with warn-then-continue semantics (D-100).
|
|
116
120
|
*
|
package/src/shutdown-latch.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type ShutdownLatch = {
|
|
|
15
15
|
* Register an in-flight activity. Throws if shutdown is in progress.
|
|
16
16
|
* Call `end()` on the returned handle when the activity completes.
|
|
17
17
|
*/
|
|
18
|
+
onShutdown(callback: () => void): () => void
|
|
18
19
|
registerActivity(): ActivityHandle
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -44,8 +45,10 @@ export class ShutdownInProgressError extends Error {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export function shutdownLatch(): ShutdownLatch {
|
|
48
|
+
const callbacks = new Set<() => void>()
|
|
47
49
|
let activeCount = 0
|
|
48
50
|
let shuttingDown = false
|
|
51
|
+
let drainPromise: Promise<void> | undefined
|
|
49
52
|
let drainResolve: (() => void) | null = null
|
|
50
53
|
|
|
51
54
|
function checkDrained() {
|
|
@@ -56,6 +59,11 @@ export function shutdownLatch(): ShutdownLatch {
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
return {
|
|
62
|
+
onShutdown(callback) {
|
|
63
|
+
if (shuttingDown) callback()
|
|
64
|
+
else callbacks.add(callback)
|
|
65
|
+
return () => { callbacks.delete(callback) }
|
|
66
|
+
},
|
|
59
67
|
registerActivity(): ActivityHandle {
|
|
60
68
|
if (shuttingDown) {
|
|
61
69
|
throw new ShutdownInProgressError()
|
|
@@ -76,14 +84,19 @@ export function shutdownLatch(): ShutdownLatch {
|
|
|
76
84
|
|
|
77
85
|
initiateShutdown(): Promise<void> {
|
|
78
86
|
shuttingDown = true
|
|
87
|
+
for (const callback of callbacks) {
|
|
88
|
+
try { callback() } catch (error) { console.error("Messaging shutdown callback failed", error) }
|
|
89
|
+
}
|
|
90
|
+
callbacks.clear()
|
|
79
91
|
|
|
80
92
|
if (activeCount === 0) {
|
|
81
93
|
return Promise.resolve()
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
drainPromise ??= new Promise((resolve) => {
|
|
85
97
|
drainResolve = resolve
|
|
86
98
|
})
|
|
99
|
+
return drainPromise
|
|
87
100
|
},
|
|
88
101
|
|
|
89
102
|
get shuttingDown() {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ResilienceConfig } from "./resilience.js"
|
|
2
|
+
|
|
3
|
+
/** Retry failures of the async stream, not just synchronous stream creation. */
|
|
4
|
+
export function streamRecovery(
|
|
5
|
+
reopen: () => void,
|
|
6
|
+
canReconnect: () => boolean,
|
|
7
|
+
config: Partial<ResilienceConfig> = {},
|
|
8
|
+
) {
|
|
9
|
+
const log = (message: string) => {
|
|
10
|
+
try {
|
|
11
|
+
;(config.log ?? console.warn)(message)
|
|
12
|
+
} catch {
|
|
13
|
+
/* Diagnostics cannot prevent recovery. */
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
17
|
+
let stopped = false
|
|
18
|
+
let attempts = 0
|
|
19
|
+
let openedAt = Date.now()
|
|
20
|
+
|
|
21
|
+
function failed(error: unknown) {
|
|
22
|
+
if (stopped || timer || !canReconnect()) return
|
|
23
|
+
if (config.isRetryable?.(error) === false || attempts >= (config.maxAttempts ?? 30)) {
|
|
24
|
+
log(`Provider stream recovery exhausted: ${String(error)}`)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
// Keep a positive floor even when jitter is zero. An immediately-ended
|
|
28
|
+
// async generator must never create a microtask reconnect loop.
|
|
29
|
+
const cap = Math.min(
|
|
30
|
+
(config.initialDelayMs ?? 100) * (config.multiplier ?? 2) ** attempts++,
|
|
31
|
+
config.maxDelayMs ?? 30000,
|
|
32
|
+
)
|
|
33
|
+
const delay = Math.max(1, cap * (0.5 + Math.random() * 0.5))
|
|
34
|
+
log(`Provider stream failed; reconnecting in ${Math.round(delay)}ms: ${String(error)}`)
|
|
35
|
+
timer = setTimeout(() => {
|
|
36
|
+
timer = undefined
|
|
37
|
+
if (stopped || !canReconnect()) return
|
|
38
|
+
openedAt = Date.now()
|
|
39
|
+
try {
|
|
40
|
+
reopen()
|
|
41
|
+
} catch (error) {
|
|
42
|
+
failed(error)
|
|
43
|
+
}
|
|
44
|
+
}, delay)
|
|
45
|
+
timer.unref?.()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
failed,
|
|
50
|
+
received() {
|
|
51
|
+
// Subscription acknowledgements alone do not prove a stable connection.
|
|
52
|
+
if (Date.now() - openedAt >= 30000) attempts = 0
|
|
53
|
+
},
|
|
54
|
+
restart() {
|
|
55
|
+
if (stopped || !canReconnect()) return
|
|
56
|
+
clearTimeout(timer)
|
|
57
|
+
timer = undefined
|
|
58
|
+
attempts = 0
|
|
59
|
+
openedAt = Date.now()
|
|
60
|
+
try {
|
|
61
|
+
reopen()
|
|
62
|
+
} catch (error) {
|
|
63
|
+
failed(error)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
stop() {
|
|
67
|
+
stopped = true
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
timer = undefined
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|