baja-lite 1.8.11 → 1.8.13

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.
Files changed (3) hide show
  1. package/cache.d.ts +193 -69
  2. package/cache.js +406 -174
  3. package/package.json +1 -1
package/cache.d.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * @internal 统一的 stale-allowed 注册表 key。
3
- * 用带 TTL 的 string(而非全局 set),每个 stale-allowed 缓存独立管理自己的标记。
4
- * key = [cache-meta]realKey,value = '1',TTL = 缓存 TTL。
5
- * 好处:自动过期,不再需要全局 set,内存占用与缓存数量而非请求数量成正比。
6
- */
7
1
  /**
8
2
  * 缓存被清理时的行为策略。
9
3
  * 由 @MethodCache 的 `staleMode` 配置决定,clearMethodCache 依此自动选择清理方式。
@@ -37,6 +31,8 @@ export declare function excuteWithLock<T>(config: {
37
31
  lockRetryInterval?: number;
38
32
  /** 当设置了lockWait=true时,等待多少【毫秒】即视为超时,放弃本次访问?默认:永不放弃 */
39
33
  lockMaxWaitTime?: number;
34
+ /** 最大重试次数。默认 5。超过后抛出异常。 */
35
+ lockMaxRetries?: number;
40
36
  /** 错误信息 */
41
37
  errorMessage?: string;
42
38
  /** 允许的并发数,默认:1 */
@@ -59,6 +55,8 @@ export declare function MethodLock<T = any>(config: {
59
55
  lockRetryInterval?: number;
60
56
  /** 当设置了lockWait=true时,等待多少【毫秒】即视为超时,放弃本次访问?默认永不放弃 */
61
57
  lockMaxWaitTime?: number;
58
+ /** 最大重试次数。默认 5。超过后抛出异常。 */
59
+ lockMaxRetries?: number;
62
60
  /** 错误信息 */
63
61
  errorMessage?: string;
64
62
  /** 允许的并发数,默认=1 */
@@ -67,75 +65,173 @@ export declare function MethodLock<T = any>(config: {
67
65
  lockMaxTime?: number;
68
66
  }): (target: T, _propertyKey: string, descriptor: PropertyDescriptor) => void;
69
67
  /**
70
- * 清空方法缓存。
71
- * - redis 侧只在配置了 redis 时才操作;没配 redis 时安静跳过,不抛错。
72
- * - 关联清除(clearKey)生效。
73
- * - 被清理的 key 如果启用了 StaleWhileRevalidate(CacheValue.sa=1),会保留旧值并标记 stale。
68
+ * 清理指定 key 的缓存。支持两种模式:
69
+ *
70
+ * **直接清理** — 传入具体缓存 key:
71
+ * - Purge 模式(sa=0):物理删除缓存
72
+ * - Stale 模式(sa=1):保留旧值,n 计数器 +1,后续请求读到 stale 数据并触发后台刷新
73
+ *
74
+ * **级联清理** — 传入 clearKey(关联清除 key):
75
+ * - 遍历所有注册了该 clearKey 的缓存,逐个执行上述清理逻辑
76
+ *
77
+ * @example <caption>直接清理某个缓存</caption>
78
+ * ```ts
79
+ * // Purge 模式:删除缓存,下次请求执行 fn 重建
80
+ * await clearMethodCache(`user-${userId}`);
81
+ *
82
+ * // Stale 模式:保留旧值 + 标记过期,后台异步刷新
83
+ * await clearMethodCache(`match-${matchId}`);
84
+ * ```
85
+ *
86
+ * @example <caption>级联清理 — 清理所有关联了某 clearKey 的缓存</caption>
87
+ * ```ts
88
+ * // 假设以下缓存都注册了 clearKey: ['match']
89
+ * // - match-${id}
90
+ * // - match-timer-${id}
91
+ * // - match-stat-${id}
74
92
  *
75
- * @param key 要清理的缓存 key 或 parent key
93
+ * // 一次调用清理所有
94
+ * await clearMethodCache('match');
95
+ * ```
76
96
  */
77
97
  export declare function clearMethodCache(key: string): Promise<void>;
78
98
  /**
79
- * 执行一个方法fn
80
- * 如果有缓存,则返回缓存,否则【加锁、二次检查】后执行方法并缓存,防止缓存击穿。
81
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透;负缓存默认沿用 autoClearTime,可单独用 nullCacheTime 设短。
82
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
99
+ * 执行一个带缓存的方法。先查缓存 → 命中直接返回;未命中则加锁执行 fn 并缓存结果。
100
+ *
101
+ * 支持三种缓存策略(通过 `staleMode` 切换):
102
+ * - **Purge**(默认):清理时物理删除缓存,下次请求阻塞执行 fn 重建
103
+ * - **StaleWhileRevalidate**:清理时保留旧值 + 标记过期,后续请求零阻塞读到旧值,后台异步刷新
104
+ *
105
+ * 防击穿机制:
106
+ * - 缓存 miss 时通过 `[cache-sf]key` 原子锁(SETNX)选出"首飞"执行 fn
107
+ * - 其他并发请求轮询缓存,首飞写完后立即命中,避免重复执行 fn
108
+ *
109
+ * @example <caption>基础用法 — Purge 模式</caption>
110
+ * ```ts
111
+ * const result = await excuteWithCache({
112
+ * key: `user-${userId}`,
113
+ * autoClearTime: 60, // 60分钟后自动过期
114
+ * cacheNullValue: true, // null 也缓存(防穿透)
115
+ * }, () => loadUserFromDB(userId));
116
+ * ```
117
+ *
118
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
119
+ * ```ts
120
+ * const result = await excuteWithCache({
121
+ * key: `match-${matchId}`,
122
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
123
+ * onCacheUpdated(value) {
124
+ * // 缓存刷新后触发:推送 WebSocket、预热下游等
125
+ * this.broadcast(value);
126
+ * },
127
+ * }, () => computeMatchData(matchId));
128
+ * ```
129
+ *
130
+ * @example <caption>cacheOnly — 只读缓存,不调用 fn</caption>
131
+ * ```ts
132
+ * const cached = await excuteWithCache({
133
+ * key: `config`,
134
+ * cacheOnly: true, // 没有缓存返回 null,不执行 fn
135
+ * }, () => loadConfig());
136
+ * ```
137
+ *
138
+ * @example <caption>forceRefresh — 跳过缓存直接执行 fn</caption>
139
+ * ```ts
140
+ * const fresh = await excuteWithCache({
141
+ * key: `match-${id}`,
142
+ * forceRefresh: true, // 忽略已有缓存,执行 fn 后写回(sa=0)
143
+ * }, () => computeFreshData(id));
144
+ * ```
145
+ *
146
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
147
+ * ```ts
148
+ * const result = await excuteWithCache({
149
+ * key: `match-timer-${id}`,
150
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
151
+ * }, () => computeTimer(id));
152
+ *
153
+ * // 清理所有关联了 'match' 的缓存
154
+ * await clearMethodCache('match');
155
+ * ```
83
156
  */
84
157
  export declare function excuteWithCache<T>(config: {
85
- /** 返回缓存key,参数=方法的参数+当前用户对象,可以用来清空缓存。 */
86
158
  key: ((...args: any[]) => string) | string;
87
- /** 返回缓存清除key,参数=方法的参数+当前用户对象,可以用来批量清空缓存 */
88
- clearKey?: string[];
89
- /**
90
- * 自动清空缓存的时间,单位分钟。
91
- * ⚠️ 不能与 `staleMode: StaleWhileRevalidate` 同时使用:
92
- * stale 只是把缓存"标记为过期",旧值仍在;而 autoClearTime 到期后会物理删除 key。
93
- * 两者语义冲突——stale 期望保留旧值等后台刷新,autoClearTime 却会直接删掉它。
94
- */
159
+ clearKey?: ((...args: any[]) => string[]) | string[];
95
160
  autoClearTime?: number;
96
- /** 是否缓存 null / undefined(负缓存防穿透),默认 false。 */
97
161
  cacheNullValue?: boolean;
98
- /** 负缓存 TTL(分钟)。默认沿用 autoClearTime;都不设时不写入。 */
99
162
  nullCacheTime?: number;
100
- /** 随着当前用户sesion的清空而一起清空 */
101
163
  clearWithSession?: boolean;
102
- /**
103
- * 缓存被清理时的行为策略。默认 Purge。
104
- * StaleWhileRevalidate 时设 sa=1 使 clearMethodCache 走"标记 stale + 保留旧值"路径。
105
- */
106
164
  staleMode?: CacheStaleMode;
107
- /**
108
- * 缓存更新回调。异步方法,在 setMethodCache 成功写缓存后调用。
109
- * 调用时三个参数:this → 方法的执行上下文(config.ctx);value → 缓存值;args → 方法输入参数。
110
- */
111
165
  onCacheUpdated?: (this: any, value: T, ...args: any[]) => void | Promise<void>;
166
+ ctx?: any;
167
+ args?: any[];
168
+ devid?: string | false | undefined;
112
169
  /**
113
- * 方法的执行上下文。在 onCacheUpdated 回调中作为 this 传入。
114
- * @example
115
- * ```ts
116
- * excuteWithCache({
117
- * key: 'calibration',
118
- * ctx: service,
119
- * args: ['device001'],
120
- * onCacheUpdated(this: MyService, value, args) {
121
- * this.broadcast(value);
122
- * },
123
- * }, () => service.getCalibration('device001'));
124
- * ```
170
+ * 只用缓存,绝不调用原始方法。无论缓存是否过期都直接返回;没有缓存则返回 null。
125
171
  */
126
- ctx?: any;
172
+ cacheOnly?: boolean;
127
173
  /**
128
- * 方法的输入参数。在 onCacheUpdated 回调中作为 args 传入。
129
- * 用于告知回调是哪些参数触发了本次缓存更新。
174
+ * 忽略已有缓存,直接调用原始方法并更新缓存(写回时 sa=0)。
130
175
  */
131
- args?: any[];
176
+ forceRefresh?: boolean;
132
177
  }, fn: () => Promise<T>): Promise<T>;
133
178
  /**
134
- * 缓存注解:先查缓存 miss 才加锁 → 锁内再查一次 → 仍 miss 才执行原方法。
135
- * 已内置 single-flight,不需要再叠 `@MethodLock`。
136
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透。
137
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
138
- * 可选 `onCacheUpdated` 设置缓存更新回调(带 this 上下文)。
179
+ * 方法装饰器:为方法添加 Redis 缓存能力。
180
+ *
181
+ * 工作流程:
182
+ * 1. 调用方法时先查 Redis 缓存
183
+ * 2. 命中 直接返回缓存值(若 stale 则触发后台异步刷新)
184
+ * 3. 未命中 → 原子锁(SETNX)选出"首飞"执行原方法 → 写缓存 → 返回
185
+ * 4. 其他并发请求轮询缓存,首飞写完后立即命中
186
+ *
187
+ * @example <caption>基础用法 — Purge 模式(默认)</caption>
188
+ * ```ts
189
+ * @MethodCache({
190
+ * key: (userId: string) => `user-${userId}`,
191
+ * autoClearTime: 60, // 60分钟后自动过期
192
+ * })
193
+ * async getUser(userId: string) {
194
+ * return await this.db.query(...);
195
+ * }
196
+ * ```
197
+ *
198
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
199
+ * ```ts
200
+ * @MethodCache({
201
+ * key: (matchId: string) => `match-${matchId}`,
202
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
203
+ * onCacheUpdated(this: MyService, value) {
204
+ * // 缓存刷新后触发(this = 当前 service 实例)
205
+ * this.webSocket.broadcast(value);
206
+ * },
207
+ * })
208
+ * async getMatch(matchId: string) { ... }
209
+ *
210
+ * // 清理缓存 → 旧值保留,后台异步刷新
211
+ * await clearMethodCache(`match-${id}`);
212
+ * ```
213
+ *
214
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
215
+ * ```ts
216
+ * @MethodCache({
217
+ * key: (matchId: string) => `match-timer-${matchId}`,
218
+ * clearKey: ['match'], // 声明依赖关系
219
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
220
+ * })
221
+ * async getMatchTimer(matchId: string) { ... }
222
+ *
223
+ * // 清理所有关联了 'match' 的缓存(包括 match-timer-*)
224
+ * await clearMethodCache('match');
225
+ * ```
226
+ *
227
+ * @example <caption>cacheOnly — 只读缓存,绝不调用原方法</caption>
228
+ * ```ts
229
+ * @MethodCache({
230
+ * key: 'app-config',
231
+ * cacheOnly: true, // 没有缓存返回 null
232
+ * })
233
+ * async loadConfig() { ... }
234
+ * ```
139
235
  */
140
236
  export declare function MethodCache<T = any>(config: {
141
237
  /** 返回缓存key,参数=方法的参数[注意:必须和主方法的参数数量、完全一致,同时会追加一个当前用户对象]+当前用户对象,可以用来清空缓存。 */
@@ -163,28 +259,54 @@ export declare function MethodCache<T = any>(config: {
163
259
  * 调用时三个参数:this → 执行上下文;args → 方法输入参数;value → 缓存值。
164
260
  */
165
261
  onCacheUpdated?: (this: any, value: T, ...args: any[]) => void | Promise<void>;
262
+ /**
263
+ * 只用缓存,绝不调用原始方法。无论缓存是否过期都直接返回;没有缓存则返回 null。
264
+ */
265
+ cacheOnly?: boolean;
266
+ /** 忽略已有缓存,直接调用原始方法并更新缓存(写回时 sa=0)。 */
267
+ forceRefresh?: boolean;
166
268
  }): (_target: T, _propertyKey: string, descriptor: PropertyDescriptor) => void;
167
269
  /**
168
- * 外部直接设置缓存值。用于绕过 @MethodCache 装饰器直接更新缓存的场景
169
- * (如外部事件驱动的数据变更、手动刷新等)。
270
+ * 外部直接写入缓存(绕过 @MethodCache 装饰器)。
170
271
  *
171
- * 内部使用统一格式 { t, d, s, sa } 序列化,确保与 @MethodCache 的读取逻辑兼容。
272
+ * @MethodCache 共享同一套存储格式 `{ t, d, sa, n }`,写入的缓存可被
273
+ * @MethodCache 装饰的方法正常读取(包括 stale 判断、计数器等)。
172
274
  *
173
- * @param cacheKey 缓存 key(不含 [cache] 前缀,与 @MethodCache 的 key 一致)
174
- * @param value 要缓存的值
175
- * @param options 选项
275
+ * 适用场景:
276
+ * - 外部事件驱动的数据变更(如 MQ 消息、定时任务)
277
+ * - 手动刷新缓存(如管理后台"清除并重建"按钮)
278
+ * - 跨服务同步缓存状态
176
279
  *
177
- * @example
280
+ * @example <caption>基础用法 — 写入缓存</caption>
178
281
  * ```ts
179
- * // 外部事件驱动更新缓存
180
- * await setCache('match-timer-123', timer, { autoClearTime: 120 });
282
+ * await setCache('match-timer-123', timer, {
283
+ * autoClearTime: 120, // 120分钟后过期
284
+ * });
285
+ * ```
181
286
  *
182
- * // 同时触发 onCacheUpdated 回调(如果有)
287
+ * @example <caption>关联清除 注册 clearKey 级联关系</caption>
288
+ * ```ts
183
289
  * await setCache('match-timer-123', timer, {
184
290
  * autoClearTime: 120,
185
- * onCacheUpdated(this: MyService, value) {
186
- * this.broadcast(value);
187
- * }
291
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
292
+ * });
293
+ *
294
+ * // 函数式 clearKey(参数来自 options.args)
295
+ * await setCache('match-timer-123', timer, {
296
+ * clearKey: (matchId) => [`match-${matchId}`],
297
+ * args: [matchId],
298
+ * });
299
+ * ```
300
+ *
301
+ * @example <caption>回调 — 缓存写入后触发通知</caption>
302
+ * ```ts
303
+ * await setCache('match-timer-123', timer, {
304
+ * autoClearTime: 120,
305
+ * ctx: this,
306
+ * args: [matchId],
307
+ * onCacheUpdated(value, matchId) {
308
+ * this.webSocket.sendRoom(matchId, { action: 'timer', param: value });
309
+ * },
188
310
  * });
189
311
  * ```
190
312
  */
@@ -193,6 +315,8 @@ export declare function setCache<T = any>(cacheKey: string, value: T, options?:
193
315
  autoClearTime?: number;
194
316
  /** 是否缓存 null / undefined。默认 false。 */
195
317
  cacheNullValue?: boolean;
318
+ /** 关联清除 key。clearMethodCache(clearKey) 时级联清除本缓存。 */
319
+ clearKey?: ((...args: any[]) => string[]) | string[];
196
320
  /** 缓存更新回调 */
197
321
  onCacheUpdated?: (this: any, value: T, ...args: any[]) => void | Promise<void>;
198
322
  /** 回调的 this 上下文 */
package/cache.js CHANGED
@@ -18,28 +18,26 @@ function cacheLog(message, ...params) {
18
18
  */
19
19
  const globalStaleInflight = new Map();
20
20
  /**
21
- * @internal [cache] 前缀下的子 key 命名全部收拢到这里,避免散落在各处 magic string。
21
+ * @internal [cache] 前缀下的子 key 命名全部收拢到这里。
22
22
  *
23
23
  * 命名规范:
24
- * - [cache]key 缓存值(JSON: { v, d, s } — v=版本 d=数据 s=是否 stale
24
+ * - [cache]key 缓存值(JSON: { t, d, sa?, n? })
25
25
  * - [cache-sf]key single-flight 首飞标记(SETNX 抢锁用)
26
- * - [cache-meta]key stale-allowed 标记(带 TTL,与缓存同生命周期)
27
26
  * - [cache-parent]key / [cache-child]key 关联清除的 parent↔child 双向 set
28
27
  */
29
28
  const CacheKey = {
30
29
  value: (k) => `[cache]${k}`,
31
30
  sf: (k) => `[cache-sf]${k}`,
32
- meta: (k) => `[cache-meta]${k}`,
33
31
  parent: (k) => `[cache-parent]${k}`,
34
32
  child: (k) => `[cache-child]${k}`,
35
33
  };
36
34
  /** @internal 序列化缓存值 */
37
- function serializeCacheValue(data, timestamp, stale = false, staleAllowed = false) {
35
+ function serializeCacheValue(data, timestamp, staleAllowed = false, staleCount = 0) {
38
36
  const v = { t: timestamp, d: data };
39
- if (stale)
40
- v.s = 1;
41
37
  if (staleAllowed)
42
38
  v.sa = 1;
39
+ if (staleCount > 0)
40
+ v.n = staleCount;
43
41
  return JSON.stringify(v);
44
42
  }
45
43
  /** @internal 反序列化缓存值 */
@@ -53,12 +51,6 @@ function deserializeCacheValue(raw) {
53
51
  return { t: 0, d: raw, isNullish: raw === 'null' || raw === 'undefined' };
54
52
  }
55
53
  }
56
- /**
57
- * @internal 统一的 stale-allowed 注册表 key。
58
- * 用带 TTL 的 string(而非全局 set),每个 stale-allowed 缓存独立管理自己的标记。
59
- * key = [cache-meta]realKey,value = '1',TTL = 缓存 TTL。
60
- * 好处:自动过期,不再需要全局 set,内存占用与缓存数量而非请求数量成正比。
61
- */
62
54
  /**
63
55
  * 缓存被清理时的行为策略。
64
56
  * 由 @MethodCache 的 `staleMode` 配置决定,clearMethodCache 依此自动选择清理方式。
@@ -124,7 +116,8 @@ export async function GetRedisLock(key, lockMaxActive) {
124
116
  await initLock.release();
125
117
  // eslint-disable-next-line no-empty
126
118
  }
127
- catch (error) {
119
+ catch {
120
+ // ignore
128
121
  }
129
122
  }
130
123
  }
@@ -136,18 +129,23 @@ export async function GetRedisLock(key, lockMaxActive) {
136
129
  export async function excuteWithLock(config, fn__) {
137
130
  const key = `[lock]${typeof config.key === 'function' ? config.key() : config.key}`;
138
131
  const db = getRedisDB();
132
+ const maxRetries = config.lockMaxRetries ?? 5;
133
+ let retries = 0;
139
134
  let wait_time = 0;
140
135
  const fn = async () => {
141
136
  const lock = await GetRedisLock(key, config.lockMaxActive);
142
137
  if (lock === false) {
143
- if (config.lockWait !== false && ((config.lockMaxWaitTime ?? 0) === 0 || (wait_time + (config.lockRetryInterval ?? 100)) <= (config.lockMaxWaitTime ?? 0))) {
144
- globalThis[_LoggerService].debugCategory?.('sql', `get lock ${key} fail, retry after ${config.lockRetryInterval ?? 100}ms...`);
138
+ retries++;
139
+ const timeOk = (config.lockMaxWaitTime ?? 0) === 0 || (wait_time + (config.lockRetryInterval ?? 100)) <= (config.lockMaxWaitTime ?? 0);
140
+ const retryOk = retries < maxRetries;
141
+ if (config.lockWait !== false && timeOk && retryOk) {
142
+ globalThis[_LoggerService].debugCategory?.('sql', `get lock ${key} fail, retry ${retries}/${maxRetries} after ${config.lockRetryInterval ?? 100}ms...`);
145
143
  await sleep(config.lockRetryInterval ?? 100);
146
144
  wait_time += (config.lockRetryInterval ?? 100);
147
145
  return await fn();
148
146
  }
149
147
  else {
150
- globalThis[_LoggerService].debugCategory?.('sql', `get lock ${key} fail`);
148
+ globalThis[_LoggerService].debugCategory?.('sql', `get lock ${key} fail after ${retries} retries`);
151
149
  throw new Error(config.errorMessage || `get lock fail: ${key}`);
152
150
  }
153
151
  }
@@ -209,22 +207,28 @@ async function setMethodCache(config, devid) {
209
207
  }
210
208
  }
211
209
  // 写入缓存值(带时间戳 + staleAllowed 标志)。
212
- // 保留旧 sa:只有显式 StaleWhileRevalidate 才设 1,否则沿用旧值(防止覆盖已有的 sa=1)。
213
210
  const timestamp = Date.now();
214
- const existingSa = await db.get(CacheKey.value(config.key)).then(r => r ? deserializeCacheValue(r).sa : undefined);
215
- const staleAllowed = config.staleMode === CacheStaleMode.StaleWhileRevalidate ? true : (existingSa ?? false);
216
- const payload = serializeCacheValue(dataStr, timestamp, false, staleAllowed);
211
+ let staleAllowed;
212
+ if (config.staleAllowedOverride !== undefined) {
213
+ staleAllowed = config.staleAllowedOverride; // 显式指定
214
+ }
215
+ else {
216
+ // 保留旧 sa:显式 StaleWhileRevalidate → 1,否则沿用旧值
217
+ const existingSa = await db.get(CacheKey.value(config.key)).then(r => r ? deserializeCacheValue(r).sa : undefined);
218
+ staleAllowed = config.staleMode === CacheStaleMode.StaleWhileRevalidate ? true : (existingSa ?? false);
219
+ }
220
+ const payload = serializeCacheValue(dataStr, timestamp, staleAllowed);
217
221
  if (ttl) { // 自动清空
218
222
  await db.set(CacheKey.value(config.key), payload, 'EX', ttl * 60);
219
- cacheLog(`cache ${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
223
+ cacheLog(`${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
220
224
  }
221
225
  else {
222
226
  await db.set(CacheKey.value(config.key), payload);
223
- cacheLog(`cache ${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
227
+ cacheLog(`${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
224
228
  }
225
229
  // staleAllowed 已存储在缓存值内部(CacheValue.sa),无需单独的 meta key
226
230
  // 删除旧格式残留(兼容升级)
227
- await db.del(CacheKey.meta(config.key));
231
+ // await db.del(`[cache-meta]${config.key}`); // 清理旧格式残留
228
232
  // 订阅:清空 clear list —— 同一 key 在缓存生命周期内只注册一次监听器,
229
233
  // 否则每次 miss 都会 .on 一次,clearMethodCache 触发时回调会被执行 N 遍,
230
234
  // 长期运行造成监听器泄漏。
@@ -233,7 +237,7 @@ async function setMethodCache(config, devid) {
233
237
  if (globalThis[_EventBus].listenerCount(event) === 0) {
234
238
  globalThis[_EventBus].on(event, async (key) => {
235
239
  await clearCacheKey(key);
236
- cacheLog(`cache ${key} clear by key!`);
240
+ cacheLog(`${key} clear by key!`);
237
241
  });
238
242
  }
239
243
  }
@@ -243,7 +247,7 @@ async function setMethodCache(config, devid) {
243
247
  if (globalThis[_EventBus].listenerCount(event) === 0) {
244
248
  globalThis[_EventBus].on(event, async (key) => {
245
249
  await clearCacheKey(key);
246
- cacheLog(`cache ${key} clear by devid!`);
250
+ cacheLog(`${key} clear by devid!`);
247
251
  });
248
252
  }
249
253
  }
@@ -265,7 +269,7 @@ async function clearCacheKey(key) {
265
269
  if (parentType === 'set') {
266
270
  const childKeys = await db.smembers(CacheKey.parent(key));
267
271
  for (const child of childKeys) {
268
- cacheLog(`cache ${child} cleared via parent ${key}!`);
272
+ cacheLog(`${child} cleared via parent ${key}!`);
269
273
  await clearCacheKey(child);
270
274
  affectedChildren.push(child);
271
275
  }
@@ -275,24 +279,29 @@ async function clearCacheKey(key) {
275
279
  const valueRaw = await db.get(CacheKey.value(key));
276
280
  const v = valueRaw ? deserializeCacheValue(valueRaw) : null;
277
281
  if (v?.sa) {
278
- // stale-allowed:保留缓存值,标记 s=1,同时保留 sa=1(防止下次 clear 变成 purge)
279
- const newPayload = serializeCacheValue(v.d, v.t, true, true);
280
- const ttlSec = await db.ttl(CacheKey.value(key));
281
- if (ttlSec > 0) {
282
- await db.set(CacheKey.value(key), newPayload, 'EX', ttlSec);
282
+ // stale-allowed:n + 1(原子 Lua;失败退化为普通写入)
283
+ try {
284
+ await db.eval(CLEAR_INCR_LUA, 1, CacheKey.value(key));
283
285
  }
284
- else {
285
- await db.set(CacheKey.value(key), newPayload);
286
+ catch {
287
+ const newPayload = serializeCacheValue(v.d, v.t, true, (v.n ?? 0) + 1);
288
+ const ttlSec = await db.ttl(CacheKey.value(key));
289
+ if (ttlSec > 0) {
290
+ await db.set(CacheKey.value(key), newPayload, 'EX', ttlSec);
291
+ }
292
+ else {
293
+ await db.set(CacheKey.value(key), newPayload);
294
+ }
286
295
  }
287
- cacheLog(`cache ${key} marked stale (t${v.t})`);
296
+ cacheLog(`${key} clear n+1`);
288
297
  }
289
298
  else {
290
299
  // Purge:直接删缓存值
291
300
  await db.del(CacheKey.value(key));
292
- cacheLog(`cache ${key} purged!`);
301
+ cacheLog(`${key} purged!`);
293
302
  }
294
303
  // 清理旧格式残留
295
- await db.del(CacheKey.meta(key));
304
+ // await db.del(`[cache-meta]${key}`); // 清理旧格式残留
296
305
  // 3. 清理 child→parent 反向关联
297
306
  const childType = await db.type(CacheKey.child(key));
298
307
  if (childType === 'set') {
@@ -308,12 +317,34 @@ async function clearCacheKey(key) {
308
317
  return affectedChildren;
309
318
  }
310
319
  /**
311
- * 清空方法缓存。
312
- * - redis 侧只在配置了 redis 时才操作;没配 redis 时安静跳过,不抛错。
313
- * - 关联清除(clearKey)生效。
314
- * - 被清理的 key 如果启用了 StaleWhileRevalidate(CacheValue.sa=1),会保留旧值并标记 stale。
320
+ * 清理指定 key 的缓存。支持两种模式:
321
+ *
322
+ * **直接清理** — 传入具体缓存 key:
323
+ * - Purge 模式(sa=0):物理删除缓存
324
+ * - Stale 模式(sa=1):保留旧值,n 计数器 +1,后续请求读到 stale 数据并触发后台刷新
325
+ *
326
+ * **级联清理** — 传入 clearKey(关联清除 key):
327
+ * - 遍历所有注册了该 clearKey 的缓存,逐个执行上述清理逻辑
328
+ *
329
+ * @example <caption>直接清理某个缓存</caption>
330
+ * ```ts
331
+ * // Purge 模式:删除缓存,下次请求执行 fn 重建
332
+ * await clearMethodCache(`user-${userId}`);
333
+ *
334
+ * // Stale 模式:保留旧值 + 标记过期,后台异步刷新
335
+ * await clearMethodCache(`match-${matchId}`);
336
+ * ```
315
337
  *
316
- * @param key 要清理的缓存 key parent key
338
+ * @example <caption>级联清理 清理所有关联了某 clearKey 的缓存</caption>
339
+ * ```ts
340
+ * // 假设以下缓存都注册了 clearKey: ['match']
341
+ * // - match-${id}
342
+ * // - match-timer-${id}
343
+ * // - match-stat-${id}
344
+ *
345
+ * // 一次调用清理所有
346
+ * await clearMethodCache('match');
347
+ * ```
317
348
  */
318
349
  export async function clearMethodCache(key) {
319
350
  const redisDao = globalThis[_dao]?.[DBType.Redis]?.[_primaryDB];
@@ -342,32 +373,99 @@ const SINGLEFLIGHT_LOCK_TTL_MS = 30000; // 首飞标记 TTL,兜底首飞挂掉
342
373
  const SINGLEFLIGHT_POLL_INTERVAL_MS = 50; // 等待者轮询缓存的间隔
343
374
  const SINGLEFLIGHT_MAX_WAIT_MS = 30000; // 等待者最长等多久;超时就自己跑 fn(保底,不应该常发生)
344
375
  /**
345
- * 后台异步刷新:fire-and-forget,不阻塞调用方。
346
- * 只有抢到首飞标记的实例才会执行 fn() 并写缓存;其它实例直接放弃。
347
- * 刷新完成后删除 stale 标记,后续读取将命中新值。
348
- * 如果配置了 onCacheUpdated 回调,调用时传入 ctx(方法的 this 上下文)。
376
+ * Lua 脚本:clear —— 原子 n+1(保留旧值和 TTL)。
377
+ * 返回新 n 值;-1 表示 key 不存在或不是 sa。
378
+ */
379
+ const CLEAR_INCR_LUA = `
380
+ local raw = redis.call('GET', KEYS[1])
381
+ if not raw then return -1 end
382
+ local ok, val = pcall(cjson.decode, raw)
383
+ if not ok then return -1 end
384
+ if not val.sa then return -1 end
385
+ val.n = (tonumber(val.n) or 0) + 1
386
+ local ttl = redis.call('TTL', KEYS[1])
387
+ if ttl > 0 then
388
+ redis.call('SET', KEYS[1], cjson.encode(val), 'EX', ttl)
389
+ else
390
+ redis.call('SET', KEYS[1], cjson.encode(val))
391
+ end
392
+ return val.n
393
+ `;
394
+ /**
395
+ * Lua 脚本:refresh 完成 —— 原子读当前 n、写新数据、n-1。
396
+ * 返回 [newN, status]:status=1 成功,-1 不存在,-2 非 sa。
397
+ *
398
+ * KEYS[1] = cache key
399
+ * ARGV[1] = 新数据的原始 JSON 字符串(将作为 CacheValue.d)
400
+ * ARGV[2] = TTL 秒数(0 表示保持原 TTL)
401
+ * ARGV[3] = 当前时间戳(毫秒,将作为 CacheValue.t)
349
402
  */
403
+ const REFRESH_DECR_LUA = `
404
+ local raw = redis.call('GET', KEYS[1])
405
+ if not raw then return {-2, 0} end
406
+ local ok, val = pcall(cjson.decode, raw)
407
+ if not ok then return {-2, 0} end
408
+ if not val.sa then return {-1, 0} end
409
+ local n = tonumber(val.n) or 0
410
+ local newN = math.max(0, n - 1)
411
+ -- 重建统一格式 {t, d, sa, n?}
412
+ local newData = {
413
+ t = tonumber(ARGV[3]) or 0,
414
+ d = ARGV[1],
415
+ sa = val.sa
416
+ }
417
+ if newN > 0 then newData.n = newN end
418
+ local ttl = tonumber(ARGV[2]) or 0
419
+ if ttl > 0 then
420
+ redis.call('SET', KEYS[1], cjson.encode(newData), 'EX', ttl)
421
+ else
422
+ redis.call('SET', KEYS[1], cjson.encode(newData))
423
+ end
424
+ return {newN, 1}
425
+ `;
350
426
  async function staleBackgroundRefresh(opts, setOpts) {
351
427
  const db = getRedisDB();
352
428
  const sfKey = CacheKey.sf(opts.key);
353
429
  try {
354
- const ok = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
355
- if (ok !== 'OK')
356
- return; // 别人已经在刷新了,自己放弃
430
+ const locked = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
431
+ if (locked !== 'OK')
432
+ return;
357
433
  }
358
434
  catch {
359
- return; // redis 出错,放弃刷新,下次读取再试
435
+ return;
360
436
  }
361
437
  try {
438
+ cacheLog(`${opts.key} refresh start...`);
362
439
  const result = await opts.fn();
363
- // 写新值(setMethodCache 内部会清除 stale 标志 s=1
364
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
365
- // 调用缓存更新回调(带 ctx)
366
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
367
- cacheLog(`cache ${opts.key} stale background refresh done!`);
440
+ // 序列化新数据(不含 n
441
+ const rawData = (result === null || result === undefined) ? 'null' : JSON.stringify(result);
442
+ const ttlSec = await db.ttl(CacheKey.value(opts.key));
443
+ // 原子地写新数据 + n-1(Lua 脚本内完成)
444
+ let r;
445
+ try {
446
+ r = await db.eval(REFRESH_DECR_LUA, 1, CacheKey.value(opts.key), rawData, String(Math.max(0, ttlSec)), String(Date.now()));
447
+ }
448
+ catch {
449
+ return;
450
+ }
451
+ const [newN, status] = r ?? [0, 0];
452
+ if (status === -2 || status === -1) {
453
+ cacheLog(`${opts.key} refresh skipped (status=${status})`);
454
+ }
455
+ else {
456
+ cacheLog(`${opts.key} refresh done (n → ${newN})`);
457
+ void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
458
+ // n > 0 → 刷新期间又被 clear,继续下一轮刷新
459
+ if (newN > 0 && !globalStaleInflight.has(opts.key)) {
460
+ globalStaleInflight.set(opts.key, staleBackgroundRefresh(opts, setOpts).finally(() => {
461
+ globalStaleInflight.delete(opts.key);
462
+ }));
463
+ }
464
+ }
368
465
  }
369
466
  catch (error) {
370
- globalThis[_LoggerService].warn?.(`cache ${opts.key} stale background refresh failed: ${error?.message}`);
467
+ const msg = error instanceof Error ? error.message : String(error);
468
+ globalThis[_LoggerService].warn?.(`cache ${opts.key} background refresh failed: ${msg}`);
371
469
  }
372
470
  finally {
373
471
  try {
@@ -376,97 +474,167 @@ async function staleBackgroundRefresh(opts, setOpts) {
376
474
  catch { /* TTL 兜底 */ }
377
475
  }
378
476
  }
379
- async function redisCacheCore(opts) {
477
+ /**
478
+ * 执行一个带缓存的方法。先查缓存 → 命中直接返回;未命中则加锁执行 fn 并缓存结果。
479
+ *
480
+ * 支持三种缓存策略(通过 `staleMode` 切换):
481
+ * - **Purge**(默认):清理时物理删除缓存,下次请求阻塞执行 fn 重建
482
+ * - **StaleWhileRevalidate**:清理时保留旧值 + 标记过期,后续请求零阻塞读到旧值,后台异步刷新
483
+ *
484
+ * 防击穿机制:
485
+ * - 缓存 miss 时通过 `[cache-sf]key` 原子锁(SETNX)选出"首飞"执行 fn
486
+ * - 其他并发请求轮询缓存,首飞写完后立即命中,避免重复执行 fn
487
+ *
488
+ * @example <caption>基础用法 — Purge 模式</caption>
489
+ * ```ts
490
+ * const result = await excuteWithCache({
491
+ * key: `user-${userId}`,
492
+ * autoClearTime: 60, // 60分钟后自动过期
493
+ * cacheNullValue: true, // null 也缓存(防穿透)
494
+ * }, () => loadUserFromDB(userId));
495
+ * ```
496
+ *
497
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
498
+ * ```ts
499
+ * const result = await excuteWithCache({
500
+ * key: `match-${matchId}`,
501
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
502
+ * onCacheUpdated(value) {
503
+ * // 缓存刷新后触发:推送 WebSocket、预热下游等
504
+ * this.broadcast(value);
505
+ * },
506
+ * }, () => computeMatchData(matchId));
507
+ * ```
508
+ *
509
+ * @example <caption>cacheOnly — 只读缓存,不调用 fn</caption>
510
+ * ```ts
511
+ * const cached = await excuteWithCache({
512
+ * key: `config`,
513
+ * cacheOnly: true, // 没有缓存返回 null,不执行 fn
514
+ * }, () => loadConfig());
515
+ * ```
516
+ *
517
+ * @example <caption>forceRefresh — 跳过缓存直接执行 fn</caption>
518
+ * ```ts
519
+ * const fresh = await excuteWithCache({
520
+ * key: `match-${id}`,
521
+ * forceRefresh: true, // 忽略已有缓存,执行 fn 后写回(sa=0)
522
+ * }, () => computeFreshData(id));
523
+ * ```
524
+ *
525
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
526
+ * ```ts
527
+ * const result = await excuteWithCache({
528
+ * key: `match-timer-${id}`,
529
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
530
+ * }, () => computeTimer(id));
531
+ *
532
+ * // 清理所有关联了 'match' 的缓存
533
+ * await clearMethodCache('match');
534
+ * ```
535
+ */
536
+ export async function excuteWithCache(config, fn) {
537
+ const callArgs = config.args ?? [];
538
+ const key = typeof config.key === 'function' ? config.key(...callArgs) : config.key;
539
+ const clearKey = typeof config.clearKey === 'function' ? config.clearKey(...callArgs) : config.clearKey;
540
+ const { autoClearTime, cacheNullValue, nullCacheTime, staleMode, onCacheUpdated, ctx, devid, cacheOnly, forceRefresh } = config;
380
541
  const db = getRedisDB();
381
- const cacheKey = CacheKey.value(opts.key);
382
- const raw = await db.get(cacheKey);
383
- if (raw !== null) {
384
- // 一次读取拿到全部信息(值 + stale 状态)
542
+ const cacheKey = CacheKey.value(key);
543
+ /**
544
+ * cache命中后:检查是否 stale→触发后台刷新;最终返回缓存值(新鲜或过期均可)。
545
+ * 单一 keep 变量名:`v` 代表当前缓存值(类型 CacheValue)。
546
+ */
547
+ const readAndMaybeRefresh = async () => {
548
+ const raw = await db.get(cacheKey);
549
+ if (raw === null)
550
+ return null;
385
551
  const v = deserializeCacheValue(raw);
386
- const isStale = v.s === 1;
387
- if (isStale) {
388
- // stale 窗口内:返回旧值 + 触发后台刷新(不阻塞)
389
- cacheLog(`cache ${opts.key} hit (stale t${v.t})!`);
390
- const setOpts = {
391
- clearKey: opts.clearKey,
392
- autoClearTime: opts.autoClearTime,
393
- cacheNullValue: opts.cacheNullValue,
394
- nullCacheTime: opts.nullCacheTime,
395
- staleMode: opts.staleMode
396
- };
397
- if (!globalStaleInflight.has(opts.key)) {
398
- const refreshP = staleBackgroundRefresh(opts, setOpts).finally(() => {
399
- globalStaleInflight.delete(opts.key);
552
+ if (!cacheOnly) {
553
+ // 有未消耗的 clear 计数(n>0)且刷新尚未运行 → 触发后台刷新
554
+ if ((v.n ?? 0) > 0 && !globalStaleInflight.has(key)) {
555
+ const refreshOpts = { key, clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode, fn, ctx, args: callArgs, onCacheUpdated };
556
+ const refreshSetOpts = { clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode };
557
+ const refreshP = staleBackgroundRefresh(refreshOpts, refreshSetOpts).finally(() => {
558
+ globalStaleInflight.delete(key);
400
559
  });
401
- globalStaleInflight.set(opts.key, refreshP);
560
+ globalStaleInflight.set(key, refreshP);
402
561
  }
562
+ cacheLog(`${key} hit (${(v.n ?? 0) > 0 ? `stale n=${v.n}` : 'purge'})`);
403
563
  }
404
564
  else {
405
- cacheLog(`cache ${opts.key} hit (t${v.t})!`);
565
+ cacheLog(`${key} hit (cacheOnly)`);
406
566
  }
407
567
  return JSON.parse(v.d);
408
- }
409
- cacheLog(`cache ${opts.key} miss!`);
410
- const setOpts = {
411
- clearKey: opts.clearKey,
412
- autoClearTime: opts.autoClearTime,
413
- cacheNullValue: opts.cacheNullValue,
414
- nullCacheTime: opts.nullCacheTime,
415
- staleMode: opts.staleMode
416
568
  };
417
- // 用一个独立的"首飞标记" key 选出谁去执行 fn——SET NX PX 是原子操作,
418
- // 同一时刻只有一个调用者拿到 true,其它返回 null。这把"标记"不重入也不计数,
419
- // 只是个 single-flight 指示器,和 redlock / GetRedisLock 的并发计数器不冲突。
420
- const sfKey = CacheKey.sf(opts.key);
569
+ // forceRefresh:跳过缓存读取,直接走执行路径
570
+ if (!forceRefresh) {
571
+ const cached = await readAndMaybeRefresh();
572
+ if (cached !== null)
573
+ return cached;
574
+ if (cacheOnly)
575
+ return null;
576
+ cacheLog(`${key} miss!`);
577
+ }
578
+ else {
579
+ cacheLog(`${key} force refresh start`);
580
+ }
581
+ // forceRefresh 写回时强制 sa=0;其他情况按 staleMode/旧值自动判断
582
+ const setOpts = { clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode, staleAllowedOverride: forceRefresh ? false : undefined };
583
+ const sfKey = CacheKey.sf(key);
421
584
  let isLeader = false;
422
585
  try {
423
586
  const ok = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
424
587
  isLeader = ok === 'OK';
425
588
  }
426
- catch (error) {
427
- // redis 出错:退化为无锁直跑,保留可用性
428
- globalThis[_LoggerService].warn?.(`single-flight setnx ${opts.key} failed: ${error?.message}, fallback to no-lock`);
429
- const result = await opts.fn();
430
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
431
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
589
+ catch {
590
+ const result = await fn();
591
+ await setMethodCache({ key, ...setOpts, result }, devid);
592
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
593
+ cacheLog(`${key} force refresh end(error-force)`);
432
594
  return result;
433
595
  }
434
596
  if (isLeader) {
435
- // 首飞:跑 fn、写缓存、释放首飞标记
436
597
  try {
437
- const result = await opts.fn();
438
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
439
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
598
+ const result = await fn();
599
+ await setMethodCache({ key, ...setOpts, result }, devid);
600
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
601
+ cacheLog(`${key} force refresh end(leader)`);
440
602
  return result;
441
603
  }
442
604
  finally {
443
605
  try {
444
606
  await db.del(sfKey);
445
607
  }
446
- catch {
447
- // 标记 TTL 会兜底,删失败不影响正确性
448
- }
608
+ catch { /* TTL 兜底 */ }
449
609
  }
450
610
  }
451
- // 等待者:循环只看缓存,不抢锁
452
611
  const waitStart = Date.now();
453
612
  while (true) {
454
613
  await sleep(SINGLEFLIGHT_POLL_INTERVAL_MS);
455
614
  const recheck = await db.get(cacheKey);
456
615
  if (recheck !== null) {
457
- cacheLog(`cache ${opts.key} hit after wait!`);
458
- return JSON.parse(deserializeCacheValue(recheck).d);
616
+ const v = deserializeCacheValue(recheck);
617
+ // 拿到数据后二次检查:若仍 stale(刷新期间又被 clear),触发下一轮刷新
618
+ if ((v.n ?? 0) > 0 && !globalStaleInflight.has(key)) {
619
+ const refreshOpts = { key, clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode, fn, ctx, args: callArgs, onCacheUpdated };
620
+ const refreshSetOpts = { clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode };
621
+ globalStaleInflight.set(key, staleBackgroundRefresh(refreshOpts, refreshSetOpts).finally(() => {
622
+ globalStaleInflight.delete(key);
623
+ }));
624
+ }
625
+ cacheLog(`${key} hit after wait!`);
626
+ return JSON.parse(v.d);
459
627
  }
460
- // 首飞挂了(TTL 到期,sf 标记自动消失)但缓存仍然空——抢做下一任首飞
461
628
  const leaderGone = await db.get(sfKey);
462
629
  if (leaderGone === null) {
463
630
  const taken = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
464
631
  if (taken === 'OK') {
465
- cacheLog(`cache ${opts.key}: previous leader gone, taking over`);
632
+ cacheLog(`${key}: previous leader gone, taking over`);
466
633
  try {
467
- const result = await opts.fn();
468
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
469
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
634
+ const result = await fn();
635
+ await setMethodCache({ key, ...setOpts, result }, devid);
636
+ cacheLog(`${key} force refresh end(leader gone)`);
637
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
470
638
  return result;
471
639
  }
472
640
  finally {
@@ -476,101 +644,155 @@ async function redisCacheCore(opts) {
476
644
  catch { /* TTL 兜底 */ }
477
645
  }
478
646
  }
479
- // 没抢到说明又有别人接手了,继续等
480
647
  }
481
- // 等待上限保底:极端情况下首飞和接班都不正常时,自己直接跑,宁可重复执行也不挂死
482
648
  if (Date.now() - waitStart > SINGLEFLIGHT_MAX_WAIT_MS) {
483
- globalThis[_LoggerService].warn?.(`cache ${opts.key}: wait timed out, running fn without lock`);
484
- const result = await opts.fn();
485
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
486
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
487
- return result;
649
+ // 超时后重新抢锁(新一轮领导选举),而非直接跑 fn 导致雪崩
650
+ const retry = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
651
+ if (retry === 'OK') {
652
+ cacheLog(`${key}: wait timed out, re-elected as leader`);
653
+ try {
654
+ const result = await fn();
655
+ await setMethodCache({ key, ...setOpts, result }, devid);
656
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
657
+ return result;
658
+ }
659
+ finally {
660
+ try {
661
+ await db.del(sfKey);
662
+ }
663
+ catch { /* TTL 兜底 */ }
664
+ }
665
+ }
666
+ // 没抢到说明又有别人接手了,继续等
488
667
  }
489
668
  }
490
669
  }
491
- /** @internal 唯一的执行入口(Memory 后端已移除) */
492
- async function excuteCacheCore(opts) {
493
- return await redisCacheCore(opts);
494
- }
495
670
  /**
496
- * 执行一个方法fn,
497
- * 如果有缓存,则返回缓存,否则【加锁、二次检查】后执行方法并缓存,防止缓存击穿。
498
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透;负缓存默认沿用 autoClearTime,可单独用 nullCacheTime 设短。
499
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
500
- */
501
- export async function excuteWithCache(config, fn) {
502
- const key = typeof config.key === 'function' ? config.key() : config.key;
503
- return await excuteCacheCore({
504
- key,
505
- clearKey: config.clearKey,
506
- autoClearTime: config.autoClearTime,
507
- cacheNullValue: config.cacheNullValue,
508
- nullCacheTime: config.nullCacheTime,
509
- staleMode: config.staleMode,
510
- onCacheUpdated: config.onCacheUpdated,
511
- ctx: config.ctx,
512
- args: config.args ?? [],
513
- fn
514
- });
515
- }
516
- /**
517
- * 缓存注解:先查缓存 → miss 才加锁 → 锁内再查一次 → 仍 miss 才执行原方法。
518
- * 已内置 single-flight,不需要再叠 `@MethodLock`。
519
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透。
520
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
521
- * 可选 `onCacheUpdated` 设置缓存更新回调(带 this 上下文)。
671
+ * 方法装饰器:为方法添加 Redis 缓存能力。
672
+ *
673
+ * 工作流程:
674
+ * 1. 调用方法时先查 Redis 缓存
675
+ * 2. 命中 → 直接返回缓存值(若 stale 则触发后台异步刷新)
676
+ * 3. 未命中 原子锁(SETNX)选出"首飞"执行原方法 → 写缓存 → 返回
677
+ * 4. 其他并发请求轮询缓存,首飞写完后立即命中
678
+ *
679
+ * @example <caption>基础用法 — Purge 模式(默认)</caption>
680
+ * ```ts
681
+ * @MethodCache({
682
+ * key: (userId: string) => `user-${userId}`,
683
+ * autoClearTime: 60, // 60分钟后自动过期
684
+ * })
685
+ * async getUser(userId: string) {
686
+ * return await this.db.query(...);
687
+ * }
688
+ * ```
689
+ *
690
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
691
+ * ```ts
692
+ * @MethodCache({
693
+ * key: (matchId: string) => `match-${matchId}`,
694
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
695
+ * onCacheUpdated(this: MyService, value) {
696
+ * // 缓存刷新后触发(this = 当前 service 实例)
697
+ * this.webSocket.broadcast(value);
698
+ * },
699
+ * })
700
+ * async getMatch(matchId: string) { ... }
701
+ *
702
+ * // 清理缓存 → 旧值保留,后台异步刷新
703
+ * await clearMethodCache(`match-${id}`);
704
+ * ```
705
+ *
706
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
707
+ * ```ts
708
+ * @MethodCache({
709
+ * key: (matchId: string) => `match-timer-${matchId}`,
710
+ * clearKey: ['match'], // 声明依赖关系
711
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
712
+ * })
713
+ * async getMatchTimer(matchId: string) { ... }
714
+ *
715
+ * // 清理所有关联了 'match' 的缓存(包括 match-timer-*)
716
+ * await clearMethodCache('match');
717
+ * ```
718
+ *
719
+ * @example <caption>cacheOnly — 只读缓存,绝不调用原方法</caption>
720
+ * ```ts
721
+ * @MethodCache({
722
+ * key: 'app-config',
723
+ * cacheOnly: true, // 没有缓存返回 null
724
+ * })
725
+ * async loadConfig() { ... }
726
+ * ```
522
727
  */
523
728
  export function MethodCache(config) {
524
729
  return function (_target, _propertyKey, descriptor) {
525
730
  const fn = descriptor.value;
526
731
  descriptor.value = async function (...args) {
527
- const key = typeof config.key === 'function' ? config.key.call(this, ...args) : config.key;
528
- const clearKey = config.clearKey
529
- ? (typeof config.clearKey === 'function' ? config.clearKey.call(this, ...args) : config.clearKey)
530
- : undefined;
531
732
  const devid = config.clearWithSession && this.ctx && this.ctx.me && this.ctx.me.devid;
532
- return await excuteCacheCore({
533
- key,
534
- clearKey,
733
+ return await excuteWithCache({
734
+ key: config.key,
735
+ clearKey: config.clearKey,
535
736
  autoClearTime: config.autoClearTime,
536
737
  cacheNullValue: config.cacheNullValue,
537
738
  nullCacheTime: config.nullCacheTime,
538
739
  staleMode: config.staleMode,
539
740
  onCacheUpdated: config.onCacheUpdated,
540
- ctx: this, // 传递当前 this 上下文
541
- args, // 传递方法输入参数
741
+ cacheOnly: config.cacheOnly,
742
+ forceRefresh: config.forceRefresh,
743
+ ctx: this,
542
744
  devid,
543
- fn: async () => await fn.call(this, ...args)
544
- });
745
+ args,
746
+ }, async () => await fn.call(this, ...args));
545
747
  };
546
748
  };
547
749
  }
548
750
  /**
549
- * 外部直接设置缓存值。用于绕过 @MethodCache 装饰器直接更新缓存的场景
550
- * (如外部事件驱动的数据变更、手动刷新等)。
751
+ * 外部直接写入缓存(绕过 @MethodCache 装饰器)。
551
752
  *
552
- * 内部使用统一格式 { t, d, s, sa } 序列化,确保与 @MethodCache 的读取逻辑兼容。
753
+ * @MethodCache 共享同一套存储格式 `{ t, d, sa, n }`,写入的缓存可被
754
+ * @MethodCache 装饰的方法正常读取(包括 stale 判断、计数器等)。
553
755
  *
554
- * @param cacheKey 缓存 key(不含 [cache] 前缀,与 @MethodCache 的 key 一致)
555
- * @param value 要缓存的值
556
- * @param options 选项
756
+ * 适用场景:
757
+ * - 外部事件驱动的数据变更(如 MQ 消息、定时任务)
758
+ * - 手动刷新缓存(如管理后台"清除并重建"按钮)
759
+ * - 跨服务同步缓存状态
760
+ *
761
+ * @example <caption>基础用法 — 写入缓存</caption>
762
+ * ```ts
763
+ * await setCache('match-timer-123', timer, {
764
+ * autoClearTime: 120, // 120分钟后过期
765
+ * });
766
+ * ```
557
767
  *
558
- * @example
768
+ * @example <caption>关联清除 — 注册 clearKey 级联关系</caption>
559
769
  * ```ts
560
- * // 外部事件驱动更新缓存
561
- * await setCache('match-timer-123', timer, { autoClearTime: 120 });
770
+ * await setCache('match-timer-123', timer, {
771
+ * autoClearTime: 120,
772
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
773
+ * });
562
774
  *
563
- * // 同时触发 onCacheUpdated 回调(如果有)
775
+ * // 函数式 clearKey(参数来自 options.args)
776
+ * await setCache('match-timer-123', timer, {
777
+ * clearKey: (matchId) => [`match-${matchId}`],
778
+ * args: [matchId],
779
+ * });
780
+ * ```
781
+ *
782
+ * @example <caption>回调 — 缓存写入后触发通知</caption>
783
+ * ```ts
564
784
  * await setCache('match-timer-123', timer, {
565
785
  * autoClearTime: 120,
566
- * onCacheUpdated(this: MyService, value) {
567
- * this.broadcast(value);
568
- * }
786
+ * ctx: this,
787
+ * args: [matchId],
788
+ * onCacheUpdated(value, matchId) {
789
+ * this.webSocket.sendRoom(matchId, { action: 'timer', param: value });
790
+ * },
569
791
  * });
570
792
  * ```
571
793
  */
572
794
  export async function setCache(cacheKey, value, options = {}) {
573
- const { autoClearTime, cacheNullValue = false, onCacheUpdated, ctx, args = [] } = options;
795
+ const { autoClearTime, cacheNullValue = false, clearKey, onCacheUpdated, ctx, args = [] } = options;
574
796
  const isNullish = value === null || value === undefined;
575
797
  if (isNullish && !cacheNullValue) {
576
798
  return;
@@ -580,14 +802,24 @@ export async function setCache(cacheKey, value, options = {}) {
580
802
  const db = getRedisDB();
581
803
  // 保留旧 sa:沿用已有标志(防止外部直写清掉 sa=1,导致 clearMethodCache 变 purge)
582
804
  const existingSa = await db.get(CacheKey.value(cacheKey)).then(r => r ? deserializeCacheValue(r).sa : undefined);
583
- const payload = serializeCacheValue(dataStr, timestamp, false, !!existingSa);
805
+ const payload = serializeCacheValue(dataStr, timestamp, !!existingSa);
584
806
  if (autoClearTime) {
585
807
  await db.set(CacheKey.value(cacheKey), payload, 'EX', autoClearTime * 60);
586
808
  }
587
809
  else {
588
810
  await db.set(CacheKey.value(cacheKey), payload);
589
811
  }
812
+ // 注册关联清除关系(与 setMethodCache 一致)
813
+ if (clearKey) {
814
+ const clearKeys = typeof clearKey === 'function' ? clearKey(...args) : clearKey;
815
+ if (clearKeys && clearKeys.length > 0) {
816
+ for (const clear of clearKeys) {
817
+ await db.sadd(CacheKey.parent(clear), cacheKey);
818
+ await db.sadd(CacheKey.child(cacheKey), clear);
819
+ }
820
+ }
821
+ }
590
822
  // 触发回调
591
823
  void onCacheUpdated?.call(ctx, value, ...args);
592
- cacheLog(`cache ${cacheKey} externally set (t${timestamp})`);
824
+ cacheLog(`${cacheKey} externally set`);
593
825
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baja-lite",
3
- "version": "1.8.11",
3
+ "version": "1.8.13",
4
4
  "description": "some util for self",
5
5
  "homepage": "https://github.com/void-soul/baja-lite",
6
6
  "repository": {