@oneuptime/common 11.5.13 → 11.6.1
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.
- package/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.ts +41 -24
- package/Models/AnalyticsModels/MetricBaselineHourly.ts +2 -0
- package/Models/AnalyticsModels/MetricItemAggMV1m.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByContainer.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +5 -2
- package/Models/DatabaseModels/EnterpriseLicense.ts +21 -0
- package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +25 -0
- package/Models/DatabaseModels/GlobalConfig.ts +76 -0
- package/Server/API/EnterpriseLicenseAPI.ts +57 -0
- package/Server/API/GlobalConfigAPI.ts +62 -1
- package/Server/EnvironmentConfig.ts +22 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.ts +38 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/AnalyticsDatabaseService.ts +61 -46
- package/Server/Services/DatabaseService.ts +66 -1
- package/Server/Services/WorkflowService.ts +100 -39
- package/Server/Types/Workflow/Components/Schedule.ts +57 -32
- package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +59 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +41 -4
- package/Server/Utils/Express.ts +13 -0
- package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +799 -0
- package/Server/Utils/Telemetry/TelemetryWriterClient.ts +263 -0
- package/Server/Utils/Telemetry/TelemetryWriterServer.ts +240 -0
- package/Server/Utils/Telemetry/TelemetryWriterShedMetrics.ts +93 -0
- package/Tests/Models/AnalyticsModels/AnalyticsBaseModel.test.ts +122 -0
- package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +232 -0
- package/Tests/Server/Services/UpdateOneByIdKeepsRowId.test.ts +107 -0
- package/Tests/Server/Services/WorkflowService.test.ts +308 -0
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +164 -3
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +99 -0
- package/Tests/Server/Utils/Attribution.test.ts +203 -0
- package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +183 -0
- package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +340 -0
- package/Tests/Server/Utils/Monitor/Criteria/ExceptionMonitorCriteria.test.ts +87 -0
- package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +165 -0
- package/Tests/Server/Utils/Monitor/Criteria/TraceMonitorCriteria.test.ts +86 -0
- package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +1565 -0
- package/Tests/Server/Utils/Telemetry/TelemetryWriterClient.test.ts +313 -0
- package/Tests/Server/Utils/Telemetry/TelemetryWriterServer.test.ts +270 -0
- package/Tests/Server/Utils/Telemetry/TelemetryWriterShedMetrics.test.ts +127 -0
- package/Tests/Types/AI/CodeFixTaskType.test.ts +3 -0
- package/Tests/Types/HashCode.test.ts +41 -0
- package/Tests/Types/Log/LogQueryParser.test.ts +186 -0
- package/Tests/Types/Monitor/MonitorCriteriaMetricVariables.test.ts +228 -0
- package/Tests/Types/Monitor/MonitorStepDefaultTelemetryConfig.test.ts +169 -0
- package/Tests/Types/Monitor/MonitorStepExceptionMonitor.test.ts +289 -0
- package/Tests/Types/Monitor/MonitorStepLogMonitor.test.ts +231 -0
- package/Tests/Types/Monitor/MonitorStepMetricViewConfigUtil.test.ts +245 -0
- package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +155 -0
- package/Tests/Types/Monitor/MonitorStepTelemetrySerialization.test.ts +141 -0
- package/Tests/Types/Monitor/MonitorStepTraceMonitor.test.ts +137 -0
- package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +311 -0
- package/Tests/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.test.ts +461 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +2852 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +758 -0
- package/Tests/UI/Utils/NotificationMethodUtil.test.ts +564 -0
- package/Tests/Utils/Array.test.ts +156 -0
- package/Tests/Utils/CronTab.test.ts +199 -0
- package/Tests/Utils/Dashboard/VariableInterpolation.test.ts +197 -0
- package/Tests/Utils/ModelImportExport.test.ts +101 -0
- package/Tests/Utils/Number.test.ts +179 -0
- package/Tests/Utils/ValueFormatter.test.ts +305 -0
- package/Tests/Utils/VersionUtil.test.ts +348 -0
- package/Tests/jest.setup.ts +1 -0
- package/Types/AnalyticsDatabase/TableColumn.ts +23 -0
- package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +6 -0
- package/Types/Monitor/MonitorStep.ts +24 -4
- package/Types/Monitor/MonitorStepMetricViewConfigUtil.ts +105 -0
- package/UI/Components/EditionLabel/EditionLabel.tsx +445 -12
- package/UI/Components/Workflow/ArgumentsForm.tsx +68 -30
- package/UI/Components/Workflow/CronScheduleField.tsx +411 -0
- package/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.ts +176 -0
- package/UI/Utils/NotificationMethodUtil.ts +310 -0
- package/Utils/CronTab.ts +823 -0
- package/Utils/ModelImportExport.ts +21 -6
- package/Utils/VersionUtil.ts +260 -0
- package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +25 -20
- package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js +2 -0
- package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js.map +1 -1
- package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +22 -0
- package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
- package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +26 -0
- package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalConfig.js +81 -0
- package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +47 -0
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/API/GlobalConfigAPI.js +52 -1
- package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +17 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +53 -17
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +53 -1
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/WorkflowService.js +80 -30
- package/build/dist/Server/Services/WorkflowService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Schedule.js +44 -19
- package/build/dist/Server/Types/Workflow/Components/Schedule.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js +24 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +29 -4
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Express.js +12 -0
- package/build/dist/Server/Utils/Express.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +496 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js +165 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js +136 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js +76 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js.map +1 -0
- package/build/dist/Types/AnalyticsDatabase/TableColumn.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/TableColumn.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStep.js +20 -4
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js +73 -0
- package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js.map +1 -0
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js +261 -13
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +30 -6
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/CronScheduleField.js +209 -0
- package/build/dist/UI/Components/Workflow/CronScheduleField.js.map +1 -0
- package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js +129 -0
- package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js.map +1 -0
- package/build/dist/UI/Utils/NotificationMethodUtil.js +189 -0
- package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -0
- package/build/dist/Utils/CronTab.js +609 -0
- package/build/dist/Utils/CronTab.js.map +1 -0
- package/build/dist/Utils/ModelImportExport.js +20 -6
- package/build/dist/Utils/ModelImportExport.js.map +1 -1
- package/build/dist/Utils/VersionUtil.js +193 -0
- package/build/dist/Utils/VersionUtil.js.map +1 -0
- 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;
|