baja-lite 1.8.9 → 1.8.11

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/cache.js CHANGED
@@ -1,8 +1,86 @@
1
1
  import { DBType } from 'baja-lite-field';
2
2
  import { sleep } from './fn.js';
3
3
  import { Throw } from './error.js';
4
- import { _dao, _EventBus, _GlobalSqlOption, _LoggerService, _memCache, _memInflight, _primaryDB, } from './const/symbols.js';
5
- import { StorageType } from './const/index.js';
4
+ import { _dao, _EventBus, _LoggerService, _primaryDB, } from './const/symbols.js';
5
+ /** @internal 输出缓存分类日志(仅当 setLogLevels 包含 'cache' 时生效) */
6
+ function cacheLog(message, ...params) {
7
+ const logger = globalThis[_LoggerService];
8
+ // pino-pretty 的 messageFormat 会根据 level=24 自动添加 [CACHE] 前缀
9
+ logger.debugCategory?.('cache', message, ...params);
10
+ }
11
+ /**
12
+ * @internal 全局 stale 刷新 Promise 映射。
13
+ * key = 缓存 key(不含前缀),value = 正在进行的 stale 刷新 Promise。
14
+ * @MethodCache 内部的 staleBackgroundRefresh 通过这个映射注册刷新 Promise,
15
+ * 同一 key 的并发 stale 命中复用同一个 Promise,避免重复执行 fn()。
16
+ * Redis 后端下此映射是每个进程本地的;但因为 [cache-sf]key 在 Redis 里全局可见,
17
+ * 所以跨进程的 single-flight 仍然有效。
18
+ */
19
+ const globalStaleInflight = new Map();
20
+ /**
21
+ * @internal [cache] 前缀下的子 key 命名全部收拢到这里,避免散落在各处 magic string。
22
+ *
23
+ * 命名规范:
24
+ * - [cache]key 缓存值(JSON: { v, d, s } — v=版本 d=数据 s=是否 stale)
25
+ * - [cache-sf]key single-flight 首飞标记(SETNX 抢锁用)
26
+ * - [cache-meta]key stale-allowed 标记(带 TTL,与缓存同生命周期)
27
+ * - [cache-parent]key / [cache-child]key 关联清除的 parent↔child 双向 set
28
+ */
29
+ const CacheKey = {
30
+ value: (k) => `[cache]${k}`,
31
+ sf: (k) => `[cache-sf]${k}`,
32
+ meta: (k) => `[cache-meta]${k}`,
33
+ parent: (k) => `[cache-parent]${k}`,
34
+ child: (k) => `[cache-child]${k}`,
35
+ };
36
+ /** @internal 序列化缓存值 */
37
+ function serializeCacheValue(data, timestamp, stale = false, staleAllowed = false) {
38
+ const v = { t: timestamp, d: data };
39
+ if (stale)
40
+ v.s = 1;
41
+ if (staleAllowed)
42
+ v.sa = 1;
43
+ return JSON.stringify(v);
44
+ }
45
+ /** @internal 反序列化缓存值 */
46
+ function deserializeCacheValue(raw) {
47
+ try {
48
+ const v = JSON.parse(raw);
49
+ return { ...v, isNullish: v.d === 'null' || v.d === 'undefined' };
50
+ }
51
+ catch {
52
+ // 旧格式兼容:纯字符串(无 JSON 包装)
53
+ return { t: 0, d: raw, isNullish: raw === 'null' || raw === 'undefined' };
54
+ }
55
+ }
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
+ /**
63
+ * 缓存被清理时的行为策略。
64
+ * 由 @MethodCache 的 `staleMode` 配置决定,clearMethodCache 依此自动选择清理方式。
65
+ */
66
+ export var CacheStaleMode;
67
+ (function (CacheStaleMode) {
68
+ /**
69
+ * 默认。清理时直接删除缓存值,下次请求触发 single-flight 重建。
70
+ * 优点:数据强一致,不会读到旧值。
71
+ * 缺点:重建期间所有请求被阻塞,直到 fn() 执行完毕。
72
+ */
73
+ CacheStaleMode[CacheStaleMode["Purge"] = 0] = "Purge";
74
+ /**
75
+ * 清理时保留旧值并标记为过期(stale),同时设一个 stale 窗口(默认 30s)。
76
+ * 窗口内:请求仍读到旧值(零阻塞),同时后台 single-flight 异步刷新。
77
+ * 窗口过后:stale 标记自动消失,退化回 Purge 行为。
78
+ *
79
+ * 适用场景:读多写少、能容忍秒级旧数据、fn() 执行代价高。
80
+ * 注意:开启后业务层可能收到乱序数据,需自行处理(如按时间戳丢弃过期包)。
81
+ */
82
+ CacheStaleMode[CacheStaleMode["StaleWhileRevalidate"] = 1] = "StaleWhileRevalidate";
83
+ })(CacheStaleMode || (CacheStaleMode = {}));
6
84
  export function getRedisDB(db) {
7
85
  const rd = globalThis[_dao][DBType.Redis][db ?? _primaryDB];
8
86
  Throw.if(!rd, 'not found redis!');
@@ -37,7 +115,7 @@ export async function GetRedisLock(key, lockMaxActive) {
37
115
  lastError = er;
38
116
  // 指数退避,避免 redis 抖动时打死服务
39
117
  const delay = Math.min(GET_REDIS_LOCK_BASE_DELAY * Math.pow(2, attempt), 1000);
40
- globalThis[_LoggerService].debug?.(`GetRedisLock ${key} attempt ${attempt + 1} failed: ${er?.message}, retry after ${delay}ms`);
118
+ globalThis[_LoggerService].debugCategory?.('sql', `GetRedisLock ${key} attempt ${attempt + 1} failed: ${er?.message}, retry after ${delay}ms`);
41
119
  await sleep(delay);
42
120
  }
43
121
  finally {
@@ -63,24 +141,24 @@ export async function excuteWithLock(config, fn__) {
63
141
  const lock = await GetRedisLock(key, config.lockMaxActive);
64
142
  if (lock === false) {
65
143
  if (config.lockWait !== false && ((config.lockMaxWaitTime ?? 0) === 0 || (wait_time + (config.lockRetryInterval ?? 100)) <= (config.lockMaxWaitTime ?? 0))) {
66
- globalThis[_LoggerService].debug?.(`get lock ${key} fail, retry after ${config.lockRetryInterval ?? 100}ms...`);
144
+ globalThis[_LoggerService].debugCategory?.('sql', `get lock ${key} fail, retry after ${config.lockRetryInterval ?? 100}ms...`);
67
145
  await sleep(config.lockRetryInterval ?? 100);
68
146
  wait_time += (config.lockRetryInterval ?? 100);
69
147
  return await fn();
70
148
  }
71
149
  else {
72
- globalThis[_LoggerService].debug?.(`get lock ${key} fail`);
150
+ globalThis[_LoggerService].debugCategory?.('sql', `get lock ${key} fail`);
73
151
  throw new Error(config.errorMessage || `get lock fail: ${key}`);
74
152
  }
75
153
  }
76
154
  else {
77
- globalThis[_LoggerService].debug?.(`get lock ${key} ok!`);
155
+ globalThis[_LoggerService].debugCategory?.('sql', `get lock ${key} ok!`);
78
156
  await db.pexpire(key, config.lockMaxTime ?? 60000);
79
157
  try {
80
158
  return await fn__();
81
159
  }
82
160
  finally {
83
- globalThis[_LoggerService].debug?.(`unlock ${key} ok!`);
161
+ globalThis[_LoggerService].debugCategory?.('sql', `unlock ${key} ok!`);
84
162
  await db.decr(key);
85
163
  }
86
164
  }
@@ -105,114 +183,10 @@ export function MethodLock(config) {
105
183
  };
106
184
  };
107
185
  }
108
- /** 全局 LRU 上限。可在 boot 时通过 `globalThis[_GlobalSqlOption].memCacheMaxSize` 覆盖。 */
109
- const MEMORY_CACHE_DEFAULT_MAX_SIZE = 10000;
110
- function getMemCache() {
111
- let m = globalThis[_memCache];
112
- if (!m) {
113
- m = new Map();
114
- globalThis[_memCache] = m;
115
- }
116
- return m;
117
- }
118
- function getMemInflight() {
119
- let m = globalThis[_memInflight];
120
- if (!m) {
121
- m = new Map();
122
- globalThis[_memInflight] = m;
123
- }
124
- return m;
125
- }
126
- function getMemCacheMaxSize() {
127
- return globalThis[_GlobalSqlOption]?.memCacheMaxSize ?? MEMORY_CACHE_DEFAULT_MAX_SIZE;
128
- }
129
- /** 内存版的 parent→children 关联表,对应 redis 的 [cache-parent]* / [cache-child]* set。 */
130
- const memParentChild = {
131
- parentToChildren: new Map(),
132
- childToParents: new Map(),
133
- link(parents, child) {
134
- for (const p of parents) {
135
- let s = this.parentToChildren.get(p);
136
- if (!s) {
137
- s = new Set();
138
- this.parentToChildren.set(p, s);
139
- }
140
- s.add(child);
141
- let r = this.childToParents.get(child);
142
- if (!r) {
143
- r = new Set();
144
- this.childToParents.set(child, r);
145
- }
146
- r.add(p);
147
- }
148
- },
149
- unlinkChild(child) {
150
- const parents = this.childToParents.get(child);
151
- if (!parents)
152
- return;
153
- for (const p of parents) {
154
- this.parentToChildren.get(p)?.delete(child);
155
- }
156
- this.childToParents.delete(child);
157
- },
158
- drainParent(parent) {
159
- const children = this.parentToChildren.get(parent);
160
- if (!children)
161
- return [];
162
- const list = [...children];
163
- for (const c of list) {
164
- this.childToParents.get(c)?.delete(parent);
165
- }
166
- this.parentToChildren.delete(parent);
167
- return list;
168
- }
169
- };
170
186
  /**
171
- * 写一条内存缓存。
172
- * - Map 的插入顺序天然实现 LRU:先 delete 再 set,把 key 放到尾部;
173
- * 超过上限时从头部(最少最近使用)开始淘汰。
174
- * - `cacheNullValue=true` 时,null/undefined 也会以 null 形式写入(防穿透)。
187
+ * 内存 LRU 条目,存储格式与 Redis 一致(CacheValue)。
188
+ * `expiresAt = 0` 表示永不过期。
175
189
  */
176
- function memSet(key, result, config) {
177
- const isNullish = result === null || result === undefined;
178
- if (isNullish && !config.cacheNullValue) {
179
- return;
180
- }
181
- const ttlMinutes = isNullish ? (config.nullCacheTime ?? config.autoClearTime) : config.autoClearTime;
182
- const expiresAt = ttlMinutes ? Date.now() + ttlMinutes * 60000 : 0;
183
- const value = isNullish ? null : result;
184
- const cache = getMemCache();
185
- if (cache.has(key))
186
- cache.delete(key);
187
- cache.set(key, { value, expiresAt });
188
- const max = getMemCacheMaxSize();
189
- while (cache.size > max) {
190
- const oldest = cache.keys().next().value;
191
- if (oldest === undefined)
192
- break;
193
- cache.delete(oldest);
194
- memParentChild.unlinkChild(oldest);
195
- }
196
- if (config.clearKey && config.clearKey.length > 0) {
197
- memParentChild.link(config.clearKey, key);
198
- }
199
- globalThis[_LoggerService].debug?.(`memcache ${key} seted${isNullish ? ' (null)' : ''}!`);
200
- }
201
- /** 读内存缓存,lazy expire。命中后挪到 LRU 尾部。 */
202
- function memGet(key) {
203
- const cache = getMemCache();
204
- const entry = cache.get(key);
205
- if (!entry)
206
- return { hit: false };
207
- if (entry.expiresAt !== 0 && entry.expiresAt <= Date.now()) {
208
- cache.delete(key);
209
- memParentChild.unlinkChild(key);
210
- return { hit: false };
211
- }
212
- cache.delete(key);
213
- cache.set(key, entry);
214
- return { hit: true, value: entry.value };
215
- }
216
190
  /** 设置方法缓存 */
217
191
  async function setMethodCache(config, devid) {
218
192
  const db = getRedisDB();
@@ -222,7 +196,7 @@ async function setMethodCache(config, devid) {
222
196
  return;
223
197
  }
224
198
  // 统一序列化:undefined 不是合法 JSON,归一化成 null。
225
- const payload = isNullish ? 'null' : JSON.stringify(config.result);
199
+ const dataStr = isNullish ? 'null' : JSON.stringify(config.result);
226
200
  // 负缓存通常用更短 TTL,避免业务恢复后还长时间返回旧的 null。
227
201
  const ttl = isNullish
228
202
  ? (config.nullCacheTime ?? config.autoClearTime)
@@ -230,100 +204,122 @@ async function setMethodCache(config, devid) {
230
204
  // 映射关系存放(负缓存也参与关联清除,因为外部清缓存的语义不区分正负)
231
205
  if (config.clearKey && config.clearKey.length > 0) {
232
206
  for (const clear of config.clearKey) {
233
- await db.sadd(`[cache-parent]${clear}`, config.key);
234
- await db.sadd(`[cache-child]${config.key}`, clear);
207
+ await db.sadd(CacheKey.parent(clear), config.key);
208
+ await db.sadd(CacheKey.child(config.key), clear);
235
209
  }
236
210
  }
211
+ // 写入缓存值(带时间戳 + staleAllowed 标志)。
212
+ // 保留旧 sa:只有显式 StaleWhileRevalidate 才设 1,否则沿用旧值(防止覆盖已有的 sa=1)。
213
+ 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);
237
217
  if (ttl) { // 自动清空
238
- await db.set(`[cache]${config.key}`, payload, 'EX', ttl * 60);
239
- globalThis[_LoggerService].debug?.(`cache ${config.key} seted${isNullish ? ' (null)' : ''}!`);
240
- // 订阅:清空 clear list —— 同一 key 在缓存生命周期内只注册一次监听器,
241
- // 否则每次 miss 都会 .on 一次,clearMethodCache 触发时回调会被执行 N 遍,
242
- // 长期运行造成监听器泄漏。
243
- if (config.clearKey && config.clearKey.length > 0) {
244
- const event = `[cache]${config.key}`;
245
- if (globalThis[_EventBus].listenerCount(event) === 0) {
246
- globalThis[_EventBus].on(event, async (key) => {
247
- await clearChild(key, true);
248
- globalThis[_LoggerService].debug?.(`cache ${key} clear by key!`);
249
- });
250
- }
251
- }
218
+ await db.set(CacheKey.value(config.key), payload, 'EX', ttl * 60);
219
+ cacheLog(`cache ${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
252
220
  }
253
221
  else {
254
- await db.set(`[cache]${config.key}`, payload);
255
- globalThis[_LoggerService].debug?.(`cache ${config.key} seted${isNullish ? ' (null)' : ''}!`);
222
+ await db.set(CacheKey.value(config.key), payload);
223
+ cacheLog(`cache ${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
224
+ }
225
+ // staleAllowed 已存储在缓存值内部(CacheValue.sa),无需单独的 meta key
226
+ // 删除旧格式残留(兼容升级)
227
+ await db.del(CacheKey.meta(config.key));
228
+ // 订阅:清空 clear list —— 同一 key 在缓存生命周期内只注册一次监听器,
229
+ // 否则每次 miss 都会 .on 一次,clearMethodCache 触发时回调会被执行 N 遍,
230
+ // 长期运行造成监听器泄漏。
231
+ if (config.clearKey && config.clearKey.length > 0) {
232
+ const event = CacheKey.value(config.key);
233
+ if (globalThis[_EventBus].listenerCount(event) === 0) {
234
+ globalThis[_EventBus].on(event, async (key) => {
235
+ await clearCacheKey(key);
236
+ cacheLog(`cache ${key} clear by key!`);
237
+ });
238
+ }
256
239
  }
257
240
  if (devid) {
258
241
  // 订阅:清空 clear list —— 同样去重,避免每次 miss 都重复注册。
259
242
  const event = `user-${devid}`;
260
243
  if (globalThis[_EventBus].listenerCount(event) === 0) {
261
- globalThis[_EventBus].on(event, async function (key) {
262
- await clearChild(key);
263
- globalThis[_LoggerService].debug?.(`cache ${key} clear by devid!`);
244
+ globalThis[_EventBus].on(event, async (key) => {
245
+ await clearCacheKey(key);
246
+ cacheLog(`cache ${key} clear by devid!`);
264
247
  });
265
248
  }
266
249
  }
267
250
  }
268
251
  /**
269
- * 清空方法缓存。同时清 redis memory 两边,业务侧无需关心存储是哪种。
270
- * - redis 侧只在配置了 redis 时才操作;没配 redis 时安静跳过,不抛错。
271
- * - 关联清除(clearKey)在两边都生效。
252
+ * 单个缓存 key stale-or-purge 决策与执行。
253
+ * - CacheValue.sa=1 标记 s=1(保留缓存值,StaleWhileRevalidate)
254
+ * - 否则 → 直接删缓存值(Purge)
255
+ *
256
+ * 无论哪种路径,parent↔child 关联关系都清理。
257
+ *
258
+ * @returns 受影响的 children 列表(parent 路径会返回所有被级联清理的 child key)
272
259
  */
273
- export async function clearMethodCache(key) {
274
- // 内存侧
275
- const cache = getMemCache();
276
- // 1. 如果 key 是 parent,把它关联的所有 children 都清掉
277
- const childrenFromParent = memParentChild.drainParent(key);
278
- for (const child of childrenFromParent) {
279
- cache.delete(child);
280
- memParentChild.unlinkChild(child);
281
- }
282
- // 2. 如果 key 本身是个缓存条目,直接清,并清掉它的反向链接
283
- if (cache.has(key)) {
284
- cache.delete(key);
285
- }
286
- memParentChild.unlinkChild(key);
287
- // redis 侧:没配 redis 时跳过
288
- const redisDao = globalThis[_dao]?.[DBType.Redis]?.[_primaryDB];
289
- if (!redisDao)
290
- return;
260
+ async function clearCacheKey(key) {
291
261
  const db = getRedisDB();
292
- let type = await db.type(`[cache-parent]${key}`);
293
- if (type === 'set') {
294
- await clearParent(key);
262
+ const affectedChildren = [];
263
+ // 1. 先处理 parent 级联:把关联的 children 全部清掉
264
+ const parentType = await db.type(CacheKey.parent(key));
265
+ if (parentType === 'set') {
266
+ const childKeys = await db.smembers(CacheKey.parent(key));
267
+ for (const child of childKeys) {
268
+ cacheLog(`cache ${child} cleared via parent ${key}!`);
269
+ await clearCacheKey(child);
270
+ affectedChildren.push(child);
271
+ }
272
+ await db.del(CacheKey.parent(key));
295
273
  }
296
- type = await db.type(`[cache]${key}`);
297
- if (type !== 'none') {
298
- await clearChild(key);
274
+ // 2. 判断 stale-allowed:查缓存值内部的 sa 标志
275
+ const valueRaw = await db.get(CacheKey.value(key));
276
+ const v = valueRaw ? deserializeCacheValue(valueRaw) : null;
277
+ 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);
283
+ }
284
+ else {
285
+ await db.set(CacheKey.value(key), newPayload);
286
+ }
287
+ cacheLog(`cache ${key} marked stale (t${v.t})`);
299
288
  }
300
- }
301
- async function clearChild(key, skipDel = false) {
302
- const db = getRedisDB();
303
- if (skipDel === false) {
304
- await db.del(`[cache]${key}`);
289
+ else {
290
+ // Purge:直接删缓存值
291
+ await db.del(CacheKey.value(key));
292
+ cacheLog(`cache ${key} purged!`);
305
293
  }
306
- const childtype = await db.type(`[cache-child]${key}`);
307
- if (childtype === 'set') {
308
- const parentKeys = await db.smembers(`[cache-child]${key}`);
309
- for (const clear of parentKeys) {
310
- const type = await db.type(`[cache-parent]${clear}`);
311
- if (type === 'set') {
312
- await db.srem(`[cache-parent]${clear}`, key);
294
+ // 清理旧格式残留
295
+ await db.del(CacheKey.meta(key));
296
+ // 3. 清理 child→parent 反向关联
297
+ const childType = await db.type(CacheKey.child(key));
298
+ if (childType === 'set') {
299
+ const parentKeys = await db.smembers(CacheKey.child(key));
300
+ for (const pk of parentKeys) {
301
+ const t = await db.type(CacheKey.parent(pk));
302
+ if (t === 'set') {
303
+ await db.srem(CacheKey.parent(pk), key);
313
304
  }
314
305
  }
315
- await db.del(`[cache-child]${key}`);
306
+ await db.del(CacheKey.child(key));
316
307
  }
308
+ return affectedChildren;
317
309
  }
318
- async function clearParent(clearKey) {
319
- const db = getRedisDB();
320
- const keys = await db.smembers(`[cache-parent]${clearKey}`);
321
- if (keys) {
322
- for (const key of keys) {
323
- globalThis[_LoggerService].debug?.(`cache ${key} cleared!`);
324
- await clearChild(key);
325
- }
326
- }
310
+ /**
311
+ * 清空方法缓存。
312
+ * - redis 侧只在配置了 redis 时才操作;没配 redis 时安静跳过,不抛错。
313
+ * - 关联清除(clearKey)生效。
314
+ * - 被清理的 key 如果启用了 StaleWhileRevalidate(CacheValue.sa=1),会保留旧值并标记 stale。
315
+ *
316
+ * @param key 要清理的缓存 key 或 parent key
317
+ */
318
+ export async function clearMethodCache(key) {
319
+ const redisDao = globalThis[_dao]?.[DBType.Redis]?.[_primaryDB];
320
+ if (!redisDao)
321
+ return;
322
+ await clearCacheKey(key);
327
323
  }
328
324
  /**
329
325
  * Redis 缓存执行:先查缓存 → miss 后单飞抢锁执行 → 其它等待者只盯缓存、不抢锁。
@@ -336,30 +332,92 @@ async function clearParent(clearKey) {
336
332
  * 命中判断用 `cached !== null`(ioredis 在 key 不存在时返回 `null`),
337
333
  * 这样 `cacheNullValue=true` 时存进去的字面量 `'null'` 也会算作命中,达到防穿透效果。
338
334
  *
335
+ * 命中时若发现 stale 标志(CacheValue.s=1),说明该值是"已被声明过期但保留的旧值",
336
+ * 此时仍返回旧值(零阻塞),同时触发后台 single-flight 异步刷新。
337
+ * 这样 clearMethodCache 后的首次读取不会被阻塞,后续读取将命中刷新后的新值。
338
+ *
339
339
  * redis 不可用或 SETNX 异常时退化为无锁直跑,保留可用性。
340
340
  */
341
341
  const SINGLEFLIGHT_LOCK_TTL_MS = 30000; // 首飞标记 TTL,兜底首飞挂掉的情况
342
342
  const SINGLEFLIGHT_POLL_INTERVAL_MS = 50; // 等待者轮询缓存的间隔
343
343
  const SINGLEFLIGHT_MAX_WAIT_MS = 30000; // 等待者最长等多久;超时就自己跑 fn(保底,不应该常发生)
344
+ /**
345
+ * 后台异步刷新:fire-and-forget,不阻塞调用方。
346
+ * 只有抢到首飞标记的实例才会执行 fn() 并写缓存;其它实例直接放弃。
347
+ * 刷新完成后删除 stale 标记,后续读取将命中新值。
348
+ * 如果配置了 onCacheUpdated 回调,调用时传入 ctx(方法的 this 上下文)。
349
+ */
350
+ async function staleBackgroundRefresh(opts, setOpts) {
351
+ const db = getRedisDB();
352
+ const sfKey = CacheKey.sf(opts.key);
353
+ try {
354
+ const ok = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
355
+ if (ok !== 'OK')
356
+ return; // 别人已经在刷新了,自己放弃
357
+ }
358
+ catch {
359
+ return; // redis 出错,放弃刷新,下次读取再试
360
+ }
361
+ try {
362
+ 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!`);
368
+ }
369
+ catch (error) {
370
+ globalThis[_LoggerService].warn?.(`cache ${opts.key} stale background refresh failed: ${error?.message}`);
371
+ }
372
+ finally {
373
+ try {
374
+ await db.del(sfKey);
375
+ }
376
+ catch { /* TTL 兜底 */ }
377
+ }
378
+ }
344
379
  async function redisCacheCore(opts) {
345
380
  const db = getRedisDB();
346
- const cacheKey = `[cache]${opts.key}`;
347
- const cached = await db.get(cacheKey);
348
- if (cached !== null) {
349
- globalThis[_LoggerService].debug?.(`cache ${opts.key} hit!`);
350
- return JSON.parse(cached);
381
+ const cacheKey = CacheKey.value(opts.key);
382
+ const raw = await db.get(cacheKey);
383
+ if (raw !== null) {
384
+ // 一次读取拿到全部信息(值 + stale 状态)
385
+ 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);
400
+ });
401
+ globalStaleInflight.set(opts.key, refreshP);
402
+ }
403
+ }
404
+ else {
405
+ cacheLog(`cache ${opts.key} hit (t${v.t})!`);
406
+ }
407
+ return JSON.parse(v.d);
351
408
  }
352
- globalThis[_LoggerService].debug?.(`cache ${opts.key} miss!`);
409
+ cacheLog(`cache ${opts.key} miss!`);
353
410
  const setOpts = {
354
411
  clearKey: opts.clearKey,
355
412
  autoClearTime: opts.autoClearTime,
356
413
  cacheNullValue: opts.cacheNullValue,
357
- nullCacheTime: opts.nullCacheTime
414
+ nullCacheTime: opts.nullCacheTime,
415
+ staleMode: opts.staleMode
358
416
  };
359
417
  // 用一个独立的"首飞标记" key 选出谁去执行 fn——SET NX PX 是原子操作,
360
418
  // 同一时刻只有一个调用者拿到 true,其它返回 null。这把"标记"不重入也不计数,
361
419
  // 只是个 single-flight 指示器,和 redlock / GetRedisLock 的并发计数器不冲突。
362
- const sfKey = `[sf]${opts.key}`;
420
+ const sfKey = CacheKey.sf(opts.key);
363
421
  let isLeader = false;
364
422
  try {
365
423
  const ok = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
@@ -370,6 +428,7 @@ async function redisCacheCore(opts) {
370
428
  globalThis[_LoggerService].warn?.(`single-flight setnx ${opts.key} failed: ${error?.message}, fallback to no-lock`);
371
429
  const result = await opts.fn();
372
430
  await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
431
+ void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
373
432
  return result;
374
433
  }
375
434
  if (isLeader) {
@@ -377,6 +436,7 @@ async function redisCacheCore(opts) {
377
436
  try {
378
437
  const result = await opts.fn();
379
438
  await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
439
+ void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
380
440
  return result;
381
441
  }
382
442
  finally {
@@ -394,18 +454,19 @@ async function redisCacheCore(opts) {
394
454
  await sleep(SINGLEFLIGHT_POLL_INTERVAL_MS);
395
455
  const recheck = await db.get(cacheKey);
396
456
  if (recheck !== null) {
397
- globalThis[_LoggerService].debug?.(`cache ${opts.key} hit after wait!`);
398
- return JSON.parse(recheck);
457
+ cacheLog(`cache ${opts.key} hit after wait!`);
458
+ return JSON.parse(deserializeCacheValue(recheck).d);
399
459
  }
400
460
  // 首飞挂了(TTL 到期,sf 标记自动消失)但缓存仍然空——抢做下一任首飞
401
461
  const leaderGone = await db.get(sfKey);
402
462
  if (leaderGone === null) {
403
463
  const taken = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
404
464
  if (taken === 'OK') {
405
- globalThis[_LoggerService].debug?.(`cache ${opts.key}: previous leader gone, taking over`);
465
+ cacheLog(`cache ${opts.key}: previous leader gone, taking over`);
406
466
  try {
407
467
  const result = await opts.fn();
408
468
  await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
469
+ void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
409
470
  return result;
410
471
  }
411
472
  finally {
@@ -422,56 +483,20 @@ async function redisCacheCore(opts) {
422
483
  globalThis[_LoggerService].warn?.(`cache ${opts.key}: wait timed out, running fn without lock`);
423
484
  const result = await opts.fn();
424
485
  await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
486
+ void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
425
487
  return result;
426
488
  }
427
489
  }
428
490
  }
429
- /**
430
- * 内存缓存执行:单进程内的 single-flight 不需要任何分布式协调——
431
- * JS 单线程 + 同一份 in-flight Map 就够了。同一 key 的并发调用全部 await 同一个 Promise,
432
- * Promise 解析后所有等待者下个事件循环 tick 并发返回,零序列化、零 redis 往返。
433
- */
434
- async function memoryCacheCore(opts) {
435
- const cacheKey = opts.key;
436
- // 1. 看缓存
437
- const hit = memGet(cacheKey);
438
- if (hit.hit) {
439
- globalThis[_LoggerService].debug?.(`memcache ${opts.key} hit!`);
440
- return hit.value;
441
- }
442
- globalThis[_LoggerService].debug?.(`memcache ${opts.key} miss!`);
443
- // 2. 已有等待中的 Promise → 直接 await,single-flight 自动达成
444
- const inflight = getMemInflight();
445
- const existing = inflight.get(cacheKey);
446
- if (existing) {
447
- return await existing;
448
- }
449
- // 3. 首飞:把 Promise 提前塞进 inflight,让后到者命中
450
- const p = (async () => {
451
- try {
452
- const result = await opts.fn();
453
- memSet(cacheKey, result, {
454
- autoClearTime: opts.autoClearTime,
455
- cacheNullValue: opts.cacheNullValue,
456
- nullCacheTime: opts.nullCacheTime,
457
- clearKey: opts.clearKey
458
- });
459
- return result;
460
- }
461
- finally {
462
- inflight.delete(cacheKey);
463
- }
464
- })();
465
- inflight.set(cacheKey, p);
466
- return await p;
467
- }
491
+ /** @internal 唯一的执行入口(Memory 后端已移除) */
468
492
  async function excuteCacheCore(opts) {
469
- return opts.storage === StorageType.Memory ? await memoryCacheCore(opts) : await redisCacheCore(opts);
493
+ return await redisCacheCore(opts);
470
494
  }
471
495
  /**
472
496
  * 执行一个方法fn,
473
497
  * 如果有缓存,则返回缓存,否则【加锁、二次检查】后执行方法并缓存,防止缓存击穿。
474
498
  * 可选 `cacheNullValue=true` 开启负缓存,防穿透;负缓存默认沿用 autoClearTime,可单独用 nullCacheTime 设短。
499
+ * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
475
500
  */
476
501
  export async function excuteWithCache(config, fn) {
477
502
  const key = typeof config.key === 'function' ? config.key() : config.key;
@@ -481,7 +506,10 @@ export async function excuteWithCache(config, fn) {
481
506
  autoClearTime: config.autoClearTime,
482
507
  cacheNullValue: config.cacheNullValue,
483
508
  nullCacheTime: config.nullCacheTime,
484
- storage: config.storage,
509
+ staleMode: config.staleMode,
510
+ onCacheUpdated: config.onCacheUpdated,
511
+ ctx: config.ctx,
512
+ args: config.args ?? [],
485
513
  fn
486
514
  });
487
515
  }
@@ -489,7 +517,8 @@ export async function excuteWithCache(config, fn) {
489
517
  * 缓存注解:先查缓存 → miss 才加锁 → 锁内再查一次 → 仍 miss 才执行原方法。
490
518
  * 已内置 single-flight,不需要再叠 `@MethodLock`。
491
519
  * 可选 `cacheNullValue=true` 开启负缓存,防穿透。
492
- * 可选 `storage: 'memory'` 用进程内 LRU 替代 redis(单进程场景,毫秒级延迟)。
520
+ * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
521
+ * 可选 `onCacheUpdated` 设置缓存更新回调(带 this 上下文)。
493
522
  */
494
523
  export function MethodCache(config) {
495
524
  return function (_target, _propertyKey, descriptor) {
@@ -506,10 +535,59 @@ export function MethodCache(config) {
506
535
  autoClearTime: config.autoClearTime,
507
536
  cacheNullValue: config.cacheNullValue,
508
537
  nullCacheTime: config.nullCacheTime,
509
- storage: config.storage,
538
+ staleMode: config.staleMode,
539
+ onCacheUpdated: config.onCacheUpdated,
540
+ ctx: this, // 传递当前 this 上下文
541
+ args, // 传递方法输入参数
510
542
  devid,
511
543
  fn: async () => await fn.call(this, ...args)
512
544
  });
513
545
  };
514
546
  };
515
547
  }
548
+ /**
549
+ * 外部直接设置缓存值。用于绕过 @MethodCache 装饰器直接更新缓存的场景
550
+ * (如外部事件驱动的数据变更、手动刷新等)。
551
+ *
552
+ * 内部使用统一格式 { t, d, s, sa } 序列化,确保与 @MethodCache 的读取逻辑兼容。
553
+ *
554
+ * @param cacheKey 缓存 key(不含 [cache] 前缀,与 @MethodCache 的 key 一致)
555
+ * @param value 要缓存的值
556
+ * @param options 选项
557
+ *
558
+ * @example
559
+ * ```ts
560
+ * // 外部事件驱动更新缓存
561
+ * await setCache('match-timer-123', timer, { autoClearTime: 120 });
562
+ *
563
+ * // 同时触发 onCacheUpdated 回调(如果有)
564
+ * await setCache('match-timer-123', timer, {
565
+ * autoClearTime: 120,
566
+ * onCacheUpdated(this: MyService, value) {
567
+ * this.broadcast(value);
568
+ * }
569
+ * });
570
+ * ```
571
+ */
572
+ export async function setCache(cacheKey, value, options = {}) {
573
+ const { autoClearTime, cacheNullValue = false, onCacheUpdated, ctx, args = [] } = options;
574
+ const isNullish = value === null || value === undefined;
575
+ if (isNullish && !cacheNullValue) {
576
+ return;
577
+ }
578
+ const dataStr = isNullish ? 'null' : JSON.stringify(value);
579
+ const timestamp = Date.now();
580
+ const db = getRedisDB();
581
+ // 保留旧 sa:沿用已有标志(防止外部直写清掉 sa=1,导致 clearMethodCache 变 purge)
582
+ const existingSa = await db.get(CacheKey.value(cacheKey)).then(r => r ? deserializeCacheValue(r).sa : undefined);
583
+ const payload = serializeCacheValue(dataStr, timestamp, false, !!existingSa);
584
+ if (autoClearTime) {
585
+ await db.set(CacheKey.value(cacheKey), payload, 'EX', autoClearTime * 60);
586
+ }
587
+ else {
588
+ await db.set(CacheKey.value(cacheKey), payload);
589
+ }
590
+ // 触发回调
591
+ void onCacheUpdated?.call(ctx, value, ...args);
592
+ cacheLog(`cache ${cacheKey} externally set (t${timestamp})`);
593
+ }