@checkstack/healthcheck-redis-backend 0.0.2
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/CHANGELOG.md +37 -0
- package/package.json +23 -0
- package/src/index.ts +23 -0
- package/src/plugin-metadata.ts +8 -0
- package/src/strategy.test.ts +207 -0
- package/src/strategy.ts +362 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @checkstack/healthcheck-redis-backend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/backend-api@0.0.2
|
|
10
|
+
- @checkstack/common@0.0.2
|
|
11
|
+
- @checkstack/healthcheck-common@0.0.2
|
|
12
|
+
|
|
13
|
+
## 0.0.3
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [b4eb432]
|
|
18
|
+
- Updated dependencies [a65e002]
|
|
19
|
+
- @checkstack/backend-api@1.1.0
|
|
20
|
+
- @checkstack/common@0.2.0
|
|
21
|
+
- @checkstack/healthcheck-common@0.1.1
|
|
22
|
+
|
|
23
|
+
## 0.0.2
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [ffc28f6]
|
|
28
|
+
- Updated dependencies [4dd644d]
|
|
29
|
+
- Updated dependencies [71275dd]
|
|
30
|
+
- Updated dependencies [ae19ff6]
|
|
31
|
+
- Updated dependencies [0babb9c]
|
|
32
|
+
- Updated dependencies [b55fae6]
|
|
33
|
+
- Updated dependencies [b354ab3]
|
|
34
|
+
- Updated dependencies [81f3f85]
|
|
35
|
+
- @checkstack/common@0.1.0
|
|
36
|
+
- @checkstack/backend-api@1.0.0
|
|
37
|
+
- @checkstack/healthcheck-common@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/healthcheck-redis-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/backend-api": "workspace:*",
|
|
13
|
+
"@checkstack/common": "workspace:*",
|
|
14
|
+
"@checkstack/healthcheck-common": "workspace:*",
|
|
15
|
+
"ioredis": "^5.3.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "^1.0.0",
|
|
19
|
+
"typescript": "^5.0.0",
|
|
20
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
21
|
+
"@checkstack/scripts": "workspace:*"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { RedisHealthCheckStrategy } from "./strategy";
|
|
6
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
7
|
+
|
|
8
|
+
export default createBackendPlugin({
|
|
9
|
+
metadata: pluginMetadata,
|
|
10
|
+
register(env) {
|
|
11
|
+
env.registerInit({
|
|
12
|
+
deps: {
|
|
13
|
+
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
14
|
+
logger: coreServices.logger,
|
|
15
|
+
},
|
|
16
|
+
init: async ({ healthCheckRegistry, logger }) => {
|
|
17
|
+
logger.debug("🔌 Registering Redis Health Check Strategy...");
|
|
18
|
+
const strategy = new RedisHealthCheckStrategy();
|
|
19
|
+
healthCheckRegistry.register(strategy);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { RedisHealthCheckStrategy, RedisClient } from "./strategy";
|
|
3
|
+
|
|
4
|
+
describe("RedisHealthCheckStrategy", () => {
|
|
5
|
+
// Helper to create mock Redis client
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
config: {
|
|
8
|
+
pingResponse?: string;
|
|
9
|
+
infoResponse?: string;
|
|
10
|
+
pingError?: Error;
|
|
11
|
+
connectError?: Error;
|
|
12
|
+
} = {}
|
|
13
|
+
): RedisClient => ({
|
|
14
|
+
connect: mock(() =>
|
|
15
|
+
config.connectError
|
|
16
|
+
? Promise.reject(config.connectError)
|
|
17
|
+
: Promise.resolve({
|
|
18
|
+
ping: mock(() =>
|
|
19
|
+
config.pingError
|
|
20
|
+
? Promise.reject(config.pingError)
|
|
21
|
+
: Promise.resolve(config.pingResponse ?? "PONG")
|
|
22
|
+
),
|
|
23
|
+
info: mock(() =>
|
|
24
|
+
Promise.resolve(
|
|
25
|
+
config.infoResponse ?? "redis_version:7.0.0\r\nrole:master\r\n"
|
|
26
|
+
)
|
|
27
|
+
),
|
|
28
|
+
quit: mock(() => Promise.resolve("OK")),
|
|
29
|
+
})
|
|
30
|
+
),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("execute", () => {
|
|
34
|
+
it("should return healthy for successful connection", async () => {
|
|
35
|
+
const strategy = new RedisHealthCheckStrategy(createMockClient());
|
|
36
|
+
|
|
37
|
+
const result = await strategy.execute({
|
|
38
|
+
host: "localhost",
|
|
39
|
+
port: 6379,
|
|
40
|
+
timeout: 5000,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.status).toBe("healthy");
|
|
44
|
+
expect(result.metadata?.connected).toBe(true);
|
|
45
|
+
expect(result.metadata?.pingSuccess).toBe(true);
|
|
46
|
+
expect(result.metadata?.redisVersion).toBe("7.0.0");
|
|
47
|
+
expect(result.metadata?.role).toBe("master");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should return unhealthy for connection error", async () => {
|
|
51
|
+
const strategy = new RedisHealthCheckStrategy(
|
|
52
|
+
createMockClient({ connectError: new Error("Connection refused") })
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const result = await strategy.execute({
|
|
56
|
+
host: "localhost",
|
|
57
|
+
port: 6379,
|
|
58
|
+
timeout: 5000,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.status).toBe("unhealthy");
|
|
62
|
+
expect(result.message).toContain("Connection refused");
|
|
63
|
+
expect(result.metadata?.connected).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should return unhealthy for PING failure", async () => {
|
|
67
|
+
const strategy = new RedisHealthCheckStrategy(
|
|
68
|
+
createMockClient({ pingError: new Error("NOAUTH") })
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const result = await strategy.execute({
|
|
72
|
+
host: "localhost",
|
|
73
|
+
port: 6379,
|
|
74
|
+
timeout: 5000,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(result.status).toBe("unhealthy");
|
|
78
|
+
expect(result.metadata?.pingSuccess).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should pass connectionTime assertion when below threshold", async () => {
|
|
82
|
+
const strategy = new RedisHealthCheckStrategy(createMockClient());
|
|
83
|
+
|
|
84
|
+
const result = await strategy.execute({
|
|
85
|
+
host: "localhost",
|
|
86
|
+
port: 6379,
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
assertions: [
|
|
89
|
+
{ field: "connectionTime", operator: "lessThan", value: 5000 },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.status).toBe("healthy");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should pass role assertion", async () => {
|
|
97
|
+
const strategy = new RedisHealthCheckStrategy(createMockClient());
|
|
98
|
+
|
|
99
|
+
const result = await strategy.execute({
|
|
100
|
+
host: "localhost",
|
|
101
|
+
port: 6379,
|
|
102
|
+
timeout: 5000,
|
|
103
|
+
assertions: [{ field: "role", operator: "equals", value: "master" }],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.status).toBe("healthy");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should fail role assertion when replica", async () => {
|
|
110
|
+
const strategy = new RedisHealthCheckStrategy(
|
|
111
|
+
createMockClient({
|
|
112
|
+
infoResponse: "redis_version:7.0.0\r\nrole:slave\r\n",
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await strategy.execute({
|
|
117
|
+
host: "localhost",
|
|
118
|
+
port: 6379,
|
|
119
|
+
timeout: 5000,
|
|
120
|
+
assertions: [{ field: "role", operator: "equals", value: "master" }],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.status).toBe("unhealthy");
|
|
124
|
+
expect(result.message).toContain("Assertion failed");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should pass pingSuccess assertion", async () => {
|
|
128
|
+
const strategy = new RedisHealthCheckStrategy(createMockClient());
|
|
129
|
+
|
|
130
|
+
const result = await strategy.execute({
|
|
131
|
+
host: "localhost",
|
|
132
|
+
port: 6379,
|
|
133
|
+
timeout: 5000,
|
|
134
|
+
assertions: [{ field: "pingSuccess", operator: "isTrue" }],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(result.status).toBe("healthy");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("aggregateResult", () => {
|
|
142
|
+
it("should calculate averages correctly", () => {
|
|
143
|
+
const strategy = new RedisHealthCheckStrategy();
|
|
144
|
+
const runs = [
|
|
145
|
+
{
|
|
146
|
+
id: "1",
|
|
147
|
+
status: "healthy" as const,
|
|
148
|
+
latencyMs: 10,
|
|
149
|
+
checkId: "c1",
|
|
150
|
+
timestamp: new Date(),
|
|
151
|
+
metadata: {
|
|
152
|
+
connected: true,
|
|
153
|
+
connectionTimeMs: 5,
|
|
154
|
+
pingTimeMs: 1,
|
|
155
|
+
pingSuccess: true,
|
|
156
|
+
role: "master",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "2",
|
|
161
|
+
status: "healthy" as const,
|
|
162
|
+
latencyMs: 20,
|
|
163
|
+
checkId: "c1",
|
|
164
|
+
timestamp: new Date(),
|
|
165
|
+
metadata: {
|
|
166
|
+
connected: true,
|
|
167
|
+
connectionTimeMs: 15,
|
|
168
|
+
pingTimeMs: 3,
|
|
169
|
+
pingSuccess: true,
|
|
170
|
+
role: "master",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
176
|
+
|
|
177
|
+
expect(aggregated.avgConnectionTime).toBe(10);
|
|
178
|
+
expect(aggregated.avgPingTime).toBe(2);
|
|
179
|
+
expect(aggregated.successRate).toBe(100);
|
|
180
|
+
expect(aggregated.errorCount).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("should count errors", () => {
|
|
184
|
+
const strategy = new RedisHealthCheckStrategy();
|
|
185
|
+
const runs = [
|
|
186
|
+
{
|
|
187
|
+
id: "1",
|
|
188
|
+
status: "unhealthy" as const,
|
|
189
|
+
latencyMs: 100,
|
|
190
|
+
checkId: "c1",
|
|
191
|
+
timestamp: new Date(),
|
|
192
|
+
metadata: {
|
|
193
|
+
connected: false,
|
|
194
|
+
connectionTimeMs: 100,
|
|
195
|
+
pingSuccess: false,
|
|
196
|
+
error: "Connection refused",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
202
|
+
|
|
203
|
+
expect(aggregated.errorCount).toBe(1);
|
|
204
|
+
expect(aggregated.successRate).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import Redis from "ioredis";
|
|
2
|
+
import {
|
|
3
|
+
HealthCheckStrategy,
|
|
4
|
+
HealthCheckResult,
|
|
5
|
+
HealthCheckRunForAggregation,
|
|
6
|
+
Versioned,
|
|
7
|
+
z,
|
|
8
|
+
timeThresholdField,
|
|
9
|
+
booleanField,
|
|
10
|
+
enumField,
|
|
11
|
+
configString,
|
|
12
|
+
configNumber,
|
|
13
|
+
configBoolean,
|
|
14
|
+
evaluateAssertions,
|
|
15
|
+
} from "@checkstack/backend-api";
|
|
16
|
+
import {
|
|
17
|
+
healthResultBoolean,
|
|
18
|
+
healthResultNumber,
|
|
19
|
+
healthResultString,
|
|
20
|
+
} from "@checkstack/healthcheck-common";
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// SCHEMAS
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Valid Redis server roles from the INFO replication command.
|
|
28
|
+
*/
|
|
29
|
+
const RedisRole = z.enum(["master", "slave", "sentinel"]);
|
|
30
|
+
export type RedisRole = z.infer<typeof RedisRole>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Assertion schema for Redis health checks using shared factories.
|
|
34
|
+
*/
|
|
35
|
+
const redisAssertionSchema = z.discriminatedUnion("field", [
|
|
36
|
+
timeThresholdField("connectionTime"),
|
|
37
|
+
timeThresholdField("pingTime"),
|
|
38
|
+
booleanField("pingSuccess"),
|
|
39
|
+
enumField("role", RedisRole.options),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export type RedisAssertion = z.infer<typeof redisAssertionSchema>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Configuration schema for Redis health checks.
|
|
46
|
+
*/
|
|
47
|
+
export const redisConfigSchema = z.object({
|
|
48
|
+
host: configString({}).describe("Redis server hostname"),
|
|
49
|
+
port: configNumber({})
|
|
50
|
+
.int()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(65_535)
|
|
53
|
+
.default(6379)
|
|
54
|
+
.describe("Redis port"),
|
|
55
|
+
password: configString({ "x-secret": true })
|
|
56
|
+
.describe("Password for authentication")
|
|
57
|
+
.optional(),
|
|
58
|
+
database: configNumber({}).int().min(0).default(0).describe("Database index"),
|
|
59
|
+
tls: configBoolean({}).default(false).describe("Use TLS connection"),
|
|
60
|
+
timeout: configNumber({})
|
|
61
|
+
.min(100)
|
|
62
|
+
.default(5000)
|
|
63
|
+
.describe("Connection timeout in milliseconds"),
|
|
64
|
+
assertions: z
|
|
65
|
+
.array(redisAssertionSchema)
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Validation conditions"),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export type RedisConfig = z.infer<typeof redisConfigSchema>;
|
|
71
|
+
export type RedisConfigInput = z.input<typeof redisConfigSchema>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Per-run result metadata.
|
|
75
|
+
*/
|
|
76
|
+
const redisResultSchema = z.object({
|
|
77
|
+
connected: healthResultBoolean({
|
|
78
|
+
"x-chart-type": "boolean",
|
|
79
|
+
"x-chart-label": "Connected",
|
|
80
|
+
}),
|
|
81
|
+
connectionTimeMs: healthResultNumber({
|
|
82
|
+
"x-chart-type": "line",
|
|
83
|
+
"x-chart-label": "Connection Time",
|
|
84
|
+
"x-chart-unit": "ms",
|
|
85
|
+
}),
|
|
86
|
+
pingTimeMs: healthResultNumber({
|
|
87
|
+
"x-chart-type": "line",
|
|
88
|
+
"x-chart-label": "Ping Time",
|
|
89
|
+
"x-chart-unit": "ms",
|
|
90
|
+
}).optional(),
|
|
91
|
+
pingSuccess: healthResultBoolean({
|
|
92
|
+
"x-chart-type": "boolean",
|
|
93
|
+
"x-chart-label": "Ping Success",
|
|
94
|
+
}),
|
|
95
|
+
role: healthResultString({
|
|
96
|
+
"x-chart-type": "text",
|
|
97
|
+
"x-chart-label": "Role",
|
|
98
|
+
}).optional(),
|
|
99
|
+
redisVersion: healthResultString({
|
|
100
|
+
"x-chart-type": "text",
|
|
101
|
+
"x-chart-label": "Redis Version",
|
|
102
|
+
}).optional(),
|
|
103
|
+
failedAssertion: redisAssertionSchema.optional(),
|
|
104
|
+
error: healthResultString({
|
|
105
|
+
"x-chart-type": "status",
|
|
106
|
+
"x-chart-label": "Error",
|
|
107
|
+
}).optional(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export type RedisResult = z.infer<typeof redisResultSchema>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Aggregated metadata for buckets.
|
|
114
|
+
*/
|
|
115
|
+
const redisAggregatedSchema = z.object({
|
|
116
|
+
avgConnectionTime: healthResultNumber({
|
|
117
|
+
"x-chart-type": "line",
|
|
118
|
+
"x-chart-label": "Avg Connection Time",
|
|
119
|
+
"x-chart-unit": "ms",
|
|
120
|
+
}),
|
|
121
|
+
avgPingTime: healthResultNumber({
|
|
122
|
+
"x-chart-type": "line",
|
|
123
|
+
"x-chart-label": "Avg Ping Time",
|
|
124
|
+
"x-chart-unit": "ms",
|
|
125
|
+
}),
|
|
126
|
+
successRate: healthResultNumber({
|
|
127
|
+
"x-chart-type": "gauge",
|
|
128
|
+
"x-chart-label": "Success Rate",
|
|
129
|
+
"x-chart-unit": "%",
|
|
130
|
+
}),
|
|
131
|
+
errorCount: healthResultNumber({
|
|
132
|
+
"x-chart-type": "counter",
|
|
133
|
+
"x-chart-label": "Errors",
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export type RedisAggregatedResult = z.infer<typeof redisAggregatedSchema>;
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// REDIS CLIENT INTERFACE (for testability)
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
export interface RedisConnection {
|
|
144
|
+
ping(): Promise<string>;
|
|
145
|
+
info(section: string): Promise<string>;
|
|
146
|
+
quit(): Promise<string>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface RedisClient {
|
|
150
|
+
connect(config: {
|
|
151
|
+
host: string;
|
|
152
|
+
port: number;
|
|
153
|
+
password?: string;
|
|
154
|
+
db: number;
|
|
155
|
+
tls: boolean;
|
|
156
|
+
connectTimeout: number;
|
|
157
|
+
}): Promise<RedisConnection>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Default client using ioredis
|
|
161
|
+
const defaultRedisClient: RedisClient = {
|
|
162
|
+
async connect(config) {
|
|
163
|
+
const redis = new Redis({
|
|
164
|
+
host: config.host,
|
|
165
|
+
port: config.port,
|
|
166
|
+
password: config.password,
|
|
167
|
+
db: config.db,
|
|
168
|
+
tls: config.tls ? {} : undefined,
|
|
169
|
+
connectTimeout: config.connectTimeout,
|
|
170
|
+
lazyConnect: true,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await redis.connect();
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ping: () => redis.ping(),
|
|
177
|
+
info: (section: string) => redis.info(section),
|
|
178
|
+
quit: () => redis.quit(),
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// STRATEGY
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
export class RedisHealthCheckStrategy
|
|
188
|
+
implements
|
|
189
|
+
HealthCheckStrategy<RedisConfig, RedisResult, RedisAggregatedResult>
|
|
190
|
+
{
|
|
191
|
+
id = "redis";
|
|
192
|
+
displayName = "Redis Health Check";
|
|
193
|
+
description = "Redis server connectivity and health monitoring";
|
|
194
|
+
|
|
195
|
+
private redisClient: RedisClient;
|
|
196
|
+
|
|
197
|
+
constructor(redisClient: RedisClient = defaultRedisClient) {
|
|
198
|
+
this.redisClient = redisClient;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
config: Versioned<RedisConfig> = new Versioned({
|
|
202
|
+
version: 1,
|
|
203
|
+
schema: redisConfigSchema,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
result: Versioned<RedisResult> = new Versioned({
|
|
207
|
+
version: 1,
|
|
208
|
+
schema: redisResultSchema,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
aggregatedResult: Versioned<RedisAggregatedResult> = new Versioned({
|
|
212
|
+
version: 1,
|
|
213
|
+
schema: redisAggregatedSchema,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
aggregateResult(
|
|
217
|
+
runs: HealthCheckRunForAggregation<RedisResult>[]
|
|
218
|
+
): RedisAggregatedResult {
|
|
219
|
+
let totalConnectionTime = 0;
|
|
220
|
+
let totalPingTime = 0;
|
|
221
|
+
let successCount = 0;
|
|
222
|
+
let errorCount = 0;
|
|
223
|
+
let validRuns = 0;
|
|
224
|
+
let pingRuns = 0;
|
|
225
|
+
|
|
226
|
+
for (const run of runs) {
|
|
227
|
+
if (run.metadata?.error) {
|
|
228
|
+
errorCount++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (run.status === "healthy") {
|
|
232
|
+
successCount++;
|
|
233
|
+
}
|
|
234
|
+
if (run.metadata) {
|
|
235
|
+
totalConnectionTime += run.metadata.connectionTimeMs;
|
|
236
|
+
if (run.metadata.pingTimeMs !== undefined) {
|
|
237
|
+
totalPingTime += run.metadata.pingTimeMs;
|
|
238
|
+
pingRuns++;
|
|
239
|
+
}
|
|
240
|
+
validRuns++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
avgConnectionTime: validRuns > 0 ? totalConnectionTime / validRuns : 0,
|
|
246
|
+
avgPingTime: pingRuns > 0 ? totalPingTime / pingRuns : 0,
|
|
247
|
+
successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
|
|
248
|
+
errorCount,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async execute(
|
|
253
|
+
config: RedisConfigInput
|
|
254
|
+
): Promise<HealthCheckResult<RedisResult>> {
|
|
255
|
+
const validatedConfig = this.config.validate(config);
|
|
256
|
+
const start = performance.now();
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// Connect to Redis
|
|
260
|
+
const connection = await this.redisClient.connect({
|
|
261
|
+
host: validatedConfig.host,
|
|
262
|
+
port: validatedConfig.port,
|
|
263
|
+
password: validatedConfig.password,
|
|
264
|
+
db: validatedConfig.database,
|
|
265
|
+
tls: validatedConfig.tls,
|
|
266
|
+
connectTimeout: validatedConfig.timeout,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const connectionTimeMs = Math.round(performance.now() - start);
|
|
270
|
+
|
|
271
|
+
// Execute PING command
|
|
272
|
+
const pingStart = performance.now();
|
|
273
|
+
let pingSuccess = false;
|
|
274
|
+
let pingTimeMs: number | undefined;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const pong = await connection.ping();
|
|
278
|
+
pingSuccess = pong === "PONG";
|
|
279
|
+
pingTimeMs = Math.round(performance.now() - pingStart);
|
|
280
|
+
} catch {
|
|
281
|
+
pingSuccess = false;
|
|
282
|
+
pingTimeMs = Math.round(performance.now() - pingStart);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Get server info
|
|
286
|
+
let role: string | undefined;
|
|
287
|
+
let redisVersion: string | undefined;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const info = await connection.info("server");
|
|
291
|
+
const roleMatch = /role:(\w+)/i.exec(info);
|
|
292
|
+
const versionMatch = /redis_version:([^\r\n]+)/i.exec(info);
|
|
293
|
+
role = roleMatch?.[1];
|
|
294
|
+
redisVersion = versionMatch?.[1];
|
|
295
|
+
} catch {
|
|
296
|
+
// Info command failed, continue without it
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await connection.quit();
|
|
300
|
+
|
|
301
|
+
const result: Omit<RedisResult, "failedAssertion" | "error"> = {
|
|
302
|
+
connected: true,
|
|
303
|
+
connectionTimeMs,
|
|
304
|
+
pingTimeMs,
|
|
305
|
+
pingSuccess,
|
|
306
|
+
role,
|
|
307
|
+
redisVersion,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Evaluate assertions using shared utility
|
|
311
|
+
const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
|
|
312
|
+
connectionTime: connectionTimeMs,
|
|
313
|
+
pingTime: pingTimeMs ?? 0,
|
|
314
|
+
pingSuccess,
|
|
315
|
+
role: role ?? "",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (failedAssertion) {
|
|
319
|
+
return {
|
|
320
|
+
status: "unhealthy",
|
|
321
|
+
latencyMs: connectionTimeMs + (pingTimeMs ?? 0),
|
|
322
|
+
message: `Assertion failed: ${failedAssertion.field} ${
|
|
323
|
+
failedAssertion.operator
|
|
324
|
+
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
325
|
+
metadata: { ...result, failedAssertion },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!pingSuccess) {
|
|
330
|
+
return {
|
|
331
|
+
status: "unhealthy",
|
|
332
|
+
latencyMs: connectionTimeMs + (pingTimeMs ?? 0),
|
|
333
|
+
message: "Redis PING failed",
|
|
334
|
+
metadata: result,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
status: "healthy",
|
|
340
|
+
latencyMs: connectionTimeMs + (pingTimeMs ?? 0),
|
|
341
|
+
message: `Redis ${redisVersion ?? "unknown"} (${
|
|
342
|
+
role ?? "unknown"
|
|
343
|
+
}) - PONG in ${pingTimeMs}ms`,
|
|
344
|
+
metadata: result,
|
|
345
|
+
};
|
|
346
|
+
} catch (error: unknown) {
|
|
347
|
+
const end = performance.now();
|
|
348
|
+
const isError = error instanceof Error;
|
|
349
|
+
return {
|
|
350
|
+
status: "unhealthy",
|
|
351
|
+
latencyMs: Math.round(end - start),
|
|
352
|
+
message: isError ? error.message : "Redis connection failed",
|
|
353
|
+
metadata: {
|
|
354
|
+
connected: false,
|
|
355
|
+
connectionTimeMs: Math.round(end - start),
|
|
356
|
+
pingSuccess: false,
|
|
357
|
+
error: isError ? error.name : "UnknownError",
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|