@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.
Files changed (68) hide show
  1. package/dist/axon-server-event-store.d.ts.map +1 -1
  2. package/dist/axon-server-event-store.js +72 -77
  3. package/dist/axon-server-event-store.js.map +1 -1
  4. package/dist/axon-server-snapshotting-event-store.d.ts +3 -3
  5. package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -1
  6. package/dist/axon-server-snapshotting-event-store.js +3 -2
  7. package/dist/axon-server-snapshotting-event-store.js.map +1 -1
  8. package/dist/axon-server.d.ts +15 -7
  9. package/dist/axon-server.d.ts.map +1 -1
  10. package/dist/axon-server.js +447 -275
  11. package/dist/axon-server.js.map +1 -1
  12. package/dist/bounded-read.d.ts +19 -0
  13. package/dist/bounded-read.d.ts.map +1 -0
  14. package/dist/bounded-read.js +39 -0
  15. package/dist/bounded-read.js.map +1 -0
  16. package/dist/connection.d.ts +13 -0
  17. package/dist/connection.d.ts.map +1 -1
  18. package/dist/connection.js +78 -40
  19. package/dist/connection.js.map +1 -1
  20. package/dist/control-plane.d.ts +9 -19
  21. package/dist/control-plane.d.ts.map +1 -1
  22. package/dist/control-plane.js +6 -37
  23. package/dist/control-plane.js.map +1 -1
  24. package/dist/event-processor-info.d.ts +5 -23
  25. package/dist/event-processor-info.d.ts.map +1 -1
  26. package/dist/event-processor-info.js +16 -20
  27. package/dist/event-processor-info.js.map +1 -1
  28. package/dist/flow-controlled-sender.d.ts.map +1 -1
  29. package/dist/flow-controlled-sender.js +37 -17
  30. package/dist/flow-controlled-sender.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/outbound-stream.d.ts +5 -9
  35. package/dist/outbound-stream.d.ts.map +1 -1
  36. package/dist/outbound-stream.js +65 -16
  37. package/dist/outbound-stream.js.map +1 -1
  38. package/dist/platform-service.d.ts +10 -1
  39. package/dist/platform-service.d.ts.map +1 -1
  40. package/dist/platform-service.js +70 -10
  41. package/dist/platform-service.js.map +1 -1
  42. package/dist/resilience.d.ts +2 -0
  43. package/dist/resilience.d.ts.map +1 -1
  44. package/dist/resilience.js +2 -1
  45. package/dist/resilience.js.map +1 -1
  46. package/dist/shutdown-latch.d.ts +1 -0
  47. package/dist/shutdown-latch.d.ts.map +1 -1
  48. package/dist/shutdown-latch.js +20 -1
  49. package/dist/shutdown-latch.js.map +1 -1
  50. package/dist/stream-recovery.d.ts +9 -0
  51. package/dist/stream-recovery.d.ts.map +1 -0
  52. package/dist/stream-recovery.js +70 -0
  53. package/dist/stream-recovery.js.map +1 -0
  54. package/package.json +2 -2
  55. package/src/axon-server-event-store.ts +77 -79
  56. package/src/axon-server-snapshotting-event-store.ts +10 -7
  57. package/src/axon-server.ts +417 -316
  58. package/src/bounded-read.ts +43 -0
  59. package/src/connection.ts +77 -45
  60. package/src/control-plane.ts +19 -63
  61. package/src/event-processor-info.ts +22 -43
  62. package/src/flow-controlled-sender.ts +33 -14
  63. package/src/index.ts +0 -1
  64. package/src/outbound-stream.ts +57 -27
  65. package/src/platform-service.ts +75 -11
  66. package/src/resilience.ts +6 -2
  67. package/src/shutdown-latch.ts +14 -1
  68. package/src/stream-recovery.ts +72 -0
@@ -105,7 +105,16 @@ export type PlatformConnection = {
105
105
  }
106
106
 
107
107
  export type PlatformServiceOptions = {
108
- /** Heartbeat interval in ms. Default: 10000 */
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 ?? 10000
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
- async function processInboundInstructions(inbound: AsyncIterable<any>) {
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
- // Handle heartbeat response track last response time for timeout detection
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
- // Check if last heartbeat response was too long ago
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 (!isConnected) return
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 new Promise((r) => setTimeout(r, delay))
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
  *
@@ -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
- return new Promise((resolve) => {
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
+ }