@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,1565 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FanInInsertError,
|
|
3
|
+
FanInInsertTarget,
|
|
4
|
+
FanInSubmitResult,
|
|
5
|
+
FanInWriterOptions,
|
|
6
|
+
TelemetryFanInWriter,
|
|
7
|
+
TransientInsertError,
|
|
8
|
+
isRetryableInsertError,
|
|
9
|
+
pushObservedAck,
|
|
10
|
+
readFanInWriterOptionsFromEnv,
|
|
11
|
+
} from "../../../../Server/Utils/Telemetry/TelemetryFanInWriter";
|
|
12
|
+
import { runWithInsertDedup } from "../../../../Server/Utils/AnalyticsDatabase/InsertDedupContext";
|
|
13
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
14
|
+
import { ClickHouseError, type ClickHouseSettings } from "@clickhouse/client";
|
|
15
|
+
import { describe, expect, test } from "@jest/globals";
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* The fan-in writer is the single funnel between telemetry ingest jobs and
|
|
19
|
+
* ClickHouse. These tests exercise the real class with tiny limits, real
|
|
20
|
+
* (short) timers, an injectable no-op backoff sleep, and a fully mocked
|
|
21
|
+
* insert target — no ClickHouse involved.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
jest.mock("../../../../Server/Utils/Logger", () => {
|
|
25
|
+
return {
|
|
26
|
+
__esModule: true,
|
|
27
|
+
default: {
|
|
28
|
+
debug: jest.fn(),
|
|
29
|
+
info: jest.fn(),
|
|
30
|
+
warn: jest.fn(),
|
|
31
|
+
error: jest.fn(),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
type InsertRowsOptions = {
|
|
37
|
+
dedupToken?: string | undefined;
|
|
38
|
+
clickhouseSettings?: ClickHouseSettings | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type InsertImpl = (
|
|
42
|
+
rows: Array<JSONObject>,
|
|
43
|
+
options?: InsertRowsOptions,
|
|
44
|
+
) => Promise<void>;
|
|
45
|
+
|
|
46
|
+
interface TestTarget extends FanInInsertTarget {
|
|
47
|
+
insertJsonRows: jest.Mock;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeTarget(data?: {
|
|
51
|
+
tableName?: string;
|
|
52
|
+
impl?: InsertImpl;
|
|
53
|
+
}): TestTarget {
|
|
54
|
+
const impl: InsertImpl =
|
|
55
|
+
data?.impl ??
|
|
56
|
+
(async (): Promise<void> => {
|
|
57
|
+
// durable by default
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
model: { tableName: data?.tableName ?? "TestTable" },
|
|
61
|
+
insertJsonRows: jest.fn(impl),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeOptions(
|
|
66
|
+
overrides?: Partial<FanInWriterOptions>,
|
|
67
|
+
): FanInWriterOptions {
|
|
68
|
+
const defaults: FanInWriterOptions = {
|
|
69
|
+
maxBatchRows: 10,
|
|
70
|
+
maxWaitMs: 20,
|
|
71
|
+
maxConcurrentInserts: 4,
|
|
72
|
+
maxPendingRows: 1000,
|
|
73
|
+
retryMaxAttempts: 3,
|
|
74
|
+
retryBaseDelayMs: 1,
|
|
75
|
+
retryMaxDelayMs: 2,
|
|
76
|
+
sleep: async (): Promise<void> => {
|
|
77
|
+
// no-op backoff so retry tests are instant
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return { ...defaults, ...overrides };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makeRows(count: number, offset: number = 0): Array<JSONObject> {
|
|
84
|
+
const rows: Array<JSONObject> = [];
|
|
85
|
+
for (let i: number = 0; i < count; i++) {
|
|
86
|
+
rows.push({ seq: offset + i });
|
|
87
|
+
}
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rowSeqs(rows: Array<JSONObject>): Array<number> {
|
|
92
|
+
return rows.map((row: JSONObject) => {
|
|
93
|
+
return row["seq"] as number;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Rows the mock received on call `callIndex`. */
|
|
98
|
+
function callRows(target: TestTarget, callIndex: number): Array<JSONObject> {
|
|
99
|
+
return target.insertJsonRows.mock.calls[callIndex]![0] as Array<JSONObject>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Options the mock received on call `callIndex`. */
|
|
103
|
+
function callOptions(target: TestTarget, callIndex: number): InsertRowsOptions {
|
|
104
|
+
return target.insertJsonRows.mock.calls[callIndex]![1] as InsertRowsOptions;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type Deferred = {
|
|
108
|
+
promise: Promise<void>;
|
|
109
|
+
resolve: () => void;
|
|
110
|
+
reject: (err: Error) => void;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function deferred(): Deferred {
|
|
114
|
+
let resolveFn: () => void = () => {};
|
|
115
|
+
let rejectFn: (err: Error) => void = () => {};
|
|
116
|
+
const promise: Promise<void> = new Promise<void>(
|
|
117
|
+
(resolve: () => void, reject: (err: Error) => void) => {
|
|
118
|
+
resolveFn = resolve;
|
|
119
|
+
rejectFn = reject;
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
return { promise, resolve: resolveFn, reject: rejectFn };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Real-timer sleep — the writer itself uses real setTimeout. */
|
|
126
|
+
function tick(ms: number): Promise<void> {
|
|
127
|
+
return new Promise<void>((resolve: () => void) => {
|
|
128
|
+
setTimeout(resolve, ms);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* True if the promise is still unsettled after `ms` of real time.
|
|
134
|
+
* Also swallows rejections so unhandled-rejection warnings never fire.
|
|
135
|
+
*/
|
|
136
|
+
async function stillPendingAfter(
|
|
137
|
+
promise: Promise<unknown>,
|
|
138
|
+
ms: number,
|
|
139
|
+
): Promise<boolean> {
|
|
140
|
+
let pending: boolean = true;
|
|
141
|
+
const mark: () => void = () => {
|
|
142
|
+
pending = false;
|
|
143
|
+
};
|
|
144
|
+
promise.then(mark, mark);
|
|
145
|
+
await tick(ms);
|
|
146
|
+
return pending;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Await a promise that MUST reject; returns the rejection error. */
|
|
150
|
+
function captureRejection(promise: Promise<void>): Promise<Error> {
|
|
151
|
+
return promise.then(
|
|
152
|
+
() => {
|
|
153
|
+
throw new Error("Expected the promise to reject, but it resolved.");
|
|
154
|
+
},
|
|
155
|
+
(err: Error) => {
|
|
156
|
+
return err;
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clickHouseError(code: string, message?: string): ClickHouseError {
|
|
162
|
+
return new ClickHouseError({
|
|
163
|
+
message: message ?? `simulated server error with code ${code}`,
|
|
164
|
+
code,
|
|
165
|
+
type: "SIMULATED",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Insert impl that fails `failures` times with `err`, then succeeds. */
|
|
170
|
+
function failNTimesImpl(failures: number, err: Error): InsertImpl {
|
|
171
|
+
let calls: number = 0;
|
|
172
|
+
return async (): Promise<void> => {
|
|
173
|
+
calls++;
|
|
174
|
+
if (calls <= failures) {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe("TelemetryFanInWriter", () => {
|
|
181
|
+
describe("flush triggers", () => {
|
|
182
|
+
test("size-triggered flush: reaching maxBatchRows inserts immediately without waiting maxWaitMs", async () => {
|
|
183
|
+
const target: TestTarget = makeTarget();
|
|
184
|
+
// maxWaitMs is huge on purpose: if the flush waited for the timer, the test would time out.
|
|
185
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
186
|
+
makeOptions({ maxBatchRows: 5, maxWaitMs: 60_000 }),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const startedAt: number = Date.now();
|
|
190
|
+
const first: FanInSubmitResult = await writer.submit(target, makeRows(2));
|
|
191
|
+
const second: FanInSubmitResult = await writer.submit(
|
|
192
|
+
target,
|
|
193
|
+
makeRows(3, 2),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
await Promise.all([first.flushed, second.flushed]);
|
|
197
|
+
|
|
198
|
+
expect(Date.now() - startedAt).toBeLessThan(2000);
|
|
199
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1, 2, 3, 4]);
|
|
201
|
+
expect(writer.getStats()).toEqual({
|
|
202
|
+
bufferedRows: 0,
|
|
203
|
+
pendingRows: 0,
|
|
204
|
+
activeInserts: 0,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("time-triggered flush: a below-threshold submission inserts after ~maxWaitMs", async () => {
|
|
209
|
+
const target: TestTarget = makeTarget();
|
|
210
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
211
|
+
makeOptions({ maxBatchRows: 100, maxWaitMs: 25 }),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const startedAt: number = Date.now();
|
|
215
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
216
|
+
target,
|
|
217
|
+
makeRows(3),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Below threshold: nothing dispatched yet.
|
|
221
|
+
expect(target.insertJsonRows).not.toHaveBeenCalled();
|
|
222
|
+
expect(writer.getStats().bufferedRows).toBe(3);
|
|
223
|
+
|
|
224
|
+
await result.flushed;
|
|
225
|
+
|
|
226
|
+
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(20);
|
|
227
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1, 2]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("cross-submission batching: several small submissions collapse into one insert with all rows in order", async () => {
|
|
232
|
+
const target: TestTarget = makeTarget();
|
|
233
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
234
|
+
makeOptions({ maxBatchRows: 100, maxWaitMs: 25 }),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const first: FanInSubmitResult = await writer.submit(target, makeRows(2));
|
|
238
|
+
const second: FanInSubmitResult = await writer.submit(
|
|
239
|
+
target,
|
|
240
|
+
makeRows(3, 2),
|
|
241
|
+
);
|
|
242
|
+
const third: FanInSubmitResult = await writer.submit(
|
|
243
|
+
target,
|
|
244
|
+
makeRows(4, 5),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
await Promise.all([first.flushed, second.flushed, third.flushed]);
|
|
248
|
+
|
|
249
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("a submission is never split: batch cut stops at maxBatchRows on submission boundaries", async () => {
|
|
254
|
+
const target: TestTarget = makeTarget();
|
|
255
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
256
|
+
makeOptions({ maxBatchRows: 3, maxWaitMs: 15 }),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
/*
|
|
260
|
+
* 2 + 2 rows: the size trigger fires at 4 >= 3, but the second
|
|
261
|
+
* submission does not fit in the first batch, so it flushes later
|
|
262
|
+
* (via the timer) as its own batch.
|
|
263
|
+
*/
|
|
264
|
+
const first: FanInSubmitResult = await writer.submit(target, makeRows(2));
|
|
265
|
+
const second: FanInSubmitResult = await writer.submit(
|
|
266
|
+
target,
|
|
267
|
+
makeRows(2, 2),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await Promise.all([first.flushed, second.flushed]);
|
|
271
|
+
|
|
272
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
273
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1]);
|
|
274
|
+
expect(rowSeqs(callRows(target, 1))).toEqual([2, 3]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("oversized single submission (rows > maxBatchRows) inserts as one batch", async () => {
|
|
278
|
+
const target: TestTarget = makeTarget();
|
|
279
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
280
|
+
makeOptions({ maxBatchRows: 3, maxWaitMs: 60_000 }),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
284
|
+
target,
|
|
285
|
+
makeRows(8),
|
|
286
|
+
);
|
|
287
|
+
await result.flushed;
|
|
288
|
+
|
|
289
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("per-table isolation: two targets with different tableNames never share an insert", async () => {
|
|
294
|
+
const targetA: TestTarget = makeTarget({ tableName: "TableA" });
|
|
295
|
+
const targetB: TestTarget = makeTarget({ tableName: "TableB" });
|
|
296
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
297
|
+
makeOptions({ maxBatchRows: 100, maxWaitMs: 60_000 }),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const fromA: FanInSubmitResult = await writer.submit(
|
|
301
|
+
targetA,
|
|
302
|
+
makeRows(3),
|
|
303
|
+
);
|
|
304
|
+
const fromB: FanInSubmitResult = await writer.submit(
|
|
305
|
+
targetB,
|
|
306
|
+
makeRows(2, 100),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
await writer.flushAll();
|
|
310
|
+
await Promise.all([fromA.flushed, fromB.flushed]);
|
|
311
|
+
|
|
312
|
+
expect(targetA.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
313
|
+
expect(targetB.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
314
|
+
expect(rowSeqs(callRows(targetA, 0))).toEqual([0, 1, 2]);
|
|
315
|
+
expect(rowSeqs(callRows(targetB, 0))).toEqual([100, 101]);
|
|
316
|
+
|
|
317
|
+
const tokenA: string | undefined = callOptions(targetA, 0).dedupToken;
|
|
318
|
+
const tokenB: string | undefined = callOptions(targetB, 0).dedupToken;
|
|
319
|
+
expect(tokenA).toMatch(/^fanin:TableA:/);
|
|
320
|
+
expect(tokenB).toMatch(/^fanin:TableB:/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("empty rows array: resolves immediately with zero insert calls", async () => {
|
|
324
|
+
const target: TestTarget = makeTarget();
|
|
325
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
326
|
+
makeOptions(),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const result: FanInSubmitResult = await writer.submit(target, []);
|
|
330
|
+
await result.flushed;
|
|
331
|
+
|
|
332
|
+
expect(target.insertJsonRows).not.toHaveBeenCalled();
|
|
333
|
+
expect(writer.getStats()).toEqual({
|
|
334
|
+
bufferedRows: 0,
|
|
335
|
+
pendingRows: 0,
|
|
336
|
+
activeInserts: 0,
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("flushAll(): buffered below-threshold rows are flushed and awaited", async () => {
|
|
341
|
+
const target: TestTarget = makeTarget();
|
|
342
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
343
|
+
makeOptions({ maxBatchRows: 100, maxWaitMs: 60_000 }),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
347
|
+
target,
|
|
348
|
+
makeRows(3),
|
|
349
|
+
);
|
|
350
|
+
expect(target.insertJsonRows).not.toHaveBeenCalled();
|
|
351
|
+
|
|
352
|
+
await writer.flushAll();
|
|
353
|
+
|
|
354
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
355
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1, 2]);
|
|
356
|
+
await result.flushed; // already settled — must not hang
|
|
357
|
+
expect(writer.getStats()).toEqual({
|
|
358
|
+
bufferedRows: 0,
|
|
359
|
+
pendingRows: 0,
|
|
360
|
+
activeInserts: 0,
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("flushAll() with nothing buffered resolves immediately", async () => {
|
|
365
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
366
|
+
makeOptions(),
|
|
367
|
+
);
|
|
368
|
+
await writer.flushAll();
|
|
369
|
+
expect(writer.getStats()).toEqual({
|
|
370
|
+
bufferedRows: 0,
|
|
371
|
+
pendingRows: 0,
|
|
372
|
+
activeInserts: 0,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("acks", () => {
|
|
378
|
+
test("ack-after-flush: flushed stays pending until the insert resolves, then resolves", async () => {
|
|
379
|
+
const gate: Deferred = deferred();
|
|
380
|
+
const target: TestTarget = makeTarget({
|
|
381
|
+
impl: (): Promise<void> => {
|
|
382
|
+
return gate.promise;
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
386
|
+
makeOptions({ maxBatchRows: 2, maxWaitMs: 60_000 }),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
390
|
+
target,
|
|
391
|
+
makeRows(2),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(await stillPendingAfter(result.flushed, 25)).toBe(true);
|
|
395
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
396
|
+
expect(writer.getStats().activeInserts).toBe(1);
|
|
397
|
+
// Rows are in flight, not buffered — but still count against the high-water mark.
|
|
398
|
+
expect(writer.getStats().bufferedRows).toBe(0);
|
|
399
|
+
expect(writer.getStats().pendingRows).toBe(2);
|
|
400
|
+
|
|
401
|
+
gate.resolve();
|
|
402
|
+
await result.flushed;
|
|
403
|
+
await writer.flushAll();
|
|
404
|
+
expect(writer.getStats().activeInserts).toBe(0);
|
|
405
|
+
expect(writer.getStats().pendingRows).toBe(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("a batch failure rejects exactly the submissions in that batch; a later submission still succeeds", async () => {
|
|
409
|
+
let shouldFail: boolean = true;
|
|
410
|
+
const target: TestTarget = makeTarget({
|
|
411
|
+
impl: async (): Promise<void> => {
|
|
412
|
+
if (shouldFail) {
|
|
413
|
+
throw clickHouseError("60", "Table does not exist");
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
418
|
+
makeOptions({ maxBatchRows: 4, maxWaitMs: 60_000 }),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const first: FanInSubmitResult = await writer.submit(target, makeRows(2));
|
|
422
|
+
const second: FanInSubmitResult = await writer.submit(
|
|
423
|
+
target,
|
|
424
|
+
makeRows(2, 2),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const [errFirst, errSecond]: [Error, Error] = await Promise.all([
|
|
428
|
+
captureRejection(first.flushed),
|
|
429
|
+
captureRejection(second.flushed),
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
expect(errFirst).toBeInstanceOf(FanInInsertError);
|
|
433
|
+
expect(errFirst.name).toBe("FanInInsertError");
|
|
434
|
+
expect(errFirst.message).toContain("TestTable");
|
|
435
|
+
expect(errFirst.message).toContain("after 1 attempt(s)");
|
|
436
|
+
expect(errFirst.message).toContain("Table does not exist");
|
|
437
|
+
// Same batch → the very same error instance for every submission in it.
|
|
438
|
+
expect(errSecond).toBe(errFirst);
|
|
439
|
+
|
|
440
|
+
// The failed rows are gone from the pending count — no wedged capacity.
|
|
441
|
+
expect(writer.getStats()).toEqual({
|
|
442
|
+
bufferedRows: 0,
|
|
443
|
+
pendingRows: 0,
|
|
444
|
+
activeInserts: 0,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
shouldFail = false;
|
|
448
|
+
const third: FanInSubmitResult = await writer.submit(
|
|
449
|
+
target,
|
|
450
|
+
makeRows(4, 100),
|
|
451
|
+
);
|
|
452
|
+
await third.flushed;
|
|
453
|
+
|
|
454
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
455
|
+
expect(rowSeqs(callRows(target, 1))).toEqual([100, 101, 102, 103]);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe("retries", () => {
|
|
460
|
+
test("ClickHouseError code 202 is retried with the SAME dedup token until success", async () => {
|
|
461
|
+
const target: TestTarget = makeTarget({
|
|
462
|
+
impl: failNTimesImpl(
|
|
463
|
+
2,
|
|
464
|
+
clickHouseError(
|
|
465
|
+
"202",
|
|
466
|
+
"Too many simultaneous queries. Maximum: 1000",
|
|
467
|
+
),
|
|
468
|
+
),
|
|
469
|
+
});
|
|
470
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
471
|
+
makeOptions({
|
|
472
|
+
maxBatchRows: 2,
|
|
473
|
+
maxWaitMs: 60_000,
|
|
474
|
+
retryMaxAttempts: 5,
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
479
|
+
target,
|
|
480
|
+
makeRows(2),
|
|
481
|
+
);
|
|
482
|
+
await result.flushed;
|
|
483
|
+
|
|
484
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(3);
|
|
485
|
+
|
|
486
|
+
const tokens: Array<string | undefined> = [0, 1, 2].map(
|
|
487
|
+
(callIndex: number) => {
|
|
488
|
+
return callOptions(target, callIndex).dedupToken;
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
expect(tokens[0]).toMatch(/^fanin:TestTable:/);
|
|
492
|
+
expect(tokens[1]).toBe(tokens[0]);
|
|
493
|
+
expect(tokens[2]).toBe(tokens[0]);
|
|
494
|
+
|
|
495
|
+
// Every attempt re-sends the identical row set.
|
|
496
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1]);
|
|
497
|
+
expect(rowSeqs(callRows(target, 1))).toEqual([0, 1]);
|
|
498
|
+
expect(rowSeqs(callRows(target, 2))).toEqual([0, 1]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("retry exhaustion: always-202 rejects flushed with FanInInsertError after retryMaxAttempts calls", async () => {
|
|
502
|
+
const target: TestTarget = makeTarget({
|
|
503
|
+
impl: async (): Promise<void> => {
|
|
504
|
+
throw clickHouseError("202");
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
508
|
+
makeOptions({
|
|
509
|
+
maxBatchRows: 2,
|
|
510
|
+
maxWaitMs: 60_000,
|
|
511
|
+
retryMaxAttempts: 3,
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
516
|
+
target,
|
|
517
|
+
makeRows(2),
|
|
518
|
+
);
|
|
519
|
+
const err: Error = await captureRejection(result.flushed);
|
|
520
|
+
|
|
521
|
+
expect(err).toBeInstanceOf(FanInInsertError);
|
|
522
|
+
expect(err.message).toContain("after 3 attempt(s)");
|
|
523
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(3);
|
|
524
|
+
expect(writer.getStats().pendingRows).toBe(0);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("non-retryable ClickHouse code (60) fails after exactly 1 attempt", async () => {
|
|
528
|
+
const target: TestTarget = makeTarget({
|
|
529
|
+
impl: async (): Promise<void> => {
|
|
530
|
+
throw clickHouseError("60", "Unknown table");
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
534
|
+
makeOptions({
|
|
535
|
+
maxBatchRows: 2,
|
|
536
|
+
maxWaitMs: 60_000,
|
|
537
|
+
retryMaxAttempts: 5,
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
542
|
+
target,
|
|
543
|
+
makeRows(2),
|
|
544
|
+
);
|
|
545
|
+
const err: Error = await captureRejection(result.flushed);
|
|
546
|
+
|
|
547
|
+
expect(err).toBeInstanceOf(FanInInsertError);
|
|
548
|
+
expect(err.message).toContain("after 1 attempt(s)");
|
|
549
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("a plain error with no matching code or message fails after exactly 1 attempt", async () => {
|
|
553
|
+
const target: TestTarget = makeTarget({
|
|
554
|
+
impl: async (): Promise<void> => {
|
|
555
|
+
throw new Error("schema mismatch: column type is wrong");
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
559
|
+
makeOptions({
|
|
560
|
+
maxBatchRows: 2,
|
|
561
|
+
maxWaitMs: 60_000,
|
|
562
|
+
retryMaxAttempts: 5,
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
567
|
+
target,
|
|
568
|
+
makeRows(2),
|
|
569
|
+
);
|
|
570
|
+
const err: Error = await captureRejection(result.flushed);
|
|
571
|
+
|
|
572
|
+
expect(err).toBeInstanceOf(FanInInsertError);
|
|
573
|
+
expect(err.message).toContain("schema mismatch");
|
|
574
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("plain Error('Timeout error.') is retried", async () => {
|
|
578
|
+
const target: TestTarget = makeTarget({
|
|
579
|
+
impl: failNTimesImpl(1, new Error("Timeout error.")),
|
|
580
|
+
});
|
|
581
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
582
|
+
makeOptions({ maxBatchRows: 2, maxWaitMs: 60_000 }),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
586
|
+
target,
|
|
587
|
+
makeRows(2),
|
|
588
|
+
);
|
|
589
|
+
await result.flushed;
|
|
590
|
+
|
|
591
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("Error with code ECONNRESET is retried", async () => {
|
|
595
|
+
const socketError: Error = Object.assign(new Error("read ECONNRESET"), {
|
|
596
|
+
code: "ECONNRESET",
|
|
597
|
+
});
|
|
598
|
+
const target: TestTarget = makeTarget({
|
|
599
|
+
impl: failNTimesImpl(1, socketError),
|
|
600
|
+
});
|
|
601
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
602
|
+
makeOptions({ maxBatchRows: 2, maxWaitMs: 60_000 }),
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
606
|
+
target,
|
|
607
|
+
makeRows(2),
|
|
608
|
+
);
|
|
609
|
+
await result.flushed;
|
|
610
|
+
|
|
611
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
612
|
+
// Retry reused the same dedup token.
|
|
613
|
+
expect(callOptions(target, 1).dedupToken).toBe(
|
|
614
|
+
callOptions(target, 0).dedupToken,
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe("concurrency and backpressure", () => {
|
|
620
|
+
test("semaphore: with maxConcurrentInserts 1, two ready tables never insert concurrently", async () => {
|
|
621
|
+
let active: number = 0;
|
|
622
|
+
let maxActive: number = 0;
|
|
623
|
+
let firstCall: boolean = true;
|
|
624
|
+
const gate: Deferred = deferred();
|
|
625
|
+
|
|
626
|
+
const impl: InsertImpl = async (): Promise<void> => {
|
|
627
|
+
active++;
|
|
628
|
+
maxActive = Math.max(maxActive, active);
|
|
629
|
+
if (firstCall) {
|
|
630
|
+
firstCall = false;
|
|
631
|
+
await gate.promise; // slow first insert holds the only slot
|
|
632
|
+
} else {
|
|
633
|
+
await tick(5);
|
|
634
|
+
}
|
|
635
|
+
active--;
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const targetA: TestTarget = makeTarget({ tableName: "TableA", impl });
|
|
639
|
+
const targetB: TestTarget = makeTarget({ tableName: "TableB", impl });
|
|
640
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
641
|
+
makeOptions({
|
|
642
|
+
maxBatchRows: 2,
|
|
643
|
+
maxWaitMs: 60_000,
|
|
644
|
+
maxConcurrentInserts: 1,
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
// Both tables hit the size trigger and are ready to insert simultaneously.
|
|
649
|
+
const fromA: FanInSubmitResult = await writer.submit(
|
|
650
|
+
targetA,
|
|
651
|
+
makeRows(2),
|
|
652
|
+
);
|
|
653
|
+
const fromB: FanInSubmitResult = await writer.submit(
|
|
654
|
+
targetB,
|
|
655
|
+
makeRows(2),
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
await tick(25);
|
|
659
|
+
expect(active).toBe(1);
|
|
660
|
+
expect(targetA.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
661
|
+
expect(targetB.insertJsonRows).not.toHaveBeenCalled();
|
|
662
|
+
expect(writer.getStats().activeInserts).toBe(1);
|
|
663
|
+
|
|
664
|
+
gate.resolve();
|
|
665
|
+
await Promise.all([fromA.flushed, fromB.flushed]);
|
|
666
|
+
|
|
667
|
+
expect(maxActive).toBe(1);
|
|
668
|
+
expect(targetB.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
669
|
+
expect(writer.getStats().activeInserts).toBe(0);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("semaphore actually allows parallelism up to maxConcurrentInserts", async () => {
|
|
673
|
+
let active: number = 0;
|
|
674
|
+
let maxActive: number = 0;
|
|
675
|
+
const gate: Deferred = deferred();
|
|
676
|
+
|
|
677
|
+
const impl: InsertImpl = async (): Promise<void> => {
|
|
678
|
+
active++;
|
|
679
|
+
maxActive = Math.max(maxActive, active);
|
|
680
|
+
await gate.promise;
|
|
681
|
+
active--;
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const targetA: TestTarget = makeTarget({ tableName: "TableA", impl });
|
|
685
|
+
const targetB: TestTarget = makeTarget({ tableName: "TableB", impl });
|
|
686
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
687
|
+
makeOptions({
|
|
688
|
+
maxBatchRows: 2,
|
|
689
|
+
maxWaitMs: 60_000,
|
|
690
|
+
maxConcurrentInserts: 2,
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
const fromA: FanInSubmitResult = await writer.submit(
|
|
695
|
+
targetA,
|
|
696
|
+
makeRows(2),
|
|
697
|
+
);
|
|
698
|
+
const fromB: FanInSubmitResult = await writer.submit(
|
|
699
|
+
targetB,
|
|
700
|
+
makeRows(2),
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
await tick(15);
|
|
704
|
+
expect(maxActive).toBe(2);
|
|
705
|
+
|
|
706
|
+
gate.resolve();
|
|
707
|
+
await Promise.all([fromA.flushed, fromB.flushed]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("backpressure: submit() acceptance blocks at maxPendingRows and resumes after the insert completes", async () => {
|
|
711
|
+
const gate: Deferred = deferred();
|
|
712
|
+
let blockedOnce: boolean = false;
|
|
713
|
+
const target: TestTarget = makeTarget({
|
|
714
|
+
impl: async (): Promise<void> => {
|
|
715
|
+
if (!blockedOnce) {
|
|
716
|
+
blockedOnce = true;
|
|
717
|
+
await gate.promise;
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
722
|
+
makeOptions({
|
|
723
|
+
maxBatchRows: 5,
|
|
724
|
+
maxWaitMs: 10,
|
|
725
|
+
maxPendingRows: 5,
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
// Fills the high-water mark exactly and dispatches into the blocked insert.
|
|
730
|
+
const first: FanInSubmitResult = await writer.submit(target, makeRows(5));
|
|
731
|
+
expect(writer.getStats().pendingRows).toBe(5);
|
|
732
|
+
|
|
733
|
+
let accepted: boolean = false;
|
|
734
|
+
const secondAcceptance: Promise<FanInSubmitResult> = writer
|
|
735
|
+
.submit(target, makeRows(3, 100))
|
|
736
|
+
.then((result: FanInSubmitResult) => {
|
|
737
|
+
accepted = true;
|
|
738
|
+
return result;
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
await tick(30);
|
|
742
|
+
expect(accepted).toBe(false);
|
|
743
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
744
|
+
|
|
745
|
+
gate.resolve();
|
|
746
|
+
const second: FanInSubmitResult = await secondAcceptance;
|
|
747
|
+
expect(accepted).toBe(true);
|
|
748
|
+
|
|
749
|
+
await first.flushed;
|
|
750
|
+
await second.flushed;
|
|
751
|
+
await writer.flushAll();
|
|
752
|
+
|
|
753
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
754
|
+
expect(rowSeqs(callRows(target, 1))).toEqual([100, 101, 102]);
|
|
755
|
+
expect(writer.getStats()).toEqual({
|
|
756
|
+
bufferedRows: 0,
|
|
757
|
+
pendingRows: 0,
|
|
758
|
+
activeInserts: 0,
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("dedup tokens and settings", () => {
|
|
764
|
+
test("two different batches get different dedup tokens with the fanin:<table>: prefix", async () => {
|
|
765
|
+
const target: TestTarget = makeTarget();
|
|
766
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
767
|
+
makeOptions({ maxBatchRows: 3, maxWaitMs: 60_000 }),
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const first: FanInSubmitResult = await writer.submit(target, makeRows(3));
|
|
771
|
+
await first.flushed;
|
|
772
|
+
const second: FanInSubmitResult = await writer.submit(
|
|
773
|
+
target,
|
|
774
|
+
makeRows(3, 3),
|
|
775
|
+
);
|
|
776
|
+
await second.flushed;
|
|
777
|
+
|
|
778
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
779
|
+
const tokenFirst: string | undefined = callOptions(target, 0).dedupToken;
|
|
780
|
+
const tokenSecond: string | undefined = callOptions(target, 1).dedupToken;
|
|
781
|
+
expect(tokenFirst).toMatch(/^fanin:TestTable:/);
|
|
782
|
+
expect(tokenSecond).toMatch(/^fanin:TestTable:/);
|
|
783
|
+
expect(tokenSecond).not.toBe(tokenFirst);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("clickhouseSettings pass through to insertJsonRows", async () => {
|
|
787
|
+
const target: TestTarget = makeTarget();
|
|
788
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
789
|
+
makeOptions({ maxBatchRows: 2, maxWaitMs: 60_000 }),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
const settings: ClickHouseSettings = {
|
|
793
|
+
async_insert: 1,
|
|
794
|
+
wait_for_async_insert: 1,
|
|
795
|
+
};
|
|
796
|
+
const result: FanInSubmitResult = await writer.submit(
|
|
797
|
+
target,
|
|
798
|
+
makeRows(2),
|
|
799
|
+
{ clickhouseSettings: settings },
|
|
800
|
+
);
|
|
801
|
+
await result.flushed;
|
|
802
|
+
|
|
803
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
804
|
+
expect(callOptions(target, 0).clickhouseSettings).toBe(settings);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("a mixed batch applies the FIRST submission's clickhouseSettings", async () => {
|
|
808
|
+
const target: TestTarget = makeTarget();
|
|
809
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
810
|
+
makeOptions({ maxBatchRows: 4, maxWaitMs: 60_000 }),
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
const settingsFirst: ClickHouseSettings = { async_insert: 1 };
|
|
814
|
+
const settingsSecond: ClickHouseSettings = { async_insert: 0 };
|
|
815
|
+
|
|
816
|
+
const first: FanInSubmitResult = await writer.submit(
|
|
817
|
+
target,
|
|
818
|
+
makeRows(2),
|
|
819
|
+
{ clickhouseSettings: settingsFirst },
|
|
820
|
+
);
|
|
821
|
+
const second: FanInSubmitResult = await writer.submit(
|
|
822
|
+
target,
|
|
823
|
+
makeRows(2, 2),
|
|
824
|
+
{ clickhouseSettings: settingsSecond },
|
|
825
|
+
);
|
|
826
|
+
await Promise.all([first.flushed, second.flushed]);
|
|
827
|
+
|
|
828
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
829
|
+
expect(callOptions(target, 0).clickhouseSettings).toBe(settingsFirst);
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
describe("isRetryableInsertError", () => {
|
|
835
|
+
test.each([["202"], ["209"], ["210"], ["241"], ["252"], ["319"]])(
|
|
836
|
+
"ClickHouseError code %s is retryable",
|
|
837
|
+
(code: string) => {
|
|
838
|
+
expect(isRetryableInsertError(clickHouseError(code))).toBe(true);
|
|
839
|
+
},
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
test.each([["60"], ["81"], ["1000"], ["404"]])(
|
|
843
|
+
"ClickHouseError code %s is NOT retryable",
|
|
844
|
+
(code: string) => {
|
|
845
|
+
expect(isRetryableInsertError(clickHouseError(code))).toBe(false);
|
|
846
|
+
},
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
test.each([
|
|
850
|
+
["ECONNRESET"],
|
|
851
|
+
["ECONNREFUSED"],
|
|
852
|
+
["ECONNABORTED"],
|
|
853
|
+
["ETIMEDOUT"],
|
|
854
|
+
["EPIPE"],
|
|
855
|
+
["EAI_AGAIN"],
|
|
856
|
+
])("socket code %s on a plain Error is retryable", (code: string) => {
|
|
857
|
+
const err: Error = Object.assign(new Error(`syscall failed: ${code}`), {
|
|
858
|
+
code,
|
|
859
|
+
});
|
|
860
|
+
expect(isRetryableInsertError(err)).toBe(true);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test("unknown socket code (ENOENT) is not retryable", () => {
|
|
864
|
+
const err: Error = Object.assign(new Error("no such file"), {
|
|
865
|
+
code: "ENOENT",
|
|
866
|
+
});
|
|
867
|
+
expect(isRetryableInsertError(err)).toBe(false);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("duck-typed numeric-string code on a plain Error follows the ClickHouse code list", () => {
|
|
871
|
+
const retryable: Error = Object.assign(new Error("duplicated package"), {
|
|
872
|
+
code: "202",
|
|
873
|
+
});
|
|
874
|
+
const notRetryable: Error = Object.assign(new Error("duplicated package"), {
|
|
875
|
+
code: "57",
|
|
876
|
+
});
|
|
877
|
+
expect(isRetryableInsertError(retryable)).toBe(true);
|
|
878
|
+
expect(isRetryableInsertError(notRetryable)).toBe(false);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("a NUMERIC (non-string) code does not match and falls through to the message checks", () => {
|
|
882
|
+
const err: Error = Object.assign(new Error("some failure"), { code: 202 });
|
|
883
|
+
expect(isRetryableInsertError(err)).toBe(false);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("plain Error('Timeout error.') is retryable", () => {
|
|
887
|
+
expect(isRetryableInsertError(new Error("Timeout error."))).toBe(true);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("message containing 'socket hang up' is retryable", () => {
|
|
891
|
+
expect(isRetryableInsertError(new Error("socket hang up"))).toBe(true);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("ordinary errors and non-errors are not retryable", () => {
|
|
895
|
+
expect(isRetryableInsertError(new Error("something else"))).toBe(false);
|
|
896
|
+
expect(isRetryableInsertError(null)).toBe(false);
|
|
897
|
+
expect(isRetryableInsertError(undefined)).toBe(false);
|
|
898
|
+
expect(isRetryableInsertError("Timeout error.")).toBe(false);
|
|
899
|
+
expect(isRetryableInsertError(42)).toBe(false);
|
|
900
|
+
expect(isRetryableInsertError({ code: "202" })).toBe(false);
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
describe("FanInInsertError", () => {
|
|
905
|
+
test("formats table, attempts, and an Error cause (with chained stack)", () => {
|
|
906
|
+
const cause: Error = new Error("underlying failure");
|
|
907
|
+
const err: FanInInsertError = new FanInInsertError({
|
|
908
|
+
tableName: "MetricSum",
|
|
909
|
+
attempts: 4,
|
|
910
|
+
cause,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
expect(err.name).toBe("FanInInsertError");
|
|
914
|
+
expect(err.message).toBe(
|
|
915
|
+
"Fan-in insert into MetricSum failed after 4 attempt(s): underlying failure",
|
|
916
|
+
);
|
|
917
|
+
expect(err.stack).toContain("Caused by:");
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("stringifies a non-Error cause", () => {
|
|
921
|
+
const err: FanInInsertError = new FanInInsertError({
|
|
922
|
+
tableName: "LogItems",
|
|
923
|
+
attempts: 1,
|
|
924
|
+
cause: "boom",
|
|
925
|
+
});
|
|
926
|
+
expect(err.message).toBe(
|
|
927
|
+
"Fan-in insert into LogItems failed after 1 attempt(s): boom",
|
|
928
|
+
);
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
describe("readFanInWriterOptionsFromEnv", () => {
|
|
933
|
+
const ENV_KEYS: Array<string> = [
|
|
934
|
+
"TELEMETRY_FANIN_MAX_BATCH_ROWS",
|
|
935
|
+
"TELEMETRY_FANIN_MAX_WAIT_MS",
|
|
936
|
+
"TELEMETRY_FANIN_MAX_CONCURRENT_INSERTS",
|
|
937
|
+
"TELEMETRY_FANIN_MAX_PENDING_ROWS",
|
|
938
|
+
"TELEMETRY_FANIN_RETRY_MAX_ATTEMPTS",
|
|
939
|
+
"TELEMETRY_FANIN_RETRY_BASE_DELAY_MS",
|
|
940
|
+
"TELEMETRY_FANIN_RETRY_MAX_DELAY_MS",
|
|
941
|
+
];
|
|
942
|
+
|
|
943
|
+
function withEnv(
|
|
944
|
+
env: Record<string, string | undefined>,
|
|
945
|
+
run: () => void,
|
|
946
|
+
): void {
|
|
947
|
+
const saved: Record<string, string | undefined> = {};
|
|
948
|
+
for (const key of ENV_KEYS) {
|
|
949
|
+
saved[key] = process.env[key];
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
for (const key of ENV_KEYS) {
|
|
953
|
+
const value: string | undefined = env[key];
|
|
954
|
+
if (value === undefined) {
|
|
955
|
+
delete process.env[key];
|
|
956
|
+
} else {
|
|
957
|
+
process.env[key] = value;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
run();
|
|
961
|
+
} finally {
|
|
962
|
+
for (const key of ENV_KEYS) {
|
|
963
|
+
const value: string | undefined = saved[key];
|
|
964
|
+
if (value === undefined) {
|
|
965
|
+
delete process.env[key];
|
|
966
|
+
} else {
|
|
967
|
+
process.env[key] = value;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
test("returns documented defaults when nothing is set", () => {
|
|
974
|
+
withEnv({}, () => {
|
|
975
|
+
const options: FanInWriterOptions = readFanInWriterOptionsFromEnv();
|
|
976
|
+
expect(options.maxBatchRows).toBe(100_000);
|
|
977
|
+
expect(options.maxWaitMs).toBe(5000);
|
|
978
|
+
expect(options.maxConcurrentInserts).toBe(4);
|
|
979
|
+
expect(options.maxPendingRows).toBe(200_000);
|
|
980
|
+
expect(options.retryMaxAttempts).toBe(6);
|
|
981
|
+
expect(options.retryBaseDelayMs).toBe(250);
|
|
982
|
+
expect(options.retryMaxDelayMs).toBe(10_000);
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test("reads positive integer overrides from the environment", () => {
|
|
987
|
+
withEnv(
|
|
988
|
+
{
|
|
989
|
+
TELEMETRY_FANIN_MAX_BATCH_ROWS: "123",
|
|
990
|
+
TELEMETRY_FANIN_MAX_CONCURRENT_INSERTS: "2",
|
|
991
|
+
},
|
|
992
|
+
() => {
|
|
993
|
+
const options: FanInWriterOptions = readFanInWriterOptionsFromEnv();
|
|
994
|
+
expect(options.maxBatchRows).toBe(123);
|
|
995
|
+
expect(options.maxConcurrentInserts).toBe(2);
|
|
996
|
+
expect(options.maxWaitMs).toBe(5000); // untouched default
|
|
997
|
+
},
|
|
998
|
+
);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("non-numeric, zero, and negative values fall back to defaults", () => {
|
|
1002
|
+
withEnv(
|
|
1003
|
+
{
|
|
1004
|
+
TELEMETRY_FANIN_MAX_BATCH_ROWS: "not-a-number",
|
|
1005
|
+
TELEMETRY_FANIN_MAX_WAIT_MS: "0",
|
|
1006
|
+
TELEMETRY_FANIN_MAX_PENDING_ROWS: "-5",
|
|
1007
|
+
},
|
|
1008
|
+
() => {
|
|
1009
|
+
const options: FanInWriterOptions = readFanInWriterOptionsFromEnv();
|
|
1010
|
+
expect(options.maxBatchRows).toBe(100_000);
|
|
1011
|
+
expect(options.maxWaitMs).toBe(5000);
|
|
1012
|
+
expect(options.maxPendingRows).toBe(200_000);
|
|
1013
|
+
},
|
|
1014
|
+
);
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
/*
|
|
1019
|
+
* The blocks below cover the adversarial-review rework: flushAll's re-cut
|
|
1020
|
+
* loop with the acceptingSubmits guard (rows that buffer only after the
|
|
1021
|
+
* drain begins must still be drained), the deterministic per-job dedup
|
|
1022
|
+
* tokens captured from runWithInsertDedup at submit time, and
|
|
1023
|
+
* pushObservedAck's pre-observed durability acks.
|
|
1024
|
+
*/
|
|
1025
|
+
|
|
1026
|
+
describe("flushAll strand regressions", () => {
|
|
1027
|
+
test("capacity-parked submit: flushAll also drains rows that buffer only after its first pass releases capacity", async () => {
|
|
1028
|
+
const gate: Deferred = deferred();
|
|
1029
|
+
let firstInsertCall: boolean = true;
|
|
1030
|
+
const target: TestTarget = makeTarget({
|
|
1031
|
+
impl: async (): Promise<void> => {
|
|
1032
|
+
if (firstInsertCall) {
|
|
1033
|
+
firstInsertCall = false;
|
|
1034
|
+
await gate.promise;
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1039
|
+
makeOptions({
|
|
1040
|
+
maxPendingRows: 5,
|
|
1041
|
+
maxBatchRows: 100,
|
|
1042
|
+
maxWaitMs: 60_000,
|
|
1043
|
+
}),
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
// A fills the high-water mark exactly and stays buffered (below maxBatchRows).
|
|
1047
|
+
const first: FanInSubmitResult = await writer.submit(target, makeRows(5));
|
|
1048
|
+
expect(target.insertJsonRows).not.toHaveBeenCalled();
|
|
1049
|
+
expect(writer.getStats().bufferedRows).toBe(5);
|
|
1050
|
+
|
|
1051
|
+
// B parks in waitForCapacity — its rows are not buffered anywhere yet.
|
|
1052
|
+
let accepted: boolean = false;
|
|
1053
|
+
const secondAcceptance: Promise<FanInSubmitResult> = writer
|
|
1054
|
+
.submit(target, makeRows(3, 100))
|
|
1055
|
+
.then((result: FanInSubmitResult) => {
|
|
1056
|
+
accepted = true;
|
|
1057
|
+
return result;
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
let insertCallsWhenFlushAllResolved: number = -1;
|
|
1061
|
+
const flushPromise: Promise<void> = writer.flushAll().then((): void => {
|
|
1062
|
+
insertCallsWhenFlushAllResolved = target.insertJsonRows.mock.calls.length;
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// flushAll dispatched A into the gated insert; B is still parked.
|
|
1066
|
+
await tick(25);
|
|
1067
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
1068
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1, 2, 3, 4]);
|
|
1069
|
+
expect(accepted).toBe(false);
|
|
1070
|
+
expect(await stillPendingAfter(flushPromise, 5)).toBe(true);
|
|
1071
|
+
|
|
1072
|
+
/*
|
|
1073
|
+
* A lands → its dispatch releases capacity → B wakes and buffers its
|
|
1074
|
+
* rows WITHOUT creating a dispatch (3 < maxBatchRows; its flush timer
|
|
1075
|
+
* is 60s and unref'd). flushAll must re-cut and drain B too — this
|
|
1076
|
+
* previously stranded B's rows.
|
|
1077
|
+
*/
|
|
1078
|
+
gate.resolve();
|
|
1079
|
+
await flushPromise;
|
|
1080
|
+
|
|
1081
|
+
expect(insertCallsWhenFlushAllResolved).toBe(2);
|
|
1082
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
1083
|
+
expect(rowSeqs(callRows(target, 1))).toEqual([100, 101, 102]);
|
|
1084
|
+
expect(writer.getStats().bufferedRows).toBe(0);
|
|
1085
|
+
|
|
1086
|
+
// BOTH durability acks resolved — B's rows were not stranded.
|
|
1087
|
+
const second: FanInSubmitResult = await secondAcceptance;
|
|
1088
|
+
expect(accepted).toBe(true);
|
|
1089
|
+
await first.flushed;
|
|
1090
|
+
await second.flushed;
|
|
1091
|
+
expect(writer.getStats()).toEqual({
|
|
1092
|
+
bufferedRows: 0,
|
|
1093
|
+
pendingRows: 0,
|
|
1094
|
+
activeInserts: 0,
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test("un-awaited submit: flushAll waits out a submit still parked on its acceptance microtask", async () => {
|
|
1099
|
+
const target: TestTarget = makeTarget();
|
|
1100
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1101
|
+
makeOptions({ maxBatchRows: 100, maxWaitMs: 60_000 }),
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
/*
|
|
1105
|
+
* No await between submit() and flushAll(): the submit has not yet
|
|
1106
|
+
* buffered its rows (it is parked on waitForCapacity's fast-path
|
|
1107
|
+
* microtask). flushAll previously concluded the drain before those
|
|
1108
|
+
* rows landed in the buffer, returning with them stranded.
|
|
1109
|
+
*/
|
|
1110
|
+
const submitPromise: Promise<FanInSubmitResult> = writer.submit(
|
|
1111
|
+
target,
|
|
1112
|
+
makeRows(3),
|
|
1113
|
+
);
|
|
1114
|
+
await writer.flushAll();
|
|
1115
|
+
|
|
1116
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
1117
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1, 2]);
|
|
1118
|
+
expect(writer.getStats().bufferedRows).toBe(0);
|
|
1119
|
+
|
|
1120
|
+
const result: FanInSubmitResult = await submitPromise;
|
|
1121
|
+
await result.flushed;
|
|
1122
|
+
expect(writer.getStats()).toEqual({
|
|
1123
|
+
bufferedRows: 0,
|
|
1124
|
+
pendingRows: 0,
|
|
1125
|
+
activeInserts: 0,
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
describe("deterministic per-job dedup tokens (runWithInsertDedup)", () => {
|
|
1131
|
+
test("tokened submissions insert separately under '<jobId>:<table>:<chunkIndex>' and re-derive byte-identical tokens on a simulated BullMQ retry", async () => {
|
|
1132
|
+
type JobRunner = (
|
|
1133
|
+
writer: TelemetryFanInWriter,
|
|
1134
|
+
target: TestTarget,
|
|
1135
|
+
) => Promise<Array<Promise<void>>>;
|
|
1136
|
+
|
|
1137
|
+
/*
|
|
1138
|
+
* Simulates one queue job processing a fixed payload: chunk tokens are
|
|
1139
|
+
* a pure function of (jobId, table, submit order), so a retry that
|
|
1140
|
+
* re-processes the same payload must re-issue the same tokens.
|
|
1141
|
+
*/
|
|
1142
|
+
const runIdenticalJob: JobRunner = async (
|
|
1143
|
+
writer: TelemetryFanInWriter,
|
|
1144
|
+
target: TestTarget,
|
|
1145
|
+
): Promise<Array<Promise<void>>> => {
|
|
1146
|
+
const acks: Array<Promise<void>> = [];
|
|
1147
|
+
await runWithInsertDedup("job1", async (): Promise<void> => {
|
|
1148
|
+
acks.push((await writer.submit(target, makeRows(3))).flushed);
|
|
1149
|
+
acks.push((await writer.submit(target, makeRows(2, 3))).flushed);
|
|
1150
|
+
});
|
|
1151
|
+
return acks;
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
// First attempt: the second submit reaches maxBatchRows and triggers the flush.
|
|
1155
|
+
const firstTarget: TestTarget = makeTarget();
|
|
1156
|
+
const firstWriter: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1157
|
+
makeOptions({ maxBatchRows: 5, maxWaitMs: 60_000 }),
|
|
1158
|
+
);
|
|
1159
|
+
await Promise.all(await runIdenticalJob(firstWriter, firstTarget));
|
|
1160
|
+
|
|
1161
|
+
// Two SEPARATE inserts — tokened submissions are never merged.
|
|
1162
|
+
expect(firstTarget.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
1163
|
+
expect(rowSeqs(callRows(firstTarget, 0))).toEqual([0, 1, 2]);
|
|
1164
|
+
expect(rowSeqs(callRows(firstTarget, 1))).toEqual([3, 4]);
|
|
1165
|
+
const firstTokens: Array<string | undefined> = [0, 1].map(
|
|
1166
|
+
(callIndex: number) => {
|
|
1167
|
+
return callOptions(firstTarget, callIndex).dedupToken;
|
|
1168
|
+
},
|
|
1169
|
+
);
|
|
1170
|
+
expect(firstTokens).toEqual(["job1:TestTable:0", "job1:TestTable:1"]);
|
|
1171
|
+
|
|
1172
|
+
// Simulated BullMQ retry: identical payload, fresh writer instance.
|
|
1173
|
+
const retryTarget: TestTarget = makeTarget();
|
|
1174
|
+
const retryWriter: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1175
|
+
makeOptions({ maxBatchRows: 5, maxWaitMs: 60_000 }),
|
|
1176
|
+
);
|
|
1177
|
+
await Promise.all(await runIdenticalJob(retryWriter, retryTarget));
|
|
1178
|
+
|
|
1179
|
+
expect(retryTarget.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
1180
|
+
const retryTokens: Array<string | undefined> = [0, 1].map(
|
|
1181
|
+
(callIndex: number) => {
|
|
1182
|
+
return callOptions(retryTarget, callIndex).dedupToken;
|
|
1183
|
+
},
|
|
1184
|
+
);
|
|
1185
|
+
// Byte-identical across retries — what lets ClickHouse drop duplicates.
|
|
1186
|
+
expect(retryTokens).toEqual(["job1:TestTable:0", "job1:TestTable:1"]);
|
|
1187
|
+
expect(retryTokens).toEqual(firstTokens);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
test("mixed batch: tokened rows insert under their deterministic token; untokened rows under a minted fanin token", async () => {
|
|
1191
|
+
const target: TestTarget = makeTarget();
|
|
1192
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1193
|
+
makeOptions({ maxBatchRows: 100, maxWaitMs: 60_000 }),
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
const acks: Array<Promise<void>> = [];
|
|
1197
|
+
await runWithInsertDedup("job2", async (): Promise<void> => {
|
|
1198
|
+
acks.push((await writer.submit(target, makeRows(2))).flushed);
|
|
1199
|
+
});
|
|
1200
|
+
acks.push((await writer.submit(target, makeRows(3, 100))).flushed);
|
|
1201
|
+
|
|
1202
|
+
expect(target.insertJsonRows).not.toHaveBeenCalled();
|
|
1203
|
+
await writer.flushAll();
|
|
1204
|
+
await Promise.all(acks);
|
|
1205
|
+
|
|
1206
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
1207
|
+
// The tokened submission inserts first, alone, under its per-job token …
|
|
1208
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1]);
|
|
1209
|
+
expect(callOptions(target, 0).dedupToken).toBe("job2:TestTable:0");
|
|
1210
|
+
// … and the untokened one inserts separately under a minted batch token.
|
|
1211
|
+
expect(rowSeqs(callRows(target, 1))).toEqual([100, 101, 102]);
|
|
1212
|
+
expect(callOptions(target, 1).dedupToken).toMatch(/^fanin:TestTable:/);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
test("failure isolation: a non-retryable failure on one token group rejects only that group's ack", async () => {
|
|
1216
|
+
const target: TestTarget = makeTarget({
|
|
1217
|
+
impl: async (
|
|
1218
|
+
rows: Array<JSONObject>,
|
|
1219
|
+
options?: InsertRowsOptions,
|
|
1220
|
+
): Promise<void> => {
|
|
1221
|
+
if (options?.dedupToken?.endsWith(":0")) {
|
|
1222
|
+
throw clickHouseError("60", `Unknown table (${rows.length} rows)`);
|
|
1223
|
+
}
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1227
|
+
makeOptions({ maxBatchRows: 100, maxWaitMs: 60_000 }),
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
const acks: Array<Promise<void>> = [];
|
|
1231
|
+
await runWithInsertDedup("job3", async (): Promise<void> => {
|
|
1232
|
+
acks.push((await writer.submit(target, makeRows(2))).flushed);
|
|
1233
|
+
acks.push((await writer.submit(target, makeRows(2, 2))).flushed);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// Handler attached BEFORE the flush so the rejection is always observed.
|
|
1237
|
+
const firstOutcome: Promise<Error> = captureRejection(acks[0]!);
|
|
1238
|
+
await writer.flushAll();
|
|
1239
|
+
|
|
1240
|
+
const err: Error = await firstOutcome;
|
|
1241
|
+
expect(err).toBeInstanceOf(FanInInsertError);
|
|
1242
|
+
expect(err.message).toContain("after 1 attempt(s)");
|
|
1243
|
+
expect(err.message).toContain("Unknown table");
|
|
1244
|
+
|
|
1245
|
+
// The sibling token group in the SAME batch still lands and resolves.
|
|
1246
|
+
await acks[1]!;
|
|
1247
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
1248
|
+
expect(callOptions(target, 0).dedupToken).toBe("job3:TestTable:0");
|
|
1249
|
+
expect(callOptions(target, 1).dedupToken).toBe("job3:TestTable:1");
|
|
1250
|
+
expect(writer.getStats()).toEqual({
|
|
1251
|
+
bufferedRows: 0,
|
|
1252
|
+
pendingRows: 0,
|
|
1253
|
+
activeInserts: 0,
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
test("tokened retry: transient 202 failures retry with the SAME deterministic token, never a fanin token", async () => {
|
|
1258
|
+
const target: TestTarget = makeTarget({
|
|
1259
|
+
impl: failNTimesImpl(
|
|
1260
|
+
2,
|
|
1261
|
+
clickHouseError("202", "Too many simultaneous queries. Maximum: 1000"),
|
|
1262
|
+
),
|
|
1263
|
+
});
|
|
1264
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1265
|
+
makeOptions({
|
|
1266
|
+
maxBatchRows: 2,
|
|
1267
|
+
maxWaitMs: 60_000,
|
|
1268
|
+
retryMaxAttempts: 5,
|
|
1269
|
+
}),
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
const acks: Array<Promise<void>> = [];
|
|
1273
|
+
await runWithInsertDedup("job4", async (): Promise<void> => {
|
|
1274
|
+
acks.push((await writer.submit(target, makeRows(2))).flushed);
|
|
1275
|
+
});
|
|
1276
|
+
await Promise.all(acks);
|
|
1277
|
+
|
|
1278
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(3);
|
|
1279
|
+
const tokens: Array<string | undefined> = [0, 1, 2].map(
|
|
1280
|
+
(callIndex: number) => {
|
|
1281
|
+
return callOptions(target, callIndex).dedupToken;
|
|
1282
|
+
},
|
|
1283
|
+
);
|
|
1284
|
+
expect(tokens).toEqual([
|
|
1285
|
+
"job4:TestTable:0",
|
|
1286
|
+
"job4:TestTable:0",
|
|
1287
|
+
"job4:TestTable:0",
|
|
1288
|
+
]);
|
|
1289
|
+
expect(tokens[0]).not.toMatch(/^fanin:/);
|
|
1290
|
+
// Every attempt re-sends the identical row set.
|
|
1291
|
+
expect(rowSeqs(callRows(target, 2))).toEqual([0, 1]);
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
describe("pushObservedAck", () => {
|
|
1296
|
+
class WrappedStorageError extends Error {
|
|
1297
|
+
public constructor(message: string) {
|
|
1298
|
+
super(message);
|
|
1299
|
+
this.name = "WrappedStorageError";
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
test("a rejected flushed promise fires no unhandledRejection and delivers the wrapped error at the await point", async () => {
|
|
1304
|
+
const unhandledReasons: Array<unknown> = [];
|
|
1305
|
+
const onUnhandledRejection: (reason: unknown) => void = (
|
|
1306
|
+
reason: unknown,
|
|
1307
|
+
): void => {
|
|
1308
|
+
unhandledReasons.push(reason);
|
|
1309
|
+
};
|
|
1310
|
+
process.on("unhandledRejection", onUnhandledRejection);
|
|
1311
|
+
|
|
1312
|
+
try {
|
|
1313
|
+
const pendingAcks: Array<Promise<void>> = [];
|
|
1314
|
+
const flushed: Promise<void> = Promise.reject(
|
|
1315
|
+
new Error("insert definitively failed"),
|
|
1316
|
+
);
|
|
1317
|
+
|
|
1318
|
+
pushObservedAck(pendingAcks, flushed, (err: Error): Error => {
|
|
1319
|
+
return new WrappedStorageError(
|
|
1320
|
+
`Telemetry write failed: ${err.message}`,
|
|
1321
|
+
);
|
|
1322
|
+
});
|
|
1323
|
+
expect(pendingAcks).toHaveLength(1);
|
|
1324
|
+
|
|
1325
|
+
// An unobserved rejection would surface within a few macrotasks.
|
|
1326
|
+
await tick(30);
|
|
1327
|
+
expect(unhandledReasons).toEqual([]);
|
|
1328
|
+
|
|
1329
|
+
// …but it is still delivered for real at the job's await point.
|
|
1330
|
+
const settled: Array<PromiseSettledResult<void>> =
|
|
1331
|
+
await Promise.allSettled(pendingAcks);
|
|
1332
|
+
const firstSettled: PromiseSettledResult<void> = settled[0]!;
|
|
1333
|
+
expect(firstSettled.status).toBe("rejected");
|
|
1334
|
+
if (firstSettled.status !== "rejected") {
|
|
1335
|
+
throw new Error("Expected the ack to be rejected.");
|
|
1336
|
+
}
|
|
1337
|
+
expect(firstSettled.reason).toBeInstanceOf(WrappedStorageError);
|
|
1338
|
+
expect((firstSettled.reason as Error).message).toBe(
|
|
1339
|
+
"Telemetry write failed: insert definitively failed",
|
|
1340
|
+
);
|
|
1341
|
+
} finally {
|
|
1342
|
+
process.removeListener("unhandledRejection", onUnhandledRejection);
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
test("a resolved flushed promise pushes a resolving ack", async () => {
|
|
1347
|
+
const pendingAcks: Array<Promise<void>> = [];
|
|
1348
|
+
pushObservedAck(pendingAcks, Promise.resolve(), (err: Error): Error => {
|
|
1349
|
+
return new WrappedStorageError(err.message);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
expect(pendingAcks).toHaveLength(1);
|
|
1353
|
+
await expect(Promise.all(pendingAcks)).resolves.toEqual([undefined]);
|
|
1354
|
+
});
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
describe("explicit dedup tokens (writer-tier passthrough)", () => {
|
|
1358
|
+
test("submit honors options.dedupToken outside any dedup scope", async () => {
|
|
1359
|
+
const target: TestTarget = makeTarget();
|
|
1360
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1361
|
+
makeOptions(),
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
const submission: FanInSubmitResult = await writer.submit(
|
|
1365
|
+
target,
|
|
1366
|
+
makeRows(3),
|
|
1367
|
+
{ dedupToken: "job-9:TestTable:2" },
|
|
1368
|
+
);
|
|
1369
|
+
await writer.flushAll();
|
|
1370
|
+
await submission.flushed;
|
|
1371
|
+
|
|
1372
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
1373
|
+
expect(callOptions(target, 0).dedupToken).toBe("job-9:TestTable:2");
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
test("explicit token wins over the ambient dedup context", async () => {
|
|
1377
|
+
const target: TestTarget = makeTarget();
|
|
1378
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1379
|
+
makeOptions(),
|
|
1380
|
+
);
|
|
1381
|
+
|
|
1382
|
+
await runWithInsertDedup("ambient-job", async (): Promise<void> => {
|
|
1383
|
+
await writer.submit(target, makeRows(2), {
|
|
1384
|
+
dedupToken: "explicit-token",
|
|
1385
|
+
});
|
|
1386
|
+
});
|
|
1387
|
+
await writer.flushAll();
|
|
1388
|
+
|
|
1389
|
+
expect(callOptions(target, 0).dedupToken).toBe("explicit-token");
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("distinct explicit tokens in one batch insert individually", async () => {
|
|
1393
|
+
const target: TestTarget = makeTarget();
|
|
1394
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1395
|
+
makeOptions({ maxBatchRows: 100 }),
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
await writer.submit(target, makeRows(2, 0), { dedupToken: "token-a" });
|
|
1399
|
+
await writer.submit(target, makeRows(2, 2), { dedupToken: "token-b" });
|
|
1400
|
+
await writer.flushAll();
|
|
1401
|
+
|
|
1402
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(2);
|
|
1403
|
+
expect(callOptions(target, 0).dedupToken).toBe("token-a");
|
|
1404
|
+
expect(callOptions(target, 1).dedupToken).toBe("token-b");
|
|
1405
|
+
expect(rowSeqs(callRows(target, 0))).toEqual([0, 1]);
|
|
1406
|
+
expect(rowSeqs(callRows(target, 1))).toEqual([2, 3]);
|
|
1407
|
+
});
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
describe("insert transport hook", () => {
|
|
1411
|
+
test("an installed transport replaces the direct insert and receives token + settings", async () => {
|
|
1412
|
+
const target: TestTarget = makeTarget();
|
|
1413
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1414
|
+
makeOptions(),
|
|
1415
|
+
);
|
|
1416
|
+
const transportCalls: Array<{
|
|
1417
|
+
tableName: string;
|
|
1418
|
+
rows: Array<JSONObject>;
|
|
1419
|
+
dedupToken: string;
|
|
1420
|
+
clickhouseSettings: ClickHouseSettings | undefined;
|
|
1421
|
+
}> = [];
|
|
1422
|
+
writer.setInsertTransport(
|
|
1423
|
+
async (
|
|
1424
|
+
transportTarget: FanInInsertTarget,
|
|
1425
|
+
rows: Array<JSONObject>,
|
|
1426
|
+
options: {
|
|
1427
|
+
dedupToken: string;
|
|
1428
|
+
clickhouseSettings: ClickHouseSettings | undefined;
|
|
1429
|
+
},
|
|
1430
|
+
): Promise<void> => {
|
|
1431
|
+
transportCalls.push({
|
|
1432
|
+
tableName: transportTarget.model.tableName,
|
|
1433
|
+
rows,
|
|
1434
|
+
dedupToken: options.dedupToken,
|
|
1435
|
+
clickhouseSettings: options.clickhouseSettings,
|
|
1436
|
+
});
|
|
1437
|
+
},
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
const submission: FanInSubmitResult = await writer.submit(
|
|
1441
|
+
target,
|
|
1442
|
+
makeRows(2),
|
|
1443
|
+
{
|
|
1444
|
+
dedupToken: "remote-token",
|
|
1445
|
+
clickhouseSettings: { async_insert: 1 },
|
|
1446
|
+
},
|
|
1447
|
+
);
|
|
1448
|
+
await writer.flushAll();
|
|
1449
|
+
await submission.flushed;
|
|
1450
|
+
|
|
1451
|
+
expect(target.insertJsonRows).not.toHaveBeenCalled();
|
|
1452
|
+
expect(transportCalls).toHaveLength(1);
|
|
1453
|
+
expect(transportCalls[0]!.tableName).toBe("TestTable");
|
|
1454
|
+
expect(transportCalls[0]!.dedupToken).toBe("remote-token");
|
|
1455
|
+
expect(transportCalls[0]!.clickhouseSettings).toEqual({ async_insert: 1 });
|
|
1456
|
+
expect(rowSeqs(transportCalls[0]!.rows)).toEqual([0, 1]);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
test("TransientInsertError from the transport retries with the SAME token", async () => {
|
|
1460
|
+
const target: TestTarget = makeTarget();
|
|
1461
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1462
|
+
makeOptions(),
|
|
1463
|
+
);
|
|
1464
|
+
const seenTokens: Array<string> = [];
|
|
1465
|
+
let attempts: number = 0;
|
|
1466
|
+
writer.setInsertTransport(
|
|
1467
|
+
async (
|
|
1468
|
+
_transportTarget: FanInInsertTarget,
|
|
1469
|
+
_rows: Array<JSONObject>,
|
|
1470
|
+
options: {
|
|
1471
|
+
dedupToken: string;
|
|
1472
|
+
clickhouseSettings: ClickHouseSettings | undefined;
|
|
1473
|
+
},
|
|
1474
|
+
): Promise<void> => {
|
|
1475
|
+
seenTokens.push(options.dedupToken);
|
|
1476
|
+
attempts++;
|
|
1477
|
+
if (attempts < 3) {
|
|
1478
|
+
throw new TransientInsertError("writer tier is shedding load (429)");
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1481
|
+
);
|
|
1482
|
+
|
|
1483
|
+
const submission: FanInSubmitResult = await writer.submit(
|
|
1484
|
+
target,
|
|
1485
|
+
makeRows(1),
|
|
1486
|
+
{ dedupToken: "sticky-token" },
|
|
1487
|
+
);
|
|
1488
|
+
await writer.flushAll();
|
|
1489
|
+
await expect(submission.flushed).resolves.toBeUndefined();
|
|
1490
|
+
|
|
1491
|
+
expect(seenTokens).toEqual([
|
|
1492
|
+
"sticky-token",
|
|
1493
|
+
"sticky-token",
|
|
1494
|
+
"sticky-token",
|
|
1495
|
+
]);
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
test("a permanent transport error fails the ack after one attempt", async () => {
|
|
1499
|
+
const target: TestTarget = makeTarget();
|
|
1500
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1501
|
+
makeOptions(),
|
|
1502
|
+
);
|
|
1503
|
+
let attempts: number = 0;
|
|
1504
|
+
writer.setInsertTransport(async (): Promise<void> => {
|
|
1505
|
+
attempts++;
|
|
1506
|
+
throw new Error("writer returned 400: unknown table");
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
const submission: FanInSubmitResult = await writer.submit(
|
|
1510
|
+
target,
|
|
1511
|
+
makeRows(1),
|
|
1512
|
+
{ dedupToken: "token" },
|
|
1513
|
+
);
|
|
1514
|
+
await writer.flushAll();
|
|
1515
|
+
|
|
1516
|
+
const err: Error = await captureRejection(submission.flushed);
|
|
1517
|
+
expect(err).toBeInstanceOf(FanInInsertError);
|
|
1518
|
+
expect(attempts).toBe(1);
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
test("clearing the transport restores the direct insert path", async () => {
|
|
1522
|
+
const target: TestTarget = makeTarget();
|
|
1523
|
+
const writer: TelemetryFanInWriter = new TelemetryFanInWriter(
|
|
1524
|
+
makeOptions(),
|
|
1525
|
+
);
|
|
1526
|
+
writer.setInsertTransport(async (): Promise<void> => {
|
|
1527
|
+
throw new Error("should not be used");
|
|
1528
|
+
});
|
|
1529
|
+
writer.setInsertTransport(null);
|
|
1530
|
+
expect(writer.hasInsertTransport()).toBe(false);
|
|
1531
|
+
|
|
1532
|
+
const submission: FanInSubmitResult = await writer.submit(
|
|
1533
|
+
target,
|
|
1534
|
+
makeRows(1),
|
|
1535
|
+
{ dedupToken: "token" },
|
|
1536
|
+
);
|
|
1537
|
+
await writer.flushAll();
|
|
1538
|
+
await submission.flushed;
|
|
1539
|
+
|
|
1540
|
+
expect(target.insertJsonRows).toHaveBeenCalledTimes(1);
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
describe("isRetryableInsertError — TransientInsertError", () => {
|
|
1545
|
+
test("TransientInsertError is retryable", () => {
|
|
1546
|
+
expect(isRetryableInsertError(new TransientInsertError("shed"))).toBe(true);
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
test("duck-typed TransientInsertError (duplicate module instance) is retryable", () => {
|
|
1550
|
+
const err: Error = new Error("shed");
|
|
1551
|
+
err.name = "TransientInsertError";
|
|
1552
|
+
expect(isRetryableInsertError(err)).toBe(true);
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
test("FanInInsertError keeps its structured cause", () => {
|
|
1556
|
+
const cause: TransientInsertError = new TransientInsertError("503");
|
|
1557
|
+
const err: FanInInsertError = new FanInInsertError({
|
|
1558
|
+
tableName: "T",
|
|
1559
|
+
attempts: 3,
|
|
1560
|
+
cause,
|
|
1561
|
+
});
|
|
1562
|
+
expect(err.causeError).toBe(cause);
|
|
1563
|
+
expect(isRetryableInsertError(err.causeError)).toBe(true);
|
|
1564
|
+
});
|
|
1565
|
+
});
|