@oneuptime/common 11.5.10 → 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/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/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 +3 -2
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import MonitorProbeService from "../../Services/MonitorProbeService";
|
|
2
2
|
import MonitorService from "../../Services/MonitorService";
|
|
3
|
-
import MonitorStatusTimelineService
|
|
3
|
+
import MonitorStatusTimelineService, {
|
|
4
|
+
MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE,
|
|
5
|
+
MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE,
|
|
6
|
+
} from "../../Services/MonitorStatusTimelineService";
|
|
7
|
+
import ServerException from "../../../Types/Exception/ServerException";
|
|
4
8
|
import logger from "../Logger";
|
|
5
9
|
import MonitorCriteriaEvaluator from "./MonitorCriteriaEvaluator";
|
|
6
10
|
import MonitorLogUtil from "./MonitorLogUtil";
|
|
@@ -1013,6 +1017,15 @@ export default class MonitorResourceUtil {
|
|
|
1013
1017
|
monitorStatusTimeline.rootCause =
|
|
1014
1018
|
"No monitoring criteria met. Change to default status. ";
|
|
1015
1019
|
|
|
1020
|
+
/*
|
|
1021
|
+
* Tracks whether the monitor really is at its default status after the
|
|
1022
|
+
* create call, so the summary event below records only what actually
|
|
1023
|
+
* happened. On the lock-skip path the write was REFUSED - recording a
|
|
1024
|
+
* "reverted" event there would put a false entry in the monitor log and
|
|
1025
|
+
* mask the very lock failure the error log surfaces.
|
|
1026
|
+
*/
|
|
1027
|
+
let revertedToDefaultStatus: boolean = true;
|
|
1028
|
+
|
|
1016
1029
|
try {
|
|
1017
1030
|
await MonitorStatusTimelineService.create({
|
|
1018
1031
|
data: monitorStatusTimeline,
|
|
@@ -1033,29 +1046,45 @@ export default class MonitorResourceUtil {
|
|
|
1033
1046
|
*/
|
|
1034
1047
|
if (
|
|
1035
1048
|
err instanceof BadDataException &&
|
|
1036
|
-
err.message ===
|
|
1037
|
-
"Monitor Status cannot be same as previous status."
|
|
1049
|
+
err.message === MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE
|
|
1038
1050
|
) {
|
|
1039
1051
|
logger.debug(
|
|
1040
1052
|
`${dataToProcess.monitorId.toString()} - Monitor status already at default; skipping duplicate status timeline (concurrent race).`,
|
|
1041
1053
|
);
|
|
1054
|
+
} else if (
|
|
1055
|
+
err instanceof ServerException &&
|
|
1056
|
+
err.message === MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE
|
|
1057
|
+
) {
|
|
1058
|
+
/*
|
|
1059
|
+
* The per-monitor timeline mutex could not be acquired (fail-closed
|
|
1060
|
+
* create, see MonitorStatusTimelineService). Skipping is recoverable:
|
|
1061
|
+
* the next probe result re-evaluates the same criteria and recreates
|
|
1062
|
+
* the revert-to-default, while failing here would abort the whole
|
|
1063
|
+
* ingest run and lose the monitor log for this probe result.
|
|
1064
|
+
*/
|
|
1065
|
+
logger.error(
|
|
1066
|
+
`${dataToProcess.monitorId.toString()} - Could not acquire the monitor status timeline lock; skipping revert to default status. It will be retried on the next probe result.`,
|
|
1067
|
+
);
|
|
1068
|
+
revertedToDefaultStatus = false;
|
|
1042
1069
|
} else {
|
|
1043
1070
|
throw err;
|
|
1044
1071
|
}
|
|
1045
1072
|
}
|
|
1046
1073
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1074
|
+
if (revertedToDefaultStatus) {
|
|
1075
|
+
const defaultStatusName: string | null = await getMonitorStatusName(
|
|
1076
|
+
monitorSteps.data.defaultMonitorStatusId,
|
|
1077
|
+
);
|
|
1050
1078
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1079
|
+
evaluationSummary.events.push({
|
|
1080
|
+
type: "monitor-status-changed",
|
|
1081
|
+
title: "Monitor status reverted",
|
|
1082
|
+
message: defaultStatusName
|
|
1083
|
+
? `Monitor status reverted to "${defaultStatusName}" because no monitoring criteria were met.`
|
|
1084
|
+
: "Monitor status reverted to its default state because no monitoring criteria were met.",
|
|
1085
|
+
at: OneUptimeDate.getCurrentDate(),
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1059
1088
|
}
|
|
1060
1089
|
}
|
|
1061
1090
|
|
|
@@ -5,7 +5,11 @@ import BadDataException from "../../../Types/Exception/BadDataException";
|
|
|
5
5
|
import MonitorCriteriaInstance from "../../../Types/Monitor/MonitorCriteriaInstance";
|
|
6
6
|
import ObjectID from "../../../Types/ObjectID";
|
|
7
7
|
import { TelemetryQuery } from "../../../Types/Telemetry/TelemetryQuery";
|
|
8
|
-
import MonitorStatusTimelineService
|
|
8
|
+
import MonitorStatusTimelineService, {
|
|
9
|
+
MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE,
|
|
10
|
+
MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE,
|
|
11
|
+
} from "../../Services/MonitorStatusTimelineService";
|
|
12
|
+
import ServerException from "../../../Types/Exception/ServerException";
|
|
9
13
|
import logger, { LogAttributes } from "../Logger";
|
|
10
14
|
import CaptureSpan from "../Telemetry/CaptureSpan";
|
|
11
15
|
import DataToProcess from "./DataToProcess";
|
|
@@ -124,19 +128,19 @@ export default class MonitorStatusTimelineUtil {
|
|
|
124
128
|
} catch (err) {
|
|
125
129
|
/*
|
|
126
130
|
* Concurrency race: two probe/ingest results for the same monitor can be
|
|
127
|
-
* processed near-simultaneously
|
|
128
|
-
*
|
|
129
|
-
* time out under load and fall through unlocked). Both see the same prior
|
|
130
|
-
* status and both try to write the same new status row. The
|
|
131
|
+
* processed near-simultaneously and both see the same prior status, so both
|
|
132
|
+
* try to write the same new status row. The
|
|
131
133
|
* MonitorStatusTimelineService.onBeforeCreate dedupe check then throws this
|
|
132
134
|
* exact BadDataException for the loser of the race. This is an idempotent
|
|
133
135
|
* no-op (the desired status is already the current status), so swallow it at
|
|
134
|
-
* debug level instead of failing the job and logging a full ERROR stack.
|
|
136
|
+
* debug level instead of failing the job and logging a full ERROR stack. The
|
|
137
|
+
* race itself is now logged at warn by onBeforeCreate, which is the
|
|
138
|
+
* authoritative telemetry for it - this log only records that we skipped.
|
|
135
139
|
* Match the exact message so unrelated BadDataExceptions still propagate.
|
|
136
140
|
*/
|
|
137
141
|
if (
|
|
138
142
|
err instanceof BadDataException &&
|
|
139
|
-
err.message ===
|
|
143
|
+
err.message === MONITOR_STATUS_SAME_AS_PREVIOUS_ERROR_MESSAGE
|
|
140
144
|
) {
|
|
141
145
|
logger.debug(
|
|
142
146
|
`${input.monitor.id?.toString()} - Monitor status already equals desired status; skipping duplicate status timeline (concurrent race).`,
|
|
@@ -144,6 +148,27 @@ export default class MonitorStatusTimelineUtil {
|
|
|
144
148
|
return null;
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
/*
|
|
152
|
+
* The per-monitor mutex in MonitorStatusTimelineService.create() is
|
|
153
|
+
* fail-closed: if Redis is unavailable or the lock cannot be acquired within
|
|
154
|
+
* the acquire timeout, the create is refused rather than performed unlocked.
|
|
155
|
+
* Writing unlocked is what produced permanently orphaned (endsAt = NULL)
|
|
156
|
+
* timeline rows, which read back as unbounded downtime on status pages and
|
|
157
|
+
* uptime reports - far worse than a missed status transition. Skipping is
|
|
158
|
+
* recoverable: the next probe result for this monitor evaluates the same
|
|
159
|
+
* criteria and creates the same status change. So log and skip rather than
|
|
160
|
+
* failing the whole probe ingest run for this monitor.
|
|
161
|
+
*/
|
|
162
|
+
if (
|
|
163
|
+
err instanceof ServerException &&
|
|
164
|
+
err.message === MONITOR_STATUS_TIMELINE_LOCK_ERROR_MESSAGE
|
|
165
|
+
) {
|
|
166
|
+
logger.error(
|
|
167
|
+
`${input.monitor.id?.toString()} - Could not acquire the monitor status timeline lock; skipping this status change. It will be retried on the next probe result.`,
|
|
168
|
+
);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
throw err;
|
|
148
173
|
}
|
|
149
174
|
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import PostgresAppInstance, {
|
|
2
|
+
DatabaseSource,
|
|
3
|
+
} from "../../Infrastructure/PostgresDatabase";
|
|
4
|
+
import DatabaseNotConnectedException from "../../../Types/Exception/DatabaseNotConnectedException";
|
|
5
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
6
|
+
import logger from "../Logger";
|
|
7
|
+
import CaptureSpan from "../Telemetry/CaptureSpan";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* A "stale open row" is a MonitorStatusTimeline row with endsAt IS NULL for which a row
|
|
11
|
+
* with a strictly later startsAt exists on the same monitor.
|
|
12
|
+
*
|
|
13
|
+
* These are produced by a write-path race: two probe results for the same monitor can be
|
|
14
|
+
* processed near-simultaneously, both resolve the same predecessor row, and both INSERT a
|
|
15
|
+
* new status row milliseconds apart. Only the later one is ever closed, because every
|
|
16
|
+
* subsequent writer resolves its predecessor with ORDER BY startsAt DESC LIMIT 1 and so
|
|
17
|
+
* permanently shadows the earlier one. The earlier row keeps endsAt = NULL forever.
|
|
18
|
+
*
|
|
19
|
+
* An open row with no successor is the monitor's legitimate current-state row and MUST be
|
|
20
|
+
* left open. Only rows that have been superseded are repaired here.
|
|
21
|
+
*
|
|
22
|
+
* The repaired endsAt is the MIN startsAt among strictly later rows on the same monitor -
|
|
23
|
+
* i.e. the moment the row was actually superseded. It is deliberately NOT "now" and NOT the
|
|
24
|
+
* startsAt of the newest row: closing an orphan at the newest row's startsAt would convert a
|
|
25
|
+
* near-zero-duration orphan into a multi-month closed downtime row, which is unrecoverable
|
|
26
|
+
* and strictly worse than leaving it open.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface RepairStaleOpenRowsOptions {
|
|
30
|
+
// Maximum number of open rows examined per monitor per round trip.
|
|
31
|
+
batchSize?: number | undefined;
|
|
32
|
+
// When set, only this monitor is reconciled. When unset, every affected monitor is.
|
|
33
|
+
monitorId?: ObjectID | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RepairStaleOpenRowsResult {
|
|
37
|
+
// Open rows examined across every batch of this run.
|
|
38
|
+
scanned: number;
|
|
39
|
+
// Open rows that were given an endsAt by this run.
|
|
40
|
+
repaired: number;
|
|
41
|
+
// Monitors that had at least one stale open row when the run started.
|
|
42
|
+
monitorsWithStaleRows: number;
|
|
43
|
+
// Monitors that had at least one row repaired. Callers may want to refresh their current status.
|
|
44
|
+
repairedMonitorIds: Array<ObjectID>;
|
|
45
|
+
// Monitors whose repair threw. They are skipped, not retried, so one failure cannot abort a run.
|
|
46
|
+
failedMonitorIds: Array<ObjectID>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RepairBatchResult {
|
|
50
|
+
scanned: number;
|
|
51
|
+
repaired: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Bounded so a single run never loads a pathological monitor's whole backlog into memory.
|
|
56
|
+
* Production currently has ~67.5k stale rows, ~56k of them on ONE monitor.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_STALE_OPEN_ROW_BATCH_SIZE: number = 1000;
|
|
59
|
+
|
|
60
|
+
const MIN_BATCH_SIZE: number = 1;
|
|
61
|
+
const MAX_BATCH_SIZE: number = 10000;
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* Pure runaway guard. Every non-terminal round repairs exactly batchSize rows, so the loop
|
|
65
|
+
* is already guaranteed to terminate; this only bounds a run if that invariant is ever broken.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_ROUNDS: number = 100000;
|
|
68
|
+
|
|
69
|
+
export default class MonitorStatusTimelineReconciler {
|
|
70
|
+
/*
|
|
71
|
+
* Finds and repairs stale open rows. Idempotent: a second run over the same data repairs
|
|
72
|
+
* nothing, because a repaired row no longer matches endsAt IS NULL.
|
|
73
|
+
*
|
|
74
|
+
* Monitors are processed round-robin, one batch each per round, so a monitor with tens of
|
|
75
|
+
* thousands of stale rows cannot starve the rest of the fleet.
|
|
76
|
+
*/
|
|
77
|
+
@CaptureSpan()
|
|
78
|
+
public static async repairStaleOpenRows(
|
|
79
|
+
options: RepairStaleOpenRowsOptions = {},
|
|
80
|
+
): Promise<RepairStaleOpenRowsResult> {
|
|
81
|
+
const batchSize: number = this.getBatchSize(options.batchSize);
|
|
82
|
+
|
|
83
|
+
const monitorIds: Array<ObjectID> = options.monitorId
|
|
84
|
+
? [options.monitorId]
|
|
85
|
+
: await this.getMonitorIdsWithStaleOpenRows();
|
|
86
|
+
|
|
87
|
+
const result: RepairStaleOpenRowsResult = {
|
|
88
|
+
scanned: 0,
|
|
89
|
+
repaired: 0,
|
|
90
|
+
monitorsWithStaleRows: monitorIds.length,
|
|
91
|
+
repairedMonitorIds: [],
|
|
92
|
+
failedMonitorIds: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (monitorIds.length === 0) {
|
|
96
|
+
logger.debug(
|
|
97
|
+
"MonitorStatusTimelineReconciler: no monitors have stale open status timeline rows.",
|
|
98
|
+
);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logger.debug(
|
|
103
|
+
`MonitorStatusTimelineReconciler: ${monitorIds.length} monitor(s) have stale open status timeline rows. Batch size ${batchSize}.`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const repairedMonitorIds: Set<string> = new Set<string>();
|
|
107
|
+
const failedMonitorIds: Set<string> = new Set<string>();
|
|
108
|
+
|
|
109
|
+
let pendingMonitorIds: Array<ObjectID> = monitorIds;
|
|
110
|
+
let round: number = 0;
|
|
111
|
+
|
|
112
|
+
while (pendingMonitorIds.length > 0) {
|
|
113
|
+
round++;
|
|
114
|
+
|
|
115
|
+
if (round > MAX_ROUNDS) {
|
|
116
|
+
logger.error(
|
|
117
|
+
`MonitorStatusTimelineReconciler: aborting after ${MAX_ROUNDS} rounds with ${pendingMonitorIds.length} monitor(s) still pending. This should be unreachable.`,
|
|
118
|
+
);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stillPendingMonitorIds: Array<ObjectID> = [];
|
|
123
|
+
|
|
124
|
+
for (const monitorId of pendingMonitorIds) {
|
|
125
|
+
/*
|
|
126
|
+
* Per-monitor try/catch, matching the sibling KeepCurrentStateConsistent jobs: one bad
|
|
127
|
+
* monitor is skipped for the remainder of the run instead of aborting it. It is not
|
|
128
|
+
* re-queued, so a monitor that fails deterministically cannot spin forever.
|
|
129
|
+
*/
|
|
130
|
+
try {
|
|
131
|
+
const batch: RepairBatchResult = await this.repairStaleOpenRowsBatch({
|
|
132
|
+
monitorId: monitorId,
|
|
133
|
+
batchSize: batchSize,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
result.scanned += batch.scanned;
|
|
137
|
+
result.repaired += batch.repaired;
|
|
138
|
+
|
|
139
|
+
if (batch.repaired > 0) {
|
|
140
|
+
repairedMonitorIds.add(monitorId.toString());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* A full batch of repairs means there may be more stale rows on this monitor. Anything
|
|
145
|
+
* short of a full batch means every open row on this monitor has now been examined -
|
|
146
|
+
* whatever is still open has no strictly later row and is therefore legitimate.
|
|
147
|
+
*/
|
|
148
|
+
if (batch.repaired >= batchSize) {
|
|
149
|
+
stillPendingMonitorIds.push(monitorId);
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
failedMonitorIds.add(monitorId.toString());
|
|
153
|
+
logger.error(
|
|
154
|
+
`MonitorStatusTimelineReconciler: failed to repair stale open rows for monitor ${monitorId.toString()}.`,
|
|
155
|
+
);
|
|
156
|
+
logger.error(err);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pendingMonitorIds = stillPendingMonitorIds;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
result.repairedMonitorIds = Array.from(repairedMonitorIds).map(
|
|
165
|
+
(id: string) => {
|
|
166
|
+
return new ObjectID(id);
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
result.failedMonitorIds = Array.from(failedMonitorIds).map((id: string) => {
|
|
171
|
+
return new ObjectID(id);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/*
|
|
178
|
+
* Counts stale open rows without repairing anything. Useful for telemetry and for asserting
|
|
179
|
+
* that a repair run actually converged.
|
|
180
|
+
*/
|
|
181
|
+
@CaptureSpan()
|
|
182
|
+
public static async countStaleOpenRows(
|
|
183
|
+
options: { monitorId?: ObjectID | undefined } = {},
|
|
184
|
+
): Promise<number> {
|
|
185
|
+
const dataSource: DatabaseSource = this.getDataSource();
|
|
186
|
+
|
|
187
|
+
const parameters: Array<string> = [];
|
|
188
|
+
let monitorFilter: string = "";
|
|
189
|
+
|
|
190
|
+
if (options.monitorId) {
|
|
191
|
+
monitorFilter = `AND t."monitorId" = $1::uuid`;
|
|
192
|
+
parameters.push(options.monitorId.toString());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const sql: string = `
|
|
196
|
+
SELECT COUNT(*)::int AS "staleCount"
|
|
197
|
+
FROM "MonitorStatusTimeline" t
|
|
198
|
+
WHERE t."endsAt" IS NULL
|
|
199
|
+
AND t."deletedAt" IS NULL
|
|
200
|
+
AND t."startsAt" IS NOT NULL
|
|
201
|
+
${monitorFilter}
|
|
202
|
+
AND EXISTS (
|
|
203
|
+
SELECT 1
|
|
204
|
+
FROM "MonitorStatusTimeline" n
|
|
205
|
+
WHERE n."monitorId" = t."monitorId"
|
|
206
|
+
AND n."deletedAt" IS NULL
|
|
207
|
+
AND n."startsAt" > t."startsAt"
|
|
208
|
+
)
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
const rows: Array<{ staleCount: number }> = await dataSource.query(
|
|
212
|
+
sql,
|
|
213
|
+
parameters,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return rows[0]?.staleCount || 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* The set of monitors that currently have at least one stale open row.
|
|
221
|
+
*
|
|
222
|
+
* Note this is deliberately NOT "monitors with more than one open row". A monitor can have
|
|
223
|
+
* exactly one open row that is still stale, if the newest row on the monitor happens to be
|
|
224
|
+
* closed. Encoding the definition directly keeps both cases covered.
|
|
225
|
+
*/
|
|
226
|
+
private static async getMonitorIdsWithStaleOpenRows(): Promise<
|
|
227
|
+
Array<ObjectID>
|
|
228
|
+
> {
|
|
229
|
+
const dataSource: DatabaseSource = this.getDataSource();
|
|
230
|
+
|
|
231
|
+
const sql: string = `
|
|
232
|
+
SELECT DISTINCT t."monitorId" AS "monitorId"
|
|
233
|
+
FROM "MonitorStatusTimeline" t
|
|
234
|
+
WHERE t."endsAt" IS NULL
|
|
235
|
+
AND t."deletedAt" IS NULL
|
|
236
|
+
AND t."startsAt" IS NOT NULL
|
|
237
|
+
AND EXISTS (
|
|
238
|
+
SELECT 1
|
|
239
|
+
FROM "MonitorStatusTimeline" n
|
|
240
|
+
WHERE n."monitorId" = t."monitorId"
|
|
241
|
+
AND n."deletedAt" IS NULL
|
|
242
|
+
AND n."startsAt" > t."startsAt"
|
|
243
|
+
)
|
|
244
|
+
ORDER BY t."monitorId" ASC
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
const rows: Array<{ monitorId: string }> = await dataSource.query(sql);
|
|
248
|
+
|
|
249
|
+
return rows.map((row: { monitorId: string }) => {
|
|
250
|
+
return new ObjectID(row.monitorId);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/*
|
|
255
|
+
* Repairs up to batchSize stale open rows on a single monitor, oldest first.
|
|
256
|
+
*
|
|
257
|
+
* Ordering is by startsAt, NEVER createdAt. createdAt is assigned DB-side by now() while
|
|
258
|
+
* startsAt is assigned by the worker pod's clock, so the two orderings genuinely disagree on
|
|
259
|
+
* real data. Two earlier migrations ordered by createdAt and produced wrong results.
|
|
260
|
+
*/
|
|
261
|
+
private static async repairStaleOpenRowsBatch(data: {
|
|
262
|
+
monitorId: ObjectID;
|
|
263
|
+
batchSize: number;
|
|
264
|
+
}): Promise<RepairBatchResult> {
|
|
265
|
+
const dataSource: DatabaseSource = this.getDataSource();
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
* candidates is materialised once (a data-modifying CTE forces materialisation), so the
|
|
269
|
+
* counts below and the UPDATE all see the same snapshot and one round trip yields both the
|
|
270
|
+
* scanned and the repaired count.
|
|
271
|
+
*
|
|
272
|
+
* nextStartsAt is NULL for an open row with no strictly later row. Those rows are the
|
|
273
|
+
* legitimate current-state row (or a startsAt tie at the head of the timeline) and are
|
|
274
|
+
* excluded from the UPDATE, so they stay open.
|
|
275
|
+
*
|
|
276
|
+
* The UPDATE re-checks m."endsAt" IS NULL even though candidates already filtered on it:
|
|
277
|
+
* the reconciler runs WITHOUT the per-monitor mutex, and under READ COMMITTED a live
|
|
278
|
+
* writer can close a candidate row between this statement's snapshot and the UPDATE
|
|
279
|
+
* taking the row lock. Postgres then re-evaluates only the UPDATE's own WHERE against
|
|
280
|
+
* the new row version (EvalPlanQual), so the openness check must live in the UPDATE
|
|
281
|
+
* itself - otherwise the reconciler would overwrite the endsAt the writer just committed
|
|
282
|
+
* with its stale snapshot-time value.
|
|
283
|
+
*/
|
|
284
|
+
const sql: string = `
|
|
285
|
+
WITH candidates AS (
|
|
286
|
+
SELECT
|
|
287
|
+
t."_id" AS "_id",
|
|
288
|
+
(
|
|
289
|
+
SELECT MIN(n."startsAt")
|
|
290
|
+
FROM "MonitorStatusTimeline" n
|
|
291
|
+
WHERE n."monitorId" = t."monitorId"
|
|
292
|
+
AND n."deletedAt" IS NULL
|
|
293
|
+
AND n."startsAt" > t."startsAt"
|
|
294
|
+
) AS "nextStartsAt"
|
|
295
|
+
FROM "MonitorStatusTimeline" t
|
|
296
|
+
WHERE t."monitorId" = $1::uuid
|
|
297
|
+
AND t."endsAt" IS NULL
|
|
298
|
+
AND t."deletedAt" IS NULL
|
|
299
|
+
AND t."startsAt" IS NOT NULL
|
|
300
|
+
ORDER BY t."startsAt" ASC
|
|
301
|
+
LIMIT $2
|
|
302
|
+
),
|
|
303
|
+
repaired AS (
|
|
304
|
+
UPDATE "MonitorStatusTimeline" m
|
|
305
|
+
SET "endsAt" = c."nextStartsAt",
|
|
306
|
+
"updatedAt" = NOW(),
|
|
307
|
+
"version" = m."version" + 1
|
|
308
|
+
FROM candidates c
|
|
309
|
+
WHERE m."_id" = c."_id"
|
|
310
|
+
AND m."endsAt" IS NULL
|
|
311
|
+
AND c."nextStartsAt" IS NOT NULL
|
|
312
|
+
RETURNING m."_id"
|
|
313
|
+
)
|
|
314
|
+
SELECT
|
|
315
|
+
(SELECT COUNT(*) FROM candidates)::int AS "scanned",
|
|
316
|
+
(SELECT COUNT(*) FROM repaired)::int AS "repaired"
|
|
317
|
+
`;
|
|
318
|
+
|
|
319
|
+
const rows: Array<{ scanned: number; repaired: number }> =
|
|
320
|
+
await dataSource.query(sql, [
|
|
321
|
+
data.monitorId.toString(),
|
|
322
|
+
data.batchSize.toString(),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const scanned: number = rows[0]?.scanned || 0;
|
|
326
|
+
const repaired: number = rows[0]?.repaired || 0;
|
|
327
|
+
|
|
328
|
+
if (repaired > 0) {
|
|
329
|
+
logger.debug(
|
|
330
|
+
`MonitorStatusTimelineReconciler: repaired ${repaired} of ${scanned} open row(s) examined on monitor ${data.monitorId.toString()}.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
scanned: scanned,
|
|
336
|
+
repaired: repaired,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private static getBatchSize(batchSize?: number | undefined): number {
|
|
341
|
+
if (
|
|
342
|
+
!batchSize ||
|
|
343
|
+
!Number.isFinite(batchSize) ||
|
|
344
|
+
batchSize < MIN_BATCH_SIZE
|
|
345
|
+
) {
|
|
346
|
+
return DEFAULT_STALE_OPEN_ROW_BATCH_SIZE;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return Math.min(Math.floor(batchSize), MAX_BATCH_SIZE);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private static getDataSource(): DatabaseSource {
|
|
353
|
+
const dataSource: DatabaseSource | null =
|
|
354
|
+
PostgresAppInstance.getDataSource();
|
|
355
|
+
|
|
356
|
+
if (!dataSource) {
|
|
357
|
+
throw new DatabaseNotConnectedException();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return dataSource;
|
|
361
|
+
}
|
|
362
|
+
}
|