@oneuptime/common 11.5.11 → 11.5.12
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/Models/AnalyticsModels/NetworkFlow.ts +12 -1
- package/Server/API/StatusPageAPI.ts +14 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/IncidentService.ts +62 -67
- package/Server/Services/MonitorGroupService.ts +15 -2
- package/Server/Services/MonitorService.ts +91 -3
- package/Server/Services/MonitorStatusTimelineService.ts +285 -100
- package/Server/Services/StatusPageService.ts +59 -34
- package/Server/Utils/Monitor/MonitorResource.ts +43 -14
- package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -7
- package/Server/Utils/Monitor/MonitorStatusTimelineReconciler.ts +362 -0
- package/Tests/Server/Services/MonitorStatusTimelineService.test.ts +235 -0
- package/Tests/Server/Utils/Monitor/MonitorStatusTimelineReconciler.test.ts +555 -0
- package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +216 -0
- package/Tests/Types/Database/GreaterThan.test.ts +5 -1
- package/Tests/Types/Database/GreaterThanOrEqual.test.ts +5 -1
- package/Tests/Types/Database/GreaterThanOrNull.test.ts +24 -1
- package/Tests/Types/Database/LessThan.test.ts +5 -1
- package/Tests/Types/Database/LessThanOrEqual.test.ts +24 -1
- package/Tests/Types/Database/LessThanOrNull.test.ts +5 -1
- package/Tests/Utils/Uptime/UptimeUtil.test.ts +726 -0
- package/Types/BaseDatabase/GreaterThan.ts +8 -1
- package/Types/BaseDatabase/GreaterThanOrEqual.ts +8 -1
- package/Types/BaseDatabase/GreaterThanOrNull.ts +9 -1
- package/Types/BaseDatabase/LessThan.ts +8 -1
- package/Types/BaseDatabase/LessThanOrEqual.ts +9 -1
- package/Types/BaseDatabase/LessThanOrNull.ts +8 -1
- package/Utils/StatusPage/ResourceUptime.ts +11 -1
- package/Utils/Uptime/UptimeUtil.ts +142 -36
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js +12 -1
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +12 -2
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +47 -56
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/MonitorGroupService.js +15 -2
- package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +66 -3
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorStatusTimelineService.js +244 -102
- package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +57 -33
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +34 -12
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +25 -8
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js +268 -0
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimelineReconciler.js.map +1 -0
- package/build/dist/Types/BaseDatabase/GreaterThan.js +8 -1
- package/build/dist/Types/BaseDatabase/GreaterThan.js.map +1 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js +8 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrEqual.js.map +1 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js +9 -1
- package/build/dist/Types/BaseDatabase/GreaterThanOrNull.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThan.js +8 -1
- package/build/dist/Types/BaseDatabase/LessThan.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThanOrEqual.js +9 -1
- package/build/dist/Types/BaseDatabase/LessThanOrEqual.js.map +1 -1
- package/build/dist/Types/BaseDatabase/LessThanOrNull.js +8 -1
- package/build/dist/Types/BaseDatabase/LessThanOrNull.js.map +1 -1
- package/build/dist/Utils/StatusPage/ResourceUptime.js +4 -1
- package/build/dist/Utils/StatusPage/ResourceUptime.js.map +1 -1
- package/build/dist/Utils/Uptime/UptimeUtil.js +86 -33
- package/build/dist/Utils/Uptime/UptimeUtil.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.ts +0 -101
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js +0 -40
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js.map +0 -1
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The reconciler talks to Postgres directly, so these tests stand a small in-memory table in
|
|
5
|
+
* front of it. The fake implements the SEMANTICS the reconciler's SQL relies on - order by
|
|
6
|
+
* startsAt, soft-delete filtering, "stale" meaning a strictly later row exists, and endsAt =
|
|
7
|
+
* MIN(later startsAt) - and records every statement it is asked to run. That lets the tests
|
|
8
|
+
* pin down the orchestration the SQL alone cannot express: batching, round-robin fairness,
|
|
9
|
+
* termination, error isolation and idempotency.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface FakeRow {
|
|
13
|
+
_id: string;
|
|
14
|
+
monitorId: string;
|
|
15
|
+
startsAt: Date | null;
|
|
16
|
+
endsAt: Date | null;
|
|
17
|
+
deletedAt: Date | null;
|
|
18
|
+
version: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let table: Array<FakeRow> = [];
|
|
22
|
+
let queryLog: Array<{ sql: string; parameters: Array<string> }> = [];
|
|
23
|
+
let failForMonitorIds: Set<string> = new Set<string>();
|
|
24
|
+
|
|
25
|
+
function notDeleted(row: FakeRow): boolean {
|
|
26
|
+
return row.deletedAt === null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function nextStartsAtFor(row: FakeRow): Date | null {
|
|
30
|
+
if (row.startsAt === null) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const laterStartsAt: Array<Date> = table
|
|
35
|
+
.filter((candidate: FakeRow) => {
|
|
36
|
+
return (
|
|
37
|
+
candidate.monitorId === row.monitorId &&
|
|
38
|
+
notDeleted(candidate) &&
|
|
39
|
+
candidate.startsAt !== null &&
|
|
40
|
+
candidate.startsAt.getTime() > row.startsAt!.getTime()
|
|
41
|
+
);
|
|
42
|
+
})
|
|
43
|
+
.map((candidate: FakeRow) => {
|
|
44
|
+
return candidate.startsAt!;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (laterStartsAt.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return laterStartsAt.reduce((min: Date, current: Date) => {
|
|
52
|
+
return current.getTime() < min.getTime() ? current : min;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isStale(row: FakeRow): boolean {
|
|
57
|
+
return (
|
|
58
|
+
row.endsAt === null &&
|
|
59
|
+
notDeleted(row) &&
|
|
60
|
+
row.startsAt !== null &&
|
|
61
|
+
nextStartsAtFor(row) !== null
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function openRowsForMonitor(monitorId: string): Array<FakeRow> {
|
|
66
|
+
return table
|
|
67
|
+
.filter((row: FakeRow) => {
|
|
68
|
+
return (
|
|
69
|
+
row.monitorId === monitorId &&
|
|
70
|
+
row.endsAt === null &&
|
|
71
|
+
notDeleted(row) &&
|
|
72
|
+
row.startsAt !== null
|
|
73
|
+
);
|
|
74
|
+
})
|
|
75
|
+
.sort((a: FakeRow, b: FakeRow) => {
|
|
76
|
+
return a.startsAt!.getTime() - b.startsAt!.getTime();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fakeDataSource: { query: jest.Mock } = {
|
|
81
|
+
query: jest.fn(
|
|
82
|
+
async (sql: string, parameters?: Array<string>): Promise<Array<any>> => {
|
|
83
|
+
queryLog.push({ sql: sql, parameters: parameters || [] });
|
|
84
|
+
|
|
85
|
+
// Discovery: distinct monitors that have at least one stale open row.
|
|
86
|
+
if (sql.includes("SELECT DISTINCT")) {
|
|
87
|
+
const monitorIds: Array<string> = Array.from(
|
|
88
|
+
new Set<string>(
|
|
89
|
+
table.filter(isStale).map((row: FakeRow) => {
|
|
90
|
+
return row.monitorId;
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
).sort();
|
|
94
|
+
|
|
95
|
+
return monitorIds.map((monitorId: string) => {
|
|
96
|
+
return { monitorId: monitorId };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stale row count.
|
|
101
|
+
if (sql.includes('COUNT(*)::int AS "staleCount"')) {
|
|
102
|
+
const monitorId: string | undefined = parameters?.[0];
|
|
103
|
+
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
staleCount: table.filter((row: FakeRow) => {
|
|
107
|
+
return (
|
|
108
|
+
isStale(row) && (!monitorId || row.monitorId === monitorId)
|
|
109
|
+
);
|
|
110
|
+
}).length,
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Batched repair for a single monitor.
|
|
116
|
+
const monitorId: string = parameters![0]!;
|
|
117
|
+
const batchSize: number = Number(parameters![1]);
|
|
118
|
+
|
|
119
|
+
if (failForMonitorIds.has(monitorId)) {
|
|
120
|
+
throw new Error(`simulated database failure for ${monitorId}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const candidates: Array<FakeRow> = openRowsForMonitor(monitorId).slice(
|
|
124
|
+
0,
|
|
125
|
+
batchSize,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* Resolve every successor against the pre-update snapshot, exactly as the materialised
|
|
130
|
+
* CTE does, so a repair inside this batch cannot change another candidate's answer.
|
|
131
|
+
*/
|
|
132
|
+
const resolved: Array<{ row: FakeRow; nextStartsAt: Date | null }> =
|
|
133
|
+
candidates.map((row: FakeRow) => {
|
|
134
|
+
return { row: row, nextStartsAt: nextStartsAtFor(row) };
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let repaired: number = 0;
|
|
138
|
+
|
|
139
|
+
for (const candidate of resolved) {
|
|
140
|
+
if (candidate.nextStartsAt === null) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
candidate.row.endsAt = candidate.nextStartsAt;
|
|
145
|
+
candidate.row.version = candidate.row.version + 1;
|
|
146
|
+
repaired++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return [{ scanned: candidates.length, repaired: repaired }];
|
|
150
|
+
},
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
jest.mock("../../../../Server/Infrastructure/PostgresDatabase", () => {
|
|
155
|
+
return {
|
|
156
|
+
__esModule: true,
|
|
157
|
+
default: {
|
|
158
|
+
getDataSource: () => {
|
|
159
|
+
return fakeDataSource;
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
import MonitorStatusTimelineReconciler, {
|
|
166
|
+
RepairStaleOpenRowsResult,
|
|
167
|
+
} from "../../../../Server/Utils/Monitor/MonitorStatusTimelineReconciler";
|
|
168
|
+
|
|
169
|
+
const MONITOR_A: string = "11111111-1111-1111-1111-111111111111";
|
|
170
|
+
const MONITOR_B: string = "22222222-2222-2222-2222-222222222222";
|
|
171
|
+
|
|
172
|
+
let rowCounter: number = 0;
|
|
173
|
+
|
|
174
|
+
function addRow(data: {
|
|
175
|
+
monitorId: string;
|
|
176
|
+
startsAtMinutes: number | null;
|
|
177
|
+
endsAtMinutes?: number | null;
|
|
178
|
+
deleted?: boolean;
|
|
179
|
+
}): FakeRow {
|
|
180
|
+
rowCounter++;
|
|
181
|
+
|
|
182
|
+
const toDate: (minutes: number | null | undefined) => Date | null = (
|
|
183
|
+
minutes: number | null | undefined,
|
|
184
|
+
) => {
|
|
185
|
+
if (minutes === null || minutes === undefined) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
return new Date(Date.UTC(2026, 0, 1, 0, minutes, 0));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const row: FakeRow = {
|
|
192
|
+
_id: `row-${rowCounter}`,
|
|
193
|
+
monitorId: data.monitorId,
|
|
194
|
+
startsAt: toDate(data.startsAtMinutes),
|
|
195
|
+
endsAt: toDate(data.endsAtMinutes),
|
|
196
|
+
deletedAt: data.deleted ? new Date() : null,
|
|
197
|
+
version: 1,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
table.push(row);
|
|
201
|
+
|
|
202
|
+
return row;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function rowByStartsAtMinutes(
|
|
206
|
+
monitorId: string,
|
|
207
|
+
minutes: number,
|
|
208
|
+
): FakeRow | undefined {
|
|
209
|
+
return table.find((row: FakeRow) => {
|
|
210
|
+
return (
|
|
211
|
+
row.monitorId === monitorId &&
|
|
212
|
+
row.startsAt !== null &&
|
|
213
|
+
row.startsAt.getTime() === Date.UTC(2026, 0, 1, 0, minutes, 0)
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function endsAtMinutesOf(row: FakeRow | undefined): number | null {
|
|
219
|
+
if (!row || row.endsAt === null) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
// Minutes since the fixture epoch, so values past 59 do not wrap.
|
|
223
|
+
return (row.endsAt.getTime() - Date.UTC(2026, 0, 1, 0, 0, 0)) / 60000;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
describe("MonitorStatusTimelineReconciler.repairStaleOpenRows", () => {
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
table = [];
|
|
229
|
+
queryLog = [];
|
|
230
|
+
failForMonitorIds = new Set<string>();
|
|
231
|
+
rowCounter = 0;
|
|
232
|
+
fakeDataSource.query.mockClear();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("closes an orphaned open row at the next row's startsAt, not at the newest row's startsAt", async () => {
|
|
236
|
+
/*
|
|
237
|
+
* The production shape: two rows written milliseconds apart, only the later one closed,
|
|
238
|
+
* then months of further activity. Closing the orphan at the newest row's startsAt would
|
|
239
|
+
* turn a 1 minute gap into a multi-row-long downtime, which is the failure mode this
|
|
240
|
+
* whole change exists to avoid.
|
|
241
|
+
*/
|
|
242
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null }); // orphan
|
|
243
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 11, endsAtMinutes: 20 });
|
|
244
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 20, endsAtMinutes: 90 });
|
|
245
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null }); // current state
|
|
246
|
+
|
|
247
|
+
const result: RepairStaleOpenRowsResult =
|
|
248
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
249
|
+
|
|
250
|
+
expect(result.repaired).toBe(1);
|
|
251
|
+
expect(result.monitorsWithStaleRows).toBe(1);
|
|
252
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_A, 10))).toBe(11);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("leaves the single latest open row on a monitor open", async () => {
|
|
256
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
257
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
258
|
+
|
|
259
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
260
|
+
|
|
261
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_A, 10))).toBe(90);
|
|
262
|
+
expect(rowByStartsAtMinutes(MONITOR_A, 90)!.endsAt).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does nothing to a monitor whose only open row is the newest row", async () => {
|
|
266
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: 90 });
|
|
267
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
268
|
+
|
|
269
|
+
const result: RepairStaleOpenRowsResult =
|
|
270
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
271
|
+
|
|
272
|
+
expect(result.monitorsWithStaleRows).toBe(0);
|
|
273
|
+
expect(result.repaired).toBe(0);
|
|
274
|
+
expect(result.scanned).toBe(0);
|
|
275
|
+
// Discovery found nothing, so no repair statement should have been issued at all.
|
|
276
|
+
expect(queryLog).toHaveLength(1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("is idempotent - a second run finds nothing to repair", async () => {
|
|
280
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
281
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 11, endsAtMinutes: null });
|
|
282
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
283
|
+
|
|
284
|
+
const firstRun: RepairStaleOpenRowsResult =
|
|
285
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
286
|
+
expect(firstRun.repaired).toBe(2);
|
|
287
|
+
|
|
288
|
+
const secondRun: RepairStaleOpenRowsResult =
|
|
289
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
290
|
+
|
|
291
|
+
expect(secondRun.repaired).toBe(0);
|
|
292
|
+
expect(secondRun.monitorsWithStaleRows).toBe(0);
|
|
293
|
+
expect(secondRun.repairedMonitorIds).toEqual([]);
|
|
294
|
+
expect(await MonitorStatusTimelineReconciler.countStaleOpenRows()).toBe(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("clears a backlog larger than the batch size without loading it in one go", async () => {
|
|
298
|
+
for (let minute: number = 1; minute <= 25; minute++) {
|
|
299
|
+
addRow({
|
|
300
|
+
monitorId: MONITOR_A,
|
|
301
|
+
startsAtMinutes: minute,
|
|
302
|
+
endsAtMinutes: null,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const result: RepairStaleOpenRowsResult =
|
|
307
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({
|
|
308
|
+
batchSize: 5,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// 24 stale rows repaired, the newest row stays open.
|
|
312
|
+
expect(result.repaired).toBe(24);
|
|
313
|
+
expect(rowByStartsAtMinutes(MONITOR_A, 25)!.endsAt).toBeNull();
|
|
314
|
+
expect(await MonitorStatusTimelineReconciler.countStaleOpenRows()).toBe(0);
|
|
315
|
+
|
|
316
|
+
// Each closed row ends exactly where the next one begins - no gaps, no overlaps.
|
|
317
|
+
for (let minute: number = 1; minute <= 24; minute++) {
|
|
318
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_A, minute))).toBe(
|
|
319
|
+
minute + 1,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const repairStatements: Array<{ parameters: Array<string> }> =
|
|
324
|
+
queryLog.filter((entry: { sql: string }) => {
|
|
325
|
+
return entry.sql.includes("WITH candidates");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// No statement ever asked for more than the batch size.
|
|
329
|
+
for (const statement of repairStatements) {
|
|
330
|
+
expect(Number(statement.parameters[1])).toBe(5);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("interleaves monitors so a large backlog cannot starve a small one", async () => {
|
|
335
|
+
// Monitor A has a large backlog, monitor B has a single stale row.
|
|
336
|
+
for (let minute: number = 1; minute <= 30; minute++) {
|
|
337
|
+
addRow({
|
|
338
|
+
monitorId: MONITOR_A,
|
|
339
|
+
startsAtMinutes: minute,
|
|
340
|
+
endsAtMinutes: null,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
addRow({ monitorId: MONITOR_B, startsAtMinutes: 10, endsAtMinutes: null });
|
|
345
|
+
addRow({ monitorId: MONITOR_B, startsAtMinutes: 90, endsAtMinutes: null });
|
|
346
|
+
|
|
347
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({ batchSize: 5 });
|
|
348
|
+
|
|
349
|
+
const repairedMonitorOrder: Array<string> = queryLog
|
|
350
|
+
.filter((entry: { sql: string }) => {
|
|
351
|
+
return entry.sql.includes("WITH candidates");
|
|
352
|
+
})
|
|
353
|
+
.map((entry: { parameters: Array<string> }) => {
|
|
354
|
+
return entry.parameters[0]!;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
/*
|
|
358
|
+
* Round-robin: monitor B is served in the very first round rather than queued behind all
|
|
359
|
+
* of monitor A's batches.
|
|
360
|
+
*/
|
|
361
|
+
expect(repairedMonitorOrder[0]).toBe(MONITOR_A);
|
|
362
|
+
expect(repairedMonitorOrder[1]).toBe(MONITOR_B);
|
|
363
|
+
// And B is finished after that one batch, so it never appears again.
|
|
364
|
+
expect(repairedMonitorOrder.slice(2)).not.toContain(MONITOR_B);
|
|
365
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_B, 10))).toBe(90);
|
|
366
|
+
expect(await MonitorStatusTimelineReconciler.countStaleOpenRows()).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("keeps going when one monitor fails, and reports it", async () => {
|
|
370
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
371
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
372
|
+
addRow({ monitorId: MONITOR_B, startsAtMinutes: 10, endsAtMinutes: null });
|
|
373
|
+
addRow({ monitorId: MONITOR_B, startsAtMinutes: 90, endsAtMinutes: null });
|
|
374
|
+
|
|
375
|
+
failForMonitorIds.add(MONITOR_A);
|
|
376
|
+
|
|
377
|
+
const result: RepairStaleOpenRowsResult =
|
|
378
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
379
|
+
|
|
380
|
+
expect(result.failedMonitorIds.map(String)).toEqual([MONITOR_A]);
|
|
381
|
+
expect(result.repairedMonitorIds.map(String)).toEqual([MONITOR_B]);
|
|
382
|
+
expect(result.repaired).toBe(1);
|
|
383
|
+
// The healthy monitor was still repaired.
|
|
384
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_B, 10))).toBe(90);
|
|
385
|
+
// The failing monitor was left untouched rather than half repaired.
|
|
386
|
+
expect(rowByStartsAtMinutes(MONITOR_A, 10)!.endsAt).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("does not retry a monitor that fails deterministically", async () => {
|
|
390
|
+
for (let minute: number = 1; minute <= 30; minute++) {
|
|
391
|
+
addRow({
|
|
392
|
+
monitorId: MONITOR_A,
|
|
393
|
+
startsAtMinutes: minute,
|
|
394
|
+
endsAtMinutes: null,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
failForMonitorIds.add(MONITOR_A);
|
|
399
|
+
|
|
400
|
+
const result: RepairStaleOpenRowsResult =
|
|
401
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({
|
|
402
|
+
batchSize: 5,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(result.failedMonitorIds.map(String)).toEqual([MONITOR_A]);
|
|
406
|
+
|
|
407
|
+
const repairAttempts: number = queryLog.filter((entry: { sql: string }) => {
|
|
408
|
+
return entry.sql.includes("WITH candidates");
|
|
409
|
+
}).length;
|
|
410
|
+
|
|
411
|
+
expect(repairAttempts).toBe(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("ignores soft deleted rows on both sides of the repair", async () => {
|
|
415
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
416
|
+
// A soft deleted successor must not be used as the endsAt value...
|
|
417
|
+
addRow({
|
|
418
|
+
monitorId: MONITOR_A,
|
|
419
|
+
startsAtMinutes: 20,
|
|
420
|
+
endsAtMinutes: null,
|
|
421
|
+
deleted: true,
|
|
422
|
+
});
|
|
423
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 30, endsAtMinutes: null });
|
|
424
|
+
// ...and a soft deleted open row must not itself be repaired.
|
|
425
|
+
const deletedOrphan: FakeRow = addRow({
|
|
426
|
+
monitorId: MONITOR_B,
|
|
427
|
+
startsAtMinutes: 10,
|
|
428
|
+
endsAtMinutes: null,
|
|
429
|
+
deleted: true,
|
|
430
|
+
});
|
|
431
|
+
addRow({ monitorId: MONITOR_B, startsAtMinutes: 90, endsAtMinutes: null });
|
|
432
|
+
|
|
433
|
+
const result: RepairStaleOpenRowsResult =
|
|
434
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
435
|
+
|
|
436
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_A, 10))).toBe(30);
|
|
437
|
+
expect(deletedOrphan.endsAt).toBeNull();
|
|
438
|
+
expect(result.repairedMonitorIds.map(String)).toEqual([MONITOR_A]);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("leaves rows tied on the newest startsAt open rather than closing them at zero length", async () => {
|
|
442
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
443
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
444
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
445
|
+
|
|
446
|
+
const result: RepairStaleOpenRowsResult =
|
|
447
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
448
|
+
|
|
449
|
+
expect(result.repaired).toBe(1);
|
|
450
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_A, 10))).toBe(90);
|
|
451
|
+
|
|
452
|
+
// The tied pair stays open - and the run still terminates.
|
|
453
|
+
const tied: Array<FakeRow> = table.filter((row: FakeRow) => {
|
|
454
|
+
return (
|
|
455
|
+
row.startsAt !== null &&
|
|
456
|
+
row.startsAt.getTime() === Date.UTC(2026, 0, 1, 0, 90, 0)
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
expect(tied).toHaveLength(2);
|
|
460
|
+
for (const row of tied) {
|
|
461
|
+
expect(row.endsAt).toBeNull();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("skips rows with no startsAt instead of guessing", async () => {
|
|
466
|
+
addRow({
|
|
467
|
+
monitorId: MONITOR_A,
|
|
468
|
+
startsAtMinutes: null,
|
|
469
|
+
endsAtMinutes: null,
|
|
470
|
+
});
|
|
471
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
472
|
+
|
|
473
|
+
const result: RepairStaleOpenRowsResult =
|
|
474
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
475
|
+
|
|
476
|
+
expect(result.repaired).toBe(0);
|
|
477
|
+
expect(result.monitorsWithStaleRows).toBe(0);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("scopes the run to a single monitor when one is given, skipping discovery", async () => {
|
|
481
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
482
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
483
|
+
addRow({ monitorId: MONITOR_B, startsAtMinutes: 10, endsAtMinutes: null });
|
|
484
|
+
addRow({ monitorId: MONITOR_B, startsAtMinutes: 90, endsAtMinutes: null });
|
|
485
|
+
|
|
486
|
+
const result: RepairStaleOpenRowsResult =
|
|
487
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({
|
|
488
|
+
monitorId: new ObjectID(MONITOR_B),
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
expect(result.repaired).toBe(1);
|
|
492
|
+
expect(endsAtMinutesOf(rowByStartsAtMinutes(MONITOR_B, 10))).toBe(90);
|
|
493
|
+
// Monitor A is untouched.
|
|
494
|
+
expect(rowByStartsAtMinutes(MONITOR_A, 10)!.endsAt).toBeNull();
|
|
495
|
+
|
|
496
|
+
expect(
|
|
497
|
+
queryLog.some((entry: { sql: string }) => {
|
|
498
|
+
return entry.sql.includes("SELECT DISTINCT");
|
|
499
|
+
}),
|
|
500
|
+
).toBe(false);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("falls back to the default batch size for absent or nonsensical values", async () => {
|
|
504
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
505
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
506
|
+
|
|
507
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({ batchSize: 0 });
|
|
508
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({
|
|
509
|
+
batchSize: -5,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const batchSizes: Array<number> = queryLog
|
|
513
|
+
.filter((entry: { sql: string }) => {
|
|
514
|
+
return entry.sql.includes("WITH candidates");
|
|
515
|
+
})
|
|
516
|
+
.map((entry: { parameters: Array<string> }) => {
|
|
517
|
+
return Number(entry.parameters[1]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(batchSizes.length).toBeGreaterThan(0);
|
|
521
|
+
for (const batchSize of batchSizes) {
|
|
522
|
+
expect(batchSize).toBe(1000);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("caps an oversized batch size", async () => {
|
|
527
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
528
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
529
|
+
|
|
530
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({
|
|
531
|
+
batchSize: 5000000,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const batchSizes: Array<number> = queryLog
|
|
535
|
+
.filter((entry: { sql: string }) => {
|
|
536
|
+
return entry.sql.includes("WITH candidates");
|
|
537
|
+
})
|
|
538
|
+
.map((entry: { parameters: Array<string> }) => {
|
|
539
|
+
return Number(entry.parameters[1]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(batchSizes).toEqual([10000]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("never orders by createdAt", async () => {
|
|
546
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 10, endsAtMinutes: null });
|
|
547
|
+
addRow({ monitorId: MONITOR_A, startsAtMinutes: 90, endsAtMinutes: null });
|
|
548
|
+
|
|
549
|
+
await MonitorStatusTimelineReconciler.repairStaleOpenRows({});
|
|
550
|
+
|
|
551
|
+
for (const entry of queryLog) {
|
|
552
|
+
expect(entry.sql).not.toContain("createdAt");
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
});
|