@oneuptime/common 12.0.0 → 12.0.2

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 (237) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -229,11 +229,14 @@ export class Service extends DatabaseService<Model> {
229
229
  }
230
230
 
231
231
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
- const data: any = {
232
+ const liveness: any = {
233
233
  lastSeenAt: OneUptimeDate.getCurrentDate(),
234
234
  otelCollectorStatus: "connected",
235
235
  };
236
236
 
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ const data: any = { ...liveness };
239
+
237
240
  if (extra?.cephVersion) {
238
241
  data.cephVersion = extra.cephVersion;
239
242
  }
@@ -273,11 +276,50 @@ export class Service extends DatabaseService<Model> {
273
276
  * Heartbeat write: a single-statement UPDATE with no hooks and no
274
277
  * `version` bump, avoiding the hot-row Postgres lock convoy that the
275
278
  * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
279
+ *
280
+ * Collector-supplied strings are clamped to their declared column
281
+ * widths inside updateColumnsByIdWithoutHooks — see
282
+ * DatabaseService.truncateStringColumnsToMaxLength.
276
283
  */
277
- await this.updateColumnsByIdWithoutHooks({
278
- id: clusterId,
279
- data: data,
280
- });
284
+ try {
285
+ await this.updateColumnsByIdWithoutHooks({
286
+ id: clusterId,
287
+ data: data,
288
+ });
289
+ } catch (err) {
290
+ /*
291
+ * Liveness must survive bad metadata. The enriched write carries
292
+ * `lastSeenAt` and `otelCollectorStatus` alongside optional,
293
+ * collector-supplied columns; if any one of them makes Postgres
294
+ * reject the statement, lastSeenAt silently stops advancing and
295
+ * markDisconnectedClusters flips a perfectly healthy Ceph cluster to
296
+ * "disconnected" 15 minutes later — with telemetry still arriving the
297
+ * whole time. That was issue #3006 on the Host path (a host.ip list
298
+ * longer than the then-varchar(500) column). Retry with liveness only
299
+ * so the enrichment is what gets dropped, never the heartbeat.
300
+ */
301
+ logger.warn(
302
+ `CephClusterService.updateLastSeen: metadata write failed for Ceph cluster ${clusterId.toString()}, falling back to a liveness-only update: ${
303
+ err instanceof Error ? err.message : String(err)
304
+ }`,
305
+ );
306
+
307
+ /*
308
+ * The throttle fingerprint was already stored, so without this the
309
+ * next batch would short-circuit on a cache hit and skip the retry
310
+ * for the rest of the window.
311
+ */
312
+ try {
313
+ await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
314
+ } catch {
315
+ // Best-effort; the fallback write below is what actually matters.
316
+ }
317
+
318
+ await this.updateColumnsByIdWithoutHooks({
319
+ id: clusterId,
320
+ data: liveness,
321
+ });
322
+ }
281
323
  }
282
324
 
283
325
  /**
@@ -1,8 +1,10 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/CephResource";
3
3
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ColumnLength from "../../Types/Database/ColumnLength";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import OneUptimeDate from "../../Types/Date";
7
+ import { truncateShortText } from "../Utils/Database/TruncateColumnValue";
6
8
  import logger from "../Utils/Logger";
7
9
 
8
10
  /*
@@ -86,6 +88,26 @@ const UPSERT_COLUMNS: Array<string> = [
86
88
  "version",
87
89
  ];
88
90
 
91
+ /*
92
+ * CephResource's text columns are all ShortText (100 chars). The bulk
93
+ * paths below go through manager.query(), which skips the length
94
+ * validation DatabaseService applies to ordinary writes, so a single
95
+ * oversized value aborts the whole 500-row INSERT chunk it rides in.
96
+ * Clamp per value instead, so one pathological daemon can never drop
97
+ * the rest of the scrape.
98
+ */
99
+ function sanitizeResource(r: ParsedCephResource): ParsedCephResource {
100
+ return {
101
+ ...r,
102
+ kind: truncateShortText(r.kind),
103
+ externalId: truncateShortText(r.externalId),
104
+ name: truncateShortText(r.name),
105
+ hostname: truncateShortText(r.hostname),
106
+ daemonVersion: truncateShortText(r.daemonVersion),
107
+ deviceClass: truncateShortText(r.deviceClass),
108
+ };
109
+ }
110
+
89
111
  export class Service extends DatabaseService<Model> {
90
112
  public constructor() {
91
113
  super(Model);
@@ -112,9 +134,21 @@ export class Service extends DatabaseService<Model> {
112
134
  return;
113
135
  }
114
136
 
137
+ const resources: Array<ParsedCephResource> = data.resources.map(
138
+ (r: ParsedCephResource) => {
139
+ const sanitized: ParsedCephResource = sanitizeResource(r);
140
+ if (sanitized.externalId !== r.externalId) {
141
+ logger.warn(
142
+ `CephResource externalId exceeds ${ColumnLength.ShortText} chars; truncated to "${sanitized.externalId}" (cluster ${data.cephClusterId.toString()}).`,
143
+ );
144
+ }
145
+ return sanitized;
146
+ },
147
+ );
148
+
115
149
  // Chunk to keep individual statement parameter counts reasonable.
116
- for (let i: number = 0; i < data.resources.length; i += UPSERT_BATCH_SIZE) {
117
- const chunk: Array<ParsedCephResource> = data.resources.slice(
150
+ for (let i: number = 0; i < resources.length; i += UPSERT_BATCH_SIZE) {
151
+ const chunk: Array<ParsedCephResource> = resources.slice(
118
152
  i,
119
153
  i + UPSERT_BATCH_SIZE,
120
154
  );
@@ -218,9 +252,14 @@ export class Service extends DatabaseService<Model> {
218
252
  valueFragments.push(
219
253
  `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::numeric, $${paramIndex++}::numeric, $${paramIndex++}::integer, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::timestamptz)`,
220
254
  );
255
+ /*
256
+ * The identity clamps MUST match bulkUpsert's, or this mirror
257
+ * UPDATE would silently miss the row the upsert just wrote
258
+ * under the truncated externalId.
259
+ */
221
260
  params.push(
222
- m.kind,
223
- m.externalId,
261
+ truncateShortText(m.kind),
262
+ truncateShortText(m.externalId),
224
263
  bigintOrNull(m.statBytes),
225
264
  bigintOrNull(m.statBytesUsed),
226
265
  m.applyLatencyMs !== null && m.applyLatencyMs !== undefined
@@ -199,11 +199,14 @@ export class Service extends DatabaseService<Model> {
199
199
  }
200
200
 
201
201
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
- const data: any = {
202
+ const liveness: any = {
203
203
  lastSeenAt: OneUptimeDate.getCurrentDate(),
204
204
  otelCollectorStatus: "connected",
205
205
  };
206
206
 
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ const data: any = { ...liveness };
209
+
207
210
  if (extra?.agentVersion) {
208
211
  data.agentVersion = extra.agentVersion;
209
212
  }
@@ -230,11 +233,50 @@ export class Service extends DatabaseService<Model> {
230
233
  * Heartbeat write: a single-statement UPDATE with no hooks and no
231
234
  * `version` bump, avoiding the hot-row Postgres lock convoy that the
232
235
  * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
236
+ *
237
+ * Collector-supplied strings are clamped to their declared column
238
+ * widths inside updateColumnsByIdWithoutHooks — see
239
+ * DatabaseService.truncateStringColumnsToMaxLength.
233
240
  */
234
- await this.updateColumnsByIdWithoutHooks({
235
- id: cloudResourceId,
236
- data: data,
237
- });
241
+ try {
242
+ await this.updateColumnsByIdWithoutHooks({
243
+ id: cloudResourceId,
244
+ data: data,
245
+ });
246
+ } catch (err) {
247
+ /*
248
+ * Liveness must survive bad metadata. The enriched write carries
249
+ * `lastSeenAt` and `otelCollectorStatus` alongside optional,
250
+ * collector-supplied columns; if any one of them makes Postgres
251
+ * reject the statement, lastSeenAt silently stops advancing and
252
+ * markDisconnectedResources flips a perfectly healthy cloud resource
253
+ * to "disconnected" 15 minutes later — with telemetry still arriving
254
+ * the whole time. That was issue #3006 on the Host path (a host.ip
255
+ * list longer than the then-varchar(500) column). Retry with liveness
256
+ * only so the enrichment is what gets dropped, never the heartbeat.
257
+ */
258
+ logger.warn(
259
+ `CloudResourceService.updateLastSeen: metadata write failed for cloud resource ${cloudResourceId.toString()}, falling back to a liveness-only update: ${
260
+ err instanceof Error ? err.message : String(err)
261
+ }`,
262
+ );
263
+
264
+ /*
265
+ * The throttle fingerprint was already stored, so without this the
266
+ * next batch would short-circuit on a cache hit and skip the retry
267
+ * for the rest of the window.
268
+ */
269
+ try {
270
+ await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
271
+ } catch {
272
+ // Best-effort; the fallback write below is what actually matters.
273
+ }
274
+
275
+ await this.updateColumnsByIdWithoutHooks({
276
+ id: cloudResourceId,
277
+ data: liveness,
278
+ });
279
+ }
238
280
  }
239
281
 
240
282
  @CaptureSpan()
@@ -1046,6 +1046,61 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1046
1046
  return data;
1047
1047
  }
1048
1048
 
1049
+ /*
1050
+ * Clamp string values to the max length their column declares, instead of
1051
+ * rejecting the whole write the way checkMaxLengthOfFields does.
1052
+ *
1053
+ * For machine-stamped metadata harvested from OpenTelemetry resource
1054
+ * attributes there is no user to show a validation error to, and the
1055
+ * write these values ride along with also carries liveness columns
1056
+ * (lastSeenAt / otelCollectorStatus). Letting one oversized optional
1057
+ * attribute abort that statement strands a healthy resource as
1058
+ * "disconnected" — the failure mode in issue #3006. A clipped display
1059
+ * string is strictly better than a lost heartbeat.
1060
+ *
1061
+ * Only string values on columns with a declared max length are touched;
1062
+ * `text` columns (VeryLongText and friends) declare none and pass
1063
+ * through untouched, as do numbers, dates and ObjectIDs.
1064
+ *
1065
+ * The raw, hook-free write paths (updateColumnsByIdWithoutHooks and
1066
+ * atomicAddToColumnsByIdWithoutHooks) call this themselves, so callers do
1067
+ * NOT need to — and must not rely on being able to skip it by writing a
1068
+ * new raw path of their own. It is NOT a substitute for sizing a column
1069
+ * correctly: if a field is legitimately long, widen the column.
1070
+ */
1071
+ public truncateStringColumnsToMaxLength(
1072
+ data: Record<string, unknown>,
1073
+ ): Record<string, unknown> {
1074
+ for (const column of Object.keys(data)) {
1075
+ const value: unknown = data[column];
1076
+
1077
+ if (typeof value !== "string" || value.length === 0) {
1078
+ continue;
1079
+ }
1080
+
1081
+ /*
1082
+ * Returns undefined for an unmapped column name despite its type —
1083
+ * leave those alone and let the write path complain about them.
1084
+ */
1085
+ const metadata: TableColumnMetadata | undefined =
1086
+ this.model.getTableColumnMetadata(column);
1087
+
1088
+ if (!metadata?.type) {
1089
+ continue;
1090
+ }
1091
+
1092
+ const maxLength: number | undefined = getMaxLengthFromTableColumnType(
1093
+ metadata.type,
1094
+ );
1095
+
1096
+ if (maxLength !== undefined && value.length > maxLength) {
1097
+ data[column] = value.substring(0, maxLength);
1098
+ }
1099
+ }
1100
+
1101
+ return data;
1102
+ }
1103
+
1049
1104
  private async checkTotalItemsBy(
1050
1105
  createdBy: CreateBy<TBaseModel>,
1051
1106
  ): Promise<void> {
@@ -2248,6 +2303,17 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
2248
2303
  * the caller), and every value is bound as a parameter, so this is not an
2249
2304
  * injection surface. Use ONLY for trusted, internal, side-effect-free
2250
2305
  * column writes where the full pipeline is pure overhead.
2306
+ *
2307
+ * Oversized strings are CLAMPED here (truncateStringColumnsToMaxLength)
2308
+ * rather than rejected. Skipping the hooks also skips
2309
+ * checkMaxLengthOfFields, so before this an over-long value reached
2310
+ * Postgres raw and failed the ENTIRE statement — including whatever else
2311
+ * rode along with it. On the telemetry liveness writes that is fatal: one
2312
+ * over-long OpenTelemetry resource attribute took `lastSeenAt` down with
2313
+ * it and markDisconnected* stranded a healthy resource as "disconnected"
2314
+ * 15 minutes later while its data kept arriving (issue #3006). Clamping
2315
+ * in here, at the choke point, is what makes that unforgettable — every
2316
+ * caller of this path gets it, including ones not written yet.
2251
2317
  */
2252
2318
  @CaptureSpan()
2253
2319
  public async updateColumnsByIdWithoutHooks(input: {
@@ -2273,9 +2339,16 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
2273
2339
  const setClauses: Array<string> = [];
2274
2340
  const params: Array<unknown> = [];
2275
2341
 
2276
- for (const [propertyName, value] of Object.entries(
2277
- input.data as ObjectLiteral,
2278
- )) {
2342
+ /*
2343
+ * Clamp on a shallow COPY: callers must not have their own object
2344
+ * mutated behind their back (a liveness-only retry, for instance,
2345
+ * rebuilds its payload from the same source values).
2346
+ */
2347
+ const data: ObjectLiteral = this.truncateStringColumnsToMaxLength({
2348
+ ...(input.data as ObjectLiteral),
2349
+ });
2350
+
2351
+ for (const [propertyName, value] of Object.entries(data)) {
2279
2352
  const column: ColumnMetadata | undefined =
2280
2353
  metadata.findColumnWithPropertyName(propertyName);
2281
2354
  if (!column) {
@@ -2347,6 +2420,11 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
2347
2420
  * caller) and every value is bound as a parameter, so this is not an
2348
2421
  * injection surface. Use ONLY for trusted, internal, side-effect-free
2349
2422
  * column writes.
2423
+ *
2424
+ * `set` values are clamped to their column widths for the same reason
2425
+ * updateColumnsByIdWithoutHooks clamps: this path also skips
2426
+ * checkMaxLengthOfFields, and an over-long string here would abort the
2427
+ * counter increments riding in the same statement.
2350
2428
  */
2351
2429
  @CaptureSpan()
2352
2430
  public async atomicAddToColumnsByIdWithoutHooks(input: {
@@ -2393,9 +2471,12 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
2393
2471
  setClauses.push(`${quoted} = COALESCE(${quoted}, 0) + $${params.length}`);
2394
2472
  }
2395
2473
 
2396
- for (const [propertyName, value] of Object.entries(
2397
- (input.set || {}) as ObjectLiteral,
2398
- )) {
2474
+ // Shallow copy never mutate the caller's object. See the clamp note above.
2475
+ const set: ObjectLiteral = this.truncateStringColumnsToMaxLength({
2476
+ ...((input.set || {}) as ObjectLiteral),
2477
+ });
2478
+
2479
+ for (const [propertyName, value] of Object.entries(set)) {
2399
2480
  if (typeof value === "function") {
2400
2481
  throw new BadDataException(
2401
2482
  `atomicAddToColumnsByIdWithoutHooks: SQL-expression values are not supported (column "${propertyName}"); pass a literal value.`,
@@ -221,11 +221,14 @@ export class Service extends DatabaseService<Model> {
221
221
  }
222
222
 
223
223
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
- const data: any = {
224
+ const liveness: any = {
225
225
  lastSeenAt: OneUptimeDate.getCurrentDate(),
226
226
  otelCollectorStatus: "connected",
227
227
  };
228
228
 
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ const data: any = { ...liveness };
231
+
229
232
  if (extra?.osType) {
230
233
  data.osType = extra.osType;
231
234
  }
@@ -240,11 +243,50 @@ export class Service extends DatabaseService<Model> {
240
243
  * Heartbeat write: a single-statement UPDATE with no hooks and no
241
244
  * `version` bump, avoiding the hot-row Postgres lock convoy that the
242
245
  * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
246
+ *
247
+ * Collector-supplied strings are clamped to their declared column
248
+ * widths inside updateColumnsByIdWithoutHooks — see
249
+ * DatabaseService.truncateStringColumnsToMaxLength.
243
250
  */
244
- await this.updateColumnsByIdWithoutHooks({
245
- id: hostId,
246
- data: data,
247
- });
251
+ try {
252
+ await this.updateColumnsByIdWithoutHooks({
253
+ id: hostId,
254
+ data: data,
255
+ });
256
+ } catch (err) {
257
+ /*
258
+ * Liveness must survive bad metadata. The enriched write carries
259
+ * `lastSeenAt` and `otelCollectorStatus` alongside optional,
260
+ * collector-supplied columns; if any one of them makes Postgres
261
+ * reject the statement, lastSeenAt silently stops advancing and
262
+ * markDisconnectedHosts flips a perfectly healthy Docker host to
263
+ * "disconnected" 15 minutes later — with telemetry still arriving the
264
+ * whole time. That was issue #3006 on the Host path (a host.ip list
265
+ * longer than the then-varchar(500) column). Retry with liveness only
266
+ * so the enrichment is what gets dropped, never the heartbeat.
267
+ */
268
+ logger.warn(
269
+ `DockerHostService.updateLastSeen: metadata write failed for Docker host ${hostId.toString()}, falling back to a liveness-only update: ${
270
+ err instanceof Error ? err.message : String(err)
271
+ }`,
272
+ );
273
+
274
+ /*
275
+ * The throttle fingerprint was already stored, so without this the
276
+ * next batch would short-circuit on a cache hit and skip the retry
277
+ * for the rest of the window.
278
+ */
279
+ try {
280
+ await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
281
+ } catch {
282
+ // Best-effort; the fallback write below is what actually matters.
283
+ }
284
+
285
+ await this.updateColumnsByIdWithoutHooks({
286
+ id: hostId,
287
+ data: liveness,
288
+ });
289
+ }
248
290
  }
249
291
 
250
292
  /**
@@ -1,9 +1,14 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/DockerResource";
3
3
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ColumnLength from "../../Types/Database/ColumnLength";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import OneUptimeDate from "../../Types/Date";
6
7
  import { JSONObject } from "../../Types/JSON";
8
+ import {
9
+ truncateLongText,
10
+ truncateShortText,
11
+ } from "../Utils/Database/TruncateColumnValue";
7
12
  import logger from "../Utils/Logger";
8
13
 
9
14
  /*
@@ -56,6 +61,35 @@ export interface DockerHostInventoryCounts {
56
61
  const UPSERT_BATCH_SIZE: number = 500;
57
62
  const STALE_DELETE_WARN_THRESHOLD: number = 100;
58
63
 
64
+ /*
65
+ * DockerResource's text columns are bounded varchars — name / imageName
66
+ * at 500 chars, kind / containerId / state at 100. These bulk paths go
67
+ * through manager.query(), which skips the length validation
68
+ * DatabaseService applies to ordinary writes, so a single oversized
69
+ * value aborts the whole 500-row INSERT chunk it rides in. Clamp per
70
+ * value instead, so one pathological container can never drop the rest.
71
+ */
72
+ function sanitizeContainer(c: ParsedDockerContainer): ParsedDockerContainer {
73
+ return {
74
+ ...c,
75
+ containerName: truncateLongText(c.containerName),
76
+ containerId: truncateShortText(c.containerId),
77
+ imageName: truncateLongText(c.imageName),
78
+ state: truncateShortText(c.state),
79
+ };
80
+ }
81
+
82
+ function sanitizeResource(r: ParsedDockerResource): ParsedDockerResource {
83
+ return {
84
+ ...r,
85
+ kind: truncateShortText(r.kind),
86
+ name: truncateLongText(r.name),
87
+ containerId: truncateShortText(r.containerId),
88
+ imageName: truncateLongText(r.imageName),
89
+ state: truncateShortText(r.state),
90
+ };
91
+ }
92
+
59
93
  export class Service extends DatabaseService<Model> {
60
94
  public constructor() {
61
95
  super(Model);
@@ -83,12 +117,20 @@ export class Service extends DatabaseService<Model> {
83
117
  return;
84
118
  }
85
119
 
86
- for (
87
- let i: number = 0;
88
- i < data.containers.length;
89
- i += UPSERT_BATCH_SIZE
90
- ) {
91
- const chunk: Array<ParsedDockerContainer> = data.containers.slice(
120
+ const containers: Array<ParsedDockerContainer> = data.containers.map(
121
+ (c: ParsedDockerContainer) => {
122
+ const sanitized: ParsedDockerContainer = sanitizeContainer(c);
123
+ if (sanitized.containerName !== c.containerName) {
124
+ logger.warn(
125
+ `DockerResource container name exceeds ${ColumnLength.LongText} chars; truncated to "${sanitized.containerName}" (host ${data.dockerHostId.toString()}).`,
126
+ );
127
+ }
128
+ return sanitized;
129
+ },
130
+ );
131
+
132
+ for (let i: number = 0; i < containers.length; i += UPSERT_BATCH_SIZE) {
133
+ const chunk: Array<ParsedDockerContainer> = containers.slice(
92
134
  i,
93
135
  i + UPSERT_BATCH_SIZE,
94
136
  );
@@ -164,8 +206,20 @@ export class Service extends DatabaseService<Model> {
164
206
  return;
165
207
  }
166
208
 
167
- for (let i: number = 0; i < data.resources.length; i += UPSERT_BATCH_SIZE) {
168
- const chunk: Array<ParsedDockerResource> = data.resources.slice(
209
+ const resources: Array<ParsedDockerResource> = data.resources.map(
210
+ (r: ParsedDockerResource) => {
211
+ const sanitized: ParsedDockerResource = sanitizeResource(r);
212
+ if (sanitized.name !== r.name) {
213
+ logger.warn(
214
+ `DockerResource name exceeds ${ColumnLength.LongText} chars; truncated to "${sanitized.name}" (host ${data.dockerHostId.toString()}).`,
215
+ );
216
+ }
217
+ return sanitized;
218
+ },
219
+ );
220
+
221
+ for (let i: number = 0; i < resources.length; i += UPSERT_BATCH_SIZE) {
222
+ const chunk: Array<ParsedDockerResource> = resources.slice(
169
223
  i,
170
224
  i + UPSERT_BATCH_SIZE,
171
225
  );
@@ -231,11 +231,14 @@ export class Service extends DatabaseService<Model> {
231
231
  }
232
232
 
233
233
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
234
- const data: any = {
234
+ const liveness: any = {
235
235
  lastSeenAt: OneUptimeDate.getCurrentDate(),
236
236
  otelCollectorStatus: "connected",
237
237
  };
238
238
 
239
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
+ const data: any = { ...liveness };
241
+
239
242
  if (extra?.dockerVersion) {
240
243
  data.dockerVersion = extra.dockerVersion;
241
244
  }
@@ -275,11 +278,51 @@ export class Service extends DatabaseService<Model> {
275
278
  * Heartbeat write: a single-statement UPDATE with no hooks and no
276
279
  * `version` bump, avoiding the hot-row Postgres lock convoy that the
277
280
  * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
281
+ *
282
+ * Collector-supplied strings are clamped to their declared column
283
+ * widths inside updateColumnsByIdWithoutHooks — see
284
+ * DatabaseService.truncateStringColumnsToMaxLength.
278
285
  */
279
- await this.updateColumnsByIdWithoutHooks({
280
- id: clusterId,
281
- data: data,
282
- });
286
+ try {
287
+ await this.updateColumnsByIdWithoutHooks({
288
+ id: clusterId,
289
+ data: data,
290
+ });
291
+ } catch (err) {
292
+ /*
293
+ * Liveness must survive bad metadata. The enriched write carries
294
+ * `lastSeenAt` and `otelCollectorStatus` alongside optional,
295
+ * collector-supplied columns; if any one of them makes Postgres
296
+ * reject the statement, lastSeenAt silently stops advancing and
297
+ * markDisconnectedClusters flips a perfectly healthy Docker Swarm
298
+ * cluster to "disconnected" 15 minutes later — with telemetry still
299
+ * arriving the whole time. That was issue #3006 on the Host path (a
300
+ * host.ip list longer than the then-varchar(500) column). Retry with
301
+ * liveness only so the enrichment is what gets dropped, never the
302
+ * heartbeat.
303
+ */
304
+ logger.warn(
305
+ `DockerSwarmClusterService.updateLastSeen: metadata write failed for Docker Swarm cluster ${clusterId.toString()}, falling back to a liveness-only update: ${
306
+ err instanceof Error ? err.message : String(err)
307
+ }`,
308
+ );
309
+
310
+ /*
311
+ * The throttle fingerprint was already stored, so without this the
312
+ * next batch would short-circuit on a cache hit and skip the retry
313
+ * for the rest of the window.
314
+ */
315
+ try {
316
+ await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
317
+ } catch {
318
+ // Best-effort; the fallback write below is what actually matters.
319
+ }
320
+
321
+ await this.updateColumnsByIdWithoutHooks({
322
+ id: clusterId,
323
+ data: liveness,
324
+ });
325
+ }
283
326
  }
284
327
 
285
328
  /**
@@ -1,9 +1,14 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/DockerSwarmResource";
3
3
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ColumnLength from "../../Types/Database/ColumnLength";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import OneUptimeDate from "../../Types/Date";
6
7
  import { JSONObject } from "../../Types/JSON";
8
+ import {
9
+ truncateLongText,
10
+ truncateShortText,
11
+ } from "../Utils/Database/TruncateColumnValue";
7
12
  import logger from "../Utils/Logger";
8
13
 
9
14
  /*
@@ -95,6 +100,33 @@ const UPSERT_COLUMNS: Array<string> = [
95
100
  "version",
96
101
  ];
97
102
 
103
+ /*
104
+ * DockerSwarmResource's text columns are bounded varchars — image at
105
+ * 500 chars, everything else at 100. The bulk paths below go through
106
+ * manager.query(), which skips the length validation DatabaseService
107
+ * applies to ordinary writes, so a single oversized value aborts the
108
+ * whole 500-row INSERT chunk it rides in. Clamp per value instead, so
109
+ * one pathological task can never drop the rest of the snapshot.
110
+ */
111
+ function sanitizeResource(
112
+ r: ParsedDockerSwarmResource,
113
+ ): ParsedDockerSwarmResource {
114
+ return {
115
+ ...r,
116
+ kind: truncateShortText(r.kind),
117
+ externalId: truncateShortText(r.externalId),
118
+ name: truncateShortText(r.name),
119
+ state: truncateShortText(r.state),
120
+ role: truncateShortText(r.role),
121
+ serviceMode: truncateShortText(r.serviceMode),
122
+ image: truncateLongText(r.image),
123
+ stackName: truncateShortText(r.stackName),
124
+ serviceName: truncateShortText(r.serviceName),
125
+ nodeHostname: truncateShortText(r.nodeHostname),
126
+ driver: truncateShortText(r.driver),
127
+ };
128
+ }
129
+
98
130
  export class Service extends DatabaseService<Model> {
99
131
  public constructor() {
100
132
  super(Model);
@@ -120,8 +152,20 @@ export class Service extends DatabaseService<Model> {
120
152
  return;
121
153
  }
122
154
 
123
- for (let i: number = 0; i < data.resources.length; i += UPSERT_BATCH_SIZE) {
124
- const chunk: Array<ParsedDockerSwarmResource> = data.resources.slice(
155
+ const resources: Array<ParsedDockerSwarmResource> = data.resources.map(
156
+ (r: ParsedDockerSwarmResource) => {
157
+ const sanitized: ParsedDockerSwarmResource = sanitizeResource(r);
158
+ if (sanitized.externalId !== r.externalId) {
159
+ logger.warn(
160
+ `DockerSwarmResource externalId exceeds ${ColumnLength.ShortText} chars; truncated to "${sanitized.externalId}" (cluster ${data.dockerSwarmClusterId.toString()}).`,
161
+ );
162
+ }
163
+ return sanitized;
164
+ },
165
+ );
166
+
167
+ for (let i: number = 0; i < resources.length; i += UPSERT_BATCH_SIZE) {
168
+ const chunk: Array<ParsedDockerSwarmResource> = resources.slice(
125
169
  i,
126
170
  i + UPSERT_BATCH_SIZE,
127
171
  );
@@ -231,9 +275,14 @@ export class Service extends DatabaseService<Model> {
231
275
  valueFragments.push(
232
276
  `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}::numeric, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::numeric, $${paramIndex++}::timestamptz)`,
233
277
  );
278
+ /*
279
+ * The identity clamps MUST match bulkUpsert's, or this mirror
280
+ * UPDATE would silently miss the row the upsert just wrote
281
+ * under the truncated externalId.
282
+ */
234
283
  params.push(
235
- m.kind,
236
- m.externalId,
284
+ truncateShortText(m.kind),
285
+ truncateShortText(m.externalId),
237
286
  m.cpuPercent !== null && m.cpuPercent !== undefined
238
287
  ? m.cpuPercent
239
288
  : null,