@oneuptime/common 11.5.12 → 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 (193) hide show
  1. package/Models/DatabaseModels/AIInsight.ts +30 -0
  2. package/Models/DatabaseModels/EnterpriseLicense.ts +21 -0
  3. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +25 -0
  4. package/Models/DatabaseModels/GlobalConfig.ts +76 -0
  5. package/Models/DatabaseModels/Project.ts +30 -0
  6. package/Models/DatabaseModels/TelemetryException.ts +128 -0
  7. package/Server/API/AIAgentDataAPI.ts +129 -5
  8. package/Server/API/AIInvestigationAPI.ts +87 -0
  9. package/Server/API/EnterpriseLicenseAPI.ts +57 -0
  10. package/Server/API/GlobalConfigAPI.ts +62 -1
  11. package/Server/EnvironmentConfig.ts +22 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.ts +56 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.ts +38 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.ts +25 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  16. package/Server/Services/AIRunService.ts +8 -0
  17. package/Server/Services/AnalyticsDatabaseService.ts +48 -45
  18. package/Server/Services/DatabaseService.ts +66 -1
  19. package/Server/Services/TelemetryExceptionService.ts +320 -24
  20. package/Server/Services/WorkflowService.ts +100 -39
  21. package/Server/Types/Workflow/Components/Schedule.ts +57 -32
  22. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +15 -4
  23. package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +15 -4
  24. package/Server/Utils/AI/SRE/Insights/InsightScanner.ts +222 -24
  25. package/Server/Utils/AI/SRE/Insights/InsightTriageRunner.ts +225 -9
  26. package/Server/Utils/AI/SRE/SubjectCodeFixRun.ts +39 -0
  27. package/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.ts +114 -0
  28. package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +59 -0
  29. package/Server/Utils/Express.ts +13 -0
  30. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +222 -0
  31. package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +799 -0
  32. package/Server/Utils/Telemetry/TelemetryWriterClient.ts +263 -0
  33. package/Server/Utils/Telemetry/TelemetryWriterServer.ts +240 -0
  34. package/Server/Utils/Telemetry/TelemetryWriterShedMetrics.ts +93 -0
  35. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +232 -0
  36. package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +205 -1
  37. package/Tests/Server/Services/UpdateOneByIdKeepsRowId.test.ts +107 -0
  38. package/Tests/Server/Services/WorkflowService.test.ts +308 -0
  39. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +150 -141
  40. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +277 -1
  41. package/Tests/Server/Utils/Attribution.test.ts +203 -0
  42. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +70 -0
  43. package/Tests/Server/Utils/InsightTriageClassification.test.ts +57 -0
  44. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +183 -0
  45. package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +340 -0
  46. package/Tests/Server/Utils/Monitor/Criteria/ExceptionMonitorCriteria.test.ts +87 -0
  47. package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +165 -0
  48. package/Tests/Server/Utils/Monitor/Criteria/TraceMonitorCriteria.test.ts +86 -0
  49. package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +1565 -0
  50. package/Tests/Server/Utils/Telemetry/TelemetryWriterClient.test.ts +313 -0
  51. package/Tests/Server/Utils/Telemetry/TelemetryWriterServer.test.ts +270 -0
  52. package/Tests/Server/Utils/Telemetry/TelemetryWriterShedMetrics.test.ts +127 -0
  53. package/Tests/Server/Utils/TelemetryImprovementTaskTrigger.test.ts +141 -0
  54. package/Tests/Types/AI/CodeFixTaskType.test.ts +3 -0
  55. package/Tests/Types/Monitor/MonitorCriteriaMetricVariables.test.ts +228 -0
  56. package/Tests/Types/Monitor/MonitorStepDefaultTelemetryConfig.test.ts +169 -0
  57. package/Tests/Types/Monitor/MonitorStepExceptionMonitor.test.ts +289 -0
  58. package/Tests/Types/Monitor/MonitorStepLogMonitor.test.ts +231 -0
  59. package/Tests/Types/Monitor/MonitorStepMetricViewConfigUtil.test.ts +245 -0
  60. package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +155 -0
  61. package/Tests/Types/Monitor/MonitorStepTelemetrySerialization.test.ts +141 -0
  62. package/Tests/Types/Monitor/MonitorStepTraceMonitor.test.ts +137 -0
  63. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +311 -0
  64. package/Tests/UI/Components/ModelTableSelectFromColumns.test.ts +259 -0
  65. package/Tests/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.test.ts +461 -0
  66. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +2852 -0
  67. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +758 -0
  68. package/Tests/UI/Utils/NotificationMethodUtil.test.ts +564 -0
  69. package/Tests/Utils/Array.test.ts +156 -0
  70. package/Tests/Utils/CronTab.test.ts +199 -0
  71. package/Tests/Utils/ModelImportExport.test.ts +101 -0
  72. package/Tests/Utils/Number.test.ts +179 -0
  73. package/Tests/Utils/VersionUtil.test.ts +348 -0
  74. package/Tests/jest.setup.ts +1 -0
  75. package/Types/AI/CodeFixTaskContext.ts +6 -0
  76. package/Types/AI/CodeFixTaskType.ts +38 -1
  77. package/Types/AI/ExceptionAIClassification.ts +27 -0
  78. package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +6 -0
  79. package/Types/Log/LogScrubPatternType.ts +7 -0
  80. package/Types/Monitor/MonitorStep.ts +24 -4
  81. package/Types/Monitor/MonitorStepMetricViewConfigUtil.ts +105 -0
  82. package/Types/Trace/TraceScrubPatternType.ts +7 -0
  83. package/UI/Components/EditionLabel/EditionLabel.tsx +445 -12
  84. package/UI/Components/ModelTable/BaseModelTable.tsx +15 -46
  85. package/UI/Components/ModelTable/SelectFromColumns.ts +129 -0
  86. package/UI/Components/Workflow/ArgumentsForm.tsx +68 -30
  87. package/UI/Components/Workflow/CronScheduleField.tsx +411 -0
  88. package/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.ts +176 -0
  89. package/UI/Utils/NotificationMethodUtil.ts +310 -0
  90. package/Utils/CronTab.ts +823 -0
  91. package/Utils/ModelImportExport.ts +21 -6
  92. package/Utils/VersionUtil.ts +260 -0
  93. package/build/dist/Models/DatabaseModels/AIInsight.js +31 -0
  94. package/build/dist/Models/DatabaseModels/AIInsight.js.map +1 -1
  95. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +22 -0
  96. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +26 -0
  98. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/GlobalConfig.js +81 -0
  100. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/Project.js +31 -0
  102. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  103. package/build/dist/Models/DatabaseModels/TelemetryException.js +134 -0
  104. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  105. package/build/dist/Server/API/AIAgentDataAPI.js +97 -8
  106. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  107. package/build/dist/Server/API/AIInvestigationAPI.js +55 -0
  108. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  109. package/build/dist/Server/API/EnterpriseLicenseAPI.js +47 -0
  110. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  111. package/build/dist/Server/API/GlobalConfigAPI.js +52 -1
  112. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  113. package/build/dist/Server/EnvironmentConfig.js +17 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js +33 -0
  116. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js +18 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js +14 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AIRunService.js +9 -2
  124. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  125. package/build/dist/Server/Services/AnalyticsDatabaseService.js +44 -16
  126. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  127. package/build/dist/Server/Services/DatabaseService.js +53 -1
  128. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  129. package/build/dist/Server/Services/TelemetryExceptionService.js +263 -22
  130. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  131. package/build/dist/Server/Services/WorkflowService.js +80 -30
  132. package/build/dist/Server/Services/WorkflowService.js.map +1 -1
  133. package/build/dist/Server/Types/Workflow/Components/Schedule.js +44 -19
  134. package/build/dist/Server/Types/Workflow/Components/Schedule.js.map +1 -1
  135. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +12 -4
  136. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
  137. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +12 -4
  138. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
  139. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js +193 -22
  140. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js.map +1 -1
  141. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js +178 -9
  142. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js.map +1 -1
  143. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js +37 -0
  144. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js.map +1 -1
  145. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js +98 -0
  146. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js.map +1 -0
  147. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js +24 -0
  148. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -0
  149. package/build/dist/Server/Utils/Express.js +12 -0
  150. package/build/dist/Server/Utils/Express.js.map +1 -1
  151. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +149 -0
  152. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -0
  153. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +496 -0
  154. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -0
  155. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js +165 -0
  156. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js.map +1 -0
  157. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js +136 -0
  158. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js.map +1 -0
  159. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js +76 -0
  160. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js.map +1 -0
  161. package/build/dist/Types/AI/CodeFixTaskType.js +38 -1
  162. package/build/dist/Types/AI/CodeFixTaskType.js.map +1 -1
  163. package/build/dist/Types/AI/ExceptionAIClassification.js +28 -0
  164. package/build/dist/Types/AI/ExceptionAIClassification.js.map +1 -0
  165. package/build/dist/Types/Log/LogScrubPatternType.js +7 -0
  166. package/build/dist/Types/Log/LogScrubPatternType.js.map +1 -1
  167. package/build/dist/Types/Monitor/MonitorStep.js +20 -4
  168. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  169. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js +73 -0
  170. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js.map +1 -0
  171. package/build/dist/Types/Trace/TraceScrubPatternType.js +7 -0
  172. package/build/dist/Types/Trace/TraceScrubPatternType.js.map +1 -1
  173. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +261 -13
  174. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  175. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +12 -37
  176. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  177. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js +82 -0
  178. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js.map +1 -0
  179. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +30 -6
  180. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  181. package/build/dist/UI/Components/Workflow/CronScheduleField.js +209 -0
  182. package/build/dist/UI/Components/Workflow/CronScheduleField.js.map +1 -0
  183. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js +129 -0
  184. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js.map +1 -0
  185. package/build/dist/UI/Utils/NotificationMethodUtil.js +189 -0
  186. package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -0
  187. package/build/dist/Utils/CronTab.js +609 -0
  188. package/build/dist/Utils/CronTab.js.map +1 -0
  189. package/build/dist/Utils/ModelImportExport.js +20 -6
  190. package/build/dist/Utils/ModelImportExport.js.map +1 -1
  191. package/build/dist/Utils/VersionUtil.js +193 -0
  192. package/build/dist/Utils/VersionUtil.js.map +1 -0
  193. package/package.json +1 -1
@@ -0,0 +1,799 @@
1
+ import logger from "../Logger";
2
+ import Sleep from "../../../Types/Sleep";
3
+ import UUID from "../../../Utils/UUID";
4
+ import { JSONObject } from "../../../Types/JSON";
5
+ import GracefulShutdown, { ShutdownPriority } from "../GracefulShutdown";
6
+ import { nextInsertDedupToken } from "../AnalyticsDatabase/InsertDedupContext";
7
+ import { ClickHouseError, type ClickHouseSettings } from "@clickhouse/client";
8
+
9
+ /*
10
+ * Re-exported so App-side code (which does not depend on @clickhouse/client
11
+ * directly) can type fan-in submissions.
12
+ */
13
+ export { type ClickHouseSettings } from "@clickhouse/client";
14
+
15
+ /*
16
+ * Fan-in writer: the single funnel between the telemetry ingest workers and
17
+ * ClickHouse.
18
+ *
19
+ * Problem it solves: each worker job used to insert its own row batches
20
+ * directly, so fleet-wide concurrent-query demand scaled as
21
+ * (pods × TELEMETRY_CONCURRENCY) and overran ClickHouse's
22
+ * max_concurrent_queries once the worker fleet scaled out ("Too many
23
+ * simultaneous queries. Maximum: 1000"). ClickHouse wants few fat inserts,
24
+ * not thousands of thin concurrent ones.
25
+ *
26
+ * This component accumulates rows per table ACROSS jobs and flushes them as
27
+ * large combined inserts through a small per-pod semaphore, so ClickHouse
28
+ * sees (pods × maxConcurrentInserts) queries regardless of job concurrency.
29
+ *
30
+ * Delivery semantics — ack-after-accept with per-job retry idempotence:
31
+ * - submit() resolves with a `flushed` promise that settles only when
32
+ * ClickHouse has ACCEPTED the rows (or the insert definitively failed).
33
+ * What "accepted" means follows TELEMETRY_WAIT_FOR_ASYNC_INSERT
34
+ * (AnalyticsDatabaseService.shouldWaitForAsyncInsert): by default the
35
+ * rows are in ClickHouse's async-insert buffer and ClickHouse owns
36
+ * flushing them (fire-and-forget — flush errors surface only server-side
37
+ * and a crash before flush loses the buffer); when set to true, the ack
38
+ * waits for the durable flush. Either way jobs await it before
39
+ * completing, so BullMQ retries any payload ClickHouse never accepted.
40
+ * - submit() captures a deterministic insert_deduplication_token from the
41
+ * ambient runWithInsertDedup context AT SUBMIT TIME (still inside the
42
+ * job's scope). Tokened submissions are inserted individually under that
43
+ * token — "<jobId>:<table>:<chunkIndex>", byte-identical when a BullMQ
44
+ * retry re-processes the same payload — so ClickHouse drops rows a prior
45
+ * attempt already landed. Merging them under one cross-job token would
46
+ * forfeit exactly that guarantee.
47
+ * - Submissions arriving outside any dedup scope are merged into one insert
48
+ * under a minted per-batch token, unique per batch and reused only across
49
+ * the writer's own in-place retries of it. Either way, transient errors
50
+ * (overload, timeouts, network) retry with the SAME token, so an
51
+ * ambiguous failure whose rows actually landed never double-writes (the
52
+ * telemetry tables set non_replicated_deduplication_window).
53
+ * - A submission is never split across batches, so an insert failure
54
+ * rejects exactly the submissions it contains and nothing else.
55
+ *
56
+ * Backpressure: awaiting submit() blocks while (buffered + in-flight) rows
57
+ * are at maxPendingRows, bounding per-pod memory; the retry loop holds its
58
+ * insert slot during backoff so a saturated ClickHouse is never offered more
59
+ * concurrent load. Upstream, jobs slow down, the BullMQ queue absorbs the
60
+ * backlog, and queue depth becomes the scale-out signal.
61
+ *
62
+ * Remote mode (unbounded worker scale-out): when TELEMETRY_WRITER_URL is
63
+ * set, an insert transport (TelemetryWriterClient) replaces the direct
64
+ * ClickHouse call and ships each token group to the dedicated
65
+ * telemetry-writer tier over HTTP. ClickHouse then sees
66
+ * (writerReplicas × maxConcurrentInserts) queries — a constant chosen by
67
+ * the operator — no matter how many worker pods exist. Everything else
68
+ * (dedup tokens, ack-after-flush, retries, backpressure) is unchanged; the
69
+ * writer tier runs this same class with the default direct transport.
70
+ */
71
+
72
+ export interface FanInInsertTarget {
73
+ model: { tableName: string };
74
+ insertJsonRows(
75
+ rows: Array<JSONObject>,
76
+ options?: {
77
+ dedupToken?: string | undefined;
78
+ clickhouseSettings?: ClickHouseSettings | undefined;
79
+ },
80
+ ): Promise<void>;
81
+ }
82
+
83
+ export interface FanInSubmitResult {
84
+ /**
85
+ * Settles when ClickHouse accepted the rows (or the insert definitively
86
+ * failed). "Accepted" = buffered for async flush by default; durably
87
+ * flushed when TELEMETRY_WAIT_FOR_ASYNC_INSERT=true.
88
+ */
89
+ flushed: Promise<void>;
90
+ }
91
+
92
+ /*
93
+ * Pluggable delivery for one token group. The default transport inserts
94
+ * directly into ClickHouse via target.insertJsonRows; the remote transport
95
+ * (TelemetryWriterClient) ships the group to the telemetry-writer tier over
96
+ * HTTP instead, so worker pods add no telemetry INSERT load to ClickHouse
97
+ * and fleet-wide insert concurrency is bounded by the writer tier's size,
98
+ * not by worker replica count. Transports signal "retry me with the same token"
99
+ * by throwing TransientInsertError (or any error isRetryableInsertError
100
+ * recognizes).
101
+ */
102
+ export type FanInInsertTransport = (
103
+ target: FanInInsertTarget,
104
+ rows: Array<JSONObject>,
105
+ options: {
106
+ dedupToken: string;
107
+ clickhouseSettings: ClickHouseSettings | undefined;
108
+ },
109
+ ) => Promise<void>;
110
+
111
+ export interface FanInWriterOptions {
112
+ /** Target rows per ClickHouse insert. Buffers flush as soon as they reach this. */
113
+ maxBatchRows: number;
114
+ /** Max time rows sit buffered before a flush is forced, ms. */
115
+ maxWaitMs: number;
116
+ /** Per-pod cap on concurrent ClickHouse inserts across all tables. */
117
+ maxConcurrentInserts: number;
118
+ /** High-water mark of buffered+in-flight rows; submit() blocks above it. */
119
+ maxPendingRows: number;
120
+ retryMaxAttempts: number;
121
+ retryBaseDelayMs: number;
122
+ retryMaxDelayMs: number;
123
+ /** Injectable for tests. */
124
+ sleep?: (ms: number) => Promise<void>;
125
+ }
126
+
127
+ /*
128
+ * Marker for transient failures raised by a pluggable insert transport
129
+ * (e.g. the remote telemetry-writer client): overload shedding (HTTP 429),
130
+ * writer-tier unavailability (503), or an ambiguous network failure. The
131
+ * writer retries these with the SAME dedup token, so an ambiguous failure
132
+ * whose rows actually landed never double-writes.
133
+ */
134
+ export class TransientInsertError extends Error {
135
+ public constructor(message: string) {
136
+ super(message);
137
+ this.name = "TransientInsertError";
138
+ }
139
+ }
140
+
141
+ export class FanInInsertError extends Error {
142
+ /*
143
+ * The final underlying failure, kept structured so the telemetry-writer
144
+ * HTTP endpoint can map "gave up on a transient error" to 503 (caller
145
+ * should retry with the same token) vs. a definitive failure to 500.
146
+ */
147
+ public readonly causeError: unknown;
148
+
149
+ public constructor(data: {
150
+ tableName: string;
151
+ attempts: number;
152
+ cause: unknown;
153
+ }) {
154
+ const causeMessage: string =
155
+ data.cause instanceof Error ? data.cause.message : String(data.cause);
156
+ super(
157
+ `Fan-in insert into ${data.tableName} failed after ${data.attempts} attempt(s): ${causeMessage}`,
158
+ );
159
+ this.name = "FanInInsertError";
160
+ this.causeError = data.cause;
161
+ if (data.cause instanceof Error && data.cause.stack) {
162
+ this.stack = `${this.stack}\nCaused by: ${data.cause.stack}`;
163
+ }
164
+ }
165
+ }
166
+
167
+ type PendingSubmission = {
168
+ rows: Array<JSONObject>;
169
+ clickhouseSettings: ClickHouseSettings | undefined;
170
+ /*
171
+ * Deterministic per-job dedup token captured at submit time from the
172
+ * ambient runWithInsertDedup context ("<jobId>:<table>:<chunkIndex>"),
173
+ * or undefined when submitted outside any job scope. Tokened submissions
174
+ * are inserted individually under their token so a BullMQ job retry
175
+ * re-issues byte-identical tokens and ClickHouse drops already-landed
176
+ * rows; untokened ones are merged under a minted per-batch token.
177
+ */
178
+ dedupToken: string | undefined;
179
+ resolveAck: () => void;
180
+ rejectAck: (err: Error) => void;
181
+ };
182
+
183
+ type TableBuffer = {
184
+ target: FanInInsertTarget;
185
+ submissions: Array<PendingSubmission>;
186
+ rowCount: number;
187
+ timer: NodeJS.Timeout | null;
188
+ };
189
+
190
+ /*
191
+ * ClickHouse server error codes that indicate transient overload or an
192
+ * ambiguous outcome. All are safe to retry here because every insert
193
+ * carries a dedup token that is stable across the writer's retries: a
194
+ * retry of a block that actually landed is dropped server-side.
195
+ * 202 TOO_MANY_SIMULTANEOUS_QUERIES, 209 SOCKET_TIMEOUT,
196
+ * 210 NETWORK_ERROR, 241 MEMORY_LIMIT_EXCEEDED, 252 TOO_MANY_PARTS,
197
+ * 319 UNKNOWN_STATUS_OF_INSERT
198
+ * NOTE: the client exposes `code` as a STRING (e.g. "202").
199
+ */
200
+ const RETRYABLE_CLICKHOUSE_CODES: Set<string> = new Set([
201
+ "202",
202
+ "209",
203
+ "210",
204
+ "241",
205
+ "252",
206
+ "319",
207
+ ]);
208
+
209
+ const NUMERIC_ERROR_CODE_REGEX: RegExp = /^\d+$/;
210
+
211
+ const RETRYABLE_SOCKET_CODES: Set<string> = new Set([
212
+ "ECONNRESET",
213
+ "ECONNREFUSED",
214
+ "ECONNABORTED",
215
+ "ETIMEDOUT",
216
+ "EPIPE",
217
+ "EAI_AGAIN",
218
+ ]);
219
+
220
+ export function isRetryableInsertError(err: unknown): boolean {
221
+ if (err instanceof ClickHouseError) {
222
+ return RETRYABLE_CLICKHOUSE_CODES.has(err.code);
223
+ }
224
+
225
+ if (err instanceof TransientInsertError) {
226
+ return true;
227
+ }
228
+
229
+ if (err instanceof Error) {
230
+ // Duck-typed TransientInsertError (duplicated module instances).
231
+ if (err.name === "TransientInsertError") {
232
+ return true;
233
+ }
234
+ const code: unknown = (err as unknown as JSONObject)["code"];
235
+ if (typeof code === "string") {
236
+ if (RETRYABLE_SOCKET_CODES.has(code)) {
237
+ return true;
238
+ }
239
+ // Duck-typed ClickHouseError (in case of duplicated package instances).
240
+ if (NUMERIC_ERROR_CODE_REGEX.test(code)) {
241
+ return RETRYABLE_CLICKHOUSE_CODES.has(code);
242
+ }
243
+ }
244
+ /*
245
+ * @clickhouse/client surfaces its socket-idle request_timeout as a plain
246
+ * Error("Timeout error."), with no code. The outcome is ambiguous (the
247
+ * body may have been received), which is exactly what the per-batch dedup
248
+ * token exists for — retry.
249
+ */
250
+ if (
251
+ err.message.includes("Timeout error") ||
252
+ err.message.includes("socket hang up")
253
+ ) {
254
+ return true;
255
+ }
256
+ }
257
+
258
+ return false;
259
+ }
260
+
261
+ type EnvIntReader = (envKey: string, defaultValue: number) => number;
262
+
263
+ const readPositiveInt: EnvIntReader = (
264
+ envKey: string,
265
+ defaultValue: number,
266
+ ): number => {
267
+ const raw: string | undefined = process.env[envKey];
268
+ if (!raw) {
269
+ return defaultValue;
270
+ }
271
+ const parsed: number = parseInt(raw, 10);
272
+ return !isNaN(parsed) && parsed > 0 ? parsed : defaultValue;
273
+ };
274
+
275
+ export function readFanInWriterOptionsFromEnv(): FanInWriterOptions {
276
+ return {
277
+ maxBatchRows: readPositiveInt("TELEMETRY_FANIN_MAX_BATCH_ROWS", 100_000),
278
+ maxWaitMs: readPositiveInt("TELEMETRY_FANIN_MAX_WAIT_MS", 5000),
279
+ maxConcurrentInserts: readPositiveInt(
280
+ "TELEMETRY_FANIN_MAX_CONCURRENT_INSERTS",
281
+ 4,
282
+ ),
283
+ /*
284
+ * Must stay comfortably above maxBatchRows: if the two are equal, the
285
+ * instant a full batch is cut every submitter blocks until that whole
286
+ * batch finishes inserting — no pipelining. 2x allows one batch in
287
+ * flight while the next fills.
288
+ */
289
+ maxPendingRows: readPositiveInt(
290
+ "TELEMETRY_FANIN_MAX_PENDING_ROWS",
291
+ 200_000,
292
+ ),
293
+ retryMaxAttempts: readPositiveInt("TELEMETRY_FANIN_RETRY_MAX_ATTEMPTS", 6),
294
+ retryBaseDelayMs: readPositiveInt(
295
+ "TELEMETRY_FANIN_RETRY_BASE_DELAY_MS",
296
+ 250,
297
+ ),
298
+ retryMaxDelayMs: readPositiveInt(
299
+ "TELEMETRY_FANIN_RETRY_MAX_DELAY_MS",
300
+ 10_000,
301
+ ),
302
+ };
303
+ }
304
+
305
+ export class TelemetryFanInWriter {
306
+ private options: FanInWriterOptions;
307
+ private buffers: Map<string, TableBuffer> = new Map();
308
+ private pendingRows: number = 0;
309
+ private capacityWaiters: Array<() => void> = [];
310
+ private activeInserts: number = 0;
311
+ private insertSlotWaiters: Array<() => void> = [];
312
+ private inFlightDispatches: Set<Promise<void>> = new Set();
313
+ /*
314
+ * Submits that have been called but have not yet buffered their rows
315
+ * (parked at the waitForCapacity await, or merely yielding its one
316
+ * microtask). flushAll() must not conclude the drain while any exist —
317
+ * their rows would land just after the final cut and strand.
318
+ */
319
+ private acceptingSubmits: number = 0;
320
+ private shutdownHandlerRegistered: boolean = false;
321
+ private insertTransport: FanInInsertTransport | null = null;
322
+
323
+ public constructor(options: FanInWriterOptions) {
324
+ this.options = options;
325
+ }
326
+
327
+ /*
328
+ * Install (or clear, with null) a custom delivery transport for token
329
+ * groups. Set once at boot when TELEMETRY_WRITER_URL is configured;
330
+ * batching, dedup-token capture, the per-pod semaphore, retry/backoff,
331
+ * and backpressure all stay in front of it unchanged.
332
+ */
333
+ public setInsertTransport(transport: FanInInsertTransport | null): void {
334
+ this.insertTransport = transport;
335
+ }
336
+
337
+ public hasInsertTransport(): boolean {
338
+ return this.insertTransport !== null;
339
+ }
340
+
341
+ /** Test hook: override options on the shared instance (e.g. shrink maxWaitMs). */
342
+ public configure(partial: Partial<FanInWriterOptions>): void {
343
+ this.options = { ...this.options, ...partial };
344
+ }
345
+
346
+ public getStats(): {
347
+ bufferedRows: number;
348
+ pendingRows: number;
349
+ activeInserts: number;
350
+ } {
351
+ let bufferedRows: number = 0;
352
+ for (const buffer of this.buffers.values()) {
353
+ bufferedRows += buffer.rowCount;
354
+ }
355
+ return {
356
+ bufferedRows,
357
+ pendingRows: this.pendingRows,
358
+ activeInserts: this.activeInserts,
359
+ };
360
+ }
361
+
362
+ /*
363
+ * Hand a batch of rows to the writer. Takes ownership of the `rows` array —
364
+ * callers must not mutate it afterwards.
365
+ *
366
+ * Awaiting submit() itself is the backpressure point: it resolves once the
367
+ * rows are accepted into the buffer (immediately under normal load, later
368
+ * when the pod is at maxPendingRows). The returned `flushed` promise is the
369
+ * acceptance ack — resolve it into the job's pending-ack list and await
370
+ * before completing the job.
371
+ *
372
+ * All submitters of one table must pass identical clickhouseSettings: a
373
+ * batch mixes submissions and applies the first submission's settings.
374
+ */
375
+ public async submit(
376
+ target: FanInInsertTarget,
377
+ rows: Array<JSONObject>,
378
+ options?: {
379
+ clickhouseSettings?: ClickHouseSettings | undefined;
380
+ /*
381
+ * Explicit dedup token for rows whose token was already captured
382
+ * elsewhere — the telemetry-writer endpoint passes through the token
383
+ * the worker-side writer minted/captured, since no ambient
384
+ * runWithInsertDedup scope exists in an HTTP handler.
385
+ */
386
+ dedupToken?: string | undefined;
387
+ },
388
+ ): Promise<FanInSubmitResult> {
389
+ if (!rows || rows.length === 0) {
390
+ return { flushed: Promise.resolve() };
391
+ }
392
+
393
+ this.registerShutdownHandlerOnce();
394
+
395
+ const tableName: string = target.model.tableName;
396
+
397
+ /*
398
+ * Capture the deterministic per-job dedup token NOW, synchronously in
399
+ * the caller's flow: submit() runs inside the job's runWithInsertDedup
400
+ * scope, while the dispatch that eventually inserts these rows runs on
401
+ * a timer with no ambient context. Capturing before any await also
402
+ * keeps token order deterministic across retries of the same payload.
403
+ */
404
+ const dedupToken: string | undefined =
405
+ options?.dedupToken ?? nextInsertDedupToken(tableName);
406
+
407
+ this.acceptingSubmits++;
408
+ try {
409
+ await this.waitForCapacity();
410
+
411
+ let buffer: TableBuffer | undefined = this.buffers.get(tableName);
412
+ if (!buffer) {
413
+ buffer = {
414
+ target,
415
+ submissions: [],
416
+ rowCount: 0,
417
+ timer: null,
418
+ };
419
+ this.buffers.set(tableName, buffer);
420
+ }
421
+
422
+ let resolveAck: () => void = () => {};
423
+ let rejectAck: (err: Error) => void = () => {};
424
+ const flushed: Promise<void> = new Promise<void>(
425
+ (resolve: () => void, reject: (err: Error) => void) => {
426
+ resolveAck = resolve;
427
+ rejectAck = reject;
428
+ },
429
+ );
430
+
431
+ buffer.submissions.push({
432
+ rows,
433
+ clickhouseSettings: options?.clickhouseSettings,
434
+ dedupToken,
435
+ resolveAck,
436
+ rejectAck,
437
+ });
438
+ buffer.rowCount += rows.length;
439
+ this.pendingRows += rows.length;
440
+
441
+ if (buffer.rowCount >= this.options.maxBatchRows) {
442
+ this.cutAndDispatch(tableName, false);
443
+ } else if (!buffer.timer) {
444
+ buffer.timer = setTimeout(() => {
445
+ const timedBuffer: TableBuffer | undefined =
446
+ this.buffers.get(tableName);
447
+ if (timedBuffer) {
448
+ timedBuffer.timer = null;
449
+ }
450
+ this.cutAndDispatch(tableName, true);
451
+ }, this.options.maxWaitMs);
452
+ /*
453
+ * Never keep the process alive just for a pending flush timer —
454
+ * graceful shutdown drains buffers explicitly.
455
+ */
456
+ buffer.timer.unref?.();
457
+ }
458
+
459
+ return { flushed };
460
+ } finally {
461
+ this.acceptingSubmits--;
462
+ }
463
+ }
464
+
465
+ /** Force-flush everything buffered and wait for all in-flight inserts to settle. */
466
+ public async flushAll(): Promise<void> {
467
+ /*
468
+ * Dispatch promises never reject (failures are routed to submission
469
+ * acks), so a plain all() is safe. Re-cut on EVERY pass: a submit
470
+ * parked in waitForCapacity() resumes when a completing dispatch
471
+ * releases capacity — scheduled BEFORE the dispatch promise's own
472
+ * reactions — and buffers its rows WITHOUT creating a dispatch when
473
+ * they are below maxBatchRows (its flush timer is unref'd and will
474
+ * never fire before process exit). Waiting on inFlightDispatches alone
475
+ * would therefore return with those rows stranded. Exit only when a
476
+ * fresh cut pass leaves nothing in flight and no submit is still
477
+ * mid-acceptance; the microtask yield lets a not-yet-buffered submit
478
+ * (parked only on waitForCapacity's fast-path await) land its rows so
479
+ * the next pass picks them up.
480
+ */
481
+ for (;;) {
482
+ for (const tableName of Array.from(this.buffers.keys())) {
483
+ this.cutAndDispatch(tableName, true);
484
+ }
485
+
486
+ if (this.inFlightDispatches.size > 0) {
487
+ await Promise.all(Array.from(this.inFlightDispatches));
488
+ continue;
489
+ }
490
+
491
+ if (this.acceptingSubmits === 0) {
492
+ return;
493
+ }
494
+
495
+ await Promise.resolve();
496
+ }
497
+ }
498
+
499
+ private registerShutdownHandlerOnce(): void {
500
+ if (this.shutdownHandlerRegistered) {
501
+ return;
502
+ }
503
+ this.shutdownHandlerRegistered = true;
504
+ GracefulShutdown.registerHandler(
505
+ "TelemetryFanInWriter",
506
+ ShutdownPriority.Buffers,
507
+ async () => {
508
+ await this.flushAll();
509
+ },
510
+ );
511
+ }
512
+
513
+ private async waitForCapacity(): Promise<void> {
514
+ while (this.pendingRows >= this.options.maxPendingRows) {
515
+ await new Promise<void>((resolve: () => void) => {
516
+ this.capacityWaiters.push(resolve);
517
+ });
518
+ }
519
+ }
520
+
521
+ private releaseCapacity(): void {
522
+ /*
523
+ * Wake all waiters; each re-checks the high-water mark in its
524
+ * waitForCapacity loop, so overshoot stays bounded to one submission
525
+ * per waiter.
526
+ */
527
+ const waiters: Array<() => void> = this.capacityWaiters;
528
+ this.capacityWaiters = [];
529
+ for (const wake of waiters) {
530
+ wake();
531
+ }
532
+ }
533
+
534
+ private async acquireInsertSlot(): Promise<void> {
535
+ while (this.activeInserts >= this.options.maxConcurrentInserts) {
536
+ await new Promise<void>((resolve: () => void) => {
537
+ this.insertSlotWaiters.push(resolve);
538
+ });
539
+ }
540
+ this.activeInserts++;
541
+ }
542
+
543
+ private releaseInsertSlot(): void {
544
+ this.activeInserts--;
545
+ const next: (() => void) | undefined = this.insertSlotWaiters.shift();
546
+ if (next) {
547
+ next();
548
+ }
549
+ }
550
+
551
+ /*
552
+ * Cut batches from a table buffer and dispatch them. When `drain` is false,
553
+ * only full batches (>= maxBatchRows) are cut — the remainder keeps waiting
554
+ * for more rows or the timer. When true, everything goes.
555
+ *
556
+ * A batch is a FIFO run of WHOLE submissions packed up to maxBatchRows
557
+ * (a single oversized submission forms its own batch), so an insert failure
558
+ * maps 1:1 onto the submissions it contained.
559
+ */
560
+ private cutAndDispatch(tableName: string, drain: boolean): void {
561
+ const buffer: TableBuffer | undefined = this.buffers.get(tableName);
562
+ if (!buffer) {
563
+ return;
564
+ }
565
+
566
+ while (
567
+ buffer.rowCount >= this.options.maxBatchRows ||
568
+ (drain && buffer.rowCount > 0)
569
+ ) {
570
+ const batch: Array<PendingSubmission> = [];
571
+ let batchRows: number = 0;
572
+
573
+ while (buffer.submissions.length > 0) {
574
+ const next: PendingSubmission = buffer.submissions[0]!;
575
+ if (
576
+ batch.length > 0 &&
577
+ batchRows + next.rows.length > this.options.maxBatchRows
578
+ ) {
579
+ break;
580
+ }
581
+ buffer.submissions.shift();
582
+ batch.push(next);
583
+ batchRows += next.rows.length;
584
+ if (batchRows >= this.options.maxBatchRows) {
585
+ break;
586
+ }
587
+ }
588
+
589
+ if (batch.length === 0) {
590
+ break;
591
+ }
592
+
593
+ buffer.rowCount -= batchRows;
594
+
595
+ const dispatch: Promise<void> = this.dispatchInsert(
596
+ buffer.target,
597
+ tableName,
598
+ batch,
599
+ batchRows,
600
+ );
601
+ this.inFlightDispatches.add(dispatch);
602
+ dispatch.finally(() => {
603
+ this.inFlightDispatches.delete(dispatch);
604
+ });
605
+ }
606
+
607
+ if (buffer.rowCount === 0 && buffer.timer) {
608
+ clearTimeout(buffer.timer);
609
+ buffer.timer = null;
610
+ }
611
+ }
612
+
613
+ /*
614
+ * Insert one batch while holding a single insert slot. Submissions that
615
+ * carry a deterministic per-job dedup token are inserted INDIVIDUALLY
616
+ * under their own token — this is what keeps BullMQ job retries
617
+ * duplicate-free: the retry re-derives byte-identical tokens and
618
+ * ClickHouse drops blocks a prior attempt already landed. Merging them
619
+ * under one shared cross-job token would break that (a token dedups by
620
+ * token, not content, so a differently-composed block under a reused
621
+ * token would silently drop other jobs' rows — the same hazard that kept
622
+ * the probe paths off dedup tokens historically). Untokened submissions
623
+ * are merged into one insert under a minted per-batch token.
624
+ *
625
+ * The tokened inserts run sequentially within this one slot, so the
626
+ * per-pod concurrency cap — the property that fixes the
627
+ * max_concurrent_queries incident — is unaffected; only the statement
628
+ * count returns to master's per-chunk granularity, and ClickHouse's
629
+ * async-insert coalescing (async_insert=1) still assembles fat parts
630
+ * server-side.
631
+ *
632
+ * Never rejects — outcomes are routed to the submissions' acks.
633
+ */
634
+ private async dispatchInsert(
635
+ target: FanInInsertTarget,
636
+ tableName: string,
637
+ batch: Array<PendingSubmission>,
638
+ batchRows: number,
639
+ ): Promise<void> {
640
+ await this.acquireInsertSlot();
641
+
642
+ try {
643
+ const untokened: Array<PendingSubmission> = [];
644
+
645
+ for (const submission of batch) {
646
+ if (submission.dedupToken) {
647
+ await this.insertGroupWithRetry(
648
+ target,
649
+ tableName,
650
+ [submission],
651
+ submission.dedupToken,
652
+ );
653
+ } else {
654
+ untokened.push(submission);
655
+ }
656
+ }
657
+
658
+ if (untokened.length > 0) {
659
+ await this.insertGroupWithRetry(
660
+ target,
661
+ tableName,
662
+ untokened,
663
+ `fanin:${tableName}:${UUID.generateTimeOrdered()}`,
664
+ );
665
+ }
666
+ } finally {
667
+ this.pendingRows -= batchRows;
668
+ this.releaseCapacity();
669
+ this.releaseInsertSlot();
670
+ }
671
+ }
672
+
673
+ /*
674
+ * Insert one token group, retrying transient failures with full-jitter
675
+ * exponential backoff and the SAME dedup token — an ambiguous failure
676
+ * (timeout, connection reset) whose rows actually landed is dropped
677
+ * server-side on the retry. The insert slot is held across backoff sleeps
678
+ * on purpose: when ClickHouse is saturated, offering it MORE concurrent
679
+ * load from this pod is the failure mode this component exists to
680
+ * prevent. Never rejects — outcomes are routed to the group's acks, so
681
+ * one group's definitive failure does not fail the batch's other groups.
682
+ */
683
+ private async insertGroupWithRetry(
684
+ target: FanInInsertTarget,
685
+ tableName: string,
686
+ group: Array<PendingSubmission>,
687
+ dedupToken: string,
688
+ ): Promise<void> {
689
+ const rows: Array<JSONObject> = [];
690
+ for (const submission of group) {
691
+ for (const row of submission.rows) {
692
+ rows.push(row);
693
+ }
694
+ }
695
+
696
+ const clickhouseSettings: ClickHouseSettings | undefined =
697
+ group[0]?.clickhouseSettings;
698
+ const sleep: (ms: number) => Promise<void> =
699
+ this.options.sleep ??
700
+ ((ms: number): Promise<void> => {
701
+ return Sleep.sleep(ms);
702
+ });
703
+
704
+ let lastError: unknown = null;
705
+ let attemptsMade: number = 0;
706
+
707
+ for (
708
+ let attempt: number = 1;
709
+ attempt <= this.options.retryMaxAttempts;
710
+ attempt++
711
+ ) {
712
+ attemptsMade = attempt;
713
+ try {
714
+ if (this.insertTransport) {
715
+ await this.insertTransport(target, rows, {
716
+ dedupToken,
717
+ clickhouseSettings,
718
+ });
719
+ } else {
720
+ await target.insertJsonRows(rows, {
721
+ dedupToken,
722
+ clickhouseSettings,
723
+ });
724
+ }
725
+
726
+ for (const submission of group) {
727
+ submission.resolveAck();
728
+ }
729
+ if (attempt > 1) {
730
+ logger.info(
731
+ `TelemetryFanInWriter: insert into ${tableName} (${rows.length} rows) succeeded on attempt ${attempt}.`,
732
+ );
733
+ }
734
+ return;
735
+ } catch (err) {
736
+ lastError = err;
737
+
738
+ if (
739
+ !isRetryableInsertError(err) ||
740
+ attempt === this.options.retryMaxAttempts
741
+ ) {
742
+ break;
743
+ }
744
+
745
+ const expDelay: number = Math.min(
746
+ this.options.retryMaxDelayMs,
747
+ this.options.retryBaseDelayMs * Math.pow(2, attempt - 1),
748
+ );
749
+ const jitteredDelay: number = Math.ceil(Math.random() * expDelay);
750
+ logger.warn(
751
+ `TelemetryFanInWriter: transient insert failure on ${tableName} (${rows.length} rows, attempt ${attempt}/${this.options.retryMaxAttempts}); retrying with the same dedup token in ${jitteredDelay}ms: ${err instanceof Error ? err.message : String(err)}`,
752
+ );
753
+ await sleep(jitteredDelay);
754
+ }
755
+ }
756
+
757
+ const finalError: FanInInsertError = new FanInInsertError({
758
+ tableName,
759
+ attempts: attemptsMade,
760
+ cause: lastError,
761
+ });
762
+ logger.error(
763
+ `TelemetryFanInWriter: giving up on insert into ${tableName} (${rows.length} rows); failing ${group.length} submission(s).`,
764
+ );
765
+ logger.error(finalError);
766
+ for (const submission of group) {
767
+ submission.rejectAck(finalError);
768
+ }
769
+ }
770
+ }
771
+
772
+ /*
773
+ * Register a durability ack on a job's pending-ack list, wrapped in the
774
+ * service's storage error class and pre-observed: a mid-job batch failure
775
+ * would otherwise reject the stored promise long before the job's final
776
+ * `await Promise.all(pendingAcks)` reaches it, firing the process-level
777
+ * unhandledRejection logger once per ack. The no-op catch marks the promise
778
+ * handled without swallowing anything — the rejection is still delivered at
779
+ * the job's await point, failing the job so BullMQ retries the payload.
780
+ */
781
+ export function pushObservedAck(
782
+ pendingAcks: Array<Promise<void>>,
783
+ flushed: Promise<void>,
784
+ wrapError: (err: Error) => Error,
785
+ ): void {
786
+ const ack: Promise<void> = flushed.catch((error: Error) => {
787
+ throw wrapError(error);
788
+ });
789
+ ack.catch(() => {
790
+ // Pre-observed; delivered for real at the job's await point.
791
+ });
792
+ pendingAcks.push(ack);
793
+ }
794
+
795
+ const defaultWriter: TelemetryFanInWriter = new TelemetryFanInWriter(
796
+ readFanInWriterOptionsFromEnv(),
797
+ );
798
+
799
+ export default defaultWriter;