@kronos-ts/axon-server 0.2.10 → 0.3.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 +1 -1
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +3 -3
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshot-store.d.ts +1 -1
- package/dist/axon-server-snapshot-store.d.ts.map +1 -1
- package/dist/axon-server-snapshot-store.js +1 -1
- package/dist/axon-server-snapshot-store.js.map +1 -1
- package/dist/axon-server.d.ts +169 -43
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +191 -323
- package/dist/axon-server.js.map +1 -1
- package/dist/connection-manager.d.ts +2 -2
- package/dist/connection-manager.d.ts.map +1 -1
- package/dist/connection-manager.js +1 -1
- package/dist/connection-manager.js.map +1 -1
- package/dist/control-plane.d.ts +108 -0
- package/dist/control-plane.d.ts.map +1 -0
- package/dist/control-plane.js +96 -0
- package/dist/control-plane.js.map +1 -0
- package/dist/flow-controlled-sender.d.ts +1 -1
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/flow-controlled-sender.js +1 -1
- package/dist/flow-controlled-sender.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -8
- package/dist/index.js.map +1 -1
- package/dist/message-size.d.ts +1 -1
- package/dist/message-size.d.ts.map +1 -1
- package/dist/message-size.js +1 -1
- package/dist/message-size.js.map +1 -1
- package/dist/outbound-stream.d.ts +1 -1
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/outbound-stream.js +1 -1
- package/dist/outbound-stream.js.map +1 -1
- package/dist/platform-service.d.ts +34 -3
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/platform-service.js +113 -42
- package/dist/platform-service.js.map +1 -1
- package/dist/shutdown-latch.d.ts +1 -1
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js +1 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/package.json +14 -8
- package/src/axon-server-event-store.ts +3 -3
- package/src/axon-server-snapshot-store.ts +1 -1
- package/src/axon-server.ts +283 -367
- package/src/connection-manager.ts +2 -2
- package/src/control-plane.ts +195 -0
- package/src/flow-controlled-sender.ts +1 -1
- package/src/index.ts +19 -8
- package/src/message-size.ts +1 -1
- package/src/outbound-stream.ts +1 -1
- package/src/platform-service.ts +152 -48
- package/src/shutdown-latch.ts +1 -1
|
@@ -10,7 +10,7 @@ import { connectToAxonServer } from "./connection.js"
|
|
|
10
10
|
* Aligned with Java's `AxonServerConnectionManager`.
|
|
11
11
|
*
|
|
12
12
|
* ```typescript
|
|
13
|
-
* const manager =
|
|
13
|
+
* const manager = connectionManager({
|
|
14
14
|
* componentName: "my-app",
|
|
15
15
|
* host: "axon-server",
|
|
16
16
|
* port: 8124,
|
|
@@ -51,7 +51,7 @@ export interface AxonServerConnectionManager {
|
|
|
51
51
|
* The base config (host, port, SSL, etc.) is shared across all contexts.
|
|
52
52
|
* Only the `context` field varies per connection.
|
|
53
53
|
*/
|
|
54
|
-
export function
|
|
54
|
+
export function connectionManager(
|
|
55
55
|
baseConfig: Omit<AxonServerConnectionConfig, "context">,
|
|
56
56
|
): AxonServerConnectionManager {
|
|
57
57
|
const connections = new Map<string, AxonServerConnection>()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Axon Server PLATFORM CONTROL PLANE — remote administration, extracted
|
|
3
|
+
* out of the backend.
|
|
4
|
+
*
|
|
5
|
+
* This is not persistence and it is not transport. It is the pair of duties
|
|
6
|
+
* Axon Server's admin surface needs from a client:
|
|
7
|
+
*
|
|
8
|
+
* 1. inbound — Axon Server pushes processor instructions (pause-processor,
|
|
9
|
+
* start-processor, release-segment, split-segment, merge-segment) which
|
|
10
|
+
* have to be routed to the live processor of that name;
|
|
11
|
+
* 2. outbound — the client periodically reports each processor's status so
|
|
12
|
+
* the Axon Dashboard can render it.
|
|
13
|
+
*
|
|
14
|
+
* It lived inside `axonServer().start()` only because it shares the gRPC
|
|
15
|
+
* connection with the data path. It is now separate and OPT-IN: a service that
|
|
16
|
+
* nobody administers remotely simply never builds one, and `axonServer()`
|
|
17
|
+
* matches `postgres()` / `rabbitmq()` — components plus an argument-less
|
|
18
|
+
* `start()` readiness barrier.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const axon = await axonServer({ ... })
|
|
22
|
+
* const app = kronos({ components: { ...axon.components }, modules })
|
|
23
|
+
* await axon.start() // data path only
|
|
24
|
+
*
|
|
25
|
+
* // opt in to remote administration
|
|
26
|
+
* const control = await axonServerControlPlane(axon.platform, app.processors.values())
|
|
27
|
+
* // …
|
|
28
|
+
* await app.stop(); await control.close(); await axon.close()
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* ORDERING INVARIANT (the reason this is an async factory rather than a
|
|
32
|
+
* constructor plus a separate `start()`): the instruction handler and the
|
|
33
|
+
* status supplier MUST both be registered BEFORE `platform.start()` sends the
|
|
34
|
+
* `register` frame. An instruction that arrives in the gap between `start()`
|
|
35
|
+
* and `onInstruction(...)` is dropped on the floor, and a status request that
|
|
36
|
+
* arrives before `registerProcessorStatusSupplier(...)` finds no supplier and
|
|
37
|
+
* reports the client as having no processors at all. Register, register, then
|
|
38
|
+
* start — all three inside one function, so there is no call order for a
|
|
39
|
+
* caller to get wrong.
|
|
40
|
+
*/
|
|
41
|
+
import type { PlatformConnection } from "./platform-service.js"
|
|
42
|
+
import type { ProcessorStatus, SegmentStatus } from "./event-processor-info.js"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A processor Axon Server is allowed to observe and control.
|
|
46
|
+
*
|
|
47
|
+
* The container version reached for `app.processors()` and cast the result to
|
|
48
|
+
* `any` before poking at `start` / `stop` / `releaseSegment` / … — this is that
|
|
49
|
+
* cast, written down. Both `TrackingEventProcessor` and
|
|
50
|
+
* `StreamingEventProcessor` satisfy it structurally; anything else that can
|
|
51
|
+
* name itself and answer some of these calls does too. Every member past the
|
|
52
|
+
* name is optional because Axon Server asks for things a given processor kind
|
|
53
|
+
* may not implement (a subscribing processor has no segments), and the
|
|
54
|
+
* instruction handler simply skips what is absent.
|
|
55
|
+
*/
|
|
56
|
+
export interface ManagedEventProcessor {
|
|
57
|
+
readonly name: string
|
|
58
|
+
readonly running?: boolean
|
|
59
|
+
readonly replaying?: boolean
|
|
60
|
+
readonly position?: bigint
|
|
61
|
+
start?(): Promise<void> | void
|
|
62
|
+
stop?(): void
|
|
63
|
+
supportsReset?(): boolean
|
|
64
|
+
processingStatus?(): ReadonlyMap<
|
|
65
|
+
number,
|
|
66
|
+
{
|
|
67
|
+
readonly position?: bigint
|
|
68
|
+
readonly caughtUp?: boolean
|
|
69
|
+
readonly replaying?: boolean
|
|
70
|
+
readonly error?: Error
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
releaseSegment?(segmentId: number): Promise<unknown> | unknown
|
|
74
|
+
splitSegment?(segmentId: number): Promise<unknown> | unknown
|
|
75
|
+
mergeSegment?(segmentId: number): Promise<unknown> | unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A running control plane. The platform stream is live; instructions are being
|
|
80
|
+
* routed and status is being reported until `close()`.
|
|
81
|
+
*/
|
|
82
|
+
export interface AxonServerControlPlane {
|
|
83
|
+
/**
|
|
84
|
+
* The processors this control plane addresses, keyed by name — the snapshot
|
|
85
|
+
* taken at construction. Exposed for introspection and tests.
|
|
86
|
+
*/
|
|
87
|
+
readonly processors: ReadonlyMap<string, ManagedEventProcessor>
|
|
88
|
+
/** Stop the platform stream. Idempotent, and safe to call before `axon.close()`. */
|
|
89
|
+
close(): Promise<void>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Map the managed processors into the status shape the platform stream reports. */
|
|
93
|
+
function processorStatuses(
|
|
94
|
+
processors: Iterable<ManagedEventProcessor>,
|
|
95
|
+
): ProcessorStatus[] {
|
|
96
|
+
return Array.from(processors, (proc) => {
|
|
97
|
+
const isStreamingProcessor = proc.supportsReset?.() !== false
|
|
98
|
+
const perSegment = proc.processingStatus?.()
|
|
99
|
+
const segments: SegmentStatus[] = perSegment
|
|
100
|
+
? Array.from(perSegment.entries()).map(([segmentId, status]) => ({
|
|
101
|
+
segmentId,
|
|
102
|
+
caughtUp: status.caughtUp ?? false,
|
|
103
|
+
replaying: status.replaying ?? false,
|
|
104
|
+
onePartOf: 1,
|
|
105
|
+
tokenPosition: status.position ?? 0n,
|
|
106
|
+
errorState: status.error?.message ?? "",
|
|
107
|
+
}))
|
|
108
|
+
: [
|
|
109
|
+
{
|
|
110
|
+
segmentId: 0,
|
|
111
|
+
caughtUp: true,
|
|
112
|
+
replaying: proc.replaying ?? false,
|
|
113
|
+
onePartOf: 1,
|
|
114
|
+
tokenPosition: proc.position ?? 0n,
|
|
115
|
+
errorState: "",
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
return {
|
|
119
|
+
name: proc.name,
|
|
120
|
+
running: proc.running ?? false,
|
|
121
|
+
mode: isStreamingProcessor ? "Tracking" : "Subscribing",
|
|
122
|
+
isStreamingProcessor,
|
|
123
|
+
activeThreads: proc.running ? 1 : 0,
|
|
124
|
+
availableThreads: 0,
|
|
125
|
+
error: false,
|
|
126
|
+
tokenStoreIdentifier: "",
|
|
127
|
+
segments,
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wire remote administration onto an Axon Server platform stream and start it.
|
|
134
|
+
*
|
|
135
|
+
* `platform` is `axonServer(...).platform` — the backend builds the platform
|
|
136
|
+
* connection (it owns the gRPC connection and the `platformService` tuning) but
|
|
137
|
+
* never starts it, because starting it is exactly what this function is for.
|
|
138
|
+
*
|
|
139
|
+
* `processors` are the LIVE processor instances, which only exist after
|
|
140
|
+
* `kronos` has built them — that is why this cannot be folded back into the
|
|
141
|
+
* backend factory. Pass `app.processors.values()`.
|
|
142
|
+
*
|
|
143
|
+
* Caveat on that call: `kronos` types `processors` as
|
|
144
|
+
* `ReadonlyMap<string, unknown>`, so `.values()` needs a cast today —
|
|
145
|
+
* `app.processors.values() as Iterable<ManagedEventProcessor>`. Narrowing that
|
|
146
|
+
* map in `@kronos-ts/app` would remove the cast; this package cannot.
|
|
147
|
+
*
|
|
148
|
+
* The collection is SNAPSHOTTED into a name-keyed map here, once. That is
|
|
149
|
+
* deliberate: a one-shot iterator (`Map.values()` is one) would otherwise yield
|
|
150
|
+
* nothing on the second status report. The processor OBJECTS are live, so each
|
|
151
|
+
* report still reads current `running` / `position` / segment state off them —
|
|
152
|
+
* only the membership of the set is fixed at construction.
|
|
153
|
+
*/
|
|
154
|
+
export async function axonServerControlPlane(
|
|
155
|
+
platform: PlatformConnection,
|
|
156
|
+
processors: Iterable<ManagedEventProcessor> = [],
|
|
157
|
+
): Promise<AxonServerControlPlane> {
|
|
158
|
+
// Name-keyed view so server-initiated instructions route to the right one.
|
|
159
|
+
const byName = new Map<string, ManagedEventProcessor>()
|
|
160
|
+
for (const proc of processors) byName.set(proc.name, proc)
|
|
161
|
+
|
|
162
|
+
platform.onInstruction(async (instruction) => {
|
|
163
|
+
switch (instruction.kind) {
|
|
164
|
+
case "pause-processor":
|
|
165
|
+
byName.get(instruction.processorName)?.stop?.()
|
|
166
|
+
break
|
|
167
|
+
case "start-processor":
|
|
168
|
+
await byName.get(instruction.processorName)?.start?.()
|
|
169
|
+
break
|
|
170
|
+
case "release-segment":
|
|
171
|
+
await byName.get(instruction.processorName)?.releaseSegment?.(instruction.segmentId)
|
|
172
|
+
break
|
|
173
|
+
case "split-segment":
|
|
174
|
+
await byName.get(instruction.processorName)?.splitSegment?.(instruction.segmentId)
|
|
175
|
+
break
|
|
176
|
+
case "merge-segment":
|
|
177
|
+
await byName.get(instruction.processorName)?.mergeSegment?.(instruction.segmentId)
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
platform.registerProcessorStatusSupplier(() => processorStatuses(byName.values()))
|
|
183
|
+
|
|
184
|
+
// ORDERING: strictly AFTER both registrations — see the file-level JSDoc.
|
|
185
|
+
// An instruction arriving in the gap would be dropped; an early status
|
|
186
|
+
// request would find no supplier.
|
|
187
|
+
await platform.start()
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
processors: byName,
|
|
191
|
+
async close() {
|
|
192
|
+
platform.stop()
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -27,7 +27,7 @@ export interface FlowControlledSender<T> {
|
|
|
27
27
|
* @param send Function called to actually send an update downstream.
|
|
28
28
|
* @param maxBufferSize Maximum number of updates to buffer. Default: 256.
|
|
29
29
|
*/
|
|
30
|
-
export function
|
|
30
|
+
export function flowControlledSender<T>(
|
|
31
31
|
send: (value: T) => void,
|
|
32
32
|
onComplete?: () => void,
|
|
33
33
|
onError?: (error: Error) => void,
|
package/src/index.ts
CHANGED
|
@@ -6,28 +6,38 @@ export {
|
|
|
6
6
|
|
|
7
7
|
export {
|
|
8
8
|
type AxonServerConnectionManager,
|
|
9
|
-
|
|
9
|
+
connectionManager,
|
|
10
10
|
} from "./connection-manager.js"
|
|
11
11
|
|
|
12
12
|
export {
|
|
13
|
-
|
|
13
|
+
axonServerEventStore,
|
|
14
14
|
} from "./axon-server-event-store.js"
|
|
15
15
|
|
|
16
16
|
export {
|
|
17
|
-
|
|
17
|
+
axonServerSnapshotStore,
|
|
18
18
|
} from "./axon-server-snapshot-store.js"
|
|
19
19
|
|
|
20
20
|
export {
|
|
21
21
|
axonServer,
|
|
22
|
-
|
|
22
|
+
distributedCommandBus,
|
|
23
|
+
distributedQueryBus,
|
|
24
|
+
type AxonServerConfig,
|
|
25
|
+
type AxonServerBackend,
|
|
26
|
+
type AxonServerComponents,
|
|
23
27
|
type FlowControlConfig,
|
|
24
28
|
type ProcessingInstructions,
|
|
25
29
|
} from "./axon-server.js"
|
|
26
30
|
|
|
31
|
+
export {
|
|
32
|
+
axonServerControlPlane,
|
|
33
|
+
type AxonServerControlPlane,
|
|
34
|
+
type ManagedEventProcessor,
|
|
35
|
+
} from "./control-plane.js"
|
|
36
|
+
|
|
27
37
|
export {
|
|
28
38
|
type MessageSizeConfig,
|
|
29
39
|
MessageSizeExceededError,
|
|
30
|
-
|
|
40
|
+
messageSizeValidator,
|
|
31
41
|
} from "./message-size.js"
|
|
32
42
|
|
|
33
43
|
export {
|
|
@@ -42,19 +52,19 @@ export {
|
|
|
42
52
|
type PlatformInstruction,
|
|
43
53
|
type InstructionHandler,
|
|
44
54
|
type PlatformServiceOptions,
|
|
45
|
-
|
|
55
|
+
platformConnection,
|
|
46
56
|
} from "./platform-service.js"
|
|
47
57
|
|
|
48
58
|
export {
|
|
49
59
|
type FlowControlledSender,
|
|
50
|
-
|
|
60
|
+
flowControlledSender,
|
|
51
61
|
} from "./flow-controlled-sender.js"
|
|
52
62
|
|
|
53
63
|
export {
|
|
54
64
|
type ShutdownLatch,
|
|
55
65
|
type ActivityHandle,
|
|
56
66
|
ShutdownInProgressError,
|
|
57
|
-
|
|
67
|
+
shutdownLatch,
|
|
58
68
|
} from "./shutdown-latch.js"
|
|
59
69
|
|
|
60
70
|
export {
|
|
@@ -73,3 +83,4 @@ export {
|
|
|
73
83
|
mapErrorCode,
|
|
74
84
|
isTransientError,
|
|
75
85
|
} from "./errors.js"
|
|
86
|
+
export type { AxonServerOptions } from "./axon-server.js"
|
package/src/message-size.ts
CHANGED
|
@@ -39,7 +39,7 @@ export class MessageSizeExceededError extends Error {
|
|
|
39
39
|
* - `validate(data)` — throws if over limit, warns if over 75%
|
|
40
40
|
* - `estimateSize(payload)` — quick byte size estimate
|
|
41
41
|
*/
|
|
42
|
-
export function
|
|
42
|
+
export function messageSizeValidator(config?: MessageSizeConfig) {
|
|
43
43
|
const maxSize = config?.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE
|
|
44
44
|
const threshold = config?.warningThreshold ?? WARNING_THRESHOLD
|
|
45
45
|
const warningSize = Math.floor(maxSize * threshold)
|
package/src/outbound-stream.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface OutboundStream<T> {
|
|
|
12
12
|
close(): void
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function
|
|
15
|
+
export function outboundStream<T>(): OutboundStream<T> {
|
|
16
16
|
let resolve: ((value: IteratorResult<T>) => void) | null = null
|
|
17
17
|
const queue: T[] = []
|
|
18
18
|
let closed = false
|
package/src/platform-service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AxonServerConnection } from "./connection.js"
|
|
2
|
-
import {
|
|
2
|
+
import { outboundStream } from "./outbound-stream.js"
|
|
3
3
|
import type { PlatformInboundInstruction } from "./generated/control.js"
|
|
4
4
|
import { Metadata } from "nice-grpc"
|
|
5
5
|
import type { ProcessorStatusSupplier } from "./event-processor-info.js"
|
|
@@ -36,9 +36,40 @@ export type InstructionHandler = (instruction: PlatformInstruction) => void | Pr
|
|
|
36
36
|
* - Receives server-initiated instructions (pause, resume, split, merge segments)
|
|
37
37
|
*/
|
|
38
38
|
export interface PlatformConnection {
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* DATA PATH. Open the platform stream, register this client, and arm the
|
|
41
|
+
* heartbeat that calls `connection.reconnect()` when the server stops
|
|
42
|
+
* answering.
|
|
43
|
+
*
|
|
44
|
+
* This is split out of {@link start} deliberately. Reconnect detection is a
|
|
45
|
+
* property of the CONNECTION, and the command/query buses hook
|
|
46
|
+
* `connection.onReconnect(...)` to re-establish their own streams — so a
|
|
47
|
+
* service that never opts into remote administration still needs it. When it
|
|
48
|
+
* lived only inside `start()` (which only the control plane calls), such a
|
|
49
|
+
* service had no heartbeat-driven reconnect detection on its data path at all
|
|
50
|
+
* and would sit on a dead channel indefinitely.
|
|
51
|
+
*
|
|
52
|
+
* Arms NOTHING control-plane-specific: no processor status reporting. Safe to
|
|
53
|
+
* call repeatedly; a stream that is already up is left alone.
|
|
54
|
+
*/
|
|
55
|
+
armConnectionMonitoring(): Promise<void>
|
|
56
|
+
/**
|
|
57
|
+
* CONTROL PLANE. Everything {@link armConnectionMonitoring} does, plus
|
|
58
|
+
* periodic processor status reporting.
|
|
59
|
+
*
|
|
60
|
+
* Idempotent in both halves: if the data path already opened the stream, this
|
|
61
|
+
* only adds status reporting; if it did not, this opens the stream too.
|
|
62
|
+
*/
|
|
40
63
|
start(): Promise<void>
|
|
41
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Stop the platform stream, heartbeats and status reporting.
|
|
66
|
+
*
|
|
67
|
+
* Note that this tears down the SHARED stream — including the data path's
|
|
68
|
+
* reconnect detection. `axonServerControlPlane(...).close()` calls it, which
|
|
69
|
+
* is correct in the documented shutdown order (`app.stop()` → `control.close()`
|
|
70
|
+
* → `axon.close()`) but means closing a control plane on a still-running
|
|
71
|
+
* service disarms reconnect detection with it.
|
|
72
|
+
*/
|
|
42
73
|
stop(): void
|
|
43
74
|
/** Register a handler for server-initiated instructions. */
|
|
44
75
|
onInstruction(handler: InstructionHandler): void
|
|
@@ -98,7 +129,7 @@ export interface PlatformServiceOptions {
|
|
|
98
129
|
* 2. Sends periodic heartbeats to verify connectivity
|
|
99
130
|
* 3. Receives instructions from Axon Server (split, merge, pause, resume)
|
|
100
131
|
*/
|
|
101
|
-
export function
|
|
132
|
+
export function platformConnection(
|
|
102
133
|
connection: AxonServerConnection,
|
|
103
134
|
options?: PlatformServiceOptions,
|
|
104
135
|
): PlatformConnection {
|
|
@@ -109,12 +140,27 @@ export function createPlatformConnection(
|
|
|
109
140
|
|
|
110
141
|
const instructionHandlers: InstructionHandler[] = []
|
|
111
142
|
const processorStatusSuppliers: ProcessorStatusSupplier[] = []
|
|
143
|
+
/**
|
|
144
|
+
* Instructions that arrived before anything registered a handler.
|
|
145
|
+
*
|
|
146
|
+
* The control plane registers its handler before calling `start()`, but the
|
|
147
|
+
* DATA path now opens the same stream via `armConnectionMonitoring()` — which
|
|
148
|
+
* runs before any control plane exists. That leaves a window in which Axon
|
|
149
|
+
* Server can push an instruction at a client with nothing to route it to.
|
|
150
|
+
* Buffering makes the window harmless instead of merely forbidden: the first
|
|
151
|
+
* `onInstruction` registration drains this queue in arrival order. Mirrors the
|
|
152
|
+
* kronosdb platform connection, which has had this since its backend started
|
|
153
|
+
* the stream for `subscriptionsAcked()`.
|
|
154
|
+
*/
|
|
155
|
+
const pendingInstructions: PlatformInstruction[] = []
|
|
112
156
|
let isConnected = false
|
|
113
157
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
114
158
|
let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
|
115
159
|
let processorStatusTimer: ReturnType<typeof setInterval> | null = null
|
|
160
|
+
/** Guards against arming the status-report timer twice — see `startProcessorStatusReporting`. */
|
|
161
|
+
let processorStatusArmed = false
|
|
116
162
|
let lastHeartbeatResponse = Date.now()
|
|
117
|
-
let outbound: ReturnType<typeof
|
|
163
|
+
let outbound: ReturnType<typeof outboundStream<PlatformInboundInstruction>> | null = null
|
|
118
164
|
/**
|
|
119
165
|
* Latches once Axon Server sends its first inbound message after
|
|
120
166
|
* registration — the earliest observable signal that the platform stream
|
|
@@ -140,6 +186,11 @@ export function createPlatformConnection(
|
|
|
140
186
|
// Parse instruction type
|
|
141
187
|
const instruction = parseInstruction(message)
|
|
142
188
|
if (instruction) {
|
|
189
|
+
if (instructionHandlers.length === 0) {
|
|
190
|
+
// Nothing to route to yet — hold it for the first registration
|
|
191
|
+
// rather than dropping it.
|
|
192
|
+
pendingInstructions.push(instruction)
|
|
193
|
+
}
|
|
143
194
|
for (const handler of instructionHandlers) {
|
|
144
195
|
try {
|
|
145
196
|
await handler(instruction)
|
|
@@ -240,6 +291,14 @@ export function createPlatformConnection(
|
|
|
240
291
|
}
|
|
241
292
|
|
|
242
293
|
function startProcessorStatusReporting() {
|
|
294
|
+
// Idempotency guard. The control plane may call `start()` after the data
|
|
295
|
+
// path already brought the stream up, and `start()` is itself documented as
|
|
296
|
+
// safe to call twice. Without this flag a second call inside the initial
|
|
297
|
+
// delay window would leave TWO pending timeouts, each of which installs an
|
|
298
|
+
// interval, and only the last would be tracked in `processorStatusTimer` —
|
|
299
|
+
// the first would leak past `stop()`.
|
|
300
|
+
if (processorStatusArmed) return
|
|
301
|
+
processorStatusArmed = true
|
|
243
302
|
if (processorStatusTimer) clearInterval(processorStatusTimer)
|
|
244
303
|
|
|
245
304
|
// Initial delay before first report
|
|
@@ -273,57 +332,87 @@ export function createPlatformConnection(
|
|
|
273
332
|
}
|
|
274
333
|
}
|
|
275
334
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Open the platform stream and arm the heartbeat. Shared by
|
|
337
|
+
* `armConnectionMonitoring()` (data path) and `start()` (control plane);
|
|
338
|
+
* whichever runs first opens it, the other finds it up and returns.
|
|
339
|
+
*/
|
|
340
|
+
function openPlatformStream() {
|
|
341
|
+
if (isConnected) return
|
|
342
|
+
|
|
343
|
+
// A heartbeat timeout clears `isConnected` WITHOUT going through `stop()`,
|
|
344
|
+
// so a later call can land here with the dropped stream's outbound still
|
|
345
|
+
// open. Close it before replacing the reference, or it leaks. This is more
|
|
346
|
+
// reachable now that the data path arms the heartbeat: previously only a
|
|
347
|
+
// control plane could get here after a timeout.
|
|
348
|
+
if (outbound) {
|
|
349
|
+
outbound.close()
|
|
350
|
+
outbound = null
|
|
351
|
+
}
|
|
283
352
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
353
|
+
// Re-arm the ack latch so a stop/start cycle correctly re-waits.
|
|
354
|
+
acked = false
|
|
355
|
+
outbound = outboundStream<PlatformInboundInstruction>()
|
|
356
|
+
|
|
357
|
+
// Register with Axon Server
|
|
358
|
+
outbound.send({
|
|
359
|
+
register: {
|
|
360
|
+
clientId: connection.config.clientId,
|
|
361
|
+
componentName: connection.config.componentName,
|
|
362
|
+
version: "1.0.0",
|
|
363
|
+
tags: {},
|
|
364
|
+
},
|
|
365
|
+
instructionId: "",
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
// Open bidirectional stream
|
|
369
|
+
const inbound = connection.platform.openStream(outbound.iterable, {
|
|
370
|
+
metadata: grpcMetadata,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
isConnected = true
|
|
374
|
+
// DATA PATH: the heartbeat is what calls connection.reconnect() on
|
|
375
|
+
// timeout, which is what the command/query buses hang their stream
|
|
376
|
+
// re-establishment off. It belongs to every service, administered or not.
|
|
377
|
+
startHeartbeat()
|
|
378
|
+
processInboundInstructions(inbound)
|
|
379
|
+
|
|
380
|
+
// Axon Server's PlatformService does NOT proactively emit an inbound
|
|
381
|
+
// frame in response to `register` — the stream is held open silently
|
|
382
|
+
// until either (a) the server pushes a topology / instruction event,
|
|
383
|
+
// or (b) one of our heartbeat pings round-trips back. That means the
|
|
384
|
+
// first-inbound-frame ack signal used by kronosdb (Plan 09-03 / D-102)
|
|
385
|
+
// doesn't fire deterministically here, and the processors-stage
|
|
386
|
+
// `withRetry({event: "per-operation"})` poll would otherwise hang.
|
|
387
|
+
//
|
|
388
|
+
// Axon-specific ack derivation: latch `acked = true` immediately once
|
|
389
|
+
// the outbound `register` frame has been flushed to the gRPC layer.
|
|
390
|
+
// Bus subscriptions (sent on the command/query streams, NOT the
|
|
391
|
+
// platform stream) are an orthogonal concern handled by the
|
|
392
|
+
// command/query bus reconnect path — the legacy 1-second sleep that
|
|
393
|
+
// we replaced was always covering register processing, not bus-side
|
|
394
|
+
// routability. Structurally this is the Axon Server equivalent of
|
|
395
|
+
// D-102: drop the magic-number wait, use the earliest deterministic
|
|
396
|
+
// observable signal that fits the underlying protocol.
|
|
397
|
+
acked = true
|
|
398
|
+
}
|
|
294
399
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
400
|
+
return {
|
|
401
|
+
async armConnectionMonitoring() {
|
|
402
|
+
openPlatformStream()
|
|
403
|
+
},
|
|
299
404
|
|
|
300
|
-
|
|
301
|
-
|
|
405
|
+
async start() {
|
|
406
|
+
openPlatformStream()
|
|
302
407
|
startProcessorStatusReporting()
|
|
303
|
-
processInboundInstructions(inbound)
|
|
304
|
-
|
|
305
|
-
// Axon Server's PlatformService does NOT proactively emit an inbound
|
|
306
|
-
// frame in response to `register` — the stream is held open silently
|
|
307
|
-
// until either (a) the server pushes a topology / instruction event,
|
|
308
|
-
// or (b) one of our heartbeat pings round-trips back. That means the
|
|
309
|
-
// first-inbound-frame ack signal used by kronosdb (Plan 09-03 / D-102)
|
|
310
|
-
// doesn't fire deterministically here, and the processors-stage
|
|
311
|
-
// `withRetry({event: "per-operation"})` poll would otherwise hang.
|
|
312
|
-
//
|
|
313
|
-
// Axon-specific ack derivation: latch `acked = true` immediately once
|
|
314
|
-
// the outbound `register` frame has been flushed to the gRPC layer.
|
|
315
|
-
// Bus subscriptions (sent on the command/query streams, NOT the
|
|
316
|
-
// platform stream) are an orthogonal concern handled by the
|
|
317
|
-
// command/query bus reconnect path — the legacy 1-second sleep that
|
|
318
|
-
// we replaced was always covering register processing, not bus-side
|
|
319
|
-
// routability. Structurally this is the Axon Server equivalent of
|
|
320
|
-
// D-102: drop the magic-number wait, use the earliest deterministic
|
|
321
|
-
// observable signal that fits the underlying protocol.
|
|
322
|
-
acked = true
|
|
323
408
|
},
|
|
324
409
|
|
|
325
410
|
stop() {
|
|
326
411
|
isConnected = false
|
|
412
|
+
// A stopped stream's un-routed backlog is stale — do not replay it if a
|
|
413
|
+
// handler registers later.
|
|
414
|
+
pendingInstructions.length = 0
|
|
415
|
+
processorStatusArmed = false
|
|
327
416
|
if (heartbeatTimer) {
|
|
328
417
|
clearInterval(heartbeatTimer)
|
|
329
418
|
heartbeatTimer = null
|
|
@@ -343,7 +432,22 @@ export function createPlatformConnection(
|
|
|
343
432
|
},
|
|
344
433
|
|
|
345
434
|
onInstruction(handler) {
|
|
435
|
+
const isFirst = instructionHandlers.length === 0
|
|
346
436
|
instructionHandlers.push(handler)
|
|
437
|
+
|
|
438
|
+
// Drain anything that arrived before a handler existed, in arrival order.
|
|
439
|
+
if (isFirst && pendingInstructions.length > 0) {
|
|
440
|
+
const backlog = pendingInstructions.splice(0, pendingInstructions.length)
|
|
441
|
+
void (async () => {
|
|
442
|
+
for (const instruction of backlog) {
|
|
443
|
+
try {
|
|
444
|
+
await handler(instruction)
|
|
445
|
+
} catch (err) {
|
|
446
|
+
console.error("Platform instruction handler error:", err)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
})()
|
|
450
|
+
}
|
|
347
451
|
},
|
|
348
452
|
|
|
349
453
|
registerProcessorStatusSupplier(supplier) {
|
package/src/shutdown-latch.ts
CHANGED
|
@@ -43,7 +43,7 @@ export class ShutdownInProgressError extends Error {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function
|
|
46
|
+
export function shutdownLatch(): ShutdownLatch {
|
|
47
47
|
let activeCount = 0
|
|
48
48
|
let shuttingDown = false
|
|
49
49
|
let drainResolve: (() => void) | null = null
|