@checkstack/automation-backend 0.2.0 → 0.3.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 +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
3
|
+
import {
|
|
4
|
+
AutomationDefinitionSchema,
|
|
5
|
+
type Automation,
|
|
6
|
+
type Trigger,
|
|
7
|
+
} from "@checkstack/automation-common";
|
|
8
|
+
|
|
9
|
+
import type { AutomationStore } from "../automation-store";
|
|
10
|
+
import { handleTriggerFiring } from "./trigger-subscriber";
|
|
11
|
+
import { makeDispatchDeps, makeRecordingAction, testPlugin } from "./test-fixtures";
|
|
12
|
+
import { createActionRegistry } from "../action-registry";
|
|
13
|
+
import type { LoadedAutomation } from "./types";
|
|
14
|
+
|
|
15
|
+
const EVENT = "healthcheck.system_health_changed";
|
|
16
|
+
|
|
17
|
+
function buildAutomation(opts: {
|
|
18
|
+
id?: string;
|
|
19
|
+
trigger: Partial<Trigger> & { event: string };
|
|
20
|
+
}): Automation {
|
|
21
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
22
|
+
name: "Window test",
|
|
23
|
+
triggers: [opts.trigger],
|
|
24
|
+
conditions: [],
|
|
25
|
+
actions: [{ action: "test.record", config: { value: "fired" } }],
|
|
26
|
+
mode: "parallel",
|
|
27
|
+
max_runs: 100,
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
id: opts.id ?? "auto-1",
|
|
31
|
+
name: "Window test",
|
|
32
|
+
status: "enabled",
|
|
33
|
+
definition,
|
|
34
|
+
createdAt: new Date(),
|
|
35
|
+
updatedAt: new Date(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeAutomationStore(automations: Automation[]): AutomationStore {
|
|
40
|
+
const byId = new Map(automations.map((a) => [a.id, a]));
|
|
41
|
+
const loaded = (a: Automation): LoadedAutomation => ({
|
|
42
|
+
id: a.id,
|
|
43
|
+
name: a.name,
|
|
44
|
+
status: a.status,
|
|
45
|
+
definition: a.definition,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
create: async () => {
|
|
49
|
+
throw new Error("not used");
|
|
50
|
+
},
|
|
51
|
+
update: async () => {
|
|
52
|
+
throw new Error("not used");
|
|
53
|
+
},
|
|
54
|
+
delete: async () => {},
|
|
55
|
+
toggle: async () => {
|
|
56
|
+
throw new Error("not used");
|
|
57
|
+
},
|
|
58
|
+
getById: async (id) => byId.get(id),
|
|
59
|
+
list: async () => ({ items: [...byId.values()], total: byId.size }),
|
|
60
|
+
listGroups: async () => [],
|
|
61
|
+
findEnabledByTriggerEvent: async (eventId) =>
|
|
62
|
+
[...byId.values()]
|
|
63
|
+
.filter(
|
|
64
|
+
(a) =>
|
|
65
|
+
a.status === "enabled" &&
|
|
66
|
+
a.definition.triggers.some((t) => t.event === eventId),
|
|
67
|
+
)
|
|
68
|
+
.map(loaded),
|
|
69
|
+
listEnabled: async () =>
|
|
70
|
+
[...byId.values()].filter((a) => a.status === "enabled").map(loaded),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setup() {
|
|
75
|
+
const actionsReg = createActionRegistry();
|
|
76
|
+
const rec = makeRecordingAction();
|
|
77
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
78
|
+
const { deps, runs, windows } = makeDispatchDeps({ actions: actionsReg });
|
|
79
|
+
return { deps, runs, windows, rec };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Fire the trigger N times for one system; return how many runs started. */
|
|
83
|
+
async function fireN(opts: {
|
|
84
|
+
deps: ReturnType<typeof setup>["deps"];
|
|
85
|
+
store: AutomationStore;
|
|
86
|
+
runs: ReturnType<typeof setup>["runs"];
|
|
87
|
+
systemId: string;
|
|
88
|
+
newStatus: string;
|
|
89
|
+
times: number;
|
|
90
|
+
}): Promise<number> {
|
|
91
|
+
const before = opts.runs.runs.size;
|
|
92
|
+
for (let i = 0; i < opts.times; i++) {
|
|
93
|
+
await handleTriggerFiring({
|
|
94
|
+
deps: opts.deps,
|
|
95
|
+
automationStore: opts.store,
|
|
96
|
+
qualifiedEventId: EVENT,
|
|
97
|
+
triggerPayload: {
|
|
98
|
+
systemId: opts.systemId,
|
|
99
|
+
newStatus: opts.newStatus,
|
|
100
|
+
previousStatus: "healthy",
|
|
101
|
+
},
|
|
102
|
+
actor: SYSTEM_ACTOR,
|
|
103
|
+
contextKey: opts.systemId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return opts.runs.runs.size - before;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("windowed-count trigger gate", () => {
|
|
110
|
+
it("does not fire below the threshold, then fires once it is reached", async () => {
|
|
111
|
+
const { deps, runs } = setup();
|
|
112
|
+
const store = makeAutomationStore([
|
|
113
|
+
buildAutomation({
|
|
114
|
+
trigger: {
|
|
115
|
+
id: "flapping",
|
|
116
|
+
event: EVENT,
|
|
117
|
+
window: { count: 3, minutes: 60, refire: "every" },
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// 2 occurrences ⇒ no run.
|
|
123
|
+
let started = await fireN({
|
|
124
|
+
deps,
|
|
125
|
+
store,
|
|
126
|
+
runs,
|
|
127
|
+
systemId: "sys-1",
|
|
128
|
+
newStatus: "unhealthy",
|
|
129
|
+
times: 2,
|
|
130
|
+
});
|
|
131
|
+
expect(started).toBe(0);
|
|
132
|
+
|
|
133
|
+
// 3rd occurrence crosses ⇒ run starts.
|
|
134
|
+
started = await fireN({
|
|
135
|
+
deps,
|
|
136
|
+
store,
|
|
137
|
+
runs,
|
|
138
|
+
systemId: "sys-1",
|
|
139
|
+
newStatus: "unhealthy",
|
|
140
|
+
times: 1,
|
|
141
|
+
});
|
|
142
|
+
expect(started).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("only counts POST-FILTER occurrences (filter gates before the window)", async () => {
|
|
146
|
+
const { deps, runs } = setup();
|
|
147
|
+
const store = makeAutomationStore([
|
|
148
|
+
buildAutomation({
|
|
149
|
+
trigger: {
|
|
150
|
+
id: "flapping",
|
|
151
|
+
event: EVENT,
|
|
152
|
+
filter: 'trigger.payload.newStatus != "healthy"',
|
|
153
|
+
window: { count: 2, minutes: 60, refire: "once" },
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
// Healthy transitions don't pass the filter ⇒ not counted.
|
|
159
|
+
let started = await fireN({
|
|
160
|
+
deps,
|
|
161
|
+
store,
|
|
162
|
+
runs,
|
|
163
|
+
systemId: "sys-1",
|
|
164
|
+
newStatus: "healthy",
|
|
165
|
+
times: 5,
|
|
166
|
+
});
|
|
167
|
+
expect(started).toBe(0);
|
|
168
|
+
|
|
169
|
+
// Two unhealthy transitions ⇒ edge fires once.
|
|
170
|
+
started = await fireN({
|
|
171
|
+
deps,
|
|
172
|
+
store,
|
|
173
|
+
runs,
|
|
174
|
+
systemId: "sys-1",
|
|
175
|
+
newStatus: "unhealthy",
|
|
176
|
+
times: 2,
|
|
177
|
+
});
|
|
178
|
+
expect(started).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("`every` re-fires on every occurrence past the threshold; `once` fires only on the edge", async () => {
|
|
182
|
+
// every
|
|
183
|
+
{
|
|
184
|
+
const { deps, runs } = setup();
|
|
185
|
+
const store = makeAutomationStore([
|
|
186
|
+
buildAutomation({
|
|
187
|
+
trigger: {
|
|
188
|
+
id: "f",
|
|
189
|
+
event: EVENT,
|
|
190
|
+
window: { count: 2, minutes: 60, refire: "every" },
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
]);
|
|
194
|
+
const started = await fireN({
|
|
195
|
+
deps,
|
|
196
|
+
store,
|
|
197
|
+
runs,
|
|
198
|
+
systemId: "sys-1",
|
|
199
|
+
newStatus: "unhealthy",
|
|
200
|
+
times: 4,
|
|
201
|
+
});
|
|
202
|
+
// occurrences 2,3,4 all fire (>= 2) ⇒ 3 runs.
|
|
203
|
+
expect(started).toBe(3);
|
|
204
|
+
}
|
|
205
|
+
// once
|
|
206
|
+
{
|
|
207
|
+
const { deps, runs } = setup();
|
|
208
|
+
const store = makeAutomationStore([
|
|
209
|
+
buildAutomation({
|
|
210
|
+
trigger: {
|
|
211
|
+
id: "f",
|
|
212
|
+
event: EVENT,
|
|
213
|
+
window: { count: 2, minutes: 60, refire: "once" },
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
]);
|
|
217
|
+
const started = await fireN({
|
|
218
|
+
deps,
|
|
219
|
+
store,
|
|
220
|
+
runs,
|
|
221
|
+
systemId: "sys-1",
|
|
222
|
+
newStatus: "unhealthy",
|
|
223
|
+
times: 4,
|
|
224
|
+
});
|
|
225
|
+
// only occurrence 2 (=== 2) fires ⇒ 1 run.
|
|
226
|
+
expect(started).toBe(1);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("windows are isolated per context key (per system)", async () => {
|
|
231
|
+
const { deps, runs } = setup();
|
|
232
|
+
const store = makeAutomationStore([
|
|
233
|
+
buildAutomation({
|
|
234
|
+
trigger: {
|
|
235
|
+
id: "f",
|
|
236
|
+
event: EVENT,
|
|
237
|
+
window: { count: 3, minutes: 60, refire: "once" },
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
await fireN({ deps, store, runs, systemId: "sys-1", newStatus: "unhealthy", times: 2 });
|
|
243
|
+
await fireN({ deps, store, runs, systemId: "sys-2", newStatus: "unhealthy", times: 2 });
|
|
244
|
+
// Neither system has reached 3 yet.
|
|
245
|
+
expect(runs.runs.size).toBe(0);
|
|
246
|
+
|
|
247
|
+
// sys-1 reaches 3 ⇒ fires; sys-2 still at 2.
|
|
248
|
+
const startedSys1 = await fireN({
|
|
249
|
+
deps,
|
|
250
|
+
store,
|
|
251
|
+
runs,
|
|
252
|
+
systemId: "sys-1",
|
|
253
|
+
newStatus: "unhealthy",
|
|
254
|
+
times: 1,
|
|
255
|
+
});
|
|
256
|
+
expect(startedSys1).toBe(1);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Fire the trigger `times` with an arbitrary payload + contextKey; returns how
|
|
262
|
+
* many runs started. Lets partitionBy tests vary a payload field (e.g.
|
|
263
|
+
* severity) independently of the built-in contextKey (systemId).
|
|
264
|
+
*/
|
|
265
|
+
async function firePayloads(opts: {
|
|
266
|
+
deps: ReturnType<typeof setup>["deps"];
|
|
267
|
+
store: AutomationStore;
|
|
268
|
+
runs: ReturnType<typeof setup>["runs"];
|
|
269
|
+
payload: Record<string, unknown>;
|
|
270
|
+
contextKey: string | null;
|
|
271
|
+
times: number;
|
|
272
|
+
}): Promise<number> {
|
|
273
|
+
const before = opts.runs.runs.size;
|
|
274
|
+
for (let i = 0; i < opts.times; i++) {
|
|
275
|
+
await handleTriggerFiring({
|
|
276
|
+
deps: opts.deps,
|
|
277
|
+
automationStore: opts.store,
|
|
278
|
+
qualifiedEventId: EVENT,
|
|
279
|
+
triggerPayload: opts.payload,
|
|
280
|
+
actor: SYSTEM_ACTOR,
|
|
281
|
+
contextKey: opts.contextKey,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return opts.runs.runs.size - before;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
describe("windowed-count trigger gate — partitionBy", () => {
|
|
288
|
+
it("partitions the count by the evaluated expression value", async () => {
|
|
289
|
+
const { deps, runs } = setup();
|
|
290
|
+
const store = makeAutomationStore([
|
|
291
|
+
buildAutomation({
|
|
292
|
+
trigger: {
|
|
293
|
+
id: "f",
|
|
294
|
+
event: EVENT,
|
|
295
|
+
window: {
|
|
296
|
+
count: 2,
|
|
297
|
+
minutes: 60,
|
|
298
|
+
refire: "once",
|
|
299
|
+
partitionBy: "trigger.payload.severity",
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
// Same system (contextKey sys-1), different severities ⇒ independent windows.
|
|
306
|
+
// Two `high` reach the threshold and fire; one `low` does not.
|
|
307
|
+
let started = await firePayloads({
|
|
308
|
+
deps,
|
|
309
|
+
store,
|
|
310
|
+
runs,
|
|
311
|
+
payload: { systemId: "sys-1", severity: "high" },
|
|
312
|
+
contextKey: "sys-1",
|
|
313
|
+
times: 1,
|
|
314
|
+
});
|
|
315
|
+
expect(started).toBe(0); // high count 1
|
|
316
|
+
|
|
317
|
+
started = await firePayloads({
|
|
318
|
+
deps,
|
|
319
|
+
store,
|
|
320
|
+
runs,
|
|
321
|
+
payload: { systemId: "sys-1", severity: "low" },
|
|
322
|
+
contextKey: "sys-1",
|
|
323
|
+
times: 1,
|
|
324
|
+
});
|
|
325
|
+
expect(started).toBe(0); // low count 1 (separate partition)
|
|
326
|
+
|
|
327
|
+
started = await firePayloads({
|
|
328
|
+
deps,
|
|
329
|
+
store,
|
|
330
|
+
runs,
|
|
331
|
+
payload: { systemId: "sys-1", severity: "high" },
|
|
332
|
+
contextKey: "sys-1",
|
|
333
|
+
times: 1,
|
|
334
|
+
});
|
|
335
|
+
expect(started).toBe(1); // high count 2 ⇒ fires on its own partition
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("keeps two distinct evaluated values in independent windows", async () => {
|
|
339
|
+
const { deps, runs } = setup();
|
|
340
|
+
const store = makeAutomationStore([
|
|
341
|
+
buildAutomation({
|
|
342
|
+
trigger: {
|
|
343
|
+
id: "f",
|
|
344
|
+
event: EVENT,
|
|
345
|
+
window: {
|
|
346
|
+
count: 3,
|
|
347
|
+
minutes: 60,
|
|
348
|
+
refire: "once",
|
|
349
|
+
partitionBy: "trigger.payload.severity",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
await firePayloads({
|
|
356
|
+
deps,
|
|
357
|
+
store,
|
|
358
|
+
runs,
|
|
359
|
+
payload: { systemId: "sys-1", severity: "high" },
|
|
360
|
+
contextKey: "sys-1",
|
|
361
|
+
times: 2,
|
|
362
|
+
});
|
|
363
|
+
await firePayloads({
|
|
364
|
+
deps,
|
|
365
|
+
store,
|
|
366
|
+
runs,
|
|
367
|
+
payload: { systemId: "sys-1", severity: "low" },
|
|
368
|
+
contextKey: "sys-1",
|
|
369
|
+
times: 2,
|
|
370
|
+
});
|
|
371
|
+
// Neither partition has reached 3 yet (2 each).
|
|
372
|
+
expect(runs.runs.size).toBe(0);
|
|
373
|
+
|
|
374
|
+
const started = await firePayloads({
|
|
375
|
+
deps,
|
|
376
|
+
store,
|
|
377
|
+
runs,
|
|
378
|
+
payload: { systemId: "sys-1", severity: "high" },
|
|
379
|
+
contextKey: "sys-1",
|
|
380
|
+
times: 1,
|
|
381
|
+
});
|
|
382
|
+
expect(started).toBe(1); // `high` reaches 3
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("omitted partitionBy ⇒ built-in contextKey (unchanged) — composite split by system", async () => {
|
|
386
|
+
const { deps, runs } = setup();
|
|
387
|
+
const store = makeAutomationStore([
|
|
388
|
+
buildAutomation({
|
|
389
|
+
trigger: {
|
|
390
|
+
id: "f",
|
|
391
|
+
event: EVENT,
|
|
392
|
+
window: { count: 2, minutes: 60, refire: "once" },
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
// Two systems, two events each: each is its own partition (the contextKey),
|
|
398
|
+
// so each fires once on its 2nd event.
|
|
399
|
+
await firePayloads({
|
|
400
|
+
deps,
|
|
401
|
+
store,
|
|
402
|
+
runs,
|
|
403
|
+
payload: { systemId: "sys-1" },
|
|
404
|
+
contextKey: "sys-1",
|
|
405
|
+
times: 1,
|
|
406
|
+
});
|
|
407
|
+
const startedSys2First = await firePayloads({
|
|
408
|
+
deps,
|
|
409
|
+
store,
|
|
410
|
+
runs,
|
|
411
|
+
payload: { systemId: "sys-2" },
|
|
412
|
+
contextKey: "sys-2",
|
|
413
|
+
times: 1,
|
|
414
|
+
});
|
|
415
|
+
expect(startedSys2First).toBe(0); // each system at count 1
|
|
416
|
+
|
|
417
|
+
const startedSys1Second = await firePayloads({
|
|
418
|
+
deps,
|
|
419
|
+
store,
|
|
420
|
+
runs,
|
|
421
|
+
payload: { systemId: "sys-1" },
|
|
422
|
+
contextKey: "sys-1",
|
|
423
|
+
times: 1,
|
|
424
|
+
});
|
|
425
|
+
expect(startedSys1Second).toBe(1); // sys-1 reaches 2
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("partitionBy evaluating to empty/undefined falls back to the built-in contextKey", async () => {
|
|
429
|
+
const { deps, runs } = setup();
|
|
430
|
+
const store = makeAutomationStore([
|
|
431
|
+
buildAutomation({
|
|
432
|
+
trigger: {
|
|
433
|
+
id: "f",
|
|
434
|
+
event: EVENT,
|
|
435
|
+
// `missing` is not in the payload ⇒ evaluates to undefined ⇒ fall
|
|
436
|
+
// back to contextKey (systemId). So per-system counting applies.
|
|
437
|
+
window: {
|
|
438
|
+
count: 2,
|
|
439
|
+
minutes: 60,
|
|
440
|
+
refire: "once",
|
|
441
|
+
partitionBy: "trigger.payload.missing",
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
}),
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
await firePayloads({
|
|
448
|
+
deps,
|
|
449
|
+
store,
|
|
450
|
+
runs,
|
|
451
|
+
payload: { systemId: "sys-1" },
|
|
452
|
+
contextKey: "sys-1",
|
|
453
|
+
times: 1,
|
|
454
|
+
});
|
|
455
|
+
// A different system must NOT share the bucket (fallback is per-context, not global).
|
|
456
|
+
const startedSys2 = await firePayloads({
|
|
457
|
+
deps,
|
|
458
|
+
store,
|
|
459
|
+
runs,
|
|
460
|
+
payload: { systemId: "sys-2" },
|
|
461
|
+
contextKey: "sys-2",
|
|
462
|
+
times: 1,
|
|
463
|
+
});
|
|
464
|
+
expect(startedSys2).toBe(0); // sys-2 at count 1 (own bucket)
|
|
465
|
+
|
|
466
|
+
const startedSys1 = await firePayloads({
|
|
467
|
+
deps,
|
|
468
|
+
store,
|
|
469
|
+
runs,
|
|
470
|
+
payload: { systemId: "sys-1" },
|
|
471
|
+
contextKey: "sys-1",
|
|
472
|
+
times: 1,
|
|
473
|
+
});
|
|
474
|
+
expect(startedSys1).toBe(1); // sys-1 reaches 2 in its own (fallback) bucket
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("partitionBy that throws on eval falls back to the built-in contextKey (fail-open)", async () => {
|
|
478
|
+
const { deps, runs } = setup();
|
|
479
|
+
const store = makeAutomationStore([
|
|
480
|
+
buildAutomation({
|
|
481
|
+
trigger: {
|
|
482
|
+
id: "f",
|
|
483
|
+
event: EVENT,
|
|
484
|
+
// Unparseable expression ⇒ eval throws ⇒ fall back to contextKey.
|
|
485
|
+
window: {
|
|
486
|
+
count: 2,
|
|
487
|
+
minutes: 60,
|
|
488
|
+
refire: "once",
|
|
489
|
+
partitionBy: "trigger.payload.(((",
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
}),
|
|
493
|
+
]);
|
|
494
|
+
|
|
495
|
+
await firePayloads({
|
|
496
|
+
deps,
|
|
497
|
+
store,
|
|
498
|
+
runs,
|
|
499
|
+
payload: { systemId: "sys-1" },
|
|
500
|
+
contextKey: "sys-1",
|
|
501
|
+
times: 1,
|
|
502
|
+
});
|
|
503
|
+
const startedSys1 = await firePayloads({
|
|
504
|
+
deps,
|
|
505
|
+
store,
|
|
506
|
+
runs,
|
|
507
|
+
payload: { systemId: "sys-1" },
|
|
508
|
+
contextKey: "sys-1",
|
|
509
|
+
times: 1,
|
|
510
|
+
});
|
|
511
|
+
expect(startedSys1).toBe(1); // counted per system via fallback
|
|
512
|
+
});
|
|
513
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createInMemoryWindowStore } from "./test-fixtures";
|
|
4
|
+
import type { WindowRefire } from "./types";
|
|
5
|
+
|
|
6
|
+
const AUTO = "auto-1";
|
|
7
|
+
const TRIGGER = "flapping";
|
|
8
|
+
const EVENT = "healthcheck.system_health_changed";
|
|
9
|
+
|
|
10
|
+
function at(baseMs: number, offsetMinutes: number): Date {
|
|
11
|
+
return new Date(baseMs + offsetMinutes * 60_000);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function record(opts: {
|
|
15
|
+
store: ReturnType<typeof createInMemoryWindowStore>["store"];
|
|
16
|
+
occurredAt: Date;
|
|
17
|
+
windowMinutes?: number;
|
|
18
|
+
threshold?: number;
|
|
19
|
+
refire?: WindowRefire;
|
|
20
|
+
contextKey?: string | null;
|
|
21
|
+
automationId?: string;
|
|
22
|
+
triggerId?: string;
|
|
23
|
+
eventId?: string;
|
|
24
|
+
}): Promise<boolean> {
|
|
25
|
+
return opts.store.recordAndCount({
|
|
26
|
+
automationId: opts.automationId ?? AUTO,
|
|
27
|
+
triggerId: opts.triggerId ?? TRIGGER,
|
|
28
|
+
eventId: opts.eventId ?? EVENT,
|
|
29
|
+
contextKey: opts.contextKey ?? "sys-1",
|
|
30
|
+
occurredAt: opts.occurredAt,
|
|
31
|
+
windowMinutes: opts.windowMinutes ?? 60,
|
|
32
|
+
threshold: opts.threshold ?? 3,
|
|
33
|
+
refire: opts.refire ?? "every",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("WindowStore.recordAndCount — counting within the window", () => {
|
|
38
|
+
it("counts occurrences within the trailing window (inclusive of the new row)", async () => {
|
|
39
|
+
const { store } = createInMemoryWindowStore();
|
|
40
|
+
const base = Date.now();
|
|
41
|
+
// 3 occurrences inside a 60-minute window; `every` threshold 3.
|
|
42
|
+
expect(await record({ store, occurredAt: at(base, 0) })).toBe(false); // count 1
|
|
43
|
+
expect(await record({ store, occurredAt: at(base, 10) })).toBe(false); // count 2
|
|
44
|
+
expect(await record({ store, occurredAt: at(base, 20) })).toBe(true); // count 3 ⇒ fire
|
|
45
|
+
expect(await record({ store, occurredAt: at(base, 30) })).toBe(true); // count 4, every ⇒ still fires
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("`once` fires only on the crossing edge", async () => {
|
|
49
|
+
const { store } = createInMemoryWindowStore();
|
|
50
|
+
const base = Date.now();
|
|
51
|
+
expect(
|
|
52
|
+
await record({ store, occurredAt: at(base, 0), refire: "once" }),
|
|
53
|
+
).toBe(false); // count 1
|
|
54
|
+
expect(
|
|
55
|
+
await record({ store, occurredAt: at(base, 10), refire: "once" }),
|
|
56
|
+
).toBe(false); // count 2
|
|
57
|
+
expect(
|
|
58
|
+
await record({ store, occurredAt: at(base, 20), refire: "once" }),
|
|
59
|
+
).toBe(true); // count 3 === threshold ⇒ fire (edge)
|
|
60
|
+
expect(
|
|
61
|
+
await record({ store, occurredAt: at(base, 30), refire: "once" }),
|
|
62
|
+
).toBe(false); // count 4 > threshold ⇒ suppressed
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("`once` re-arms as old rows age out and the count re-crosses", async () => {
|
|
66
|
+
const { store } = createInMemoryWindowStore();
|
|
67
|
+
const base = Date.now();
|
|
68
|
+
const w = 10; // 10-minute window
|
|
69
|
+
// First burst: 3 rows at t=0,1,2 inside the 10-min window ⇒ edge at the 3rd.
|
|
70
|
+
await record({ store, occurredAt: at(base, 0), windowMinutes: w, refire: "once" });
|
|
71
|
+
await record({ store, occurredAt: at(base, 1), windowMinutes: w, refire: "once" });
|
|
72
|
+
expect(
|
|
73
|
+
await record({ store, occurredAt: at(base, 2), windowMinutes: w, refire: "once" }),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
// 4th still inside ⇒ suppressed (count 4 > threshold).
|
|
76
|
+
expect(
|
|
77
|
+
await record({ store, occurredAt: at(base, 3), windowMinutes: w, refire: "once" }),
|
|
78
|
+
).toBe(false);
|
|
79
|
+
// Second burst much later (t=100..102): the t=0..3 rows are all >10 min
|
|
80
|
+
// old, so the window holds only the new rows. The 3rd new row re-crosses
|
|
81
|
+
// the threshold edge.
|
|
82
|
+
await record({ store, occurredAt: at(base, 100), windowMinutes: w, refire: "once" }); // count 1
|
|
83
|
+
await record({ store, occurredAt: at(base, 101), windowMinutes: w, refire: "once" }); // count 2
|
|
84
|
+
expect(
|
|
85
|
+
await record({ store, occurredAt: at(base, 102), windowMinutes: w, refire: "once" }),
|
|
86
|
+
).toBe(true); // count 3 ⇒ edge again
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("slides: occurrences older than the window do not count", async () => {
|
|
90
|
+
const { store } = createInMemoryWindowStore();
|
|
91
|
+
const base = Date.now();
|
|
92
|
+
const w = 10;
|
|
93
|
+
// 2 occurrences at t=0,1; then a gap past the window.
|
|
94
|
+
await record({ store, occurredAt: at(base, 0), windowMinutes: w, threshold: 3 });
|
|
95
|
+
await record({ store, occurredAt: at(base, 1), windowMinutes: w, threshold: 3 });
|
|
96
|
+
// t=20 is >10 min after t=0,1 — those have aged out; count is just 1.
|
|
97
|
+
expect(
|
|
98
|
+
await record({ store, occurredAt: at(base, 20), windowMinutes: w, threshold: 3 }),
|
|
99
|
+
).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("WindowStore — key isolation", () => {
|
|
104
|
+
it("counts are independent per (automation, contextKey, eventId)", async () => {
|
|
105
|
+
const { store } = createInMemoryWindowStore();
|
|
106
|
+
const base = Date.now();
|
|
107
|
+
// Same automation+trigger, two systems: each needs its own 3 to fire.
|
|
108
|
+
await record({ store, occurredAt: at(base, 0), contextKey: "sys-1" });
|
|
109
|
+
await record({ store, occurredAt: at(base, 1), contextKey: "sys-1" });
|
|
110
|
+
await record({ store, occurredAt: at(base, 2), contextKey: "sys-2" });
|
|
111
|
+
// sys-1 reaches 3 first.
|
|
112
|
+
expect(
|
|
113
|
+
await record({ store, occurredAt: at(base, 3), contextKey: "sys-1" }),
|
|
114
|
+
).toBe(true);
|
|
115
|
+
// sys-2 still at 2.
|
|
116
|
+
expect(
|
|
117
|
+
await record({ store, occurredAt: at(base, 4), contextKey: "sys-2" }),
|
|
118
|
+
).toBe(false);
|
|
119
|
+
|
|
120
|
+
// A different automation watching the same event/system has its own count.
|
|
121
|
+
expect(
|
|
122
|
+
await record({
|
|
123
|
+
store,
|
|
124
|
+
occurredAt: at(base, 5),
|
|
125
|
+
contextKey: "sys-1",
|
|
126
|
+
automationId: "auto-2",
|
|
127
|
+
}),
|
|
128
|
+
).toBe(false); // count 1 for auto-2/sys-1
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("a null contextKey forms its own per-automation window", async () => {
|
|
132
|
+
const { store } = createInMemoryWindowStore();
|
|
133
|
+
const base = Date.now();
|
|
134
|
+
await record({ store, occurredAt: at(base, 0), contextKey: null });
|
|
135
|
+
await record({ store, occurredAt: at(base, 1), contextKey: null });
|
|
136
|
+
expect(
|
|
137
|
+
await record({ store, occurredAt: at(base, 2), contextKey: null }),
|
|
138
|
+
).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("WindowStore — pruning + lifecycle", () => {
|
|
143
|
+
it("sweepExpired deletes only rows older than the cutoff", async () => {
|
|
144
|
+
const { store, events } = createInMemoryWindowStore();
|
|
145
|
+
const base = Date.now();
|
|
146
|
+
await record({ store, occurredAt: at(base, -120) }); // 2h ago
|
|
147
|
+
await record({ store, occurredAt: at(base, 0) }); // now
|
|
148
|
+
await store.sweepExpired(at(base, -60)); // cutoff 1h ago
|
|
149
|
+
expect(events).toHaveLength(1);
|
|
150
|
+
expect(events[0]!.occurredAt.getTime()).toBe(at(base, 0).getTime());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("deleteForAutomation drops only that automation's rows", async () => {
|
|
154
|
+
const { store, events } = createInMemoryWindowStore();
|
|
155
|
+
const base = Date.now();
|
|
156
|
+
await record({ store, occurredAt: at(base, 0), automationId: "auto-1" });
|
|
157
|
+
await record({ store, occurredAt: at(base, 1), automationId: "auto-2" });
|
|
158
|
+
await store.deleteForAutomation("auto-1");
|
|
159
|
+
expect(events).toHaveLength(1);
|
|
160
|
+
expect(events[0]!.automationId).toBe("auto-2");
|
|
161
|
+
});
|
|
162
|
+
});
|