Skip to content

Commit a4d99f5

Browse files
authored
refactor(test): improve e2e test infrastructure for maintenance scena… (#6)
* refactor(test): improve e2e test infrastructure for maintenance scenarios * refactor(test): improve e2e test infrastructure for maintenance scenarios
1 parent 0dfac7e commit a4d99f5

File tree

3 files changed

+160
-65
lines changed

3 files changed

+160
-65
lines changed

packages/client/lib/tests/test-scenario/test-command-runner.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,11 @@ type FireCommandsUntilStopSignalOptions = {
2222
) => Array<() => Promise<unknown>>;
2323
};
2424

25+
/**
26+
* Utility class for running test commands until a stop signal is received
27+
*/
2528
export class TestCommandRunner {
26-
constructor(
27-
private client: ReturnType<typeof createClient<any, any, any, any>>
28-
) {}
29-
30-
private defaultOptions: FireCommandsUntilStopSignalOptions = {
29+
private static readonly defaultOptions: FireCommandsUntilStopSignalOptions = {
3130
batchSize: 60,
3231
timeoutMs: 10,
3332
createCommands: (
@@ -38,7 +37,7 @@ export class TestCommandRunner {
3837
],
3938
};
4039

41-
#toSettled<T>(p: Promise<T>) {
40+
static #toSettled<T>(p: Promise<T>) {
4241
return p
4342
.then((value) => ({ status: "fulfilled" as const, value, error: null }))
4443
.catch((reason) => ({
@@ -48,47 +47,52 @@ export class TestCommandRunner {
4847
}));
4948
}
5049

51-
async #racePromises<S, T>({
50+
static async #racePromises<S, T>({
5251
timeout,
5352
stopper,
5453
}: {
5554
timeout: Promise<S>;
5655
stopper: Promise<T>;
5756
}) {
5857
return Promise.race([
59-
this.#toSettled<S>(timeout).then((result) => ({
58+
TestCommandRunner.#toSettled<S>(timeout).then((result) => ({
6059
...result,
6160
stop: false,
6261
})),
63-
this.#toSettled<T>(stopper).then((result) => ({ ...result, stop: true })),
62+
TestCommandRunner.#toSettled<T>(stopper).then((result) => ({
63+
...result,
64+
stop: true,
65+
})),
6466
]);
6567
}
6668

6769
/**
68-
* Fires commands until a stop signal is received.
69-
* @param stopSignalPromise Promise that resolves when the command execution should stop
70-
* @param options Options for the command execution
71-
* @returns Promise that resolves when the stop signal is received
70+
* Fires a batch of test commands until a stop signal is received
71+
* @param client - The Redis client to use
72+
* @param stopSignalPromise - Promise that resolves when the execution should stop
73+
* @param options - Options for the command execution
74+
* @returns An object containing the promises of all executed commands and the result of the stop signal
7275
*/
73-
async fireCommandsUntilStopSignal(
76+
static async fireCommandsUntilStopSignal(
77+
client: ReturnType<typeof createClient<any, any, any, any>>,
7478
stopSignalPromise: Promise<unknown>,
7579
options?: Partial<FireCommandsUntilStopSignalOptions>
7680
) {
7781
const executeOptions = {
78-
...this.defaultOptions,
82+
...TestCommandRunner.defaultOptions,
7983
...options,
8084
};
8185

8286
const commandPromises = [];
8387

8488
while (true) {
8589
for (let i = 0; i < executeOptions.batchSize; i++) {
86-
for (const command of executeOptions.createCommands(this.client)) {
87-
commandPromises.push(this.#toSettled(command()));
90+
for (const command of executeOptions.createCommands(client)) {
91+
commandPromises.push(TestCommandRunner.#toSettled(command()));
8892
}
8993
}
9094

91-
const result = await this.#racePromises({
95+
const result = await TestCommandRunner.#racePromises({
9296
timeout: setTimeout(executeOptions.timeoutMs),
9397
stopper: stopSignalPromise,
9498
});

packages/client/lib/tests/test-scenario/test-scenario.util.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { readFileSync } from "fs";
2+
import { createClient, RedisClientOptions } from "../../..";
3+
import { stub } from "sinon";
24

35
type DatabaseEndpoint = {
46
addr: string[];
@@ -108,3 +110,88 @@ export function getDatabaseConfig(
108110
};
109111
}
110112

113+
// TODO this should be moved in the tests utils package
114+
export async function blockSetImmediate(fn: () => Promise<unknown>) {
115+
let setImmediateStub: any;
116+
117+
try {
118+
setImmediateStub = stub(global, "setImmediate");
119+
setImmediateStub.callsFake(() => {
120+
//Dont call the callback, effectively blocking execution
121+
});
122+
await fn();
123+
} finally {
124+
if (setImmediateStub) {
125+
setImmediateStub.restore();
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Factory class for creating and managing Redis clients
132+
*/
133+
export class ClientFactory {
134+
private readonly clients = new Map<
135+
string,
136+
ReturnType<typeof createClient<any, any, any, any>>
137+
>();
138+
139+
constructor(private readonly config: RedisConnectionConfig) {}
140+
141+
/**
142+
* Creates a new client with the specified options and connects it to the database
143+
* @param key - The key to store the client under
144+
* @param options - Optional client options
145+
* @returns The created and connected client
146+
*/
147+
async create(key: string, options: Partial<RedisClientOptions> = {}) {
148+
const client = createClient({
149+
socket: {
150+
host: this.config.host,
151+
port: this.config.port,
152+
...(this.config.tls === true ? { tls: true } : {}),
153+
},
154+
password: this.config.password,
155+
username: this.config.username,
156+
RESP: 3,
157+
maintPushNotifications: "auto",
158+
maintMovingEndpointType: "auto",
159+
...options,
160+
});
161+
162+
client.on("error", (err: Error) => {
163+
throw new Error(`Client error: ${err.message}`);
164+
});
165+
166+
await client.connect();
167+
168+
this.clients.set(key, client);
169+
170+
return client;
171+
}
172+
173+
/**
174+
* Gets an existing client by key or the first one if no key is provided
175+
* @param key - The key of the client to retrieve
176+
* @returns The client if found, undefined otherwise
177+
*/
178+
get(key?: string) {
179+
if (key) {
180+
return this.clients.get(key);
181+
}
182+
183+
// Get the first one if no key is provided
184+
return this.clients.values().next().value;
185+
}
186+
187+
/**
188+
* Destroys all created clients
189+
*/
190+
destroyAll() {
191+
this.clients.forEach((client) => {
192+
if (client && client.isOpen) {
193+
client.destroy();
194+
}
195+
});
196+
}
197+
}
Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,51 @@
11
import assert from "node:assert";
2-
import { setTimeout } from "node:timers/promises";
2+
33
import { FaultInjectorClient } from "./fault-injector-client";
44
import {
5+
ClientFactory,
56
getDatabaseConfig,
67
getDatabaseConfigFromEnv,
78
getEnvConfig,
89
RedisConnectionConfig,
10+
blockSetImmediate
911
} from "./test-scenario.util";
1012
import { createClient } from "../../..";
1113
import { before } from "mocha";
1214
import { TestCommandRunner } from "./test-command-runner";
1315

1416
describe("Timeout Handling During Notifications", () => {
1517
let clientConfig: RedisConnectionConfig;
16-
let client: ReturnType<typeof createClient<any, any, any, 3>>;
18+
let clientFactory: ClientFactory;
1719
let faultInjectorClient: FaultInjectorClient;
18-
let commandRunner: TestCommandRunner;
20+
let defaultClient: ReturnType<typeof createClient<any, any, any, any>>;
1921

2022
before(() => {
2123
const envConfig = getEnvConfig();
2224
const redisConfig = getDatabaseConfigFromEnv(
2325
envConfig.redisEndpointsConfigPath
2426
);
2527

26-
faultInjectorClient = new FaultInjectorClient(envConfig.faultInjectorUrl);
2728
clientConfig = getDatabaseConfig(redisConfig);
29+
faultInjectorClient = new FaultInjectorClient(envConfig.faultInjectorUrl);
30+
clientFactory = new ClientFactory(clientConfig);
2831
});
2932

3033
beforeEach(async () => {
31-
client = createClient({
32-
socket: {
33-
host: clientConfig.host,
34-
port: clientConfig.port,
35-
...(clientConfig.tls === true ? { tls: true } : {}),
36-
},
37-
password: clientConfig.password,
38-
username: clientConfig.username,
39-
RESP: 3,
40-
maintPushNotifications: "auto",
41-
maintMovingEndpointType: "auto",
42-
});
43-
44-
client.on("error", (err: Error) => {
45-
throw new Error(`Client error: ${err.message}`);
46-
});
47-
48-
commandRunner = new TestCommandRunner(client);
34+
defaultClient = await clientFactory.create("default");
4935

50-
await client.connect();
36+
await defaultClient.flushAll();
5137
});
5238

53-
afterEach(() => {
54-
client.destroy();
39+
afterEach(async () => {
40+
clientFactory.destroyAll();
5541
});
5642

5743
it("should relax command timeout on MOVING, MIGRATING, and MIGRATED", async () => {
5844
// PART 1
5945
// Set very low timeout to trigger errors
60-
client.options!.maintRelaxedCommandTimeout = 50;
46+
const lowTimeoutClient = await clientFactory.create("lowTimeout", {
47+
maintRelaxedCommandTimeout: 50,
48+
});
6149

6250
const { action_id: lowTimeoutBindAndMigrateActionId } =
6351
await faultInjectorClient.migrateAndBindAction({
@@ -70,7 +58,10 @@ describe("Timeout Handling During Notifications", () => {
7058
);
7159

7260
const lowTimeoutCommandPromises =
73-
await commandRunner.fireCommandsUntilStopSignal(lowTimeoutWaitPromise);
61+
await TestCommandRunner.fireCommandsUntilStopSignal(
62+
lowTimeoutClient,
63+
lowTimeoutWaitPromise
64+
);
7465

7566
const lowTimeoutRejectedCommands = (
7667
await Promise.all(lowTimeoutCommandPromises.commandPromises)
@@ -90,7 +81,9 @@ describe("Timeout Handling During Notifications", () => {
9081

9182
// PART 2
9283
// Set high timeout to avoid errors
93-
client.options!.maintRelaxedCommandTimeout = 10000;
84+
const highTimeoutClient = await clientFactory.create("highTimeout", {
85+
maintRelaxedCommandTimeout: 10000,
86+
});
9487

9588
const { action_id: highTimeoutBindAndMigrateActionId } =
9689
await faultInjectorClient.migrateAndBindAction({
@@ -103,7 +96,10 @@ describe("Timeout Handling During Notifications", () => {
10396
);
10497

10598
const highTimeoutCommandPromises =
106-
await commandRunner.fireCommandsUntilStopSignal(highTimeoutWaitPromise);
99+
await TestCommandRunner.fireCommandsUntilStopSignal(
100+
highTimeoutClient,
101+
highTimeoutWaitPromise
102+
);
107103

108104
const highTimeoutRejectedCommands = (
109105
await Promise.all(highTimeoutCommandPromises.commandPromises)
@@ -112,13 +108,15 @@ describe("Timeout Handling During Notifications", () => {
112108
assert.strictEqual(highTimeoutRejectedCommands.length, 0);
113109
});
114110

115-
// TODO this is WIP
116-
it.skip("should unrelax command timeout after MAINTENANCE", async () => {
117-
client.options!.maintRelaxedCommandTimeout = 10000;
118-
client.options!.commandOptions = {
119-
...client.options!.commandOptions,
120-
timeout: 1, // Set very low timeout to trigger errors
121-
};
111+
it("should unrelax command timeout after MAINTENANCE", async () => {
112+
const clientWithCommandTimeout = await clientFactory.create(
113+
"clientWithCommandTimeout",
114+
{
115+
commandOptions: {
116+
timeout: 100,
117+
},
118+
}
119+
);
122120

123121
const { action_id: bindAndMigrateActionId } =
124122
await faultInjectorClient.migrateAndBindAction({
@@ -131,25 +129,31 @@ describe("Timeout Handling During Notifications", () => {
131129
);
132130

133131
const relaxedTimeoutCommandPromises =
134-
await commandRunner.fireCommandsUntilStopSignal(lowTimeoutWaitPromise);
132+
await TestCommandRunner.fireCommandsUntilStopSignal(
133+
clientWithCommandTimeout,
134+
lowTimeoutWaitPromise
135+
);
135136

136137
const relaxedTimeoutRejectedCommands = (
137138
await Promise.all(relaxedTimeoutCommandPromises.commandPromises)
138139
).filter((result) => result.status === "rejected");
139-
console.log(
140-
"relaxedTimeoutRejectedCommands",
141-
relaxedTimeoutRejectedCommands
142-
);
143140

144141
assert.ok(relaxedTimeoutRejectedCommands.length === 0);
145142

146-
const unrelaxedCommandPromises =
147-
await commandRunner.fireCommandsUntilStopSignal(setTimeout(1 * 1000));
143+
const start = performance.now();
148144

149-
const unrelaxedRejectedCommands = (
150-
await Promise.all(unrelaxedCommandPromises.commandPromises)
151-
).filter((result) => result.status === "rejected");
145+
let error: any;
146+
await blockSetImmediate(async () => {
147+
try {
148+
await clientWithCommandTimeout.set("key", "value");
149+
} catch (err: any) {
150+
error = err;
151+
}
152+
});
152153

153-
assert.ok(unrelaxedRejectedCommands.length > 0);
154+
// Make sure it took less than 1sec to fail
155+
assert.ok(performance.now() - start < 1000);
156+
assert.ok(error instanceof Error);
157+
assert.ok(error.constructor.name === "TimeoutError");
154158
});
155159
});

0 commit comments

Comments
 (0)