@checkstack/healthcheck-backend 1.16.0 โ 1.18.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 +399 -0
- package/package.json +30 -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/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +87 -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 +395 -294
- 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 +103 -19
- 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 +98 -0
- package/src/service-ordering.test.ts +4 -0
- package/src/service-paused-filter.test.ts +14 -7
- package/src/service-rollup-worst-wins.test.ts +37 -4
- package/src/service.ts +348 -145
- 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/widgets.ts +11 -1
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
- package/src/system-health-override.test.ts +94 -0
- package/src/system-health-override.ts +93 -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,
|
|
@@ -55,6 +59,12 @@ import { HealthCheckService } from "./service";
|
|
|
55
59
|
import { healthCheckHooks } from "./hooks";
|
|
56
60
|
import { incrementHourlyAggregate } from "./realtime-aggregation";
|
|
57
61
|
import type { HealthCheckCache } from "./cache";
|
|
62
|
+
import {
|
|
63
|
+
resolveSlowCheckRuntime,
|
|
64
|
+
type SlowCheckRuntime,
|
|
65
|
+
} from "./slow-check-config";
|
|
66
|
+
import type { RecentRun } from "./slow-check-classifier";
|
|
67
|
+
import { evaluateSlowCheckAdmission } from "./slow-check-admission";
|
|
58
68
|
import {
|
|
59
69
|
classifyTransition,
|
|
60
70
|
shouldNotifyTransition,
|
|
@@ -98,6 +108,48 @@ function toHealthEntityView(state: AggregatedHealth): HealthEntityState {
|
|
|
98
108
|
};
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Read the most recent runs for ONE (config, system, environment) slice,
|
|
113
|
+
* newest-first, projected to the fields the slow-check classifier needs. Used
|
|
114
|
+
* only when the slow-check bulkhead is enabled; keyed on the SAME
|
|
115
|
+
* `environmentId` the job runs (an env-less job reads the `environment_id IS
|
|
116
|
+
* NULL` slice), so the classification reflects exactly this slice's streak.
|
|
117
|
+
*/
|
|
118
|
+
async function fetchRecentRunsForSlice(props: {
|
|
119
|
+
db: Db;
|
|
120
|
+
configId: string;
|
|
121
|
+
systemId: string;
|
|
122
|
+
environmentId: string | null;
|
|
123
|
+
limit: number;
|
|
124
|
+
}): Promise<RecentRun[]> {
|
|
125
|
+
const { db, configId, systemId, environmentId, limit } = props;
|
|
126
|
+
const rows = await db
|
|
127
|
+
.select({
|
|
128
|
+
environmentId: healthCheckRuns.environmentId,
|
|
129
|
+
status: healthCheckRuns.status,
|
|
130
|
+
latencyMs: healthCheckRuns.latencyMs,
|
|
131
|
+
timestamp: healthCheckRuns.timestamp,
|
|
132
|
+
})
|
|
133
|
+
.from(healthCheckRuns)
|
|
134
|
+
.where(
|
|
135
|
+
and(
|
|
136
|
+
eq(healthCheckRuns.configurationId, configId),
|
|
137
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
138
|
+
environmentId === null
|
|
139
|
+
? isNull(healthCheckRuns.environmentId)
|
|
140
|
+
: eq(healthCheckRuns.environmentId, environmentId),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
144
|
+
.limit(limit);
|
|
145
|
+
return rows.map((r) => ({
|
|
146
|
+
environmentId: r.environmentId,
|
|
147
|
+
status: r.status,
|
|
148
|
+
latencyMs: r.latencyMs,
|
|
149
|
+
timestamp: r.timestamp,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
101
153
|
/** The known transport timing phase keys, in transport order. */
|
|
102
154
|
const RUN_TIMING_KEYS = [
|
|
103
155
|
"dnsMs",
|
|
@@ -185,11 +237,38 @@ async function emitCheckCompletedHook({
|
|
|
185
237
|
}
|
|
186
238
|
|
|
187
239
|
/**
|
|
188
|
-
* Payload for health check queue jobs
|
|
240
|
+
* Payload for health check queue jobs. Every job runs EXACTLY ONE environment
|
|
241
|
+
* slice - there is no in-job fan-out:
|
|
242
|
+
* - `environmentId: null` - the single ENV-LESS run of a system that has no
|
|
243
|
+
* environments. Its write IS the system rollup, so it notifies directly.
|
|
244
|
+
* - `environmentId: <id>` - the run for that specific environment. The system
|
|
245
|
+
* rollup is recomputed by the event-driven rollup consumer, not inline.
|
|
246
|
+
*
|
|
247
|
+
* The scheduling reconciler owns which (config, system, env) jobs exist; the
|
|
248
|
+
* `run_now` action enqueues one job per effective environment.
|
|
189
249
|
*/
|
|
190
250
|
export interface HealthCheckJobPayload {
|
|
191
251
|
configId: string;
|
|
192
252
|
systemId: string;
|
|
253
|
+
environmentId: string | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Prefix every health-check recurring jobId shares (used for orphan scans). */
|
|
257
|
+
export const HEALTH_CHECK_JOB_PREFIX = "healthcheck:";
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Build the recurring jobId for a check. The env-less form keeps the historical
|
|
261
|
+
* `healthcheck:${configId}:${systemId}` shape (so env-less systems' jobs are
|
|
262
|
+
* unchanged across the per-env migration); an env-scoped job appends the env id.
|
|
263
|
+
*/
|
|
264
|
+
export function encodeHealthCheckJobId(props: {
|
|
265
|
+
configId: string;
|
|
266
|
+
systemId: string;
|
|
267
|
+
environmentId: string | null;
|
|
268
|
+
}): string {
|
|
269
|
+
const { configId, systemId, environmentId } = props;
|
|
270
|
+
const base = `${HEALTH_CHECK_JOB_PREFIX}${configId}:${systemId}`;
|
|
271
|
+
return environmentId === null ? base : `${base}:${environmentId}`;
|
|
193
272
|
}
|
|
194
273
|
|
|
195
274
|
/**
|
|
@@ -230,7 +309,11 @@ export async function scheduleHealthCheck(props: {
|
|
|
230
309
|
const queue =
|
|
231
310
|
queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
|
|
232
311
|
|
|
233
|
-
const jobId =
|
|
312
|
+
const jobId = encodeHealthCheckJobId({
|
|
313
|
+
configId: payload.configId,
|
|
314
|
+
systemId: payload.systemId,
|
|
315
|
+
environmentId: payload.environmentId,
|
|
316
|
+
});
|
|
234
317
|
|
|
235
318
|
logger?.debug(
|
|
236
319
|
`Scheduling recurring health check ${jobId} with interval ${intervalSeconds}s, startDelay ${startDelay}s`,
|
|
@@ -272,16 +355,45 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
272
355
|
getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
|
|
273
356
|
advisoryLock: AdvisoryLockService;
|
|
274
357
|
logger: Logger;
|
|
275
|
-
|
|
276
|
-
|
|
358
|
+
/**
|
|
359
|
+
* When provided, a real rollup status change (prev โ next) also invalidates
|
|
360
|
+
* the per-system cache and broadcasts `SYSTEM_STATUS_CHANGED`, matching the
|
|
361
|
+
* system-level signal the pre-per-env inline rollup fired. Omit for the pure
|
|
362
|
+
* entity-only recompute (the framework's `ENTITY_CHANGED` still drives
|
|
363
|
+
* SLO/dependency/triggers regardless).
|
|
364
|
+
*/
|
|
365
|
+
signalService?: SignalService;
|
|
366
|
+
cache?: HealthCheckCache;
|
|
367
|
+
}): Promise<{ previousStatus: HealthCheckStatus; newStatus: HealthCheckStatus } | undefined> {
|
|
368
|
+
const {
|
|
369
|
+
systemId,
|
|
370
|
+
service,
|
|
371
|
+
getHealthEntity,
|
|
372
|
+
advisoryLock,
|
|
373
|
+
logger,
|
|
374
|
+
signalService,
|
|
375
|
+
cache,
|
|
376
|
+
} = args;
|
|
277
377
|
const rollupEntityId = encodeHealthEntityId({ systemId });
|
|
278
378
|
const makeHealthSerializer = createHealthEntitySerializer({ advisoryLock });
|
|
379
|
+
// The system-level signal + cache invalidation are only needed when a caller
|
|
380
|
+
// wants them (the rollup consumer). The framework snapshots its OWN prev
|
|
381
|
+
// inside `handle.mutate` for the authoritative `ENTITY_CHANGED`, so the
|
|
382
|
+
// pure entity-recompute path (pause/resume) skips the extra prev read.
|
|
383
|
+
const wantsSignal = signalService !== undefined || cache !== undefined;
|
|
279
384
|
try {
|
|
385
|
+
let previousStatus: HealthCheckStatus | undefined;
|
|
386
|
+
if (wantsSignal) {
|
|
387
|
+
const previousState = await service.getSystemHealthStatus(systemId);
|
|
388
|
+
previousStatus = previousState.status;
|
|
389
|
+
}
|
|
390
|
+
let newStatus: HealthCheckStatus | undefined = previousStatus;
|
|
280
391
|
await writeHealthEntity({
|
|
281
392
|
handle: getHealthEntity?.(),
|
|
282
393
|
entityId: rollupEntityId,
|
|
283
394
|
apply: async () => {
|
|
284
395
|
const rollupState = await service.getSystemHealthStatus(systemId);
|
|
396
|
+
newStatus = rollupState.status;
|
|
285
397
|
return toHealthEntityView(rollupState);
|
|
286
398
|
},
|
|
287
399
|
serialize: makeHealthSerializer(rollupEntityId),
|
|
@@ -291,6 +403,23 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
291
403
|
error,
|
|
292
404
|
),
|
|
293
405
|
});
|
|
406
|
+
|
|
407
|
+
if (
|
|
408
|
+
wantsSignal &&
|
|
409
|
+
previousStatus !== undefined &&
|
|
410
|
+
newStatus !== undefined &&
|
|
411
|
+
newStatus !== previousStatus
|
|
412
|
+
) {
|
|
413
|
+
await cache?.invalidateSystem(systemId);
|
|
414
|
+
await signalService?.broadcast(SYSTEM_STATUS_CHANGED, {
|
|
415
|
+
systemId,
|
|
416
|
+
previousStatus,
|
|
417
|
+
newStatus,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return previousStatus !== undefined && newStatus !== undefined
|
|
421
|
+
? { previousStatus, newStatus }
|
|
422
|
+
: undefined;
|
|
294
423
|
} catch (error) {
|
|
295
424
|
// A recompute failure must never break the pause/resume RPC. The
|
|
296
425
|
// durable tables still hold the authoritative runs; the next run tick
|
|
@@ -299,6 +428,7 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
299
428
|
`Failed to recompute system rollup health for ${systemId}`,
|
|
300
429
|
error,
|
|
301
430
|
);
|
|
431
|
+
return undefined;
|
|
302
432
|
}
|
|
303
433
|
}
|
|
304
434
|
|
|
@@ -323,6 +453,13 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
323
453
|
* Policy is resolved per-assignment (per system+configuration) โ the
|
|
324
454
|
* just-ran check is the one driving any aggregate transition in this
|
|
325
455
|
* execution, so its policy is the authoritative one.
|
|
456
|
+
*
|
|
457
|
+
* Returns `true` when a subscriber notification was actually delivered, and
|
|
458
|
+
* `false` when it was skipped (no-op transition, policy/maintenance/incident
|
|
459
|
+
* suppression) or the delivery threw. Callers use this to deduplicate the
|
|
460
|
+
* system-rollup notification against the per-environment ones fired in the
|
|
461
|
+
* same tick: when any environment already notified, the rollup notification
|
|
462
|
+
* (which describes the same underlying outage) is redundant and suppressed.
|
|
326
463
|
*/
|
|
327
464
|
async function notifyStateChange(props: {
|
|
328
465
|
systemId: string;
|
|
@@ -351,7 +488,7 @@ async function notifyStateChange(props: {
|
|
|
351
488
|
maintenanceClient: MaintenanceClient;
|
|
352
489
|
incidentClient: IncidentClient;
|
|
353
490
|
logger: Logger;
|
|
354
|
-
}): Promise<
|
|
491
|
+
}): Promise<boolean> {
|
|
355
492
|
const {
|
|
356
493
|
systemId,
|
|
357
494
|
systemName,
|
|
@@ -373,7 +510,7 @@ async function notifyStateChange(props: {
|
|
|
373
510
|
|
|
374
511
|
const transition = classifyTransition(previousStatus, newStatus);
|
|
375
512
|
if (transition === "none") {
|
|
376
|
-
return;
|
|
513
|
+
return false;
|
|
377
514
|
}
|
|
378
515
|
|
|
379
516
|
// Per-assignment notification policy. Failure to load defaults to
|
|
@@ -396,7 +533,7 @@ async function notifyStateChange(props: {
|
|
|
396
533
|
logger.debug(
|
|
397
534
|
`Skipping notification for ${systemId}: ${transition} suppressed by policy`,
|
|
398
535
|
);
|
|
399
|
-
return;
|
|
536
|
+
return false;
|
|
400
537
|
}
|
|
401
538
|
|
|
402
539
|
// Check if notifications should be suppressed due to active maintenance
|
|
@@ -407,7 +544,7 @@ async function notifyStateChange(props: {
|
|
|
407
544
|
logger.debug(
|
|
408
545
|
`Skipping notification for ${systemId}: active maintenance with suppression enabled`,
|
|
409
546
|
);
|
|
410
|
-
return;
|
|
547
|
+
return false;
|
|
411
548
|
}
|
|
412
549
|
} catch (error) {
|
|
413
550
|
// Log but continue with notification - suppression check failure shouldn't block notifications
|
|
@@ -425,7 +562,7 @@ async function notifyStateChange(props: {
|
|
|
425
562
|
logger.debug(
|
|
426
563
|
`Skipping notification for ${systemId}: active incident with suppression enabled`,
|
|
427
564
|
);
|
|
428
|
-
return;
|
|
565
|
+
return false;
|
|
429
566
|
}
|
|
430
567
|
} catch (error) {
|
|
431
568
|
// Log but continue with notification - suppression check failure shouldn't block notifications
|
|
@@ -502,12 +639,16 @@ async function notifyStateChange(props: {
|
|
|
502
639
|
logger.debug(
|
|
503
640
|
`Notified subscribers: ${previousStatus} โ ${newStatus} for system ${systemId}`,
|
|
504
641
|
);
|
|
642
|
+
return true;
|
|
505
643
|
} catch (error) {
|
|
506
|
-
// Log but don't fail the operation - notifications are best-effort
|
|
644
|
+
// Log but don't fail the operation - notifications are best-effort. A
|
|
645
|
+
// delivery that threw did NOT inform the user, so report `false` and let
|
|
646
|
+
// the caller's rollup fallback still fire.
|
|
507
647
|
logger.warn(
|
|
508
648
|
`Failed to notify subscribers for health state change on system ${systemId}:`,
|
|
509
649
|
error,
|
|
510
650
|
);
|
|
651
|
+
return false;
|
|
511
652
|
}
|
|
512
653
|
}
|
|
513
654
|
|
|
@@ -551,6 +692,12 @@ async function executeHealthCheckJob(props: {
|
|
|
551
692
|
* without it, marker-bearing configs fail their runs clearly.
|
|
552
693
|
*/
|
|
553
694
|
internalSecrets?: InternalSecretsService;
|
|
695
|
+
/**
|
|
696
|
+
* Slow-check bulkhead + adaptive-timeout runtime, resolved once at worker
|
|
697
|
+
* startup. `null`/`undefined` disables the feature: the classification read
|
|
698
|
+
* is skipped and the run executes exactly as before (full timeout, no lane).
|
|
699
|
+
*/
|
|
700
|
+
slowCheckRuntime?: SlowCheckRuntime | null;
|
|
554
701
|
}): Promise<void> {
|
|
555
702
|
const {
|
|
556
703
|
payload,
|
|
@@ -569,6 +716,7 @@ async function executeHealthCheckJob(props: {
|
|
|
569
716
|
cache,
|
|
570
717
|
secretResolver,
|
|
571
718
|
internalSecrets,
|
|
719
|
+
slowCheckRuntime,
|
|
572
720
|
} = props;
|
|
573
721
|
const { configId, systemId } = payload;
|
|
574
722
|
|
|
@@ -592,6 +740,10 @@ async function executeHealthCheckJob(props: {
|
|
|
592
740
|
const rollupPreviousState = await service.getSystemHealthStatus(systemId);
|
|
593
741
|
const rollupPreviousStatus = rollupPreviousState.status;
|
|
594
742
|
|
|
743
|
+
// Slow-check lane admission (set when this run was admitted to the suspect
|
|
744
|
+
// lane); released in the outer finally so the slot frees on any exit path.
|
|
745
|
+
let laneKey: string | undefined;
|
|
746
|
+
|
|
595
747
|
try {
|
|
596
748
|
// Fetch configuration (including name for signals)
|
|
597
749
|
const [configRow] = await db
|
|
@@ -705,13 +857,15 @@ async function executeHealthCheckJob(props: {
|
|
|
705
857
|
// tables and is re-read every tick via the cross-plugin RPC, so every pod
|
|
706
858
|
// resolves the same set (state-and-scale: no pod-local env state).
|
|
707
859
|
let membership: Environment[] = [];
|
|
860
|
+
let catalogResolutionFailed = false;
|
|
708
861
|
try {
|
|
709
862
|
membership = await catalogClient.resolveSystemEnvironments({ systemId });
|
|
710
863
|
} catch (error) {
|
|
711
|
-
// Fail-open: a catalog read failure must not wedge the check.
|
|
712
|
-
//
|
|
864
|
+
// Fail-open: a catalog read failure must not wedge the check. We keep
|
|
865
|
+
// running the payload's env with degraded fields rather than skipping.
|
|
866
|
+
catalogResolutionFailed = true;
|
|
713
867
|
logger.warn(
|
|
714
|
-
`Could not resolve environments for system ${systemId}
|
|
868
|
+
`Could not resolve environments for system ${systemId}`,
|
|
715
869
|
error,
|
|
716
870
|
);
|
|
717
871
|
// Observability: a `logger.warn` alone is easy to miss when a durable
|
|
@@ -735,28 +889,102 @@ async function executeHealthCheckJob(props: {
|
|
|
735
889
|
environmentIds: configRow.environmentIds,
|
|
736
890
|
membership,
|
|
737
891
|
});
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
892
|
+
|
|
893
|
+
// Select THE single environment this job runs (payload.environmentId). The
|
|
894
|
+
// reconciler owns which (config, system, env) jobs exist; here we only
|
|
895
|
+
// validate the payload's env against the CURRENT effective set so a stale
|
|
896
|
+
// job (env removed, or an env-less job for a system that has since gained
|
|
897
|
+
// envs) is skipped and the reconciler converges the set.
|
|
898
|
+
const targetEnvironmentId = payload.environmentId;
|
|
899
|
+
let singleEnvironment: EffectiveEnvironment | null;
|
|
900
|
+
if (targetEnvironmentId === null) {
|
|
901
|
+
// Env-less job: valid only while the system has no effective envs.
|
|
902
|
+
if (!catalogResolutionFailed && effectiveEnvs.length > 0) {
|
|
903
|
+
logger.debug(
|
|
904
|
+
`Env-less job for ${configId}/${systemId} is stale (system now has ${effectiveEnvs.length} env(s)); skipping`,
|
|
905
|
+
);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
singleEnvironment = null;
|
|
909
|
+
} else {
|
|
910
|
+
const found =
|
|
911
|
+
effectiveEnvs.find((env) => env.id === targetEnvironmentId) ?? null;
|
|
912
|
+
if (found) {
|
|
913
|
+
singleEnvironment = found;
|
|
914
|
+
} else if (catalogResolutionFailed) {
|
|
915
|
+
// Transient catalog failure: still run the probe, with degraded (empty)
|
|
916
|
+
// env fields rather than skipping the tick. The next tick recovers.
|
|
917
|
+
singleEnvironment = {
|
|
918
|
+
id: targetEnvironmentId,
|
|
919
|
+
name: targetEnvironmentId,
|
|
920
|
+
fields: {},
|
|
921
|
+
};
|
|
922
|
+
} else {
|
|
923
|
+
logger.debug(
|
|
924
|
+
`Env ${targetEnvironmentId} no longer effective for ${configId}/${systemId}; skipping`,
|
|
925
|
+
);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// โโ Slow-check bulkhead + adaptive timeout โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
931
|
+
// Classify THIS slice's recent runs. A slice whose last K runs were SLOW
|
|
932
|
+
// transport failures (held its slot ~the full timeout) is "suspect": it is
|
|
933
|
+
// admitted to a capped, pod-local lane (or DEFERRED this tick when the lane
|
|
934
|
+
// is full or a prior run of the same slice is still in flight โ recording
|
|
935
|
+
// nothing so it can't pile up) and probed with a timeout shrunk toward its
|
|
936
|
+
// OWN healthy-latency baseline, so a stuck target frees its slot fast
|
|
937
|
+
// instead of pinning it for the full timeout. A healthy slice is untouched.
|
|
938
|
+
let effectiveTimeout = executionTimeout;
|
|
939
|
+
const sliceEnvironmentId = singleEnvironment?.id ?? null;
|
|
940
|
+
if (slowCheckRuntime) {
|
|
941
|
+
try {
|
|
942
|
+
const recentRuns = await fetchRecentRunsForSlice({
|
|
943
|
+
db,
|
|
944
|
+
configId,
|
|
945
|
+
systemId,
|
|
946
|
+
environmentId: sliceEnvironmentId,
|
|
947
|
+
limit: slowCheckRuntime.recentRunsLimit,
|
|
948
|
+
});
|
|
949
|
+
const decision = evaluateSlowCheckAdmission({
|
|
950
|
+
runtime: slowCheckRuntime,
|
|
951
|
+
recentRuns,
|
|
952
|
+
configId,
|
|
953
|
+
systemId,
|
|
954
|
+
environmentId: sliceEnvironmentId,
|
|
955
|
+
executionTimeoutMs: executionTimeout,
|
|
956
|
+
});
|
|
957
|
+
if (decision.kind === "defer") {
|
|
958
|
+
healthcheckDeferredCounter().add(1, { reason: decision.reason });
|
|
959
|
+
logger.debug(
|
|
960
|
+
`Deferred suspect health check ${configId}/${systemId}` +
|
|
961
|
+
(sliceEnvironmentId ? ` [${sliceEnvironmentId}]` : "") +
|
|
962
|
+
` (${decision.reason}); recording nothing this tick`,
|
|
963
|
+
);
|
|
964
|
+
// Record nothing: the recurring job stays scheduled, so the next tick
|
|
965
|
+
// retries once the lane drains / the in-flight run finishes.
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
effectiveTimeout = decision.effectiveTimeoutMs;
|
|
969
|
+
laneKey = decision.laneKey;
|
|
970
|
+
} catch (error) {
|
|
971
|
+
// Classification is best-effort: a read failure must never wedge the
|
|
972
|
+
// check. Fall back to the full timeout with no lane admission.
|
|
973
|
+
logger.warn(
|
|
974
|
+
`Slow-check classification failed for ${configId}/${systemId}; running at full timeout`,
|
|
975
|
+
error,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// This job runs exactly this ONE env. `isFannedOut` is now a per-JOB
|
|
981
|
+
// property: an env-scoped run (`isFannedOut === true`) mutates the
|
|
982
|
+
// `<systemId>::<env>` entity and leaves the bare `<systemId>` ROLLUP to the
|
|
983
|
+
// event-driven rollup consumer; an env-less run mutates the bare entity
|
|
984
|
+
// (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
|
|
985
|
+
// directly.
|
|
986
|
+
const runEnvironments: (EffectiveEnvironment | null)[] = [singleEnvironment];
|
|
987
|
+
const isFannedOut = targetEnvironmentId !== null;
|
|
760
988
|
for (const environment of runEnvironments) {
|
|
761
989
|
const environmentId = environment?.id ?? null;
|
|
762
990
|
// The env-qualified entity id this run mutates. For the env-less run
|
|
@@ -1047,9 +1275,9 @@ async function executeHealthCheckJob(props: {
|
|
|
1047
1275
|
setTimeout(
|
|
1048
1276
|
() =>
|
|
1049
1277
|
reject(
|
|
1050
|
-
new Error(`Execution timeout after ${
|
|
1278
|
+
new Error(`Execution timeout after ${effectiveTimeout}ms`),
|
|
1051
1279
|
),
|
|
1052
|
-
|
|
1280
|
+
effectiveTimeout,
|
|
1053
1281
|
),
|
|
1054
1282
|
),
|
|
1055
1283
|
]);
|
|
@@ -1081,31 +1309,38 @@ async function executeHealthCheckJob(props: {
|
|
|
1081
1309
|
handle: getHealthEntity?.(),
|
|
1082
1310
|
entityId: envEntityId,
|
|
1083
1311
|
apply: async () => {
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1312
|
+
// ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
|
|
1313
|
+
// `SET LOCAL search_path` transaction (3 scoped-db transactions โ 1),
|
|
1314
|
+
// which also makes the run and its aggregate commit atomically.
|
|
1315
|
+
await withScopedTransaction(db, async (tx) => {
|
|
1316
|
+
await tx.insert(healthCheckRuns).values({
|
|
1317
|
+
configurationId: configId,
|
|
1318
|
+
systemId,
|
|
1319
|
+
environmentId,
|
|
1320
|
+
status: result.status,
|
|
1321
|
+
latencyMs: result.latencyMs,
|
|
1322
|
+
result: { ...result } as Record<string, unknown>,
|
|
1323
|
+
sourceId: undefined,
|
|
1324
|
+
sourceLabel: "Local",
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
await incrementHourlyAggregate({
|
|
1328
|
+
db: tx,
|
|
1329
|
+
systemId,
|
|
1330
|
+
configurationId: configId,
|
|
1331
|
+
environmentId,
|
|
1332
|
+
status: result.status,
|
|
1333
|
+
latencyMs: result.latencyMs,
|
|
1334
|
+
runTimestamp: new Date(),
|
|
1335
|
+
result: { ...result } as Record<string, unknown>,
|
|
1336
|
+
collectorRegistry,
|
|
1337
|
+
sourceLabel: "Local",
|
|
1338
|
+
});
|
|
1106
1339
|
});
|
|
1107
1340
|
|
|
1108
1341
|
// Env-scoped view: the per-env entity reflects only this env's runs.
|
|
1342
|
+
// Runs as its own batched read AFTER the write commits, so it sees
|
|
1343
|
+
// the just-inserted run.
|
|
1109
1344
|
newState = await service.getSystemHealthStatus(systemId, environmentId);
|
|
1110
1345
|
return toHealthEntityView(newState);
|
|
1111
1346
|
},
|
|
@@ -1116,7 +1351,6 @@ async function executeHealthCheckJob(props: {
|
|
|
1116
1351
|
error,
|
|
1117
1352
|
),
|
|
1118
1353
|
});
|
|
1119
|
-
anyEnvRunPersisted = true;
|
|
1120
1354
|
|
|
1121
1355
|
logger.debug(
|
|
1122
1356
|
`Health check ${configId} for system ${systemId} failed: ${finalError}`,
|
|
@@ -1191,6 +1425,21 @@ async function executeHealthCheckJob(props: {
|
|
|
1191
1425
|
// undefined and the frontend falls back to the coarse connection split.
|
|
1192
1426
|
const timings = extractRunTimings(connectedClient);
|
|
1193
1427
|
|
|
1428
|
+
// Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
|
|
1429
|
+
// network sub-phases. The `phase` breakdown is what tells "slow target"
|
|
1430
|
+
// (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
|
|
1431
|
+
// grow under a same-host stampede) apart from platform delay.
|
|
1432
|
+
healthcheckExecutionHistogram().record(totalLatencyMs, { status });
|
|
1433
|
+
if (timings) {
|
|
1434
|
+
for (const [phase, value] of Object.entries(timings)) {
|
|
1435
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1436
|
+
healthcheckPhaseHistogram().record(value, {
|
|
1437
|
+
phase: phase.replace(/Ms$/, ""),
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1194
1443
|
const result = {
|
|
1195
1444
|
status: status as "healthy" | "unhealthy",
|
|
1196
1445
|
latencyMs: totalLatencyMs,
|
|
@@ -1217,33 +1466,40 @@ async function executeHealthCheckJob(props: {
|
|
|
1217
1466
|
handle: getHealthEntity?.(),
|
|
1218
1467
|
entityId: envEntityId,
|
|
1219
1468
|
apply: async () => {
|
|
1220
|
-
//
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1469
|
+
// ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
|
|
1470
|
+
// `SET LOCAL search_path` transaction (3 scoped-db transactions โ 1),
|
|
1471
|
+
// which also makes the run and its aggregate commit atomically.
|
|
1472
|
+
await withScopedTransaction(db, async (tx) => {
|
|
1473
|
+
// Store result (spread to convert structured type to plain record for jsonb)
|
|
1474
|
+
await tx.insert(healthCheckRuns).values({
|
|
1475
|
+
configurationId: configId,
|
|
1476
|
+
systemId,
|
|
1477
|
+
environmentId,
|
|
1478
|
+
status: result.status,
|
|
1479
|
+
latencyMs: result.latencyMs,
|
|
1480
|
+
result: { ...result } as Record<string, unknown>,
|
|
1481
|
+
sourceId: undefined,
|
|
1482
|
+
sourceLabel: "Local",
|
|
1483
|
+
});
|
|
1231
1484
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1485
|
+
// Trigger incremental hourly aggregation
|
|
1486
|
+
await incrementHourlyAggregate({
|
|
1487
|
+
db: tx,
|
|
1488
|
+
systemId,
|
|
1489
|
+
configurationId: configId,
|
|
1490
|
+
environmentId,
|
|
1491
|
+
status: result.status,
|
|
1492
|
+
latencyMs: result.latencyMs,
|
|
1493
|
+
runTimestamp: new Date(),
|
|
1494
|
+
result: { ...result } as Record<string, unknown>,
|
|
1495
|
+
collectorRegistry,
|
|
1496
|
+
sourceLabel: "Local",
|
|
1497
|
+
});
|
|
1244
1498
|
});
|
|
1245
1499
|
|
|
1246
1500
|
// Env-scoped view: the per-env entity reflects only this env's runs.
|
|
1501
|
+
// Runs as its own batched read AFTER the write commits, so it sees the
|
|
1502
|
+
// just-inserted run.
|
|
1247
1503
|
newState = await service.getSystemHealthStatus(systemId, environmentId);
|
|
1248
1504
|
return toHealthEntityView(newState);
|
|
1249
1505
|
},
|
|
@@ -1251,7 +1507,6 @@ async function executeHealthCheckJob(props: {
|
|
|
1251
1507
|
onError: (error) =>
|
|
1252
1508
|
logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
|
|
1253
1509
|
});
|
|
1254
|
-
anyEnvRunPersisted = true;
|
|
1255
1510
|
|
|
1256
1511
|
logger.debug(
|
|
1257
1512
|
`Ran health check ${configId} for system ${systemId}: ${result.status}`,
|
|
@@ -1343,81 +1598,14 @@ async function executeHealthCheckJob(props: {
|
|
|
1343
1598
|
}
|
|
1344
1599
|
} // end per-environment fan-out loop (for ... of runEnvironments)
|
|
1345
1600
|
|
|
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
|
-
}
|
|
1601
|
+
// The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
|
|
1602
|
+
// run is recomputed ASYNCHRONOUSLY by the event-driven rollup consumer,
|
|
1603
|
+
// which subscribes to per-env `health` entity changes and debounces per
|
|
1604
|
+
// system (recordSystemRollupChange). Doing it inline per env-job would
|
|
1605
|
+
// multiply the `health:<systemId>` advisory-lock load by the fan-out
|
|
1606
|
+
// factor. An env-less run needs no separate rollup: its write above IS the
|
|
1607
|
+
// bare `<systemId>` entity, and it already recorded its own transition,
|
|
1608
|
+
// notification, and SYSTEM_STATUS_CHANGED signal.
|
|
1421
1609
|
|
|
1422
1610
|
// Note: No manual rescheduling needed - recurring job handles it automatically
|
|
1423
1611
|
} catch (error) {
|
|
@@ -1438,27 +1626,32 @@ async function executeHealthCheckJob(props: {
|
|
|
1438
1626
|
handle: getHealthEntity?.(),
|
|
1439
1627
|
entityId: rollupEntityId,
|
|
1440
1628
|
apply: async () => {
|
|
1441
|
-
//
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1629
|
+
// ยงperf: batch the failure run INSERT + aggregate SELECT/UPSERT under
|
|
1630
|
+
// ONE `SET LOCAL search_path` transaction (3 scoped-db transactions โ
|
|
1631
|
+
// 1), which also makes them commit atomically.
|
|
1632
|
+
await withScopedTransaction(db, async (tx) => {
|
|
1633
|
+
// Store failure (no latencyMs for failures)
|
|
1634
|
+
await tx.insert(healthCheckRuns).values({
|
|
1635
|
+
configurationId: configId,
|
|
1636
|
+
systemId,
|
|
1637
|
+
status: "unhealthy",
|
|
1638
|
+
result: { error: String(error) } as Record<string, unknown>,
|
|
1639
|
+
sourceId: undefined,
|
|
1640
|
+
sourceLabel: "Local",
|
|
1641
|
+
});
|
|
1450
1642
|
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1643
|
+
// Trigger incremental hourly aggregation
|
|
1644
|
+
await incrementHourlyAggregate({
|
|
1645
|
+
db: tx,
|
|
1646
|
+
systemId,
|
|
1647
|
+
configurationId: configId,
|
|
1648
|
+
status: "unhealthy",
|
|
1649
|
+
latencyMs: undefined,
|
|
1650
|
+
runTimestamp: new Date(),
|
|
1651
|
+
// No collector data for error cases
|
|
1652
|
+
collectorRegistry,
|
|
1653
|
+
sourceLabel: "Local",
|
|
1654
|
+
});
|
|
1462
1655
|
});
|
|
1463
1656
|
|
|
1464
1657
|
newState = await service.getSystemHealthStatus(systemId);
|
|
@@ -1554,6 +1747,11 @@ async function executeHealthCheckJob(props: {
|
|
|
1554
1747
|
}
|
|
1555
1748
|
|
|
1556
1749
|
// Note: No manual rescheduling needed - recurring job handles it automatically
|
|
1750
|
+
} finally {
|
|
1751
|
+
// Release the suspect-lane slot (single-flight + capacity) on EVERY exit
|
|
1752
|
+
// path โ success, timeout, or a catastrophic throw โ so a slow run frees
|
|
1753
|
+
// its slot for the next tick. A no-op when this run was not admitted.
|
|
1754
|
+
if (laneKey && slowCheckRuntime) slowCheckRuntime.lane.release(laneKey);
|
|
1557
1755
|
}
|
|
1558
1756
|
}
|
|
1559
1757
|
|
|
@@ -1574,6 +1772,12 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1574
1772
|
cache: HealthCheckCache;
|
|
1575
1773
|
secretResolver?: SecretResolverService;
|
|
1576
1774
|
internalSecrets?: InternalSecretsService;
|
|
1775
|
+
/**
|
|
1776
|
+
* Slow-check bulkhead runtime. Omit to resolve it once from `process.env`
|
|
1777
|
+
* (the production path); pass `null` to force the feature OFF (tests that
|
|
1778
|
+
* don't exercise the bulkhead), or a concrete runtime to drive it.
|
|
1779
|
+
*/
|
|
1780
|
+
slowCheckRuntime?: SlowCheckRuntime | null;
|
|
1577
1781
|
}): Promise<void> {
|
|
1578
1782
|
const {
|
|
1579
1783
|
db,
|
|
@@ -1594,6 +1798,16 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1594
1798
|
internalSecrets,
|
|
1595
1799
|
} = props;
|
|
1596
1800
|
|
|
1801
|
+
// Resolve the slow-check runtime once at startup unless the caller supplied
|
|
1802
|
+
// one (including an explicit `null` to disable it).
|
|
1803
|
+
const slowCheckRuntime =
|
|
1804
|
+
props.slowCheckRuntime === undefined
|
|
1805
|
+
? resolveSlowCheckRuntime(process.env)
|
|
1806
|
+
: props.slowCheckRuntime;
|
|
1807
|
+
if (slowCheckRuntime) {
|
|
1808
|
+
logger.debug("๐ฉบ Slow-check bulkhead + adaptive timeout enabled.");
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1597
1811
|
const queue =
|
|
1598
1812
|
queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
|
|
1599
1813
|
|
|
@@ -1617,6 +1831,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1617
1831
|
cache,
|
|
1618
1832
|
secretResolver,
|
|
1619
1833
|
internalSecrets,
|
|
1834
|
+
slowCheckRuntime,
|
|
1620
1835
|
});
|
|
1621
1836
|
},
|
|
1622
1837
|
{
|
|
@@ -1628,117 +1843,3 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1628
1843
|
logger.debug("๐ฏ Health Check Worker subscribed to queue");
|
|
1629
1844
|
}
|
|
1630
1845
|
|
|
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
|
-
}
|