@oneuptime/common 11.5.13 → 11.6.0

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 (122) hide show
  1. package/Models/DatabaseModels/EnterpriseLicense.ts +21 -0
  2. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +25 -0
  3. package/Models/DatabaseModels/GlobalConfig.ts +76 -0
  4. package/Server/API/EnterpriseLicenseAPI.ts +57 -0
  5. package/Server/API/GlobalConfigAPI.ts +62 -1
  6. package/Server/EnvironmentConfig.ts +22 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.ts +38 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.ts +25 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/AnalyticsDatabaseService.ts +48 -45
  11. package/Server/Services/DatabaseService.ts +66 -1
  12. package/Server/Services/WorkflowService.ts +100 -39
  13. package/Server/Types/Workflow/Components/Schedule.ts +57 -32
  14. package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +59 -0
  15. package/Server/Utils/Express.ts +13 -0
  16. package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +799 -0
  17. package/Server/Utils/Telemetry/TelemetryWriterClient.ts +263 -0
  18. package/Server/Utils/Telemetry/TelemetryWriterServer.ts +240 -0
  19. package/Server/Utils/Telemetry/TelemetryWriterShedMetrics.ts +93 -0
  20. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +232 -0
  21. package/Tests/Server/Services/UpdateOneByIdKeepsRowId.test.ts +107 -0
  22. package/Tests/Server/Services/WorkflowService.test.ts +308 -0
  23. package/Tests/Server/Utils/Attribution.test.ts +203 -0
  24. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +183 -0
  25. package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +340 -0
  26. package/Tests/Server/Utils/Monitor/Criteria/ExceptionMonitorCriteria.test.ts +87 -0
  27. package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +165 -0
  28. package/Tests/Server/Utils/Monitor/Criteria/TraceMonitorCriteria.test.ts +86 -0
  29. package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +1565 -0
  30. package/Tests/Server/Utils/Telemetry/TelemetryWriterClient.test.ts +313 -0
  31. package/Tests/Server/Utils/Telemetry/TelemetryWriterServer.test.ts +270 -0
  32. package/Tests/Server/Utils/Telemetry/TelemetryWriterShedMetrics.test.ts +127 -0
  33. package/Tests/Types/AI/CodeFixTaskType.test.ts +3 -0
  34. package/Tests/Types/Monitor/MonitorCriteriaMetricVariables.test.ts +228 -0
  35. package/Tests/Types/Monitor/MonitorStepDefaultTelemetryConfig.test.ts +169 -0
  36. package/Tests/Types/Monitor/MonitorStepExceptionMonitor.test.ts +289 -0
  37. package/Tests/Types/Monitor/MonitorStepLogMonitor.test.ts +231 -0
  38. package/Tests/Types/Monitor/MonitorStepMetricViewConfigUtil.test.ts +245 -0
  39. package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +155 -0
  40. package/Tests/Types/Monitor/MonitorStepTelemetrySerialization.test.ts +141 -0
  41. package/Tests/Types/Monitor/MonitorStepTraceMonitor.test.ts +137 -0
  42. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +311 -0
  43. package/Tests/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.test.ts +461 -0
  44. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +2852 -0
  45. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +758 -0
  46. package/Tests/UI/Utils/NotificationMethodUtil.test.ts +564 -0
  47. package/Tests/Utils/Array.test.ts +156 -0
  48. package/Tests/Utils/CronTab.test.ts +199 -0
  49. package/Tests/Utils/ModelImportExport.test.ts +101 -0
  50. package/Tests/Utils/Number.test.ts +179 -0
  51. package/Tests/Utils/VersionUtil.test.ts +348 -0
  52. package/Tests/jest.setup.ts +1 -0
  53. package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +6 -0
  54. package/Types/Monitor/MonitorStep.ts +24 -4
  55. package/Types/Monitor/MonitorStepMetricViewConfigUtil.ts +105 -0
  56. package/UI/Components/EditionLabel/EditionLabel.tsx +445 -12
  57. package/UI/Components/Workflow/ArgumentsForm.tsx +68 -30
  58. package/UI/Components/Workflow/CronScheduleField.tsx +411 -0
  59. package/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.ts +176 -0
  60. package/UI/Utils/NotificationMethodUtil.ts +310 -0
  61. package/Utils/CronTab.ts +823 -0
  62. package/Utils/ModelImportExport.ts +21 -6
  63. package/Utils/VersionUtil.ts +260 -0
  64. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +22 -0
  65. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  66. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +26 -0
  67. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
  68. package/build/dist/Models/DatabaseModels/GlobalConfig.js +81 -0
  69. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  70. package/build/dist/Server/API/EnterpriseLicenseAPI.js +47 -0
  71. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  72. package/build/dist/Server/API/GlobalConfigAPI.js +52 -1
  73. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  74. package/build/dist/Server/EnvironmentConfig.js +17 -0
  75. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js +18 -0
  77. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js.map +1 -0
  78. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js +14 -0
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js.map +1 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  82. package/build/dist/Server/Services/AnalyticsDatabaseService.js +44 -16
  83. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  84. package/build/dist/Server/Services/DatabaseService.js +53 -1
  85. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  86. package/build/dist/Server/Services/WorkflowService.js +80 -30
  87. package/build/dist/Server/Services/WorkflowService.js.map +1 -1
  88. package/build/dist/Server/Types/Workflow/Components/Schedule.js +44 -19
  89. package/build/dist/Server/Types/Workflow/Components/Schedule.js.map +1 -1
  90. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js +24 -0
  91. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -0
  92. package/build/dist/Server/Utils/Express.js +12 -0
  93. package/build/dist/Server/Utils/Express.js.map +1 -1
  94. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +496 -0
  95. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -0
  96. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js +165 -0
  97. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js.map +1 -0
  98. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js +136 -0
  99. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js.map +1 -0
  100. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js +76 -0
  101. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js.map +1 -0
  102. package/build/dist/Types/Monitor/MonitorStep.js +20 -4
  103. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  104. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js +73 -0
  105. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js.map +1 -0
  106. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +261 -13
  107. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  108. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +30 -6
  109. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  110. package/build/dist/UI/Components/Workflow/CronScheduleField.js +209 -0
  111. package/build/dist/UI/Components/Workflow/CronScheduleField.js.map +1 -0
  112. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js +129 -0
  113. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js.map +1 -0
  114. package/build/dist/UI/Utils/NotificationMethodUtil.js +189 -0
  115. package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -0
  116. package/build/dist/Utils/CronTab.js +609 -0
  117. package/build/dist/Utils/CronTab.js.map +1 -0
  118. package/build/dist/Utils/ModelImportExport.js +20 -6
  119. package/build/dist/Utils/ModelImportExport.js.map +1 -1
  120. package/build/dist/Utils/VersionUtil.js +193 -0
  121. package/build/dist/Utils/VersionUtil.js.map +1 -0
  122. package/package.json +1 -1
@@ -47,7 +47,7 @@ import {
47
47
  ResponseJSON,
48
48
  ResultSet,
49
49
  } from "@clickhouse/client";
50
- import { AsyncLocalStorage } from "node:async_hooks";
50
+ import { nextInsertDedupToken } from "../Utils/AnalyticsDatabase/InsertDedupContext";
51
51
  import AnalyticsBaseModel from "../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
52
52
  import { WorkflowRoute } from "../../ServiceRoute";
53
53
  import Protocol from "../../Types/API/Protocol";
@@ -140,40 +140,42 @@ export const MigrationExecuteOptions: ClickhouseExecuteOptions = {
140
140
  },
141
141
  };
142
142
 
143
- /**
144
- * Ambient context that makes ClickHouse inserts idempotent across queue
145
- * retries. The telemetry queue worker wraps each job in
146
- * `runWithInsertDedup(jobId, ...)`; every insertJsonRows call inside the
147
- * job then stamps `insert_deduplication_token =
148
- * "<tokenBase>:<table>:<chunkIndex>"` plus async_insert_deduplicate=1 /
149
- * wait_for_async_insert=1, so a stalled-job retry that re-processes the
150
- * same payload re-issues byte-identical tokens and ClickHouse drops the
151
- * duplicate blocks (on replicated tables; on plain MergeTree the token is
152
- * ignored unless non_replicated_deduplication_window is set — no harm
153
- * either way). The chunk counter is per table because one job inserts
154
- * into several tables (e.g. Span + ExceptionInstance) in a deterministic
155
- * order.
143
+ /*
144
+ * The insert-dedup ambient context lives in
145
+ * Utils/AnalyticsDatabase/InsertDedupContext so that both this service and
146
+ * TelemetryFanInWriter can consume deterministic tokens without an import
147
+ * cycle. Re-exported here for existing callers.
156
148
  *
157
- * HTTP-path inserts run outside the context and keep the fire-and-forget
158
- * async insert (wait_for_async_insert=0) — dedup waiting is only
159
- * affordable off the request thread.
149
+ * HTTP-path inserts run outside the context and are always fire-and-forget
150
+ * (wait_for_async_insert=0) — flush waiting, when opted into via
151
+ * TELEMETRY_WAIT_FOR_ASYNC_INSERT, is only affordable off the request
152
+ * thread and therefore only applies to tokened (queue-job) inserts.
160
153
  */
161
- export interface InsertDedupContextStore {
162
- tokenBase: string;
163
- chunkIndexByTable: Map<string, number>;
164
- }
154
+ export {
155
+ runWithInsertDedup,
156
+ nextInsertDedupToken,
157
+ type InsertDedupContextStore,
158
+ } from "../Utils/AnalyticsDatabase/InsertDedupContext";
165
159
 
166
- const insertDedupContext: AsyncLocalStorage<InsertDedupContextStore> =
167
- new AsyncLocalStorage<InsertDedupContextStore>();
168
-
169
- export function runWithInsertDedup<T>(
170
- tokenBase: string,
171
- fn: () => Promise<T>,
172
- ): Promise<T> {
173
- return insertDedupContext.run(
174
- { tokenBase, chunkIndexByTable: new Map<string, number>() },
175
- fn,
176
- );
160
+ /*
161
+ * Ack mode for telemetry ClickHouse inserts.
162
+ *
163
+ * Default (false): wait_for_async_insert=0 — fire-and-forget. ClickHouse
164
+ * acks as soon as the batch is accepted into its async-insert buffer and
165
+ * owns flushing from there; inserts release their query slot almost
166
+ * immediately. The trade: flush-time errors surface only in server logs
167
+ * (system.asynchronous_inserts / part log), and a ClickHouse crash between
168
+ * buffer-accept and flush loses that buffer even though callers were acked.
169
+ *
170
+ * Set TELEMETRY_WAIT_FOR_ASYNC_INSERT=true to make every tokened insert
171
+ * wait for the durable flush before acking (ack-after-flush end to end
172
+ * through the fan-in writer and writer tier) — at the cost of each waiting
173
+ * insert holding a ClickHouse query slot until its buffer flushes.
174
+ */
175
+ export function shouldWaitForAsyncInsert(): boolean {
176
+ const raw: string | undefined =
177
+ process.env["TELEMETRY_WAIT_FOR_ASYNC_INSERT"];
178
+ return raw === "true" || raw === "1";
177
179
  }
178
180
 
179
181
  export default class AnalyticsDatabaseService<
@@ -259,30 +261,31 @@ export default class AnalyticsDatabaseService<
259
261
  let dedupToken: string | undefined = options?.dedupToken;
260
262
 
261
263
  if (!dedupToken) {
262
- const dedupStore: InsertDedupContextStore | undefined =
263
- insertDedupContext.getStore();
264
- if (dedupStore) {
265
- const chunkIndex: number =
266
- dedupStore.chunkIndexByTable.get(tableName) ?? 0;
267
- dedupStore.chunkIndexByTable.set(tableName, chunkIndex + 1);
268
- dedupToken = `${dedupStore.tokenBase}:${tableName}:${chunkIndex}`;
269
- }
264
+ dedupToken = nextInsertDedupToken(tableName);
270
265
  }
271
266
 
267
+ const waitForAsyncInsert: 0 | 1 = shouldWaitForAsyncInsert() ? 1 : 0;
268
+
272
269
  let clickhouseSettings: ClickHouseSettings = {
273
270
  async_insert: 1,
274
- wait_for_async_insert: 0,
271
+ wait_for_async_insert: waitForAsyncInsert,
275
272
  };
276
273
 
277
274
  if (dedupToken) {
278
275
  /*
279
- * wait_for_async_insert=1 so the worker only acks the job after
280
- * the block actually landed (or was deduplicated) otherwise a
281
- * crash between buffer-write and flush loses data with no retry.
276
+ * Dedup settings ride along in both ack modes. For async inserts
277
+ * ClickHouse dedups by content hash of the insert body when
278
+ * async_insert_deduplicate=1 (the explicit token is not yet honored
279
+ * for async inserts — ClickHouse #52018), so byte-identical queue
280
+ * retries are still dropped. The ack mode is a separate, deliberate
281
+ * trade (see shouldWaitForAsyncInsert): by default ClickHouse owns
282
+ * flushing and an ack means "accepted into the async-insert buffer";
283
+ * with TELEMETRY_WAIT_FOR_ASYNC_INSERT=true the ack waits for the
284
+ * durable flush instead.
282
285
  */
283
286
  clickhouseSettings = {
284
287
  async_insert: 1,
285
- wait_for_async_insert: 1,
288
+ wait_for_async_insert: waitForAsyncInsert,
286
289
  async_insert_deduplicate: 1,
287
290
  insert_deduplication_token: dedupToken,
288
291
  };
@@ -1743,10 +1743,67 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1743
1743
  });
1744
1744
  }
1745
1745
 
1746
+ /*
1747
+ * Update `data` may arrive as a full model instance rather than a plain
1748
+ * partial — the QueryDeepPartialEntity type structurally admits both, and
1749
+ * callers do construct `new Model()` payloads. A model instance carries
1750
+ * non-column own properties from DatabaseBaseModel (every column
1751
+ * initializer plus `isPermissionIf = {}`), and _updateBy turns every data
1752
+ * key into a select column for its internal find, so the extra keys made
1753
+ * that find throw `TableColumnMetadata not found for isPermissionIf
1754
+ * column` and fail the whole update. Strip a model instance down to its
1755
+ * set table columns; plain objects pass through untouched so a typo'd
1756
+ * column name in a literal still fails loudly instead of being silently
1757
+ * dropped.
1758
+ *
1759
+ * `_id`, `createdAt`, `updatedAt` and `version` are dropped for model
1760
+ * instances the same way BaseAPI drops them from client payloads: the row
1761
+ * is located by the update query (spreading a foreign `_id` into the save
1762
+ * payload would redirect the write), timestamps are database-managed, and
1763
+ * `version` is TypeORM's optimistic-concurrency counter.
1764
+ */
1765
+ public sanitizeUpdateData(
1766
+ data: UpdateBy<TBaseModel>["data"],
1767
+ ): UpdateBy<TBaseModel>["data"] {
1768
+ if (!(data instanceof BaseModel)) {
1769
+ return data;
1770
+ }
1771
+
1772
+ const plainData: JSONObject = {};
1773
+
1774
+ for (const key of Object.keys(data)) {
1775
+ if (!this.model.isTableColumn(key)) {
1776
+ continue;
1777
+ }
1778
+
1779
+ if (
1780
+ key === "_id" ||
1781
+ key === "createdAt" ||
1782
+ key === "updatedAt" ||
1783
+ key === "version"
1784
+ ) {
1785
+ continue;
1786
+ }
1787
+
1788
+ const value: JSONValue = (data as any)[key];
1789
+
1790
+ // Unset columns must not become writes (or select columns).
1791
+ if (value === undefined) {
1792
+ continue;
1793
+ }
1794
+
1795
+ plainData[key] = value;
1796
+ }
1797
+
1798
+ return plainData as UpdateBy<TBaseModel>["data"];
1799
+ }
1800
+
1746
1801
  private async _updateBy(updateBy: UpdateBy<TBaseModel>): Promise<number> {
1747
1802
  try {
1748
1803
  this.setTelemetryContextFromProps(updateBy.props);
1749
1804
 
1805
+ updateBy.data = this.sanitizeUpdateData(updateBy.data);
1806
+
1750
1807
  const onUpdate: OnUpdate<TBaseModel> = updateBy.props.ignoreHooks
1751
1808
  ? { updateBy, carryForward: [] }
1752
1809
  : await this.onBeforeUpdate(updateBy);
@@ -1830,9 +1887,17 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1830
1887
  });
1831
1888
 
1832
1889
  for (const item of items) {
1890
+ /*
1891
+ * _id must be set AFTER the spread: update data can carry an
1892
+ * explicit `_id: undefined` (sanitizeUpdateData strips it from model
1893
+ * instances, but a plain object can still hold it), and spreading it
1894
+ * after _id would clobber the located row's id — save() then sees no
1895
+ * primary key, INSERTs instead of updating, and dies on the first
1896
+ * NOT NULL column.
1897
+ */
1833
1898
  const updatedItem: any = {
1834
- _id: item._id!,
1835
1899
  ...data,
1900
+ _id: item._id!,
1836
1901
  } as any;
1837
1902
 
1838
1903
  logger.debug("Updated Item", {
@@ -49,6 +49,40 @@ export class Service extends DatabaseService<Model> {
49
49
  createdItem.webhookSecretKey = secretKey;
50
50
  }
51
51
 
52
+ /*
53
+ * A workflow that arrives with its graph already in place - imported from
54
+ * a JSON export, or duplicated from another workflow - never passes
55
+ * through onUpdateSuccess, so nothing has denormalized its trigger onto
56
+ * the row. The runner looks workflows up by triggerId, so without this the
57
+ * workflow is saved and looks correct in the builder but silently never
58
+ * fires.
59
+ */
60
+ if (createdItem.id && createdItem.graph) {
61
+ await this.saveTriggerFromGraph({
62
+ workflowId: createdItem.id,
63
+ graph: createdItem.graph,
64
+ });
65
+
66
+ /*
67
+ * Registers schedule triggers with the runner. Best effort: the row and
68
+ * its trigger are already persisted, so a workflow service outage must
69
+ * not fail the create. The trigger is already on the row, so the next
70
+ * save - or the runner's own startup scan in Schedule.init, which
71
+ * queries by triggerId - picks the workflow up.
72
+ */
73
+ try {
74
+ await this.notifyWorkflowService(createdItem.id);
75
+ } catch (error) {
76
+ logger.error(
77
+ `Error notifying workflow service of created workflow: ${error}`,
78
+ {
79
+ projectId: createdItem.projectId?.toString(),
80
+ workflowId: createdItem.id?.toString(),
81
+ } as LogAttributes,
82
+ );
83
+ }
84
+ }
85
+
52
86
  if (createdItem.projectId && createdItem.id) {
53
87
  /*
54
88
  * Run label rule first so rule-added labels are persisted before
@@ -87,39 +121,13 @@ export class Service extends DatabaseService<Model> {
87
121
  ): Promise<OnUpdate<Model>> {
88
122
  /// save trigger and trigger args.
89
123
 
90
- if (
91
- onUpdate.updateBy.data &&
92
- (onUpdate.updateBy.data as any).graph &&
93
- (((onUpdate.updateBy.data as any).graph as any)[
94
- "nodes"
95
- ] as Array<JSONObject>)
96
- ) {
97
- let trigger: NodeDataProp | null = null;
98
-
99
- // check if it has a trigger node.
100
- for (const node of ((onUpdate.updateBy.data as any).graph as any)[
101
- "nodes"
102
- ] as Array<JSONObject>) {
103
- const nodeData: NodeDataProp = node["data"] as any;
104
- if (
105
- nodeData.componentType === ComponentType.Trigger &&
106
- nodeData.nodeType === NodeType.Node
107
- ) {
108
- // found the trigger;
109
- trigger = nodeData;
110
- }
111
- }
124
+ const updatedGraph: JSONObject | undefined = (onUpdate.updateBy.data as any)
125
+ ?.graph as JSONObject | undefined;
112
126
 
113
- await this.updateOneById({
114
- id: new ObjectID(onUpdate.updateBy.query._id! as any),
115
- data: {
116
- triggerId: trigger?.metadataId || null,
117
- triggerArguments: trigger?.arguments || {},
118
- } as any,
119
- props: {
120
- isRoot: true,
121
- ignoreHooks: true,
122
- },
127
+ if (updatedGraph) {
128
+ await this.saveTriggerFromGraph({
129
+ workflowId: new ObjectID(onUpdate.updateBy.query._id! as any),
130
+ graph: updatedGraph,
123
131
  });
124
132
  }
125
133
 
@@ -127,23 +135,76 @@ export class Service extends DatabaseService<Model> {
127
135
  workflowId: onUpdate.updateBy.query._id?.toString(),
128
136
  } as LogAttributes);
129
137
 
138
+ await this.notifyWorkflowService(
139
+ new ObjectID(onUpdate.updateBy.query._id! as any),
140
+ );
141
+
142
+ logger.debug("Updated workflow on the workflow service", {
143
+ workflowId: onUpdate.updateBy.query._id?.toString(),
144
+ } as LogAttributes);
145
+
146
+ return onUpdate;
147
+ }
148
+
149
+ /*
150
+ * The trigger node is denormalized out of the graph onto triggerId /
151
+ * triggerArguments because the runner queries workflows by trigger and
152
+ * cannot parse every graph to do it. A graph with no trigger node clears
153
+ * both columns, which is how a workflow stops firing when its trigger is
154
+ * removed in the builder.
155
+ */
156
+ private async saveTriggerFromGraph(data: {
157
+ workflowId: ObjectID;
158
+ graph: JSONObject;
159
+ }): Promise<void> {
160
+ const nodes: Array<JSONObject> | undefined = data.graph["nodes"] as
161
+ | Array<JSONObject>
162
+ | undefined;
163
+
164
+ if (!nodes || !Array.isArray(nodes)) {
165
+ return;
166
+ }
167
+
168
+ let trigger: NodeDataProp | null = null;
169
+
170
+ // check if it has a trigger node.
171
+ for (const node of nodes) {
172
+ const nodeData: NodeDataProp = node["data"] as any;
173
+
174
+ if (
175
+ nodeData?.componentType === ComponentType.Trigger &&
176
+ nodeData?.nodeType === NodeType.Node
177
+ ) {
178
+ // found the trigger;
179
+ trigger = nodeData;
180
+ }
181
+ }
182
+
183
+ await this.updateOneById({
184
+ id: data.workflowId,
185
+ data: {
186
+ triggerId: trigger?.metadataId || null,
187
+ triggerArguments: trigger?.arguments || {},
188
+ } as any,
189
+ props: {
190
+ isRoot: true,
191
+ ignoreHooks: true,
192
+ },
193
+ });
194
+ }
195
+
196
+ private async notifyWorkflowService(workflowId: ObjectID): Promise<void> {
130
197
  await API.post<EmptyResponseData>({
131
198
  url: new URL(
132
199
  Protocol.HTTP,
133
200
  WorkflowHostname,
134
- new Route("/workflow/update/" + onUpdate.updateBy.query._id!),
201
+ new Route("/workflow/update/" + workflowId.toString()),
135
202
  ),
136
203
  data: {},
137
204
  headers: {
138
205
  ...ClusterKeyAuthorization.getClusterKeyHeaders(),
139
206
  },
140
207
  });
141
-
142
- logger.debug("Updated workflow on the workflow service", {
143
- workflowId: onUpdate.updateBy.query._id?.toString(),
144
- } as LogAttributes);
145
-
146
- return onUpdate;
147
208
  }
148
209
  }
149
210
  export default new Service();
@@ -15,6 +15,7 @@ import ComponentID from "../../../../Types/Workflow/ComponentID";
15
15
  import ScheduleComponents from "../../../../Types/Workflow/Components/Schedule";
16
16
  import Workflow from "../../../../Models/DatabaseModels/Workflow";
17
17
  import CaptureSpan from "../../../Utils/Telemetry/CaptureSpan";
18
+ import logger from "../../../Utils/Logger";
18
19
 
19
20
  export default class WebhookTrigger extends TriggerCode {
20
21
  public constructor() {
@@ -52,24 +53,37 @@ export default class WebhookTrigger extends TriggerCode {
52
53
 
53
54
  // query all workflows.
54
55
  for (const workflow of workflows) {
55
- const executeWorkflow: ExecuteWorkflowType = {
56
- workflowId: new ObjectID(workflow._id!),
57
- returnValues: {},
58
- };
59
-
60
- if (
61
- workflow.triggerArguments &&
62
- workflow.triggerArguments["schedule"] &&
63
- workflow.isEnabled
64
- ) {
65
- await props.scheduleWorkflow(
66
- executeWorkflow,
67
- workflow.triggerArguments["schedule"] as string,
56
+ /*
57
+ * Isolate each workflow: a single workflow whose schedule can't be
58
+ * registered (e.g. an invalid cron, or a transient queue error) must not
59
+ * abort the startup scan and leave every other scheduled workflow
60
+ * unregistered.
61
+ */
62
+ try {
63
+ const executeWorkflow: ExecuteWorkflowType = {
64
+ workflowId: new ObjectID(workflow._id!),
65
+ returnValues: {},
66
+ };
67
+
68
+ if (
69
+ workflow.triggerArguments &&
70
+ workflow.triggerArguments["schedule"] &&
71
+ workflow.isEnabled
72
+ ) {
73
+ await props.scheduleWorkflow(
74
+ executeWorkflow,
75
+ workflow.triggerArguments["schedule"] as string,
76
+ );
77
+ }
78
+
79
+ if (!workflow.isEnabled) {
80
+ await props.removeWorkflow(workflow.id!);
81
+ }
82
+ } catch (err) {
83
+ logger.error(
84
+ `Failed to register schedule for workflow ${workflow._id}`,
68
85
  );
69
- }
70
-
71
- if (!workflow.isEnabled) {
72
- await props.removeWorkflow(workflow.id!);
86
+ logger.error(err);
73
87
  }
74
88
  }
75
89
  }
@@ -128,23 +142,34 @@ export default class WebhookTrigger extends TriggerCode {
128
142
  returnValues: {},
129
143
  };
130
144
 
131
- if (
132
- workflow.triggerArguments &&
133
- workflow.triggerArguments["schedule"] &&
134
- workflow.isEnabled
135
- ) {
136
- await this.scheduleWorkflow(
137
- executeWorkflow,
138
- workflow.triggerArguments["schedule"] as string,
139
- );
140
- }
145
+ /*
146
+ * Guard the (re)registration so a bad schedule can't fail the workflow
147
+ * save that triggered this update. Invalid crons are already surfaced as a
148
+ * workflow log by the queue layer; this catch is a backstop for unexpected
149
+ * errors (e.g. a transient queue error).
150
+ */
151
+ try {
152
+ if (
153
+ workflow.triggerArguments &&
154
+ workflow.triggerArguments["schedule"] &&
155
+ workflow.isEnabled
156
+ ) {
157
+ await this.scheduleWorkflow(
158
+ executeWorkflow,
159
+ workflow.triggerArguments["schedule"] as string,
160
+ );
161
+ }
141
162
 
142
- if (!this.removeWorkflow) {
143
- return;
144
- }
163
+ if (!this.removeWorkflow) {
164
+ return;
165
+ }
145
166
 
146
- if (!workflow.isEnabled) {
147
- await this.removeWorkflow(workflow.id!);
167
+ if (!workflow.isEnabled) {
168
+ await this.removeWorkflow(workflow.id!);
169
+ }
170
+ } catch (err) {
171
+ logger.error(`Failed to update schedule for workflow ${workflow._id}`);
172
+ logger.error(err);
148
173
  }
149
174
  }
150
175
  }
@@ -0,0 +1,59 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+
3
+ /**
4
+ * Ambient context that makes ClickHouse inserts idempotent across queue
5
+ * retries. The telemetry queue worker wraps each job in
6
+ * `runWithInsertDedup(jobId, ...)`; token consumers then derive
7
+ * `insert_deduplication_token = "<tokenBase>:<table>:<chunkIndex>"`, so a
8
+ * stalled-job recovery or attempts-based retry that re-processes the same
9
+ * payload re-issues byte-identical tokens and ClickHouse drops the duplicate
10
+ * blocks (on replicated tables; on plain MergeTree the token is ignored
11
+ * unless non_replicated_deduplication_window is set — no harm either way).
12
+ * The chunk counter is per table because one job inserts into several tables
13
+ * (e.g. Span + ExceptionInstance) in a deterministic order.
14
+ *
15
+ * Two consumers exist:
16
+ * - AnalyticsDatabaseService.insertJsonRows stamps direct inserts that run
17
+ * inside the context without an explicit token.
18
+ * - TelemetryFanInWriter captures a token per submission AT SUBMIT TIME
19
+ * (still inside the job's context) so cross-job batching downstream can
20
+ * preserve per-job retry idempotence.
21
+ */
22
+ export interface InsertDedupContextStore {
23
+ tokenBase: string;
24
+ chunkIndexByTable: Map<string, number>;
25
+ }
26
+
27
+ const insertDedupContext: AsyncLocalStorage<InsertDedupContextStore> =
28
+ new AsyncLocalStorage<InsertDedupContextStore>();
29
+
30
+ export function runWithInsertDedup<T>(
31
+ tokenBase: string,
32
+ fn: () => Promise<T>,
33
+ ): Promise<T> {
34
+ return insertDedupContext.run(
35
+ { tokenBase, chunkIndexByTable: new Map<string, number>() },
36
+ fn,
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Consume the next deterministic dedup token for `tableName` from the
42
+ * ambient context, or undefined when called outside a runWithInsertDedup
43
+ * scope. Consuming advances the per-table chunk counter, so calls must
44
+ * happen in a deterministic order relative to the payload — true for the
45
+ * ingest services, whose chunking is a pure function of the (byte-identical
46
+ * across retries) job body.
47
+ */
48
+ export function nextInsertDedupToken(tableName: string): string | undefined {
49
+ const store: InsertDedupContextStore | undefined =
50
+ insertDedupContext.getStore();
51
+
52
+ if (!store) {
53
+ return undefined;
54
+ }
55
+
56
+ const chunkIndex: number = store.chunkIndexByTable.get(tableName) ?? 0;
57
+ store.chunkIndexByTable.set(tableName, chunkIndex + 1);
58
+ return `${store.tokenBase}:${tableName}:${chunkIndex}`;
59
+ }
@@ -103,6 +103,19 @@ class Express {
103
103
 
104
104
  if (!this.httpServer) {
105
105
  this.httpServer = createServer(this.app);
106
+
107
+ /*
108
+ * Keep upstream (nginx) connection reuse safe. When the ingress pools
109
+ * idle upstream connections (proxy keepalive), the backend must NOT be
110
+ * the side that closes an idle socket first — otherwise nginx can pick a
111
+ * socket the backend already closed and 502 a request it cannot safely
112
+ * retry (e.g. a POST on the telemetry ingest path). Node's default
113
+ * keepAliveTimeout (~5s) is well below nginx's upstream keepalive_timeout
114
+ * (60s), so raise it above that window. headersTimeout must stay greater
115
+ * than keepAliveTimeout or Node can cut an idle socket mid-request.
116
+ */
117
+ this.httpServer.keepAliveTimeout = 75 * 1000;
118
+ this.httpServer.headersTimeout = 76 * 1000;
106
119
  }
107
120
 
108
121
  /*