@checkstack/healthcheck-backend 1.17.0 โ 1.19.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/CHANGELOG.md +559 -0
- package/package.json +32 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/health-notification-content.test.ts +89 -0
- package/src/health-notification-content.ts +138 -0
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +58 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +426 -362
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +46 -13
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +106 -0
- package/src/service-bulk-counts.it.test.ts +144 -0
- package/src/service-bulk-run-stats.it.test.ts +197 -0
- package/src/service-ordering.test.ts +10 -2
- package/src/service-paused-filter.test.ts +27 -7
- package/src/service-rollup-worst-wins.test.ts +221 -124
- package/src/service.ts +557 -266
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -0
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +303 -0
- package/src/status-page/widgets.ts +155 -39
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
package/src/queue-executor.ts
CHANGED
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
type CollectorRunContext,
|
|
12
12
|
type AdvisoryLockService,
|
|
13
13
|
renderTemplatableConfig,
|
|
14
|
+
withScopedTransaction,
|
|
15
|
+
healthcheckExecutionHistogram,
|
|
16
|
+
healthcheckPhaseHistogram,
|
|
17
|
+
healthcheckDeferredCounter,
|
|
14
18
|
} from "@checkstack/backend-api";
|
|
15
19
|
import type { RunTimings } from "@checkstack/healthcheck-common";
|
|
16
20
|
import { QueueManager } from "@checkstack/queue-api";
|
|
@@ -20,7 +24,7 @@ import {
|
|
|
20
24
|
healthCheckRuns,
|
|
21
25
|
} from "./schema";
|
|
22
26
|
import * as schema from "./schema";
|
|
23
|
-
import { eq, and,
|
|
27
|
+
import { eq, and, desc, isNull } from "drizzle-orm";
|
|
24
28
|
import { type SignalService } from "@checkstack/signal-common";
|
|
25
29
|
import {
|
|
26
30
|
HEALTH_CHECK_RUN_COMPLETED,
|
|
@@ -31,20 +35,17 @@ import {
|
|
|
31
35
|
} from "@checkstack/healthcheck-common";
|
|
32
36
|
import {
|
|
33
37
|
CatalogApi,
|
|
34
|
-
catalogRoutes,
|
|
35
|
-
createSystemSubject,
|
|
36
38
|
type Environment,
|
|
37
39
|
} from "@checkstack/catalog-common";
|
|
38
40
|
import {
|
|
39
41
|
resolveEffectiveEnvironments,
|
|
40
42
|
type EffectiveEnvironment,
|
|
41
43
|
} from "./effective-environments";
|
|
42
|
-
import {
|
|
44
|
+
import { buildHealthTransitionNotification } from "./health-notification-content";
|
|
43
45
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
44
46
|
import { IncidentApi } from "@checkstack/incident-common";
|
|
45
47
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
46
|
-
import {
|
|
47
|
-
import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
48
|
+
import { type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
48
49
|
import { secretEnvMappingSchema } from "@checkstack/secrets-common";
|
|
49
50
|
import type {
|
|
50
51
|
SecretResolverService,
|
|
@@ -55,6 +56,12 @@ import { HealthCheckService } from "./service";
|
|
|
55
56
|
import { healthCheckHooks } from "./hooks";
|
|
56
57
|
import { incrementHourlyAggregate } from "./realtime-aggregation";
|
|
57
58
|
import type { HealthCheckCache } from "./cache";
|
|
59
|
+
import {
|
|
60
|
+
resolveSlowCheckRuntime,
|
|
61
|
+
type SlowCheckRuntime,
|
|
62
|
+
} from "./slow-check-config";
|
|
63
|
+
import type { RecentRun } from "./slow-check-classifier";
|
|
64
|
+
import { evaluateSlowCheckAdmission } from "./slow-check-admission";
|
|
58
65
|
import {
|
|
59
66
|
classifyTransition,
|
|
60
67
|
shouldNotifyTransition,
|
|
@@ -98,6 +105,48 @@ function toHealthEntityView(state: AggregatedHealth): HealthEntityState {
|
|
|
98
105
|
};
|
|
99
106
|
}
|
|
100
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Read the most recent runs for ONE (config, system, environment) slice,
|
|
110
|
+
* newest-first, projected to the fields the slow-check classifier needs. Used
|
|
111
|
+
* only when the slow-check bulkhead is enabled; keyed on the SAME
|
|
112
|
+
* `environmentId` the job runs (an env-less job reads the `environment_id IS
|
|
113
|
+
* NULL` slice), so the classification reflects exactly this slice's streak.
|
|
114
|
+
*/
|
|
115
|
+
async function fetchRecentRunsForSlice(props: {
|
|
116
|
+
db: Db;
|
|
117
|
+
configId: string;
|
|
118
|
+
systemId: string;
|
|
119
|
+
environmentId: string | null;
|
|
120
|
+
limit: number;
|
|
121
|
+
}): Promise<RecentRun[]> {
|
|
122
|
+
const { db, configId, systemId, environmentId, limit } = props;
|
|
123
|
+
const rows = await db
|
|
124
|
+
.select({
|
|
125
|
+
environmentId: healthCheckRuns.environmentId,
|
|
126
|
+
status: healthCheckRuns.status,
|
|
127
|
+
latencyMs: healthCheckRuns.latencyMs,
|
|
128
|
+
timestamp: healthCheckRuns.timestamp,
|
|
129
|
+
})
|
|
130
|
+
.from(healthCheckRuns)
|
|
131
|
+
.where(
|
|
132
|
+
and(
|
|
133
|
+
eq(healthCheckRuns.configurationId, configId),
|
|
134
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
135
|
+
environmentId === null
|
|
136
|
+
? isNull(healthCheckRuns.environmentId)
|
|
137
|
+
: eq(healthCheckRuns.environmentId, environmentId),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
141
|
+
.limit(limit);
|
|
142
|
+
return rows.map((r) => ({
|
|
143
|
+
environmentId: r.environmentId,
|
|
144
|
+
status: r.status,
|
|
145
|
+
latencyMs: r.latencyMs,
|
|
146
|
+
timestamp: r.timestamp,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
101
150
|
/** The known transport timing phase keys, in transport order. */
|
|
102
151
|
const RUN_TIMING_KEYS = [
|
|
103
152
|
"dnsMs",
|
|
@@ -185,11 +234,38 @@ async function emitCheckCompletedHook({
|
|
|
185
234
|
}
|
|
186
235
|
|
|
187
236
|
/**
|
|
188
|
-
* Payload for health check queue jobs
|
|
237
|
+
* Payload for health check queue jobs. Every job runs EXACTLY ONE environment
|
|
238
|
+
* slice - there is no in-job fan-out:
|
|
239
|
+
* - `environmentId: null` - the single ENV-LESS run of a system that has no
|
|
240
|
+
* environments. Its write IS the system rollup, so it notifies directly.
|
|
241
|
+
* - `environmentId: <id>` - the run for that specific environment. The system
|
|
242
|
+
* rollup is recomputed by the event-driven rollup consumer, not inline.
|
|
243
|
+
*
|
|
244
|
+
* The scheduling reconciler owns which (config, system, env) jobs exist; the
|
|
245
|
+
* `run_now` action enqueues one job per effective environment.
|
|
189
246
|
*/
|
|
190
247
|
export interface HealthCheckJobPayload {
|
|
191
248
|
configId: string;
|
|
192
249
|
systemId: string;
|
|
250
|
+
environmentId: string | null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Prefix every health-check recurring jobId shares (used for orphan scans). */
|
|
254
|
+
export const HEALTH_CHECK_JOB_PREFIX = "healthcheck:";
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build the recurring jobId for a check. The env-less form keeps the historical
|
|
258
|
+
* `healthcheck:${configId}:${systemId}` shape (so env-less systems' jobs are
|
|
259
|
+
* unchanged across the per-env migration); an env-scoped job appends the env id.
|
|
260
|
+
*/
|
|
261
|
+
export function encodeHealthCheckJobId(props: {
|
|
262
|
+
configId: string;
|
|
263
|
+
systemId: string;
|
|
264
|
+
environmentId: string | null;
|
|
265
|
+
}): string {
|
|
266
|
+
const { configId, systemId, environmentId } = props;
|
|
267
|
+
const base = `${HEALTH_CHECK_JOB_PREFIX}${configId}:${systemId}`;
|
|
268
|
+
return environmentId === null ? base : `${base}:${environmentId}`;
|
|
193
269
|
}
|
|
194
270
|
|
|
195
271
|
/**
|
|
@@ -230,7 +306,11 @@ export async function scheduleHealthCheck(props: {
|
|
|
230
306
|
const queue =
|
|
231
307
|
queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
|
|
232
308
|
|
|
233
|
-
const jobId =
|
|
309
|
+
const jobId = encodeHealthCheckJobId({
|
|
310
|
+
configId: payload.configId,
|
|
311
|
+
systemId: payload.systemId,
|
|
312
|
+
environmentId: payload.environmentId,
|
|
313
|
+
});
|
|
234
314
|
|
|
235
315
|
logger?.debug(
|
|
236
316
|
`Scheduling recurring health check ${jobId} with interval ${intervalSeconds}s, startDelay ${startDelay}s`,
|
|
@@ -272,16 +352,45 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
272
352
|
getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
|
|
273
353
|
advisoryLock: AdvisoryLockService;
|
|
274
354
|
logger: Logger;
|
|
275
|
-
|
|
276
|
-
|
|
355
|
+
/**
|
|
356
|
+
* When provided, a real rollup status change (prev โ next) also invalidates
|
|
357
|
+
* the per-system cache and broadcasts `SYSTEM_STATUS_CHANGED`, matching the
|
|
358
|
+
* system-level signal the pre-per-env inline rollup fired. Omit for the pure
|
|
359
|
+
* entity-only recompute (the framework's `ENTITY_CHANGED` still drives
|
|
360
|
+
* SLO/dependency/triggers regardless).
|
|
361
|
+
*/
|
|
362
|
+
signalService?: SignalService;
|
|
363
|
+
cache?: HealthCheckCache;
|
|
364
|
+
}): Promise<{ previousStatus: HealthCheckStatus; newStatus: HealthCheckStatus } | undefined> {
|
|
365
|
+
const {
|
|
366
|
+
systemId,
|
|
367
|
+
service,
|
|
368
|
+
getHealthEntity,
|
|
369
|
+
advisoryLock,
|
|
370
|
+
logger,
|
|
371
|
+
signalService,
|
|
372
|
+
cache,
|
|
373
|
+
} = args;
|
|
277
374
|
const rollupEntityId = encodeHealthEntityId({ systemId });
|
|
278
375
|
const makeHealthSerializer = createHealthEntitySerializer({ advisoryLock });
|
|
376
|
+
// The system-level signal + cache invalidation are only needed when a caller
|
|
377
|
+
// wants them (the rollup consumer). The framework snapshots its OWN prev
|
|
378
|
+
// inside `handle.mutate` for the authoritative `ENTITY_CHANGED`, so the
|
|
379
|
+
// pure entity-recompute path (pause/resume) skips the extra prev read.
|
|
380
|
+
const wantsSignal = signalService !== undefined || cache !== undefined;
|
|
279
381
|
try {
|
|
382
|
+
let previousStatus: HealthCheckStatus | undefined;
|
|
383
|
+
if (wantsSignal) {
|
|
384
|
+
const previousState = await service.getSystemHealthStatus(systemId);
|
|
385
|
+
previousStatus = previousState.status;
|
|
386
|
+
}
|
|
387
|
+
let newStatus: HealthCheckStatus | undefined = previousStatus;
|
|
280
388
|
await writeHealthEntity({
|
|
281
389
|
handle: getHealthEntity?.(),
|
|
282
390
|
entityId: rollupEntityId,
|
|
283
391
|
apply: async () => {
|
|
284
392
|
const rollupState = await service.getSystemHealthStatus(systemId);
|
|
393
|
+
newStatus = rollupState.status;
|
|
285
394
|
return toHealthEntityView(rollupState);
|
|
286
395
|
},
|
|
287
396
|
serialize: makeHealthSerializer(rollupEntityId),
|
|
@@ -291,6 +400,23 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
291
400
|
error,
|
|
292
401
|
),
|
|
293
402
|
});
|
|
403
|
+
|
|
404
|
+
if (
|
|
405
|
+
wantsSignal &&
|
|
406
|
+
previousStatus !== undefined &&
|
|
407
|
+
newStatus !== undefined &&
|
|
408
|
+
newStatus !== previousStatus
|
|
409
|
+
) {
|
|
410
|
+
await cache?.invalidateSystem(systemId);
|
|
411
|
+
await signalService?.broadcast(SYSTEM_STATUS_CHANGED, {
|
|
412
|
+
systemId,
|
|
413
|
+
previousStatus,
|
|
414
|
+
newStatus,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return previousStatus !== undefined && newStatus !== undefined
|
|
418
|
+
? { previousStatus, newStatus }
|
|
419
|
+
: undefined;
|
|
294
420
|
} catch (error) {
|
|
295
421
|
// A recompute failure must never break the pause/resume RPC. The
|
|
296
422
|
// durable tables still hold the authoritative runs; the next run tick
|
|
@@ -299,6 +425,7 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
299
425
|
`Failed to recompute system rollup health for ${systemId}`,
|
|
300
426
|
error,
|
|
301
427
|
);
|
|
428
|
+
return undefined;
|
|
302
429
|
}
|
|
303
430
|
}
|
|
304
431
|
|
|
@@ -323,11 +450,25 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
323
450
|
* Policy is resolved per-assignment (per system+configuration) โ the
|
|
324
451
|
* just-ran check is the one driving any aggregate transition in this
|
|
325
452
|
* execution, so its policy is the authoritative one.
|
|
453
|
+
*
|
|
454
|
+
* Returns `true` when a subscriber notification was actually delivered, and
|
|
455
|
+
* `false` when it was skipped (no-op transition, policy/maintenance/incident
|
|
456
|
+
* suppression) or the delivery threw. Callers use this to deduplicate the
|
|
457
|
+
* system-rollup notification against the per-environment ones fired in the
|
|
458
|
+
* same tick: when any environment already notified, the rollup notification
|
|
459
|
+
* (which describes the same underlying outage) is redundant and suppressed.
|
|
326
460
|
*/
|
|
327
461
|
async function notifyStateChange(props: {
|
|
328
462
|
systemId: string;
|
|
329
463
|
systemName: string;
|
|
330
464
|
configurationId: string;
|
|
465
|
+
/**
|
|
466
|
+
* Human-readable name of the health check whose run drove this transition.
|
|
467
|
+
* Named in the body and surfaced as a `healthcheck.healthcheck` subject so
|
|
468
|
+
* subscribers see WHICH check failed, not just which system. Best-effort:
|
|
469
|
+
* falls back to the `configurationId` when the name could not be resolved.
|
|
470
|
+
*/
|
|
471
|
+
configurationName?: string;
|
|
331
472
|
previousStatus: HealthCheckStatus;
|
|
332
473
|
newStatus: HealthCheckStatus;
|
|
333
474
|
/**
|
|
@@ -351,11 +492,12 @@ async function notifyStateChange(props: {
|
|
|
351
492
|
maintenanceClient: MaintenanceClient;
|
|
352
493
|
incidentClient: IncidentClient;
|
|
353
494
|
logger: Logger;
|
|
354
|
-
}): Promise<
|
|
495
|
+
}): Promise<boolean> {
|
|
355
496
|
const {
|
|
356
497
|
systemId,
|
|
357
498
|
systemName,
|
|
358
499
|
configurationId,
|
|
500
|
+
configurationName,
|
|
359
501
|
previousStatus,
|
|
360
502
|
newStatus,
|
|
361
503
|
environmentId,
|
|
@@ -368,12 +510,13 @@ async function notifyStateChange(props: {
|
|
|
368
510
|
logger,
|
|
369
511
|
} = props;
|
|
370
512
|
|
|
371
|
-
|
|
372
|
-
|
|
513
|
+
// The check that just ran is the one driving this aggregate transition, so
|
|
514
|
+
// its name is the authoritative check to blame. Fall back to the id.
|
|
515
|
+
const checkName = configurationName ?? configurationId;
|
|
373
516
|
|
|
374
517
|
const transition = classifyTransition(previousStatus, newStatus);
|
|
375
518
|
if (transition === "none") {
|
|
376
|
-
return;
|
|
519
|
+
return false;
|
|
377
520
|
}
|
|
378
521
|
|
|
379
522
|
// Per-assignment notification policy. Failure to load defaults to
|
|
@@ -396,7 +539,7 @@ async function notifyStateChange(props: {
|
|
|
396
539
|
logger.debug(
|
|
397
540
|
`Skipping notification for ${systemId}: ${transition} suppressed by policy`,
|
|
398
541
|
);
|
|
399
|
-
return;
|
|
542
|
+
return false;
|
|
400
543
|
}
|
|
401
544
|
|
|
402
545
|
// Check if notifications should be suppressed due to active maintenance
|
|
@@ -407,7 +550,7 @@ async function notifyStateChange(props: {
|
|
|
407
550
|
logger.debug(
|
|
408
551
|
`Skipping notification for ${systemId}: active maintenance with suppression enabled`,
|
|
409
552
|
);
|
|
410
|
-
return;
|
|
553
|
+
return false;
|
|
411
554
|
}
|
|
412
555
|
} catch (error) {
|
|
413
556
|
// Log but continue with notification - suppression check failure shouldn't block notifications
|
|
@@ -425,7 +568,7 @@ async function notifyStateChange(props: {
|
|
|
425
568
|
logger.debug(
|
|
426
569
|
`Skipping notification for ${systemId}: active incident with suppression enabled`,
|
|
427
570
|
);
|
|
428
|
-
return;
|
|
571
|
+
return false;
|
|
429
572
|
}
|
|
430
573
|
} catch (error) {
|
|
431
574
|
// Log but continue with notification - suppression check failure shouldn't block notifications
|
|
@@ -435,79 +578,37 @@ async function notifyStateChange(props: {
|
|
|
435
578
|
);
|
|
436
579
|
}
|
|
437
580
|
|
|
438
|
-
let title: string;
|
|
439
|
-
let body: string;
|
|
440
|
-
let importance: "info" | "warning" | "critical";
|
|
441
|
-
|
|
442
|
-
if (transition === "recovery") {
|
|
443
|
-
title = `System health restored${envSuffix}: ${systemName}`;
|
|
444
|
-
body = envScoped
|
|
445
|
-
? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
|
|
446
|
-
: `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
|
|
447
|
-
importance = "info";
|
|
448
|
-
} else if (newStatus === "unhealthy") {
|
|
449
|
-
title = `System health critical${envSuffix}: ${systemName}`;
|
|
450
|
-
body = envScoped
|
|
451
|
-
? `Health checks indicate **${systemName}** is unhealthy in environment **${environmentName ?? environmentId}** and may be down in that environment.`
|
|
452
|
-
: `Health checks indicate **${systemName}** is unhealthy and may be down.`;
|
|
453
|
-
importance = "critical";
|
|
454
|
-
} else {
|
|
455
|
-
// degraded โ either an escalation from healthy or a partial recovery
|
|
456
|
-
title = `System health degraded${envSuffix}: ${systemName}`;
|
|
457
|
-
body = envScoped
|
|
458
|
-
? `Some health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are failing. That environment may be experiencing issues.`
|
|
459
|
-
: `Some health checks for **${systemName}** are failing. The system may be experiencing issues.`;
|
|
460
|
-
importance = "warning";
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
|
|
464
|
-
systemId,
|
|
465
|
-
});
|
|
466
|
-
// Recovery lands on the default (all) view; failing transitions deep-link
|
|
467
|
-
// operators into the failing-checks filter so they can debug immediately.
|
|
468
|
-
const actionUrl =
|
|
469
|
-
transition === "recovery"
|
|
470
|
-
? systemDetailPath
|
|
471
|
-
: `${systemDetailPath}?filter=failing`;
|
|
472
|
-
const actionLabel =
|
|
473
|
-
transition === "recovery" ? "View System" : "View failing checks";
|
|
474
|
-
|
|
475
581
|
void catalogClient; // parents are resolved server-side via stored target edges
|
|
476
582
|
|
|
477
583
|
try {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
subjects: [
|
|
494
|
-
createSystemSubject({
|
|
495
|
-
id: systemId,
|
|
496
|
-
name: systemName,
|
|
497
|
-
url: systemDetailPath,
|
|
498
|
-
status: newStatus,
|
|
499
|
-
}),
|
|
500
|
-
],
|
|
501
|
-
});
|
|
584
|
+
// Content (title/body/subjects/collapseKey) is built by a pure, unit-tested
|
|
585
|
+
// helper so the wording - which now NAMES the failing check and pushes a
|
|
586
|
+
// `healthcheck.healthcheck` subject - can be verified without the executor.
|
|
587
|
+
await notificationClient.notifyForSubscription(
|
|
588
|
+
buildHealthTransitionNotification({
|
|
589
|
+
transition,
|
|
590
|
+
systemId,
|
|
591
|
+
systemName,
|
|
592
|
+
configurationId,
|
|
593
|
+
checkName,
|
|
594
|
+
newStatus,
|
|
595
|
+
environmentId,
|
|
596
|
+
environmentName,
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
502
599
|
logger.debug(
|
|
503
600
|
`Notified subscribers: ${previousStatus} โ ${newStatus} for system ${systemId}`,
|
|
504
601
|
);
|
|
602
|
+
return true;
|
|
505
603
|
} catch (error) {
|
|
506
|
-
// Log but don't fail the operation - notifications are best-effort
|
|
604
|
+
// Log but don't fail the operation - notifications are best-effort. A
|
|
605
|
+
// delivery that threw did NOT inform the user, so report `false` and let
|
|
606
|
+
// the caller's rollup fallback still fire.
|
|
507
607
|
logger.warn(
|
|
508
608
|
`Failed to notify subscribers for health state change on system ${systemId}:`,
|
|
509
609
|
error,
|
|
510
610
|
);
|
|
611
|
+
return false;
|
|
511
612
|
}
|
|
512
613
|
}
|
|
513
614
|
|
|
@@ -551,6 +652,12 @@ async function executeHealthCheckJob(props: {
|
|
|
551
652
|
* without it, marker-bearing configs fail their runs clearly.
|
|
552
653
|
*/
|
|
553
654
|
internalSecrets?: InternalSecretsService;
|
|
655
|
+
/**
|
|
656
|
+
* Slow-check bulkhead + adaptive-timeout runtime, resolved once at worker
|
|
657
|
+
* startup. `null`/`undefined` disables the feature: the classification read
|
|
658
|
+
* is skipped and the run executes exactly as before (full timeout, no lane).
|
|
659
|
+
*/
|
|
660
|
+
slowCheckRuntime?: SlowCheckRuntime | null;
|
|
554
661
|
}): Promise<void> {
|
|
555
662
|
const {
|
|
556
663
|
payload,
|
|
@@ -569,6 +676,7 @@ async function executeHealthCheckJob(props: {
|
|
|
569
676
|
cache,
|
|
570
677
|
secretResolver,
|
|
571
678
|
internalSecrets,
|
|
679
|
+
slowCheckRuntime,
|
|
572
680
|
} = props;
|
|
573
681
|
const { configId, systemId } = payload;
|
|
574
682
|
|
|
@@ -592,6 +700,10 @@ async function executeHealthCheckJob(props: {
|
|
|
592
700
|
const rollupPreviousState = await service.getSystemHealthStatus(systemId);
|
|
593
701
|
const rollupPreviousStatus = rollupPreviousState.status;
|
|
594
702
|
|
|
703
|
+
// Slow-check lane admission (set when this run was admitted to the suspect
|
|
704
|
+
// lane); released in the outer finally so the slot frees on any exit path.
|
|
705
|
+
let laneKey: string | undefined;
|
|
706
|
+
|
|
595
707
|
try {
|
|
596
708
|
// Fetch configuration (including name for signals)
|
|
597
709
|
const [configRow] = await db
|
|
@@ -705,13 +817,15 @@ async function executeHealthCheckJob(props: {
|
|
|
705
817
|
// tables and is re-read every tick via the cross-plugin RPC, so every pod
|
|
706
818
|
// resolves the same set (state-and-scale: no pod-local env state).
|
|
707
819
|
let membership: Environment[] = [];
|
|
820
|
+
let catalogResolutionFailed = false;
|
|
708
821
|
try {
|
|
709
822
|
membership = await catalogClient.resolveSystemEnvironments({ systemId });
|
|
710
823
|
} catch (error) {
|
|
711
|
-
// Fail-open: a catalog read failure must not wedge the check.
|
|
712
|
-
//
|
|
824
|
+
// Fail-open: a catalog read failure must not wedge the check. We keep
|
|
825
|
+
// running the payload's env with degraded fields rather than skipping.
|
|
826
|
+
catalogResolutionFailed = true;
|
|
713
827
|
logger.warn(
|
|
714
|
-
`Could not resolve environments for system ${systemId}
|
|
828
|
+
`Could not resolve environments for system ${systemId}`,
|
|
715
829
|
error,
|
|
716
830
|
);
|
|
717
831
|
// Observability: a `logger.warn` alone is easy to miss when a durable
|
|
@@ -735,28 +849,102 @@ async function executeHealthCheckJob(props: {
|
|
|
735
849
|
environmentIds: configRow.environmentIds,
|
|
736
850
|
membership,
|
|
737
851
|
});
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
852
|
+
|
|
853
|
+
// Select THE single environment this job runs (payload.environmentId). The
|
|
854
|
+
// reconciler owns which (config, system, env) jobs exist; here we only
|
|
855
|
+
// validate the payload's env against the CURRENT effective set so a stale
|
|
856
|
+
// job (env removed, or an env-less job for a system that has since gained
|
|
857
|
+
// envs) is skipped and the reconciler converges the set.
|
|
858
|
+
const targetEnvironmentId = payload.environmentId;
|
|
859
|
+
let singleEnvironment: EffectiveEnvironment | null;
|
|
860
|
+
if (targetEnvironmentId === null) {
|
|
861
|
+
// Env-less job: valid only while the system has no effective envs.
|
|
862
|
+
if (!catalogResolutionFailed && effectiveEnvs.length > 0) {
|
|
863
|
+
logger.debug(
|
|
864
|
+
`Env-less job for ${configId}/${systemId} is stale (system now has ${effectiveEnvs.length} env(s)); skipping`,
|
|
865
|
+
);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
singleEnvironment = null;
|
|
869
|
+
} else {
|
|
870
|
+
const found =
|
|
871
|
+
effectiveEnvs.find((env) => env.id === targetEnvironmentId) ?? null;
|
|
872
|
+
if (found) {
|
|
873
|
+
singleEnvironment = found;
|
|
874
|
+
} else if (catalogResolutionFailed) {
|
|
875
|
+
// Transient catalog failure: still run the probe, with degraded (empty)
|
|
876
|
+
// env fields rather than skipping the tick. The next tick recovers.
|
|
877
|
+
singleEnvironment = {
|
|
878
|
+
id: targetEnvironmentId,
|
|
879
|
+
name: targetEnvironmentId,
|
|
880
|
+
fields: {},
|
|
881
|
+
};
|
|
882
|
+
} else {
|
|
883
|
+
logger.debug(
|
|
884
|
+
`Env ${targetEnvironmentId} no longer effective for ${configId}/${systemId}; skipping`,
|
|
885
|
+
);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// โโ Slow-check bulkhead + adaptive timeout โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
891
|
+
// Classify THIS slice's recent runs. A slice whose last K runs were SLOW
|
|
892
|
+
// transport failures (held its slot ~the full timeout) is "suspect": it is
|
|
893
|
+
// admitted to a capped, pod-local lane (or DEFERRED this tick when the lane
|
|
894
|
+
// is full or a prior run of the same slice is still in flight โ recording
|
|
895
|
+
// nothing so it can't pile up) and probed with a timeout shrunk toward its
|
|
896
|
+
// OWN healthy-latency baseline, so a stuck target frees its slot fast
|
|
897
|
+
// instead of pinning it for the full timeout. A healthy slice is untouched.
|
|
898
|
+
let effectiveTimeout = executionTimeout;
|
|
899
|
+
const sliceEnvironmentId = singleEnvironment?.id ?? null;
|
|
900
|
+
if (slowCheckRuntime) {
|
|
901
|
+
try {
|
|
902
|
+
const recentRuns = await fetchRecentRunsForSlice({
|
|
903
|
+
db,
|
|
904
|
+
configId,
|
|
905
|
+
systemId,
|
|
906
|
+
environmentId: sliceEnvironmentId,
|
|
907
|
+
limit: slowCheckRuntime.recentRunsLimit,
|
|
908
|
+
});
|
|
909
|
+
const decision = evaluateSlowCheckAdmission({
|
|
910
|
+
runtime: slowCheckRuntime,
|
|
911
|
+
recentRuns,
|
|
912
|
+
configId,
|
|
913
|
+
systemId,
|
|
914
|
+
environmentId: sliceEnvironmentId,
|
|
915
|
+
executionTimeoutMs: executionTimeout,
|
|
916
|
+
});
|
|
917
|
+
if (decision.kind === "defer") {
|
|
918
|
+
healthcheckDeferredCounter().add(1, { reason: decision.reason });
|
|
919
|
+
logger.debug(
|
|
920
|
+
`Deferred suspect health check ${configId}/${systemId}` +
|
|
921
|
+
(sliceEnvironmentId ? ` [${sliceEnvironmentId}]` : "") +
|
|
922
|
+
` (${decision.reason}); recording nothing this tick`,
|
|
923
|
+
);
|
|
924
|
+
// Record nothing: the recurring job stays scheduled, so the next tick
|
|
925
|
+
// retries once the lane drains / the in-flight run finishes.
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
effectiveTimeout = decision.effectiveTimeoutMs;
|
|
929
|
+
laneKey = decision.laneKey;
|
|
930
|
+
} catch (error) {
|
|
931
|
+
// Classification is best-effort: a read failure must never wedge the
|
|
932
|
+
// check. Fall back to the full timeout with no lane admission.
|
|
933
|
+
logger.warn(
|
|
934
|
+
`Slow-check classification failed for ${configId}/${systemId}; running at full timeout`,
|
|
935
|
+
error,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// This job runs exactly this ONE env. `isFannedOut` is now a per-JOB
|
|
941
|
+
// property: an env-scoped run (`isFannedOut === true`) mutates the
|
|
942
|
+
// `<systemId>::<env>` entity and leaves the bare `<systemId>` ROLLUP to the
|
|
943
|
+
// event-driven rollup consumer; an env-less run mutates the bare entity
|
|
944
|
+
// (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
|
|
945
|
+
// directly.
|
|
946
|
+
const runEnvironments: (EffectiveEnvironment | null)[] = [singleEnvironment];
|
|
947
|
+
const isFannedOut = targetEnvironmentId !== null;
|
|
760
948
|
for (const environment of runEnvironments) {
|
|
761
949
|
const environmentId = environment?.id ?? null;
|
|
762
950
|
// The env-qualified entity id this run mutates. For the env-less run
|
|
@@ -1047,9 +1235,9 @@ async function executeHealthCheckJob(props: {
|
|
|
1047
1235
|
setTimeout(
|
|
1048
1236
|
() =>
|
|
1049
1237
|
reject(
|
|
1050
|
-
new Error(`Execution timeout after ${
|
|
1238
|
+
new Error(`Execution timeout after ${effectiveTimeout}ms`),
|
|
1051
1239
|
),
|
|
1052
|
-
|
|
1240
|
+
effectiveTimeout,
|
|
1053
1241
|
),
|
|
1054
1242
|
),
|
|
1055
1243
|
]);
|
|
@@ -1081,31 +1269,38 @@ async function executeHealthCheckJob(props: {
|
|
|
1081
1269
|
handle: getHealthEntity?.(),
|
|
1082
1270
|
entityId: envEntityId,
|
|
1083
1271
|
apply: async () => {
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1272
|
+
// ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
|
|
1273
|
+
// `SET LOCAL search_path` transaction (3 scoped-db transactions โ 1),
|
|
1274
|
+
// which also makes the run and its aggregate commit atomically.
|
|
1275
|
+
await withScopedTransaction(db, async (tx) => {
|
|
1276
|
+
await tx.insert(healthCheckRuns).values({
|
|
1277
|
+
configurationId: configId,
|
|
1278
|
+
systemId,
|
|
1279
|
+
environmentId,
|
|
1280
|
+
status: result.status,
|
|
1281
|
+
latencyMs: result.latencyMs,
|
|
1282
|
+
result: { ...result } as Record<string, unknown>,
|
|
1283
|
+
sourceId: undefined,
|
|
1284
|
+
sourceLabel: "Local",
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
await incrementHourlyAggregate({
|
|
1288
|
+
db: tx,
|
|
1289
|
+
systemId,
|
|
1290
|
+
configurationId: configId,
|
|
1291
|
+
environmentId,
|
|
1292
|
+
status: result.status,
|
|
1293
|
+
latencyMs: result.latencyMs,
|
|
1294
|
+
runTimestamp: new Date(),
|
|
1295
|
+
result: { ...result } as Record<string, unknown>,
|
|
1296
|
+
collectorRegistry,
|
|
1297
|
+
sourceLabel: "Local",
|
|
1298
|
+
});
|
|
1106
1299
|
});
|
|
1107
1300
|
|
|
1108
1301
|
// Env-scoped view: the per-env entity reflects only this env's runs.
|
|
1302
|
+
// Runs as its own batched read AFTER the write commits, so it sees
|
|
1303
|
+
// the just-inserted run.
|
|
1109
1304
|
newState = await service.getSystemHealthStatus(systemId, environmentId);
|
|
1110
1305
|
return toHealthEntityView(newState);
|
|
1111
1306
|
},
|
|
@@ -1116,7 +1311,6 @@ async function executeHealthCheckJob(props: {
|
|
|
1116
1311
|
error,
|
|
1117
1312
|
),
|
|
1118
1313
|
});
|
|
1119
|
-
anyEnvRunPersisted = true;
|
|
1120
1314
|
|
|
1121
1315
|
logger.debug(
|
|
1122
1316
|
`Health check ${configId} for system ${systemId} failed: ${finalError}`,
|
|
@@ -1156,6 +1350,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1156
1350
|
systemId,
|
|
1157
1351
|
systemName,
|
|
1158
1352
|
configurationId: configId,
|
|
1353
|
+
configurationName: configRow.configName,
|
|
1159
1354
|
previousStatus,
|
|
1160
1355
|
newStatus: newState.status,
|
|
1161
1356
|
environmentId,
|
|
@@ -1191,6 +1386,21 @@ async function executeHealthCheckJob(props: {
|
|
|
1191
1386
|
// undefined and the frontend falls back to the coarse connection split.
|
|
1192
1387
|
const timings = extractRunTimings(connectedClient);
|
|
1193
1388
|
|
|
1389
|
+
// Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
|
|
1390
|
+
// network sub-phases. The `phase` breakdown is what tells "slow target"
|
|
1391
|
+
// (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
|
|
1392
|
+
// grow under a same-host stampede) apart from platform delay.
|
|
1393
|
+
healthcheckExecutionHistogram().record(totalLatencyMs, { status });
|
|
1394
|
+
if (timings) {
|
|
1395
|
+
for (const [phase, value] of Object.entries(timings)) {
|
|
1396
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1397
|
+
healthcheckPhaseHistogram().record(value, {
|
|
1398
|
+
phase: phase.replace(/Ms$/, ""),
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1194
1404
|
const result = {
|
|
1195
1405
|
status: status as "healthy" | "unhealthy",
|
|
1196
1406
|
latencyMs: totalLatencyMs,
|
|
@@ -1217,33 +1427,40 @@ async function executeHealthCheckJob(props: {
|
|
|
1217
1427
|
handle: getHealthEntity?.(),
|
|
1218
1428
|
entityId: envEntityId,
|
|
1219
1429
|
apply: async () => {
|
|
1220
|
-
//
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1430
|
+
// ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
|
|
1431
|
+
// `SET LOCAL search_path` transaction (3 scoped-db transactions โ 1),
|
|
1432
|
+
// which also makes the run and its aggregate commit atomically.
|
|
1433
|
+
await withScopedTransaction(db, async (tx) => {
|
|
1434
|
+
// Store result (spread to convert structured type to plain record for jsonb)
|
|
1435
|
+
await tx.insert(healthCheckRuns).values({
|
|
1436
|
+
configurationId: configId,
|
|
1437
|
+
systemId,
|
|
1438
|
+
environmentId,
|
|
1439
|
+
status: result.status,
|
|
1440
|
+
latencyMs: result.latencyMs,
|
|
1441
|
+
result: { ...result } as Record<string, unknown>,
|
|
1442
|
+
sourceId: undefined,
|
|
1443
|
+
sourceLabel: "Local",
|
|
1444
|
+
});
|
|
1231
1445
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1446
|
+
// Trigger incremental hourly aggregation
|
|
1447
|
+
await incrementHourlyAggregate({
|
|
1448
|
+
db: tx,
|
|
1449
|
+
systemId,
|
|
1450
|
+
configurationId: configId,
|
|
1451
|
+
environmentId,
|
|
1452
|
+
status: result.status,
|
|
1453
|
+
latencyMs: result.latencyMs,
|
|
1454
|
+
runTimestamp: new Date(),
|
|
1455
|
+
result: { ...result } as Record<string, unknown>,
|
|
1456
|
+
collectorRegistry,
|
|
1457
|
+
sourceLabel: "Local",
|
|
1458
|
+
});
|
|
1244
1459
|
});
|
|
1245
1460
|
|
|
1246
1461
|
// Env-scoped view: the per-env entity reflects only this env's runs.
|
|
1462
|
+
// Runs as its own batched read AFTER the write commits, so it sees the
|
|
1463
|
+
// just-inserted run.
|
|
1247
1464
|
newState = await service.getSystemHealthStatus(systemId, environmentId);
|
|
1248
1465
|
return toHealthEntityView(newState);
|
|
1249
1466
|
},
|
|
@@ -1251,7 +1468,6 @@ async function executeHealthCheckJob(props: {
|
|
|
1251
1468
|
onError: (error) =>
|
|
1252
1469
|
logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
|
|
1253
1470
|
});
|
|
1254
|
-
anyEnvRunPersisted = true;
|
|
1255
1471
|
|
|
1256
1472
|
logger.debug(
|
|
1257
1473
|
`Ran health check ${configId} for system ${systemId}: ${result.status}`,
|
|
@@ -1302,6 +1518,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1302
1518
|
systemId,
|
|
1303
1519
|
systemName,
|
|
1304
1520
|
configurationId: configId,
|
|
1521
|
+
configurationName: configRow.configName,
|
|
1305
1522
|
previousStatus,
|
|
1306
1523
|
newStatus: newState.status,
|
|
1307
1524
|
environmentId,
|
|
@@ -1343,81 +1560,14 @@ async function executeHealthCheckJob(props: {
|
|
|
1343
1560
|
}
|
|
1344
1561
|
} // end per-environment fan-out loop (for ... of runEnvironments)
|
|
1345
1562
|
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
// serializes against itself, independent of the per-env locks.
|
|
1355
|
-
//
|
|
1356
|
-
// Skipped when env-less (the loop's lone write already targeted the bare
|
|
1357
|
-
// `<systemId>` entity = the rollup) or when nothing persisted (a fully
|
|
1358
|
-
// isolated-failure loop left no new runs to roll up).
|
|
1359
|
-
if (isFannedOut && anyEnvRunPersisted) {
|
|
1360
|
-
const rollupEntityId = encodeHealthEntityId({ systemId });
|
|
1361
|
-
let rollupState!: AggregatedHealth;
|
|
1362
|
-
try {
|
|
1363
|
-
await writeHealthEntity({
|
|
1364
|
-
handle: getHealthEntity?.(),
|
|
1365
|
-
entityId: rollupEntityId,
|
|
1366
|
-
apply: async () => {
|
|
1367
|
-
// No durable insert โ recompute the all-runs (rollup) view.
|
|
1368
|
-
rollupState = await service.getSystemHealthStatus(systemId);
|
|
1369
|
-
return toHealthEntityView(rollupState);
|
|
1370
|
-
},
|
|
1371
|
-
serialize: makeHealthSerializer(rollupEntityId),
|
|
1372
|
-
onError: (error) =>
|
|
1373
|
-
logger.warn(
|
|
1374
|
-
`Failed to mirror rollup health entity for ${systemId}`,
|
|
1375
|
-
error,
|
|
1376
|
-
),
|
|
1377
|
-
});
|
|
1378
|
-
|
|
1379
|
-
// Record the ROLLUP transition (environmentId = null) so system-level
|
|
1380
|
-
// "in status since" reflects the aggregate, and notify on a real
|
|
1381
|
-
// rollup status change so existing system-level notifications fire.
|
|
1382
|
-
if (rollupState.status !== rollupPreviousStatus) {
|
|
1383
|
-
await recordStateTransition({
|
|
1384
|
-
db,
|
|
1385
|
-
systemId,
|
|
1386
|
-
configurationId: configId,
|
|
1387
|
-
environmentId: null,
|
|
1388
|
-
fromStatus: rollupPreviousStatus,
|
|
1389
|
-
toStatus: rollupState.status,
|
|
1390
|
-
});
|
|
1391
|
-
|
|
1392
|
-
await notifyStateChange({
|
|
1393
|
-
notificationClient,
|
|
1394
|
-
systemId,
|
|
1395
|
-
systemName,
|
|
1396
|
-
configurationId: configId,
|
|
1397
|
-
previousStatus: rollupPreviousStatus,
|
|
1398
|
-
newStatus: rollupState.status,
|
|
1399
|
-
service,
|
|
1400
|
-
catalogClient,
|
|
1401
|
-
maintenanceClient,
|
|
1402
|
-
incidentClient,
|
|
1403
|
-
logger,
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
|
-
await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
|
|
1407
|
-
systemId,
|
|
1408
|
-
previousStatus: rollupPreviousStatus as HealthCheckStatus,
|
|
1409
|
-
newStatus: rollupState.status,
|
|
1410
|
-
});
|
|
1411
|
-
}
|
|
1412
|
-
} catch (rollupError) {
|
|
1413
|
-
// The rollup is best-effort reactivity over already-durable runs; a
|
|
1414
|
-
// failure must not wedge the (completed) per-env runs.
|
|
1415
|
-
logger.error(
|
|
1416
|
-
`Failed to write system rollup health for ${systemId}`,
|
|
1417
|
-
rollupError,
|
|
1418
|
-
);
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1563
|
+
// The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
|
|
1564
|
+
// run is recomputed ASYNCHRONOUSLY by the event-driven rollup consumer,
|
|
1565
|
+
// which subscribes to per-env `health` entity changes and debounces per
|
|
1566
|
+
// system (recordSystemRollupChange). Doing it inline per env-job would
|
|
1567
|
+
// multiply the `health:<systemId>` advisory-lock load by the fan-out
|
|
1568
|
+
// factor. An env-less run needs no separate rollup: its write above IS the
|
|
1569
|
+
// bare `<systemId>` entity, and it already recorded its own transition,
|
|
1570
|
+
// notification, and SYSTEM_STATUS_CHANGED signal.
|
|
1421
1571
|
|
|
1422
1572
|
// Note: No manual rescheduling needed - recurring job handles it automatically
|
|
1423
1573
|
} catch (error) {
|
|
@@ -1438,27 +1588,32 @@ async function executeHealthCheckJob(props: {
|
|
|
1438
1588
|
handle: getHealthEntity?.(),
|
|
1439
1589
|
entityId: rollupEntityId,
|
|
1440
1590
|
apply: async () => {
|
|
1441
|
-
//
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1591
|
+
// ยงperf: batch the failure run INSERT + aggregate SELECT/UPSERT under
|
|
1592
|
+
// ONE `SET LOCAL search_path` transaction (3 scoped-db transactions โ
|
|
1593
|
+
// 1), which also makes them commit atomically.
|
|
1594
|
+
await withScopedTransaction(db, async (tx) => {
|
|
1595
|
+
// Store failure (no latencyMs for failures)
|
|
1596
|
+
await tx.insert(healthCheckRuns).values({
|
|
1597
|
+
configurationId: configId,
|
|
1598
|
+
systemId,
|
|
1599
|
+
status: "unhealthy",
|
|
1600
|
+
result: { error: String(error) } as Record<string, unknown>,
|
|
1601
|
+
sourceId: undefined,
|
|
1602
|
+
sourceLabel: "Local",
|
|
1603
|
+
});
|
|
1450
1604
|
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1605
|
+
// Trigger incremental hourly aggregation
|
|
1606
|
+
await incrementHourlyAggregate({
|
|
1607
|
+
db: tx,
|
|
1608
|
+
systemId,
|
|
1609
|
+
configurationId: configId,
|
|
1610
|
+
status: "unhealthy",
|
|
1611
|
+
latencyMs: undefined,
|
|
1612
|
+
runTimestamp: new Date(),
|
|
1613
|
+
// No collector data for error cases
|
|
1614
|
+
collectorRegistry,
|
|
1615
|
+
sourceLabel: "Local",
|
|
1616
|
+
});
|
|
1462
1617
|
});
|
|
1463
1618
|
|
|
1464
1619
|
newState = await service.getSystemHealthStatus(systemId);
|
|
@@ -1530,6 +1685,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1530
1685
|
systemId,
|
|
1531
1686
|
systemName,
|
|
1532
1687
|
configurationId: configId,
|
|
1688
|
+
configurationName: configName,
|
|
1533
1689
|
previousStatus,
|
|
1534
1690
|
newStatus: newState.status,
|
|
1535
1691
|
service,
|
|
@@ -1554,6 +1710,11 @@ async function executeHealthCheckJob(props: {
|
|
|
1554
1710
|
}
|
|
1555
1711
|
|
|
1556
1712
|
// Note: No manual rescheduling needed - recurring job handles it automatically
|
|
1713
|
+
} finally {
|
|
1714
|
+
// Release the suspect-lane slot (single-flight + capacity) on EVERY exit
|
|
1715
|
+
// path โ success, timeout, or a catastrophic throw โ so a slow run frees
|
|
1716
|
+
// its slot for the next tick. A no-op when this run was not admitted.
|
|
1717
|
+
if (laneKey && slowCheckRuntime) slowCheckRuntime.lane.release(laneKey);
|
|
1557
1718
|
}
|
|
1558
1719
|
}
|
|
1559
1720
|
|
|
@@ -1574,6 +1735,12 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1574
1735
|
cache: HealthCheckCache;
|
|
1575
1736
|
secretResolver?: SecretResolverService;
|
|
1576
1737
|
internalSecrets?: InternalSecretsService;
|
|
1738
|
+
/**
|
|
1739
|
+
* Slow-check bulkhead runtime. Omit to resolve it once from `process.env`
|
|
1740
|
+
* (the production path); pass `null` to force the feature OFF (tests that
|
|
1741
|
+
* don't exercise the bulkhead), or a concrete runtime to drive it.
|
|
1742
|
+
*/
|
|
1743
|
+
slowCheckRuntime?: SlowCheckRuntime | null;
|
|
1577
1744
|
}): Promise<void> {
|
|
1578
1745
|
const {
|
|
1579
1746
|
db,
|
|
@@ -1594,6 +1761,16 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1594
1761
|
internalSecrets,
|
|
1595
1762
|
} = props;
|
|
1596
1763
|
|
|
1764
|
+
// Resolve the slow-check runtime once at startup unless the caller supplied
|
|
1765
|
+
// one (including an explicit `null` to disable it).
|
|
1766
|
+
const slowCheckRuntime =
|
|
1767
|
+
props.slowCheckRuntime === undefined
|
|
1768
|
+
? resolveSlowCheckRuntime(process.env)
|
|
1769
|
+
: props.slowCheckRuntime;
|
|
1770
|
+
if (slowCheckRuntime) {
|
|
1771
|
+
logger.debug("๐ฉบ Slow-check bulkhead + adaptive timeout enabled.");
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1597
1774
|
const queue =
|
|
1598
1775
|
queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
|
|
1599
1776
|
|
|
@@ -1617,6 +1794,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1617
1794
|
cache,
|
|
1618
1795
|
secretResolver,
|
|
1619
1796
|
internalSecrets,
|
|
1797
|
+
slowCheckRuntime,
|
|
1620
1798
|
});
|
|
1621
1799
|
},
|
|
1622
1800
|
{
|
|
@@ -1628,117 +1806,3 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1628
1806
|
logger.debug("๐ฏ Health Check Worker subscribed to queue");
|
|
1629
1807
|
}
|
|
1630
1808
|
|
|
1631
|
-
/**
|
|
1632
|
-
* Bootstrap health checks by enqueueing all enabled checks
|
|
1633
|
-
*/
|
|
1634
|
-
export async function bootstrapHealthChecks(props: {
|
|
1635
|
-
db: Db;
|
|
1636
|
-
queueManager: QueueManager;
|
|
1637
|
-
logger: Logger;
|
|
1638
|
-
}): Promise<void> {
|
|
1639
|
-
const { db, queueManager, logger } = props;
|
|
1640
|
-
|
|
1641
|
-
// Get all enabled health checks
|
|
1642
|
-
const enabledChecks = await db
|
|
1643
|
-
.select({
|
|
1644
|
-
systemId: systemHealthChecks.systemId,
|
|
1645
|
-
configId: healthCheckConfigurations.id,
|
|
1646
|
-
interval: healthCheckConfigurations.intervalSeconds,
|
|
1647
|
-
})
|
|
1648
|
-
.from(systemHealthChecks)
|
|
1649
|
-
.innerJoin(
|
|
1650
|
-
healthCheckConfigurations,
|
|
1651
|
-
eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
|
|
1652
|
-
)
|
|
1653
|
-
.where(eq(systemHealthChecks.enabled, true));
|
|
1654
|
-
|
|
1655
|
-
// Get latest run timestamp for each system+config pair
|
|
1656
|
-
// Using Drizzle's max() function for proper timestamp handling (no raw SQL)
|
|
1657
|
-
const latestRuns = await db
|
|
1658
|
-
.select({
|
|
1659
|
-
systemId: healthCheckRuns.systemId,
|
|
1660
|
-
configurationId: healthCheckRuns.configurationId,
|
|
1661
|
-
maxTimestamp: max(healthCheckRuns.timestamp),
|
|
1662
|
-
})
|
|
1663
|
-
.from(healthCheckRuns)
|
|
1664
|
-
.groupBy(healthCheckRuns.systemId, healthCheckRuns.configurationId);
|
|
1665
|
-
|
|
1666
|
-
// Create a lookup map for fast access
|
|
1667
|
-
const lastRunMap = new Map<string, Date>();
|
|
1668
|
-
for (const run of latestRuns) {
|
|
1669
|
-
if (run.maxTimestamp) {
|
|
1670
|
-
const key = `${run.systemId}:${run.configurationId}`;
|
|
1671
|
-
lastRunMap.set(key, run.maxTimestamp);
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
logger.debug(`Bootstrapping ${enabledChecks.length} health checks`);
|
|
1676
|
-
|
|
1677
|
-
for (const check of enabledChecks) {
|
|
1678
|
-
// Look up the last run from the map
|
|
1679
|
-
const lastRunKey = `${check.systemId}:${check.configId}`;
|
|
1680
|
-
const lastRun = lastRunMap.get(lastRunKey);
|
|
1681
|
-
|
|
1682
|
-
// Calculate delay for first run based on time since last run
|
|
1683
|
-
let startDelay = 0;
|
|
1684
|
-
if (lastRun) {
|
|
1685
|
-
const elapsedSeconds = Math.floor(
|
|
1686
|
-
(Date.now() - lastRun.getTime()) / 1000,
|
|
1687
|
-
);
|
|
1688
|
-
if (elapsedSeconds < check.interval) {
|
|
1689
|
-
// Not overdue yet - schedule with remaining time
|
|
1690
|
-
startDelay = check.interval - elapsedSeconds;
|
|
1691
|
-
}
|
|
1692
|
-
// Otherwise it's overdue - run immediately (startDelay = 0)
|
|
1693
|
-
logger.debug(
|
|
1694
|
-
`Health check ${check.configId}:${
|
|
1695
|
-
check.systemId
|
|
1696
|
-
} - lastRun: ${lastRun.toISOString()}, elapsed: ${elapsedSeconds}s, interval: ${
|
|
1697
|
-
check.interval
|
|
1698
|
-
}s, startDelay: ${startDelay}s`,
|
|
1699
|
-
);
|
|
1700
|
-
} else {
|
|
1701
|
-
logger.debug(
|
|
1702
|
-
`Health check ${check.configId}:${check.systemId} - no lastRun found, running immediately`,
|
|
1703
|
-
);
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
await scheduleHealthCheck({
|
|
1707
|
-
queueManager,
|
|
1708
|
-
payload: {
|
|
1709
|
-
configId: check.configId,
|
|
1710
|
-
systemId: check.systemId,
|
|
1711
|
-
},
|
|
1712
|
-
intervalSeconds: check.interval,
|
|
1713
|
-
startDelay,
|
|
1714
|
-
logger,
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
logger.debug(`โ
Bootstrapped ${enabledChecks.length} health checks`);
|
|
1719
|
-
|
|
1720
|
-
// Clean up orphaned jobs
|
|
1721
|
-
const queue =
|
|
1722
|
-
queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
|
|
1723
|
-
const allRecurringJobs = await queue.listRecurringJobs();
|
|
1724
|
-
const expectedJobIds = new Set(
|
|
1725
|
-
enabledChecks.map(
|
|
1726
|
-
(check) => `healthcheck:${check.configId}:${check.systemId}`,
|
|
1727
|
-
),
|
|
1728
|
-
);
|
|
1729
|
-
|
|
1730
|
-
const orphanedJobs = allRecurringJobs.filter(
|
|
1731
|
-
(jobId) => jobId.startsWith("healthcheck:") && !expectedJobIds.has(jobId),
|
|
1732
|
-
);
|
|
1733
|
-
|
|
1734
|
-
for (const jobId of orphanedJobs) {
|
|
1735
|
-
await queue.cancelRecurring(jobId);
|
|
1736
|
-
logger.debug(`Removed orphaned job scheduler: ${jobId}`);
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
if (orphanedJobs.length > 0) {
|
|
1740
|
-
logger.info(
|
|
1741
|
-
`๐งน Cleaned up ${orphanedJobs.length} orphaned health check jobs`,
|
|
1742
|
-
);
|
|
1743
|
-
}
|
|
1744
|
-
}
|