@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
@@ -1,5 +1,6 @@
1
1
  import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
2
2
  import HostService from "../../../Server/Services/HostService";
3
+ import SingleFlight from "../../../Server/Utils/SingleFlight";
3
4
  import Host from "../../../Models/DatabaseModels/Host";
4
5
  import ObjectID from "../../../Types/ObjectID";
5
6
  import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
@@ -50,24 +51,43 @@ type UpdateLastSeenExtra = Parameters<typeof HostService.updateLastSeen>[1];
50
51
 
51
52
  /** The argument to the hook-free UPDATE the service ultimately issues. */
52
53
  type ColumnWrite = Parameters<
53
- typeof HostService.updateColumnsByIdWithoutHooks
54
+ typeof HostService.updateColumnsByIdIfUnlockedWithoutHooks
54
55
  >[0];
55
56
 
56
57
  let writes: Array<WriteCall> = [];
57
58
  let cache: Map<string, string> = new Map<string, string>();
58
59
  let deletedCacheKeys: Array<string> = [];
59
60
 
61
+ /**
62
+ * A faithful in-memory Redis for the two atomic gate primitives: SET NX
63
+ * succeeds only on an absent key, compare-and-claim only on a differing
64
+ * value. Stubbing either to a constant would let this suite agree with a
65
+ * throttle that does not actually throttle — which is the bug that took
66
+ * production down.
67
+ */
60
68
  function mockCache(): void {
61
69
  jest
62
- .spyOn(GlobalCache, "getString")
63
- .mockImplementation(async (namespace: string, key: string) => {
64
- return cache.get(`${namespace}:${key}`) ?? null;
65
- });
70
+ .spyOn(GlobalCache, "setStringIfNotExists")
71
+ .mockImplementation(
72
+ async (namespace: string, key: string, value: string) => {
73
+ const full: string = `${namespace}:${key}`;
74
+ if (cache.has(full)) {
75
+ return false;
76
+ }
77
+ cache.set(full, value);
78
+ return true;
79
+ },
80
+ );
66
81
  jest
67
- .spyOn(GlobalCache, "setString")
82
+ .spyOn(GlobalCache, "setStringIfChanged")
68
83
  .mockImplementation(
69
84
  async (namespace: string, key: string, value: string) => {
70
- cache.set(`${namespace}:${key}`, value);
85
+ const full: string = `${namespace}:${key}`;
86
+ if (cache.get(full) === value) {
87
+ return false;
88
+ }
89
+ cache.set(full, value);
90
+ return true;
71
91
  },
72
92
  );
73
93
  jest
@@ -78,6 +98,18 @@ function mockCache(): void {
78
98
  });
79
99
  }
80
100
 
101
+ /** Re-opens the liveness window, as its TTL would. */
102
+ function expireLivenessWindow(): void {
103
+ cache.delete(`host-last-seen:${HOST_ID.toString()}`);
104
+ SingleFlight.clear();
105
+ }
106
+
107
+ /** Re-opens the enrichment rate-limit window, as its TTL would. */
108
+ function expireEnrichmentWindow(): void {
109
+ cache.delete(`host-last-seen-write-window:${HOST_ID.toString()}`);
110
+ SingleFlight.clear();
111
+ }
112
+
81
113
  function recordWrite(input: {
82
114
  id: ObjectID;
83
115
  data: unknown;
@@ -92,9 +124,11 @@ function recordWrite(input: {
92
124
  /** Records every hook-free UPDATE instead of issuing it. */
93
125
  function mockWritesSucceeding(): void {
94
126
  jest
95
- .spyOn(HostService, "updateColumnsByIdWithoutHooks")
127
+ .spyOn(HostService, "updateColumnsByIdIfUnlockedWithoutHooks")
96
128
  .mockImplementation(async (input: ColumnWrite) => {
97
129
  recordWrite(input);
130
+ // The SKIP LOCKED primitive resolves true when the row was updated.
131
+ return true;
98
132
  });
99
133
  }
100
134
 
@@ -106,12 +140,13 @@ function mockEnrichedWriteFailing(
106
140
  message: string = `value too long for type character varying(100)`,
107
141
  ): void {
108
142
  jest
109
- .spyOn(HostService, "updateColumnsByIdWithoutHooks")
143
+ .spyOn(HostService, "updateColumnsByIdIfUnlockedWithoutHooks")
110
144
  .mockImplementation(async (input: ColumnWrite) => {
111
145
  const data: Record<string, unknown> = recordWrite(input);
112
146
  if (Object.keys(data).length > 2) {
113
147
  throw new Error(message);
114
148
  }
149
+ return true;
115
150
  });
116
151
  }
117
152
 
@@ -160,7 +195,8 @@ function mockRealWritePath(): void {
160
195
  data: bound,
161
196
  });
162
197
  bound = {};
163
- return [];
198
+ // One affected row: the SKIP LOCKED write reports success via RETURNING.
199
+ return [{ _id: String(params[params.length - 1]) }];
164
200
  },
165
201
  },
166
202
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -176,11 +212,13 @@ describe("HostService.updateLastSeen", () => {
176
212
  writes = [];
177
213
  cache = new Map<string, string>();
178
214
  deletedCacheKeys = [];
215
+ SingleFlight.clear();
179
216
  mockCache();
180
217
  });
181
218
 
182
219
  afterEach(() => {
183
220
  jest.restoreAllMocks();
221
+ SingleFlight.clear();
184
222
  });
185
223
 
186
224
  describe("liveness columns", () => {
@@ -203,17 +241,118 @@ describe("HostService.updateLastSeen", () => {
203
241
 
204
242
  test("skips the write when the same metadata was written recently", async () => {
205
243
  await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
244
+ SingleFlight.clear();
206
245
  await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
207
246
 
208
247
  expect(writes).toHaveLength(1);
209
248
  });
210
249
 
211
- test("writes again as soon as any metadata changes", async () => {
250
+ /*
251
+ * A changed attribute lands in the NEXT window, not on the very next
252
+ * batch. That narrows the old "writes again immediately" contract on
253
+ * purpose: change detection cannot tell a real update apart from a gauge
254
+ * that moves every scrape — both present as "it changed" — and only one
255
+ * of the two is bounded. Bounding both is what removes the convoy, and
256
+ * the cost is at most one window of staleness on a descriptive column.
257
+ * `lastSeenAt` is unaffected; it rides the separate liveness gate.
258
+ */
259
+ test("a metadata change lands in the next window", async () => {
212
260
  await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
261
+ expireLivenessWindow();
262
+ expireEnrichmentWindow();
263
+
213
264
  await HostService.updateLastSeen(HOST_ID, { osType: "windows" });
214
265
 
266
+ expect(writes).toHaveLength(2);
267
+ expect(lastWrite()["osType"]).toBe("windows");
268
+ });
269
+
270
+ test("a metadata change cannot write twice inside one window", async () => {
271
+ await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
272
+ SingleFlight.clear();
273
+ await HostService.updateLastSeen(HOST_ID, { osType: "windows" });
274
+
275
+ expect(writes).toHaveLength(1);
276
+ });
277
+
278
+ /*
279
+ * Per-scrape gauges must NOT participate in the throttle fingerprint.
280
+ *
281
+ * cpuCores, totalMemoryBytes and processCount used to be hashed into it,
282
+ * and `system.processes.count` is partitioned by process.status and
283
+ * summed — so it changes on essentially every scrape. The fingerprint
284
+ * differed every time and the 60s window never engaged, for exactly the
285
+ * hosts producing the most batches. OneUptime's own install guide enables
286
+ * the processes scraper at a 30s interval, so every host following the
287
+ * documentation hit this.
288
+ *
289
+ * They are still WRITTEN (see below) — they just ride along on whatever
290
+ * write the throttle admits instead of forcing one.
291
+ */
292
+ test("a gauge-only change does not defeat the throttle", async () => {
293
+ await HostService.updateLastSeen(HOST_ID, {
294
+ osType: "linux",
295
+ cpuCores: 8,
296
+ totalMemoryBytes: 17179869184,
297
+ processCount: 412,
298
+ });
299
+ expireEnrichmentWindow();
300
+
301
+ await HostService.updateLastSeen(HOST_ID, {
302
+ osType: "linux",
303
+ cpuCores: 8,
304
+ totalMemoryBytes: 17179869184,
305
+ processCount: 413,
306
+ });
307
+
308
+ /*
309
+ * Both gates were re-opened, so the only thing that can suppress the
310
+ * second write is the fingerprint agreeing — which is the point: the
311
+ * gauges must not be part of it.
312
+ */
313
+ expect(writes).toHaveLength(1);
314
+ });
315
+
316
+ test("a churning process count over many scrapes still writes once", async () => {
317
+ for (let scrape: number = 0; scrape < 20; scrape++) {
318
+ expireEnrichmentWindow();
319
+ await HostService.updateLastSeen(HOST_ID, {
320
+ osType: "linux",
321
+ processCount: 400 + scrape,
322
+ });
323
+ }
324
+
325
+ expect(writes).toHaveLength(1);
326
+ });
327
+
328
+ test("a real metadata change alongside a churning gauge still writes", async () => {
329
+ await HostService.updateLastSeen(HOST_ID, {
330
+ osType: "linux",
331
+ processCount: 412,
332
+ });
333
+ expireLivenessWindow();
334
+ expireEnrichmentWindow();
335
+
336
+ await HostService.updateLastSeen(HOST_ID, {
337
+ osType: "windows",
338
+ processCount: 413,
339
+ });
340
+
215
341
  expect(writes).toHaveLength(2);
216
342
  });
343
+
344
+ test("the gauges are still written when a write happens", async () => {
345
+ await HostService.updateLastSeen(HOST_ID, {
346
+ osType: "linux",
347
+ cpuCores: 8,
348
+ totalMemoryBytes: 17179869184,
349
+ processCount: 412,
350
+ });
351
+
352
+ expect(lastWrite()["cpuCores"]).toBe(8);
353
+ expect(lastWrite()["totalMemoryBytes"]).toBe(17179869184);
354
+ expect(lastWrite()["processCount"]).toBe(412);
355
+ });
217
356
  });
218
357
 
219
358
  describe("oversized collector metadata", () => {
@@ -342,13 +481,22 @@ describe("HostService.updateLastSeen", () => {
342
481
  expect(lastWrite()["lastSeenAt"]).toBeInstanceOf(Date);
343
482
  });
344
483
 
345
- test("busts the throttle cache so the next batch is not skipped", async () => {
484
+ /*
485
+ * The enrichment gates were claimed before the write that failed, so
486
+ * without releasing them the next batch would short-circuit and the retry
487
+ * would never happen. The liveness gate is deliberately NOT released: the
488
+ * fallback write below already refreshed lastSeenAt, so re-opening it
489
+ * would buy a second heartbeat inside one window for nothing.
490
+ */
491
+ test("busts the enrichment gates so the next batch is not skipped", async () => {
346
492
  await HostService.updateLastSeen(HOST_ID, {
347
493
  hostIpAddresses: HUGE_IP_LIST,
348
494
  });
349
495
 
350
- expect(deletedCacheKeys).toHaveLength(1);
351
- expect(deletedCacheKeys[0]).toContain(HOST_ID.toString());
496
+ expect(deletedCacheKeys).toEqual([
497
+ `host-last-seen-fingerprint:${HOST_ID.toString()}`,
498
+ `host-last-seen-write-window:${HOST_ID.toString()}`,
499
+ ]);
352
500
  });
353
501
 
354
502
  test("the next batch retries rather than short-circuiting on a cache hit", async () => {
@@ -383,7 +531,7 @@ describe("HostService.updateLastSeen", () => {
383
531
  test("surfaces a genuinely broken database instead of hiding it", async () => {
384
532
  // Both attempts fail — the caller must find out.
385
533
  jest
386
- .spyOn(HostService, "updateColumnsByIdWithoutHooks")
534
+ .spyOn(HostService, "updateColumnsByIdIfUnlockedWithoutHooks")
387
535
  .mockRejectedValue(new Error("connection terminated"));
388
536
 
389
537
  await expect(
@@ -0,0 +1,222 @@
1
+ import MetricTypeService from "../../../Server/Services/MetricTypeService";
2
+ import ObjectID from "../../../Types/ObjectID";
3
+ import BadDataException from "../../../Types/Exception/BadDataException";
4
+ import { getJestSpyOn } from "../../Spy";
5
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
6
+
7
+ /*
8
+ * Pins the SQL that associates services with a metric type.
9
+ *
10
+ * The association used to be expressed by handing `updateOneById` the whole
11
+ * `services` array. Because `services` is an EntityArray, its mere PRESENCE as
12
+ * a key routes DatabaseService to `getRepository().save()` — a real
13
+ * BEGIN/COMMIT that reloads the entity, loads relation ids, bumps `version`,
14
+ * and DELETEs then re-INSERTs junction rows, holding the MetricType row's
15
+ * write lock across all of it. On a path that runs per metric name per batch
16
+ * with no backpressure, that was the only multi-round-trip lock hold in the
17
+ * pipeline.
18
+ *
19
+ * It was also losing data. save() deletes any association missing from the
20
+ * array it is handed, and an ingest worker only knows the services in ITS
21
+ * batch — so two workers deleted each other's associations and re-inserted
22
+ * them on the next batch. Permanent junction churn, with real service-to-metric
23
+ * links missing from the UI in between.
24
+ *
25
+ * The assertions below are about the emitted statement, because that is where
26
+ * both properties live: it must be a single additive INSERT, and it must never
27
+ * be able to express a delete.
28
+ *
29
+ * No Postgres — the repository is faked and the SQL is captured.
30
+ */
31
+
32
+ const METRIC_TYPE_ID: ObjectID = new ObjectID(
33
+ "44444444-4444-4444-8444-444444444444",
34
+ );
35
+ const SERVICE_A: ObjectID = new ObjectID(
36
+ "11111111-1111-4111-8111-111111111111",
37
+ );
38
+ const SERVICE_B: ObjectID = new ObjectID(
39
+ "22222222-2222-4222-8222-222222222222",
40
+ );
41
+
42
+ interface CapturedQuery {
43
+ sql: string;
44
+ params: Array<unknown>;
45
+ }
46
+
47
+ describe("MetricTypeService.attachServices", () => {
48
+ let captured: Array<CapturedQuery>;
49
+
50
+ beforeEach(() => {
51
+ jest.restoreAllMocks();
52
+ captured = [];
53
+
54
+ getJestSpyOn(MetricTypeService, "getRepository").mockReturnValue({
55
+ metadata: {
56
+ findRelationWithPropertyPath: (path: string) => {
57
+ if (path !== "services") {
58
+ return undefined;
59
+ }
60
+ return {
61
+ junctionEntityMetadata: {
62
+ tableName: "MetricTypeService",
63
+ columns: [
64
+ { databaseName: "metricTypeId" },
65
+ { databaseName: "serviceId" },
66
+ ],
67
+ },
68
+ };
69
+ },
70
+ },
71
+ manager: {
72
+ query: async (
73
+ sql: string,
74
+ params: Array<unknown>,
75
+ ): Promise<Array<unknown>> => {
76
+ captured.push({ sql, params });
77
+ return [];
78
+ },
79
+ },
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ } as any);
82
+ });
83
+
84
+ it("writes the associations in one statement", async () => {
85
+ await MetricTypeService.attachServices({
86
+ metricTypeId: METRIC_TYPE_ID,
87
+ serviceIds: [SERVICE_A, SERVICE_B],
88
+ });
89
+
90
+ expect(captured).toHaveLength(1);
91
+ });
92
+
93
+ it("inserts additively, tolerating a concurrent writer adding the same pair", async () => {
94
+ await MetricTypeService.attachServices({
95
+ metricTypeId: METRIC_TYPE_ID,
96
+ serviceIds: [SERVICE_A],
97
+ });
98
+
99
+ expect(captured[0]!.sql).toContain(
100
+ `INSERT INTO "MetricTypeService" ("metricTypeId", "serviceId")`,
101
+ );
102
+ expect(captured[0]!.sql).toContain("ON CONFLICT DO NOTHING");
103
+ });
104
+
105
+ /*
106
+ * The load-bearing negative. If a DELETE can ever be emitted here, the
107
+ * data-loss bug is expressible again.
108
+ */
109
+ it("can never emit a DELETE", async () => {
110
+ await MetricTypeService.attachServices({
111
+ metricTypeId: METRIC_TYPE_ID,
112
+ serviceIds: [SERVICE_A, SERVICE_B],
113
+ });
114
+
115
+ expect(captured[0]!.sql.toUpperCase()).not.toContain("DELETE");
116
+ });
117
+
118
+ it("never opens a transaction", async () => {
119
+ await MetricTypeService.attachServices({
120
+ metricTypeId: METRIC_TYPE_ID,
121
+ serviceIds: [SERVICE_A],
122
+ });
123
+
124
+ expect(captured[0]!.sql.toUpperCase()).not.toContain("BEGIN");
125
+ expect(captured[0]!.sql.toUpperCase()).not.toContain("COMMIT");
126
+ });
127
+
128
+ it("never touches the optimistic-lock version column", async () => {
129
+ await MetricTypeService.attachServices({
130
+ metricTypeId: METRIC_TYPE_ID,
131
+ serviceIds: [SERVICE_A],
132
+ });
133
+
134
+ expect(captured[0]!.sql).not.toContain("version");
135
+ });
136
+
137
+ it("binds the ids as parameters, never as literals", async () => {
138
+ await MetricTypeService.attachServices({
139
+ metricTypeId: METRIC_TYPE_ID,
140
+ serviceIds: [SERVICE_A, SERVICE_B],
141
+ });
142
+
143
+ expect(captured[0]!.params[0]).toBe(METRIC_TYPE_ID.toString());
144
+ expect(captured[0]!.sql).not.toContain(METRIC_TYPE_ID.toString());
145
+ });
146
+
147
+ /*
148
+ * Sorting is not cosmetic. Two workers inserting overlapping sets acquire
149
+ * their row locks in the order the rows appear; a consistent order is what
150
+ * keeps them from deadlocking each other.
151
+ */
152
+ it("sorts the ids so concurrent writers lock in a consistent order", async () => {
153
+ const ids: Array<ObjectID> = [SERVICE_B, SERVICE_A];
154
+ const expected: Array<string> = [
155
+ SERVICE_A.toString(),
156
+ SERVICE_B.toString(),
157
+ ].sort();
158
+
159
+ await MetricTypeService.attachServices({
160
+ metricTypeId: METRIC_TYPE_ID,
161
+ serviceIds: ids,
162
+ });
163
+
164
+ expect(captured[0]!.params[1]).toEqual(expected);
165
+ });
166
+
167
+ it("deduplicates repeated ids", async () => {
168
+ await MetricTypeService.attachServices({
169
+ metricTypeId: METRIC_TYPE_ID,
170
+ serviceIds: [SERVICE_A, SERVICE_A, SERVICE_A],
171
+ });
172
+
173
+ expect(captured[0]!.params[1]).toEqual([SERVICE_A.toString()]);
174
+ });
175
+
176
+ it("issues nothing for an empty set", async () => {
177
+ await MetricTypeService.attachServices({
178
+ metricTypeId: METRIC_TYPE_ID,
179
+ serviceIds: [],
180
+ });
181
+
182
+ expect(captured).toHaveLength(0);
183
+ });
184
+
185
+ it("requires a metric type id", async () => {
186
+ await expect(
187
+ MetricTypeService.attachServices({
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ metricTypeId: undefined as any,
190
+ serviceIds: [SERVICE_A],
191
+ }),
192
+ ).rejects.toThrow(BadDataException);
193
+ });
194
+
195
+ /*
196
+ * The junction table and column names come from entity metadata, never from
197
+ * a caller and never hardcoded at the call site — so a schema rename cannot
198
+ * leave this writing to a table that no longer exists.
199
+ */
200
+ it("fails loudly if the relation metadata is missing rather than guessing", async () => {
201
+ getJestSpyOn(MetricTypeService, "getRepository").mockReturnValue({
202
+ metadata: {
203
+ findRelationWithPropertyPath: () => {
204
+ return undefined;
205
+ },
206
+ },
207
+ manager: {
208
+ query: async (): Promise<Array<unknown>> => {
209
+ return [];
210
+ },
211
+ },
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ } as any);
214
+
215
+ await expect(
216
+ MetricTypeService.attachServices({
217
+ metricTypeId: METRIC_TYPE_ID,
218
+ serviceIds: [SERVICE_A],
219
+ }),
220
+ ).rejects.toThrow(BadDataException);
221
+ });
222
+ });