@h-ai/cache 0.1.0-alpha.66 → 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/README.en.md +71 -41
- package/README.ja.md +72 -42
- package/README.md +83 -42
- package/dist/index.d.ts +291 -93
- package/dist/index.js +423 -133
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
166
|
-
|
|
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
|
|
170
|
-
if (
|
|
171
|
-
return
|
|
172
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
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 =
|
|
861
|
+
const owner = core.id.uuid();
|
|
758
862
|
const existing = getValidEntry(lockKey);
|
|
759
863
|
if (existing) {
|
|
760
|
-
return ok(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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 (
|
|
904
|
-
args.push(
|
|
905
|
-
|
|
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 (
|
|
1142
|
+
else if (policy.xx)
|
|
914
1143
|
args.push("XX");
|
|
915
|
-
if (
|
|
1144
|
+
if (policy.keepTtl)
|
|
916
1145
|
args.push("KEEPTTL");
|
|
917
|
-
await client.
|
|
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(() =>
|
|
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(() =>
|
|
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(
|
|
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(() =>
|
|
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 =
|
|
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
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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 (!
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
|