@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,313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FanInInsertTarget,
|
|
3
|
+
FanInInsertTransport,
|
|
4
|
+
TransientInsertError,
|
|
5
|
+
} from "../../../../Server/Utils/Telemetry/TelemetryFanInWriter";
|
|
6
|
+
import {
|
|
7
|
+
TELEMETRY_WRITER_INSERT_ROUTE,
|
|
8
|
+
WriterPostFn,
|
|
9
|
+
WriterPostResult,
|
|
10
|
+
createTelemetryWriterTransport,
|
|
11
|
+
getTelemetryWriterRequestTimeoutMs,
|
|
12
|
+
getTelemetryWriterUrl,
|
|
13
|
+
isTelemetryWriterForwardingEnabled,
|
|
14
|
+
} from "../../../../Server/Utils/Telemetry/TelemetryWriterClient";
|
|
15
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* The worker-side transport for the telemetry-writer tier. All tests inject
|
|
20
|
+
* a fake post function — no network, no ClickHouse.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TARGET: FanInInsertTarget = {
|
|
24
|
+
model: { tableName: "LogTable" },
|
|
25
|
+
insertJsonRows: async (): Promise<void> => {
|
|
26
|
+
throw new Error("the remote transport must never insert directly");
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type PostCall = {
|
|
31
|
+
url: string;
|
|
32
|
+
body: JSONObject;
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function makePost(results: Array<WriterPostResult>): {
|
|
37
|
+
post: WriterPostFn;
|
|
38
|
+
calls: Array<PostCall>;
|
|
39
|
+
} {
|
|
40
|
+
const calls: Array<PostCall> = [];
|
|
41
|
+
const post: WriterPostFn = async (
|
|
42
|
+
args: PostCall,
|
|
43
|
+
): Promise<WriterPostResult> => {
|
|
44
|
+
calls.push(args);
|
|
45
|
+
const result: WriterPostResult | undefined = results.shift();
|
|
46
|
+
if (!result) {
|
|
47
|
+
throw new Error("no scripted result left");
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
return { post, calls };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("TelemetryWriterClient", () => {
|
|
55
|
+
const savedUrl: string | undefined = process.env["TELEMETRY_WRITER_URL"];
|
|
56
|
+
const savedTimeout: string | undefined =
|
|
57
|
+
process.env["TELEMETRY_WRITER_REQUEST_TIMEOUT_MS"];
|
|
58
|
+
const savedMaxBody: string | undefined =
|
|
59
|
+
process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"];
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
process.env["TELEMETRY_WRITER_URL"] = "http://telemetry-writer:3002";
|
|
63
|
+
delete process.env["TELEMETRY_WRITER_REQUEST_TIMEOUT_MS"];
|
|
64
|
+
delete process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
if (savedUrl === undefined) {
|
|
69
|
+
delete process.env["TELEMETRY_WRITER_URL"];
|
|
70
|
+
} else {
|
|
71
|
+
process.env["TELEMETRY_WRITER_URL"] = savedUrl;
|
|
72
|
+
}
|
|
73
|
+
if (savedTimeout === undefined) {
|
|
74
|
+
delete process.env["TELEMETRY_WRITER_REQUEST_TIMEOUT_MS"];
|
|
75
|
+
} else {
|
|
76
|
+
process.env["TELEMETRY_WRITER_REQUEST_TIMEOUT_MS"] = savedTimeout;
|
|
77
|
+
}
|
|
78
|
+
if (savedMaxBody === undefined) {
|
|
79
|
+
delete process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"];
|
|
80
|
+
} else {
|
|
81
|
+
process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"] = savedMaxBody;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("configuration", () => {
|
|
86
|
+
test("forwarding is disabled when TELEMETRY_WRITER_URL is unset or blank", () => {
|
|
87
|
+
delete process.env["TELEMETRY_WRITER_URL"];
|
|
88
|
+
expect(getTelemetryWriterUrl()).toBeNull();
|
|
89
|
+
expect(isTelemetryWriterForwardingEnabled()).toBe(false);
|
|
90
|
+
|
|
91
|
+
process.env["TELEMETRY_WRITER_URL"] = " ";
|
|
92
|
+
expect(isTelemetryWriterForwardingEnabled()).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a trailing slash is normalized away", () => {
|
|
96
|
+
process.env["TELEMETRY_WRITER_URL"] = "http://writer:3002/";
|
|
97
|
+
expect(getTelemetryWriterUrl()).toBe("http://writer:3002");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("request timeout defaults to 90s (under the BullMQ lock) and honors the env override", () => {
|
|
101
|
+
expect(getTelemetryWriterRequestTimeoutMs()).toBe(90_000);
|
|
102
|
+
process.env["TELEMETRY_WRITER_REQUEST_TIMEOUT_MS"] = "5000";
|
|
103
|
+
expect(getTelemetryWriterRequestTimeoutMs()).toBe(5000);
|
|
104
|
+
process.env["TELEMETRY_WRITER_REQUEST_TIMEOUT_MS"] = "not-a-number";
|
|
105
|
+
expect(getTelemetryWriterRequestTimeoutMs()).toBe(90_000);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("request shape", () => {
|
|
110
|
+
test("posts table, rows, token, and settings to the insert route", async () => {
|
|
111
|
+
const { post, calls } = makePost([{ statusCode: 200 }]);
|
|
112
|
+
const transport: FanInInsertTransport =
|
|
113
|
+
createTelemetryWriterTransport(post);
|
|
114
|
+
|
|
115
|
+
await transport(TARGET, [{ a: 1 }, { a: 2 }], {
|
|
116
|
+
dedupToken: "job-1:LogTable:0",
|
|
117
|
+
clickhouseSettings: { async_insert: 1 },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(calls).toHaveLength(1);
|
|
121
|
+
expect(calls[0]!.url).toBe(
|
|
122
|
+
`http://telemetry-writer:3002${TELEMETRY_WRITER_INSERT_ROUTE}`,
|
|
123
|
+
);
|
|
124
|
+
expect(calls[0]!.timeoutMs).toBe(90_000);
|
|
125
|
+
expect(calls[0]!.body).toEqual({
|
|
126
|
+
tableName: "LogTable",
|
|
127
|
+
rows: [{ a: 1 }, { a: 2 }],
|
|
128
|
+
dedupToken: "job-1:LogTable:0",
|
|
129
|
+
clickhouseSettings: { async_insert: 1 },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("omits clickhouseSettings from the body when not set", async () => {
|
|
134
|
+
const { post, calls } = makePost([{ statusCode: 200 }]);
|
|
135
|
+
const transport: FanInInsertTransport =
|
|
136
|
+
createTelemetryWriterTransport(post);
|
|
137
|
+
|
|
138
|
+
await transport(TARGET, [{ a: 1 }], {
|
|
139
|
+
dedupToken: "t",
|
|
140
|
+
clickhouseSettings: undefined,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(
|
|
144
|
+
Object.prototype.hasOwnProperty.call(
|
|
145
|
+
calls[0]!.body,
|
|
146
|
+
"clickhouseSettings",
|
|
147
|
+
),
|
|
148
|
+
).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("throws a permanent error when the URL is not configured", async () => {
|
|
152
|
+
delete process.env["TELEMETRY_WRITER_URL"];
|
|
153
|
+
const { post } = makePost([]);
|
|
154
|
+
const transport: FanInInsertTransport =
|
|
155
|
+
createTelemetryWriterTransport(post);
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
transport(TARGET, [{ a: 1 }], {
|
|
159
|
+
dedupToken: "t",
|
|
160
|
+
clickhouseSettings: undefined,
|
|
161
|
+
}),
|
|
162
|
+
).rejects.toThrow("TELEMETRY_WRITER_URL is not configured");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("status classification", () => {
|
|
167
|
+
const transientStatuses: Array<number> = [429, 502, 503, 504, 0, -1];
|
|
168
|
+
for (const status of transientStatuses) {
|
|
169
|
+
test(`status ${status} throws TransientInsertError (retry, same token)`, async () => {
|
|
170
|
+
const { post } = makePost([
|
|
171
|
+
{ statusCode: status, errorMessage: "nope" },
|
|
172
|
+
]);
|
|
173
|
+
const transport: FanInInsertTransport =
|
|
174
|
+
createTelemetryWriterTransport(post);
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
transport(TARGET, [{ a: 1 }], {
|
|
178
|
+
dedupToken: "t",
|
|
179
|
+
clickhouseSettings: undefined,
|
|
180
|
+
}),
|
|
181
|
+
).rejects.toBeInstanceOf(TransientInsertError);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const permanentStatuses: Array<number> = [400, 401, 403, 422, 500];
|
|
186
|
+
for (const status of permanentStatuses) {
|
|
187
|
+
test(`status ${status} throws a permanent (non-transient) error`, async () => {
|
|
188
|
+
const { post } = makePost([
|
|
189
|
+
{ statusCode: status, errorMessage: "bad" },
|
|
190
|
+
]);
|
|
191
|
+
const transport: FanInInsertTransport =
|
|
192
|
+
createTelemetryWriterTransport(post);
|
|
193
|
+
|
|
194
|
+
const err: Error = await transport(TARGET, [{ a: 1 }], {
|
|
195
|
+
dedupToken: "t",
|
|
196
|
+
clickhouseSettings: undefined,
|
|
197
|
+
}).then(
|
|
198
|
+
() => {
|
|
199
|
+
throw new Error("expected rejection");
|
|
200
|
+
},
|
|
201
|
+
(e: Error) => {
|
|
202
|
+
return e;
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
expect(err).not.toBeInstanceOf(TransientInsertError);
|
|
206
|
+
expect(err.message).toContain(`status ${status}`);
|
|
207
|
+
expect(err.message).toContain("LogTable");
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
test("2xx resolves", async () => {
|
|
212
|
+
const { post } = makePost([{ statusCode: 200 }]);
|
|
213
|
+
const transport: FanInInsertTransport =
|
|
214
|
+
createTelemetryWriterTransport(post);
|
|
215
|
+
await expect(
|
|
216
|
+
transport(TARGET, [{ a: 1 }], {
|
|
217
|
+
dedupToken: "t",
|
|
218
|
+
clickhouseSettings: undefined,
|
|
219
|
+
}),
|
|
220
|
+
).resolves.toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("byte-aware splitting", () => {
|
|
225
|
+
test("a body over TELEMETRY_WRITER_MAX_BODY_BYTES splits into sequential halves with derived tokens", async () => {
|
|
226
|
+
// The 4-row body serializes to ~98 bytes; a 90-byte cap forces one halving.
|
|
227
|
+
process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"] = "90";
|
|
228
|
+
const { post, calls } = makePost([
|
|
229
|
+
{ statusCode: 200 },
|
|
230
|
+
{ statusCode: 200 },
|
|
231
|
+
]);
|
|
232
|
+
const transport: FanInInsertTransport =
|
|
233
|
+
createTelemetryWriterTransport(post);
|
|
234
|
+
|
|
235
|
+
const rows: Array<JSONObject> = [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }];
|
|
236
|
+
await transport(TARGET, rows, {
|
|
237
|
+
dedupToken: "job-1:LogTable:0",
|
|
238
|
+
clickhouseSettings: undefined,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(calls).toHaveLength(2);
|
|
242
|
+
expect(calls[0]!.body["dedupToken"]).toBe("job-1:LogTable:0#0");
|
|
243
|
+
expect(calls[0]!.body["rows"]).toEqual([{ a: 1 }, { a: 2 }]);
|
|
244
|
+
expect(calls[1]!.body["dedupToken"]).toBe("job-1:LogTable:0#1");
|
|
245
|
+
expect(calls[1]!.body["rows"]).toEqual([{ a: 3 }, { a: 4 }]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("splitting recurses until halves fit, keeping row order and deterministic tokens", async () => {
|
|
249
|
+
process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"] = "100";
|
|
250
|
+
const results: Array<WriterPostResult> = [];
|
|
251
|
+
for (let i: number = 0; i < 8; i++) {
|
|
252
|
+
results.push({ statusCode: 200 });
|
|
253
|
+
}
|
|
254
|
+
const { post, calls } = makePost(results);
|
|
255
|
+
const transport: FanInInsertTransport =
|
|
256
|
+
createTelemetryWriterTransport(post);
|
|
257
|
+
|
|
258
|
+
const rows: Array<JSONObject> = [];
|
|
259
|
+
for (let i: number = 0; i < 8; i++) {
|
|
260
|
+
rows.push({ padded: `row-${i}-xxxxxxxxxx` });
|
|
261
|
+
}
|
|
262
|
+
await transport(TARGET, rows, {
|
|
263
|
+
dedupToken: "t",
|
|
264
|
+
clickhouseSettings: undefined,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// All rows arrive exactly once, in order, across the splits.
|
|
268
|
+
const seen: Array<JSONObject> = calls.flatMap((call: PostCall) => {
|
|
269
|
+
return call.body["rows"] as Array<JSONObject>;
|
|
270
|
+
});
|
|
271
|
+
expect(seen).toEqual(rows);
|
|
272
|
+
// Every posted body fits the cap, and tokens are unique per part.
|
|
273
|
+
const tokens: Array<string> = calls.map((call: PostCall) => {
|
|
274
|
+
return call.body["dedupToken"] as string;
|
|
275
|
+
});
|
|
276
|
+
expect(new Set(tokens).size).toBe(tokens.length);
|
|
277
|
+
for (const call of calls) {
|
|
278
|
+
expect(
|
|
279
|
+
Buffer.byteLength(JSON.stringify(call.body), "utf8"),
|
|
280
|
+
).toBeLessThanOrEqual(100);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("a single oversized row is posted anyway (cannot be split)", async () => {
|
|
285
|
+
process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"] = "50";
|
|
286
|
+
const { post, calls } = makePost([{ statusCode: 200 }]);
|
|
287
|
+
const transport: FanInInsertTransport =
|
|
288
|
+
createTelemetryWriterTransport(post);
|
|
289
|
+
|
|
290
|
+
await transport(TARGET, [{ huge: "x".repeat(200) }], {
|
|
291
|
+
dedupToken: "t",
|
|
292
|
+
clickhouseSettings: undefined,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(calls).toHaveLength(1);
|
|
296
|
+
expect(calls[0]!.body["dedupToken"]).toBe("t");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("no split below the cap — token and rows pass through untouched", async () => {
|
|
300
|
+
const { post, calls } = makePost([{ statusCode: 200 }]);
|
|
301
|
+
const transport: FanInInsertTransport =
|
|
302
|
+
createTelemetryWriterTransport(post);
|
|
303
|
+
|
|
304
|
+
await transport(TARGET, [{ a: 1 }], {
|
|
305
|
+
dedupToken: "plain-token",
|
|
306
|
+
clickhouseSettings: undefined,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(calls).toHaveLength(1);
|
|
310
|
+
expect(calls[0]!.body["dedupToken"]).toBe("plain-token");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FanInInsertError,
|
|
3
|
+
FanInInsertTarget,
|
|
4
|
+
FanInSubmitResult,
|
|
5
|
+
TransientInsertError,
|
|
6
|
+
} from "../../../../Server/Utils/Telemetry/TelemetryFanInWriter";
|
|
7
|
+
import {
|
|
8
|
+
InflightGate,
|
|
9
|
+
TableTargetResolver,
|
|
10
|
+
WriterInsertDeps,
|
|
11
|
+
WriterInsertOutcome,
|
|
12
|
+
createTableTargetResolver,
|
|
13
|
+
handleWriterInsert,
|
|
14
|
+
parseWriterInsertRequest,
|
|
15
|
+
readWriterMaxInflightRequestsFromEnv,
|
|
16
|
+
} from "../../../../Server/Utils/Telemetry/TelemetryWriterServer";
|
|
17
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
18
|
+
import { type ClickHouseSettings } from "@clickhouse/client";
|
|
19
|
+
import { afterEach, describe, expect, test } from "@jest/globals";
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Writer-tier request handling: admission gate, validation, table
|
|
23
|
+
* resolution, and the HTTP status contract for insert outcomes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
function makeTarget(tableName: string): FanInInsertTarget {
|
|
27
|
+
return {
|
|
28
|
+
model: { tableName },
|
|
29
|
+
insertJsonRows: async (): Promise<void> => {
|
|
30
|
+
// unused — deps.submit is mocked in these tests
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SubmitCall = {
|
|
36
|
+
tableName: string;
|
|
37
|
+
rows: Array<JSONObject>;
|
|
38
|
+
dedupToken: string;
|
|
39
|
+
clickhouseSettings: ClickHouseSettings | undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function makeDeps(data?: {
|
|
43
|
+
flushed?: Promise<void>;
|
|
44
|
+
forwardingEnabled?: boolean;
|
|
45
|
+
tables?: Array<string>;
|
|
46
|
+
}): { deps: WriterInsertDeps; calls: Array<SubmitCall> } {
|
|
47
|
+
const calls: Array<SubmitCall> = [];
|
|
48
|
+
const resolver: TableTargetResolver = createTableTargetResolver(
|
|
49
|
+
(data?.tables ?? ["LogTable"]).map((name: string) => {
|
|
50
|
+
return makeTarget(name);
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
const deps: WriterInsertDeps = {
|
|
54
|
+
resolveTarget: resolver,
|
|
55
|
+
forwardingEnabled: data?.forwardingEnabled ?? false,
|
|
56
|
+
submit: async (
|
|
57
|
+
target: FanInInsertTarget,
|
|
58
|
+
rows: Array<JSONObject>,
|
|
59
|
+
options: {
|
|
60
|
+
dedupToken: string;
|
|
61
|
+
clickhouseSettings?: ClickHouseSettings | undefined;
|
|
62
|
+
},
|
|
63
|
+
): Promise<FanInSubmitResult> => {
|
|
64
|
+
calls.push({
|
|
65
|
+
tableName: target.model.tableName,
|
|
66
|
+
rows,
|
|
67
|
+
dedupToken: options.dedupToken,
|
|
68
|
+
clickhouseSettings: options.clickhouseSettings,
|
|
69
|
+
});
|
|
70
|
+
return { flushed: data?.flushed ?? Promise.resolve() };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return { deps, calls };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validBody(overrides?: JSONObject): JSONObject {
|
|
77
|
+
return {
|
|
78
|
+
tableName: "LogTable",
|
|
79
|
+
rows: [{ a: 1 }, { a: 2 }],
|
|
80
|
+
dedupToken: "job-1:LogTable:0",
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("InflightGate", () => {
|
|
86
|
+
test("admits up to the cap, then sheds until released", () => {
|
|
87
|
+
const gate: InflightGate = new InflightGate(2);
|
|
88
|
+
expect(gate.tryAcquire()).toBe(true);
|
|
89
|
+
expect(gate.tryAcquire()).toBe(true);
|
|
90
|
+
expect(gate.tryAcquire()).toBe(false);
|
|
91
|
+
expect(gate.inflight).toBe(2);
|
|
92
|
+
|
|
93
|
+
gate.release();
|
|
94
|
+
expect(gate.tryAcquire()).toBe(true);
|
|
95
|
+
expect(gate.tryAcquire()).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("readWriterMaxInflightRequestsFromEnv", () => {
|
|
100
|
+
const saved: string | undefined =
|
|
101
|
+
process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"];
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
if (saved === undefined) {
|
|
105
|
+
delete process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"];
|
|
106
|
+
} else {
|
|
107
|
+
process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"] = saved;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("defaults to 100; honors valid overrides; rejects junk", () => {
|
|
112
|
+
delete process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"];
|
|
113
|
+
expect(readWriterMaxInflightRequestsFromEnv()).toBe(100);
|
|
114
|
+
|
|
115
|
+
process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"] = "250";
|
|
116
|
+
expect(readWriterMaxInflightRequestsFromEnv()).toBe(250);
|
|
117
|
+
|
|
118
|
+
process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"] = "-5";
|
|
119
|
+
expect(readWriterMaxInflightRequestsFromEnv()).toBe(100);
|
|
120
|
+
|
|
121
|
+
process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"] = "abc";
|
|
122
|
+
expect(readWriterMaxInflightRequestsFromEnv()).toBe(100);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("parseWriterInsertRequest", () => {
|
|
127
|
+
test("accepts a well-formed request", () => {
|
|
128
|
+
const parsed: ReturnType<typeof parseWriterInsertRequest> =
|
|
129
|
+
parseWriterInsertRequest(validBody({ clickhouseSettings: { x: 1 } }));
|
|
130
|
+
expect(parsed).toEqual({
|
|
131
|
+
tableName: "LogTable",
|
|
132
|
+
rows: [{ a: 1 }, { a: 2 }],
|
|
133
|
+
dedupToken: "job-1:LogTable:0",
|
|
134
|
+
clickhouseSettings: { x: 1 },
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const badBodies: Array<{ label: string; body: unknown }> = [
|
|
139
|
+
{ label: "null body", body: null },
|
|
140
|
+
{ label: "array body", body: [] },
|
|
141
|
+
{ label: "string body", body: "hi" },
|
|
142
|
+
{ label: "missing tableName", body: validBody({ tableName: undefined }) },
|
|
143
|
+
{ label: "blank tableName", body: validBody({ tableName: " " }) },
|
|
144
|
+
{ label: "rows not an array", body: validBody({ rows: "rows" }) },
|
|
145
|
+
{ label: "row is an array", body: validBody({ rows: [[1]] }) },
|
|
146
|
+
{ label: "row is null", body: validBody({ rows: [null] }) },
|
|
147
|
+
{ label: "missing dedupToken", body: validBody({ dedupToken: undefined }) },
|
|
148
|
+
{ label: "blank dedupToken", body: validBody({ dedupToken: " " }) },
|
|
149
|
+
{
|
|
150
|
+
label: "clickhouseSettings is an array",
|
|
151
|
+
body: validBody({ clickhouseSettings: [1] }),
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
for (const { label, body } of badBodies) {
|
|
155
|
+
test(`rejects ${label}`, () => {
|
|
156
|
+
const parsed: ReturnType<typeof parseWriterInsertRequest> =
|
|
157
|
+
parseWriterInsertRequest(body);
|
|
158
|
+
expect("error" in parsed).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("handleWriterInsert", () => {
|
|
164
|
+
test("200 after rows durably land; token and settings pass through", async () => {
|
|
165
|
+
const { deps, calls } = makeDeps();
|
|
166
|
+
const outcome: WriterInsertOutcome = await handleWriterInsert(
|
|
167
|
+
validBody({ clickhouseSettings: { async_insert: 1 } }),
|
|
168
|
+
deps,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(outcome.statusCode).toBe(200);
|
|
172
|
+
expect(outcome.body).toEqual({ rowsAccepted: 2 });
|
|
173
|
+
expect(calls).toHaveLength(1);
|
|
174
|
+
expect(calls[0]!.tableName).toBe("LogTable");
|
|
175
|
+
expect(calls[0]!.dedupToken).toBe("job-1:LogTable:0");
|
|
176
|
+
expect(calls[0]!.clickhouseSettings).toEqual({ async_insert: 1 });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("empty rows short-circuit to 200 without submitting", async () => {
|
|
180
|
+
const { deps, calls } = makeDeps();
|
|
181
|
+
const outcome: WriterInsertOutcome = await handleWriterInsert(
|
|
182
|
+
validBody({ rows: [] }),
|
|
183
|
+
deps,
|
|
184
|
+
);
|
|
185
|
+
expect(outcome.statusCode).toBe(200);
|
|
186
|
+
expect(outcome.body).toEqual({ rowsAccepted: 0 });
|
|
187
|
+
expect(calls).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("400 on malformed body", async () => {
|
|
191
|
+
const { deps } = makeDeps();
|
|
192
|
+
const outcome: WriterInsertOutcome = await handleWriterInsert(
|
|
193
|
+
{ nope: true },
|
|
194
|
+
deps,
|
|
195
|
+
);
|
|
196
|
+
expect(outcome.statusCode).toBe(400);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("503 on unknown table (retryable — rolling-upgrade version skew must self-heal)", async () => {
|
|
200
|
+
const { deps, calls } = makeDeps();
|
|
201
|
+
const outcome: WriterInsertOutcome = await handleWriterInsert(
|
|
202
|
+
validBody({ tableName: "NoSuchTable" }),
|
|
203
|
+
deps,
|
|
204
|
+
);
|
|
205
|
+
expect(outcome.statusCode).toBe(503);
|
|
206
|
+
expect(String(outcome.body["message"])).toContain("NoSuchTable");
|
|
207
|
+
expect(calls).toHaveLength(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("503 when this pod itself forwards to a remote writer (loop guard)", async () => {
|
|
211
|
+
const { deps, calls } = makeDeps({ forwardingEnabled: true });
|
|
212
|
+
const outcome: WriterInsertOutcome = await handleWriterInsert(
|
|
213
|
+
validBody(),
|
|
214
|
+
deps,
|
|
215
|
+
);
|
|
216
|
+
expect(outcome.statusCode).toBe(503);
|
|
217
|
+
expect(calls).toHaveLength(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("503 when the flush fails on a still-transient error (caller retries)", async () => {
|
|
221
|
+
const flushed: Promise<void> = Promise.reject(
|
|
222
|
+
new FanInInsertError({
|
|
223
|
+
tableName: "LogTable",
|
|
224
|
+
attempts: 6,
|
|
225
|
+
cause: new TransientInsertError("202 after all retries"),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
flushed.catch(() => {
|
|
229
|
+
// pre-observed
|
|
230
|
+
});
|
|
231
|
+
const { deps } = makeDeps({ flushed });
|
|
232
|
+
const outcome: WriterInsertOutcome = await handleWriterInsert(
|
|
233
|
+
validBody(),
|
|
234
|
+
deps,
|
|
235
|
+
);
|
|
236
|
+
expect(outcome.statusCode).toBe(503);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("500 when the flush fails definitively", async () => {
|
|
240
|
+
const flushed: Promise<void> = Promise.reject(
|
|
241
|
+
new FanInInsertError({
|
|
242
|
+
tableName: "LogTable",
|
|
243
|
+
attempts: 1,
|
|
244
|
+
cause: new Error("schema mismatch"),
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
flushed.catch(() => {
|
|
248
|
+
// pre-observed
|
|
249
|
+
});
|
|
250
|
+
const { deps } = makeDeps({ flushed });
|
|
251
|
+
const outcome: WriterInsertOutcome = await handleWriterInsert(
|
|
252
|
+
validBody(),
|
|
253
|
+
deps,
|
|
254
|
+
);
|
|
255
|
+
expect(outcome.statusCode).toBe(500);
|
|
256
|
+
expect(String(outcome.body["message"])).toContain("schema mismatch");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("createTableTargetResolver", () => {
|
|
261
|
+
test("resolves by exact table name and returns undefined otherwise", () => {
|
|
262
|
+
const resolver: TableTargetResolver = createTableTargetResolver([
|
|
263
|
+
makeTarget("A"),
|
|
264
|
+
makeTarget("B"),
|
|
265
|
+
]);
|
|
266
|
+
expect(resolver("A")?.model.tableName).toBe("A");
|
|
267
|
+
expect(resolver("B")?.model.tableName).toBe("B");
|
|
268
|
+
expect(resolver("C")).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ShedCounterClient,
|
|
3
|
+
getRecentShedCount,
|
|
4
|
+
recordShed,
|
|
5
|
+
shedBucketKey,
|
|
6
|
+
} from "../../../../Server/Utils/Telemetry/TelemetryWriterShedMetrics";
|
|
7
|
+
import { describe, expect, test } from "@jest/globals";
|
|
8
|
+
|
|
9
|
+
jest.mock("../../../../Server/Utils/Logger", () => {
|
|
10
|
+
return {
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: {
|
|
13
|
+
debug: jest.fn(),
|
|
14
|
+
info: jest.fn(),
|
|
15
|
+
warn: jest.fn(),
|
|
16
|
+
error: jest.fn(),
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The KEDA scaling signal for the telemetry-writer tier: a Redis-backed,
|
|
23
|
+
* minute-bucketed shed counter. All tests inject a fake client — no Redis.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
class FakeClient implements ShedCounterClient {
|
|
27
|
+
public store: Map<string, string> = new Map();
|
|
28
|
+
public ttls: Map<string, number> = new Map();
|
|
29
|
+
public failNextCalls: boolean = false;
|
|
30
|
+
|
|
31
|
+
public async incr(key: string): Promise<number> {
|
|
32
|
+
if (this.failNextCalls) {
|
|
33
|
+
throw new Error("redis down");
|
|
34
|
+
}
|
|
35
|
+
const next: number = parseInt(this.store.get(key) ?? "0", 10) + 1;
|
|
36
|
+
this.store.set(key, String(next));
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public async expire(key: string, seconds: number): Promise<unknown> {
|
|
41
|
+
if (this.failNextCalls) {
|
|
42
|
+
throw new Error("redis down");
|
|
43
|
+
}
|
|
44
|
+
this.ttls.set(key, seconds);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public async get(key: string): Promise<string | null> {
|
|
49
|
+
if (this.failNextCalls) {
|
|
50
|
+
throw new Error("redis down");
|
|
51
|
+
}
|
|
52
|
+
return this.store.get(key) ?? null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2026-07-22T12:00:30Z — mid-minute so bucket boundaries are unambiguous.
|
|
57
|
+
const NOW: Date = new Date(1784721630000);
|
|
58
|
+
const NOW_MINUTE: number = Math.floor(NOW.getTime() / 60_000);
|
|
59
|
+
|
|
60
|
+
describe("recordShed", () => {
|
|
61
|
+
test("increments the current minute bucket and stamps a TTL", async () => {
|
|
62
|
+
const client: FakeClient = new FakeClient();
|
|
63
|
+
|
|
64
|
+
await recordShed({ now: NOW, client });
|
|
65
|
+
await recordShed({ now: NOW, client });
|
|
66
|
+
|
|
67
|
+
const key: string = shedBucketKey(NOW_MINUTE);
|
|
68
|
+
expect(client.store.get(key)).toBe("2");
|
|
69
|
+
expect(client.ttls.get(key)).toBe(300);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("sheds in different minutes land in different buckets", async () => {
|
|
73
|
+
const client: FakeClient = new FakeClient();
|
|
74
|
+
const nextMinute: Date = new Date(NOW.getTime() + 60_000);
|
|
75
|
+
|
|
76
|
+
await recordShed({ now: NOW, client });
|
|
77
|
+
await recordShed({ now: nextMinute, client });
|
|
78
|
+
|
|
79
|
+
expect(client.store.get(shedBucketKey(NOW_MINUTE))).toBe("1");
|
|
80
|
+
expect(client.store.get(shedBucketKey(NOW_MINUTE + 1))).toBe("1");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("swallows Redis failures and a missing client", async () => {
|
|
84
|
+
const client: FakeClient = new FakeClient();
|
|
85
|
+
client.failNextCalls = true;
|
|
86
|
+
|
|
87
|
+
await expect(recordShed({ now: NOW, client })).resolves.toBeUndefined();
|
|
88
|
+
await expect(
|
|
89
|
+
recordShed({ now: NOW, client: null }),
|
|
90
|
+
).resolves.toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("getRecentShedCount", () => {
|
|
95
|
+
test("sums the current and previous minute buckets", async () => {
|
|
96
|
+
const client: FakeClient = new FakeClient();
|
|
97
|
+
client.store.set(shedBucketKey(NOW_MINUTE), "7");
|
|
98
|
+
client.store.set(shedBucketKey(NOW_MINUTE - 1), "5");
|
|
99
|
+
// An older bucket must NOT be counted.
|
|
100
|
+
client.store.set(shedBucketKey(NOW_MINUTE - 2), "100");
|
|
101
|
+
|
|
102
|
+
await expect(getRecentShedCount({ now: NOW, client })).resolves.toBe(12);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("empty buckets read as zero", async () => {
|
|
106
|
+
const client: FakeClient = new FakeClient();
|
|
107
|
+
await expect(getRecentShedCount({ now: NOW, client })).resolves.toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("garbled bucket contents are ignored", async () => {
|
|
111
|
+
const client: FakeClient = new FakeClient();
|
|
112
|
+
client.store.set(shedBucketKey(NOW_MINUTE), "not-a-number");
|
|
113
|
+
client.store.set(shedBucketKey(NOW_MINUTE - 1), "-4");
|
|
114
|
+
|
|
115
|
+
await expect(getRecentShedCount({ now: NOW, client })).resolves.toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("returns 0 (not an error) when Redis is down or absent — biases KEDA against scaling on missing data", async () => {
|
|
119
|
+
const client: FakeClient = new FakeClient();
|
|
120
|
+
client.failNextCalls = true;
|
|
121
|
+
|
|
122
|
+
await expect(getRecentShedCount({ now: NOW, client })).resolves.toBe(0);
|
|
123
|
+
await expect(getRecentShedCount({ now: NOW, client: null })).resolves.toBe(
|
|
124
|
+
0,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|