@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
@@ -14,6 +14,7 @@ import {
14
14
  deriveRelationships,
15
15
  EntityRelationshipEdge,
16
16
  } from "../../../Utils/Telemetry/EntityRelationship";
17
+ import crypto from "crypto";
17
18
 
18
19
  /*
19
20
  * Shared entity-registry reconciliation machinery (phases 2 + 5 of the
@@ -87,28 +88,81 @@ export const REGISTRY_PROMOTED_TYPES: ReadonlySet<EntityType> =
87
88
  */
88
89
  const FENCE_NAMESPACE: string = "otel-maintenance-fence";
89
90
  const FENCE_SCOPE: string = "entity-reconcile";
91
+ const ROW_FENCE_SCOPE: string = "entity-reconcile-row";
90
92
  const FENCE_TTL_SECONDS: number = 5 * 60; // 5 minutes
91
93
 
94
+ /*
95
+ * Atomic claim. A read-then-write here admits every worker that reads the
96
+ * absent key in the same instant, which at ingest concurrency is all of them —
97
+ * the defect behind the row-lock convoy documented in
98
+ * DatabaseService.updateColumnsByIdIfUnlockedWithoutHooks. TTLs are jittered
99
+ * so a fleet-wide restart cannot re-synchronise the windows and rebuild the
100
+ * herd on a fixed period.
101
+ */
92
102
  async function shouldReconcile(fenceId: string): Promise<boolean> {
93
103
  try {
94
- const key: string = `${FENCE_SCOPE}:${fenceId}`;
95
- const seen: string | null = await GlobalCache.getString(
104
+ return await GlobalCache.setStringIfNotExists(
96
105
  FENCE_NAMESPACE,
97
- key,
106
+ `${FENCE_SCOPE}:${hashFenceId(fenceId)}`,
107
+ "1",
108
+ { expiresInSeconds: GlobalCache.withJitter(FENCE_TTL_SECONDS) },
98
109
  );
99
- if (seen) {
100
- return false;
101
- }
102
- await GlobalCache.setString(FENCE_NAMESPACE, key, "1", {
103
- expiresInSeconds: FENCE_TTL_SECONDS,
104
- });
110
+ } catch {
111
+ // If the cache is down, default to running the reconcile.
105
112
  return true;
113
+ }
114
+ }
115
+
116
+ /*
117
+ * The set-level fence id concatenates every promoted entity key, so it is
118
+ * unbounded in length — a pod with many entities would otherwise produce a
119
+ * multi-kilobyte Redis key. Hashing keeps it fixed-width without changing which
120
+ * sets collide.
121
+ */
122
+ function hashFenceId(fenceId: string): string {
123
+ return crypto.createHash("sha1").update(fenceId).digest("hex");
124
+ }
125
+
126
+ /*
127
+ * Per-ROW fence, sitting inside the set-level one.
128
+ *
129
+ * The set-level fence keys on (project + the whole promoted entity set), which
130
+ * in practice is unique per POD — a pod's own key is in the set. But the writes
131
+ * it gates are per ROW: the single TelemetryEntity row for a Kubernetes cluster
132
+ * takes one UPDATE per pod in that cluster per window, the namespace row one
133
+ * per pod in the namespace, and so on. Throttle granularity finer than write
134
+ * granularity means the throttle does not bound the writes at all, and the
135
+ * mismatch factor is the pod count — the same shape of defect as the Service
136
+ * heartbeat, one layer down.
137
+ *
138
+ * Claiming per row before the lookup also removes the redundant SELECT, not
139
+ * just the redundant UPDATE.
140
+ */
141
+ async function shouldReconcileRow(rowFenceId: string): Promise<boolean> {
142
+ try {
143
+ return await GlobalCache.setStringIfNotExists(
144
+ FENCE_NAMESPACE,
145
+ `${ROW_FENCE_SCOPE}:${hashFenceId(rowFenceId)}`,
146
+ "1",
147
+ { expiresInSeconds: GlobalCache.withJitter(FENCE_TTL_SECONDS) },
148
+ );
106
149
  } catch {
107
150
  // If the cache is down, default to running the reconcile.
108
151
  return true;
109
152
  }
110
153
  }
111
154
 
155
+ async function releaseRowFence(rowFenceId: string): Promise<void> {
156
+ try {
157
+ await GlobalCache.deleteKey(
158
+ FENCE_NAMESPACE,
159
+ `${ROW_FENCE_SCOPE}:${hashFenceId(rowFenceId)}`,
160
+ );
161
+ } catch {
162
+ // Best effort — losing the release costs one window of staleness.
163
+ }
164
+ }
165
+
112
166
  /*
113
167
  * Over-budget skips happen per row, but the warn log is fenced to once per
114
168
  * (project, entityType) per fence window — an over-budget project would
@@ -119,20 +173,12 @@ export async function shouldWarnEntityBudgetOnce(data: {
119
173
  entityType: EntityType;
120
174
  }): Promise<boolean> {
121
175
  try {
122
- const key: string = `entity-budget-warn:${data.projectId.toString()}:${
123
- data.entityType
124
- }`;
125
- const seen: string | null = await GlobalCache.getString(
176
+ return await GlobalCache.setStringIfNotExists(
126
177
  FENCE_NAMESPACE,
127
- key,
178
+ `entity-budget-warn:${data.projectId.toString()}:${data.entityType}`,
179
+ "1",
180
+ { expiresInSeconds: GlobalCache.withJitter(FENCE_TTL_SECONDS) },
128
181
  );
129
- if (seen) {
130
- return false;
131
- }
132
- await GlobalCache.setString(FENCE_NAMESPACE, key, "1", {
133
- expiresInSeconds: FENCE_TTL_SECONDS,
134
- });
135
- return true;
136
182
  } catch {
137
183
  // If the cache is down, default to warning (visibility over silence).
138
184
  return true;
@@ -230,6 +276,13 @@ export async function reconcileByNaturalKey<
230
276
  lastSeenAt: Date;
231
277
  /** Human-readable row identity for log lines, e.g. "entity host/abc123". */
232
278
  describe: string;
279
+ /**
280
+ * Stable identity of the ROW being reconciled, e.g.
281
+ * `${projectId}:${entityType}:${entityKey}`. Claimed atomically before the
282
+ * lookup so one writer per row per window does the work — see
283
+ * shouldReconcileRow for why the set-level fence above is not enough.
284
+ */
285
+ rowFenceId: string;
233
286
  /** Extra columns to select on the existing row, for `buildUpdate` diffing. */
234
287
  select?: Select<TBaseModel> | undefined;
235
288
  /**
@@ -264,6 +317,10 @@ export async function reconcileByNaturalKey<
264
317
  } as unknown as QueryDeepPartialEntity<TBaseModel>;
265
318
  };
266
319
 
320
+ if (!(await shouldReconcileRow(data.rowFenceId))) {
321
+ return;
322
+ }
323
+
267
324
  const existing: TBaseModel | null = await data.service.findOneBy({
268
325
  query: data.query,
269
326
  select,
@@ -278,15 +335,32 @@ export async function reconcileByNaturalKey<
278
335
  * audit). buildBump returns only plain values — lastSeenAt plus, at most,
279
336
  * the descriptiveAttributes / labels JSON columns — which the primitive
280
337
  * persists via the driver transformer path. See ServiceService.updateLastSeen.
338
+ *
339
+ * SKIP LOCKED: a contended bump yields rather than queueing behind the
340
+ * writer that already holds the row. Registry rows for shared entities (a
341
+ * cluster, a namespace) are written by every pod that reports them, so
342
+ * this is exactly the hot-row shape that convoyed on Service.
281
343
  */
282
- await data.service.updateColumnsByIdWithoutHooks({
283
- id: existing.id!,
284
- data: buildBump(existing),
285
- });
344
+ const wrote: boolean =
345
+ await data.service.updateColumnsByIdIfUnlockedWithoutHooks({
346
+ id: existing.id!,
347
+ data: buildBump(existing),
348
+ });
349
+
350
+ if (!wrote) {
351
+ // Skipped under contention — re-open the window so the next batch retries.
352
+ await releaseRowFence(data.rowFenceId);
353
+ }
286
354
  return;
287
355
  }
288
356
 
289
357
  if (data.beforeCreate && !(await data.beforeCreate())) {
358
+ /*
359
+ * Over budget: release the row fence so the gate is re-evaluated next
360
+ * batch rather than being suppressed for a full window by a decision that
361
+ * wrote nothing.
362
+ */
363
+ await releaseRowFence(data.rowFenceId);
290
364
  return;
291
365
  }
292
366
 
@@ -312,13 +386,20 @@ export async function reconcileByNaturalKey<
312
386
  logger.debug(
313
387
  `EntityRegistry: create raced for ${data.describe} (concurrent insert); bumping lastSeenAt on the winning row.`,
314
388
  );
315
- await data.service.updateColumnsByIdWithoutHooks({
389
+ await data.service.updateColumnsByIdIfUnlockedWithoutHooks({
316
390
  id: winner.id!,
317
391
  data: buildBump(winner),
318
392
  });
319
393
  return;
320
394
  }
321
395
 
396
+ /*
397
+ * The insert itself was invalid. Re-open the row window so the next batch
398
+ * retries rather than being silently suppressed for the TTL — a genuinely
399
+ * broken row should keep surfacing this warning, not go quiet.
400
+ */
401
+ await releaseRowFence(data.rowFenceId);
402
+
322
403
  logger.warn(`EntityRegistry: create failed for ${data.describe}:`);
323
404
  logger.warn(err as Error);
324
405
  }
@@ -0,0 +1,314 @@
1
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
2
+ import QueryDeepPartialEntity from "../../../Types/Database/PartialEntity";
3
+ import ObjectID from "../../../Types/ObjectID";
4
+ import DatabaseService from "../../Services/DatabaseService";
5
+ import GlobalCache from "../../Infrastructure/GlobalCache";
6
+ import SingleFlight from "../SingleFlight";
7
+ import logger from "../Logger";
8
+
9
+ /*
10
+ * The one place a telemetry resource's liveness heartbeat gets written.
11
+ *
12
+ * Twelve services (Service, Host, DockerHost, PodmanHost, KubernetesCluster,
13
+ * ProxmoxCluster, CephCluster, IoTFleet, CloudResource, ServerlessFunction,
14
+ * RumApplication, DockerSwarmCluster) each carried a hand-copied version of
15
+ * this: read a throttle key, compare a fingerprint, write it back, UPDATE the
16
+ * row. Copying it twelve times copied its defects twelve times, and one of
17
+ * those copies took production down.
18
+ *
19
+ * THE DEFECT. The throttle keyed on the row id but STORED a fingerprint of the
20
+ * incoming metadata, skipping only when the stored fingerprint matched. That
21
+ * works while one producer owns a row. It provides ZERO throttling the moment
22
+ * the fingerprinted payload changes as fast as the batches arrive — and on
23
+ * these paths it does, in two different ways:
24
+ *
25
+ * - Two producers of the same row disagreeing forever. `Service` is unique
26
+ * on (projectId, name) alone, so one service.name in dev and prod inside a
27
+ * project is one row with two fingerprints, each busting the other's key.
28
+ * - A single producer whose payload is a per-scrape GAUGE. The cluster and
29
+ * fleet snapshots hash things like `runningTaskCount`, `osdUpCount`,
30
+ * `onlineDeviceCount` and `capacityUsedPercent` into the fingerprint.
31
+ * A float utilisation percentage changes on literally every scrape, so
32
+ * the fingerprint never repeats and the window never engages.
33
+ *
34
+ * Either way the throttle admitted one write per BATCH rather than one per
35
+ * window, against rows shared by every node/pod/device reporting into them.
36
+ * With the autoscaler at 100 pods (~25K concurrent jobs) that was thousands of
37
+ * simultaneous UPDATEs onto ~2,300 rows. Postgres queues row-lock waiters
38
+ * strictly, so they did not collide and retry — they lined up: 1,017 active
39
+ * connections, 892 parked on locks, the tail waiting 3.7 hours. The database
40
+ * was never short of capacity.
41
+ *
42
+ * THE SHAPE. Liveness and enrichment are gated separately, because they are
43
+ * different kinds of fact:
44
+ *
45
+ * LIVENESS (`lastSeenAt`, collector status) is gated on key PRESENCE, never
46
+ * on the payload. One heartbeat per resource per window, full stop — no
47
+ * metadata, however fast it churns, can bust it.
48
+ *
49
+ * ENRICHMENT (everything harvested from the payload) is gated on TWO keys: a
50
+ * compare-and-claim so a genuinely changed value still lands promptly, plus
51
+ * a presence-only rate limiter so a permanently-churning value costs one
52
+ * write per window instead of one per batch. The fingerprint alone does not
53
+ * bound churn — it is exactly what failed.
54
+ *
55
+ * Both claims are ATOMIC. The old read-then-write was check-then-act, and at
56
+ * ingest concurrency the losers of that race do not politely retry: they all
57
+ * proceed.
58
+ *
59
+ * Underneath both, the write is `FOR UPDATE SKIP LOCKED`, so a contended
60
+ * writer yields instead of queueing. That layer holds even when every gate
61
+ * above fails open at once — which is exactly what a Redis outage does — and
62
+ * it is what makes failing open affordable. Failing open is required:
63
+ * suppressing a heartbeat strands a healthy resource as "disconnected" while
64
+ * its telemetry is still arriving (issue #3006's failure mode).
65
+ */
66
+
67
+ export interface ResourceHeartbeatWrite<TBaseModel extends BaseModel> {
68
+ service: DatabaseService<TBaseModel>;
69
+ id: ObjectID;
70
+
71
+ /**
72
+ * Redis namespace for this model's liveness key. The two enrichment keys are
73
+ * derived from it, so one namespace per model keeps all three together.
74
+ */
75
+ cacheNamespace: string;
76
+
77
+ /** Liveness window in seconds, before jitter. */
78
+ throttleInSeconds: number;
79
+
80
+ /**
81
+ * Enrichment rate-limit window in seconds, before jitter. Defaults to the
82
+ * liveness window: enrichment then lands no later than it used to, while
83
+ * the number of writes is bounded for the first time. Raise it only for
84
+ * genuinely static metadata (a service version, a cloud region), never for
85
+ * values a user watches change.
86
+ */
87
+ enrichmentThrottleInSeconds?: number | undefined;
88
+
89
+ /**
90
+ * Columns that must always be written and are safe to lose to contention —
91
+ * a concurrent writer is storing the same `now()`.
92
+ */
93
+ liveness: QueryDeepPartialEntity<TBaseModel>;
94
+
95
+ /**
96
+ * Columns harvested from the payload. Unlike liveness these can carry
97
+ * information a concurrent writer does not have, so a contended write of
98
+ * them is retried rather than dropped.
99
+ */
100
+ metadata?: QueryDeepPartialEntity<TBaseModel> | undefined;
101
+
102
+ /** Stable fingerprint of `metadata`, for change detection. */
103
+ fingerprint: string;
104
+
105
+ /** Human-readable row identity for log lines, e.g. "host abc123". */
106
+ describe: string;
107
+ }
108
+
109
+ /** Derived from the model's liveness namespace — see cacheNamespace. */
110
+ function fingerprintNamespace(cacheNamespace: string): string {
111
+ return `${cacheNamespace}-fingerprint`;
112
+ }
113
+
114
+ function writeWindowNamespace(cacheNamespace: string): string {
115
+ return `${cacheNamespace}-write-window`;
116
+ }
117
+
118
+ export default class ResourceHeartbeat {
119
+ public static async write<TBaseModel extends BaseModel>(
120
+ input: ResourceHeartbeatWrite<TBaseModel>,
121
+ ): Promise<void> {
122
+ const cacheKey: string = input.id.toString();
123
+
124
+ /*
125
+ * Collapse duplicate concurrent callers inside THIS process first. One
126
+ * OTLP batch resolves the same resource once per contained resource — the
127
+ * host-metrics collector config OneUptime ships emits hundreds per batch —
128
+ * and without this every one of them independently asks Redis the same
129
+ * question and throws the answer away.
130
+ */
131
+ await SingleFlight.run(
132
+ `${input.cacheNamespace}:${cacheKey}:${input.fingerprint}`,
133
+ async () => {
134
+ await this.writeOnce(input, cacheKey);
135
+ },
136
+ );
137
+ }
138
+
139
+ private static async writeOnce<TBaseModel extends BaseModel>(
140
+ input: ResourceHeartbeatWrite<TBaseModel>,
141
+ cacheKey: string,
142
+ ): Promise<void> {
143
+ /*
144
+ * Liveness gate. Presence-only and jittered: a fleet-wide restart would
145
+ * otherwise re-synchronise every resource's window and rebuild the herd on
146
+ * a fixed period.
147
+ */
148
+ let writeLiveness: boolean = true;
149
+ try {
150
+ writeLiveness = await GlobalCache.setStringIfNotExists(
151
+ input.cacheNamespace,
152
+ cacheKey,
153
+ "1",
154
+ {
155
+ expiresInSeconds: GlobalCache.withJitter(input.throttleInSeconds),
156
+ },
157
+ );
158
+ } catch {
159
+ /*
160
+ * Cache unavailable — fail OPEN and refresh liveness anyway. Suppressing
161
+ * the heartbeat during a Redis outage is how a healthy resource is shown
162
+ * as disconnected while its data keeps arriving. Affordable only because
163
+ * the write below cannot queue.
164
+ */
165
+ writeLiveness = true;
166
+ }
167
+
168
+ const hasMetadata: boolean =
169
+ Boolean(input.metadata) && Object.keys(input.metadata!).length > 0;
170
+
171
+ /*
172
+ * Enrichment gate 1: has the payload actually changed? Compare-and-claim,
173
+ * so a genuinely new value is written promptly rather than waiting out a
174
+ * window.
175
+ */
176
+ let metadataChanged: boolean = false;
177
+ if (hasMetadata) {
178
+ try {
179
+ metadataChanged = await GlobalCache.setStringIfChanged(
180
+ fingerprintNamespace(input.cacheNamespace),
181
+ cacheKey,
182
+ input.fingerprint,
183
+ {
184
+ expiresInSeconds: GlobalCache.withJitter(
185
+ input.enrichmentThrottleInSeconds ?? input.throttleInSeconds,
186
+ ),
187
+ },
188
+ );
189
+ } catch {
190
+ /*
191
+ * Fail CLOSED for enrichment, unlike liveness. These columns are
192
+ * descriptive; nothing is mis-reported by writing them a little later,
193
+ * and letting a Redis outage open this gate restores the per-batch
194
+ * write storm this exists to prevent.
195
+ */
196
+ metadataChanged = false;
197
+ }
198
+ }
199
+
200
+ /*
201
+ * Enrichment gate 2: the rate limiter. Change detection cannot tell a
202
+ * meaningful update apart from a gauge that moves every scrape — both
203
+ * present as "it changed". Since the churning case is unbounded, this
204
+ * bounds both.
205
+ */
206
+ let writeMetadata: boolean = false;
207
+ if (metadataChanged) {
208
+ try {
209
+ writeMetadata = await GlobalCache.setStringIfNotExists(
210
+ writeWindowNamespace(input.cacheNamespace),
211
+ cacheKey,
212
+ "1",
213
+ {
214
+ expiresInSeconds: GlobalCache.withJitter(
215
+ input.enrichmentThrottleInSeconds ?? input.throttleInSeconds,
216
+ ),
217
+ },
218
+ );
219
+ } catch {
220
+ writeMetadata = false;
221
+ }
222
+ }
223
+
224
+ if (!writeLiveness && !writeMetadata) {
225
+ // Nothing to say. Issue no statement at all.
226
+ return;
227
+ }
228
+
229
+ /*
230
+ * Liveness rides along even on an enrichment-only write: refreshing it
231
+ * early is harmless (it is a floor, not a sample), and splitting the two
232
+ * into separate statements would double the writes we just halved.
233
+ */
234
+ const data: QueryDeepPartialEntity<TBaseModel> = {
235
+ ...(input.liveness as Record<string, unknown>),
236
+ ...(writeMetadata
237
+ ? ((input.metadata || {}) as Record<string, unknown>)
238
+ : {}),
239
+ } as QueryDeepPartialEntity<TBaseModel>;
240
+
241
+ let wrote: boolean = false;
242
+
243
+ try {
244
+ wrote = await input.service.updateColumnsByIdIfUnlockedWithoutHooks({
245
+ id: input.id,
246
+ data: data,
247
+ });
248
+ } catch (err) {
249
+ /*
250
+ * Liveness must survive bad metadata. Every enrichment value is a
251
+ * collector-supplied attribute, so if one of them makes Postgres reject
252
+ * the statement it takes `lastSeenAt` down with it and the resource
253
+ * silently stops looking alive — the disconnection sweep then flips a
254
+ * perfectly healthy resource 15 minutes later with telemetry arriving
255
+ * the whole time. That was issue #3006. Retry with liveness only, so
256
+ * enrichment is what gets dropped, never the heartbeat.
257
+ */
258
+ logger.warn(
259
+ `ResourceHeartbeat: metadata write failed for ${input.describe}, falling back to a liveness-only update: ${
260
+ err instanceof Error ? err.message : String(err)
261
+ }`,
262
+ );
263
+
264
+ await this.releaseGates(input.cacheNamespace, cacheKey, writeMetadata);
265
+
266
+ await input.service.updateColumnsByIdIfUnlockedWithoutHooks({
267
+ id: input.id,
268
+ data: input.liveness,
269
+ });
270
+
271
+ return;
272
+ }
273
+
274
+ if (!wrote && writeMetadata) {
275
+ /*
276
+ * The row was locked, so this write was SKIPPED rather than applied. For
277
+ * liveness that is a non-event — whoever holds the lock is storing the
278
+ * same timestamp. Enrichment is different: the holder may be writing an
279
+ * older payload, and the gates just claimed would suppress ours for the
280
+ * rest of the window. Release them so the next batch re-attempts.
281
+ *
282
+ * This cannot become a retry storm: each attempt is a non-blocking
283
+ * statement that returns immediately when contended.
284
+ */
285
+ await this.releaseGates(input.cacheNamespace, cacheKey, true);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Re-open the gates so the next batch retries. When enrichment was at stake
291
+ * those are the gates that must not stay claimed after a write that never
292
+ * happened; otherwise it is the liveness gate.
293
+ */
294
+ private static async releaseGates(
295
+ cacheNamespace: string,
296
+ cacheKey: string,
297
+ hadMetadata: boolean,
298
+ ): Promise<void> {
299
+ const namespaces: Array<string> = hadMetadata
300
+ ? [
301
+ fingerprintNamespace(cacheNamespace),
302
+ writeWindowNamespace(cacheNamespace),
303
+ ]
304
+ : [cacheNamespace];
305
+
306
+ for (const namespace of namespaces) {
307
+ try {
308
+ await GlobalCache.deleteKey(namespace, cacheKey);
309
+ } catch {
310
+ // Best effort — the retry is what actually matters.
311
+ }
312
+ }
313
+ }
314
+ }