@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/package.json +30 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  9. package/src/healthcheck-gitops-kinds.ts +17 -13
  10. package/src/index.ts +58 -6
  11. package/src/migration-chain-contract.test.ts +7 -1
  12. package/src/notification-policy.test.ts +19 -0
  13. package/src/notification-policy.ts +26 -0
  14. package/src/queue-executor.test.ts +391 -338
  15. package/src/queue-executor.ts +395 -294
  16. package/src/realtime-aggregation.ts +9 -2
  17. package/src/rollup-consumer.test.ts +191 -0
  18. package/src/rollup-consumer.ts +160 -0
  19. package/src/router.ts +11 -14
  20. package/src/schedule-jitter.test.ts +69 -0
  21. package/src/schedule-jitter.ts +50 -0
  22. package/src/schedule-reconciler.it.test.ts +453 -0
  23. package/src/schedule-reconciler.test.ts +418 -0
  24. package/src/schedule-reconciler.ts +304 -0
  25. package/src/service-batching.test.ts +98 -0
  26. package/src/service-ordering.test.ts +4 -0
  27. package/src/service-paused-filter.test.ts +14 -7
  28. package/src/service-rollup-worst-wins.test.ts +37 -4
  29. package/src/service.ts +255 -145
  30. package/src/slow-check-admission.test.ts +184 -0
  31. package/src/slow-check-admission.ts +101 -0
  32. package/src/slow-check-classifier.test.ts +155 -0
  33. package/src/slow-check-classifier.ts +137 -0
  34. package/src/slow-check-config.ts +102 -0
  35. package/src/suspect-lane.test.ts +50 -0
  36. 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
- // Get all associations for this system with their thresholds and config names
1034
- const associations = await this.db
1035
- .select({
1036
- configurationId: systemHealthChecks.configurationId,
1037
- stateThresholds: systemHealthChecks.stateThresholds,
1038
- configName: healthCheckConfigurations.name,
1039
- enabled: systemHealthChecks.enabled,
1040
- })
1041
- .from(systemHealthChecks)
1042
- .innerJoin(
1043
- healthCheckConfigurations,
1044
- eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1045
- )
1046
- .where(
1047
- and(
1048
- eq(systemHealthChecks.systemId, systemId),
1049
- eq(systemHealthChecks.enabled, true),
1050
- // A paused configuration contributes no signal to the system's
1051
- // health: its execution is skipped (see queue-executor pause gate)
1052
- // and its historical runs MUST NOT keep the aggregate degraded
1053
- // while it is paused. Excluding it here makes the rollup reflect
1054
- // only the actively-running checks, so pausing the sole failing
1055
- // check clears the system's status and downstream SLO downtime.
1056
- eq(healthCheckConfigurations.paused, false),
1057
- ),
1058
- );
1059
-
1060
- if (associations.length === 0) {
1061
- // No health checks configured - default healthy
1062
- return {
1063
- status: "healthy",
1064
- evaluatedAt: new Date(),
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
- let status: HealthCheckStatus;
1095
- let runsConsidered: number;
1096
- let lastRunAt: Date | undefined;
1097
-
1098
- if (environmentId === undefined) {
1099
- // System rollup: evaluate the threshold window PER ENVIRONMENT within
1100
- // the association, then take worst-wins ACROSS envs. Flattening every
1101
- // env's runs into one list feeds interleaved statuses to
1102
- // `evaluateConsecutive` (the default mode): the streak breaks on the
1103
- // first interleaving env, so the evaluator collapses to whatever
1104
- // single env's status the most recent run landed on. That masks any
1105
- // permanently-failing sibling env in the default mode ("the healthy
1106
- // env wins"), and flaps healthy↔degraded whenever env insertion
1107
- // order drifts across ticks (see the regression test
1108
- // `rollup — worst-wins across environments within an association`).
1109
- // Per-env evaluation makes the rollup worst-wins stable regardless of
1110
- // insertion order or multi-pod racing.
1111
- const runs = await this.db
1112
- .select({
1113
- status: healthCheckRuns.status,
1114
- timestamp: healthCheckRuns.timestamp,
1115
- environmentId: healthCheckRuns.environmentId,
1116
- })
1117
- .from(healthCheckRuns)
1118
- .where(
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 = "healthy";
1141
- runsConsidered = runs.length;
1142
- lastRunAt = runs[0]?.timestamp;
1143
- for (const envRuns of byEnv.values()) {
1144
- const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
1145
- if (envStatus === "unhealthy") {
1146
- status = "unhealthy";
1147
- break; // worst: stop
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
- if (envStatus === "degraded" && status === "healthy") {
1150
- status = "degraded";
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
- status = evaluateHealthStatus({ runs, thresholds });
1174
- runsConsidered = runs.length;
1175
- lastRunAt = runs[0]?.timestamp;
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
- checkStatuses.push({
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
+ });