@checkstack/healthcheck-redis-backend 0.1.13 → 0.1.14

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 CHANGED
@@ -1,5 +1,35 @@
1
1
  # @checkstack/healthcheck-redis-backend
2
2
 
3
+ ## 0.1.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
8
+
9
+ ### Breaking Changes (Internal)
10
+
11
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
12
+
13
+ ### New Features
14
+
15
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
16
+ - `mergeCounter()` - track occurrences
17
+ - `mergeAverage()` - track sum/count, compute avg
18
+ - `mergeRate()` - track success/total, compute %
19
+ - `mergeMinMax()` - track min/max values
20
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
21
+
22
+ ### Improvements
23
+
24
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
25
+ - Prepares for real-time hourly aggregation without batch accumulation
26
+
27
+ - Updated dependencies [f676e11]
28
+ - Updated dependencies [48c2080]
29
+ - @checkstack/common@0.6.2
30
+ - @checkstack/backend-api@0.6.0
31
+ - @checkstack/healthcheck-common@0.8.2
32
+
3
33
  ## 0.1.13
4
34
 
5
35
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-redis-backend",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -9,15 +9,15 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/backend-api": "0.5.1",
13
- "@checkstack/common": "0.6.0",
14
- "@checkstack/healthcheck-common": "0.7.0",
12
+ "@checkstack/backend-api": "0.5.2",
13
+ "@checkstack/common": "0.6.1",
14
+ "@checkstack/healthcheck-common": "0.8.1",
15
15
  "ioredis": "^5.3.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/bun": "^1.0.0",
19
19
  "typescript": "^5.0.0",
20
- "@checkstack/tsconfig": "0.0.2",
21
- "@checkstack/scripts": "0.1.0"
20
+ "@checkstack/tsconfig": "0.0.3",
21
+ "@checkstack/scripts": "0.1.1"
22
22
  }
23
23
  }
@@ -79,7 +79,7 @@ describe("CommandCollector", () => {
79
79
  });
80
80
  });
81
81
 
82
- describe("aggregateResult", () => {
82
+ describe("mergeResult", () => {
83
83
  it("should calculate average response time and success rate", () => {
84
84
  const collector = new CommandCollector();
85
85
  const runs = [
@@ -101,7 +101,8 @@ describe("CommandCollector", () => {
101
101
  },
102
102
  ];
103
103
 
104
- const aggregated = collector.aggregateResult(runs);
104
+ let aggregated = collector.mergeResult(undefined, runs[0]);
105
+ aggregated = collector.mergeResult(aggregated, runs[1]);
105
106
 
106
107
  expect(aggregated.avgResponseTimeMs).toBe(10);
107
108
  expect(aggregated.successRate).toBe(100);
@@ -128,7 +129,8 @@ describe("CommandCollector", () => {
128
129
  },
129
130
  ];
130
131
 
131
- const aggregated = collector.aggregateResult(runs);
132
+ let aggregated = collector.mergeResult(undefined, runs[0]);
133
+ aggregated = collector.mergeResult(aggregated, runs[1]);
132
134
 
133
135
  expect(aggregated.successRate).toBe(50);
134
136
  });
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ averageStateSchema,
9
+ mergeRate,
10
+ rateStateSchema,
11
+ type AverageState,
12
+ type RateState,
7
13
  } from "@checkstack/backend-api";
8
14
  import {
9
15
  healthResultNumber,
@@ -53,7 +59,7 @@ const commandResultSchema = healthResultSchema({
53
59
 
54
60
  export type CommandResult = z.infer<typeof commandResultSchema>;
55
61
 
56
- const commandAggregatedSchema = healthResultSchema({
62
+ const commandAggregatedDisplaySchema = healthResultSchema({
57
63
  avgResponseTimeMs: healthResultNumber({
58
64
  "x-chart-type": "line",
59
65
  "x-chart-label": "Avg Response Time",
@@ -66,6 +72,17 @@ const commandAggregatedSchema = healthResultSchema({
66
72
  }),
67
73
  });
68
74
 
75
+ const commandAggregatedInternalSchema = z.object({
76
+ _responseTime: averageStateSchema
77
+ .optional(),
78
+ _success: rateStateSchema
79
+ .optional(),
80
+ });
81
+
82
+ const commandAggregatedSchema = commandAggregatedDisplaySchema.merge(
83
+ commandAggregatedInternalSchema,
84
+ );
85
+
69
86
  export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
70
87
 
71
88
  // ============================================================================
@@ -76,15 +93,12 @@ export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
76
93
  * Built-in Redis command collector.
77
94
  * Executes Redis commands and checks results.
78
95
  */
79
- export class CommandCollector
80
- implements
81
- CollectorStrategy<
82
- RedisTransportClient,
83
- CommandConfig,
84
- CommandResult,
85
- CommandAggregatedResult
86
- >
87
- {
96
+ export class CommandCollector implements CollectorStrategy<
97
+ RedisTransportClient,
98
+ CommandConfig,
99
+ CommandResult,
100
+ CommandAggregatedResult
101
+ > {
88
102
  id = "command";
89
103
  displayName = "Redis Command";
90
104
  description = "Execute a Redis command and check the result";
@@ -127,28 +141,27 @@ export class CommandCollector
127
141
  };
128
142
  }
129
143
 
130
- aggregateResult(
131
- runs: HealthCheckRunForAggregation<CommandResult>[]
144
+ mergeResult(
145
+ existing: CommandAggregatedResult | undefined,
146
+ run: HealthCheckRunForAggregation<CommandResult>,
132
147
  ): CommandAggregatedResult {
133
- const times = runs
134
- .map((r) => r.metadata?.responseTimeMs)
135
- .filter((v): v is number => typeof v === "number");
148
+ const metadata = run.metadata;
136
149
 
137
- const successes = runs
138
- .map((r) => r.metadata?.success)
139
- .filter((v): v is boolean => typeof v === "boolean");
150
+ const responseTimeState = mergeAverage(
151
+ existing?._responseTime as AverageState | undefined,
152
+ metadata?.responseTimeMs,
153
+ );
140
154
 
141
- const successCount = successes.filter(Boolean).length;
155
+ const successState = mergeRate(
156
+ existing?._success as RateState | undefined,
157
+ metadata?.success,
158
+ );
142
159
 
143
160
  return {
144
- avgResponseTimeMs:
145
- times.length > 0
146
- ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
147
- : 0,
148
- successRate:
149
- successes.length > 0
150
- ? Math.round((successCount / successes.length) * 100)
151
- : 0,
161
+ avgResponseTimeMs: responseTimeState.avg,
162
+ successRate: successState.rate,
163
+ _responseTime: responseTimeState,
164
+ _success: successState,
152
165
  };
153
166
  }
154
167
  }
@@ -12,17 +12,17 @@ describe("RedisHealthCheckStrategy", () => {
12
12
  pingResponse?: string;
13
13
  infoResponse?: string;
14
14
  pingError?: Error;
15
- } = {}
15
+ } = {},
16
16
  ): RedisConnection => ({
17
17
  ping: mock(() =>
18
18
  config.pingError
19
19
  ? Promise.reject(config.pingError)
20
- : Promise.resolve(config.pingResponse ?? "PONG")
20
+ : Promise.resolve(config.pingResponse ?? "PONG"),
21
21
  ),
22
22
  info: mock(() =>
23
23
  Promise.resolve(
24
- config.infoResponse ?? "redis_version:7.0.0\r\nrole:master\r\n"
25
- )
24
+ config.infoResponse ?? "redis_version:7.0.0\r\nrole:master\r\n",
25
+ ),
26
26
  ),
27
27
  get: mock(() => Promise.resolve(undefined)),
28
28
  quit: mock(() => Promise.resolve("OK")),
@@ -35,12 +35,12 @@ describe("RedisHealthCheckStrategy", () => {
35
35
  infoResponse?: string;
36
36
  pingError?: Error;
37
37
  connectError?: Error;
38
- } = {}
38
+ } = {},
39
39
  ): RedisClient => ({
40
40
  connect: mock(() =>
41
41
  config.connectError
42
42
  ? Promise.reject(config.connectError)
43
- : Promise.resolve(createMockConnection(config))
43
+ : Promise.resolve(createMockConnection(config)),
44
44
  ),
45
45
  });
46
46
 
@@ -63,7 +63,7 @@ describe("RedisHealthCheckStrategy", () => {
63
63
 
64
64
  it("should throw for connection error", async () => {
65
65
  const strategy = new RedisHealthCheckStrategy(
66
- createMockClient({ connectError: new Error("Connection refused") })
66
+ createMockClient({ connectError: new Error("Connection refused") }),
67
67
  );
68
68
 
69
69
  await expect(
@@ -71,7 +71,7 @@ describe("RedisHealthCheckStrategy", () => {
71
71
  host: "localhost",
72
72
  port: 6379,
73
73
  timeout: 5000,
74
- })
74
+ }),
75
75
  ).rejects.toThrow("Connection refused");
76
76
  });
77
77
  });
@@ -94,7 +94,7 @@ describe("RedisHealthCheckStrategy", () => {
94
94
 
95
95
  it("should return error for ping failure", async () => {
96
96
  const strategy = new RedisHealthCheckStrategy(
97
- createMockClient({ pingError: new Error("NOAUTH") })
97
+ createMockClient({ pingError: new Error("NOAUTH") }),
98
98
  );
99
99
  const connectedClient = await strategy.createClient({
100
100
  host: "localhost",
@@ -128,7 +128,7 @@ describe("RedisHealthCheckStrategy", () => {
128
128
  });
129
129
  });
130
130
 
131
- describe("aggregateResult", () => {
131
+ describe("mergeResult", () => {
132
132
  it("should calculate averages correctly", () => {
133
133
  const strategy = new RedisHealthCheckStrategy();
134
134
  const runs = [
@@ -160,7 +160,8 @@ describe("RedisHealthCheckStrategy", () => {
160
160
  },
161
161
  ];
162
162
 
163
- const aggregated = strategy.aggregateResult(runs);
163
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
164
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
164
165
 
165
166
  expect(aggregated.avgConnectionTime).toBe(10);
166
167
  expect(aggregated.successRate).toBe(100);
@@ -169,23 +170,21 @@ describe("RedisHealthCheckStrategy", () => {
169
170
 
170
171
  it("should count errors", () => {
171
172
  const strategy = new RedisHealthCheckStrategy();
172
- const runs = [
173
- {
174
- id: "1",
175
- status: "unhealthy" as const,
176
- latencyMs: 100,
177
- checkId: "c1",
178
- timestamp: new Date(),
179
- metadata: {
180
- connected: false,
181
- connectionTimeMs: 100,
182
- pingSuccess: false,
183
- error: "Connection refused",
184
- },
173
+ const run = {
174
+ id: "1",
175
+ status: "unhealthy" as const,
176
+ latencyMs: 100,
177
+ checkId: "c1",
178
+ timestamp: new Date(),
179
+ metadata: {
180
+ connected: false,
181
+ connectionTimeMs: 100,
182
+ pingSuccess: false,
183
+ error: "Connection refused",
185
184
  },
186
- ];
185
+ };
187
186
 
188
- const aggregated = strategy.aggregateResult(runs);
187
+ const aggregated = strategy.mergeResult(undefined, run);
189
188
 
190
189
  expect(aggregated.errorCount).toBe(1);
191
190
  expect(aggregated.successRate).toBe(0);
package/src/strategy.ts CHANGED
@@ -8,6 +8,18 @@ import {
8
8
  configNumber,
9
9
  configBoolean,
10
10
  type ConnectedClient,
11
+ mergeAverage,
12
+ averageStateSchema,
13
+ mergeRate,
14
+ rateStateSchema,
15
+ mergeCounter,
16
+ counterStateSchema,
17
+ mergeMinMax,
18
+ minMaxStateSchema,
19
+ type AverageState,
20
+ type RateState,
21
+ type CounterState,
22
+ type MinMaxState,
11
23
  } from "@checkstack/backend-api";
12
24
  import {
13
25
  healthResultBoolean,
@@ -78,7 +90,8 @@ type RedisResult = z.infer<typeof redisResultSchema>;
78
90
  /**
79
91
  * Aggregated metadata for buckets.
80
92
  */
81
- const redisAggregatedSchema = healthResultSchema({
93
+ // UI-visible aggregated fields
94
+ const redisAggregatedDisplaySchema = healthResultSchema({
82
95
  avgConnectionTime: healthResultNumber({
83
96
  "x-chart-type": "line",
84
97
  "x-chart-label": "Avg Connection Time",
@@ -100,6 +113,20 @@ const redisAggregatedSchema = healthResultSchema({
100
113
  }),
101
114
  });
102
115
 
116
+ // Internal state for incremental aggregation
117
+ const redisAggregatedInternalSchema = z.object({
118
+ _connectionTime: averageStateSchema
119
+ .optional(),
120
+ _maxConnectionTime: minMaxStateSchema.optional(),
121
+ _success: rateStateSchema
122
+ .optional(),
123
+ _errors: counterStateSchema.optional(),
124
+ });
125
+
126
+ const redisAggregatedSchema = redisAggregatedDisplaySchema.merge(
127
+ redisAggregatedInternalSchema,
128
+ );
129
+
103
130
  type RedisAggregatedResult = z.infer<typeof redisAggregatedSchema>;
104
131
 
105
132
  // ============================================================================
@@ -160,15 +187,12 @@ const defaultRedisClient: RedisClient = {
160
187
  // STRATEGY
161
188
  // ============================================================================
162
189
 
163
- export class RedisHealthCheckStrategy
164
- implements
165
- HealthCheckStrategy<
166
- RedisConfig,
167
- RedisTransportClient,
168
- RedisResult,
169
- RedisAggregatedResult
170
- >
171
- {
190
+ export class RedisHealthCheckStrategy implements HealthCheckStrategy<
191
+ RedisConfig,
192
+ RedisTransportClient,
193
+ RedisResult,
194
+ RedisAggregatedResult
195
+ > {
172
196
  id = "redis";
173
197
  displayName = "Redis Health Check";
174
198
  description = "Redis server connectivity and health monitoring";
@@ -210,53 +234,46 @@ export class RedisHealthCheckStrategy
210
234
  schema: redisAggregatedSchema,
211
235
  });
212
236
 
213
- aggregateResult(
214
- runs: HealthCheckRunForAggregation<RedisResult>[]
237
+ mergeResult(
238
+ existing: RedisAggregatedResult | undefined,
239
+ run: HealthCheckRunForAggregation<RedisResult>,
215
240
  ): RedisAggregatedResult {
216
- const validRuns = runs.filter((r) => r.metadata);
217
-
218
- if (validRuns.length === 0) {
219
- return {
220
- avgConnectionTime: 0,
221
- maxConnectionTime: 0,
222
- successRate: 0,
223
- errorCount: 0,
224
- };
225
- }
226
-
227
- const connectionTimes = validRuns
228
- .map((r) => r.metadata?.connectionTimeMs)
229
- .filter((t): t is number => typeof t === "number");
230
-
231
- const avgConnectionTime =
232
- connectionTimes.length > 0
233
- ? Math.round(
234
- connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
235
- )
236
- : 0;
237
-
238
- const maxConnectionTime =
239
- connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
240
-
241
- const successCount = validRuns.filter(
242
- (r) => r.metadata?.connected === true
243
- ).length;
244
- const successRate = Math.round((successCount / validRuns.length) * 100);
245
-
246
- const errorCount = validRuns.filter(
247
- (r) => r.metadata?.error !== undefined
248
- ).length;
241
+ const metadata = run.metadata;
242
+
243
+ const connectionTimeState = mergeAverage(
244
+ existing?._connectionTime as AverageState | undefined,
245
+ metadata?.connectionTimeMs,
246
+ );
247
+
248
+ const maxConnectionTimeState = mergeMinMax(
249
+ existing?._maxConnectionTime as MinMaxState | undefined,
250
+ metadata?.connectionTimeMs,
251
+ );
252
+
253
+ const successState = mergeRate(
254
+ existing?._success as RateState | undefined,
255
+ metadata?.connected,
256
+ );
257
+
258
+ const errorState = mergeCounter(
259
+ existing?._errors as CounterState | undefined,
260
+ metadata?.error !== undefined,
261
+ );
249
262
 
250
263
  return {
251
- avgConnectionTime,
252
- maxConnectionTime,
253
- successRate,
254
- errorCount,
264
+ avgConnectionTime: connectionTimeState.avg,
265
+ maxConnectionTime: maxConnectionTimeState.max,
266
+ successRate: successState.rate,
267
+ errorCount: errorState.count,
268
+ _connectionTime: connectionTimeState,
269
+ _maxConnectionTime: maxConnectionTimeState,
270
+ _success: successState,
271
+ _errors: errorState,
255
272
  };
256
273
  }
257
274
 
258
275
  async createClient(
259
- config: RedisConfigInput
276
+ config: RedisConfigInput,
260
277
  ): Promise<ConnectedClient<RedisTransportClient>> {
261
278
  const validatedConfig = this.config.validate(config);
262
279