Repository navigation
Expand file tree
/
Copy pathdataSourcesHealthScheduler.ts
More file actions
171 lines (149 loc) · 6.41 KB
/
Copy pathdataSourcesHealthScheduler.ts
File metadata and controls
171 lines (149 loc) · 6.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
/**
* DataSources Health Scheduler
* ============================
* Runs every 15 minutes. For each enabled data source it performs a lightweight
* HTTP HEAD/GET probe against the source's baseUrl (or a known health endpoint),
* then updates:
* - status: "active" | "degraded" | "offline"
* - avgResponseMs: rolling average of the last probe latency
* - uptimePct: simple rolling uptime (95% decay per failure, +1% per success, capped 0–100)
* - lastCheckedAt: timestamp of this probe
*/
import { getDb } from "./db";
import { dataSources, dataSourceHealthLogs } from "../drizzle/schema";
import { eq } from "drizzle-orm";
import { notifyOwner } from "./_core/notification";
const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
const PROBE_TIMEOUT_MS = 8_000; // 8 second timeout per probe
// ─── Single source probe ──────────────────────────────────────────────────────
interface ProbeResult {
status: "active" | "degraded" | "offline";
responseMs: number;
}
async function probeDataSource(baseUrl: string): Promise<ProbeResult> {
const start = Date.now();
try {
// Try a HEAD request first (cheaper), fall back to GET
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
const res = await fetch(baseUrl, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
}).catch(async () => {
// HEAD not supported by some servers — try GET
const c2 = new AbortController();
const t2 = setTimeout(() => c2.abort(), PROBE_TIMEOUT_MS);
const r = await fetch(baseUrl, { method: "GET", signal: c2.signal, redirect: "follow" });
clearTimeout(t2);
return r;
});
clearTimeout(timer);
const responseMs = Date.now() - start;
if (res.ok || (res.status >= 200 && res.status < 500)) {
// 4xx counts as "degraded" (server is up but rejecting requests — expected for auth-gated APIs)
const status: ProbeResult["status"] = res.status >= 400 ? "degraded" : "active";
return { status, responseMs };
}
return { status: "offline", responseMs };
} catch {
return { status: "offline", responseMs: Date.now() - start };
}
}
// ─── Update uptime percentage ─────────────────────────────────────────────────
function updateUptimePct(current: number, probeStatus: ProbeResult["status"]): number {
const pct = current ?? 100;
if (probeStatus === "active") {
// Recover slowly: +1% per successful probe, capped at 100
return Math.min(100, pct + 1);
} else if (probeStatus === "degraded") {
// Slight degradation: -2% per degraded probe
return Math.max(0, pct - 2);
} else {
// Offline: -5% per failed probe
return Math.max(0, pct - 5);
}
}
// ─── Core health check job ────────────────────────────────────────────────────
export interface HealthCheckResult {
checked: number;
active: number;
degraded: number;
offline: number;
skipped: number;
}
export async function runDataSourcesHealthCheck(): Promise<HealthCheckResult> {
const db = await getDb();
if (!db) return { checked: 0, active: 0, degraded: 0, offline: 0, skipped: 0 };
// Fetch all enabled sources that have a baseUrl
const sources = await db
.select()
.from(dataSources)
.where(eq(dataSources.enabled, true));
const result: HealthCheckResult = { checked: 0, active: 0, degraded: 0, offline: 0, skipped: 0 };
// Probe all sources in parallel (capped at 10 concurrent)
const CONCURRENCY = 10;
for (let i = 0; i < sources.length; i += CONCURRENCY) {
const batch = sources.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async (source) => {
if (!source.baseUrl) {
result.skipped++;
return;
}
result.checked++;
const probe = await probeDataSource(source.baseUrl);
// Compute new uptime percentage
const newUptimePct = updateUptimePct(source.uptimePct ?? 100, probe.status);
// Rolling average response time (simple EMA with α=0.3)
const prevAvg = source.avgResponseMs ?? 0;
const newAvgMs = prevAvg === 0
? probe.responseMs
: Math.round(0.3 * probe.responseMs + 0.7 * prevAvg);
const checkedAt = new Date();
await db
.update(dataSources)
.set({
status: probe.status,
avgResponseMs: newAvgMs,
uptimePct: newUptimePct,
lastCheckedAt: checkedAt,
})
.where(eq(dataSources.id, source.id));
// Write a health log entry for the sparkline chart
await db.insert(dataSourceHealthLogs).values({
dataSourceId: source.id,
status: probe.status,
responseMs: probe.responseMs,
checkedAt,
}).catch(() => {}); // non-fatal
// Notify owner when a source transitions to offline
if (probe.status === "offline" && source.status !== "offline") {
notifyOwner({
title: `Data Source Offline: ${source.name}`,
content: `The data source "${source.name}" (${source.baseUrl}) is now OFFLINE. Last response time: ${probe.responseMs}ms. Uptime dropped to ${newUptimePct}%.`,
}).catch(() => {}); // non-fatal
}
result[probe.status]++;
})
);
}
console.log(
`[DataSources Health] Checked ${result.checked} sources — ` +
`active: ${result.active}, degraded: ${result.degraded}, offline: ${result.offline}, skipped: ${result.skipped}`
);
return result;
}
// ─── Scheduler entry point ────────────────────────────────────────────────────
export function startDataSourcesHealthScheduler(): void {
console.log("[DataSources Health] Scheduler started — runs every 15 minutes.");
// Run immediately on startup, then on interval
runDataSourcesHealthCheck().catch((err) =>
console.error("[DataSources Health] Initial run failed:", err)
);
setInterval(() => {
runDataSourcesHealthCheck().catch((err) =>
console.error("[DataSources Health] Scheduled run failed:", err)
);
}, INTERVAL_MS);
}