@oneuptime/common 12.0.2 → 12.0.3

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 (228) hide show
  1. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +14 -0
  2. package/Models/DatabaseModels/Index.ts +2 -0
  3. package/Models/DatabaseModels/LogDropFilter.ts +9 -20
  4. package/Models/DatabaseModels/StatusPageMonitorRule.ts +911 -0
  5. package/Models/DatabaseModels/StatusPageResource.ts +73 -0
  6. package/Models/DatabaseModels/TraceDropFilter.ts +9 -20
  7. package/Server/API/BaseAPI.ts +13 -2
  8. package/Server/API/DashboardAPI.ts +39 -0
  9. package/Server/EnvironmentConfig.ts +31 -1
  10. package/Server/Infrastructure/GlobalCache.ts +115 -0
  11. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +16 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.ts +123 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  14. package/Server/Services/AlertStateTimelineService.ts +20 -0
  15. package/Server/Services/CephClusterService.ts +26 -86
  16. package/Server/Services/CloudResourceService.ts +23 -83
  17. package/Server/Services/DatabaseService.ts +111 -0
  18. package/Server/Services/DockerHostService.ts +19 -79
  19. package/Server/Services/DockerSwarmClusterService.ts +27 -88
  20. package/Server/Services/HostService.ts +46 -99
  21. package/Server/Services/IncidentStateTimelineService.ts +20 -0
  22. package/Server/Services/Index.ts +2 -0
  23. package/Server/Services/IoTFleetService.ts +19 -79
  24. package/Server/Services/KubernetesClusterService.ts +17 -78
  25. package/Server/Services/LabelService.ts +57 -10
  26. package/Server/Services/MetricTypeService.ts +84 -0
  27. package/Server/Services/MonitorService.ts +89 -14
  28. package/Server/Services/MonitorStatusTimelineService.ts +29 -0
  29. package/Server/Services/MonitorTemplateService.ts +65 -0
  30. package/Server/Services/PodmanHostService.ts +19 -79
  31. package/Server/Services/ProxmoxClusterService.ts +23 -83
  32. package/Server/Services/RumApplicationService.ts +19 -80
  33. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +21 -0
  34. package/Server/Services/ServerlessFunctionService.ts +24 -85
  35. package/Server/Services/ServiceService.ts +299 -73
  36. package/Server/Services/StatusPageMonitorRuleEngineService.ts +752 -0
  37. package/Server/Services/StatusPageMonitorRuleService.ts +471 -0
  38. package/Server/Services/TelemetryEntityRelationshipService.ts +1 -0
  39. package/Server/Services/TelemetryEntityService.ts +1 -0
  40. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +170 -39
  41. package/Server/Utils/Monitor/MonitorResource.ts +63 -36
  42. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +36 -0
  43. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +148 -127
  44. package/Server/Utils/Monitor/MonitorStepsReferenceExtractor.ts +434 -0
  45. package/Server/Utils/SingleFlight.ts +83 -0
  46. package/Server/Utils/StatusPage/MonitorRulePatternValidator.ts +46 -0
  47. package/Server/Utils/Telemetry/EntityRegistry.ts +107 -26
  48. package/Server/Utils/Telemetry/ResourceHeartbeat.ts +314 -0
  49. package/Server/Utils/Telemetry/Telemetry.ts +351 -122
  50. package/Tests/App/Dashboard/WidgetCatalog.test.ts +2 -0
  51. package/Tests/Models/DatabaseModels/ColumnTransformerInvariants.test.ts +306 -0
  52. package/Tests/Models/DatabaseModels/NumberColumnDeserialization.test.ts +333 -0
  53. package/Tests/Models/DatabaseModels/StatusPageMonitorRuleModel.test.ts +190 -0
  54. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +26 -0
  55. package/Tests/Server/Infrastructure/GlobalCache.test.ts +226 -0
  56. package/Tests/Server/Infrastructure/PostgresLockTimeoutOptions.test.ts +181 -0
  57. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlocked.test.ts +342 -0
  58. package/Tests/Server/Services/DropFilterSaveValidation.test.ts +245 -0
  59. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +163 -15
  60. package/Tests/Server/Services/MetricTypeAttachServices.test.ts +222 -0
  61. package/Tests/Server/Services/MissingReferenceWriteGuard.test.ts +614 -0
  62. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +23 -0
  63. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +305 -56
  64. package/Tests/Server/Services/ServiceUpdateLastSeenSplitGates.test.ts +705 -0
  65. package/Tests/Server/Services/StatusPageMonitorRuleEngineService.test.ts +1719 -0
  66. package/Tests/Server/Services/StatusPageMonitorRuleHooks.test.ts +1212 -0
  67. package/Tests/Server/Services/StatusPageMonitorRuleMonitorCreateHook.test.ts +228 -0
  68. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +267 -0
  69. package/Tests/Server/Utils/Database/ProjectScopedReferenceValidator.test.ts +260 -13
  70. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +302 -0
  71. package/Tests/Server/Utils/MetricTypeIndexConvergence.test.ts +598 -0
  72. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineUnusableStatus.test.ts +154 -0
  73. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +638 -92
  74. package/Tests/Server/Utils/Monitor/MonitorStepsReferenceExtractor.test.ts +724 -0
  75. package/Tests/Server/Utils/SingleFlight.test.ts +221 -0
  76. package/Tests/Types/Dashboard/NetworkDashboardTemplate.test.ts +793 -0
  77. package/Tests/Types/Database/BigIntColumnTransformer.test.ts +147 -0
  78. package/Tests/Types/Database/DatabasePropertyTransformer.test.ts +140 -0
  79. package/Tests/Types/Database/NumericColumnValue.test.ts +127 -0
  80. package/Tests/Types/Decimal.test.ts +37 -0
  81. package/Tests/Types/Monitor/SnmpVersionParsing.test.ts +152 -0
  82. package/Tests/Types/Port.test.ts +38 -0
  83. package/Tests/UI/Components/LogsHistogram.test.tsx +276 -0
  84. package/Tests/UI/Components/UseLiveLogsRefresh.test.tsx +489 -0
  85. package/Tests/UI/Components/UseLogsHistogram.test.tsx +500 -0
  86. package/Tests/Utils/Dashboard/Components/DashboardComponentsUtil.test.ts +120 -0
  87. package/Tests/Utils/Dashboard/DashboardNetworkMapComponent.test.ts +327 -0
  88. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  89. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  90. package/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.ts +40 -0
  91. package/Types/Dashboard/DashboardTemplates.ts +397 -0
  92. package/Types/Database/BigIntColumnTransformer.ts +74 -0
  93. package/Types/Database/DatabaseProperty.ts +26 -0
  94. package/Types/Database/NumericColumnValue.ts +128 -0
  95. package/Types/Decimal.ts +16 -4
  96. package/Types/Permission.ts +48 -0
  97. package/Types/Port.ts +16 -4
  98. package/UI/Components/LogsViewer/useLiveLogsRefresh.ts +86 -0
  99. package/UI/Components/LogsViewer/useLogsHistogram.ts +113 -0
  100. package/Utils/Dashboard/Components/DashboardNetworkMapComponent.ts +136 -0
  101. package/Utils/Dashboard/Components/Index.ts +7 -0
  102. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +12 -0
  103. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  105. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/LogDropFilter.js +9 -20
  107. package/build/dist/Models/DatabaseModels/LogDropFilter.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js +925 -0
  109. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js.map +1 -0
  110. package/build/dist/Models/DatabaseModels/StatusPageResource.js +72 -0
  111. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/TraceDropFilter.js +9 -20
  113. package/build/dist/Models/DatabaseModels/TraceDropFilter.js.map +1 -1
  114. package/build/dist/Server/API/BaseAPI.js +11 -1
  115. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  116. package/build/dist/Server/API/DashboardAPI.js +39 -0
  117. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  118. package/build/dist/Server/EnvironmentConfig.js +27 -1
  119. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  120. package/build/dist/Server/Infrastructure/GlobalCache.js +91 -0
  121. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +5 -3
  123. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js +48 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  128. package/build/dist/Server/Services/AlertStateTimelineService.js +19 -0
  129. package/build/dist/Server/Services/AlertStateTimelineService.js.map +1 -1
  130. package/build/dist/Server/Services/CephClusterService.js +26 -76
  131. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  132. package/build/dist/Server/Services/CloudResourceService.js +23 -73
  133. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  134. package/build/dist/Server/Services/DatabaseService.js +86 -0
  135. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  136. package/build/dist/Server/Services/DockerHostService.js +19 -69
  137. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  138. package/build/dist/Server/Services/DockerSwarmClusterService.js +27 -78
  139. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  140. package/build/dist/Server/Services/HostService.js +58 -102
  141. package/build/dist/Server/Services/HostService.js.map +1 -1
  142. package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
  143. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  144. package/build/dist/Server/Services/Index.js +2 -0
  145. package/build/dist/Server/Services/Index.js.map +1 -1
  146. package/build/dist/Server/Services/IoTFleetService.js +19 -69
  147. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  148. package/build/dist/Server/Services/KubernetesClusterService.js +17 -68
  149. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  150. package/build/dist/Server/Services/LabelService.js +42 -7
  151. package/build/dist/Server/Services/LabelService.js.map +1 -1
  152. package/build/dist/Server/Services/MetricTypeService.js +74 -0
  153. package/build/dist/Server/Services/MetricTypeService.js.map +1 -1
  154. package/build/dist/Server/Services/MonitorService.js +81 -9
  155. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  156. package/build/dist/Server/Services/MonitorStatusTimelineService.js +28 -0
  157. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  158. package/build/dist/Server/Services/MonitorTemplateService.js +60 -0
  159. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  160. package/build/dist/Server/Services/PodmanHostService.js +19 -69
  161. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  162. package/build/dist/Server/Services/ProxmoxClusterService.js +23 -73
  163. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  164. package/build/dist/Server/Services/RumApplicationService.js +19 -70
  165. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  166. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +20 -0
  167. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  168. package/build/dist/Server/Services/ServerlessFunctionService.js +24 -75
  169. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  170. package/build/dist/Server/Services/ServiceService.js +236 -61
  171. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  172. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +603 -0
  173. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -0
  174. package/build/dist/Server/Services/StatusPageMonitorRuleService.js +401 -0
  175. package/build/dist/Server/Services/StatusPageMonitorRuleService.js.map +1 -0
  176. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +1 -0
  177. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  178. package/build/dist/Server/Services/TelemetryEntityService.js +1 -0
  179. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  180. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +105 -29
  181. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  182. package/build/dist/Server/Utils/Monitor/MonitorResource.js +56 -32
  183. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  184. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +30 -3
  185. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  186. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +110 -98
  187. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -1
  188. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js +312 -0
  189. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js.map +1 -0
  190. package/build/dist/Server/Utils/SingleFlight.js +76 -0
  191. package/build/dist/Server/Utils/SingleFlight.js.map +1 -0
  192. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js +29 -0
  193. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js.map +1 -0
  194. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +78 -19
  195. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  196. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +160 -0
  197. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -0
  198. package/build/dist/Server/Utils/Telemetry/Telemetry.js +279 -104
  199. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  200. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  201. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  202. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  203. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  204. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js +2 -0
  205. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js.map +1 -0
  206. package/build/dist/Types/Dashboard/DashboardTemplates.js +368 -0
  207. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  208. package/build/dist/Types/Database/BigIntColumnTransformer.js +66 -0
  209. package/build/dist/Types/Database/BigIntColumnTransformer.js.map +1 -0
  210. package/build/dist/Types/Database/DatabaseProperty.js +25 -0
  211. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  212. package/build/dist/Types/Database/NumericColumnValue.js +97 -0
  213. package/build/dist/Types/Database/NumericColumnValue.js.map +1 -0
  214. package/build/dist/Types/Decimal.js +14 -3
  215. package/build/dist/Types/Decimal.js.map +1 -1
  216. package/build/dist/Types/Permission.js +42 -0
  217. package/build/dist/Types/Permission.js.map +1 -1
  218. package/build/dist/Types/Port.js +15 -5
  219. package/build/dist/Types/Port.js.map +1 -1
  220. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js +44 -0
  221. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js.map +1 -0
  222. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js +75 -0
  223. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js.map +1 -0
  224. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js +114 -0
  225. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js.map +1 -0
  226. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  227. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  228. package/package.json +1 -1
@@ -5,6 +5,7 @@ import Model from "../../Models/DatabaseModels/CephCluster";
5
5
  import Label from "../../Models/DatabaseModels/Label";
6
6
  import { OnCreate } from "../Types/Database/Hooks";
7
7
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
8
+ import ResourceHeartbeat from "../Utils/Telemetry/ResourceHeartbeat";
8
9
  import ObjectID from "../../Types/ObjectID";
9
10
  import QueryHelper from "../Types/Database/QueryHelper";
10
11
  import OneUptimeDate from "../../Types/Date";
@@ -181,7 +182,6 @@ export class Service extends DatabaseService<Model> {
181
182
  capacityUsedPercent?: number | undefined;
182
183
  },
183
184
  ): Promise<void> {
184
- const cacheKey: string = clusterId.toString();
185
185
  const extrasFingerprint: string = crypto
186
186
  .createHash("sha1")
187
187
  .update(
@@ -200,34 +200,6 @@ export class Service extends DatabaseService<Model> {
200
200
  )
201
201
  .digest("hex");
202
202
 
203
- let cached: string | null = null;
204
- try {
205
- cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
206
- } catch {
207
- /*
208
- * Cache unavailable — fail open and refresh lastSeenAt anyway. A
209
- * cache error must never skip the DB write below, otherwise the
210
- * resource is wrongly marked "disconnected" while telemetry is
211
- * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
212
- */
213
- cached = null;
214
- }
215
-
216
- if (cached === extrasFingerprint) {
217
- return; // same data was written recently
218
- }
219
-
220
- try {
221
- await GlobalCache.setString(
222
- LAST_SEEN_CACHE_NAMESPACE,
223
- cacheKey,
224
- extrasFingerprint,
225
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
226
- );
227
- } catch {
228
- // Best-effort throttle write; proceed with the DB update regardless.
229
- }
230
-
231
203
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
204
  const liveness: any = {
233
205
  lastSeenAt: OneUptimeDate.getCurrentDate(),
@@ -235,91 +207,59 @@ export class Service extends DatabaseService<Model> {
235
207
  };
236
208
 
237
209
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
- const data: any = { ...liveness };
210
+ const metadata: any = {};
239
211
 
240
212
  if (extra?.cephVersion) {
241
- data.cephVersion = extra.cephVersion;
213
+ metadata.cephVersion = extra.cephVersion;
242
214
  }
243
215
  if (extra?.fsid) {
244
- data.fsid = extra.fsid;
216
+ metadata.fsid = extra.fsid;
245
217
  }
246
218
  if (extra?.agentVersion) {
247
- data.agentVersion = extra.agentVersion;
219
+ metadata.agentVersion = extra.agentVersion;
248
220
  }
249
221
  /*
250
222
  * Counts and health: 0 is a legitimate value (healthStatus 0 = OK,
251
223
  * osdUpCount 0 = every OSD down) — gate on undefined, not falsiness.
252
224
  */
253
225
  if (extra?.monCount !== undefined) {
254
- data.monCount = extra.monCount;
226
+ metadata.monCount = extra.monCount;
255
227
  }
256
228
  if (extra?.osdCount !== undefined) {
257
- data.osdCount = extra.osdCount;
229
+ metadata.osdCount = extra.osdCount;
258
230
  }
259
231
  if (extra?.osdUpCount !== undefined) {
260
- data.osdUpCount = extra.osdUpCount;
232
+ metadata.osdUpCount = extra.osdUpCount;
261
233
  }
262
234
  if (extra?.osdInCount !== undefined) {
263
- data.osdInCount = extra.osdInCount;
235
+ metadata.osdInCount = extra.osdInCount;
264
236
  }
265
237
  if (extra?.poolCount !== undefined) {
266
- data.poolCount = extra.poolCount;
238
+ metadata.poolCount = extra.poolCount;
267
239
  }
268
240
  if (extra?.healthStatus !== undefined) {
269
- data.healthStatus = extra.healthStatus;
241
+ metadata.healthStatus = extra.healthStatus;
270
242
  }
271
243
  if (extra?.capacityUsedPercent !== undefined) {
272
- data.capacityUsedPercent = extra.capacityUsedPercent;
244
+ metadata.capacityUsedPercent = extra.capacityUsedPercent;
273
245
  }
274
246
 
275
247
  /*
276
- * Heartbeat write: a single-statement UPDATE with no hooks and no
277
- * `version` bump, avoiding the hot-row Postgres lock convoy that the
278
- * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
279
- *
280
- * Collector-supplied strings are clamped to their declared column
281
- * widths inside updateColumnsByIdWithoutHooks — see
282
- * DatabaseService.truncateStringColumnsToMaxLength.
248
+ * One gated, non-blocking heartbeat write. The gates, the fail-open /
249
+ * fail-closed split and the liveness-only fallback all live in
250
+ * ResourceHeartbeat see there for why this row's throttle used to
251
+ * provide no throttling at all.
283
252
  */
284
- try {
285
- await this.updateColumnsByIdWithoutHooks({
286
- id: clusterId,
287
- data: data,
288
- });
289
- } catch (err) {
290
- /*
291
- * Liveness must survive bad metadata. The enriched write carries
292
- * `lastSeenAt` and `otelCollectorStatus` alongside optional,
293
- * collector-supplied columns; if any one of them makes Postgres
294
- * reject the statement, lastSeenAt silently stops advancing and
295
- * markDisconnectedClusters flips a perfectly healthy Ceph cluster to
296
- * "disconnected" 15 minutes later — with telemetry still arriving the
297
- * whole time. That was issue #3006 on the Host path (a host.ip list
298
- * longer than the then-varchar(500) column). Retry with liveness only
299
- * so the enrichment is what gets dropped, never the heartbeat.
300
- */
301
- logger.warn(
302
- `CephClusterService.updateLastSeen: metadata write failed for Ceph cluster ${clusterId.toString()}, falling back to a liveness-only update: ${
303
- err instanceof Error ? err.message : String(err)
304
- }`,
305
- );
306
-
307
- /*
308
- * The throttle fingerprint was already stored, so without this the
309
- * next batch would short-circuit on a cache hit and skip the retry
310
- * for the rest of the window.
311
- */
312
- try {
313
- await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
314
- } catch {
315
- // Best-effort; the fallback write below is what actually matters.
316
- }
317
-
318
- await this.updateColumnsByIdWithoutHooks({
319
- id: clusterId,
320
- data: liveness,
321
- });
322
- }
253
+ await ResourceHeartbeat.write({
254
+ service: this,
255
+ id: clusterId,
256
+ cacheNamespace: LAST_SEEN_CACHE_NAMESPACE,
257
+ throttleInSeconds: LAST_SEEN_THROTTLE_SECONDS,
258
+ liveness: liveness,
259
+ metadata: metadata,
260
+ fingerprint: extrasFingerprint,
261
+ describe: `ceph cluster ${clusterId.toString()}`,
262
+ });
323
263
  }
324
264
 
325
265
  /**
@@ -2,6 +2,7 @@ import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/CloudResource";
3
3
  import Label from "../../Models/DatabaseModels/Label";
4
4
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
5
+ import ResourceHeartbeat from "../Utils/Telemetry/ResourceHeartbeat";
5
6
  import ObjectID from "../../Types/ObjectID";
6
7
  import QueryHelper from "../Types/Database/QueryHelper";
7
8
  import OneUptimeDate from "../../Types/Date";
@@ -154,7 +155,6 @@ export class Service extends DatabaseService<Model> {
154
155
  runtimeVersion?: string | undefined;
155
156
  },
156
157
  ): Promise<void> {
157
- const cacheKey: string = cloudResourceId.toString();
158
158
  const extrasFingerprint: string = crypto
159
159
  .createHash("sha1")
160
160
  .update(
@@ -170,34 +170,6 @@ export class Service extends DatabaseService<Model> {
170
170
  )
171
171
  .digest("hex");
172
172
 
173
- let cached: string | null = null;
174
- try {
175
- cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
176
- } catch {
177
- /*
178
- * Cache unavailable — fail open and refresh lastSeenAt anyway. A
179
- * cache error must never skip the DB write below, otherwise the
180
- * resource is wrongly marked "disconnected" while telemetry is
181
- * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
182
- */
183
- cached = null;
184
- }
185
-
186
- if (cached === extrasFingerprint) {
187
- return; // same data was written recently
188
- }
189
-
190
- try {
191
- await GlobalCache.setString(
192
- LAST_SEEN_CACHE_NAMESPACE,
193
- cacheKey,
194
- extrasFingerprint,
195
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
196
- );
197
- } catch {
198
- // Best-effort throttle write; proceed with the DB update regardless.
199
- }
200
-
201
173
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
174
  const liveness: any = {
203
175
  lastSeenAt: OneUptimeDate.getCurrentDate(),
@@ -205,78 +177,46 @@ export class Service extends DatabaseService<Model> {
205
177
  };
206
178
 
207
179
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
- const data: any = { ...liveness };
180
+ const metadata: any = {};
209
181
 
210
182
  if (extra?.agentVersion) {
211
- data.agentVersion = extra.agentVersion;
183
+ metadata.agentVersion = extra.agentVersion;
212
184
  }
213
185
  if (extra?.cloudPlatform) {
214
- data.cloudPlatform = extra.cloudPlatform;
186
+ metadata.cloudPlatform = extra.cloudPlatform;
215
187
  }
216
188
  if (extra?.cloudProvider) {
217
- data.cloudProvider = extra.cloudProvider;
189
+ metadata.cloudProvider = extra.cloudProvider;
218
190
  }
219
191
  if (extra?.cloudRegion) {
220
- data.cloudRegion = extra.cloudRegion;
192
+ metadata.cloudRegion = extra.cloudRegion;
221
193
  }
222
194
  if (extra?.cloudAccountId) {
223
- data.cloudAccountId = extra.cloudAccountId;
195
+ metadata.cloudAccountId = extra.cloudAccountId;
224
196
  }
225
197
  if (extra?.runtimeName) {
226
- data.runtimeName = extra.runtimeName;
198
+ metadata.runtimeName = extra.runtimeName;
227
199
  }
228
200
  if (extra?.runtimeVersion) {
229
- data.runtimeVersion = extra.runtimeVersion;
201
+ metadata.runtimeVersion = extra.runtimeVersion;
230
202
  }
231
203
 
232
204
  /*
233
- * Heartbeat write: a single-statement UPDATE with no hooks and no
234
- * `version` bump, avoiding the hot-row Postgres lock convoy that the
235
- * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
236
- *
237
- * Collector-supplied strings are clamped to their declared column
238
- * widths inside updateColumnsByIdWithoutHooks — see
239
- * DatabaseService.truncateStringColumnsToMaxLength.
205
+ * One gated, non-blocking heartbeat write. The gates, the fail-open /
206
+ * fail-closed split and the liveness-only fallback all live in
207
+ * ResourceHeartbeat see there for why this row's throttle used to
208
+ * provide no throttling at all.
240
209
  */
241
- try {
242
- await this.updateColumnsByIdWithoutHooks({
243
- id: cloudResourceId,
244
- data: data,
245
- });
246
- } catch (err) {
247
- /*
248
- * Liveness must survive bad metadata. The enriched write carries
249
- * `lastSeenAt` and `otelCollectorStatus` alongside optional,
250
- * collector-supplied columns; if any one of them makes Postgres
251
- * reject the statement, lastSeenAt silently stops advancing and
252
- * markDisconnectedResources flips a perfectly healthy cloud resource
253
- * to "disconnected" 15 minutes later — with telemetry still arriving
254
- * the whole time. That was issue #3006 on the Host path (a host.ip
255
- * list longer than the then-varchar(500) column). Retry with liveness
256
- * only so the enrichment is what gets dropped, never the heartbeat.
257
- */
258
- logger.warn(
259
- `CloudResourceService.updateLastSeen: metadata write failed for cloud resource ${cloudResourceId.toString()}, falling back to a liveness-only update: ${
260
- err instanceof Error ? err.message : String(err)
261
- }`,
262
- );
263
-
264
- /*
265
- * The throttle fingerprint was already stored, so without this the
266
- * next batch would short-circuit on a cache hit and skip the retry
267
- * for the rest of the window.
268
- */
269
- try {
270
- await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
271
- } catch {
272
- // Best-effort; the fallback write below is what actually matters.
273
- }
274
-
275
- await this.updateColumnsByIdWithoutHooks({
276
- id: cloudResourceId,
277
- data: liveness,
278
- });
279
- }
210
+ await ResourceHeartbeat.write({
211
+ service: this,
212
+ id: cloudResourceId,
213
+ cacheNamespace: LAST_SEEN_CACHE_NAMESPACE,
214
+ throttleInSeconds: LAST_SEEN_THROTTLE_SECONDS,
215
+ liveness: liveness,
216
+ metadata: metadata,
217
+ fingerprint: extrasFingerprint,
218
+ describe: `cloud resource ${cloudResourceId.toString()}`,
219
+ });
280
220
  }
281
221
 
282
222
  @CaptureSpan()
@@ -2400,6 +2400,117 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
2400
2400
  await repository.manager.query(sql, params);
2401
2401
  }
2402
2402
 
2403
+ /*
2404
+ * Same single-statement, hook-free, no-version-bump write as
2405
+ * `updateColumnsByIdWithoutHooks`, except it YIELDS instead of queueing:
2406
+ * if another transaction already holds the row's lock, this write is
2407
+ * abandoned and the method returns false. It never waits.
2408
+ *
2409
+ * Why this exists. A single-statement UPDATE holds its row lock for a very
2410
+ * short time, but "short" does not mean "safe" once the arrival rate on ONE
2411
+ * row exceeds the service rate. Postgres queues lock waiters strictly, so
2412
+ * every waiter pins a backend for the sum of everyone ahead of it — the
2413
+ * classic convoy. In the outage this was written for, ~25K concurrent
2414
+ * ingest jobs across 100 worker pods funnelled onto ~2,300 Service rows and
2415
+ * produced 1,017 active connections with 892 of them parked on row locks;
2416
+ * the tail had been waiting 3.7 hours. Nothing about that is fixable by
2417
+ * making the statement faster, and no Postgres tier changes it: the queue
2418
+ * is the bug.
2419
+ *
2420
+ * `FOR UPDATE SKIP LOCKED` inverts it. A contended writer does zero work
2421
+ * and returns immediately, so the number of backends blocked on any single
2422
+ * row is capped at ONE no matter how many workers arrive together. The
2423
+ * convoy cannot form — and cannot form even if every upstream throttle
2424
+ * fails open at once (Redis down, fence bug, cache flush). That is the
2425
+ * point: this is the structural backstop underneath the throttles, not a
2426
+ * second copy of them.
2427
+ *
2428
+ * Use ONLY where losing the write is harmless because a concurrent writer
2429
+ * is writing the same thing — liveness timestamps and other idempotent
2430
+ * bookkeeping, whose contended value is by construction the value the
2431
+ * winner is already storing. It is the WRONG primitive for a write carrying
2432
+ * information the winner does not have (a changed attribute); for those the
2433
+ * caller must re-attempt, and `false` is the signal to do it. Returning a
2434
+ * boolean rather than swallowing the skip is deliberate: a silent drop here
2435
+ * would be indistinguishable from a successful write at the call site,
2436
+ * which is exactly how a "reliable" heartbeat quietly stops beating.
2437
+ *
2438
+ * Returns true when the row was updated, false when it was locked (or no
2439
+ * longer exists).
2440
+ */
2441
+ @CaptureSpan()
2442
+ public async updateColumnsByIdIfUnlockedWithoutHooks(input: {
2443
+ id: ObjectID;
2444
+ data: PartialEntity<TBaseModel>;
2445
+ skipUpdateDateColumn?: boolean;
2446
+ }): Promise<boolean> {
2447
+ if (!input.id) {
2448
+ throw new BadDataException("id is required");
2449
+ }
2450
+
2451
+ const repository: Repository<TBaseModel> = this.getRepository();
2452
+ const metadata: EntityMetadata = repository.metadata;
2453
+ const driver: Driver = repository.manager.connection.driver;
2454
+
2455
+ const setClauses: Array<string> = [];
2456
+ const params: Array<unknown> = [];
2457
+
2458
+ // Clamp on a shallow COPY — see updateColumnsByIdWithoutHooks.
2459
+ const data: ObjectLiteral = this.truncateStringColumnsToMaxLength({
2460
+ ...(input.data as ObjectLiteral),
2461
+ });
2462
+
2463
+ for (const [propertyName, value] of Object.entries(data)) {
2464
+ const column: ColumnMetadata | undefined =
2465
+ metadata.findColumnWithPropertyName(propertyName);
2466
+ if (!column) {
2467
+ throw new BadDataException(
2468
+ `updateColumnsByIdIfUnlockedWithoutHooks: unknown column "${propertyName}" on "${metadata.tableName}"`,
2469
+ );
2470
+ }
2471
+ if (typeof value === "function") {
2472
+ throw new BadDataException(
2473
+ `updateColumnsByIdIfUnlockedWithoutHooks: SQL-expression values are not supported (column "${propertyName}"); pass a literal value.`,
2474
+ );
2475
+ }
2476
+ params.push(driver.preparePersistentValue(value, column));
2477
+ setClauses.push(`"${column.databaseName}" = $${params.length}`);
2478
+ }
2479
+
2480
+ if (setClauses.length === 0) {
2481
+ return false;
2482
+ }
2483
+
2484
+ if (metadata.updateDateColumn && !input.skipUpdateDateColumn) {
2485
+ setClauses.push(
2486
+ `"${metadata.updateDateColumn.databaseName}" = CURRENT_TIMESTAMP`,
2487
+ );
2488
+ }
2489
+
2490
+ const primaryColumnName: string =
2491
+ metadata.primaryColumns[0]?.databaseName || "_id";
2492
+ params.push(input.id.toString());
2493
+
2494
+ /*
2495
+ * The lock is taken by the sub-SELECT and inherited by the UPDATE within
2496
+ * the same implicit transaction, so the outer write never blocks either.
2497
+ * RETURNING is how the affected-row count comes back: TypeORM's `query()`
2498
+ * surfaces the driver's rows and a bare UPDATE returns none, so without it
2499
+ * a skipped write is indistinguishable from an applied one.
2500
+ */
2501
+ const sql: string = `UPDATE "${metadata.tableName}" SET ${setClauses.join(
2502
+ ", ",
2503
+ )} WHERE "${primaryColumnName}" IN (SELECT "${primaryColumnName}" FROM "${
2504
+ metadata.tableName
2505
+ }" WHERE "${primaryColumnName}" = $${
2506
+ params.length
2507
+ } FOR UPDATE SKIP LOCKED) RETURNING "${primaryColumnName}"`;
2508
+
2509
+ const result: unknown = await repository.manager.query(sql, params);
2510
+
2511
+ return Array.isArray(result) && result.length > 0;
2512
+ }
2513
+
2403
2514
  /*
2404
2515
  * Atomically add to numeric columns and set literal columns for one row,
2405
2516
  * in a SINGLE auto-committed UPDATE, with no hooks and no `version` bump.
@@ -5,6 +5,7 @@ import Model from "../../Models/DatabaseModels/DockerHost";
5
5
  import Label from "../../Models/DatabaseModels/Label";
6
6
  import { OnCreate } from "../Types/Database/Hooks";
7
7
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
8
+ import ResourceHeartbeat from "../Utils/Telemetry/ResourceHeartbeat";
8
9
  import ObjectID from "../../Types/ObjectID";
9
10
  import QueryHelper from "../Types/Database/QueryHelper";
10
11
  import OneUptimeDate from "../../Types/Date";
@@ -180,7 +181,6 @@ export class Service extends DatabaseService<Model> {
180
181
  agentVersion?: string | undefined;
181
182
  },
182
183
  ): Promise<void> {
183
- const cacheKey: string = hostId.toString();
184
184
  const extrasFingerprint: string = crypto
185
185
  .createHash("sha1")
186
186
  .update(
@@ -192,34 +192,6 @@ export class Service extends DatabaseService<Model> {
192
192
  )
193
193
  .digest("hex");
194
194
 
195
- let cached: string | null = null;
196
- try {
197
- cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
198
- } catch {
199
- /*
200
- * Cache unavailable — fail open and refresh lastSeenAt anyway. A
201
- * cache error must never skip the DB write below, otherwise the
202
- * resource is wrongly marked "disconnected" while telemetry is
203
- * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
204
- */
205
- cached = null;
206
- }
207
-
208
- if (cached === extrasFingerprint) {
209
- return; // same data was written recently
210
- }
211
-
212
- try {
213
- await GlobalCache.setString(
214
- LAST_SEEN_CACHE_NAMESPACE,
215
- cacheKey,
216
- extrasFingerprint,
217
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
218
- );
219
- } catch {
220
- // Best-effort throttle write; proceed with the DB update regardless.
221
- }
222
-
223
195
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
196
  const liveness: any = {
225
197
  lastSeenAt: OneUptimeDate.getCurrentDate(),
@@ -227,66 +199,34 @@ export class Service extends DatabaseService<Model> {
227
199
  };
228
200
 
229
201
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
- const data: any = { ...liveness };
202
+ const metadata: any = {};
231
203
 
232
204
  if (extra?.osType) {
233
- data.osType = extra.osType;
205
+ metadata.osType = extra.osType;
234
206
  }
235
207
  if (extra?.osVersion) {
236
- data.osVersion = extra.osVersion;
208
+ metadata.osVersion = extra.osVersion;
237
209
  }
238
210
  if (extra?.agentVersion) {
239
- data.agentVersion = extra.agentVersion;
211
+ metadata.agentVersion = extra.agentVersion;
240
212
  }
241
213
 
242
214
  /*
243
- * Heartbeat write: a single-statement UPDATE with no hooks and no
244
- * `version` bump, avoiding the hot-row Postgres lock convoy that the
245
- * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
246
- *
247
- * Collector-supplied strings are clamped to their declared column
248
- * widths inside updateColumnsByIdWithoutHooks — see
249
- * DatabaseService.truncateStringColumnsToMaxLength.
215
+ * One gated, non-blocking heartbeat write. The gates, the fail-open /
216
+ * fail-closed split and the liveness-only fallback all live in
217
+ * ResourceHeartbeat see there for why this row's throttle used to
218
+ * provide no throttling at all.
250
219
  */
251
- try {
252
- await this.updateColumnsByIdWithoutHooks({
253
- id: hostId,
254
- data: data,
255
- });
256
- } catch (err) {
257
- /*
258
- * Liveness must survive bad metadata. The enriched write carries
259
- * `lastSeenAt` and `otelCollectorStatus` alongside optional,
260
- * collector-supplied columns; if any one of them makes Postgres
261
- * reject the statement, lastSeenAt silently stops advancing and
262
- * markDisconnectedHosts flips a perfectly healthy Docker host to
263
- * "disconnected" 15 minutes later — with telemetry still arriving the
264
- * whole time. That was issue #3006 on the Host path (a host.ip list
265
- * longer than the then-varchar(500) column). Retry with liveness only
266
- * so the enrichment is what gets dropped, never the heartbeat.
267
- */
268
- logger.warn(
269
- `DockerHostService.updateLastSeen: metadata write failed for Docker host ${hostId.toString()}, falling back to a liveness-only update: ${
270
- err instanceof Error ? err.message : String(err)
271
- }`,
272
- );
273
-
274
- /*
275
- * The throttle fingerprint was already stored, so without this the
276
- * next batch would short-circuit on a cache hit and skip the retry
277
- * for the rest of the window.
278
- */
279
- try {
280
- await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
281
- } catch {
282
- // Best-effort; the fallback write below is what actually matters.
283
- }
284
-
285
- await this.updateColumnsByIdWithoutHooks({
286
- id: hostId,
287
- data: liveness,
288
- });
289
- }
220
+ await ResourceHeartbeat.write({
221
+ service: this,
222
+ id: hostId,
223
+ cacheNamespace: LAST_SEEN_CACHE_NAMESPACE,
224
+ throttleInSeconds: LAST_SEEN_THROTTLE_SECONDS,
225
+ liveness: liveness,
226
+ metadata: metadata,
227
+ fingerprint: extrasFingerprint,
228
+ describe: `docker host ${hostId.toString()}`,
229
+ });
290
230
  }
291
231
 
292
232
  /**