@oneuptime/common 11.0.13 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.ts +23 -0
  2. package/Models/AnalyticsModels/AuditLog.ts +2 -0
  3. package/Models/AnalyticsModels/ExceptionInstance.ts +5 -0
  4. package/Models/AnalyticsModels/Log.ts +10 -0
  5. package/Models/AnalyticsModels/Metric.ts +7 -0
  6. package/Models/AnalyticsModels/MetricBaselineHourly.ts +5 -0
  7. package/Models/AnalyticsModels/MetricItemAggMV1m.ts +5 -0
  8. package/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.ts +2 -0
  9. package/Models/AnalyticsModels/MonitorLog.ts +2 -0
  10. package/Models/AnalyticsModels/Profile.ts +9 -0
  11. package/Models/AnalyticsModels/ProfileSample.ts +2 -0
  12. package/Models/AnalyticsModels/Span.ts +5 -0
  13. package/Models/DatabaseModels/DockerResource.ts +6 -6
  14. package/Server/EnvironmentConfig.ts +25 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1782800000000-WidenDockerResourceImageColumns.ts +45 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  17. package/Server/Services/AnalyticsDatabaseService.ts +27 -14
  18. package/Server/Services/IncidentService.ts +19 -1
  19. package/Server/Utils/AnalyticsDatabase/ClusterConfig.ts +177 -0
  20. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +101 -21
  21. package/Server/Utils/CodeRepository/CodeRepository.ts +8 -0
  22. package/Server/Utils/Execute.ts +9 -0
  23. package/Server/Utils/Monitor/MonitorIncident.ts +64 -14
  24. package/Server/Utils/Monitor/MonitorResource.ts +40 -10
  25. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -6
  26. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +8 -3
  27. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +296 -0
  28. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +5 -3
  29. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +8 -0
  30. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
  31. package/build/dist/Models/AnalyticsModels/AuditLog.js +2 -0
  32. package/build/dist/Models/AnalyticsModels/AuditLog.js.map +1 -1
  33. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +5 -0
  34. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  35. package/build/dist/Models/AnalyticsModels/Log.js +10 -0
  36. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  37. package/build/dist/Models/AnalyticsModels/Metric.js +7 -0
  38. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  39. package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js +5 -0
  40. package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js.map +1 -1
  41. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js +5 -0
  42. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js.map +1 -1
  43. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js +2 -0
  44. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js.map +1 -1
  45. package/build/dist/Models/AnalyticsModels/MonitorLog.js +2 -0
  46. package/build/dist/Models/AnalyticsModels/MonitorLog.js.map +1 -1
  47. package/build/dist/Models/AnalyticsModels/Profile.js +9 -0
  48. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  49. package/build/dist/Models/AnalyticsModels/ProfileSample.js +2 -0
  50. package/build/dist/Models/AnalyticsModels/ProfileSample.js.map +1 -1
  51. package/build/dist/Models/AnalyticsModels/Span.js +5 -0
  52. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  53. package/build/dist/Models/DatabaseModels/DockerResource.js +6 -6
  54. package/build/dist/Models/DatabaseModels/DockerResource.js.map +1 -1
  55. package/build/dist/Server/EnvironmentConfig.js +21 -0
  56. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  57. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782800000000-WidenDockerResourceImageColumns.js +30 -0
  58. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782800000000-WidenDockerResourceImageColumns.js.map +1 -0
  59. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  60. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  61. package/build/dist/Server/Services/AnalyticsDatabaseService.js +22 -14
  62. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  63. package/build/dist/Server/Services/IncidentService.js +17 -1
  64. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  65. package/build/dist/Server/Utils/AnalyticsDatabase/ClusterConfig.js +144 -0
  66. package/build/dist/Server/Utils/AnalyticsDatabase/ClusterConfig.js.map +1 -0
  67. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +68 -15
  68. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  69. package/build/dist/Server/Utils/CodeRepository/CodeRepository.js +2 -6
  70. package/build/dist/Server/Utils/CodeRepository/CodeRepository.js.map +1 -1
  71. package/build/dist/Server/Utils/Execute.js +3 -1
  72. package/build/dist/Server/Utils/Execute.js.map +1 -1
  73. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +70 -27
  74. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  75. package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -8
  76. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  77. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +29 -7
  78. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  79. package/package.json +1 -1
@@ -6,6 +6,13 @@ import Sort from "../../Types/AnalyticsDatabase/Sort";
6
6
  import UpdateBy from "../../Types/AnalyticsDatabase/UpdateBy";
7
7
  import logger from "../Logger";
8
8
  import { SQL, Statement } from "./Statement";
9
+ import {
10
+ adaptTableSettingsForStorage,
11
+ getDistributedEngine,
12
+ getStorageEngine,
13
+ getStorageTableName,
14
+ onClusterClause,
15
+ } from "./ClusterConfig";
9
16
  import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
10
17
  import CommonModel, {
11
18
  Record as AnalyticsRecord,
@@ -78,12 +85,24 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
78
85
  const setStatement: Statement = this.toSetStatement(updateBy.data);
79
86
  const whereStatement: Statement = this.toWhereStatement(updateBy.query);
80
87
 
88
+ /*
89
+ * `ALTER TABLE … UPDATE` is a mutation and cannot target a Distributed
90
+ * table, so in cluster mode it runs against the local storage table and is
91
+ * dispatched to every shard via ON CLUSTER (replicated within each shard by
92
+ * Keeper). onClusterClause() is appended as RAW SQL — it is not an
93
+ * identifier and must not become a {pN:Identifier} parameter — and is empty
94
+ * in single-node mode, leaving the original statement unchanged.
95
+ */
81
96
  /* eslint-disable prettier/prettier */
82
97
  const statement: Statement = SQL`
83
- ALTER TABLE ${this.database.getDatasourceOptions().database!}.${
84
- this.model.tableName
85
- }
86
- UPDATE `
98
+ ALTER TABLE ${this.database.getDatasourceOptions().database!}.${getStorageTableName(
99
+ this.model.tableName,
100
+ )}`
101
+ .append(onClusterClause())
102
+ .append(
103
+ SQL`
104
+ UPDATE `,
105
+ )
87
106
  .append(setStatement)
88
107
  .append(
89
108
  SQL`
@@ -1089,9 +1108,9 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1089
1108
  ): Promise<Statement> {
1090
1109
  const statement: string = `ALTER TABLE ${
1091
1110
  this.database.getDatasourceOptions().database
1092
- }.${
1093
- this.model.tableName
1094
- } RENAME COLUMN IF EXISTS ${oldColumnName} TO ${newColumnName}`;
1111
+ }.${getStorageTableName(
1112
+ this.model.tableName,
1113
+ )} RENAME COLUMN IF EXISTS ${oldColumnName} TO ${newColumnName}`;
1095
1114
 
1096
1115
  return SQL`${statement}`;
1097
1116
  }
@@ -1309,9 +1328,9 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1309
1328
  }
1310
1329
 
1311
1330
  const statement: Statement = SQL`
1312
- ALTER TABLE ${this.database.getDatasourceOptions().database!}.${
1313
- this.model.tableName
1314
- } ADD COLUMN IF NOT EXISTS `.append(columnDef);
1331
+ ALTER TABLE ${this.database.getDatasourceOptions().database!}.${getStorageTableName(
1332
+ this.model.tableName,
1333
+ )} ADD COLUMN IF NOT EXISTS `.append(columnDef);
1315
1334
 
1316
1335
  logger.debug(`${this.model.tableName} Add Column Statement`);
1317
1336
  logger.debug(statement);
@@ -1341,7 +1360,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1341
1360
  const databaseName: string = this.database.getDatasourceOptions().database!;
1342
1361
  const statement: Statement = new Statement();
1343
1362
  statement.append(
1344
- `ALTER TABLE ${databaseName}.${this.model.tableName} ADD INDEX IF NOT EXISTS ${idx.name} ${columnExpr} TYPE ${idx.type}${paramsStr} GRANULARITY ${idx.granularity}`,
1363
+ `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)} ADD INDEX IF NOT EXISTS ${idx.name} ${columnExpr} TYPE ${idx.type}${paramsStr} GRANULARITY ${idx.granularity}`,
1345
1364
  );
1346
1365
 
1347
1366
  logger.debug(`${this.model.tableName} Add Skip Index Statement`);
@@ -1352,7 +1371,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1352
1371
 
1353
1372
  public toDropSkipIndexStatement(indexName: string): string {
1354
1373
  const databaseName: string = this.database.getDatasourceOptions().database!;
1355
- const statement: string = `ALTER TABLE ${databaseName}.${this.model.tableName} DROP INDEX IF EXISTS ${indexName}`;
1374
+ const statement: string = `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)} DROP INDEX IF EXISTS ${indexName}`;
1356
1375
 
1357
1376
  logger.debug(`${this.model.tableName} Drop Skip Index Statement`);
1358
1377
  logger.debug(statement);
@@ -1362,7 +1381,9 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1362
1381
 
1363
1382
  public toDropColumnStatement(columnName: string): string {
1364
1383
  const statement: string = `ALTER TABLE ${this.database.getDatasourceOptions()
1365
- .database!}.${this.model.tableName} DROP COLUMN IF EXISTS ${columnName}`;
1384
+ .database!}.${getStorageTableName(
1385
+ this.model.tableName,
1386
+ )} DROP COLUMN IF EXISTS ${columnName}`;
1366
1387
 
1367
1388
  logger.debug(`${this.model.tableName} Drop Column Statement`);
1368
1389
  logger.debug(statement);
@@ -1377,16 +1398,40 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1377
1398
  );
1378
1399
 
1379
1400
  /*
1380
- * special case - ClickHouse does not support using a query parameter
1381
- * to specify the table engine
1401
+ * special case - ClickHouse does not support using a query parameter to
1402
+ * specify the table name, engine, or ON CLUSTER clause, so these are
1403
+ * interpolated as raw SQL (the SQL tag only parameterizes ${{value,type}}
1404
+ * objects, not plain string interpolations).
1405
+ *
1406
+ * In cluster mode this builds the LOCAL storage table
1407
+ * (`<tableName>Local` with a Replicated* engine) `ON CLUSTER '<name>'`; the
1408
+ * app-facing Distributed table is created separately via
1409
+ * toDistributedTableCreateStatement(). In single-node mode it builds the
1410
+ * model's own table with its plain engine, exactly as before.
1382
1411
  */
1383
- const tableEngineStatement: string = this.model.tableEngine;
1412
+ const tableEngineStatement: string = getStorageEngine(
1413
+ this.model.tableEngine,
1414
+ );
1415
+
1416
+ const storageTableName: string = getStorageTableName(this.model.tableName);
1417
+
1418
+ const onCluster: string = onClusterClause();
1384
1419
 
1385
1420
  const partitionKey: string = this.model.partitionKey;
1386
1421
 
1387
1422
  const statement: Statement = SQL`
1388
- CREATE TABLE IF NOT EXISTS ${databaseName}.${this.model.tableName}
1389
- (\n`
1423
+ CREATE TABLE IF NOT EXISTS ${databaseName}.${storageTableName}`
1424
+ /*
1425
+ * ON CLUSTER is appended as RAW SQL — the SQL tag turns every ${..}
1426
+ * interpolation into a {pN:Identifier} parameter, which would wrongly
1427
+ * quote the whole " ON CLUSTER '<name>'" clause as a single identifier.
1428
+ * onCluster is "" in single-node mode, so this is a no-op there.
1429
+ */
1430
+ .append(onCluster)
1431
+ .append(
1432
+ SQL`
1433
+ (\n`,
1434
+ )
1390
1435
  .append(columnsStatement)
1391
1436
  .append(
1392
1437
  SQL`
@@ -1426,10 +1471,15 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1426
1471
 
1427
1472
  /*
1428
1473
  * Append table-level SETTINGS if specified (e.g. ttl_only_drop_parts = 1
1429
- * so TTL drops whole time-partitions instead of rewriting parts).
1474
+ * so TTL drops whole time-partitions instead of rewriting parts). In
1475
+ * cluster mode the non-replicated dedup window is rewritten to its
1476
+ * replicated equivalent so insert idempotency survives.
1430
1477
  */
1431
- if (this.model.tableSettings) {
1432
- statement.append(`\nSETTINGS ${this.model.tableSettings}`);
1478
+ const tableSettings: string | undefined = adaptTableSettingsForStorage(
1479
+ this.model.tableSettings,
1480
+ );
1481
+ if (tableSettings) {
1482
+ statement.append(`\nSETTINGS ${tableSettings}`);
1433
1483
  }
1434
1484
 
1435
1485
  /* eslint-enable prettier/prettier */
@@ -1439,4 +1489,34 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1439
1489
 
1440
1490
  return statement;
1441
1491
  }
1492
+
1493
+ /*
1494
+ * The app-facing Distributed table that wraps the model's local storage table.
1495
+ * Built with `AS <db>.<local>` so its column layout is copied from — and stays
1496
+ * identical to — the local table, and with `CREATE OR REPLACE` so re-running it
1497
+ * every boot atomically re-syncs the wrapper after a column is reconciled onto
1498
+ * the local table (the Distributed table holds no data, so the replace is cheap
1499
+ * and lossless). The Distributed engine routes writes by the model's sharding
1500
+ * key and scatter-gathers reads across all shards.
1501
+ */
1502
+ public toDistributedTableCreateStatement(): Statement {
1503
+ const databaseName: string = this.database.getDatasourceOptions().database!;
1504
+ const distributedTableName: string = this.model.tableName;
1505
+ const localTableName: string = getStorageTableName(this.model.tableName);
1506
+ const onCluster: string = onClusterClause();
1507
+ const distributedEngine: string = getDistributedEngine(
1508
+ localTableName,
1509
+ this.model.shardingKey,
1510
+ );
1511
+
1512
+ const statement: Statement = new Statement();
1513
+ statement.append(
1514
+ `CREATE OR REPLACE TABLE ${databaseName}.${distributedTableName}${onCluster} AS ${databaseName}.${localTableName} ENGINE = ${distributedEngine}`,
1515
+ );
1516
+
1517
+ logger.debug(`${this.model.tableName} Distributed Table Create Statement`);
1518
+ logger.debug(statement);
1519
+
1520
+ return statement;
1521
+ }
1442
1522
  }
@@ -397,6 +397,13 @@ export default class CodeRepositoryUtil {
397
397
  public static async getCommitAuthorsWithFiles(data: {
398
398
  repoPath: string;
399
399
  path?: string | undefined;
400
+ /*
401
+ * Hard upper bound (ms) on the `git log` invocation. Over a full-history repo
402
+ * this walk can take a very long time, so callers on (or near) a request path
403
+ * should bound it; on timeout the child is killed and this rejects, letting
404
+ * the caller fall back gracefully.
405
+ */
406
+ timeoutInMS?: number | undefined;
400
407
  }): Promise<
401
408
  Array<{
402
409
  authorName: string;
@@ -428,6 +435,7 @@ export default class CodeRepositoryUtil {
428
435
  cwd: path.resolve(data.repoPath),
429
436
  // git log over a full repo can easily exceed the 1 MB default.
430
437
  maxBuffer: 256 * 1024 * 1024,
438
+ ...(data.timeoutInMS ? { timeoutInMS: data.timeoutInMS } : {}),
431
439
  });
432
440
 
433
441
  const commits: Array<{
@@ -59,6 +59,12 @@ export default class Execute {
59
59
  * can emit large output (e.g. `git log` over a whole repo) should raise this.
60
60
  */
61
61
  maxBuffer?: number | undefined;
62
+ /*
63
+ * Hard upper bound (ms) on how long the child may run. When exceeded the
64
+ * child is force-killed (SIGKILL) and the promise rejects, so a slow command
65
+ * (e.g. `git log` over a huge full-history repo) can never hang the caller.
66
+ */
67
+ timeoutInMS?: number | undefined;
62
68
  }): Promise<string> {
63
69
  return new Promise(
64
70
  (
@@ -71,6 +77,9 @@ export default class Execute {
71
77
  {
72
78
  cwd: data.cwd,
73
79
  ...(data.maxBuffer ? { maxBuffer: data.maxBuffer } : {}),
80
+ ...(data.timeoutInMS
81
+ ? { timeout: data.timeoutInMS, killSignal: "SIGKILL" }
82
+ : {}),
74
83
  },
75
84
  (err: ExecException | null, stdout: string, stderr: string) => {
76
85
  if (err) {
@@ -341,8 +341,18 @@ export default class MonitorIncident {
341
341
  storageMap,
342
342
  });
343
343
 
344
- if (!criteriaIncident.incidentSeverityId) {
345
- // pick the critical criteria.
344
+ /*
345
+ * Resolve the incident severity. `criteriaIncident.incidentSeverityId`
346
+ * can be a truthy-but-EMPTY ObjectID (id === "") — a stored
347
+ * `{"_type":"ObjectID","value":""}` deserializes to `new ObjectID("")`,
348
+ * which is an object so `!incidentSeverityId` is false. That empty id
349
+ * serializes to "" for the `uuid` (not-null) column and lands as NULL,
350
+ * throwing 23502 inside the probe-ingest worker and retrying forever.
351
+ * Use `?.toString()` truthiness so an empty/blank ObjectID is treated
352
+ * the same as "missing" and falls through to the project-default lookup.
353
+ */
354
+ if (!criteriaIncident.incidentSeverityId?.toString()) {
355
+ // pick the critical (first/lowest-order root) severity.
346
356
 
347
357
  const severity: IncidentSeverity | null =
348
358
  await IncidentSeverityService.findOneBy({
@@ -360,15 +370,33 @@ export default class MonitorIncident {
360
370
  },
361
371
  });
362
372
 
363
- if (!severity) {
364
- throw new BadDataException(
365
- "Project does not have incident severity",
373
+ if (!severity?.id?.toString()) {
374
+ /*
375
+ * The project has no incident severity configured. Throwing here
376
+ * would fail the entire probe/telemetry ingest job, which then
377
+ * retries forever for a misconfiguration the worker cannot fix.
378
+ * Skip incident creation gracefully and log instead.
379
+ */
380
+ logger.error(
381
+ `${input.monitor.id?.toString()} - Cannot create incident: project ${input.monitor.projectId?.toString()} has no incident severity configured. Skipping incident creation for criteria "${
382
+ input.criteriaInstance.data?.name
383
+ }".`,
366
384
  );
367
- } else {
368
- incident.incidentSeverityId = severity.id!;
385
+
386
+ input.evaluationSummary?.events.push({
387
+ type: "incident-skipped",
388
+ title: "Incident creation skipped",
389
+ message:
390
+ "Skipped creating an incident because the project has no incident severity configured.",
391
+ relatedCriteriaId: input.criteriaInstance.data?.id,
392
+ at: OneUptimeDate.getCurrentDate(),
393
+ });
394
+ continue;
369
395
  }
396
+
397
+ incident.incidentSeverityId = severity.id!;
370
398
  } else {
371
- incident.incidentSeverityId = criteriaIncident.incidentSeverityId!;
399
+ incident.incidentSeverityId = criteriaIncident.incidentSeverityId;
372
400
  }
373
401
 
374
402
  incident.monitors = [input.monitor];
@@ -882,12 +910,34 @@ export default class MonitorIncident {
882
910
  );
883
911
  }
884
912
 
885
- await IncidentStateTimelineService.create({
886
- data: incidentStateTimeline,
887
- props: {
888
- isRoot: true,
889
- },
890
- });
913
+ try {
914
+ await IncidentStateTimelineService.create({
915
+ data: incidentStateTimeline,
916
+ props: {
917
+ isRoot: true,
918
+ },
919
+ });
920
+ } catch (err) {
921
+ /*
922
+ * Idempotent concurrency race: two probe/ingest results for the same monitor
923
+ * can both decide to auto-resolve the same open incident near-simultaneously.
924
+ * The loser's IncidentStateTimelineService.onBeforeCreate dedupe check throws
925
+ * this exact BadDataException (incident is already in the resolved state).
926
+ * Treat as a no-op at debug level instead of failing the job and logging a
927
+ * full ERROR stack. Match the exact message so unrelated BadDataExceptions
928
+ * (e.g. state-order validation) still propagate.
929
+ */
930
+ if (
931
+ err instanceof BadDataException &&
932
+ err.message === "Incident state cannot be same as previous state."
933
+ ) {
934
+ logger.debug(
935
+ `${input.openIncident.id?.toString()} - Incident already in resolved state; skipping duplicate state timeline (concurrent race).`,
936
+ );
937
+ } else {
938
+ throw err;
939
+ }
940
+ }
891
941
  }
892
942
 
893
943
  private static shouldCloseIncident(input: {
@@ -402,9 +402,18 @@ export default class MonitorResourceUtil {
402
402
  `${dataToProcess.monitorId.toString()} - Monitor metrics saved`,
403
403
  );
404
404
 
405
- const monitorSteps: MonitorSteps = monitor.monitorSteps!;
405
+ /*
406
+ * `monitorSteps` is optional on the model. When a monitor has none
407
+ * configured, `monitor.monitorSteps` is undefined and the previous
408
+ * non-null assertion (`!`) was a lie — `monitorSteps.data` then threw
409
+ * "Cannot read properties of null (reading 'data')" inside the probe
410
+ * ingest worker. Guard the value itself and take the existing
411
+ * "no monitoring steps" early return.
412
+ */
413
+ const monitorSteps: MonitorSteps | undefined = monitor.monitorSteps;
406
414
 
407
415
  if (
416
+ !monitorSteps ||
408
417
  !monitorSteps.data?.monitorStepsInstanceArray ||
409
418
  monitorSteps.data?.monitorStepsInstanceArray.length === 0
410
419
  ) {
@@ -856,15 +865,36 @@ export default class MonitorResourceUtil {
856
865
  monitorStatusTimeline.rootCause =
857
866
  "No monitoring criteria met. Change to default status. ";
858
867
 
859
- await MonitorStatusTimelineService.create({
860
- data: monitorStatusTimeline,
861
- props: {
862
- isRoot: true,
863
- },
864
- });
865
- logger.debug(
866
- `${dataToProcess.monitorId.toString()} - Monitor status updated to default.`,
867
- );
868
+ try {
869
+ await MonitorStatusTimelineService.create({
870
+ data: monitorStatusTimeline,
871
+ props: {
872
+ isRoot: true,
873
+ },
874
+ });
875
+ logger.debug(
876
+ `${dataToProcess.monitorId.toString()} - Monitor status updated to default.`,
877
+ );
878
+ } catch (err) {
879
+ /*
880
+ * Idempotent concurrency race (see MonitorStatusTimeline.ts): a
881
+ * concurrent result already moved the monitor to this default status,
882
+ * so onBeforeCreate's dedupe check throws this exact BadDataException.
883
+ * Treat as a no-op at debug level rather than failing the job. Match the
884
+ * exact message so unrelated BadDataExceptions still propagate.
885
+ */
886
+ if (
887
+ err instanceof BadDataException &&
888
+ err.message ===
889
+ "Monitor Status cannot be same as previous status."
890
+ ) {
891
+ logger.debug(
892
+ `${dataToProcess.monitorId.toString()} - Monitor status already at default; skipping duplicate status timeline (concurrent race).`,
893
+ );
894
+ } else {
895
+ throw err;
896
+ }
897
+ }
868
898
 
869
899
  const defaultStatusName: string | null = await getMonitorStatusName(
870
900
  monitorSteps.data.defaultMonitorStatusId,
@@ -114,12 +114,38 @@ export default class MonitorStatusTimelineUtil {
114
114
  );
115
115
  monitorStatusTimeline.rootCause = input.rootCause;
116
116
 
117
- return await MonitorStatusTimelineService.create({
118
- data: monitorStatusTimeline,
119
- props: {
120
- isRoot: true,
121
- },
122
- });
117
+ try {
118
+ return await MonitorStatusTimelineService.create({
119
+ data: monitorStatusTimeline,
120
+ props: {
121
+ isRoot: true,
122
+ },
123
+ });
124
+ } catch (err) {
125
+ /*
126
+ * Concurrency race: two probe/ingest results for the same monitor can be
127
+ * processed near-simultaneously (the per-monitor mutexes in
128
+ * MonitorResourceUtil.monitorResource and MonitorStatusTimeline.create can
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
+ * MonitorStatusTimelineService.onBeforeCreate dedupe check then throws this
132
+ * exact BadDataException for the loser of the race. This is an idempotent
133
+ * 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.
135
+ * Match the exact message so unrelated BadDataExceptions still propagate.
136
+ */
137
+ if (
138
+ err instanceof BadDataException &&
139
+ err.message === "Monitor Status cannot be same as previous status."
140
+ ) {
141
+ logger.debug(
142
+ `${input.monitor.id?.toString()} - Monitor status already equals desired status; skipping duplicate status timeline (concurrent race).`,
143
+ );
144
+ return null;
145
+ }
146
+
147
+ throw err;
148
+ }
123
149
  }
124
150
 
125
151
  return null;
@@ -356,13 +356,18 @@ describe("AnalyticsDatabaseService", () => {
356
356
  tableName: "<table-name>",
357
357
  });
358
358
 
359
+ /*
360
+ * Cluster mode: deletes are an ALTER ... DELETE mutation on the local
361
+ * table, dispatched ON CLUSTER (lightweight DELETE can't hit a Distributed
362
+ * table).
363
+ */
359
364
  expect(statement.query).toBe(
360
- "DELETE FROM {p0:Identifier}.{p1:Identifier}\n" +
361
- "WHERE TRUE <where-statement>",
365
+ "ALTER TABLE {p0:Identifier}.{p1:Identifier} ON CLUSTER 'oneuptime'\n" +
366
+ "DELETE WHERE TRUE <where-statement>",
362
367
  );
363
368
  expect(statement.query_params).toStrictEqual({
364
369
  p0: "oneuptime",
365
- p1: "<table-name>",
370
+ p1: "<table-name>Local",
366
371
  });
367
372
  });
368
373
  });