@causal-order/monitor 0.0.9
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/.build/src/config.d.ts +1 -0
- package/.build/src/config.js +1 -0
- package/.build/src/health/HealthTracker.d.ts +12 -0
- package/.build/src/health/HealthTracker.js +133 -0
- package/.build/src/health.d.ts +1 -0
- package/.build/src/health.js +1 -0
- package/.build/src/index.d.ts +16 -0
- package/.build/src/index.js +15 -0
- package/.build/src/inspect/inspectMonitorSnapshot.d.ts +2 -0
- package/.build/src/inspect/inspectMonitorSnapshot.js +79 -0
- package/.build/src/inspect.d.ts +1 -0
- package/.build/src/inspect.js +1 -0
- package/.build/src/replay/ReplayCoordinator.d.ts +24 -0
- package/.build/src/replay/ReplayCoordinator.js +205 -0
- package/.build/src/replay.d.ts +1 -0
- package/.build/src/replay.js +1 -0
- package/.build/src/routing/DeliveryRouter.d.ts +8 -0
- package/.build/src/routing/DeliveryRouter.js +48 -0
- package/.build/src/routing.d.ts +1 -0
- package/.build/src/routing.js +1 -0
- package/.build/src/runtime/MonitorRuntime.d.ts +35 -0
- package/.build/src/runtime/MonitorRuntime.js +253 -0
- package/.build/src/runtime/createMonitorRuntime.d.ts +3 -0
- package/.build/src/runtime/createMonitorRuntime.js +4 -0
- package/.build/src/runtime.d.ts +2 -0
- package/.build/src/runtime.js +2 -0
- package/.build/src/storage/SQLiteReservoir.d.ts +44 -0
- package/.build/src/storage/SQLiteReservoir.js +369 -0
- package/.build/src/storage/schema.d.ts +3 -0
- package/.build/src/storage/schema.js +80 -0
- package/.build/src/storage.d.ts +1 -0
- package/.build/src/storage.js +1 -0
- package/.build/src/testing/monitorHarness.d.ts +27 -0
- package/.build/src/testing/monitorHarness.js +98 -0
- package/.build/src/throttle/ThrottleController.d.ts +14 -0
- package/.build/src/throttle/ThrottleController.js +51 -0
- package/.build/src/throttle.d.ts +1 -0
- package/.build/src/throttle.js +1 -0
- package/.build/src/transport/TransportMonitorAdapter.d.ts +42 -0
- package/.build/src/transport/TransportMonitorAdapter.js +135 -0
- package/.build/src/transport.d.ts +1 -0
- package/.build/src/transport.js +1 -0
- package/.build/src/types/config.d.ts +46 -0
- package/.build/src/types/config.js +54 -0
- package/.build/src/types/events.d.ts +51 -0
- package/.build/src/types/events.js +1 -0
- package/.build/src/types/snapshots.d.ts +63 -0
- package/.build/src/types/snapshots.js +1 -0
- package/.build/src/types.d.ts +3 -0
- package/.build/src/types.js +3 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/package.json +128 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createDefaultMonitorConfig, } from "../types/config.js";
|
|
2
|
+
import { HealthTracker } from "../health/HealthTracker.js";
|
|
3
|
+
import { inspectMonitorSnapshot } from "../inspect/inspectMonitorSnapshot.js";
|
|
4
|
+
import { DeliveryRouter } from "../routing/DeliveryRouter.js";
|
|
5
|
+
import { ReplayCoordinator } from "../replay/ReplayCoordinator.js";
|
|
6
|
+
import { SQLiteReservoir } from "../storage/SQLiteReservoir.js";
|
|
7
|
+
import { ThrottleController } from "../throttle/ThrottleController.js";
|
|
8
|
+
export class MonitorRuntime {
|
|
9
|
+
#config;
|
|
10
|
+
#now;
|
|
11
|
+
#healthTracker;
|
|
12
|
+
#reservoir;
|
|
13
|
+
#router;
|
|
14
|
+
#replay;
|
|
15
|
+
#throttleTier;
|
|
16
|
+
constructor(config = {}) {
|
|
17
|
+
const defaults = createDefaultMonitorConfig();
|
|
18
|
+
this.#config = {
|
|
19
|
+
...defaults,
|
|
20
|
+
...config,
|
|
21
|
+
reservoir: {
|
|
22
|
+
...defaults.reservoir,
|
|
23
|
+
...config.reservoir,
|
|
24
|
+
},
|
|
25
|
+
transport: {
|
|
26
|
+
...defaults.transport,
|
|
27
|
+
...config.transport,
|
|
28
|
+
},
|
|
29
|
+
health: {
|
|
30
|
+
transport: {
|
|
31
|
+
...defaults.health.transport,
|
|
32
|
+
...config.health?.transport,
|
|
33
|
+
},
|
|
34
|
+
dedupe: {
|
|
35
|
+
...defaults.health.dedupe,
|
|
36
|
+
...config.health?.dedupe,
|
|
37
|
+
},
|
|
38
|
+
causalOrder: {
|
|
39
|
+
...defaults.health.causalOrder,
|
|
40
|
+
...config.health?.causalOrder,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
throttle: {
|
|
44
|
+
...defaults.throttle,
|
|
45
|
+
...config.throttle,
|
|
46
|
+
open: {
|
|
47
|
+
...defaults.throttle.open,
|
|
48
|
+
...config.throttle?.open,
|
|
49
|
+
},
|
|
50
|
+
slow: {
|
|
51
|
+
...defaults.throttle.slow,
|
|
52
|
+
...config.throttle?.slow,
|
|
53
|
+
},
|
|
54
|
+
verySlow: {
|
|
55
|
+
...defaults.throttle.verySlow,
|
|
56
|
+
...config.throttle?.verySlow,
|
|
57
|
+
},
|
|
58
|
+
paused: {
|
|
59
|
+
...defaults.throttle.paused,
|
|
60
|
+
...config.throttle?.paused,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
replay: {
|
|
64
|
+
...defaults.replay,
|
|
65
|
+
...config.replay,
|
|
66
|
+
},
|
|
67
|
+
now: config.now ?? defaults.now,
|
|
68
|
+
};
|
|
69
|
+
this.#now = this.#config.now ?? defaults.now;
|
|
70
|
+
this.#healthTracker = new HealthTracker(this.#config.health, this.#now);
|
|
71
|
+
this.#reservoir = new SQLiteReservoir(this.#config.reservoir, this.#now);
|
|
72
|
+
this.#router = new DeliveryRouter(new ThrottleController(this.#config.throttle));
|
|
73
|
+
this.#replay = new ReplayCoordinator(this.#config.replay, this.#reservoir, this.#now);
|
|
74
|
+
this.#throttleTier = this.#config.throttle.defaultTier;
|
|
75
|
+
}
|
|
76
|
+
getConfig() {
|
|
77
|
+
return this.#config;
|
|
78
|
+
}
|
|
79
|
+
getSnapshot() {
|
|
80
|
+
return {
|
|
81
|
+
generatedAt: this.#now(),
|
|
82
|
+
routingMode: this.#deriveRoutingMode(),
|
|
83
|
+
throttleTier: this.#throttleTier,
|
|
84
|
+
components: this.getHealthSnapshot(),
|
|
85
|
+
reservoir: this.getReservoirStats(),
|
|
86
|
+
replay: this.getReplaySnapshot(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
getInspectedSnapshot() {
|
|
90
|
+
return inspectMonitorSnapshot(this.getSnapshot());
|
|
91
|
+
}
|
|
92
|
+
getHealthSnapshot() {
|
|
93
|
+
return this.#healthTracker.getSnapshot();
|
|
94
|
+
}
|
|
95
|
+
getReservoirStats() {
|
|
96
|
+
return this.#reservoir.getStats();
|
|
97
|
+
}
|
|
98
|
+
getReplaySnapshot() {
|
|
99
|
+
return this.#replay.getSnapshot();
|
|
100
|
+
}
|
|
101
|
+
getIngressDecision() {
|
|
102
|
+
const decision = this.#router.decideIngress(this.#deriveRoutingMode(), this.getReservoirStats());
|
|
103
|
+
const gatedDecision = this.#config.replay.pauseLiveFlowDuringReplay && this.#replay.isGateClosed()
|
|
104
|
+
? {
|
|
105
|
+
...decision,
|
|
106
|
+
action: "buffer_only",
|
|
107
|
+
reason: `${decision.reason}; replay gate holding live flow`,
|
|
108
|
+
}
|
|
109
|
+
: decision;
|
|
110
|
+
this.#throttleTier = gatedDecision.throttleTier;
|
|
111
|
+
return gatedDecision;
|
|
112
|
+
}
|
|
113
|
+
updateComponentHealth(component, update) {
|
|
114
|
+
const snapshot = this.#healthTracker.updateComponentHealth(component, update);
|
|
115
|
+
this.#reservoir.recordHealthTransition(snapshot);
|
|
116
|
+
if (component === "dedupe" && snapshot.state === "offline") {
|
|
117
|
+
this.#throttleTier = "slow";
|
|
118
|
+
}
|
|
119
|
+
if (component === "causal-order" && snapshot.state === "offline") {
|
|
120
|
+
this.#throttleTier = "paused";
|
|
121
|
+
}
|
|
122
|
+
if (component === "transport" &&
|
|
123
|
+
snapshot.state === "offline" &&
|
|
124
|
+
this.getReservoirStats().totalPendingRows === 0) {
|
|
125
|
+
this.#throttleTier = "paused";
|
|
126
|
+
}
|
|
127
|
+
const components = this.#healthTracker.getSnapshot();
|
|
128
|
+
if (components.transport.state === "online" &&
|
|
129
|
+
components.dedupe.state === "online" &&
|
|
130
|
+
components["causal-order"].state === "online") {
|
|
131
|
+
this.#throttleTier = this.#config.throttle.defaultTier;
|
|
132
|
+
}
|
|
133
|
+
this.#reconcileReplayQueue();
|
|
134
|
+
return snapshot;
|
|
135
|
+
}
|
|
136
|
+
observeHeartbeat(component, observedAt = this.#now(), details = {}) {
|
|
137
|
+
const snapshot = this.#healthTracker.observeHeartbeat(component, observedAt, details);
|
|
138
|
+
this.#reservoir.recordHealthTransition(snapshot);
|
|
139
|
+
this.#replay.observeRecoveryHeartbeat(component, snapshot.state, this.#areReplayTargetsHealthy());
|
|
140
|
+
this.#reconcileReplayQueue();
|
|
141
|
+
return snapshot;
|
|
142
|
+
}
|
|
143
|
+
setThrottleTier(tier) {
|
|
144
|
+
this.#throttleTier = tier;
|
|
145
|
+
return this.#throttleTier;
|
|
146
|
+
}
|
|
147
|
+
queueReplay() {
|
|
148
|
+
return this.#replay.queueIfNeeded();
|
|
149
|
+
}
|
|
150
|
+
startReplay() {
|
|
151
|
+
if (!this.#areReplayTargetsHealthy()) {
|
|
152
|
+
throw new Error("Cannot start replay until both dedupe and causal-order are online.");
|
|
153
|
+
}
|
|
154
|
+
this.#replay.queueIfNeeded();
|
|
155
|
+
return this.#replay.start();
|
|
156
|
+
}
|
|
157
|
+
claimReplayBatch(limit = this.#config.throttle.verySlow.batchSize) {
|
|
158
|
+
if (!this.#areReplayTargetsHealthy()) {
|
|
159
|
+
throw new Error("Cannot claim replay batch until both dedupe and causal-order are online.");
|
|
160
|
+
}
|
|
161
|
+
this.#replay.queueIfNeeded();
|
|
162
|
+
return this.#replay.claimNextBatch(limit);
|
|
163
|
+
}
|
|
164
|
+
acknowledgeReplayBatch(rowIds) {
|
|
165
|
+
return this.#replay.acknowledgeBatch(rowIds);
|
|
166
|
+
}
|
|
167
|
+
acknowledgeIngressDelivery(rowIds) {
|
|
168
|
+
return this.#reservoir.markIngressRowsDelivered(rowIds);
|
|
169
|
+
}
|
|
170
|
+
failReplay(error, rowIds = []) {
|
|
171
|
+
return this.#replay.fail(error, rowIds);
|
|
172
|
+
}
|
|
173
|
+
abortReplay(reason, rowIds = []) {
|
|
174
|
+
return this.#replay.abort(reason, rowIds);
|
|
175
|
+
}
|
|
176
|
+
ingestTransportEvent(event, sourceStreamId) {
|
|
177
|
+
const decision = this.getIngressDecision();
|
|
178
|
+
const rowId = this.#reservoir.appendIngressEvent(event, {
|
|
179
|
+
sourcePath: "transport_normalized_stream",
|
|
180
|
+
sourceStreamId,
|
|
181
|
+
deliveryMode: decision.deliveryMode,
|
|
182
|
+
fullOutageActive: this.#isFullOutageActive(),
|
|
183
|
+
});
|
|
184
|
+
return { rowId, decision };
|
|
185
|
+
}
|
|
186
|
+
observeDedupeEvent(event, sourceStreamId) {
|
|
187
|
+
return this.#reservoir.appendIngressEvent(event, {
|
|
188
|
+
sourcePath: "deduped_observation",
|
|
189
|
+
sourceStreamId,
|
|
190
|
+
deliveryMode: "normal",
|
|
191
|
+
replayState: "delivered",
|
|
192
|
+
fullOutageActive: false,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
pruneReservoir() {
|
|
196
|
+
return this.#reservoir.pruneExpired(this.#isFullOutageActive());
|
|
197
|
+
}
|
|
198
|
+
refreshHealthStates(at = this.#now()) {
|
|
199
|
+
const before = this.#healthTracker.getSnapshot();
|
|
200
|
+
const after = this.#healthTracker.refreshStates(at);
|
|
201
|
+
for (const component of ["transport", "dedupe", "causal-order"]) {
|
|
202
|
+
if (before[component].state !== after[component].state) {
|
|
203
|
+
this.#reservoir.recordHealthTransition(after[component]);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.#reconcileReplayQueue();
|
|
207
|
+
return after;
|
|
208
|
+
}
|
|
209
|
+
close() {
|
|
210
|
+
this.#reservoir.close();
|
|
211
|
+
}
|
|
212
|
+
#deriveRoutingMode() {
|
|
213
|
+
const components = this.#healthTracker.getSnapshot();
|
|
214
|
+
const transport = components.transport.state;
|
|
215
|
+
const dedupe = components.dedupe.state;
|
|
216
|
+
const causalOrder = components["causal-order"].state;
|
|
217
|
+
if (this.#replay.isGateClosed()) {
|
|
218
|
+
return "replay_through_dedupe";
|
|
219
|
+
}
|
|
220
|
+
if (dedupe === "offline" && causalOrder === "offline") {
|
|
221
|
+
return "full_outage_buffer";
|
|
222
|
+
}
|
|
223
|
+
if (causalOrder === "offline") {
|
|
224
|
+
return "order_buffer_only";
|
|
225
|
+
}
|
|
226
|
+
if (dedupe === "offline") {
|
|
227
|
+
return "dedupe_bypass_throttled";
|
|
228
|
+
}
|
|
229
|
+
if (transport === "offline" && this.getReservoirStats().totalPendingRows > 0) {
|
|
230
|
+
return "order_buffer_only";
|
|
231
|
+
}
|
|
232
|
+
return "normal";
|
|
233
|
+
}
|
|
234
|
+
#isFullOutageActive() {
|
|
235
|
+
const components = this.#healthTracker.getSnapshot();
|
|
236
|
+
return (components.dedupe.state === "offline" &&
|
|
237
|
+
components["causal-order"].state === "offline");
|
|
238
|
+
}
|
|
239
|
+
#areReplayTargetsHealthy() {
|
|
240
|
+
const components = this.#healthTracker.getSnapshot();
|
|
241
|
+
return (components.dedupe.state === "online" &&
|
|
242
|
+
components["causal-order"].state === "online");
|
|
243
|
+
}
|
|
244
|
+
#reconcileReplayQueue() {
|
|
245
|
+
const backlog = this.getReservoirStats().totalPendingRows;
|
|
246
|
+
if (backlog === 0) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (this.#areReplayTargetsHealthy()) {
|
|
250
|
+
this.#replay.queueIfNeeded();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MonitorReservoirConfig } from "../types/config.js";
|
|
2
|
+
import type { MonitorDeliveryMode, MonitorIngressEvent, MonitorSourcePath } from "../types/events.js";
|
|
3
|
+
import type { MonitorComponentHealthSnapshot, ReplaySessionSnapshot, ReservoirStats } from "../types/snapshots.js";
|
|
4
|
+
interface ReservoirIngressOptions {
|
|
5
|
+
sourcePath: MonitorSourcePath;
|
|
6
|
+
deliveryMode: MonitorDeliveryMode;
|
|
7
|
+
sourceStreamId?: string | null;
|
|
8
|
+
replayState?: ReservoirReplayState;
|
|
9
|
+
retryNotBeforeMs?: bigint | null;
|
|
10
|
+
monitorIngestAt?: bigint;
|
|
11
|
+
fullOutageActive?: boolean;
|
|
12
|
+
}
|
|
13
|
+
type ReservoirReplayState = "pending" | "replaying" | "delivered" | "dead_letter";
|
|
14
|
+
export interface ReservoirReplayEntry {
|
|
15
|
+
rowId: number;
|
|
16
|
+
event: MonitorIngressEvent;
|
|
17
|
+
sourcePath: MonitorSourcePath;
|
|
18
|
+
sourceStreamId: string | null;
|
|
19
|
+
deliveryMode: MonitorDeliveryMode;
|
|
20
|
+
replayAttempts: number;
|
|
21
|
+
}
|
|
22
|
+
interface PruneResult {
|
|
23
|
+
markedDeadLetter: number;
|
|
24
|
+
deletedRows: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class SQLiteReservoir {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(config: MonitorReservoirConfig, now: () => bigint);
|
|
29
|
+
appendIngressEvent(event: MonitorIngressEvent, options: ReservoirIngressOptions): number;
|
|
30
|
+
recordHealthTransition(snapshot: MonitorComponentHealthSnapshot): number;
|
|
31
|
+
recordReplaySnapshot(snapshot: ReplaySessionSnapshot): number;
|
|
32
|
+
bumpReplayAttempts(limitToStates?: ReadonlyArray<ReservoirReplayState>): number;
|
|
33
|
+
updateReplayState(fromStates: ReadonlyArray<ReservoirReplayState>, nextState: ReservoirReplayState): number;
|
|
34
|
+
claimReplayBatch(limit: number, deliveryMode?: MonitorDeliveryMode): ReservoirReplayEntry[];
|
|
35
|
+
markReplayBatchDelivered(rowIds: ReadonlyArray<number>): number;
|
|
36
|
+
markIngressRowsDelivered(rowIds: ReadonlyArray<number>): number;
|
|
37
|
+
resetReplayBatchToPending(rowIds: ReadonlyArray<number>, retryNotBeforeMs?: bigint | null): number;
|
|
38
|
+
deadLetterReplayBatch(rowIds: ReadonlyArray<number>): number;
|
|
39
|
+
pruneExpired(fullOutageActive?: boolean): PruneResult;
|
|
40
|
+
getStats(): ReservoirStats;
|
|
41
|
+
getDatabasePath(): string;
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { applyMonitorSchema } from "./schema.js";
|
|
3
|
+
const BIGINT_SENTINEL = "__monitorBigInt";
|
|
4
|
+
function toSqlBigInt(value) {
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
function parseCount(value) {
|
|
8
|
+
if (typeof value === "number") {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === "bigint") {
|
|
12
|
+
return Number(value);
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === "string") {
|
|
15
|
+
return Number(value);
|
|
16
|
+
}
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
function parseBigInt(value) {
|
|
20
|
+
if (typeof value === "bigint") {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "number") {
|
|
24
|
+
return BigInt(value);
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === "string" && value.length > 0) {
|
|
27
|
+
return BigInt(value);
|
|
28
|
+
}
|
|
29
|
+
return 0n;
|
|
30
|
+
}
|
|
31
|
+
function serializeJson(value) {
|
|
32
|
+
return JSON.stringify(value, (_key, currentValue) => typeof currentValue === "bigint"
|
|
33
|
+
? {
|
|
34
|
+
[BIGINT_SENTINEL]: currentValue.toString(),
|
|
35
|
+
}
|
|
36
|
+
: currentValue);
|
|
37
|
+
}
|
|
38
|
+
function deserializeJson(value) {
|
|
39
|
+
return JSON.parse(value, (_key, currentValue) => {
|
|
40
|
+
if (currentValue &&
|
|
41
|
+
typeof currentValue === "object" &&
|
|
42
|
+
BIGINT_SENTINEL in currentValue &&
|
|
43
|
+
typeof currentValue[BIGINT_SENTINEL] === "string") {
|
|
44
|
+
return BigInt(currentValue[BIGINT_SENTINEL]);
|
|
45
|
+
}
|
|
46
|
+
return currentValue;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export class SQLiteReservoir {
|
|
50
|
+
#config;
|
|
51
|
+
#now;
|
|
52
|
+
#db;
|
|
53
|
+
constructor(config, now) {
|
|
54
|
+
this.#config = config;
|
|
55
|
+
this.#now = now;
|
|
56
|
+
this.#db = new Database(config.databasePath);
|
|
57
|
+
applyMonitorSchema(this.#db);
|
|
58
|
+
}
|
|
59
|
+
appendIngressEvent(event, options) {
|
|
60
|
+
const monitorIngestAt = options.monitorIngestAt ?? event.ingestedAt ?? this.#now();
|
|
61
|
+
const retentionWindowMs = options.fullOutageActive
|
|
62
|
+
? this.#config.fullOutageMaxWindowMs
|
|
63
|
+
: this.#config.rollingBufferWindowMs;
|
|
64
|
+
const expiresAt = monitorIngestAt + retentionWindowMs;
|
|
65
|
+
const replayState = options.replayState ??
|
|
66
|
+
(options.sourcePath === "deduped_observation" ? "delivered" : "pending");
|
|
67
|
+
const result = this.#db
|
|
68
|
+
.prepare(`INSERT INTO ingress_events (
|
|
69
|
+
id,
|
|
70
|
+
monitor_ingest_at_ms,
|
|
71
|
+
source_node_id,
|
|
72
|
+
source_stream_id,
|
|
73
|
+
source_path,
|
|
74
|
+
event_id,
|
|
75
|
+
trace_id,
|
|
76
|
+
sequence,
|
|
77
|
+
logical_time_ms,
|
|
78
|
+
payload_json,
|
|
79
|
+
payload_encoding,
|
|
80
|
+
delivery_mode,
|
|
81
|
+
replay_state,
|
|
82
|
+
replay_attempts,
|
|
83
|
+
retry_not_before_ms,
|
|
84
|
+
expires_at_ms
|
|
85
|
+
) VALUES (
|
|
86
|
+
@id,
|
|
87
|
+
@monitor_ingest_at_ms,
|
|
88
|
+
@source_node_id,
|
|
89
|
+
@source_stream_id,
|
|
90
|
+
@source_path,
|
|
91
|
+
@event_id,
|
|
92
|
+
@trace_id,
|
|
93
|
+
@sequence,
|
|
94
|
+
@logical_time_ms,
|
|
95
|
+
@payload_json,
|
|
96
|
+
@payload_encoding,
|
|
97
|
+
@delivery_mode,
|
|
98
|
+
@replay_state,
|
|
99
|
+
@replay_attempts,
|
|
100
|
+
@retry_not_before_ms,
|
|
101
|
+
@expires_at_ms
|
|
102
|
+
)`)
|
|
103
|
+
.run({
|
|
104
|
+
id: event.id,
|
|
105
|
+
monitor_ingest_at_ms: toSqlBigInt(monitorIngestAt),
|
|
106
|
+
source_node_id: event.nodeId,
|
|
107
|
+
source_stream_id: options.sourceStreamId ?? null,
|
|
108
|
+
source_path: options.sourcePath,
|
|
109
|
+
event_id: event.id,
|
|
110
|
+
trace_id: event.traceId ?? event.payload.traceId ?? null,
|
|
111
|
+
sequence: event.sequence?.toString() ?? null,
|
|
112
|
+
logical_time_ms: toSqlBigInt(event.clock.physicalTimeMs),
|
|
113
|
+
payload_json: serializeJson(event),
|
|
114
|
+
payload_encoding: "json",
|
|
115
|
+
delivery_mode: options.deliveryMode,
|
|
116
|
+
replay_state: replayState,
|
|
117
|
+
replay_attempts: 0,
|
|
118
|
+
retry_not_before_ms: options.retryNotBeforeMs === null || options.retryNotBeforeMs === undefined
|
|
119
|
+
? null
|
|
120
|
+
: toSqlBigInt(options.retryNotBeforeMs),
|
|
121
|
+
expires_at_ms: toSqlBigInt(expiresAt),
|
|
122
|
+
});
|
|
123
|
+
return Number(result.lastInsertRowid);
|
|
124
|
+
}
|
|
125
|
+
recordHealthTransition(snapshot) {
|
|
126
|
+
const result = this.#db
|
|
127
|
+
.prepare(`INSERT INTO component_health_log (
|
|
128
|
+
recorded_at_ms,
|
|
129
|
+
component,
|
|
130
|
+
health_state,
|
|
131
|
+
reason_code,
|
|
132
|
+
details_json
|
|
133
|
+
) VALUES (
|
|
134
|
+
@recorded_at_ms,
|
|
135
|
+
@component,
|
|
136
|
+
@health_state,
|
|
137
|
+
@reason_code,
|
|
138
|
+
@details_json
|
|
139
|
+
)`)
|
|
140
|
+
.run({
|
|
141
|
+
recorded_at_ms: toSqlBigInt(snapshot.observedAt),
|
|
142
|
+
component: snapshot.component,
|
|
143
|
+
health_state: snapshot.state,
|
|
144
|
+
reason_code: snapshot.reasonCode,
|
|
145
|
+
details_json: serializeJson(snapshot.details),
|
|
146
|
+
});
|
|
147
|
+
return Number(result.lastInsertRowid);
|
|
148
|
+
}
|
|
149
|
+
recordReplaySnapshot(snapshot) {
|
|
150
|
+
const result = this.#db
|
|
151
|
+
.prepare(`INSERT INTO replay_sessions (
|
|
152
|
+
started_at_ms,
|
|
153
|
+
ended_at_ms,
|
|
154
|
+
target_path,
|
|
155
|
+
session_state,
|
|
156
|
+
event_count_attempted,
|
|
157
|
+
event_count_delivered,
|
|
158
|
+
error_count,
|
|
159
|
+
details_json
|
|
160
|
+
) VALUES (
|
|
161
|
+
@started_at_ms,
|
|
162
|
+
@ended_at_ms,
|
|
163
|
+
@target_path,
|
|
164
|
+
@session_state,
|
|
165
|
+
@event_count_attempted,
|
|
166
|
+
@event_count_delivered,
|
|
167
|
+
@error_count,
|
|
168
|
+
@details_json
|
|
169
|
+
)`)
|
|
170
|
+
.run({
|
|
171
|
+
started_at_ms: snapshot.startedAt === null ? null : toSqlBigInt(snapshot.startedAt),
|
|
172
|
+
ended_at_ms: snapshot.endedAt === null ? null : toSqlBigInt(snapshot.endedAt),
|
|
173
|
+
target_path: snapshot.targetPath,
|
|
174
|
+
session_state: snapshot.state,
|
|
175
|
+
event_count_attempted: snapshot.queuedEventCount,
|
|
176
|
+
event_count_delivered: snapshot.deliveredEventCount,
|
|
177
|
+
error_count: snapshot.lastError ? 1 : 0,
|
|
178
|
+
details_json: serializeJson({
|
|
179
|
+
lastError: snapshot.lastError,
|
|
180
|
+
nextRetryAt: snapshot.nextRetryAt,
|
|
181
|
+
consecutiveFailureCount: snapshot.consecutiveFailureCount,
|
|
182
|
+
recoveryHeartbeatCount: snapshot.recoveryHeartbeatCount,
|
|
183
|
+
requiredRecoveryHeartbeats: snapshot.requiredRecoveryHeartbeats,
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
return Number(result.lastInsertRowid);
|
|
187
|
+
}
|
|
188
|
+
bumpReplayAttempts(limitToStates = []) {
|
|
189
|
+
const pendingStates = limitToStates.length === 0 ? ["pending", "replaying"] : limitToStates;
|
|
190
|
+
const placeholders = pendingStates.map(() => "?").join(", ");
|
|
191
|
+
const result = this.#db
|
|
192
|
+
.prepare(`UPDATE ingress_events
|
|
193
|
+
SET replay_attempts = replay_attempts + 1
|
|
194
|
+
WHERE replay_state IN (${placeholders})`)
|
|
195
|
+
.run(...pendingStates);
|
|
196
|
+
return result.changes;
|
|
197
|
+
}
|
|
198
|
+
updateReplayState(fromStates, nextState) {
|
|
199
|
+
const placeholders = fromStates.map(() => "?").join(", ");
|
|
200
|
+
const result = this.#db
|
|
201
|
+
.prepare(`UPDATE ingress_events
|
|
202
|
+
SET replay_state = ?
|
|
203
|
+
WHERE replay_state IN (${placeholders})`)
|
|
204
|
+
.run(nextState, ...fromStates);
|
|
205
|
+
return result.changes;
|
|
206
|
+
}
|
|
207
|
+
claimReplayBatch(limit, deliveryMode = "replay_through_dedupe") {
|
|
208
|
+
if (limit <= 0) {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
const claim = this.#db.transaction((nowMs, batchLimit, nextDeliveryMode) => {
|
|
212
|
+
const rows = this.#db
|
|
213
|
+
.prepare(`SELECT
|
|
214
|
+
rowid AS row_id,
|
|
215
|
+
payload_json,
|
|
216
|
+
source_path,
|
|
217
|
+
source_stream_id,
|
|
218
|
+
replay_attempts
|
|
219
|
+
FROM ingress_events
|
|
220
|
+
WHERE replay_state = 'pending'
|
|
221
|
+
AND (retry_not_before_ms IS NULL OR retry_not_before_ms <= ?)
|
|
222
|
+
ORDER BY monitor_ingest_at_ms ASC, rowid ASC
|
|
223
|
+
LIMIT ?`)
|
|
224
|
+
.all(toSqlBigInt(nowMs), batchLimit);
|
|
225
|
+
if (rows.length === 0) {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
const ids = rows.map((row) => row.row_id);
|
|
229
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
230
|
+
this.#db
|
|
231
|
+
.prepare(`UPDATE ingress_events
|
|
232
|
+
SET replay_state = 'replaying',
|
|
233
|
+
replay_attempts = replay_attempts + 1,
|
|
234
|
+
retry_not_before_ms = NULL,
|
|
235
|
+
delivery_mode = ?
|
|
236
|
+
WHERE rowid IN (${placeholders})`)
|
|
237
|
+
.run(nextDeliveryMode, ...ids);
|
|
238
|
+
return rows.map((row) => ({
|
|
239
|
+
rowId: row.row_id,
|
|
240
|
+
event: deserializeJson(row.payload_json),
|
|
241
|
+
sourcePath: row.source_path,
|
|
242
|
+
sourceStreamId: row.source_stream_id,
|
|
243
|
+
deliveryMode: nextDeliveryMode,
|
|
244
|
+
replayAttempts: row.replay_attempts + 1,
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
247
|
+
return claim(this.#now(), limit, deliveryMode);
|
|
248
|
+
}
|
|
249
|
+
markReplayBatchDelivered(rowIds) {
|
|
250
|
+
return this.#updateReplayRows(rowIds, "delivered", null);
|
|
251
|
+
}
|
|
252
|
+
markIngressRowsDelivered(rowIds) {
|
|
253
|
+
return this.#updateReplayRows(rowIds, "delivered", null);
|
|
254
|
+
}
|
|
255
|
+
resetReplayBatchToPending(rowIds, retryNotBeforeMs) {
|
|
256
|
+
return this.#updateReplayRows(rowIds, "pending", retryNotBeforeMs);
|
|
257
|
+
}
|
|
258
|
+
deadLetterReplayBatch(rowIds) {
|
|
259
|
+
return this.#updateReplayRows(rowIds, "dead_letter", null);
|
|
260
|
+
}
|
|
261
|
+
pruneExpired(fullOutageActive = false) {
|
|
262
|
+
const now = this.#now();
|
|
263
|
+
const rollingWindowMs = fullOutageActive
|
|
264
|
+
? this.#config.fullOutageMaxWindowMs
|
|
265
|
+
: this.#config.rollingBufferWindowMs;
|
|
266
|
+
const rollingCutoff = now - rollingWindowMs;
|
|
267
|
+
const hardCutoff = now - this.#config.fullOutageMaxWindowMs;
|
|
268
|
+
const markResult = this.#db
|
|
269
|
+
.prepare(`UPDATE ingress_events
|
|
270
|
+
SET replay_state = 'dead_letter'
|
|
271
|
+
WHERE replay_state IN ('pending')
|
|
272
|
+
AND (
|
|
273
|
+
monitor_ingest_at_ms <= @hard_cutoff
|
|
274
|
+
OR (
|
|
275
|
+
retry_not_before_ms IS NULL
|
|
276
|
+
AND monitor_ingest_at_ms <= @rolling_cutoff
|
|
277
|
+
)
|
|
278
|
+
)`)
|
|
279
|
+
.run({
|
|
280
|
+
hard_cutoff: toSqlBigInt(hardCutoff),
|
|
281
|
+
rolling_cutoff: toSqlBigInt(rollingCutoff),
|
|
282
|
+
});
|
|
283
|
+
const deleteResult = this.#db
|
|
284
|
+
.prepare(`DELETE FROM ingress_events
|
|
285
|
+
WHERE replay_state IN ('delivered', 'dead_letter')
|
|
286
|
+
AND expires_at_ms <= ?`)
|
|
287
|
+
.run(toSqlBigInt(now));
|
|
288
|
+
return {
|
|
289
|
+
markedDeadLetter: markResult.changes,
|
|
290
|
+
deletedRows: deleteResult.changes,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
getStats() {
|
|
294
|
+
const totalPendingRow = this.#db
|
|
295
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
296
|
+
FROM ingress_events
|
|
297
|
+
WHERE replay_state IN ('pending', 'replaying')`)
|
|
298
|
+
.get();
|
|
299
|
+
const oldestPendingRow = this.#db
|
|
300
|
+
.prepare(`SELECT MIN(monitor_ingest_at_ms) AS oldest
|
|
301
|
+
FROM ingress_events
|
|
302
|
+
WHERE replay_state IN ('pending', 'replaying')`)
|
|
303
|
+
.get();
|
|
304
|
+
const retryWaitingRow = this.#db
|
|
305
|
+
.prepare(`SELECT
|
|
306
|
+
COUNT(*) AS count,
|
|
307
|
+
MIN(retry_not_before_ms) AS earliest_retry_at
|
|
308
|
+
FROM ingress_events
|
|
309
|
+
WHERE replay_state = 'pending'
|
|
310
|
+
AND retry_not_before_ms IS NOT NULL
|
|
311
|
+
AND retry_not_before_ms > ?`)
|
|
312
|
+
.get(toSqlBigInt(this.#now()));
|
|
313
|
+
const bySourceRows = this.#db
|
|
314
|
+
.prepare(`SELECT source_path, COUNT(*) AS count
|
|
315
|
+
FROM ingress_events
|
|
316
|
+
WHERE replay_state IN ('pending', 'replaying')
|
|
317
|
+
GROUP BY source_path`)
|
|
318
|
+
.all();
|
|
319
|
+
const byDeliveryRows = this.#db
|
|
320
|
+
.prepare(`SELECT delivery_mode, COUNT(*) AS count
|
|
321
|
+
FROM ingress_events
|
|
322
|
+
WHERE replay_state IN ('pending', 'replaying')
|
|
323
|
+
GROUP BY delivery_mode`)
|
|
324
|
+
.all();
|
|
325
|
+
const sourceCounts = {
|
|
326
|
+
transport_normalized_stream: 0,
|
|
327
|
+
deduped_observation: 0,
|
|
328
|
+
};
|
|
329
|
+
for (const row of bySourceRows) {
|
|
330
|
+
sourceCounts[row.source_path] = parseCount(row.count);
|
|
331
|
+
}
|
|
332
|
+
const deliveryCounts = {};
|
|
333
|
+
for (const row of byDeliveryRows) {
|
|
334
|
+
deliveryCounts[row.delivery_mode] = parseCount(row.count);
|
|
335
|
+
}
|
|
336
|
+
const oldestPendingAt = parseBigInt(oldestPendingRow.oldest);
|
|
337
|
+
const oldestPendingAgeMs = oldestPendingAt === 0n ? 0n : this.#now() - oldestPendingAt;
|
|
338
|
+
const earliestRetryAtRaw = parseBigInt(retryWaitingRow.earliest_retry_at);
|
|
339
|
+
return {
|
|
340
|
+
totalPendingRows: parseCount(totalPendingRow.count),
|
|
341
|
+
oldestPendingAgeMs,
|
|
342
|
+
retryWaitingRows: parseCount(retryWaitingRow.count),
|
|
343
|
+
earliestRetryAt: earliestRetryAtRaw === 0n ? null : earliestRetryAtRaw,
|
|
344
|
+
pendingRowsBySourcePath: sourceCounts,
|
|
345
|
+
pendingRowsByDeliveryMode: deliveryCounts,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
getDatabasePath() {
|
|
349
|
+
return this.#config.databasePath;
|
|
350
|
+
}
|
|
351
|
+
close() {
|
|
352
|
+
this.#db.close();
|
|
353
|
+
}
|
|
354
|
+
#updateReplayRows(rowIds, nextState, retryNotBeforeMs) {
|
|
355
|
+
if (rowIds.length === 0) {
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
const placeholders = rowIds.map(() => "?").join(", ");
|
|
359
|
+
const result = this.#db
|
|
360
|
+
.prepare(`UPDATE ingress_events
|
|
361
|
+
SET replay_state = ?,
|
|
362
|
+
retry_not_before_ms = ?
|
|
363
|
+
WHERE rowid IN (${placeholders})`)
|
|
364
|
+
.run(nextState, retryNotBeforeMs === null || retryNotBeforeMs === undefined
|
|
365
|
+
? null
|
|
366
|
+
: toSqlBigInt(retryNotBeforeMs), ...rowIds);
|
|
367
|
+
return result.changes;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export declare const MONITOR_SQLITE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS ingress_events (\n monitor_ingest_seq INTEGER PRIMARY KEY AUTOINCREMENT,\n id TEXT NOT NULL,\n monitor_ingest_at_ms INTEGER NOT NULL,\n source_node_id TEXT NOT NULL,\n source_stream_id TEXT,\n source_path TEXT NOT NULL,\n event_id TEXT NOT NULL,\n trace_id TEXT,\n sequence TEXT,\n logical_time_ms INTEGER NOT NULL,\n payload_json TEXT NOT NULL,\n payload_encoding TEXT NOT NULL DEFAULT 'json',\n delivery_mode TEXT NOT NULL,\n replay_state TEXT NOT NULL,\n replay_attempts INTEGER NOT NULL DEFAULT 0,\n retry_not_before_ms INTEGER,\n expires_at_ms INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_ingress_events_expires_at\n ON ingress_events(expires_at_ms);\n\nCREATE INDEX IF NOT EXISTS idx_ingress_events_replay_state\n ON ingress_events(replay_state);\n\nCREATE INDEX IF NOT EXISTS idx_ingress_events_replay_retry\n ON ingress_events(replay_state, retry_not_before_ms);\n\nCREATE INDEX IF NOT EXISTS idx_ingress_events_source_path\n ON ingress_events(source_path);\n\nCREATE TABLE IF NOT EXISTS component_health_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n recorded_at_ms INTEGER NOT NULL,\n component TEXT NOT NULL,\n health_state TEXT NOT NULL,\n reason_code TEXT,\n details_json TEXT NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_component_health_log_component_time\n ON component_health_log(component, recorded_at_ms);\n\nCREATE TABLE IF NOT EXISTS outage_windows (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at_ms INTEGER NOT NULL,\n ended_at_ms INTEGER,\n dedupe_state TEXT NOT NULL,\n causal_order_state TEXT NOT NULL,\n buffer_mode TEXT NOT NULL,\n notes_json TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS replay_sessions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at_ms INTEGER,\n ended_at_ms INTEGER,\n target_path TEXT NOT NULL,\n session_state TEXT NOT NULL,\n event_count_attempted INTEGER NOT NULL DEFAULT 0,\n event_count_delivered INTEGER NOT NULL DEFAULT 0,\n error_count INTEGER NOT NULL DEFAULT 0,\n details_json TEXT NOT NULL\n);\n";
|
|
3
|
+
export declare function applyMonitorSchema(db: InstanceType<typeof Database>): void;
|