@h-ai/cache 0.1.0-alpha.67 → 0.1.0-alpha.68

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/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
+ import process from 'process';
2
3
  import { core, err, ok } from '@h-ai/core';
3
4
  import Redis from 'ioredis';
5
+ import { Buffer } from 'buffer';
6
+ import { createHash } from 'crypto';
4
7
 
5
8
  // src/cache-config.ts
6
9
  var RedisClusterNodeSchema = z.object({
@@ -27,7 +30,7 @@ var RedisConfigSchema = z.object({
27
30
  host: z.string().default("localhost"),
28
31
  /** 端口号;默认 6379 */
29
32
  port: z.number().int().min(1).max(65535).default(6379),
30
- /** 认证密码 */
33
+ /** 单机、Cluster 和 Sentinel 数据节点的认证密码;url 模式使用 URL 内凭据。 */
31
34
  password: z.string().optional(),
32
35
  /** 数据库编号(0~15);默认 0 */
33
36
  db: z.number().int().min(0).max(15).default(0),
@@ -68,7 +71,36 @@ var en_US_default = {
68
71
  cache_keyNotFound: "Key not found",
69
72
  cache_indexOutOfRange: "Index out of range",
70
73
  cache_configError: "Cache config validation failed: {error}",
71
- cache_invalidHashSetInput: "Invalid hash hset input: pass a field and value, or a field object"
74
+ cache_invalidHashSetInput: "Invalid hash hset input: pass a field and value, or a field object",
75
+ cache_invalidScan: "Invalid scan cursor or options",
76
+ cache_scanTopologyChanged: "Scan topology or match changed; restart from 0",
77
+ cache_crossSlot: "This operation requires keys in the same Redis Cluster slot; use a shared hash tag",
78
+ cache_sharedBackendRequired: "Production mode requires a shared backend; local files and memory backends are for development or explicit end-to-end tests.",
79
+ cache_invalidSetOptions: "Invalid SET options: use one positive safe-integer expiry, do not combine it with keepTtl, and choose at most one of nx/xx",
80
+ cache_setUnexpectedReply: "Redis returned an unexpected SET result",
81
+ cache_serializationFailed: "The cache value could not be serialized as JSON"
82
+ };
83
+
84
+ // messages/ja-JP.json
85
+ var ja_JP_default = {
86
+ $schema: "https://inlang.com/schema/inlang-message-format",
87
+ cache_notInitialized: "\u30AD\u30E3\u30C3\u30B7\u30E5\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306Bcache.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
88
+ cache_operationFailed: "\u30AD\u30E3\u30C3\u30B7\u30E5\u64CD\u4F5C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
89
+ cache_redisConnectionFailed: "Redis\u3078\u306E\u63A5\u7D9A\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
90
+ cache_initFailed: "\u30AD\u30E3\u30C3\u30B7\u30E5\u306E\u521D\u671F\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
91
+ cache_unsupportedType: "\u672A\u5BFE\u5FDC\u306E\u30AD\u30E3\u30C3\u30B7\u30E5\u306E\u7A2E\u985E\u3067\u3059: {type}",
92
+ cache_valueNotNumber: "\u5024\u304C\u6570\u5024\u3067\u306F\u3042\u308A\u307E\u305B\u3093",
93
+ cache_keyNotFound: "\u30AD\u30FC\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093",
94
+ cache_indexOutOfRange: "\u30A4\u30F3\u30C7\u30C3\u30AF\u30B9\u304C\u7BC4\u56F2\u5916\u3067\u3059",
95
+ cache_configError: "\u30AD\u30E3\u30C3\u30B7\u30E5\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
96
+ cache_invalidHashSetInput: "\u30CF\u30C3\u30B7\u30E5\u306Ehset\u5165\u529B\u304C\u7121\u52B9\u3067\u3059\u3002\u30D5\u30A3\u30FC\u30EB\u30C9\u3068\u5024\u3001\u307E\u305F\u306F\u30D5\u30A3\u30FC\u30EB\u30C9\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3092\u6E21\u3057\u3066\u304F\u3060\u3055\u3044",
97
+ cache_invalidScan: "\u30B9\u30AD\u30E3\u30F3\u30AB\u30FC\u30BD\u30EB\u307E\u305F\u306F\u30AA\u30D7\u30B7\u30E7\u30F3\u304C\u7121\u52B9\u3067\u3059",
98
+ cache_scanTopologyChanged: "\u30B9\u30AD\u30E3\u30F3\u306E\u30C8\u30DD\u30ED\u30B8\u30FC\u307E\u305F\u306F\u4E00\u81F4\u6761\u4EF6\u304C\u5909\u66F4\u3055\u308C\u307E\u3057\u305F\u30020 \u304B\u3089\u518D\u958B\u3057\u3066\u304F\u3060\u3055\u3044",
99
+ cache_crossSlot: "\u3053\u306E\u64CD\u4F5C\u306E\u30AD\u30FC\u306F\u540C\u3058 Redis Cluster \u30B9\u30ED\u30C3\u30C8\u306B\u914D\u7F6E\u3059\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002\u5171\u901A\u306E\u30CF\u30C3\u30B7\u30E5\u30BF\u30B0\u3092\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044",
100
+ cache_sharedBackendRequired: "\u672C\u756A\u74B0\u5883\u306B\u306F\u5171\u6709\u30D0\u30C3\u30AF\u30A8\u30F3\u30C9\u304C\u5FC5\u8981\u3067\u3059\u3002\u30ED\u30FC\u30AB\u30EB\u30D5\u30A1\u30A4\u30EB\u3068\u30E1\u30E2\u30EA\u30FC\u30D0\u30C3\u30AF\u30A8\u30F3\u30C9\u306F\u958B\u767A\u74B0\u5883\u307E\u305F\u306F\u660E\u793A\u7684\u306A E2E \u30C6\u30B9\u30C8\u3067\u306E\u307F\u4F7F\u7528\u3067\u304D\u307E\u3059\u3002",
101
+ cache_invalidSetOptions: "SET \u30AA\u30D7\u30B7\u30E7\u30F3\u304C\u7121\u52B9\u3067\u3059\u3002\u6709\u52B9\u671F\u9650\u306B\u306F\u6B63\u306E\u5B89\u5168\u306A\u6574\u6570\u30921\u7A2E\u985E\u3060\u3051\u6307\u5B9A\u3057\u3001keepTtl \u3068\u4F75\u7528\u3057\u306A\u3044\u3067\u304F\u3060\u3055\u3044\u3002nx \u3068 xx \u306F\u540C\u6642\u306B\u6307\u5B9A\u3067\u304D\u307E\u305B\u3093",
102
+ cache_setUnexpectedReply: "Redis \u304C\u4E88\u671F\u3057\u306A\u3044 SET \u306E\u7D50\u679C\u3092\u8FD4\u3057\u307E\u3057\u305F",
103
+ cache_serializationFailed: "\u30AD\u30E3\u30C3\u30B7\u30E5\u5024\u3092 JSON \u306B\u30B7\u30EA\u30A2\u30E9\u30A4\u30BA\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F"
72
104
  };
73
105
 
74
106
  // messages/zh-CN.json
@@ -83,13 +115,21 @@ var zh_CN_default = {
83
115
  cache_keyNotFound: "\u952E\u4E0D\u5B58\u5728",
84
116
  cache_indexOutOfRange: "\u7D22\u5F15\u8D8A\u754C",
85
117
  cache_configError: "\u7F13\u5B58\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A{error}",
86
- cache_invalidHashSetInput: "Hash hset \u53C2\u6570\u65E0\u6548\uFF1A\u8BF7\u4F20\u5165\u5B57\u6BB5\u548C\u503C\uFF0C\u6216\u4F20\u5165\u5B57\u6BB5\u5BF9\u8C61"
118
+ cache_invalidHashSetInput: "Hash hset \u53C2\u6570\u65E0\u6548\uFF1A\u8BF7\u4F20\u5165\u5B57\u6BB5\u548C\u503C\uFF0C\u6216\u4F20\u5165\u5B57\u6BB5\u5BF9\u8C61",
119
+ cache_invalidScan: "\u626B\u63CF\u6E38\u6807\u6216\u53C2\u6570\u65E0\u6548",
120
+ cache_scanTopologyChanged: "\u626B\u63CF\u62D3\u6251\u6216\u5339\u914D\u89C4\u5219\u5DF2\u6539\u53D8\uFF0C\u8BF7\u4ECE0\u91CD\u65B0\u5F00\u59CB",
121
+ cache_crossSlot: "\u6B64\u64CD\u4F5C\u8981\u6C42\u6240\u6709\u952E\u4F4D\u4E8E\u540C\u4E00Redis Cluster slot\uFF0C\u8BF7\u4F7F\u7528\u5171\u540Chash tag",
122
+ cache_sharedBackendRequired: "\u751F\u4EA7\u6A21\u5F0F\u9700\u8981\u8DE8\u8282\u70B9\u5171\u4EAB\u540E\u7AEF\uFF1B\u672C\u673A\u6587\u4EF6\u6216\u5185\u5B58\u540E\u7AEF\u4EC5\u7528\u4E8E\u5F00\u53D1\u548C\u663E\u5F0F\u7AEF\u5230\u7AEF\u6D4B\u8BD5\u3002",
123
+ cache_invalidSetOptions: "SET \u9009\u9879\u65E0\u6548\uFF1A\u671F\u9650\u987B\u4E3A\u6B63\u5B89\u5168\u6574\u6570\u4E14\u53EA\u80FD\u9009\u4E00\u79CD\uFF0C\u4E0D\u80FD\u4E0E keepTtl \u540C\u65F6\u4F7F\u7528\uFF0Cnx/xx \u4E0D\u80FD\u540C\u65F6\u542F\u7528",
124
+ cache_setUnexpectedReply: "Redis \u8FD4\u56DE\u4E86\u975E\u9884\u671F\u7684 SET \u7ED3\u679C",
125
+ cache_serializationFailed: "\u7F13\u5B58\u503C\u65E0\u6CD5\u5E8F\u5217\u5316\u4E3A JSON"
87
126
  };
88
127
 
89
128
  // src/cache-i18n.ts
90
129
  var cacheM = core.i18n.createMessageGetter({
91
130
  "zh-CN": zh_CN_default,
92
- "en-US": en_US_default
131
+ "en-US": en_US_default,
132
+ "ja-JP": ja_JP_default
93
133
  });
94
134
  var CacheErrorInfo = {
95
135
  CONNECTION_FAILED: "001:500",
@@ -100,9 +140,44 @@ var CacheErrorInfo = {
100
140
  TIMEOUT: "006:504",
101
141
  NOT_INITIALIZED: "010:500",
102
142
  UNSUPPORTED_TYPE: "011:400",
103
- CONFIG_ERROR: "012:500"
143
+ CONFIG_ERROR: "012:500",
144
+ INVALID_SCAN: "013:400",
145
+ SCAN_TOPOLOGY_CHANGED: "014:409",
146
+ CROSS_SLOT: "015:400",
147
+ INVALID_SET_OPTIONS: "016:400"
104
148
  };
105
149
  var HaiCacheError = core.error.buildHaiErrorsDef("cache", CacheErrorInfo);
150
+ function validateSetOptions(options) {
151
+ const invalid = () => err(HaiCacheError.INVALID_SET_OPTIONS, cacheM("cache_invalidSetOptions"));
152
+ if (options !== void 0 && (options === null || typeof options !== "object" || Array.isArray(options)))
153
+ return invalid();
154
+ for (const flag of ["nx", "xx", "keepTtl"]) {
155
+ if (options?.[flag] !== void 0 && typeof options[flag] !== "boolean")
156
+ return invalid();
157
+ }
158
+ if (options?.nx && options.xx)
159
+ return invalid();
160
+ const entries = ["ex", "px", "exat", "pxat"].filter((key) => options?.[key] !== void 0);
161
+ if (entries.length > 1 || entries.length > 0 && options?.keepTtl)
162
+ return invalid();
163
+ const result = { nx: options?.nx === true, xx: options?.xx === true, keepTtl: options?.keepTtl === true };
164
+ const mode = entries[0];
165
+ if (mode) {
166
+ const value = options[mode];
167
+ if (!Number.isSafeInteger(value) || value <= 0)
168
+ return invalid();
169
+ const milliseconds = mode === "ex" || mode === "exat" ? value * 1e3 : value;
170
+ const expiresAt = mode === "ex" || mode === "px" ? Date.now() + milliseconds : milliseconds;
171
+ if (!Number.isSafeInteger(expiresAt))
172
+ return invalid();
173
+ const command = { ex: "EX", px: "PX", exat: "EXAT", pxat: "PXAT" };
174
+ result.expiry = [command[mode], value];
175
+ result.expiresAt = expiresAt;
176
+ }
177
+ return ok(result);
178
+ }
179
+
180
+ // src/providers/cache-provider-memory.ts
106
181
  function serializeValue(value) {
107
182
  return JSON.stringify(value);
108
183
  }
@@ -146,35 +221,58 @@ function createMemoryProvider() {
146
221
  }
147
222
  return entry;
148
223
  }
149
- function calculateExpiry(options) {
150
- if (!options)
151
- return void 0;
152
- const now = Date.now();
153
- if (options.ex)
154
- return now + options.ex * 1e3;
155
- if (options.px)
156
- return now + options.px;
157
- if (options.exat != null)
158
- return options.exat * 1e3;
159
- if (options.pxat != null)
160
- return options.pxat;
161
- return void 0;
162
- }
163
224
  const kv = {
225
+ async compareAndSwap(key, expected, value, options) {
226
+ try {
227
+ if (options?.px !== void 0 && (!Number.isSafeInteger(options.px) || options.px <= 0) || options?.px !== void 0 && options.keepTtl)
228
+ throw new TypeError("Invalid compare-and-swap expiry");
229
+ const entry = getValidEntry(key);
230
+ const actual = entry ? serializeValue(entry.value) : void 0;
231
+ if (actual !== (expected === void 0 ? void 0 : serializeValue(expected)))
232
+ return ok(false);
233
+ if (value === void 0) {
234
+ store.delete(key);
235
+ } else {
236
+ const expiresAt = options?.px !== void 0 ? Date.now() + options.px : options?.keepTtl ? entry?.expiresAt : void 0;
237
+ store.set(key, { value: deserializeValue(serializeValue(value)), expiresAt });
238
+ }
239
+ return ok(true);
240
+ } catch (error) {
241
+ return err(HaiCacheError.OPERATION_FAILED, cacheM("cache_operationFailed", { params: { error: "Compare-and-swap failed" } }), error);
242
+ }
243
+ },
164
244
  async get(key) {
165
- const entry = getValidEntry(key);
166
- return ok(entry ? entry.value : null);
245
+ try {
246
+ const entry = getValidEntry(key);
247
+ return ok(entry ? deserializeValue(serializeValue(entry.value)) : null);
248
+ } catch (error) {
249
+ return err(HaiCacheError.SERIALIZATION_FAILED, cacheM("cache_operationFailed", { params: { error: "JSON snapshot failed" } }), error);
250
+ }
167
251
  },
168
252
  async set(key, value, options) {
169
- const existing = getValidEntry(key) !== null;
170
- if (options?.nx && existing)
171
- return ok(void 0);
172
- if (options?.xx && !existing)
173
- return ok(void 0);
253
+ const validated = validateSetOptions(options);
254
+ if (!validated.success)
255
+ return validated;
256
+ const policy = validated.data;
174
257
  const currentEntry = getValidEntry(key);
175
- const expiresAt = options?.keepTtl && currentEntry ? currentEntry.expiresAt : calculateExpiry(options);
176
- store.set(key, { value, expiresAt });
177
- return ok(void 0);
258
+ const existing = currentEntry !== null || Boolean(hashStore.get(key)?.size || listStore.get(key)?.length || setStore.get(key)?.size || zsetStore.get(key)?.size);
259
+ if (policy.nx && existing || policy.xx && !existing)
260
+ return ok(false);
261
+ try {
262
+ const snapshot = deserializeValue(serializeValue(value));
263
+ const expiresAt = policy.keepTtl ? currentEntry?.expiresAt : policy.expiresAt;
264
+ hashStore.delete(key);
265
+ listStore.delete(key);
266
+ setStore.delete(key);
267
+ zsetStore.delete(key);
268
+ if (expiresAt !== void 0 && expiresAt <= Date.now())
269
+ store.delete(key);
270
+ else
271
+ store.set(key, { value: snapshot, expiresAt });
272
+ return ok(true);
273
+ } catch (error) {
274
+ return err(HaiCacheError.SERIALIZATION_FAILED, cacheM("cache_serializationFailed"), error);
275
+ }
178
276
  },
179
277
  async del(...keys) {
180
278
  let count = 0;
@@ -246,12 +344,16 @@ function createMemoryProvider() {
246
344
  return kv.incrBy(key, -decrement);
247
345
  },
248
346
  async mget(...keys) {
249
- const results = [];
250
- for (const key of keys) {
251
- const entry = getValidEntry(key);
252
- results.push(entry ? entry.value : null);
347
+ try {
348
+ const results = [];
349
+ for (const key of keys) {
350
+ const entry = getValidEntry(key);
351
+ results.push(entry ? deserializeValue(serializeValue(entry.value)) : null);
352
+ }
353
+ return ok(results);
354
+ } catch (error) {
355
+ return err(HaiCacheError.SERIALIZATION_FAILED, cacheM("cache_operationFailed", { params: { error: "JSON snapshot failed" } }), error);
253
356
  }
254
- return ok(results);
255
357
  },
256
358
  async mset(entries) {
257
359
  for (const [key, value] of entries) {
@@ -260,6 +362,8 @@ function createMemoryProvider() {
260
362
  return ok(void 0);
261
363
  },
262
364
  async scan(cursor, options) {
365
+ if (typeof cursor !== "string" || !/^\d{1,20}$/.test(cursor) || BigInt(cursor) > 18446744073709551615n || !Number.isSafeInteger(options?.count ?? 10) || (options?.count ?? 10) <= 0)
366
+ return err(HaiCacheError.INVALID_SCAN, cacheM("cache_invalidScan"));
263
367
  const allKeys = Array.from(store.keys()).filter((key) => {
264
368
  const entry = store.get(key);
265
369
  return entry && !isExpired(entry);
@@ -267,10 +371,10 @@ function createMemoryProvider() {
267
371
  const regex = options?.match ? globToRegex(options.match) : null;
268
372
  const filtered = regex ? allKeys.filter((k) => regex.test(k)) : allKeys;
269
373
  const count = options?.count || 10;
270
- const start = cursor;
374
+ const start = Number(cursor);
271
375
  const end = Math.min(start + count, filtered.length);
272
376
  const keys = filtered.slice(start, end);
273
- const nextCursor = end >= filtered.length ? 0 : end;
377
+ const nextCursor = end >= filtered.length ? "0" : String(end);
274
378
  return ok([nextCursor, keys]);
275
379
  },
276
380
  async keys(pattern) {
@@ -754,16 +858,16 @@ function createMemoryProvider() {
754
858
  async acquire(key, options) {
755
859
  const lockKey = `${LOCK_PREFIX}${key}`;
756
860
  const ttl = options?.ttl ?? 30;
757
- const owner = options?.owner ?? "default";
861
+ const owner = core.id.uuid();
758
862
  const existing = getValidEntry(lockKey);
759
863
  if (existing) {
760
- return ok(false);
864
+ return ok(null);
761
865
  }
762
866
  store.set(lockKey, {
763
867
  value: owner,
764
868
  expiresAt: Date.now() + ttl * 1e3
765
869
  });
766
- return ok(true);
870
+ return ok(owner);
767
871
  },
768
872
  async release(key, owner) {
769
873
  const lockKey = `${LOCK_PREFIX}${key}`;
@@ -771,7 +875,7 @@ function createMemoryProvider() {
771
875
  if (!entry) {
772
876
  return ok(false);
773
877
  }
774
- if (owner !== void 0 && entry.value !== owner) {
878
+ if (!owner || entry.value !== owner) {
775
879
  return ok(false);
776
880
  }
777
881
  store.delete(lockKey);
@@ -788,7 +892,7 @@ function createMemoryProvider() {
788
892
  if (!entry) {
789
893
  return ok(false);
790
894
  }
791
- if (owner !== void 0 && entry.value !== owner) {
895
+ if (!owner || entry.value !== owner) {
792
896
  return ok(false);
793
897
  }
794
898
  entry.expiresAt = Date.now() + ttl * 1e3;
@@ -828,19 +932,104 @@ function createMemoryProvider() {
828
932
  }
829
933
  };
830
934
  }
831
- var logger = core.logger.child({ module: "cache", scope: "redis" });
832
- function sanitizeRedisUrl(url) {
833
- try {
834
- const u = new URL(url);
835
- if (u.password)
836
- u.password = "***";
837
- if (u.username)
838
- u.username = "***";
839
- return u.toString();
840
- } catch {
841
- return "(invalid url)";
935
+ var RedisScanError = class extends Error {
936
+ constructor(reason) {
937
+ super(`Redis scan ${reason}`);
938
+ this.reason = reason;
939
+ }
940
+ reason;
941
+ };
942
+ var PositionSchema = z.string().regex(/^\d{1,20}$/).refine((value) => BigInt(value) <= 18446744073709551615n);
943
+ var ClusterCursorSchema = z.object({
944
+ topology: z.string().regex(/^[a-f0-9]{64}$/),
945
+ node: z.number().int().nonnegative(),
946
+ position: PositionSchema
947
+ });
948
+ function sortedMasters(client) {
949
+ return client.nodes("master").sort((left, right) => {
950
+ const a = `${left.options.host}:${left.options.port}`;
951
+ const b = `${right.options.host}:${right.options.port}`;
952
+ return a < b ? -1 : a > b ? 1 : 0;
953
+ });
954
+ }
955
+ function escapeGlob(prefix) {
956
+ return prefix.replace(/[\\*?[\]]/g, "\\$&");
957
+ }
958
+ async function scanRedisKeys(client, cursor, options = {}) {
959
+ const prefix = client.options.keyPrefix ?? ("redisOptions" in client.options ? client.options.redisOptions?.keyPrefix : "") ?? "";
960
+ const count = options.count ?? 10;
961
+ if (!Number.isSafeInteger(count) || count <= 0 || typeof cursor !== "string" || cursor.length > 1024)
962
+ throw new RedisScanError("invalid");
963
+ const match = options.match ?? "*";
964
+ const pattern = `${escapeGlob(prefix)}${match}`;
965
+ let node;
966
+ let position = cursor;
967
+ let state;
968
+ let masters = [];
969
+ if ("nodes" in client) {
970
+ masters = sortedMasters(client);
971
+ const topology = createHash("sha256").update(JSON.stringify({
972
+ nodes: masters.map((master) => [master.options.host, master.options.port]),
973
+ // slot 所有者变化也使旧游标失效,避免无提示地跳过迁移数据。
974
+ slots: client.slots.map((nodes) => nodes[0]),
975
+ prefix,
976
+ match
977
+ })).digest("hex");
978
+ if (cursor === "0") {
979
+ state = { topology, node: 0, position: "0" };
980
+ } else {
981
+ if (!cursor.startsWith("c1."))
982
+ throw new RedisScanError("invalid");
983
+ try {
984
+ state = ClusterCursorSchema.parse(JSON.parse(Buffer.from(cursor.slice(3), "base64url").toString("utf8")));
985
+ } catch {
986
+ throw new RedisScanError("invalid");
987
+ }
988
+ if (state.topology !== topology)
989
+ throw new RedisScanError("topology");
990
+ }
991
+ if (masters.length === 0 || state.node >= masters.length)
992
+ throw new RedisScanError("topology");
993
+ node = masters[state.node];
994
+ position = state.position;
995
+ } else {
996
+ if (!PositionSchema.safeParse(cursor).success)
997
+ throw new RedisScanError("invalid");
998
+ node = client;
999
+ }
1000
+ const [nextPosition, keys] = await node.scan(position, "MATCH", pattern, "COUNT", count);
1001
+ const logicalKeys = keys.filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length));
1002
+ if (!state)
1003
+ return [nextPosition, logicalKeys];
1004
+ if (nextPosition === "0") {
1005
+ state.node++;
1006
+ state.position = "0";
1007
+ if (state.node >= masters.length)
1008
+ return ["0", logicalKeys];
1009
+ } else {
1010
+ state.position = nextPosition;
842
1011
  }
1012
+ return [`c1.${Buffer.from(JSON.stringify(state)).toString("base64url")}`, logicalKeys];
843
1013
  }
1014
+
1015
+ // src/providers/cache-provider-redis.ts
1016
+ var logger = core.logger.child({ module: "cache", scope: "redis" });
1017
+ var COMPARE_AND_SWAP_SCRIPT = `
1018
+ local current = redis.call('get', KEYS[1])
1019
+ if ARGV[1] == 'missing' then
1020
+ if current then return 0 end
1021
+ elseif current ~= ARGV[2] then return 0 end
1022
+ if ARGV[3] == 'delete' then
1023
+ redis.call('del', KEYS[1])
1024
+ elseif ARGV[5] == 'keep' then
1025
+ redis.call('set', KEYS[1], ARGV[4], 'KEEPTTL')
1026
+ elseif ARGV[5] == 'px' then
1027
+ redis.call('set', KEYS[1], ARGV[4], 'PX', ARGV[6])
1028
+ else
1029
+ redis.call('set', KEYS[1], ARGV[4])
1030
+ end
1031
+ return 1
1032
+ `;
844
1033
  var RELEASE_LOCK_SCRIPT = `
845
1034
  if redis.call("get", KEYS[1]) == ARGV[1] then
846
1035
  return redis.call("del", KEYS[1])
@@ -857,6 +1046,30 @@ var EXTEND_LOCK_SCRIPT = `
857
1046
  `;
858
1047
  function createRedisProvider() {
859
1048
  let client = null;
1049
+ let ready = false;
1050
+ let closing = null;
1051
+ function closeClient() {
1052
+ const graceful = ready && client?.status === "ready";
1053
+ ready = false;
1054
+ if (closing)
1055
+ return closing;
1056
+ const owned = client;
1057
+ if (!owned)
1058
+ return Promise.resolve();
1059
+ closing = (async () => {
1060
+ try {
1061
+ if (graceful)
1062
+ await owned.quit();
1063
+ } catch (error) {
1064
+ logger.warn("Redis graceful shutdown failed; disconnecting client", { error });
1065
+ }
1066
+ owned.disconnect();
1067
+ client = null;
1068
+ })().finally(() => {
1069
+ closing = null;
1070
+ });
1071
+ return closing;
1072
+ }
860
1073
  function serialize(value) {
861
1074
  return JSON.stringify(value);
862
1075
  }
@@ -870,7 +1083,7 @@ function createRedisProvider() {
870
1083
  }
871
1084
  }
872
1085
  async function wrapOperation(operation) {
873
- if (!client) {
1086
+ if (!ready || !client) {
874
1087
  return err(
875
1088
  HaiCacheError.NOT_INITIALIZED,
876
1089
  cacheM("cache_notInitialized")
@@ -880,6 +1093,11 @@ function createRedisProvider() {
880
1093
  const result = await operation();
881
1094
  return ok(result);
882
1095
  } catch (error) {
1096
+ if (error instanceof RedisScanError) {
1097
+ return error.reason === "topology" ? err(HaiCacheError.SCAN_TOPOLOGY_CHANGED, cacheM("cache_scanTopologyChanged"), error) : err(HaiCacheError.INVALID_SCAN, cacheM("cache_invalidScan"), error);
1098
+ }
1099
+ if (error instanceof Error && error.message.startsWith("CROSSSLOT"))
1100
+ return err(HaiCacheError.CROSS_SLOT, cacheM("cache_crossSlot"), error);
883
1101
  return err(
884
1102
  HaiCacheError.OPERATION_FAILED,
885
1103
  cacheM("cache_operationFailed", { params: { error: error instanceof Error ? error.message : String(error) } }),
@@ -890,7 +1108,20 @@ function createRedisProvider() {
890
1108
  function isRedisOne(result) {
891
1109
  return result === 1 || result === "1";
892
1110
  }
1111
+ async function mapIndependentKeys(keys, operation) {
1112
+ const values = [];
1113
+ for (let offset = 0; offset < keys.length; offset += 64)
1114
+ values.push(...await Promise.all(keys.slice(offset, offset + 64).map(operation)));
1115
+ return values;
1116
+ }
893
1117
  const kv = {
1118
+ async compareAndSwap(key, expected, value, options) {
1119
+ return wrapOperation(async () => {
1120
+ if (options?.px !== void 0 && (!Number.isSafeInteger(options.px) || options.px <= 0) || options?.px !== void 0 && options.keepTtl)
1121
+ throw new TypeError("Invalid compare-and-swap expiry");
1122
+ return isRedisOne(await client.eval(COMPARE_AND_SWAP_SCRIPT, 1, key, expected === void 0 ? "missing" : "value", expected === void 0 ? "" : serialize(expected), value === void 0 ? "delete" : "value", value === void 0 ? "" : serialize(value), options?.keepTtl ? "keep" : options?.px !== void 0 ? "px" : "none", options?.px ?? 0));
1123
+ });
1124
+ },
894
1125
  async get(key) {
895
1126
  return wrapOperation(async () => {
896
1127
  const value = await client.get(key);
@@ -898,30 +1129,43 @@ function createRedisProvider() {
898
1129
  });
899
1130
  },
900
1131
  async set(key, value, options) {
1132
+ const validated = validateSetOptions(options);
1133
+ if (!validated.success)
1134
+ return validated;
901
1135
  return wrapOperation(async () => {
1136
+ const policy = validated.data;
902
1137
  const args = [key, serialize(value)];
903
- if (options?.ex)
904
- args.push("EX", options.ex);
905
- else if (options?.px)
906
- args.push("PX", options.px);
907
- else if (options?.exat)
908
- args.push("EXAT", options.exat);
909
- else if (options?.pxat)
910
- args.push("PXAT", options.pxat);
911
- if (options?.nx)
1138
+ if (policy.expiry)
1139
+ args.push(...policy.expiry);
1140
+ if (policy.nx)
912
1141
  args.push("NX");
913
- else if (options?.xx)
1142
+ else if (policy.xx)
914
1143
  args.push("XX");
915
- if (options?.keepTtl)
1144
+ if (policy.keepTtl)
916
1145
  args.push("KEEPTTL");
917
- await client.set(...args);
1146
+ const result = await client.call("SET", ...args);
1147
+ if (result !== "OK" && result !== null)
1148
+ throw new Error(cacheM("cache_setUnexpectedReply"));
1149
+ return result === "OK";
918
1150
  });
919
1151
  },
920
1152
  async del(...keys) {
921
- return wrapOperation(() => client.del(...keys));
1153
+ return wrapOperation(async () => {
1154
+ if (keys.length === 0)
1155
+ return 0;
1156
+ if (client instanceof Redis.Cluster)
1157
+ return (await mapIndependentKeys(keys, (key) => client.del(key))).reduce((sum, value) => sum + value, 0);
1158
+ return client.del(...keys);
1159
+ });
922
1160
  },
923
1161
  async exists(...keys) {
924
- return wrapOperation(() => client.exists(...keys));
1162
+ return wrapOperation(async () => {
1163
+ if (keys.length === 0)
1164
+ return 0;
1165
+ if (client instanceof Redis.Cluster)
1166
+ return (await mapIndependentKeys(keys, (key) => client.exists(key))).reduce((sum, value) => sum + value, 0);
1167
+ return client.exists(...keys);
1168
+ });
925
1169
  },
926
1170
  async expire(key, seconds) {
927
1171
  return wrapOperation(async () => {
@@ -958,12 +1202,14 @@ function createRedisProvider() {
958
1202
  },
959
1203
  async mget(...keys) {
960
1204
  return wrapOperation(async () => {
961
- const values = await client.mget(...keys);
1205
+ const values = keys.length === 0 ? [] : client instanceof Redis.Cluster ? await mapIndependentKeys(keys, (key) => client.get(key)) : await client.mget(...keys);
962
1206
  return values.map((v) => deserialize(v));
963
1207
  });
964
1208
  },
965
1209
  async mset(entries) {
966
1210
  return wrapOperation(async () => {
1211
+ if (entries.length === 0)
1212
+ return;
967
1213
  const args = [];
968
1214
  for (const [key, value] of entries) {
969
1215
  args.push(key, serialize(value));
@@ -972,23 +1218,19 @@ function createRedisProvider() {
972
1218
  });
973
1219
  },
974
1220
  async scan(cursor, options) {
975
- return wrapOperation(async () => {
976
- let result;
977
- if (options?.match && options?.count) {
978
- result = await client.scan(cursor, "MATCH", options.match, "COUNT", options.count);
979
- } else if (options?.match) {
980
- result = await client.scan(cursor, "MATCH", options.match);
981
- } else if (options?.count) {
982
- result = await client.scan(cursor, "COUNT", options.count);
983
- } else {
984
- result = await client.scan(cursor);
985
- }
986
- const [nextCursor, keys] = result;
987
- return [Number.parseInt(nextCursor, 10), keys];
988
- });
1221
+ return wrapOperation(() => scanRedisKeys(client, cursor, options));
989
1222
  },
990
1223
  async keys(pattern) {
991
- return wrapOperation(() => client.keys(pattern));
1224
+ return wrapOperation(async () => {
1225
+ let cursor = "0";
1226
+ const keys = /* @__PURE__ */ new Set();
1227
+ do {
1228
+ const page = await scanRedisKeys(client, cursor, { match: pattern, count: 100 });
1229
+ cursor = page[0];
1230
+ for (const key of page[1]) keys.add(key);
1231
+ } while (cursor !== "0");
1232
+ return [...keys];
1233
+ });
992
1234
  },
993
1235
  async type(key) {
994
1236
  return wrapOperation(() => client.type(key));
@@ -1273,20 +1515,18 @@ function createRedisProvider() {
1273
1515
  return wrapOperation(async () => {
1274
1516
  const lockKey = `${LOCK_PREFIX}${key}`;
1275
1517
  const ttl = options?.ttl ?? 30;
1276
- const owner = options?.owner ?? "default";
1518
+ const owner = core.id.uuid();
1277
1519
  const result = await client.set(lockKey, owner, "EX", ttl, "NX");
1278
- return result === "OK";
1520
+ return result === "OK" ? owner : null;
1279
1521
  });
1280
1522
  },
1281
1523
  async release(key, owner) {
1282
1524
  return wrapOperation(async () => {
1283
1525
  const lockKey = `${LOCK_PREFIX}${key}`;
1284
- if (owner !== void 0) {
1285
- const result = await client.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, owner);
1286
- return isRedisOne(result);
1287
- }
1288
- const deleted = await client.del(lockKey);
1289
- return deleted === 1;
1526
+ if (!owner)
1527
+ return false;
1528
+ const result = await client.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, owner);
1529
+ return isRedisOne(result);
1290
1530
  });
1291
1531
  },
1292
1532
  async isLocked(key) {
@@ -1299,12 +1539,10 @@ function createRedisProvider() {
1299
1539
  async extend(key, ttl, owner) {
1300
1540
  return wrapOperation(async () => {
1301
1541
  const lockKey = `${LOCK_PREFIX}${key}`;
1302
- if (owner !== void 0) {
1303
- const result2 = await client.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, owner, ttl);
1304
- return isRedisOne(result2);
1305
- }
1306
- const result = await client.expire(lockKey, ttl);
1307
- return result === 1;
1542
+ if (!owner)
1543
+ return false;
1544
+ const result = await client.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, owner, ttl);
1545
+ return isRedisOne(result);
1308
1546
  });
1309
1547
  }
1310
1548
  };
@@ -1330,7 +1568,9 @@ function createRedisProvider() {
1330
1568
  );
1331
1569
  }
1332
1570
  try {
1571
+ await closeClient();
1333
1572
  const redisOptions = {
1573
+ ...!config.url && config.password !== void 0 ? { password: config.password } : {},
1334
1574
  connectTimeout: config.connectTimeout,
1335
1575
  commandTimeout: config.commandTimeout,
1336
1576
  keyPrefix: config.keyPrefix,
@@ -1350,6 +1590,8 @@ function createRedisProvider() {
1350
1590
  client = new Redis(config.url, redisOptions);
1351
1591
  } else if (config.cluster && config.cluster.length > 0) {
1352
1592
  const clusterOptions = {
1593
+ // Cluster 自身也必须延迟连接,避免构造时与下方显式 connect 竞争。
1594
+ lazyConnect: true,
1353
1595
  redisOptions,
1354
1596
  clusterRetryStrategy: (times) => {
1355
1597
  if (times > config.maxRetries)
@@ -1363,7 +1605,6 @@ function createRedisProvider() {
1363
1605
  ...redisOptions,
1364
1606
  sentinels: config.sentinel.sentinels,
1365
1607
  name: config.sentinel.name,
1366
- password: config.password,
1367
1608
  db: config.db
1368
1609
  });
1369
1610
  } else {
@@ -1371,32 +1612,37 @@ function createRedisProvider() {
1371
1612
  ...redisOptions,
1372
1613
  host: config.host,
1373
1614
  port: config.port,
1374
- password: config.password,
1375
1615
  db: config.db
1376
1616
  });
1377
1617
  }
1618
+ client.on("error", () => logger.warn("Redis client connection error"));
1378
1619
  await client.connect();
1379
1620
  await client.ping();
1380
- const address = config.url ? sanitizeRedisUrl(config.url) : `${config.host}:${config.port}`;
1621
+ ready = true;
1622
+ const address = config.url ? core.sanitize.sanitizeUrl(config.url) : `${config.host}:${config.port}`;
1381
1623
  logger.info("Redis connected", { address });
1382
1624
  return ok(void 0);
1383
1625
  } catch (error) {
1384
- return err(
1626
+ ready = false;
1627
+ let cleanupError;
1628
+ try {
1629
+ client?.disconnect();
1630
+ client = null;
1631
+ } catch (failure) {
1632
+ cleanupError = failure;
1633
+ }
1634
+ const result = err(
1385
1635
  HaiCacheError.CONNECTION_FAILED,
1386
1636
  cacheM("cache_redisConnectionFailed", { params: { error: error instanceof Error ? error.message : String(error) } }),
1387
1637
  error
1388
1638
  );
1639
+ if (cleanupError !== void 0 && !result.success)
1640
+ return err({ ...result.error, ext: { cleanupPending: true, cleanupError } });
1641
+ return result;
1389
1642
  }
1390
1643
  },
1391
- async close() {
1392
- if (client) {
1393
- logger.info("Disconnecting Redis");
1394
- await client.quit();
1395
- client = null;
1396
- logger.info("Redis disconnected");
1397
- }
1398
- },
1399
- isConnected: () => client !== null,
1644
+ close: closeClient,
1645
+ isConnected: () => ready && client !== null && client.status === "ready",
1400
1646
  kv,
1401
1647
  hash,
1402
1648
  list,
@@ -1414,6 +1660,40 @@ var logger2 = core.logger.child({ module: "cache", scope: "main" });
1414
1660
  var currentProvider = null;
1415
1661
  var currentConfig = null;
1416
1662
  var initInProgress = false;
1663
+ var pendingProvider = null;
1664
+ var initCompletion = null;
1665
+ var closingPromise = null;
1666
+ async function closeOwnedProvider() {
1667
+ if (currentProvider) {
1668
+ pendingProvider = currentProvider;
1669
+ currentProvider = null;
1670
+ }
1671
+ currentConfig = null;
1672
+ if (!pendingProvider)
1673
+ return ok(void 0);
1674
+ try {
1675
+ await pendingProvider.close();
1676
+ pendingProvider = null;
1677
+ return ok(void 0);
1678
+ } catch (error) {
1679
+ logger2.error("Provider cleanup failed; retaining resource for retry", { error });
1680
+ return err({ ...HaiCacheError.CONNECTION_FAILED, message: cacheM("cache_operationFailed", {
1681
+ params: { error: error instanceof Error ? error.message : String(error) }
1682
+ }), cause: error, ext: { cleanupPending: true } });
1683
+ }
1684
+ }
1685
+ async function failInitialization(result) {
1686
+ const cleanup = await closeOwnedProvider();
1687
+ if (result.success)
1688
+ return result;
1689
+ if (cleanup.success) {
1690
+ const ext = { ...result.error.ext };
1691
+ delete ext.cleanupPending;
1692
+ delete ext.cleanupError;
1693
+ return err({ ...result.error, ext: Object.keys(ext).length > 0 ? ext : void 0 });
1694
+ }
1695
+ return err({ ...result.error, ext: { ...result.error.ext, cleanupPending: true, cleanupError: cleanup.error } });
1696
+ }
1417
1697
  function createProvider(config) {
1418
1698
  switch (config.type) {
1419
1699
  case "memory":
@@ -1456,7 +1736,7 @@ var cache = {
1456
1736
  * ```
1457
1737
  */
1458
1738
  async init(config) {
1459
- if (initInProgress) {
1739
+ if (initInProgress || closingPromise) {
1460
1740
  logger2.warn("Cache init already in progress, skipping concurrent call");
1461
1741
  return err(
1462
1742
  HaiCacheError.OPERATION_FAILED,
@@ -1464,11 +1744,14 @@ var cache = {
1464
1744
  );
1465
1745
  }
1466
1746
  initInProgress = true;
1747
+ let resolveInit;
1748
+ initCompletion = new Promise((resolve) => {
1749
+ resolveInit = resolve;
1750
+ });
1467
1751
  try {
1468
- if (currentProvider) {
1469
- logger2.warn("Cache module is already initialized, reinitializing");
1470
- await cache.close();
1471
- }
1752
+ const cleanup = await closeOwnedProvider();
1753
+ if (!cleanup.success)
1754
+ return cleanup;
1472
1755
  logger2.info("Initializing cache module");
1473
1756
  const parseResult = CacheConfigSchema.safeParse(config);
1474
1757
  if (!parseResult.success) {
@@ -1480,35 +1763,41 @@ var cache = {
1480
1763
  );
1481
1764
  }
1482
1765
  const parsed = parseResult.data;
1766
+ if (core.module.requiresSharedState(process.env) && parsed.type === "memory")
1767
+ return err(HaiCacheError.CONFIG_ERROR, cacheM("cache_sharedBackendRequired"));
1483
1768
  try {
1484
1769
  const providerResult = createProvider(parsed);
1485
1770
  if (!providerResult.success)
1486
1771
  return err(providerResult.error);
1487
1772
  const provider = providerResult.data;
1773
+ pendingProvider = provider;
1488
1774
  const connectResult = await provider.connect(parsed);
1489
1775
  if (!connectResult.success) {
1490
1776
  logger2.error("Cache module initialization failed", {
1491
1777
  code: connectResult.error.code,
1492
1778
  message: connectResult.error.message
1493
1779
  });
1494
- return connectResult;
1780
+ return await failInitialization(connectResult);
1495
1781
  }
1782
+ pendingProvider = null;
1496
1783
  currentProvider = provider;
1497
1784
  currentConfig = parsed;
1498
1785
  logger2.info("Cache module initialized", { type: parsed.type });
1499
1786
  return ok(void 0);
1500
1787
  } catch (error) {
1501
1788
  logger2.error("Cache module initialization failed", { error });
1502
- return err(
1789
+ return await failInitialization(err(
1503
1790
  HaiCacheError.CONNECTION_FAILED,
1504
1791
  cacheM("cache_initFailed", {
1505
1792
  params: { error: error instanceof Error ? error.message : String(error) }
1506
1793
  }),
1507
1794
  error
1508
- );
1795
+ ));
1509
1796
  }
1510
1797
  } finally {
1511
1798
  initInProgress = false;
1799
+ initCompletion = null;
1800
+ resolveInit();
1512
1801
  }
1513
1802
  },
1514
1803
  /** KV 操作子接口;未 init 时所有方法返回 NOT_INITIALIZED */
@@ -1551,23 +1840,24 @@ var cache = {
1551
1840
  ping() {
1552
1841
  return currentProvider?.ping() ?? Promise.resolve(notInitialized.result());
1553
1842
  },
1554
- /** 关闭缓存连接并释放资源;未初始化时调用安全无副作用 */
1843
+ /**
1844
+ * 停止提供缓存操作并释放资源;等待正在进行的初始化,并合并并发关闭。
1845
+ * @returns 清理失败返回错误并保留资源,后续 close/init 先重试同一资源。
1846
+ * @example
1847
+ * const closed = await cache.close()
1848
+ * if (!closed.success) await cache.close()
1849
+ */
1555
1850
  async close() {
1556
- if (!currentProvider) {
1557
- currentConfig = null;
1558
- logger2.info("Cache module already closed, skipping");
1559
- return;
1560
- }
1561
- logger2.info("Closing cache module");
1562
- try {
1563
- await currentProvider.close();
1564
- logger2.info("Cache module closed");
1565
- } catch (error) {
1566
- logger2.error("Cache module close failed", { error });
1567
- } finally {
1568
- currentProvider = null;
1569
- currentConfig = null;
1851
+ if (!closingPromise) {
1852
+ closingPromise = (async () => {
1853
+ if (initCompletion)
1854
+ await initCompletion;
1855
+ return await closeOwnedProvider();
1856
+ })().finally(() => {
1857
+ closingPromise = null;
1858
+ });
1570
1859
  }
1860
+ return await closingPromise;
1571
1861
  }
1572
1862
  };
1573
1863