@checkstack/healthcheck-backend 1.17.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 +265 -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 +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 +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 +11 -14
- 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 +255 -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/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
package/src/service.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
isNull,
|
|
44
44
|
isNotNull,
|
|
45
45
|
inArray,
|
|
46
|
+
max,
|
|
46
47
|
} from "drizzle-orm";
|
|
47
48
|
import { ORPCError } from "@orpc/server";
|
|
48
49
|
import { evaluateHealthStatus } from "./state-evaluator";
|
|
@@ -51,7 +52,9 @@ import { parseHealthEntityId } from "./health-entity-id";
|
|
|
51
52
|
import { stateThresholds } from "./state-thresholds-migrations";
|
|
52
53
|
import type { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
53
54
|
import type { Logger } from "@checkstack/backend-api";
|
|
55
|
+
import { resolveEffectiveEnvironments } from "./effective-environments";
|
|
54
56
|
import { incrementHourlyAggregate } from "./realtime-aggregation";
|
|
57
|
+
import { withScopedTransaction } from "@checkstack/backend-api";
|
|
55
58
|
import type {
|
|
56
59
|
HealthCheckRegistry,
|
|
57
60
|
SafeDatabase,
|
|
@@ -107,6 +110,10 @@ interface SystemCheckStatus {
|
|
|
107
110
|
status: HealthCheckStatus;
|
|
108
111
|
runsConsidered: number;
|
|
109
112
|
lastRunAt?: Date;
|
|
113
|
+
/** Environment slices this check currently fans out to (>= 1). */
|
|
114
|
+
sliceCount: number;
|
|
115
|
+
/** How many of {@link sliceCount} slices are currently non-healthy. */
|
|
116
|
+
failingSliceCount: number;
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
interface SystemHealthStatusResponse {
|
|
@@ -260,6 +267,53 @@ export class HealthCheckService {
|
|
|
260
267
|
return config ? this.mapConfig(config) : undefined;
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the per-environment slices a (system, config) assignment should
|
|
272
|
+
* enqueue for a ONE-OFF run (the `run_now` automation). Returns the list of
|
|
273
|
+
* environment ids to run, or `[null]` (a single env-less run) when the
|
|
274
|
+
* assignment has no effective environments. Mirrors the executor's fan-out
|
|
275
|
+
* resolution so a manual run covers exactly the same slices the recurring
|
|
276
|
+
* schedule does. Fail-open: a catalog resolution failure collapses to a
|
|
277
|
+
* single env-less run rather than enqueuing nothing.
|
|
278
|
+
*/
|
|
279
|
+
async resolveEnqueueEnvironmentIds(props: {
|
|
280
|
+
systemId: string;
|
|
281
|
+
configurationId: string;
|
|
282
|
+
catalogClient: CatalogClient;
|
|
283
|
+
logger: Logger;
|
|
284
|
+
}): Promise<(string | null)[]> {
|
|
285
|
+
const { systemId, configurationId, catalogClient, logger } = props;
|
|
286
|
+
const [assignment] = await this.db
|
|
287
|
+
.select({ environmentIds: systemHealthChecks.environmentIds })
|
|
288
|
+
.from(systemHealthChecks)
|
|
289
|
+
.where(
|
|
290
|
+
and(
|
|
291
|
+
eq(systemHealthChecks.systemId, systemId),
|
|
292
|
+
eq(systemHealthChecks.configurationId, configurationId),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
let membership: Awaited<
|
|
297
|
+
ReturnType<CatalogClient["resolveSystemEnvironments"]>
|
|
298
|
+
> = [];
|
|
299
|
+
try {
|
|
300
|
+
membership = await catalogClient.resolveSystemEnvironments({ systemId });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
logger.warn(
|
|
303
|
+
`run_now: could not resolve environments for system ${systemId}`,
|
|
304
|
+
error,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const effectiveEnvs = resolveEffectiveEnvironments({
|
|
309
|
+
environmentIds: assignment?.environmentIds,
|
|
310
|
+
membership,
|
|
311
|
+
});
|
|
312
|
+
return effectiveEnvs.length > 0
|
|
313
|
+
? effectiveEnvs.map((env) => env.id)
|
|
314
|
+
: [null];
|
|
315
|
+
}
|
|
316
|
+
|
|
263
317
|
/**
|
|
264
318
|
* Redact a configuration for a UI/AI read: every `x-secret` field is
|
|
265
319
|
* removed from the strategy config and each collector config. Stored
|
|
@@ -1030,159 +1084,179 @@ export class HealthCheckService {
|
|
|
1030
1084
|
systemId: string,
|
|
1031
1085
|
environmentId?: string | null,
|
|
1032
1086
|
): Promise<SystemHealthStatusResponse> {
|
|
1033
|
-
//
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
checkStatuses: [],
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// For each association, get recent runs and evaluate status
|
|
1070
|
-
const checkStatuses: SystemCheckStatus[] = [];
|
|
1071
|
-
const maxWindowSize = 100; // Max configurable window size
|
|
1072
|
-
|
|
1073
|
-
// Environment filter for the per-check run window. `undefined` (rollup)
|
|
1074
|
-
// adds no predicate; `null` filters to the env-less slice; a string
|
|
1075
|
-
// filters to that environment. The lookup index leads with
|
|
1076
|
-
// (system_id, environment_id, …) so the env-scoped query is index-efficient.
|
|
1077
|
-
//
|
|
1078
|
-
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
1079
|
-
// flat run list — see the per-association branch below for why.
|
|
1080
|
-
const envFilter =
|
|
1081
|
-
environmentId === undefined
|
|
1082
|
-
? undefined
|
|
1083
|
-
: environmentId === null
|
|
1084
|
-
? isNull(healthCheckRuns.environmentId)
|
|
1085
|
-
: eq(healthCheckRuns.environmentId, environmentId);
|
|
1086
|
-
|
|
1087
|
-
for (const assoc of associations) {
|
|
1088
|
-
// Extract and migrate thresholds from versioned config
|
|
1089
|
-
let thresholds: StateThresholds | undefined;
|
|
1090
|
-
if (assoc.stateThresholds) {
|
|
1091
|
-
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1092
|
-
}
|
|
1087
|
+
// §perf: batch the 1 (associations) + N (per-check run window) reads
|
|
1088
|
+
// into ONE scoped transaction so the whole read fan-out pays a single
|
|
1089
|
+
// BEGIN/SET LOCAL/COMMIT and holds one connection, instead of 1+N
|
|
1090
|
+
// standalone scoped queries each checking a connection out. Pure
|
|
1091
|
+
// evaluation runs inside too (it issues no DB). See withScopedTransaction.
|
|
1092
|
+
const checkStatuses = await withScopedTransaction(this.db, async (tx) => {
|
|
1093
|
+
// Get all associations for this system with their thresholds and config names
|
|
1094
|
+
const associations = await tx
|
|
1095
|
+
.select({
|
|
1096
|
+
configurationId: systemHealthChecks.configurationId,
|
|
1097
|
+
stateThresholds: systemHealthChecks.stateThresholds,
|
|
1098
|
+
configName: healthCheckConfigurations.name,
|
|
1099
|
+
enabled: systemHealthChecks.enabled,
|
|
1100
|
+
})
|
|
1101
|
+
.from(systemHealthChecks)
|
|
1102
|
+
.innerJoin(
|
|
1103
|
+
healthCheckConfigurations,
|
|
1104
|
+
eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
|
|
1105
|
+
)
|
|
1106
|
+
.where(
|
|
1107
|
+
and(
|
|
1108
|
+
eq(systemHealthChecks.systemId, systemId),
|
|
1109
|
+
eq(systemHealthChecks.enabled, true),
|
|
1110
|
+
// A paused configuration contributes no signal to the system's
|
|
1111
|
+
// health: its execution is skipped (see queue-executor pause gate)
|
|
1112
|
+
// and its historical runs MUST NOT keep the aggregate degraded
|
|
1113
|
+
// while it is paused. Excluding it here makes the rollup reflect
|
|
1114
|
+
// only the actively-running checks, so pausing the sole failing
|
|
1115
|
+
// check clears the system's status and downstream SLO downtime.
|
|
1116
|
+
eq(healthCheckConfigurations.paused, false),
|
|
1117
|
+
),
|
|
1118
|
+
);
|
|
1093
1119
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
.
|
|
1119
|
-
and(
|
|
1120
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
1121
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1122
|
-
),
|
|
1123
|
-
)
|
|
1124
|
-
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1125
|
-
.limit(maxWindowSize);
|
|
1126
|
-
|
|
1127
|
-
// Group by environmentId. `null` is its own group (the env-less slice
|
|
1128
|
-
// of an assignment that has opted out, plus any pre-3b env-less runs).
|
|
1129
|
-
const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
|
|
1130
|
-
for (const r of runs) {
|
|
1131
|
-
const key = r.environmentId ?? null;
|
|
1132
|
-
const bucket = byEnv.get(key);
|
|
1133
|
-
if (bucket) {
|
|
1134
|
-
bucket.push(r);
|
|
1135
|
-
} else {
|
|
1136
|
-
byEnv.set(key, [r]);
|
|
1137
|
-
}
|
|
1120
|
+
if (associations.length === 0) return [];
|
|
1121
|
+
|
|
1122
|
+
// For each association, get recent runs and evaluate status
|
|
1123
|
+
const out: SystemCheckStatus[] = [];
|
|
1124
|
+
const maxWindowSize = 100; // Max configurable window size
|
|
1125
|
+
|
|
1126
|
+
// Environment filter for the per-check run window. `undefined` (rollup)
|
|
1127
|
+
// adds no predicate; `null` filters to the env-less slice; a string
|
|
1128
|
+
// filters to that environment. The lookup index leads with
|
|
1129
|
+
// (system_id, environment_id, …) so the env-scoped query is index-efficient.
|
|
1130
|
+
//
|
|
1131
|
+
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
1132
|
+
// flat run list — see the per-association branch below for why.
|
|
1133
|
+
const envFilter =
|
|
1134
|
+
environmentId === undefined
|
|
1135
|
+
? undefined
|
|
1136
|
+
: environmentId === null
|
|
1137
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1138
|
+
: eq(healthCheckRuns.environmentId, environmentId);
|
|
1139
|
+
|
|
1140
|
+
for (const assoc of associations) {
|
|
1141
|
+
// Extract and migrate thresholds from versioned config
|
|
1142
|
+
let thresholds: StateThresholds | undefined;
|
|
1143
|
+
if (assoc.stateThresholds) {
|
|
1144
|
+
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1138
1145
|
}
|
|
1139
1146
|
|
|
1140
|
-
status
|
|
1141
|
-
runsConsidered
|
|
1142
|
-
lastRunAt
|
|
1143
|
-
for
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1147
|
+
let status: HealthCheckStatus;
|
|
1148
|
+
let runsConsidered: number;
|
|
1149
|
+
let lastRunAt: Date | undefined;
|
|
1150
|
+
// Fan-out accounting for the honest "X of Y checks failing" denominator:
|
|
1151
|
+
// how many environment slices this check currently spans, and how many
|
|
1152
|
+
// are non-healthy. A non-fanned (single-env / env-less) check is one
|
|
1153
|
+
// slice. Populated in both branches so the DTO field is always present.
|
|
1154
|
+
let sliceCount = 1;
|
|
1155
|
+
let failingSliceCount = 0;
|
|
1156
|
+
|
|
1157
|
+
if (environmentId === undefined) {
|
|
1158
|
+
// System rollup: evaluate the threshold window PER ENVIRONMENT within
|
|
1159
|
+
// the association, then take worst-wins ACROSS envs. Flattening every
|
|
1160
|
+
// env's runs into one list feeds interleaved statuses to
|
|
1161
|
+
// `evaluateConsecutive` (the default mode): the streak breaks on the
|
|
1162
|
+
// first interleaving env, so the evaluator collapses to whatever
|
|
1163
|
+
// single env's status the most recent run landed on. That masks any
|
|
1164
|
+
// permanently-failing sibling env in the default mode ("the healthy
|
|
1165
|
+
// env wins"), and flaps healthy↔degraded whenever env insertion
|
|
1166
|
+
// order drifts across ticks (see the regression test
|
|
1167
|
+
// `rollup — worst-wins across environments within an association`).
|
|
1168
|
+
// Per-env evaluation makes the rollup worst-wins stable regardless of
|
|
1169
|
+
// insertion order or multi-pod racing.
|
|
1170
|
+
const runs = await tx
|
|
1171
|
+
.select({
|
|
1172
|
+
status: healthCheckRuns.status,
|
|
1173
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1174
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1175
|
+
})
|
|
1176
|
+
.from(healthCheckRuns)
|
|
1177
|
+
.where(
|
|
1178
|
+
and(
|
|
1179
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1180
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1181
|
+
),
|
|
1182
|
+
)
|
|
1183
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1184
|
+
.limit(maxWindowSize);
|
|
1185
|
+
|
|
1186
|
+
// Group by environmentId. `null` is its own group (the env-less slice
|
|
1187
|
+
// of an assignment that has opted out, plus any pre-3b env-less runs).
|
|
1188
|
+
const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
|
|
1189
|
+
for (const r of runs) {
|
|
1190
|
+
const key = r.environmentId ?? null;
|
|
1191
|
+
const bucket = byEnv.get(key);
|
|
1192
|
+
if (bucket) {
|
|
1193
|
+
bucket.push(r);
|
|
1194
|
+
} else {
|
|
1195
|
+
byEnv.set(key, [r]);
|
|
1196
|
+
}
|
|
1148
1197
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1198
|
+
|
|
1199
|
+
status = "healthy";
|
|
1200
|
+
runsConsidered = runs.length;
|
|
1201
|
+
lastRunAt = runs[0]?.timestamp;
|
|
1202
|
+
// Each env group is a slice. A check that has runs against N envs
|
|
1203
|
+
// currently fans out to N; before it has ever run it is still one
|
|
1204
|
+
// logical slice (byEnv empty => keep the default 1).
|
|
1205
|
+
sliceCount = Math.max(byEnv.size, 1);
|
|
1206
|
+
failingSliceCount = 0;
|
|
1207
|
+
for (const envRuns of byEnv.values()) {
|
|
1208
|
+
const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
|
|
1209
|
+
// Count EVERY failing slice (don't break early): the failing count
|
|
1210
|
+
// feeds the dashboard numerator, so all non-healthy envs must tally.
|
|
1211
|
+
if (envStatus !== "healthy") {
|
|
1212
|
+
failingSliceCount++;
|
|
1213
|
+
}
|
|
1214
|
+
if (envStatus === "unhealthy") {
|
|
1215
|
+
status = "unhealthy";
|
|
1216
|
+
} else if (envStatus === "degraded" && status === "healthy") {
|
|
1217
|
+
status = "degraded";
|
|
1218
|
+
}
|
|
1151
1219
|
}
|
|
1220
|
+
} else {
|
|
1221
|
+
// Per-env (string) or env-less (null) slice: that slice's flat run
|
|
1222
|
+
// window is monotonic per-env, so the threshold evaluator sees no
|
|
1223
|
+
// interleaving — the consecutive streak is well-defined.
|
|
1224
|
+
const runs = await tx
|
|
1225
|
+
.select({
|
|
1226
|
+
status: healthCheckRuns.status,
|
|
1227
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1228
|
+
})
|
|
1229
|
+
.from(healthCheckRuns)
|
|
1230
|
+
.where(
|
|
1231
|
+
and(
|
|
1232
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1233
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1234
|
+
...(envFilter ? [envFilter] : []),
|
|
1235
|
+
),
|
|
1236
|
+
)
|
|
1237
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1238
|
+
.limit(maxWindowSize);
|
|
1239
|
+
|
|
1240
|
+
status = evaluateHealthStatus({ runs, thresholds });
|
|
1241
|
+
runsConsidered = runs.length;
|
|
1242
|
+
lastRunAt = runs[0]?.timestamp;
|
|
1243
|
+
// Single-slice evaluation: this env either counts as failing or not.
|
|
1244
|
+
sliceCount = 1;
|
|
1245
|
+
failingSliceCount = status === "healthy" ? 0 : 1;
|
|
1152
1246
|
}
|
|
1153
|
-
} else {
|
|
1154
|
-
// Per-env (string) or env-less (null) slice: that slice's flat run
|
|
1155
|
-
// window is monotonic per-env, so the threshold evaluator sees no
|
|
1156
|
-
// interleaving — the consecutive streak is well-defined.
|
|
1157
|
-
const runs = await this.db
|
|
1158
|
-
.select({
|
|
1159
|
-
status: healthCheckRuns.status,
|
|
1160
|
-
timestamp: healthCheckRuns.timestamp,
|
|
1161
|
-
})
|
|
1162
|
-
.from(healthCheckRuns)
|
|
1163
|
-
.where(
|
|
1164
|
-
and(
|
|
1165
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
1166
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1167
|
-
...(envFilter ? [envFilter] : []),
|
|
1168
|
-
),
|
|
1169
|
-
)
|
|
1170
|
-
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1171
|
-
.limit(maxWindowSize);
|
|
1172
1247
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1248
|
+
out.push({
|
|
1249
|
+
configurationId: assoc.configurationId,
|
|
1250
|
+
configurationName: assoc.configName,
|
|
1251
|
+
status,
|
|
1252
|
+
runsConsidered,
|
|
1253
|
+
lastRunAt,
|
|
1254
|
+
sliceCount,
|
|
1255
|
+
failingSliceCount,
|
|
1256
|
+
});
|
|
1176
1257
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
configurationId: assoc.configurationId,
|
|
1180
|
-
configurationName: assoc.configName,
|
|
1181
|
-
status,
|
|
1182
|
-
runsConsidered,
|
|
1183
|
-
lastRunAt,
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1258
|
+
return out;
|
|
1259
|
+
});
|
|
1186
1260
|
|
|
1187
1261
|
// Aggregate status: worst status wins (unhealthy > degraded > healthy)
|
|
1188
1262
|
let aggregateStatus: HealthCheckStatus = "healthy";
|
|
@@ -1488,6 +1562,39 @@ export class HealthCheckService {
|
|
|
1488
1562
|
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1489
1563
|
}
|
|
1490
1564
|
|
|
1565
|
+
// Most recent HEALTHY run per environment, computed OUTSIDE the bounded
|
|
1566
|
+
// sparkline window so "last successful run" stays correct even when a
|
|
1567
|
+
// check has been failing for far longer than the last 25 runs. One
|
|
1568
|
+
// grouped aggregate query per check (env-less = the `null` group). The
|
|
1569
|
+
// (system_id, configuration_id, environment_id, timestamp) index makes
|
|
1570
|
+
// this a cheap max-per-group scan.
|
|
1571
|
+
const lastHealthyRows = await this.db
|
|
1572
|
+
.select({
|
|
1573
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1574
|
+
lastSuccessAt: max(healthCheckRuns.timestamp),
|
|
1575
|
+
})
|
|
1576
|
+
.from(healthCheckRuns)
|
|
1577
|
+
.where(
|
|
1578
|
+
and(
|
|
1579
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1580
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1581
|
+
eq(healthCheckRuns.status, "healthy"),
|
|
1582
|
+
),
|
|
1583
|
+
)
|
|
1584
|
+
.groupBy(healthCheckRuns.environmentId);
|
|
1585
|
+
const lastHealthyByEnv = new Map<string | null, Date>();
|
|
1586
|
+
let checkLastSuccessfulRunAt: Date | undefined;
|
|
1587
|
+
for (const row of lastHealthyRows) {
|
|
1588
|
+
if (!row.lastSuccessAt) continue;
|
|
1589
|
+
lastHealthyByEnv.set(row.environmentId ?? null, row.lastSuccessAt);
|
|
1590
|
+
if (
|
|
1591
|
+
!checkLastSuccessfulRunAt ||
|
|
1592
|
+
row.lastSuccessAt > checkLastSuccessfulRunAt
|
|
1593
|
+
) {
|
|
1594
|
+
checkLastSuccessfulRunAt = row.lastSuccessAt;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1491
1598
|
// Group the fetched runs by environmentId (null = env-less slice). We
|
|
1492
1599
|
// query each env's slice separately below to evaluate it on its own
|
|
1493
1600
|
// monotonic run window and worst-wins across envs — this is the same
|
|
@@ -1497,6 +1604,7 @@ export class HealthCheckService {
|
|
|
1497
1604
|
const perEnvironment: {
|
|
1498
1605
|
environmentId: string | null;
|
|
1499
1606
|
status: HealthCheckStatus;
|
|
1607
|
+
lastSuccessfulRunAt?: Date;
|
|
1500
1608
|
recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
|
|
1501
1609
|
}[] = [];
|
|
1502
1610
|
|
|
@@ -1552,6 +1660,7 @@ export class HealthCheckService {
|
|
|
1552
1660
|
perEnvironment.push({
|
|
1553
1661
|
environmentId: envId,
|
|
1554
1662
|
status: envStatus,
|
|
1663
|
+
lastSuccessfulRunAt: lastHealthyByEnv.get(envId),
|
|
1555
1664
|
recentRuns: envRuns.toReversed().map((r) => ({
|
|
1556
1665
|
id: r.id,
|
|
1557
1666
|
status: r.status,
|
|
@@ -1581,6 +1690,7 @@ export class HealthCheckService {
|
|
|
1581
1690
|
paused: assoc.paused,
|
|
1582
1691
|
status,
|
|
1583
1692
|
stateThresholds: thresholds,
|
|
1693
|
+
lastSuccessfulRunAt: checkLastSuccessfulRunAt,
|
|
1584
1694
|
recentRuns: chronologicalRuns.map((r) => ({
|
|
1585
1695
|
id: r.id,
|
|
1586
1696
|
status: r.status,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
evaluateSlowCheckAdmission,
|
|
4
|
+
slowCheckLaneKey,
|
|
5
|
+
} from "./slow-check-admission";
|
|
6
|
+
import { SuspectLane } from "./suspect-lane";
|
|
7
|
+
import type { SlowCheckRuntime } from "./slow-check-config";
|
|
8
|
+
import type { RecentRun } from "./slow-check-classifier";
|
|
9
|
+
|
|
10
|
+
function runtime(overrides: Partial<SlowCheckRuntime> = {}): SlowCheckRuntime {
|
|
11
|
+
return {
|
|
12
|
+
lane: new SuspectLane(1),
|
|
13
|
+
recentRunsLimit: 20,
|
|
14
|
+
classifierParams: {
|
|
15
|
+
consecutiveFailures: 3,
|
|
16
|
+
slowFraction: 0.8,
|
|
17
|
+
recoveryProbeEvery: 5,
|
|
18
|
+
},
|
|
19
|
+
safetyFactor: 1.5,
|
|
20
|
+
absoluteFloorMs: 1000,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TIMEOUT = 10_000;
|
|
26
|
+
|
|
27
|
+
/** A slow transport failure (held its slot ~the full timeout). */
|
|
28
|
+
function slowFail(): RecentRun {
|
|
29
|
+
return {
|
|
30
|
+
environmentId: null,
|
|
31
|
+
status: "unhealthy",
|
|
32
|
+
latencyMs: TIMEOUT, // >= slowFraction * timeout
|
|
33
|
+
timestamp: new Date(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A healthy run with a given latency. */
|
|
38
|
+
function healthy(latencyMs: number): RecentRun {
|
|
39
|
+
return { environmentId: null, status: "healthy", latencyMs, timestamp: new Date() };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("slowCheckLaneKey", () => {
|
|
43
|
+
it("keys on (config, system, env) with ENV_LESS_KEY for null", () => {
|
|
44
|
+
expect(
|
|
45
|
+
slowCheckLaneKey({ configId: "c", systemId: "s", environmentId: null }),
|
|
46
|
+
).toBe("c:s:_");
|
|
47
|
+
expect(
|
|
48
|
+
slowCheckLaneKey({ configId: "c", systemId: "s", environmentId: "prod" }),
|
|
49
|
+
).toBe("c:s:prod");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("evaluateSlowCheckAdmission", () => {
|
|
54
|
+
it("runs a NON-suspect slice at the full timeout with no lane involvement", () => {
|
|
55
|
+
const rt = runtime();
|
|
56
|
+
const decision = evaluateSlowCheckAdmission({
|
|
57
|
+
runtime: rt,
|
|
58
|
+
recentRuns: [healthy(50), healthy(60)],
|
|
59
|
+
configId: "c",
|
|
60
|
+
systemId: "s",
|
|
61
|
+
environmentId: null,
|
|
62
|
+
executionTimeoutMs: TIMEOUT,
|
|
63
|
+
});
|
|
64
|
+
expect(decision).toEqual({ kind: "run", effectiveTimeoutMs: TIMEOUT });
|
|
65
|
+
// No slot was taken.
|
|
66
|
+
expect(rt.lane.active).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("admits a suspect slice and shrinks the timeout toward its healthy baseline", () => {
|
|
70
|
+
const rt = runtime();
|
|
71
|
+
const decision = evaluateSlowCheckAdmission({
|
|
72
|
+
runtime: rt,
|
|
73
|
+
// 3 leading slow failures ⇒ suspect; one healthy sample gives a baseline.
|
|
74
|
+
recentRuns: [slowFail(), slowFail(), slowFail(), healthy(200)],
|
|
75
|
+
configId: "c",
|
|
76
|
+
systemId: "s",
|
|
77
|
+
environmentId: null,
|
|
78
|
+
executionTimeoutMs: TIMEOUT,
|
|
79
|
+
});
|
|
80
|
+
expect(decision.kind).toBe("run");
|
|
81
|
+
if (decision.kind !== "run") return;
|
|
82
|
+
// adaptiveTimeout: max(floor 1000, 200 * 1.5) = 1000, and < configured.
|
|
83
|
+
expect(decision.effectiveTimeoutMs).toBe(1000);
|
|
84
|
+
expect(decision.laneKey).toBe("c:s:_");
|
|
85
|
+
expect(rt.lane.active).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("keeps the full timeout on a recovery-probe suspect run", () => {
|
|
89
|
+
const rt = runtime();
|
|
90
|
+
// 5 leading slow failures ⇒ suspect AND a recovery probe (every 5th).
|
|
91
|
+
const decision = evaluateSlowCheckAdmission({
|
|
92
|
+
runtime: rt,
|
|
93
|
+
recentRuns: [
|
|
94
|
+
slowFail(),
|
|
95
|
+
slowFail(),
|
|
96
|
+
slowFail(),
|
|
97
|
+
slowFail(),
|
|
98
|
+
slowFail(),
|
|
99
|
+
healthy(200),
|
|
100
|
+
],
|
|
101
|
+
configId: "c",
|
|
102
|
+
systemId: "s",
|
|
103
|
+
environmentId: null,
|
|
104
|
+
executionTimeoutMs: TIMEOUT,
|
|
105
|
+
});
|
|
106
|
+
expect(decision.kind).toBe("run");
|
|
107
|
+
if (decision.kind !== "run") return;
|
|
108
|
+
// Recovery probe re-measures at the FULL configured timeout.
|
|
109
|
+
expect(decision.effectiveTimeoutMs).toBe(TIMEOUT);
|
|
110
|
+
expect(decision.laneKey).toBe("c:s:_");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("DEFERS a suspect slice when the lane is full (lane_full)", () => {
|
|
114
|
+
const rt = runtime({ lane: new SuspectLane(1) });
|
|
115
|
+
// Fill the single slot with a different slice.
|
|
116
|
+
rt.lane.tryAdmit("other:slice:_");
|
|
117
|
+
|
|
118
|
+
const decision = evaluateSlowCheckAdmission({
|
|
119
|
+
runtime: rt,
|
|
120
|
+
recentRuns: [slowFail(), slowFail(), slowFail()],
|
|
121
|
+
configId: "c",
|
|
122
|
+
systemId: "s",
|
|
123
|
+
environmentId: null,
|
|
124
|
+
executionTimeoutMs: TIMEOUT,
|
|
125
|
+
});
|
|
126
|
+
expect(decision).toEqual({ kind: "defer", reason: "lane_full" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("DEFERS a suspect slice already in flight (in_flight, single-flight)", () => {
|
|
130
|
+
const rt = runtime({ lane: new SuspectLane(4) });
|
|
131
|
+
// Same slice already holds a slot (a prior tick still running).
|
|
132
|
+
rt.lane.tryAdmit("c:s:_");
|
|
133
|
+
|
|
134
|
+
const decision = evaluateSlowCheckAdmission({
|
|
135
|
+
runtime: rt,
|
|
136
|
+
recentRuns: [slowFail(), slowFail(), slowFail()],
|
|
137
|
+
configId: "c",
|
|
138
|
+
systemId: "s",
|
|
139
|
+
environmentId: null,
|
|
140
|
+
executionTimeoutMs: TIMEOUT,
|
|
141
|
+
});
|
|
142
|
+
expect(decision).toEqual({ kind: "defer", reason: "in_flight" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("classifies per-env: one env suspect, a sibling env healthy in the same runtime", () => {
|
|
146
|
+
const rt = runtime({ lane: new SuspectLane(2) });
|
|
147
|
+
const prodFail = (): RecentRun => ({
|
|
148
|
+
environmentId: "prod",
|
|
149
|
+
status: "unhealthy",
|
|
150
|
+
latencyMs: TIMEOUT,
|
|
151
|
+
timestamp: new Date(),
|
|
152
|
+
});
|
|
153
|
+
const runs: RecentRun[] = [
|
|
154
|
+
prodFail(),
|
|
155
|
+
prodFail(),
|
|
156
|
+
prodFail(),
|
|
157
|
+
{ environmentId: "staging", status: "healthy", latencyMs: 40, timestamp: new Date() },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const prod = evaluateSlowCheckAdmission({
|
|
161
|
+
runtime: rt,
|
|
162
|
+
recentRuns: runs,
|
|
163
|
+
configId: "c",
|
|
164
|
+
systemId: "s",
|
|
165
|
+
environmentId: "prod",
|
|
166
|
+
executionTimeoutMs: TIMEOUT,
|
|
167
|
+
});
|
|
168
|
+
const staging = evaluateSlowCheckAdmission({
|
|
169
|
+
runtime: rt,
|
|
170
|
+
recentRuns: runs,
|
|
171
|
+
configId: "c",
|
|
172
|
+
systemId: "s",
|
|
173
|
+
environmentId: "staging",
|
|
174
|
+
executionTimeoutMs: TIMEOUT,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// prod is suspect (admitted, shrunk); staging runs at full timeout, no slot.
|
|
178
|
+
expect(prod.kind).toBe("run");
|
|
179
|
+
if (prod.kind === "run") expect(prod.laneKey).toBe("c:s:prod");
|
|
180
|
+
expect(staging).toEqual({ kind: "run", effectiveTimeoutMs: TIMEOUT });
|
|
181
|
+
// Only the suspect env took a slot.
|
|
182
|
+
expect(rt.lane.active).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
});
|