@compforge/harness-toolbox 0.4.2 → 0.4.3

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/README.md CHANGED
@@ -62,6 +62,26 @@ never close it themselves.
62
62
  Version 0.4.2 requires Common 0.5.4. Replace single path arguments with arrays, supply `maxTotalBytes`,
63
63
  and handle per-file results. Publish Common before Toolbox; this change has no single-file legacy adapter.
64
64
 
65
+ ## Redis sources
66
+
67
+ `RedisClient` owns resolved configuration and reusable node connections. Use `clusterType` to
68
+ select `single`, `cluster` or `sentinel`; `endpoints` contains that mode's seed addresses. Sentinel
69
+ configuration additionally provides `sentinelMasterName` and optional Sentinel credentials,
70
+ separate from data-node credentials. Cluster connections require database zero.
71
+
72
+ Supply ordered TCP transports through `ConnectionSource`. Only mapping or handshake network
73
+ failures advance to the next transport; authentication and TLS verification failures stop immediately.
74
+ `connectTimeoutMs` bounds each route (mapping and handshake); it defaults to the command
75
+ `timeoutMs`. `acquireTimeoutMs` bounds all candidates together and defaults to the per-route budget
76
+ times the number of routes. A spent total budget does not start another route.
77
+ `onRoute` reports the selected route and the preceding failure code without credentials.
78
+
79
+ `client.topology()` refreshes topology using the same connections and route policy as
80
+ `client.access.connection(endpoint, database)`. A root signal cancels connection preparation and
81
+ active commands. Commands and pipelines are sent once, with no offline queue or automatic replay;
82
+ a subsequent acquisition replaces an unhealthy connection. The caller owns `dispose()` and transport
83
+ cleanup, and diagnostics own sampling policy and collected observations.
84
+
65
85
  ## Kubernetes resources
66
86
 
67
87
  Borrow a root `KubernetesClient` through `KubernetesEnvironment` and `ClientManager`. Use
@@ -26,7 +26,7 @@ function sentinelRows(value) {
26
26
  });
27
27
  }
28
28
  async function discoverSentinel(access, config) {
29
- const sentinels = config.sentinelHosts.length ? config.sentinelHosts : config.endpoints;
29
+ const sentinels = config.endpoints;
30
30
  let lastError;
31
31
  for (const sentinel of sentinels) {
32
32
  try {
@@ -0,0 +1,366 @@
1
+ import {
2
+ discoverRedisTopology
3
+ } from "./chunk-5tbzf9wd.js";
4
+ import {
5
+ ToolboxError
6
+ } from "./chunk-20kv4wc3.js";
7
+ import {
8
+ isConnectionNetworkError
9
+ } from "./chunk-h8fwx0k7.js";
10
+
11
+ // src/redis/client.ts
12
+ import { createClient, SocketClosedUnexpectedlyError, ConnectionTimeoutError } from "@redis/client";
13
+ function stringValue(value) {
14
+ if (Buffer.isBuffer(value))
15
+ return value.toString("utf8");
16
+ return String(value ?? "");
17
+ }
18
+ function scalar(value) {
19
+ if (/^-?(?:\d+|\d+\.\d+)$/.test(value))
20
+ return Number(value);
21
+ return value;
22
+ }
23
+ function parseRedisInfo(raw) {
24
+ const result = {};
25
+ for (const line of raw.split(/\r?\n/)) {
26
+ if (!line || line.startsWith("#"))
27
+ continue;
28
+ const separator = line.indexOf(":");
29
+ if (separator < 1)
30
+ continue;
31
+ const name = line.slice(0, separator);
32
+ const value = line.slice(separator + 1);
33
+ if (/^db\d+$/.test(name)) {
34
+ result[name] = Object.fromEntries(value.split(",").map((item) => {
35
+ const index = item.indexOf("=");
36
+ return [item.slice(0, index), scalar(item.slice(index + 1))];
37
+ }));
38
+ } else {
39
+ result[name] = scalar(value);
40
+ }
41
+ }
42
+ return result;
43
+ }
44
+ async function abortable(pending, signal, cancel) {
45
+ if (signal.aborted) {
46
+ cancel?.();
47
+ pending.catch(() => {});
48
+ signal.throwIfAborted();
49
+ }
50
+ let abort;
51
+ const cancelled = new Promise((_, reject) => {
52
+ abort = () => {
53
+ cancel?.();
54
+ reject(signal.reason);
55
+ };
56
+ signal.addEventListener("abort", abort, { once: true });
57
+ });
58
+ try {
59
+ return await Promise.race([pending, cancelled]);
60
+ } finally {
61
+ signal.removeEventListener("abort", abort);
62
+ }
63
+ }
64
+
65
+ class RedisConnection {
66
+ endpoint;
67
+ client;
68
+ timeoutMs;
69
+ lastError;
70
+ signal;
71
+ #abort;
72
+ constructor(endpoint, client, timeoutMs, lastError, signal) {
73
+ this.endpoint = endpoint;
74
+ this.client = client;
75
+ this.timeoutMs = timeoutMs;
76
+ this.lastError = lastError;
77
+ this.signal = signal;
78
+ this.#abort = () => this.close();
79
+ signal?.addEventListener("abort", this.#abort, { once: true });
80
+ }
81
+ static async connect(endpoint, mapped, config, clientFactory = createClient) {
82
+ config.signal?.throwIfAborted();
83
+ config.connectSignal?.throwIfAborted();
84
+ const baseSocket = {
85
+ host: mapped.host,
86
+ port: mapped.port,
87
+ connectTimeout: config.connectTimeoutMs ?? config.timeoutMs,
88
+ reconnectStrategy: false
89
+ };
90
+ const socket = config.useSsl ? { ...baseSocket, tls: true, servername: mapped.servername ?? endpoint.host } : { ...baseSocket, tls: false };
91
+ const client = clientFactory({
92
+ socket,
93
+ database: config.database,
94
+ username: config.username,
95
+ password: config.password,
96
+ disableOfflineQueue: true,
97
+ commandOptions: { timeout: config.timeoutMs }
98
+ });
99
+ let lastError;
100
+ client.on("error", (err) => {
101
+ lastError = err instanceof Error ? err : new Error(String(err));
102
+ });
103
+ const timeout = new AbortController;
104
+ const timer = setTimeout(() => timeout.abort(new ToolboxError("Redis handshake timed out", { kind: "timeout", code: "ETIMEDOUT" })), config.connectTimeoutMs ?? config.timeoutMs);
105
+ const signal = AbortSignal.any([
106
+ timeout.signal,
107
+ ...config.signal ? [config.signal] : [],
108
+ ...config.connectSignal ? [config.connectSignal] : []
109
+ ]);
110
+ const close = () => {
111
+ if (client.isOpen)
112
+ client.destroy();
113
+ };
114
+ try {
115
+ await abortable(client.connect(), signal, close);
116
+ signal.throwIfAborted();
117
+ return new RedisConnection(endpoint, client, config.timeoutMs, () => lastError, config.signal);
118
+ } catch (error) {
119
+ close();
120
+ throw error;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+ get isReady() {
126
+ return !this.signal?.aborted && this.client.isReady;
127
+ }
128
+ async#execute(operationName, operation) {
129
+ this.signal?.throwIfAborted();
130
+ if (!this.client.isOpen) {
131
+ const reason = this.lastError()?.message;
132
+ throw new Error(`Redis connection is closed${reason ? `: ${reason}` : ""}`);
133
+ }
134
+ const timeout = new AbortController;
135
+ const timer = setTimeout(() => timeout.abort(new ToolboxError(`Redis ${operationName} timed out after ${this.timeoutMs}ms (${this.endpoint.host}:${this.endpoint.port})`, { kind: "timeout", code: "ETIMEDOUT" })), this.timeoutMs);
136
+ const signal = this.signal ? AbortSignal.any([this.signal, timeout.signal]) : timeout.signal;
137
+ try {
138
+ return await abortable(Promise.resolve().then(() => {
139
+ signal.throwIfAborted();
140
+ return operation();
141
+ }), signal, () => this.close());
142
+ } finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
146
+ ping() {
147
+ return this.#execute("PING", () => this.client.ping());
148
+ }
149
+ async info(section) {
150
+ const raw = await this.#execute(section ? `INFO ${section}` : "INFO", () => section ? this.client.sendCommand(["INFO", section]) : this.client.sendCommand(["INFO"]));
151
+ return parseRedisInfo(stringValue(raw));
152
+ }
153
+ async dbSize() {
154
+ return Number(await this.#execute("DBSIZE", () => this.client.sendCommand(["DBSIZE"])));
155
+ }
156
+ command(args) {
157
+ return this.#execute(args[0]?.toUpperCase() ?? "command", () => this.client.sendCommand(args));
158
+ }
159
+ async pipeline(commands) {
160
+ const commandNames = new Set(commands.map((command) => command[0]?.toUpperCase()).filter(Boolean));
161
+ const operationName = commandNames.size === 1 ? `${[...commandNames][0]} pipeline` : "pipeline";
162
+ return await this.#execute(operationName, () => {
163
+ const pipeline = this.client.multi();
164
+ for (const command of commands)
165
+ pipeline.addCommand(command);
166
+ return pipeline.execAsPipeline();
167
+ });
168
+ }
169
+ async scan(cursor, count) {
170
+ const reply = await this.command(["SCAN", cursor, "COUNT", String(count)]);
171
+ if (!Array.isArray(reply) || !Array.isArray(reply[1]))
172
+ throw new Error("Redis SCAN 返回格式无效");
173
+ return { cursor: stringValue(reply[0]), keys: reply[1].map(stringValue) };
174
+ }
175
+ close() {
176
+ if (this.#abort)
177
+ this.signal?.removeEventListener("abort", this.#abort);
178
+ if (this.client.isOpen)
179
+ this.client.destroy();
180
+ }
181
+ }
182
+
183
+ class RedisAccess {
184
+ baseConfig;
185
+ connectionFactory;
186
+ lifecycle;
187
+ #connections = new Map;
188
+ #controller = new AbortController;
189
+ #signal;
190
+ #transports;
191
+ #closing;
192
+ constructor(transports, baseConfig, connectionFactory = RedisConnection.connect, lifecycle = {}) {
193
+ this.baseConfig = baseConfig;
194
+ this.connectionFactory = connectionFactory;
195
+ this.lifecycle = lifecycle;
196
+ this.#transports = Array.isArray(transports) ? transports : [transports];
197
+ if (!this.#transports.length)
198
+ throw new Error("Redis requires a TCP transport");
199
+ for (const value of [
200
+ baseConfig.timeoutMs,
201
+ baseConfig.connectTimeoutMs ?? baseConfig.timeoutMs,
202
+ baseConfig.acquireTimeoutMs ?? (baseConfig.connectTimeoutMs ?? baseConfig.timeoutMs) * this.#transports.length
203
+ ]) {
204
+ if (!Number.isFinite(value) || value <= 0)
205
+ throw new Error("Redis timeouts must be positive milliseconds");
206
+ }
207
+ this.#signal = lifecycle.signal ? AbortSignal.any([lifecycle.signal, this.#controller.signal]) : this.#controller.signal;
208
+ }
209
+ async connection(endpoint, database, credentials = this.baseConfig) {
210
+ this.#signal.throwIfAborted();
211
+ const key = [endpoint.host, endpoint.port, database, credentials.username ?? "", credentials.password ?? ""].join("\x00");
212
+ const existing = this.#connections.get(key);
213
+ if (existing) {
214
+ const connection = await existing;
215
+ this.#signal.throwIfAborted();
216
+ if (connection.isReady)
217
+ return connection;
218
+ if (this.#connections.get(key) === existing) {
219
+ this.#connections.delete(key);
220
+ connection.close();
221
+ }
222
+ return this.connection(endpoint, database, credentials);
223
+ }
224
+ const pending = this.#open(endpoint, database, credentials);
225
+ this.#connections.set(key, pending);
226
+ pending.catch(() => {
227
+ if (this.#connections.get(key) === pending)
228
+ this.#connections.delete(key);
229
+ });
230
+ return pending;
231
+ }
232
+ async#open(endpoint, database, credentials) {
233
+ const timeout = new AbortController;
234
+ const attemptBudget = this.baseConfig.connectTimeoutMs ?? this.baseConfig.timeoutMs;
235
+ const budget = this.baseConfig.acquireTimeoutMs ?? attemptBudget * this.#transports.length;
236
+ const deadline = performance.now() + budget;
237
+ const timer = setTimeout(() => timeout.abort(new ToolboxError(`Redis connection timed out after ${budget}ms (${endpoint.host}:${endpoint.port})`, { kind: "timeout", code: "ETIMEDOUT" })), budget);
238
+ const signal = AbortSignal.any([this.#signal, timeout.signal]);
239
+ let reason;
240
+ try {
241
+ for (let index = 0;index < this.#transports.length; index++) {
242
+ signal.throwIfAborted();
243
+ const transport = this.#transports[index];
244
+ const attempt = new AbortController;
245
+ const remaining = Math.min(attemptBudget, Math.max(1, Math.ceil(deadline - performance.now())));
246
+ const attemptTimer = setTimeout(() => attempt.abort(new ToolboxError(`Redis connection attempt timed out (${transport.name})`, { kind: "timeout", code: "ETIMEDOUT" })), remaining);
247
+ const attemptSignal = AbortSignal.any([signal, attempt.signal]);
248
+ try {
249
+ const mapped = await abortable(transport.connect(endpoint), attemptSignal);
250
+ attemptSignal.throwIfAborted();
251
+ const pending = this.connectionFactory(endpoint, mapped, {
252
+ ...this.baseConfig,
253
+ ...credentials,
254
+ database,
255
+ connectTimeoutMs: remaining,
256
+ signal: this.#signal,
257
+ connectSignal: attemptSignal
258
+ });
259
+ const prepared = pending.then((connection2) => {
260
+ if (attemptSignal.aborted) {
261
+ connection2.close();
262
+ attemptSignal.throwIfAborted();
263
+ }
264
+ return connection2;
265
+ });
266
+ const connection = await abortable(prepared, attemptSignal);
267
+ this.lifecycle.onRoute?.({ transport: mapped.host === endpoint.host && mapped.port === endpoint.port ? "direct" : transport.name, reason });
268
+ return connection;
269
+ } catch (failure) {
270
+ signal.throwIfAborted();
271
+ const error = attemptSignal.aborted ? attemptSignal.reason : failure;
272
+ const network = isConnectionNetworkError(error) || error instanceof SocketClosedUnexpectedlyError || error instanceof ConnectionTimeoutError;
273
+ if (!network || index === this.#transports.length - 1)
274
+ throw error;
275
+ reason = error instanceof SocketClosedUnexpectedlyError ? "connection_lost" : error instanceof ConnectionTimeoutError ? "ETIMEDOUT" : String(error.code);
276
+ } finally {
277
+ clearTimeout(attemptTimer);
278
+ }
279
+ }
280
+ throw new Error("Redis requires a TCP transport");
281
+ } finally {
282
+ clearTimeout(timer);
283
+ }
284
+ }
285
+ close() {
286
+ return this.#closing ??= (async () => {
287
+ this.#controller.abort(new Error("Redis access is closed"));
288
+ const settled = await Promise.allSettled(this.#connections.values());
289
+ this.#connections.clear();
290
+ const errors = [];
291
+ for (const result of settled)
292
+ if (result.status === "fulfilled") {
293
+ try {
294
+ result.value.close();
295
+ } catch (error) {
296
+ errors.push(error);
297
+ }
298
+ }
299
+ if (errors.length)
300
+ throw new AggregateError(errors, "Redis connection cleanup failed");
301
+ })();
302
+ }
303
+ }
304
+
305
+ class RedisClient {
306
+ source;
307
+ lifecycle;
308
+ #controller = new AbortController;
309
+ signal;
310
+ #initialization;
311
+ #disposal;
312
+ #access;
313
+ #target;
314
+ constructor(source, lifecycle = {}) {
315
+ this.source = source;
316
+ this.lifecycle = lifecycle;
317
+ this.signal = lifecycle.signal ? AbortSignal.any([lifecycle.signal, this.#controller.signal]) : this.#controller.signal;
318
+ }
319
+ initialize() {
320
+ this.signal.throwIfAborted();
321
+ return this.#initialization ??= (async () => {
322
+ this.#target = await this.source.resolve();
323
+ this.signal.throwIfAborted();
324
+ if (!this.source.transports.length || this.source.transports.some((transport) => transport.kind !== "tcp")) {
325
+ throw new Error("Redis requires TCP transports");
326
+ }
327
+ if (this.#target.clusterType === "cluster" && this.#target.database !== 0)
328
+ throw new Error("Redis Cluster requires database 0");
329
+ this.#access = new RedisAccess(this.source.transports, this.#target, undefined, { ...this.lifecycle, signal: this.signal });
330
+ })();
331
+ }
332
+ get access() {
333
+ this.signal.throwIfAborted();
334
+ if (!this.#access)
335
+ throw new Error("Redis client is not initialized");
336
+ return this.#access;
337
+ }
338
+ topology() {
339
+ return discoverRedisTopology(this.access, this.target);
340
+ }
341
+ get target() {
342
+ if (!this.#target)
343
+ throw new Error("Redis client is not initialized");
344
+ return this.#target;
345
+ }
346
+ dispose() {
347
+ return this.#disposal ??= (async () => {
348
+ this.#controller.abort(new Error("Redis client disposed"));
349
+ await this.#initialization?.catch(() => {});
350
+ await this.#access?.close();
351
+ })();
352
+ }
353
+ }
354
+ async function openRedis(source, lifecycle = {}) {
355
+ const client = new RedisClient(source, lifecycle);
356
+ try {
357
+ await client.initialize();
358
+ } catch (error) {
359
+ await client.dispose();
360
+ throw error;
361
+ }
362
+ lifecycle.onDispose?.(() => client.dispose());
363
+ return client;
364
+ }
365
+
366
+ export { parseRedisInfo, RedisConnection, RedisAccess, RedisClient, openRedis };
@@ -1,6 +1,7 @@
1
1
  import type { Client } from "../client";
2
2
  import type { ConnectionSource, ClientLifecycle } from "../datasource";
3
- import type { TcpTransport } from "../transport";
3
+ import { type TcpTransport } from "../transport";
4
+ import { type RedisTopologyConfig, type RedisTopology } from "./topology";
4
5
  import { createClient, type RedisClientType } from "@redis/client";
5
6
  export interface RedisEndpoint {
6
7
  host: string;
@@ -17,6 +18,10 @@ export interface RedisConnectionConfig extends RedisCredentials {
17
18
  database: number;
18
19
  useSsl: boolean;
19
20
  timeoutMs: number;
21
+ /** Per-route mapping and handshake budget; defaults to timeoutMs. */
22
+ connectTimeoutMs?: number;
23
+ /** Total acquisition budget; defaults to connectTimeoutMs times the number of routes. */
24
+ acquireTimeoutMs?: number;
20
25
  }
21
26
  export type RedisEndpointMapper = (endpoint: RedisEndpoint) => Promise<RedisMappedEndpoint>;
22
27
  type NodeRedisClient = RedisClientType;
@@ -25,7 +30,10 @@ export interface RedisManagedConnection extends RedisConnectionApi {
25
30
  readonly isReady: boolean;
26
31
  close(): void;
27
32
  }
28
- export type RedisConnectionFactory = (endpoint: RedisEndpoint, mapped: RedisMappedEndpoint, config: RedisConnectionConfig) => Promise<RedisManagedConnection>;
33
+ export type RedisConnectionFactory = (endpoint: RedisEndpoint, mapped: RedisMappedEndpoint, config: RedisConnectionConfig & {
34
+ signal?: AbortSignal;
35
+ connectSignal?: AbortSignal;
36
+ }) => Promise<RedisManagedConnection>;
29
37
  /** Redis INFO wire 文本解析;协议适配留在 infra,领域层只接收结构化字段。 */
30
38
  export declare function parseRedisInfo(raw: string): Record<string, unknown>;
31
39
  export interface RedisConnectionApi {
@@ -44,14 +52,19 @@ export interface RedisAccessApi {
44
52
  connection(endpoint: RedisEndpoint, database: number, credentials?: RedisCredentials): Promise<RedisConnectionApi>;
45
53
  close(): Promise<void>;
46
54
  }
55
+ /** @spec A command is sent once; a later operation may acquire a new connection after failure. */
47
56
  export declare class RedisConnection implements RedisManagedConnection {
48
57
  #private;
49
58
  readonly endpoint: RedisEndpoint;
50
59
  private readonly client;
51
60
  private readonly timeoutMs;
52
61
  private readonly lastError;
62
+ private readonly signal?;
53
63
  private constructor();
54
- static connect(endpoint: RedisEndpoint, mapped: RedisMappedEndpoint, config: RedisConnectionConfig, clientFactory?: NodeRedisClientFactory): Promise<RedisConnection>;
64
+ static connect(endpoint: RedisEndpoint, mapped: RedisMappedEndpoint, config: RedisConnectionConfig & {
65
+ signal?: AbortSignal;
66
+ connectSignal?: AbortSignal;
67
+ }, clientFactory?: NodeRedisClientFactory): Promise<RedisConnection>;
55
68
  get isReady(): boolean;
56
69
  ping(): Promise<string>;
57
70
  info(section?: string): Promise<Record<string, unknown>>;
@@ -64,25 +77,31 @@ export declare class RedisConnection implements RedisManagedConnection {
64
77
  }>;
65
78
  close(): void;
66
79
  }
80
+ /**
81
+ * @spec Only connection establishment can advance to another transport; protocol operations are never replayed.
82
+ * @why Every topology node needs the same route policy, credentials, deadline and root cancellation.
83
+ */
67
84
  export declare class RedisAccess implements RedisAccessApi {
68
85
  #private;
69
- private readonly transport;
70
86
  private readonly baseConfig;
71
87
  private readonly connectionFactory;
72
- constructor(transport: TcpTransport, baseConfig: Omit<RedisConnectionConfig, "database">, connectionFactory?: RedisConnectionFactory);
88
+ private readonly lifecycle;
89
+ constructor(transports: TcpTransport | readonly TcpTransport[], baseConfig: Omit<RedisConnectionConfig, "database">, connectionFactory?: RedisConnectionFactory, lifecycle?: ClientLifecycle);
73
90
  connection(endpoint: RedisEndpoint, database: number, credentials?: RedisCredentials): Promise<RedisManagedConnection>;
74
91
  close(): Promise<void>;
75
92
  }
76
- export interface RedisDataSourceTarget extends RedisConnectionConfig {
77
- endpoints: RedisEndpoint[];
78
- }
93
+ /** Connection configuration includes topology; diagnostics do not need a second target model. */
94
+ export type RedisDataSourceTarget = RedisConnectionConfig & RedisTopologyConfig;
79
95
  export declare class RedisClient implements Client {
80
96
  #private;
81
97
  private readonly source;
98
+ private readonly lifecycle;
82
99
  readonly signal: AbortSignal;
83
100
  constructor(source: ConnectionSource<RedisDataSourceTarget>, lifecycle?: ClientLifecycle);
84
101
  initialize(): Promise<void>;
85
102
  get access(): RedisAccess;
103
+ /** Refresh topology for this observation, while retaining reusable node connections. */
104
+ topology(): Promise<RedisTopology>;
86
105
  get target(): RedisDataSourceTarget;
87
106
  dispose(): Promise<void>;
88
107
  }
@@ -4,7 +4,15 @@ import {
4
4
  RedisConnection,
5
5
  openRedis,
6
6
  parseRedisInfo
7
- } from "../chunk-ajhdd4hp.js";
7
+ } from "../chunk-yp6tcw22.js";
8
+ import"../chunk-5tbzf9wd.js";
9
+ import"../chunk-20kv4wc3.js";
10
+ import"../chunk-h8fwx0k7.js";
11
+ import"../chunk-wv4jwhvj.js";
12
+ import"../chunk-631vce5z.js";
13
+ import"../chunk-hmah9t8e.js";
14
+ import"../chunk-mn6dwsvh.js";
15
+ import"../chunk-ssj93brx.js";
8
16
  export {
9
17
  parseRedisInfo,
10
18
  openRedis,
@@ -2,16 +2,23 @@ import {
2
2
  collectRedisKeyStats,
3
3
  redisGroupedSampleSizes
4
4
  } from "../chunk-jr9y0ghr.js";
5
- import {
6
- discoverRedisTopology
7
- } from "../chunk-c6z919qb.js";
8
5
  import {
9
6
  RedisAccess,
10
7
  RedisClient,
11
8
  RedisConnection,
12
9
  openRedis,
13
10
  parseRedisInfo
14
- } from "../chunk-ajhdd4hp.js";
11
+ } from "../chunk-yp6tcw22.js";
12
+ import {
13
+ discoverRedisTopology
14
+ } from "../chunk-5tbzf9wd.js";
15
+ import"../chunk-20kv4wc3.js";
16
+ import"../chunk-h8fwx0k7.js";
17
+ import"../chunk-wv4jwhvj.js";
18
+ import"../chunk-631vce5z.js";
19
+ import"../chunk-hmah9t8e.js";
20
+ import"../chunk-mn6dwsvh.js";
21
+ import"../chunk-ssj93brx.js";
15
22
  export {
16
23
  redisGroupedSampleSizes,
17
24
  parseRedisInfo,
@@ -1,13 +1,16 @@
1
1
  import type { RedisAccessApi, RedisCredentials, RedisEndpoint } from "./client";
2
- export interface RedisTopologyConfig extends RedisCredentials {
2
+ /** Endpoints are standalone/cluster seeds or Sentinel seeds according to clusterType. */
3
+ export type RedisTopologyConfig = RedisCredentials & {
3
4
  endpoints: RedisEndpoint[];
4
5
  database: number;
5
- clusterType: "single" | "sentinel" | "cluster";
6
- sentinelHosts: RedisEndpoint[];
6
+ } & ({
7
+ clusterType: "single" | "cluster";
8
+ } | {
9
+ clusterType: "sentinel";
7
10
  sentinelMasterName: string;
8
11
  sentinelUsername?: string;
9
12
  sentinelPassword?: string;
10
- }
13
+ });
11
14
  export interface RedisSlotRange {
12
15
  start: number;
13
16
  end: number;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  discoverRedisTopology
3
- } from "../chunk-c6z919qb.js";
3
+ } from "../chunk-5tbzf9wd.js";
4
4
  export {
5
5
  discoverRedisTopology
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compforge/harness-toolbox",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,267 +0,0 @@
1
- // src/redis/client.ts
2
- import { createClient } from "@redis/client";
3
- function stringValue(value) {
4
- if (Buffer.isBuffer(value))
5
- return value.toString("utf8");
6
- return String(value ?? "");
7
- }
8
- function scalar(value) {
9
- if (/^-?(?:\d+|\d+\.\d+)$/.test(value))
10
- return Number(value);
11
- return value;
12
- }
13
- function parseRedisInfo(raw) {
14
- const result = {};
15
- for (const line of raw.split(/\r?\n/)) {
16
- if (!line || line.startsWith("#"))
17
- continue;
18
- const separator = line.indexOf(":");
19
- if (separator < 1)
20
- continue;
21
- const name = line.slice(0, separator);
22
- const value = line.slice(separator + 1);
23
- if (/^db\d+$/.test(name)) {
24
- result[name] = Object.fromEntries(value.split(",").map((item) => {
25
- const index = item.indexOf("=");
26
- return [item.slice(0, index), scalar(item.slice(index + 1))];
27
- }));
28
- } else {
29
- result[name] = scalar(value);
30
- }
31
- }
32
- return result;
33
- }
34
-
35
- class RedisConnection {
36
- endpoint;
37
- client;
38
- timeoutMs;
39
- lastError;
40
- constructor(endpoint, client, timeoutMs, lastError) {
41
- this.endpoint = endpoint;
42
- this.client = client;
43
- this.timeoutMs = timeoutMs;
44
- this.lastError = lastError;
45
- }
46
- static async connect(endpoint, mapped, config, clientFactory = createClient) {
47
- const baseSocket = {
48
- host: mapped.host,
49
- port: mapped.port,
50
- connectTimeout: config.timeoutMs,
51
- reconnectStrategy: (retries) => retries >= 2 ? false : 100 * 2 ** retries
52
- };
53
- const socket = config.useSsl ? {
54
- ...baseSocket,
55
- tls: true,
56
- servername: mapped.servername ?? endpoint.host
57
- } : {
58
- ...baseSocket,
59
- tls: false
60
- };
61
- const client = clientFactory({
62
- socket,
63
- database: config.database,
64
- username: config.username,
65
- password: config.password,
66
- commandOptions: { timeout: config.timeoutMs }
67
- });
68
- let lastError;
69
- client.on("error", (err) => {
70
- lastError = err instanceof Error ? err : new Error(String(err));
71
- });
72
- try {
73
- await client.connect();
74
- } catch (error) {
75
- if (client.isOpen)
76
- client.destroy();
77
- throw error;
78
- }
79
- return new RedisConnection(endpoint, client, config.timeoutMs, () => lastError);
80
- }
81
- get isReady() {
82
- return this.client.isReady;
83
- }
84
- async#execute(operationName, operation) {
85
- try {
86
- return await this.#executeOnce(operationName, operation);
87
- } catch (err) {
88
- if (this.client.isOpen && !this.client.isReady)
89
- return this.#executeOnce(operationName, operation);
90
- throw err;
91
- }
92
- }
93
- async#executeOnce(operationName, operation) {
94
- if (!this.client.isOpen) {
95
- const reason = this.lastError()?.message;
96
- throw new Error(`Redis connection is closed${reason ? `: ${reason}` : ""}`);
97
- }
98
- let timer;
99
- const deadline = new Promise((_, reject) => {
100
- timer = setTimeout(() => {
101
- reject(new Error(`Redis ${operationName} timed out after ${this.timeoutMs}ms (${this.endpoint.host}:${this.endpoint.port})`));
102
- this.close();
103
- }, this.timeoutMs);
104
- });
105
- try {
106
- return await Promise.race([Promise.resolve().then(operation), deadline]);
107
- } finally {
108
- if (timer)
109
- clearTimeout(timer);
110
- }
111
- }
112
- ping() {
113
- return this.#execute("PING", () => this.client.ping());
114
- }
115
- async info(section) {
116
- const raw = await this.#execute(section ? `INFO ${section}` : "INFO", () => section ? this.client.sendCommand(["INFO", section]) : this.client.sendCommand(["INFO"]));
117
- return parseRedisInfo(stringValue(raw));
118
- }
119
- async dbSize() {
120
- return Number(await this.#execute("DBSIZE", () => this.client.sendCommand(["DBSIZE"])));
121
- }
122
- command(args) {
123
- return this.#execute(args[0]?.toUpperCase() ?? "command", () => this.client.sendCommand(args));
124
- }
125
- async pipeline(commands) {
126
- const commandNames = new Set(commands.map((command) => command[0]?.toUpperCase()).filter(Boolean));
127
- const operationName = commandNames.size === 1 ? `${[...commandNames][0]} pipeline` : "pipeline";
128
- return await this.#execute(operationName, () => {
129
- const pipeline = this.client.multi();
130
- for (const command of commands)
131
- pipeline.addCommand(command);
132
- return pipeline.execAsPipeline();
133
- });
134
- }
135
- async scan(cursor, count) {
136
- const reply = await this.command(["SCAN", cursor, "COUNT", String(count)]);
137
- if (!Array.isArray(reply) || !Array.isArray(reply[1]))
138
- throw new Error("Redis SCAN 返回格式无效");
139
- return { cursor: stringValue(reply[0]), keys: reply[1].map(stringValue) };
140
- }
141
- close() {
142
- if (this.client.isOpen)
143
- this.client.destroy();
144
- }
145
- }
146
-
147
- class RedisAccess {
148
- transport;
149
- baseConfig;
150
- connectionFactory;
151
- #connections = new Map;
152
- #closing;
153
- constructor(transport, baseConfig, connectionFactory = RedisConnection.connect) {
154
- this.transport = transport;
155
- this.baseConfig = baseConfig;
156
- this.connectionFactory = connectionFactory;
157
- }
158
- async connection(endpoint, database, credentials = this.baseConfig) {
159
- if (this.#closing)
160
- throw new Error("Redis access is closed");
161
- const key = [
162
- endpoint.host,
163
- endpoint.port,
164
- database,
165
- credentials.username ?? "",
166
- credentials.password ?? ""
167
- ].join("\x00");
168
- let pending = this.#connections.get(key);
169
- if (pending) {
170
- const connection = await pending;
171
- if (this.#closing)
172
- throw new Error("Redis access is closed");
173
- if (connection.isReady)
174
- return connection;
175
- if (this.#connections.get(key) === pending) {
176
- this.#connections.delete(key);
177
- connection.close();
178
- } else {
179
- return this.connection(endpoint, database, credentials);
180
- }
181
- }
182
- pending = this.transport.connect(endpoint).then((mapped) => this.connectionFactory(endpoint, mapped, {
183
- ...this.baseConfig,
184
- ...credentials,
185
- database
186
- }));
187
- this.#connections.set(key, pending);
188
- pending.catch(() => {
189
- if (this.#connections.get(key) === pending)
190
- this.#connections.delete(key);
191
- });
192
- return await pending;
193
- }
194
- close() {
195
- return this.#closing ??= (async () => {
196
- const settled = await Promise.allSettled(this.#connections.values());
197
- this.#connections.clear();
198
- const errors = [];
199
- for (const result of settled)
200
- if (result.status === "fulfilled") {
201
- try {
202
- result.value.close();
203
- } catch (error) {
204
- errors.push(error);
205
- }
206
- }
207
- if (errors.length)
208
- throw new AggregateError(errors, "Redis connection cleanup failed");
209
- })();
210
- }
211
- }
212
-
213
- class RedisClient {
214
- source;
215
- #controller = new AbortController;
216
- signal;
217
- #initialization;
218
- #disposal;
219
- #access;
220
- #target;
221
- constructor(source, lifecycle = {}) {
222
- this.source = source;
223
- this.signal = lifecycle.signal ? AbortSignal.any([lifecycle.signal, this.#controller.signal]) : this.#controller.signal;
224
- }
225
- initialize() {
226
- this.signal.throwIfAborted();
227
- return this.#initialization ??= (async () => {
228
- this.#target = await this.source.resolve();
229
- this.signal.throwIfAborted();
230
- const transport = this.source.transports[0];
231
- if (!transport || transport.kind !== "tcp")
232
- throw new Error("Redis requires a TCP transport");
233
- this.#access = new RedisAccess(transport, this.#target);
234
- })();
235
- }
236
- get access() {
237
- this.signal.throwIfAborted();
238
- if (!this.#access)
239
- throw new Error("Redis client is not initialized");
240
- return this.#access;
241
- }
242
- get target() {
243
- if (!this.#target)
244
- throw new Error("Redis client is not initialized");
245
- return this.#target;
246
- }
247
- dispose() {
248
- return this.#disposal ??= (async () => {
249
- this.#controller.abort(new Error("Redis client disposed"));
250
- await this.#initialization?.catch(() => {});
251
- await this.#access?.close();
252
- })();
253
- }
254
- }
255
- async function openRedis(source, lifecycle = {}) {
256
- const client = new RedisClient(source, lifecycle);
257
- try {
258
- await client.initialize();
259
- } catch (error) {
260
- await client.dispose();
261
- throw error;
262
- }
263
- lifecycle.onDispose?.(() => client.dispose());
264
- return client;
265
- }
266
-
267
- export { parseRedisInfo, RedisConnection, RedisAccess, RedisClient, openRedis };