@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
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ScopedQueryRunner,
|
|
3
|
+
CollectorRegistry,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
2
5
|
import {
|
|
3
6
|
ASSERTIONS_AGG_KEY,
|
|
4
7
|
AssertionOutcomeSchema,
|
|
@@ -11,7 +14,11 @@ import * as schema from "./schema";
|
|
|
11
14
|
import { healthCheckAggregates } from "./schema";
|
|
12
15
|
import { eq, and, sql } from "drizzle-orm";
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
// Accepts either the scoped database OR a transaction handle from it, so the
|
|
18
|
+
// caller can compose the aggregate SELECT + UPSERT inside a single batching
|
|
19
|
+
// transaction (one `SET LOCAL search_path` for the whole write group) — see
|
|
20
|
+
// `withScopedTransaction`.
|
|
21
|
+
type Db = ScopedQueryRunner<typeof schema>;
|
|
15
22
|
|
|
16
23
|
/**
|
|
17
24
|
* Get the hour bucket start time for a given timestamp.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
setupRollupConsumer,
|
|
4
|
+
encodeRollupDebounceJobId,
|
|
5
|
+
HEALTH_ROLLUP_QUEUE,
|
|
6
|
+
type HealthRollupJobPayload,
|
|
7
|
+
} from "./rollup-consumer";
|
|
8
|
+
import { encodeHealthEntityId } from "./health-entity-id";
|
|
9
|
+
import type { EntityChanged, OnEntityChanged } from "@checkstack/automation-backend";
|
|
10
|
+
|
|
11
|
+
describe("encodeRollupDebounceJobId", () => {
|
|
12
|
+
it("coalesces changes in the same window to one jobId", () => {
|
|
13
|
+
const a = encodeRollupDebounceJobId({ systemId: "s1", now: 10_000, windowMs: 2000 });
|
|
14
|
+
const b = encodeRollupDebounceJobId({ systemId: "s1", now: 11_500, windowMs: 2000 });
|
|
15
|
+
expect(a).toBe(b);
|
|
16
|
+
expect(a).toBe("healthrollup:s1:5");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("uses a fresh jobId for the next window", () => {
|
|
20
|
+
const a = encodeRollupDebounceJobId({ systemId: "s1", now: 10_000, windowMs: 2000 });
|
|
21
|
+
const b = encodeRollupDebounceJobId({ systemId: "s1", now: 12_500, windowMs: 2000 });
|
|
22
|
+
expect(a).not.toBe(b);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keys distinct systems separately", () => {
|
|
26
|
+
const a = encodeRollupDebounceJobId({ systemId: "s1", now: 10_000, windowMs: 2000 });
|
|
27
|
+
const b = encodeRollupDebounceJobId({ systemId: "s2", now: 10_000, windowMs: 2000 });
|
|
28
|
+
expect(a).not.toBe(b);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface Harness {
|
|
33
|
+
enqueue: ReturnType<typeof mock>;
|
|
34
|
+
consumeHandler: (job: { data: HealthRollupJobPayload }) => Promise<void>;
|
|
35
|
+
changeHandler: (change: EntityChanged) => Promise<void>;
|
|
36
|
+
getSystemHealthStatus: ReturnType<typeof mock>;
|
|
37
|
+
broadcast: ReturnType<typeof mock>;
|
|
38
|
+
invalidateSystem: ReturnType<typeof mock>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function setup(opts: {
|
|
42
|
+
statuses?: string[]; // successive getSystemHealthStatus results
|
|
43
|
+
now?: number;
|
|
44
|
+
} = {}): Promise<Harness> {
|
|
45
|
+
const statuses = opts.statuses ?? ["healthy", "healthy"];
|
|
46
|
+
let statusCall = 0;
|
|
47
|
+
const getSystemHealthStatus = mock(async () => ({
|
|
48
|
+
status: statuses[Math.min(statusCall++, statuses.length - 1)],
|
|
49
|
+
checkStatuses: [],
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const enqueue = mock(async () => "job-id");
|
|
53
|
+
let consumeHandler!: (job: { data: HealthRollupJobPayload }) => Promise<void>;
|
|
54
|
+
const queue = {
|
|
55
|
+
enqueue,
|
|
56
|
+
consume: mock(
|
|
57
|
+
async (
|
|
58
|
+
handler: (job: { data: HealthRollupJobPayload }) => Promise<void>,
|
|
59
|
+
) => {
|
|
60
|
+
consumeHandler = handler;
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
const queueManager = {
|
|
65
|
+
getQueue: mock((name: string) => {
|
|
66
|
+
expect(name).toBe(HEALTH_ROLLUP_QUEUE);
|
|
67
|
+
return queue;
|
|
68
|
+
}),
|
|
69
|
+
} as unknown as Parameters<typeof setupRollupConsumer>[0]["queueManager"];
|
|
70
|
+
|
|
71
|
+
let changeHandler!: (change: EntityChanged) => Promise<void>;
|
|
72
|
+
const onEntityChanged = mock((input: Parameters<OnEntityChanged>[0]) => {
|
|
73
|
+
changeHandler = input.handler as (c: EntityChanged) => Promise<void>;
|
|
74
|
+
return async () => {};
|
|
75
|
+
}) as unknown as OnEntityChanged;
|
|
76
|
+
|
|
77
|
+
const broadcast = mock(async () => {});
|
|
78
|
+
const invalidateSystem = mock(async () => {});
|
|
79
|
+
|
|
80
|
+
await setupRollupConsumer({
|
|
81
|
+
queueManager,
|
|
82
|
+
onEntityChanged,
|
|
83
|
+
service: { getSystemHealthStatus } as never,
|
|
84
|
+
advisoryLock: {
|
|
85
|
+
withXactLock: async ({ fn }: { fn: () => Promise<unknown> }) => fn(),
|
|
86
|
+
} as never,
|
|
87
|
+
signalService: { broadcast } as never,
|
|
88
|
+
cache: { invalidateSystem } as never,
|
|
89
|
+
// No entity handle: writeHealthEntity runs `apply` directly.
|
|
90
|
+
getHealthEntity: () => undefined,
|
|
91
|
+
logger: {
|
|
92
|
+
debug: () => {},
|
|
93
|
+
info: () => {},
|
|
94
|
+
warn: () => {},
|
|
95
|
+
error: () => {},
|
|
96
|
+
} as never,
|
|
97
|
+
now: () => opts.now ?? 10_000,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
enqueue,
|
|
102
|
+
consumeHandler,
|
|
103
|
+
changeHandler,
|
|
104
|
+
getSystemHealthStatus,
|
|
105
|
+
broadcast,
|
|
106
|
+
invalidateSystem,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("setupRollupConsumer subscription", () => {
|
|
111
|
+
it("enqueues a debounced rollup job for a per-env health change", async () => {
|
|
112
|
+
const h = await setup({ now: 10_000 });
|
|
113
|
+
await h.changeHandler({
|
|
114
|
+
kind: "health",
|
|
115
|
+
id: encodeHealthEntityId({ systemId: "s1", environmentId: "prod" }),
|
|
116
|
+
prev: null,
|
|
117
|
+
next: { status: "unhealthy" },
|
|
118
|
+
} as unknown as EntityChanged);
|
|
119
|
+
|
|
120
|
+
expect(h.enqueue).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(h.enqueue.mock.calls[0]![0]).toEqual({ systemId: "s1" });
|
|
122
|
+
expect(h.enqueue.mock.calls[0]![1]).toMatchObject({
|
|
123
|
+
jobId: "healthrollup:s1:5",
|
|
124
|
+
startDelay: 2,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("IGNORES a bare-rollup change (feedback-loop guard)", async () => {
|
|
129
|
+
const h = await setup();
|
|
130
|
+
await h.changeHandler({
|
|
131
|
+
kind: "health",
|
|
132
|
+
id: encodeHealthEntityId({ systemId: "s1" }), // bare rollup id
|
|
133
|
+
prev: null,
|
|
134
|
+
next: { status: "unhealthy" },
|
|
135
|
+
} as unknown as EntityChanged);
|
|
136
|
+
|
|
137
|
+
expect(h.enqueue).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("coalesces two per-env changes in the same window to one jobId", async () => {
|
|
141
|
+
const h = await setup({ now: 10_000 });
|
|
142
|
+
const mk = (env: string) =>
|
|
143
|
+
({
|
|
144
|
+
kind: "health",
|
|
145
|
+
id: encodeHealthEntityId({ systemId: "s1", environmentId: env }),
|
|
146
|
+
prev: null,
|
|
147
|
+
next: { status: "unhealthy" },
|
|
148
|
+
}) as unknown as EntityChanged;
|
|
149
|
+
|
|
150
|
+
await h.changeHandler(mk("prod"));
|
|
151
|
+
await h.changeHandler(mk("staging"));
|
|
152
|
+
|
|
153
|
+
// Both enqueue with the SAME jobId; the queue backend dedupes them.
|
|
154
|
+
expect(h.enqueue).toHaveBeenCalledTimes(2);
|
|
155
|
+
expect(h.enqueue.mock.calls[0]![1]).toMatchObject({
|
|
156
|
+
jobId: "healthrollup:s1:5",
|
|
157
|
+
});
|
|
158
|
+
expect(h.enqueue.mock.calls[1]![1]).toMatchObject({
|
|
159
|
+
jobId: "healthrollup:s1:5",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("setupRollupConsumer rollup recompute", () => {
|
|
165
|
+
it("broadcasts SYSTEM_STATUS_CHANGED + invalidates cache on a rollup status change", async () => {
|
|
166
|
+
const h = await setup({ statuses: ["healthy", "unhealthy"] });
|
|
167
|
+
await h.consumeHandler({ data: { systemId: "s1" } });
|
|
168
|
+
|
|
169
|
+
expect(h.getSystemHealthStatus).toHaveBeenCalled();
|
|
170
|
+
expect(h.invalidateSystem).toHaveBeenCalledWith("s1");
|
|
171
|
+
expect(h.broadcast).toHaveBeenCalledTimes(1);
|
|
172
|
+
const payload = h.broadcast.mock.calls[0]![1] as {
|
|
173
|
+
systemId: string;
|
|
174
|
+
previousStatus: string;
|
|
175
|
+
newStatus: string;
|
|
176
|
+
};
|
|
177
|
+
expect(payload).toEqual({
|
|
178
|
+
systemId: "s1",
|
|
179
|
+
previousStatus: "healthy",
|
|
180
|
+
newStatus: "unhealthy",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does NOT broadcast when the rollup status is unchanged", async () => {
|
|
185
|
+
const h = await setup({ statuses: ["degraded", "degraded"] });
|
|
186
|
+
await h.consumeHandler({ data: { systemId: "s1" } });
|
|
187
|
+
|
|
188
|
+
expect(h.broadcast).not.toHaveBeenCalled();
|
|
189
|
+
expect(h.invalidateSystem).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-driven system-rollup consumer (Phase 2 of the per-environment-jobs
|
|
3
|
+
* migration).
|
|
4
|
+
*
|
|
5
|
+
* Under per-environment jobs, each recurring job writes ONLY its own
|
|
6
|
+
* `"<systemId>::<environmentId>"` health entity; the bare `"<systemId>"` ROLLUP
|
|
7
|
+
* entity (the worst-status view every existing system-level consumer, badge,
|
|
8
|
+
* SLO rule and dashboard references) is no longer recomputed inline. This
|
|
9
|
+
* consumer closes that gap WITHOUT re-coupling the executor to the rollup:
|
|
10
|
+
*
|
|
11
|
+
* 1. It subscribes to `health` `ENTITY_CHANGED` with **work-queue** delivery
|
|
12
|
+
* (exactly once per cluster per change) and filters to PER-ENV ids
|
|
13
|
+
* (`environmentId !== null`). A bare-rollup change is IGNORED, so the
|
|
14
|
+
* rollup write this consumer itself performs can never re-trigger it - no
|
|
15
|
+
* feedback loop.
|
|
16
|
+
* 2. Per-env changes are DEBOUNCED per system into a short fixed window: the
|
|
17
|
+
* handler enqueues a `{ systemId }` job on the rollup queue keyed
|
|
18
|
+
* `healthrollup:<systemId>:<window-bucket>`. Both queue backends dedupe on
|
|
19
|
+
* jobId, so a burst of env transitions for one system in the same window
|
|
20
|
+
* coalesces to ONE rollup recompute (and the bucket id is used once, so
|
|
21
|
+
* BullMQ's completed-job retention never drops a later window's job).
|
|
22
|
+
* 3. The rollup-queue consumer recomputes the bare `"<systemId>"` entity via
|
|
23
|
+
* `recomputeSystemRollupHealth`, which diffs prev → next inside the
|
|
24
|
+
* `health:<systemId>` advisory lock, emits the rollup `ENTITY_CHANGED`, and
|
|
25
|
+
* (on a real change) invalidates the cache + broadcasts
|
|
26
|
+
* `SYSTEM_STATUS_CHANGED`.
|
|
27
|
+
*
|
|
28
|
+
* Notifications are intentionally NOT sent here: each env run already notifies
|
|
29
|
+
* its own transition, so a rollup notification would duplicate it. This is the
|
|
30
|
+
* structural form of the #417 rollup-notification dedup - for a fanned-out
|
|
31
|
+
* system the rollup notification is ALWAYS suppressed; an env-less system needs
|
|
32
|
+
* no consumer at all (its run IS the bare-entity write and notifies directly).
|
|
33
|
+
*
|
|
34
|
+
* Scale-correctness (state-and-scale): the rollup is recomputed from Postgres
|
|
35
|
+
* (`getSystemHealthStatus`) on whichever pod claims the debounced job, and the
|
|
36
|
+
* advisory lock serializes concurrent recomputes, so every pod converges to the
|
|
37
|
+
* same rollup regardless of which one ran it.
|
|
38
|
+
*/
|
|
39
|
+
import type { Logger, AdvisoryLockService } from "@checkstack/backend-api";
|
|
40
|
+
import type { QueueManager } from "@checkstack/queue-api";
|
|
41
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
42
|
+
import type {
|
|
43
|
+
OnEntityChanged,
|
|
44
|
+
EntityChanged,
|
|
45
|
+
EntityHandle,
|
|
46
|
+
} from "@checkstack/automation-backend";
|
|
47
|
+
import { HEALTH_ENTITY_KIND, type HealthEntityState } from "./health-entity";
|
|
48
|
+
import { parseHealthEntityId } from "./health-entity-id";
|
|
49
|
+
import { recomputeSystemRollupHealth } from "./queue-executor";
|
|
50
|
+
import type { HealthCheckService } from "./service";
|
|
51
|
+
import type { HealthCheckCache } from "./cache";
|
|
52
|
+
|
|
53
|
+
/** The dedicated queue the debounced rollup recomputes run on. */
|
|
54
|
+
export const HEALTH_ROLLUP_QUEUE = "health-rollup";
|
|
55
|
+
|
|
56
|
+
/** Payload for a debounced rollup recompute job. */
|
|
57
|
+
export interface HealthRollupJobPayload {
|
|
58
|
+
systemId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Default debounce window (ms) a burst of per-env changes coalesces into. */
|
|
62
|
+
export const DEFAULT_ROLLUP_DEBOUNCE_MS = 2000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The dedup jobId for a debounced rollup recompute. Keyed on the system AND a
|
|
66
|
+
* fixed time bucket so a burst within one window coalesces to a single job,
|
|
67
|
+
* while the NEXT window gets a fresh id (so a completed-job retention on the
|
|
68
|
+
* queue backend can never suppress a later window's recompute).
|
|
69
|
+
*/
|
|
70
|
+
export function encodeRollupDebounceJobId(props: {
|
|
71
|
+
systemId: string;
|
|
72
|
+
now: number;
|
|
73
|
+
windowMs: number;
|
|
74
|
+
}): string {
|
|
75
|
+
const { systemId, now, windowMs } = props;
|
|
76
|
+
const bucket = Math.floor(now / windowMs);
|
|
77
|
+
return `healthrollup:${systemId}:${bucket}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RollupConsumerDeps {
|
|
81
|
+
queueManager: QueueManager;
|
|
82
|
+
onEntityChanged: OnEntityChanged;
|
|
83
|
+
service: HealthCheckService;
|
|
84
|
+
advisoryLock: AdvisoryLockService;
|
|
85
|
+
signalService: SignalService;
|
|
86
|
+
cache: HealthCheckCache;
|
|
87
|
+
getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
|
|
88
|
+
logger: Logger;
|
|
89
|
+
/** Debounce window in ms. Defaults to {@link DEFAULT_ROLLUP_DEBOUNCE_MS}. */
|
|
90
|
+
debounceMs?: number;
|
|
91
|
+
/** Injectable clock for tests. Defaults to `Date.now`. */
|
|
92
|
+
now?: () => number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wire the rollup-queue consumer + the per-env `health` change subscription.
|
|
97
|
+
* Returns the `onEntityChanged` unsubscribe handle for teardown.
|
|
98
|
+
*/
|
|
99
|
+
export async function setupRollupConsumer(
|
|
100
|
+
deps: RollupConsumerDeps,
|
|
101
|
+
): Promise<() => Promise<void>> {
|
|
102
|
+
const {
|
|
103
|
+
queueManager,
|
|
104
|
+
onEntityChanged,
|
|
105
|
+
service,
|
|
106
|
+
advisoryLock,
|
|
107
|
+
signalService,
|
|
108
|
+
cache,
|
|
109
|
+
getHealthEntity,
|
|
110
|
+
logger,
|
|
111
|
+
debounceMs = DEFAULT_ROLLUP_DEBOUNCE_MS,
|
|
112
|
+
now = () => Date.now(),
|
|
113
|
+
} = deps;
|
|
114
|
+
|
|
115
|
+
const rollupQueue =
|
|
116
|
+
queueManager.getQueue<HealthRollupJobPayload>(HEALTH_ROLLUP_QUEUE);
|
|
117
|
+
|
|
118
|
+
// Consumer: recompute the bare-system rollup entity for the job's system.
|
|
119
|
+
await rollupQueue.consume(
|
|
120
|
+
async (job) => {
|
|
121
|
+
await recomputeSystemRollupHealth({
|
|
122
|
+
systemId: job.data.systemId,
|
|
123
|
+
service,
|
|
124
|
+
getHealthEntity,
|
|
125
|
+
advisoryLock,
|
|
126
|
+
signalService,
|
|
127
|
+
cache,
|
|
128
|
+
logger,
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
{ consumerGroup: "health-rollup" },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Subscription: a per-env `health` change debounces a rollup recompute for
|
|
135
|
+
// its system. Bare-rollup changes are ignored (feedback-loop guard).
|
|
136
|
+
const unsubscribe = onEntityChanged({
|
|
137
|
+
kind: HEALTH_ENTITY_KIND,
|
|
138
|
+
delivery: { mode: "work-queue", workerGroup: "health-rollup-debounce" },
|
|
139
|
+
handler: async (change: EntityChanged) => {
|
|
140
|
+
const { systemId, environmentId } = parseHealthEntityId(change.id);
|
|
141
|
+
// Only PER-ENV changes drive a rollup recompute. A bare-`<systemId>`
|
|
142
|
+
// change is either an env-less run (already the rollup) or this
|
|
143
|
+
// consumer's own rollup write - never re-enqueue on it.
|
|
144
|
+
if (environmentId === null) return;
|
|
145
|
+
|
|
146
|
+
const jobId = encodeRollupDebounceJobId({
|
|
147
|
+
systemId,
|
|
148
|
+
now: now(),
|
|
149
|
+
windowMs: debounceMs,
|
|
150
|
+
});
|
|
151
|
+
await rollupQueue.enqueue(
|
|
152
|
+
{ systemId },
|
|
153
|
+
{ jobId, startDelay: Math.ceil(debounceMs / 1000) },
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
logger.debug("✅ Health rollup consumer wired (per-env → debounced rollup).");
|
|
159
|
+
return unsubscribe;
|
|
160
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -213,21 +213,18 @@ export const createHealthCheckRouter = (opts: {
|
|
|
213
213
|
}) => {
|
|
214
214
|
await cache.invalidateSystem(args.systemId);
|
|
215
215
|
|
|
216
|
-
// If enabling the health check,
|
|
217
|
-
// probing right away.
|
|
216
|
+
// If enabling the health check, reconcile this system's per-env recurring
|
|
217
|
+
// jobs immediately so it starts probing right away. A system-scoped
|
|
218
|
+
// reconcile only adds/updates (no orphan cleanup), so it needs no lock.
|
|
218
219
|
if (args.enabled) {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
},
|
|
228
|
-
intervalSeconds: config.intervalSeconds,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
220
|
+
const { reconcileHealthCheckJobs } = await import("./schedule-reconciler");
|
|
221
|
+
await reconcileHealthCheckJobs({
|
|
222
|
+
db: database,
|
|
223
|
+
queueManager: args.queueManager,
|
|
224
|
+
catalogClient,
|
|
225
|
+
logger,
|
|
226
|
+
systemId: args.systemId,
|
|
227
|
+
});
|
|
231
228
|
}
|
|
232
229
|
|
|
233
230
|
// Notify subscribers (e.g., satellite-backend) that assignments changed.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
computeScheduleJitterSeconds,
|
|
4
|
+
DEFAULT_JITTER_WINDOW_SECONDS,
|
|
5
|
+
} from "./schedule-jitter";
|
|
6
|
+
|
|
7
|
+
describe("computeScheduleJitterSeconds", () => {
|
|
8
|
+
it("is deterministic for the same key + interval", () => {
|
|
9
|
+
const a = computeScheduleJitterSeconds({ key: "sys:cfg", intervalSeconds: 60 });
|
|
10
|
+
const b = computeScheduleJitterSeconds({ key: "sys:cfg", intervalSeconds: 60 });
|
|
11
|
+
expect(a).toBe(b);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("stays within [0, min(interval, window))", () => {
|
|
15
|
+
// Short interval: window is the interval itself.
|
|
16
|
+
for (let i = 0; i < 200; i++) {
|
|
17
|
+
const v = computeScheduleJitterSeconds({
|
|
18
|
+
key: `k${i}`,
|
|
19
|
+
intervalSeconds: 10,
|
|
20
|
+
});
|
|
21
|
+
expect(v).toBeGreaterThanOrEqual(0);
|
|
22
|
+
expect(v).toBeLessThan(10);
|
|
23
|
+
}
|
|
24
|
+
// Long interval: capped by the default window.
|
|
25
|
+
for (let i = 0; i < 200; i++) {
|
|
26
|
+
const v = computeScheduleJitterSeconds({
|
|
27
|
+
key: `k${i}`,
|
|
28
|
+
intervalSeconds: 3600,
|
|
29
|
+
});
|
|
30
|
+
expect(v).toBeGreaterThanOrEqual(0);
|
|
31
|
+
expect(v).toBeLessThan(DEFAULT_JITTER_WINDOW_SECONDS);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("spreads a synchronized set across the window (de-clusters)", () => {
|
|
36
|
+
// 50 checks that share an interval must NOT all land on the same offset.
|
|
37
|
+
const offsets = new Set<number>();
|
|
38
|
+
for (let i = 0; i < 50; i++) {
|
|
39
|
+
offsets.add(
|
|
40
|
+
computeScheduleJitterSeconds({
|
|
41
|
+
key: `system-${i}:check-a`,
|
|
42
|
+
intervalSeconds: 60,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
// Comfortably more than a handful of distinct slots out of a 30s window.
|
|
47
|
+
expect(offsets.size).toBeGreaterThan(10);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns 0 for a non-positive interval or window", () => {
|
|
51
|
+
expect(
|
|
52
|
+
computeScheduleJitterSeconds({ key: "k", intervalSeconds: 0 }),
|
|
53
|
+
).toBe(0);
|
|
54
|
+
expect(
|
|
55
|
+
computeScheduleJitterSeconds({
|
|
56
|
+
key: "k",
|
|
57
|
+
intervalSeconds: 60,
|
|
58
|
+
maxWindowSeconds: 0,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("different keys generally produce different offsets", () => {
|
|
64
|
+
const a = computeScheduleJitterSeconds({ key: "alpha", intervalSeconds: 60 });
|
|
65
|
+
const b = computeScheduleJitterSeconds({ key: "beta", intervalSeconds: 60 });
|
|
66
|
+
// Not a hard guarantee for any two strings, but these two must differ.
|
|
67
|
+
expect(a).not.toBe(b);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic per-check scheduling jitter to de-cluster the health-check
|
|
3
|
+
* "thundering herd".
|
|
4
|
+
*
|
|
5
|
+
* Many checks created together - or all overdue at once on a fresh boot - would
|
|
6
|
+
* otherwise be scheduled with the same `startDelay` and identical intervals, so
|
|
7
|
+
* they fire on the same phase forever: dozens of TCP/TLS handshakes contending
|
|
8
|
+
* at the same instant, which inflates and destabilizes per-run connection setup
|
|
9
|
+
* (the observed "same check, same site, wildly different durations"). Offsetting
|
|
10
|
+
* each check's first fire by a stable fraction of its interval spreads them out;
|
|
11
|
+
* because the queue anchors the recurrence to that first fire, the offset
|
|
12
|
+
* persists for the schedule's whole life.
|
|
13
|
+
*
|
|
14
|
+
* The offset is DETERMINISTIC in the check's key (config + system), so a check
|
|
15
|
+
* keeps the same slot across restarts instead of re-clustering on a fresh random
|
|
16
|
+
* draw each boot.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Default upper bound on the jitter window, in seconds. */
|
|
20
|
+
export const DEFAULT_JITTER_WINDOW_SECONDS = 30;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A stable jitter offset in `[0, min(intervalSeconds, maxWindowSeconds))`
|
|
24
|
+
* seconds, derived from `key`. Short intervals spread across the whole interval;
|
|
25
|
+
* long intervals cap the window so a check still starts reasonably promptly.
|
|
26
|
+
*/
|
|
27
|
+
export function computeScheduleJitterSeconds({
|
|
28
|
+
key,
|
|
29
|
+
intervalSeconds,
|
|
30
|
+
maxWindowSeconds = DEFAULT_JITTER_WINDOW_SECONDS,
|
|
31
|
+
}: {
|
|
32
|
+
key: string;
|
|
33
|
+
intervalSeconds: number;
|
|
34
|
+
maxWindowSeconds?: number;
|
|
35
|
+
}): number {
|
|
36
|
+
const window = Math.min(
|
|
37
|
+
Math.max(0, Math.floor(intervalSeconds)),
|
|
38
|
+
Math.max(0, Math.floor(maxWindowSeconds)),
|
|
39
|
+
);
|
|
40
|
+
if (window <= 0) return 0;
|
|
41
|
+
|
|
42
|
+
// FNV-1a 32-bit hash of the key -> a stable fraction in [0, 1).
|
|
43
|
+
let hash = 0x81_1C_9D_C5;
|
|
44
|
+
for (let i = 0; i < key.length; i++) {
|
|
45
|
+
hash ^= key.codePointAt(i) ?? 0;
|
|
46
|
+
hash = Math.imul(hash, 0x01_00_01_93);
|
|
47
|
+
}
|
|
48
|
+
const fraction = (hash >>> 0) / 0x1_00_00_00_00;
|
|
49
|
+
return Math.floor(fraction * window);
|
|
50
|
+
}
|