@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
@@ -13,6 +13,7 @@ import Model from "../../Models/DatabaseModels/Service";
13
13
  import Label from "../../Models/DatabaseModels/Label";
14
14
  import Project from "../../Models/DatabaseModels/Project";
15
15
  import GlobalCache from "../Infrastructure/GlobalCache";
16
+ import SingleFlight from "../Utils/SingleFlight";
16
17
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
17
18
  import logger, { LogAttributes } from "../Utils/Logger";
18
19
  import crypto from "crypto";
@@ -22,6 +23,20 @@ const DEFAULT_TELEMETRY_RETENTION_IN_DAYS: number = 15;
22
23
  const LAST_SEEN_CACHE_NAMESPACE: string = "service-last-seen";
23
24
  const LAST_SEEN_THROTTLE_SECONDS: number = 60;
24
25
 
26
+ /*
27
+ * Enrichment (the descriptive, collector-supplied columns) is gated
28
+ * separately from liveness — see the long note on updateLastSeen. The
29
+ * fingerprint key detects a genuine change; the write-window key bounds how
30
+ * often a service whose producers permanently disagree can act on one.
31
+ */
32
+ const METADATA_FINGERPRINT_CACHE_NAMESPACE: string =
33
+ "service-metadata-fingerprint";
34
+ const METADATA_FINGERPRINT_TTL_SECONDS: number = 60 * 60;
35
+
36
+ const METADATA_WRITE_WINDOW_CACHE_NAMESPACE: string =
37
+ "service-metadata-write-window";
38
+ const METADATA_WRITE_THROTTLE_SECONDS: number = 5 * 60;
39
+
25
40
  const LABELS_APPLIED_CACHE_NAMESPACE: string = "service-labels-applied";
26
41
  const LABELS_APPLIED_CACHE_TTL_SECONDS: number = 60;
27
42
 
@@ -119,10 +134,53 @@ export class Service extends DatabaseService<Model> {
119
134
  }
120
135
 
121
136
  /*
122
- * Refresh `lastSeenAt` for a service. Throttled per-service so the
123
- * steady-state telemetry firehose (every metric/log/trace batch
124
- * re-resolves the same serviceId) costs one in-memory cache lookup
125
- * per batch instead of a DB write.
137
+ * Refresh `lastSeenAt` (and, occasionally, the descriptive columns) for a
138
+ * service. This is the single hottest UPDATE in the system: every
139
+ * metric/log/trace/profile batch re-resolves the same serviceId, and every
140
+ * OTLP resource inside a batch does it again.
141
+ *
142
+ * It is also the write that took production down, so the throttling here is
143
+ * deliberate in a way worth reading before changing.
144
+ *
145
+ * WHAT WENT WRONG. The throttle used to key on the serviceId but STORE a
146
+ * fingerprint of the metadata, and skip only when the stored fingerprint
147
+ * matched. That works when one producer owns a row. It collapses when two
148
+ * producers of the SAME row disagree, because `Service` is unique on
149
+ * (projectId, name) alone: the same service.name running in dev and prod
150
+ * inside one project is ONE row with two permanently disagreeing
151
+ * fingerprints. A writes fp_A, B reads fp_A, sees a mismatch, writes fp_B, A
152
+ * reads fp_B, sees a mismatch... forever. The same holds for active-active
153
+ * deployments differing in cloud.region or cloud.account.id, and transiently
154
+ * for every rolling deploy (service.version). For those rows the throttle
155
+ * admitted not one write per minute but one write per BATCH — and with the
156
+ * autoscaler at 100 pods (~25K concurrent jobs) that was thousands of
157
+ * simultaneous UPDATEs onto ~2,300 rows. Postgres queues row-lock waiters
158
+ * strictly, so they did not collide and retry, they lined up: 1,017 active
159
+ * connections, 892 parked on locks, the tail waiting 3.7 hours. The database
160
+ * was never short of capacity.
161
+ *
162
+ * The shape below separates the two things that were tangled together:
163
+ *
164
+ * LIVENESS (`lastSeenAt`) is gated on key PRESENCE, never on metadata. One
165
+ * heartbeat per service per window, full stop — no payload can bust it, so
166
+ * disagreeing producers cannot flap it.
167
+ *
168
+ * ENRICHMENT (the descriptive columns) is gated on TWO keys: a fingerprint
169
+ * claim so a genuinely changed attribute still lands promptly, plus a
170
+ * presence-only rate limiter so a permanently-flapping service costs one
171
+ * enrichment write per rate-limit window instead of one per batch. The
172
+ * fingerprint key alone does not fix flapping — a longer TTL just makes
173
+ * each flap more expensive.
174
+ *
175
+ * Both claims are ATOMIC (SET NX / compare-and-claim in one round trip).
176
+ * The old read-then-write was check-then-act, and at this concurrency the
177
+ * losers of that race do not politely retry — they all proceed.
178
+ *
179
+ * Underneath both, the write itself is non-blocking (SKIP LOCKED), so a
180
+ * convoy cannot form even if every gate above fails open at once — which is
181
+ * exactly what a Redis outage does. That layering is what makes fail-open
182
+ * affordable here, and fail-open is required: a suppressed heartbeat strands
183
+ * a healthy service as "disconnected" while its telemetry keeps arriving.
126
184
  */
127
185
  @CaptureSpan()
128
186
  public async updateLastSeen(
@@ -140,90 +198,258 @@ export class Service extends DatabaseService<Model> {
140
198
  cloudAccountId?: string | undefined;
141
199
  },
142
200
  ): Promise<void> {
201
+ const cacheKey: string = serviceId.toString();
202
+
143
203
  /*
144
- * Throttle keyed on a fingerprint of the metadata so the steady-state
145
- * firehose (the same serviceId re-resolved every batch) costs one
146
- * in-memory cache lookup, but a changed attribute (e.g. a new
147
- * service.version after a deploy) busts the cache and writes
148
- * immediately. With no extras the fingerprint is constant, preserving
149
- * the original "refresh lastSeenAt at most once per window" behaviour.
204
+ * Collapse duplicate concurrent callers inside THIS process first. One
205
+ * OTLP batch resolves the same service once per resource the shipped
206
+ * host-metrics collector config produces hundreds per batch and without
207
+ * this every one of them independently asks Redis the same question.
150
208
  */
151
- const cacheKey: string = serviceId.toString();
152
- const fingerprint: string = this.fingerprintLastSeenExtras(extra);
153
- const cached: string | null = await GlobalCache.getString(
154
- LAST_SEEN_CACHE_NAMESPACE,
155
- cacheKey,
209
+ await SingleFlight.run(
210
+ `${LAST_SEEN_CACHE_NAMESPACE}:${cacheKey}:${this.fingerprintLastSeenExtras(
211
+ extra,
212
+ )}`,
213
+ async () => {
214
+ await this.writeLastSeen(serviceId, cacheKey, extra);
215
+ },
156
216
  );
217
+ }
157
218
 
158
- if (cached === fingerprint) {
159
- return;
219
+ private async writeLastSeen(
220
+ serviceId: ObjectID,
221
+ cacheKey: string,
222
+ extra?: {
223
+ serviceVersion?: string | undefined;
224
+ deploymentEnvironment?: string | undefined;
225
+ serviceNamespace?: string | undefined;
226
+ runtimeName?: string | undefined;
227
+ runtimeVersion?: string | undefined;
228
+ telemetrySdkLanguage?: string | undefined;
229
+ cloudProvider?: string | undefined;
230
+ cloudPlatform?: string | undefined;
231
+ cloudRegion?: string | undefined;
232
+ cloudAccountId?: string | undefined;
233
+ },
234
+ ): Promise<void> {
235
+ const fingerprint: string = this.fingerprintLastSeenExtras(extra);
236
+
237
+ /*
238
+ * Liveness gate. Presence-only and jittered: a fleet-wide restart would
239
+ * otherwise re-synchronise every service's window and rebuild the herd on
240
+ * a one-minute period.
241
+ */
242
+ let writeLiveness: boolean = true;
243
+ try {
244
+ writeLiveness = await GlobalCache.setStringIfNotExists(
245
+ LAST_SEEN_CACHE_NAMESPACE,
246
+ cacheKey,
247
+ "1",
248
+ {
249
+ expiresInSeconds: GlobalCache.withJitter(LAST_SEEN_THROTTLE_SECONDS),
250
+ },
251
+ );
252
+ } catch {
253
+ /*
254
+ * Cache unavailable — fail OPEN and refresh liveness anyway. Suppressing
255
+ * the heartbeat during a Redis outage is how a healthy service gets
256
+ * shown as disconnected while its data keeps arriving (issue #3006's
257
+ * failure mode). Affordable only because the write below cannot queue.
258
+ */
259
+ writeLiveness = true;
160
260
  }
161
261
 
162
- await GlobalCache.setString(
163
- LAST_SEEN_CACHE_NAMESPACE,
164
- cacheKey,
165
- fingerprint,
166
- {
167
- expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS,
168
- },
169
- );
262
+ /*
263
+ * Enrichment gate 1: has the payload actually changed? Compare-and-claim,
264
+ * so a deploy's new service.version is written promptly rather than
265
+ * waiting out a window.
266
+ */
267
+ let metadataChanged: boolean = false;
268
+ try {
269
+ metadataChanged = await GlobalCache.setStringIfChanged(
270
+ METADATA_FINGERPRINT_CACHE_NAMESPACE,
271
+ cacheKey,
272
+ fingerprint,
273
+ {
274
+ expiresInSeconds: GlobalCache.withJitter(
275
+ METADATA_FINGERPRINT_TTL_SECONDS,
276
+ ),
277
+ },
278
+ );
279
+ } catch {
280
+ /*
281
+ * Fail CLOSED for enrichment (unlike liveness). These columns are
282
+ * descriptive, not operational — nothing is mis-reported by writing them
283
+ * a little later, and letting a Redis outage open this gate would
284
+ * restore the per-batch write storm this method exists to prevent.
285
+ */
286
+ metadataChanged = false;
287
+ }
288
+
289
+ /*
290
+ * Enrichment gate 2: rate limiter. A service whose producers permanently
291
+ * disagree (dev + prod under one name, active-active regions) reports a
292
+ * "change" on every batch forever. Gate 1 cannot tell that apart from a
293
+ * real deploy, so this bounds it regardless.
294
+ */
295
+ let writeMetadata: boolean = false;
296
+ if (metadataChanged) {
297
+ try {
298
+ writeMetadata = await GlobalCache.setStringIfNotExists(
299
+ METADATA_WRITE_WINDOW_CACHE_NAMESPACE,
300
+ cacheKey,
301
+ "1",
302
+ {
303
+ expiresInSeconds: GlobalCache.withJitter(
304
+ METADATA_WRITE_THROTTLE_SECONDS,
305
+ ),
306
+ },
307
+ );
308
+ } catch {
309
+ writeMetadata = false;
310
+ }
311
+ }
312
+
313
+ if (!writeLiveness && !writeMetadata) {
314
+ // Nothing to say. Issue no statement at all.
315
+ return;
316
+ }
170
317
 
171
318
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
- const data: any = {
319
+ const liveness: any = {
173
320
  lastSeenAt: OneUptimeDate.getCurrentDate(),
174
321
  };
175
322
 
176
- if (extra?.serviceVersion) {
177
- data.serviceVersion = extra.serviceVersion;
178
- }
179
- if (extra?.deploymentEnvironment) {
180
- data.deploymentEnvironment = extra.deploymentEnvironment;
181
- }
182
- if (extra?.serviceNamespace) {
183
- data.serviceNamespace = extra.serviceNamespace;
184
- }
185
- if (extra?.runtimeName) {
186
- data.runtimeName = extra.runtimeName;
187
- }
188
- if (extra?.runtimeVersion) {
189
- data.runtimeVersion = extra.runtimeVersion;
190
- }
191
- if (extra?.telemetrySdkLanguage) {
192
- data.telemetrySdkLanguage = extra.telemetrySdkLanguage;
193
- }
194
- if (extra?.cloudProvider) {
195
- data.cloudProvider = extra.cloudProvider;
196
- }
197
- if (extra?.cloudPlatform) {
198
- data.cloudPlatform = extra.cloudPlatform;
199
- }
200
- if (extra?.cloudRegion) {
201
- data.cloudRegion = extra.cloudRegion;
202
- }
203
- if (extra?.cloudAccountId) {
204
- data.cloudAccountId = extra.cloudAccountId;
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ const metadata: any = {};
325
+
326
+ if (writeMetadata) {
327
+ if (extra?.serviceVersion) {
328
+ metadata.serviceVersion = extra.serviceVersion;
329
+ }
330
+ if (extra?.deploymentEnvironment) {
331
+ metadata.deploymentEnvironment = extra.deploymentEnvironment;
332
+ }
333
+ if (extra?.serviceNamespace) {
334
+ metadata.serviceNamespace = extra.serviceNamespace;
335
+ }
336
+ if (extra?.runtimeName) {
337
+ metadata.runtimeName = extra.runtimeName;
338
+ }
339
+ if (extra?.runtimeVersion) {
340
+ metadata.runtimeVersion = extra.runtimeVersion;
341
+ }
342
+ if (extra?.telemetrySdkLanguage) {
343
+ metadata.telemetrySdkLanguage = extra.telemetrySdkLanguage;
344
+ }
345
+ if (extra?.cloudProvider) {
346
+ metadata.cloudProvider = extra.cloudProvider;
347
+ }
348
+ if (extra?.cloudPlatform) {
349
+ metadata.cloudPlatform = extra.cloudPlatform;
350
+ }
351
+ if (extra?.cloudRegion) {
352
+ metadata.cloudRegion = extra.cloudRegion;
353
+ }
354
+ if (extra?.cloudAccountId) {
355
+ metadata.cloudAccountId = extra.cloudAccountId;
356
+ }
205
357
  }
206
358
 
359
+ const hasMetadata: boolean = Object.keys(metadata).length > 0;
360
+
207
361
  /*
208
- * Heartbeat write. This is the single hottest UPDATE in the system —
209
- * every telemetry batch for a service re-resolves the same serviceId
210
- * so it must NOT go through the full updateOneById pipeline (permission
211
- * SELECT -> _findBy SELECT -> save() with `version = version + 1` ->
212
- * workflow/realtime/audit hooks). That pipeline holds the Service row's
213
- * write lock across several round trips and, when an event loop stalls
214
- * mid-`save()` transaction, leaves it open as the head of a lock convoy
215
- * that starves the Postgres connection pool. lastSeenAt is an internal
216
- * liveness timestamp with no onBeforeUpdate/onUpdateSuccess hooks on this
217
- * model (connected / disconnected status is derived from it at read
218
- * time), so a bare single-statement UPDATE is both correct and far
219
- * cheaper. This also intentionally drops the per-update workflow trigger
220
- * Service's @EnableWorkflow({ update: true }) fired on every heartbeat —
221
- * a liveness ping should not trigger user workflows.
362
+ * `lastSeenAt` rides along even on an enrichment-only write: refreshing it
363
+ * early is harmless (it is a liveness floor, not a sample), and splitting
364
+ * the two into separate statements would double the writes we just went to
365
+ * such lengths to halve.
222
366
  */
223
- await this.updateColumnsByIdWithoutHooks({
224
- id: serviceId,
225
- data: data,
226
- });
367
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
368
+ const data: any = { ...liveness, ...metadata };
369
+
370
+ /*
371
+ * Heartbeat write. Deliberately NOT the full updateOneById pipeline
372
+ * (permission SELECT -> _findBy SELECT -> save() with `version = version +
373
+ * 1` -> workflow/realtime/audit hooks): that pipeline holds the row's
374
+ * write lock across several round trips inside an open transaction, which
375
+ * is precisely how a stalled event loop becomes the head of a lock convoy.
376
+ * It also intentionally drops the per-update workflow trigger that
377
+ * Service's @EnableWorkflow({ update: true }) fired on every heartbeat — a
378
+ * liveness ping should not trigger user workflows.
379
+ *
380
+ * And it is the SKIP LOCKED variant: contended writers yield instead of
381
+ * queueing, capping backends blocked on any one Service row at one.
382
+ */
383
+ let wrote: boolean = false;
384
+
385
+ try {
386
+ wrote = await this.updateColumnsByIdIfUnlockedWithoutHooks({
387
+ id: serviceId,
388
+ data: data,
389
+ });
390
+ } catch (err) {
391
+ /*
392
+ * Liveness must survive bad metadata. Every enrichment value is a
393
+ * collector-supplied resource attribute, so if one of them makes
394
+ * Postgres reject the statement it takes `lastSeenAt` down with it and
395
+ * the service silently stops looking alive. Retry with liveness only, so
396
+ * enrichment is what gets dropped, never the heartbeat. (Issue #3006 was
397
+ * this exact failure on Host.)
398
+ */
399
+ logger.warn(
400
+ `ServiceService.updateLastSeen: metadata write failed for service ${serviceId.toString()}, falling back to a liveness-only update: ${
401
+ err instanceof Error ? err.message : String(err)
402
+ }`,
403
+ );
404
+
405
+ await this.clearLastSeenGates(cacheKey, hasMetadata);
406
+
407
+ await this.updateColumnsByIdIfUnlockedWithoutHooks({
408
+ id: serviceId,
409
+ data: liveness,
410
+ });
411
+
412
+ return;
413
+ }
414
+
415
+ if (!wrote && hasMetadata) {
416
+ /*
417
+ * The row was locked, so this write was SKIPPED rather than applied. For
418
+ * liveness that is a non-event — whoever holds the lock is storing the
419
+ * same timestamp. Enrichment is different: the holder may be writing an
420
+ * older payload, and the gates we just claimed would suppress ours for
421
+ * the rest of the window. Release them so the next batch re-attempts.
422
+ *
423
+ * This cannot become a retry storm — each attempt is a non-blocking
424
+ * statement that returns immediately when contended.
425
+ */
426
+ await this.clearLastSeenGates(cacheKey, true);
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Re-open the gates so the next batch retries. Liveness is only re-opened
432
+ * when there was no enrichment at stake; otherwise the enrichment gates are
433
+ * the ones that must not stay claimed after a write that never happened.
434
+ */
435
+ private async clearLastSeenGates(
436
+ cacheKey: string,
437
+ hadMetadata: boolean,
438
+ ): Promise<void> {
439
+ const namespaces: Array<string> = hadMetadata
440
+ ? [
441
+ METADATA_FINGERPRINT_CACHE_NAMESPACE,
442
+ METADATA_WRITE_WINDOW_CACHE_NAMESPACE,
443
+ ]
444
+ : [LAST_SEEN_CACHE_NAMESPACE];
445
+
446
+ for (const namespace of namespaces) {
447
+ try {
448
+ await GlobalCache.deleteKey(namespace, cacheKey);
449
+ } catch {
450
+ // Best effort — the retry below is what actually matters.
451
+ }
452
+ }
227
453
  }
228
454
 
229
455
  private fingerprintLastSeenExtras(extra?: {