@hile/cache 2.0.7 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/dist/define.d.ts +23 -2
- package/dist/define.js +17 -2
- package/dist/index.d.ts +20 -1
- package/dist/index.js +146 -19
- package/dist/options.d.ts +8 -0
- package/dist/options.js +12 -0
- package/dist/payload.d.ts +13 -0
- package/dist/payload.js +79 -0
- package/dist/tag-scripts.d.ts +3 -0
- package/dist/tag-scripts.js +61 -0
- package/dist/tags.d.ts +19 -0
- package/dist/tags.js +58 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
基于 Redis 的类型安全读穿透缓存层。依赖 `ioredis`,构造时注入已连接的 `Redis` 实例(可与 `@hile/ioredis` 配合使用)。
|
|
4
4
|
|
|
5
|
+
从 3.0.0 开始,新增或重构的 Hile 架构包统一进入 3.x 版本线,2.x 时代结束。
|
|
6
|
+
|
|
5
7
|
## 安装
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -23,6 +25,11 @@ const userCache = defineCache('user:{id:string}:info', async (params) => {
|
|
|
23
25
|
// params 的类型自动推导为 { id: string }
|
|
24
26
|
const data = await fetchUserFromDB(params.id);
|
|
25
27
|
return new Cache(data).setExpire(300); // 5 分钟 TTL
|
|
28
|
+
}, {
|
|
29
|
+
singleflight: true,
|
|
30
|
+
stale: { ttl: 60 },
|
|
31
|
+
negative: { ttl: 30 },
|
|
32
|
+
tags: ({ id }) => ['users', `user:${id}`],
|
|
26
33
|
});
|
|
27
34
|
```
|
|
28
35
|
|
|
@@ -49,6 +56,9 @@ const exists = await has({ id: 'u-001' });
|
|
|
49
56
|
|
|
50
57
|
// 删除
|
|
51
58
|
await remove({ id: 'u-001' });
|
|
59
|
+
|
|
60
|
+
// 按 tag 批量失效
|
|
61
|
+
await cache.removeTag('users');
|
|
52
62
|
```
|
|
53
63
|
|
|
54
64
|
---
|
|
@@ -86,6 +96,31 @@ read(params)
|
|
|
86
96
|
└─ false → write(params) → handler 回源 → SET/SETEX → 返回
|
|
87
97
|
```
|
|
88
98
|
|
|
99
|
+
### Singleflight
|
|
100
|
+
|
|
101
|
+
`singleflight` 使用 `@hile/redis-lock` 合并同一个 key 的并发 miss,避免热点 key 同时打到数据库。
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
const userCache = defineCache('user:{id:string}', fetchUser, {
|
|
105
|
+
singleflight: {
|
|
106
|
+
ttl: 10_000,
|
|
107
|
+
wait: 10_000,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Stale Cache
|
|
113
|
+
|
|
114
|
+
`stale: { ttl }` 会在数据过了 `Cache#setExpire()` 的 fresh 时间后继续保留一段 stale 时间。读取 stale 数据时立即返回旧值,并在后台刷新。
|
|
115
|
+
|
|
116
|
+
### Negative Cache
|
|
117
|
+
|
|
118
|
+
默认 `new Cache(undefined)` 不写 Redis。配置 `negative: { ttl }` 后会写入一个短 TTL 哨兵,避免不存在的数据反复回源。
|
|
119
|
+
|
|
120
|
+
### Tags
|
|
121
|
+
|
|
122
|
+
`tags` 会把缓存 key 记录到 Redis set,之后可用 `cache.removeTag(tag)` 批量删除。
|
|
123
|
+
|
|
89
124
|
---
|
|
90
125
|
|
|
91
126
|
## API 参考
|
|
@@ -95,15 +130,20 @@ read(params)
|
|
|
95
130
|
```typescript
|
|
96
131
|
function defineCache<T extends string = string, R = any>(
|
|
97
132
|
key: T,
|
|
98
|
-
fn: (params: ExtractParams<T>) => Promise<Cache<R
|
|
133
|
+
fn: (params: ExtractParams<T>) => Promise<Cache<R>>,
|
|
134
|
+
options?: boolean | DefineCacheOptions<T, R>
|
|
99
135
|
): DefineCacheResult<T, R>;
|
|
100
136
|
```
|
|
101
137
|
|
|
138
|
+
第三个参数仍兼容旧的 `boolean` fieldable 写法;新能力使用 options 对象。
|
|
139
|
+
|
|
140
|
+
> `fieldable` 使用 Redis Hash 存储,不兼容 `stale` 和 `negative` 这类依赖字符串 payload 的策略;组合使用会在 `defineCache()` 阶段直接抛出配置错误。
|
|
141
|
+
|
|
102
142
|
### RedisCache
|
|
103
143
|
|
|
104
144
|
```typescript
|
|
105
145
|
class RedisCache {
|
|
106
|
-
constructor(prefix: string, redis: Redis);
|
|
146
|
+
constructor(prefix: string, redis: Redis, options?: { locks?: RedisLock });
|
|
107
147
|
|
|
108
148
|
loadCache<T extends string, R>(
|
|
109
149
|
target: DefineCacheResult<T, R>
|
|
@@ -113,6 +153,8 @@ class RedisCache {
|
|
|
113
153
|
remove(params: ExtractParams<T>): Promise<number>;
|
|
114
154
|
has(params: ExtractParams<T>): Promise<boolean>;
|
|
115
155
|
}>;
|
|
156
|
+
|
|
157
|
+
removeTag(tag: string): Promise<number>;
|
|
116
158
|
}
|
|
117
159
|
```
|
|
118
160
|
|
|
@@ -122,6 +164,7 @@ class RedisCache {
|
|
|
122
164
|
| `write` | `R \| undefined` | 强制执行回源并写入 Redis(data=undefined 则删除 key) |
|
|
123
165
|
| `remove` | `number` | 删除缓存,返回 0/1 |
|
|
124
166
|
| `has` | `boolean` | 检查 key 是否存在 |
|
|
167
|
+
| `removeTag` | `number` | 删除某个 tag 下记录的所有缓存 key |
|
|
125
168
|
|
|
126
169
|
### Redis 中的 key 结构
|
|
127
170
|
|
package/dist/define.d.ts
CHANGED
|
@@ -11,9 +11,30 @@ export type ExtractParams<Template extends string> = string extends Template ? R
|
|
|
11
11
|
[K in Key]: Type extends "string" ? string : Type extends "number" ? number : Type extends "boolean" ? boolean : never;
|
|
12
12
|
} & ExtractParams<Rest> : {};
|
|
13
13
|
export type DefineCacheHandler<T extends string = string, R = any> = (opts: ExtractParams<T>) => Promise<Cache<R>>;
|
|
14
|
+
export type CacheTagResolver<T extends string = string, R = any> = string[] | ((params: ExtractParams<T>, data: R | undefined) => string[]);
|
|
15
|
+
export type CacheSingleflightOptions = {
|
|
16
|
+
ttl?: number;
|
|
17
|
+
wait?: number;
|
|
18
|
+
pollInterval?: number;
|
|
19
|
+
maxPollInterval?: number;
|
|
20
|
+
};
|
|
21
|
+
export type CacheStaleOptions = {
|
|
22
|
+
ttl: number;
|
|
23
|
+
};
|
|
24
|
+
export type CacheNegativeOptions = {
|
|
25
|
+
ttl: number;
|
|
26
|
+
};
|
|
27
|
+
export type DefineCacheOptions<T extends string = string, R = any> = {
|
|
28
|
+
fieldable?: boolean;
|
|
29
|
+
singleflight?: boolean | CacheSingleflightOptions;
|
|
30
|
+
stale?: CacheStaleOptions;
|
|
31
|
+
negative?: CacheNegativeOptions;
|
|
32
|
+
tags?: CacheTagResolver<T, R>;
|
|
33
|
+
};
|
|
14
34
|
export type DefineCacheResult<T extends string = string, R = any> = {
|
|
15
35
|
fn: DefineCacheHandler<T, R>;
|
|
16
|
-
key:
|
|
36
|
+
key: T;
|
|
17
37
|
fieldable: boolean;
|
|
38
|
+
options: DefineCacheOptions<T, R>;
|
|
18
39
|
};
|
|
19
|
-
export declare function defineCache<T extends string = string, R = any>(key: T, fn: DefineCacheHandler<T, R>,
|
|
40
|
+
export declare function defineCache<T extends string = string, R = any>(key: T, fn: DefineCacheHandler<T, R>, options?: boolean | DefineCacheOptions<T, R>): DefineCacheResult<T, R>;
|
package/dist/define.js
CHANGED
|
@@ -13,11 +13,26 @@ export class Cache {
|
|
|
13
13
|
return this._expire;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
function normalizeDefineCacheOptions(options) {
|
|
17
|
+
return typeof options === 'boolean'
|
|
18
|
+
? { fieldable: options }
|
|
19
|
+
: options;
|
|
20
|
+
}
|
|
21
|
+
function assertCompatibleCacheOptions(options) {
|
|
22
|
+
if (!options.fieldable)
|
|
23
|
+
return;
|
|
24
|
+
if (options.negative || options.stale) {
|
|
25
|
+
throw new TypeError('fieldable cache cannot be combined with negative or stale cache options');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function defineCache(key, fn, options = false) {
|
|
29
|
+
const normalized = normalizeDefineCacheOptions(options);
|
|
30
|
+
assertCompatibleCacheOptions(normalized);
|
|
17
31
|
return {
|
|
18
32
|
fn,
|
|
19
33
|
key,
|
|
20
|
-
fieldable,
|
|
34
|
+
fieldable: normalized.fieldable ?? false,
|
|
35
|
+
options: normalized,
|
|
21
36
|
};
|
|
22
37
|
}
|
|
23
38
|
// defineCache('user:{id:string}:ddd:{x:number}:idsaf:{y:boolean}', async (params) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
|
+
import { RedisLock } from '@hile/redis-lock';
|
|
1
2
|
import { DefineCacheResult, ExtractParams } from './define.js';
|
|
2
3
|
import { ChainableCommander, Redis } from 'ioredis';
|
|
3
4
|
export * from './define.js';
|
|
5
|
+
export * from './options.js';
|
|
6
|
+
export * from './payload.js';
|
|
7
|
+
export * from './tags.js';
|
|
8
|
+
export type RedisCacheOptions = {
|
|
9
|
+
locks?: RedisLock;
|
|
10
|
+
};
|
|
4
11
|
export declare class RedisCache {
|
|
5
12
|
private readonly prefix;
|
|
6
13
|
private readonly redis;
|
|
7
14
|
private readonly _regexp;
|
|
8
|
-
|
|
15
|
+
private readonly locks;
|
|
16
|
+
private readonly tags;
|
|
17
|
+
constructor(prefix: string, redis: Redis, options?: RedisCacheOptions);
|
|
9
18
|
private makeKey;
|
|
10
19
|
private _multi;
|
|
11
20
|
private _write;
|
|
21
|
+
private _writeWithKey;
|
|
22
|
+
private _storeCacheResult;
|
|
23
|
+
private rememberTags;
|
|
12
24
|
private _read;
|
|
25
|
+
private _readExisting;
|
|
26
|
+
private _refreshStale;
|
|
13
27
|
private _remove;
|
|
28
|
+
private withKeyTagMutationLock;
|
|
29
|
+
private withMutationLocks;
|
|
30
|
+
private makeKeyMutationLockKey;
|
|
31
|
+
private makeTagMutationLockKey;
|
|
14
32
|
private _has;
|
|
15
33
|
loadCache<T extends string, R>(target: DefineCacheResult<T, R>): Promise<{
|
|
16
34
|
write: (params: ExtractParams<T>) => Promise<R | undefined>;
|
|
@@ -19,4 +37,5 @@ export declare class RedisCache {
|
|
|
19
37
|
has: (params: ExtractParams<T>) => Promise<boolean>;
|
|
20
38
|
multi: (params: ExtractParams<T>, callback: (multi: ChainableCommander, key: string) => unknown) => Promise<[error: Error | null, result: unknown][] | null>;
|
|
21
39
|
}>;
|
|
40
|
+
removeTag(tag: string): Promise<number>;
|
|
22
41
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
import { RedisLock } from '@hile/redis-lock';
|
|
2
|
+
import { decodeCacheValue, encodeCacheValue, encodeNegative, resolveNegativeTtl, resolveRedisTtl, } from './payload.js';
|
|
3
|
+
import { resolveSingleflightOptions } from './options.js';
|
|
4
|
+
import { CacheTagIndex } from './tags.js';
|
|
1
5
|
export * from './define.js';
|
|
6
|
+
export * from './options.js';
|
|
7
|
+
export * from './payload.js';
|
|
8
|
+
export * from './tags.js';
|
|
9
|
+
const TAG_MUTATION_LOCK_OPTIONS = {
|
|
10
|
+
ttl: 10_000,
|
|
11
|
+
wait: 10_000,
|
|
12
|
+
};
|
|
2
13
|
export class RedisCache {
|
|
3
14
|
prefix;
|
|
4
15
|
redis;
|
|
5
16
|
_regexp = /\{([^\:]+):[^\}]+\}/g;
|
|
6
|
-
|
|
17
|
+
locks;
|
|
18
|
+
tags;
|
|
19
|
+
constructor(prefix, redis, options = {}) {
|
|
7
20
|
this.prefix = prefix;
|
|
8
21
|
this.redis = redis;
|
|
22
|
+
this.locks = options.locks ?? new RedisLock(redis);
|
|
23
|
+
this.tags = new CacheTagIndex(prefix, redis);
|
|
9
24
|
}
|
|
10
25
|
makeKey(key, options) {
|
|
11
26
|
return this.prefix + key.replace(this._regexp, (_, key) => String(options[key]));
|
|
@@ -15,23 +30,44 @@ export class RedisCache {
|
|
|
15
30
|
const redis = this.redis;
|
|
16
31
|
const exists = await redis.exists(key);
|
|
17
32
|
if (!exists)
|
|
18
|
-
await this.
|
|
33
|
+
await this._writeWithKey(target, params, key);
|
|
19
34
|
const multi = redis.multi();
|
|
20
35
|
callback(multi, key);
|
|
21
36
|
return await multi.exec();
|
|
22
37
|
}
|
|
23
38
|
async _write(target, params) {
|
|
24
39
|
const key = this.makeKey(target.key, params);
|
|
40
|
+
return this._writeWithKey(target, params, key);
|
|
41
|
+
}
|
|
42
|
+
async _writeWithKey(target, params, key) {
|
|
25
43
|
const cache = await target.fn(params);
|
|
26
|
-
if (cache.__$flag__ !== 'cache') {
|
|
44
|
+
if (!cache || cache.__$flag__ !== 'cache') {
|
|
27
45
|
throw new Error('Cache result must be an instance of Cache');
|
|
28
46
|
}
|
|
47
|
+
if (!target.options.tags) {
|
|
48
|
+
return this._storeCacheResult(target, params, key, cache);
|
|
49
|
+
}
|
|
50
|
+
const nextTags = cache.data === undefined && resolveNegativeTtl(target.options) === undefined
|
|
51
|
+
? []
|
|
52
|
+
: this.tags.resolveTags(target, params, cache.data);
|
|
53
|
+
return this.withKeyTagMutationLock(target, params, key, nextTags, () => {
|
|
54
|
+
return this._storeCacheResult(target, params, key, cache, nextTags);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async _storeCacheResult(target, params, key, cache, resolvedTags) {
|
|
29
58
|
const redis = this.redis;
|
|
30
59
|
const exists = await redis.exists(key);
|
|
31
60
|
if (cache.data === undefined) {
|
|
32
|
-
|
|
33
|
-
|
|
61
|
+
const negativeTtl = resolveNegativeTtl(target.options);
|
|
62
|
+
if (negativeTtl !== undefined) {
|
|
63
|
+
await redis.setex(key, negativeTtl, encodeNegative());
|
|
64
|
+
await this.rememberTags(target, params, undefined, key, resolvedTags);
|
|
65
|
+
return;
|
|
34
66
|
}
|
|
67
|
+
if (exists)
|
|
68
|
+
await redis.del(key);
|
|
69
|
+
if (target.options.tags)
|
|
70
|
+
await this.tags.forget(target, params, key);
|
|
35
71
|
return;
|
|
36
72
|
}
|
|
37
73
|
if (target.fieldable) {
|
|
@@ -39,40 +75,126 @@ export class RedisCache {
|
|
|
39
75
|
if (cache.expire > 0) {
|
|
40
76
|
await redis.expire(key, cache.expire);
|
|
41
77
|
}
|
|
78
|
+
else {
|
|
79
|
+
await redis.persist(key);
|
|
80
|
+
}
|
|
81
|
+
await this.rememberTags(target, params, cache.data, key, resolvedTags);
|
|
42
82
|
return cache.data;
|
|
43
83
|
}
|
|
44
|
-
const payload =
|
|
45
|
-
|
|
46
|
-
|
|
84
|
+
const payload = encodeCacheValue(cache.data, cache.expire, target.options);
|
|
85
|
+
const ttl = resolveRedisTtl(cache.expire, target.options);
|
|
86
|
+
if (ttl > 0) {
|
|
87
|
+
await redis.setex(key, ttl, payload);
|
|
47
88
|
}
|
|
48
89
|
else {
|
|
49
90
|
await redis.set(key, payload);
|
|
50
91
|
}
|
|
92
|
+
await this.rememberTags(target, params, cache.data, key, resolvedTags);
|
|
51
93
|
return cache.data;
|
|
52
94
|
}
|
|
95
|
+
async rememberTags(target, params, data, key, resolvedTags) {
|
|
96
|
+
if (!target.options.tags)
|
|
97
|
+
return;
|
|
98
|
+
if (resolvedTags) {
|
|
99
|
+
await this.tags.rememberTags(key, resolvedTags);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await this.tags.remember(target, params, data, key);
|
|
103
|
+
}
|
|
53
104
|
async _read(target, params) {
|
|
54
105
|
const key = this.makeKey(target.key, params);
|
|
55
|
-
const
|
|
106
|
+
const cached = await this._readExisting(target, key);
|
|
107
|
+
if (cached.hit) {
|
|
108
|
+
if (cached.stale) {
|
|
109
|
+
void this._refreshStale(target, params, key);
|
|
110
|
+
}
|
|
111
|
+
return cached.value;
|
|
112
|
+
}
|
|
113
|
+
const singleflight = resolveSingleflightOptions(target.options);
|
|
114
|
+
if (singleflight) {
|
|
115
|
+
return this.locks.withLock(`${key}:lock`, singleflight, async () => {
|
|
116
|
+
const current = await this._readExisting(target, key);
|
|
117
|
+
if (current.hit)
|
|
118
|
+
return current.value;
|
|
119
|
+
return this._writeWithKey(target, params, key);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return await this._writeWithKey(target, params, key);
|
|
123
|
+
}
|
|
124
|
+
async _readExisting(target, key) {
|
|
56
125
|
if (target.fieldable) {
|
|
57
|
-
const fields = await redis.hgetall(key);
|
|
58
|
-
if (!fields)
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
return await this._write(target, params);
|
|
62
|
-
return fields;
|
|
126
|
+
const fields = await this.redis.hgetall(key);
|
|
127
|
+
if (!fields || Object.keys(fields).length === 0)
|
|
128
|
+
return { hit: false };
|
|
129
|
+
return { hit: true, value: fields, stale: false };
|
|
63
130
|
}
|
|
64
|
-
const text = await redis.get(key);
|
|
131
|
+
const text = await this.redis.get(key);
|
|
65
132
|
if (!text)
|
|
66
|
-
return
|
|
67
|
-
return
|
|
133
|
+
return { hit: false };
|
|
134
|
+
return decodeCacheValue(text, target.options);
|
|
135
|
+
}
|
|
136
|
+
async _refreshStale(target, params, key) {
|
|
137
|
+
try {
|
|
138
|
+
const singleflight = resolveSingleflightOptions(target.options);
|
|
139
|
+
if (singleflight) {
|
|
140
|
+
await this.locks.withLock(`${key}:lock`, {
|
|
141
|
+
...singleflight,
|
|
142
|
+
wait: 0,
|
|
143
|
+
}, async () => {
|
|
144
|
+
await this._writeWithKey(target, params, key);
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await this._writeWithKey(target, params, key);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// stale cache should keep serving the previous value when refresh fails
|
|
152
|
+
}
|
|
68
153
|
}
|
|
69
154
|
async _remove(target, params) {
|
|
70
155
|
const key = this.makeKey(target.key, params);
|
|
156
|
+
if (target.options.tags) {
|
|
157
|
+
return this.withKeyTagMutationLock(target, params, key, [], async () => {
|
|
158
|
+
const removed = await this.redis.del(key);
|
|
159
|
+
await this.tags.forget(target, params, key);
|
|
160
|
+
return removed;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
71
163
|
const redis = this.redis;
|
|
72
164
|
const exists = await redis.exists(key);
|
|
73
165
|
if (!exists)
|
|
74
166
|
return 0;
|
|
75
|
-
|
|
167
|
+
const removed = await redis.del(key);
|
|
168
|
+
if (removed > 0)
|
|
169
|
+
await this.tags.forget(target, params, key);
|
|
170
|
+
return removed;
|
|
171
|
+
}
|
|
172
|
+
async withKeyTagMutationLock(target, params, key, nextTags, callback) {
|
|
173
|
+
return this.withMutationLocks([this.makeKeyMutationLockKey(key)], async () => {
|
|
174
|
+
const previousTags = await this.tags.readKeyTags(key);
|
|
175
|
+
const legacyTags = previousTags.length === 0
|
|
176
|
+
? this.tags.resolveTags(target, params, undefined)
|
|
177
|
+
: [];
|
|
178
|
+
const tagLockKeys = [...previousTags, ...legacyTags, ...nextTags]
|
|
179
|
+
.map(tag => this.makeTagMutationLockKey(tag));
|
|
180
|
+
return this.withMutationLocks(tagLockKeys, callback);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async withMutationLocks(lockKeys, callback) {
|
|
184
|
+
const keys = [...new Set(lockKeys)].sort();
|
|
185
|
+
const run = async (index) => {
|
|
186
|
+
if (index >= keys.length)
|
|
187
|
+
return callback();
|
|
188
|
+
const lockKey = keys[index];
|
|
189
|
+
return this.locks.withLock(lockKey, TAG_MUTATION_LOCK_OPTIONS, async () => run(index + 1));
|
|
190
|
+
};
|
|
191
|
+
return run(0);
|
|
192
|
+
}
|
|
193
|
+
makeKeyMutationLockKey(key) {
|
|
194
|
+
return `${key}:hile-cache:mutation-lock`;
|
|
195
|
+
}
|
|
196
|
+
makeTagMutationLockKey(tag) {
|
|
197
|
+
return `${this.prefix}tag:${tag}:mutation-lock`;
|
|
76
198
|
}
|
|
77
199
|
async _has(target, params) {
|
|
78
200
|
const key = this.makeKey(target.key, params);
|
|
@@ -99,4 +221,9 @@ export class RedisCache {
|
|
|
99
221
|
}
|
|
100
222
|
};
|
|
101
223
|
}
|
|
224
|
+
async removeTag(tag) {
|
|
225
|
+
return this.withMutationLocks([this.makeTagMutationLockKey(tag)], () => {
|
|
226
|
+
return this.tags.removeTag(tag);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
102
229
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DefineCacheOptions } from './define';
|
|
2
|
+
export type ResolvedSingleflightOptions = {
|
|
3
|
+
ttl: number;
|
|
4
|
+
wait: number;
|
|
5
|
+
pollInterval?: number;
|
|
6
|
+
maxPollInterval?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function resolveSingleflightOptions<T extends string, R>(options: DefineCacheOptions<T, R>): ResolvedSingleflightOptions | undefined;
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function resolveSingleflightOptions(options) {
|
|
2
|
+
if (!options.singleflight)
|
|
3
|
+
return undefined;
|
|
4
|
+
const singleflight = options.singleflight === true ? {} : options.singleflight;
|
|
5
|
+
const ttl = singleflight.ttl ?? 10_000;
|
|
6
|
+
return {
|
|
7
|
+
ttl,
|
|
8
|
+
wait: singleflight.wait ?? ttl,
|
|
9
|
+
pollInterval: singleflight.pollInterval,
|
|
10
|
+
maxPollInterval: singleflight.maxPollInterval,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DefineCacheOptions } from './define';
|
|
2
|
+
export type CacheReadResult<R> = {
|
|
3
|
+
hit: false;
|
|
4
|
+
} | {
|
|
5
|
+
hit: true;
|
|
6
|
+
value: R | undefined;
|
|
7
|
+
stale: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function resolveNegativeTtl<T extends string, R>(options: DefineCacheOptions<T, R>): number | undefined;
|
|
10
|
+
export declare function resolveRedisTtl<T extends string, R>(expire: number, options: DefineCacheOptions<T, R>): number;
|
|
11
|
+
export declare function encodeCacheValue<T extends string, R>(data: R, expire: number, options: DefineCacheOptions<T, R>): string;
|
|
12
|
+
export declare function encodeNegative(): string;
|
|
13
|
+
export declare function decodeCacheValue<R, T extends string = string>(text: string, options?: DefineCacheOptions<T, R>): CacheReadResult<R>;
|
package/dist/payload.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const PAYLOAD_FLAG = '__$hile_cache';
|
|
2
|
+
const PAYLOAD_PREFIX = '\x1fhile-cache:v1:';
|
|
3
|
+
function getPayloadFlag(value) {
|
|
4
|
+
if (typeof value !== 'object' || value === null)
|
|
5
|
+
return undefined;
|
|
6
|
+
return value[PAYLOAD_FLAG];
|
|
7
|
+
}
|
|
8
|
+
function isStoredValue(value) {
|
|
9
|
+
if (getPayloadFlag(value) !== 'value')
|
|
10
|
+
return false;
|
|
11
|
+
const record = value;
|
|
12
|
+
const keys = Object.keys(record);
|
|
13
|
+
return 'data' in record
|
|
14
|
+
&& typeof record.freshUntil === 'number'
|
|
15
|
+
&& keys.every(key => key === PAYLOAD_FLAG || key === 'data' || key === 'freshUntil');
|
|
16
|
+
}
|
|
17
|
+
function isStoredNegative(value) {
|
|
18
|
+
if (getPayloadFlag(value) !== 'negative')
|
|
19
|
+
return false;
|
|
20
|
+
return Object.keys(value).length === 1;
|
|
21
|
+
}
|
|
22
|
+
function encodePayload(value) {
|
|
23
|
+
return `${PAYLOAD_PREFIX}${JSON.stringify(value)}`;
|
|
24
|
+
}
|
|
25
|
+
function decodePrefixedPayload(text) {
|
|
26
|
+
if (!text.startsWith(PAYLOAD_PREFIX))
|
|
27
|
+
return undefined;
|
|
28
|
+
const stored = JSON.parse(text.slice(PAYLOAD_PREFIX.length));
|
|
29
|
+
if (stored.type === 'negative') {
|
|
30
|
+
return { hit: true, value: undefined, stale: false };
|
|
31
|
+
}
|
|
32
|
+
if (stored.type !== 'value') {
|
|
33
|
+
throw new Error('Invalid cache payload');
|
|
34
|
+
}
|
|
35
|
+
const freshUntil = typeof stored.freshUntil === 'number' ? stored.freshUntil : undefined;
|
|
36
|
+
return {
|
|
37
|
+
hit: true,
|
|
38
|
+
value: stored.data,
|
|
39
|
+
stale: freshUntil !== undefined && Date.now() > freshUntil,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function resolveNegativeTtl(options) {
|
|
43
|
+
return options.negative?.ttl;
|
|
44
|
+
}
|
|
45
|
+
export function resolveRedisTtl(expire, options) {
|
|
46
|
+
if (expire <= 0)
|
|
47
|
+
return 0;
|
|
48
|
+
return expire + (options.stale?.ttl ?? 0);
|
|
49
|
+
}
|
|
50
|
+
export function encodeCacheValue(data, expire, options) {
|
|
51
|
+
if (!options.stale || expire <= 0)
|
|
52
|
+
return JSON.stringify(data);
|
|
53
|
+
return encodePayload({
|
|
54
|
+
type: 'value',
|
|
55
|
+
data,
|
|
56
|
+
freshUntil: Date.now() + expire * 1000,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export function encodeNegative() {
|
|
60
|
+
return encodePayload({ type: 'negative' });
|
|
61
|
+
}
|
|
62
|
+
export function decodeCacheValue(text, options = {}) {
|
|
63
|
+
const prefixed = decodePrefixedPayload(text);
|
|
64
|
+
if (prefixed)
|
|
65
|
+
return prefixed;
|
|
66
|
+
const parsed = JSON.parse(text);
|
|
67
|
+
if (options.negative && isStoredNegative(parsed)) {
|
|
68
|
+
return { hit: true, value: undefined, stale: false };
|
|
69
|
+
}
|
|
70
|
+
if (!options.stale || !isStoredValue(parsed)) {
|
|
71
|
+
return { hit: true, value: parsed, stale: false };
|
|
72
|
+
}
|
|
73
|
+
const freshUntil = typeof parsed.freshUntil === 'number' ? parsed.freshUntil : undefined;
|
|
74
|
+
return {
|
|
75
|
+
hit: true,
|
|
76
|
+
value: parsed.data,
|
|
77
|
+
stale: freshUntil !== undefined && Date.now() > freshUntil,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const REPLACE_CACHE_TAGS = "\n-- REPLACE_CACHE_TAGS\nlocal cacheKey = ARGV[1]\nlocal tagPrefix = ARGV[2]\nlocal next = {}\n\nfor i = 3, #ARGV do\n next[ARGV[i]] = true\nend\n\nlocal previous = redis.call('SMEMBERS', KEYS[1])\nfor _, tag in ipairs(previous) do\n if not next[tag] then\n redis.call('SREM', tagPrefix .. tag, cacheKey)\n end\nend\n\nredis.call('DEL', KEYS[1])\nfor i = 3, #ARGV do\n redis.call('SADD', tagPrefix .. ARGV[i], cacheKey)\n redis.call('SADD', KEYS[1], ARGV[i])\nend\n\nreturn #ARGV - 2\n";
|
|
2
|
+
export declare const FORGET_CACHE_TAGS = "\n-- FORGET_CACHE_TAGS\nlocal cacheKey = ARGV[1]\nlocal tagPrefix = ARGV[2]\nlocal tags = redis.call('SMEMBERS', KEYS[1])\n\nfor _, tag in ipairs(tags) do\n redis.call('SREM', tagPrefix .. tag, cacheKey)\nend\nredis.call('DEL', KEYS[1])\n\nreturn #tags\n";
|
|
3
|
+
export declare const REMOVE_CACHE_TAG = "\n-- REMOVE_CACHE_TAG\nlocal tagPrefix = ARGV[1]\nlocal indexPrefix = ARGV[2]\nlocal cacheKeys = redis.call('SMEMBERS', KEYS[1])\nlocal removed = 0\n\nfor _, cacheKey in ipairs(cacheKeys) do\n local indexKey = indexPrefix .. cacheKey\n local tags = redis.call('SMEMBERS', indexKey)\n\n for _, tag in ipairs(tags) do\n redis.call('SREM', tagPrefix .. tag, cacheKey)\n end\n\n redis.call('DEL', indexKey)\n removed = removed + redis.call('DEL', cacheKey)\nend\n\nredis.call('DEL', KEYS[1])\n\nreturn removed\n";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const REPLACE_CACHE_TAGS = `
|
|
2
|
+
-- REPLACE_CACHE_TAGS
|
|
3
|
+
local cacheKey = ARGV[1]
|
|
4
|
+
local tagPrefix = ARGV[2]
|
|
5
|
+
local next = {}
|
|
6
|
+
|
|
7
|
+
for i = 3, #ARGV do
|
|
8
|
+
next[ARGV[i]] = true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
local previous = redis.call('SMEMBERS', KEYS[1])
|
|
12
|
+
for _, tag in ipairs(previous) do
|
|
13
|
+
if not next[tag] then
|
|
14
|
+
redis.call('SREM', tagPrefix .. tag, cacheKey)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
redis.call('DEL', KEYS[1])
|
|
19
|
+
for i = 3, #ARGV do
|
|
20
|
+
redis.call('SADD', tagPrefix .. ARGV[i], cacheKey)
|
|
21
|
+
redis.call('SADD', KEYS[1], ARGV[i])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
return #ARGV - 2
|
|
25
|
+
`;
|
|
26
|
+
export const FORGET_CACHE_TAGS = `
|
|
27
|
+
-- FORGET_CACHE_TAGS
|
|
28
|
+
local cacheKey = ARGV[1]
|
|
29
|
+
local tagPrefix = ARGV[2]
|
|
30
|
+
local tags = redis.call('SMEMBERS', KEYS[1])
|
|
31
|
+
|
|
32
|
+
for _, tag in ipairs(tags) do
|
|
33
|
+
redis.call('SREM', tagPrefix .. tag, cacheKey)
|
|
34
|
+
end
|
|
35
|
+
redis.call('DEL', KEYS[1])
|
|
36
|
+
|
|
37
|
+
return #tags
|
|
38
|
+
`;
|
|
39
|
+
export const REMOVE_CACHE_TAG = `
|
|
40
|
+
-- REMOVE_CACHE_TAG
|
|
41
|
+
local tagPrefix = ARGV[1]
|
|
42
|
+
local indexPrefix = ARGV[2]
|
|
43
|
+
local cacheKeys = redis.call('SMEMBERS', KEYS[1])
|
|
44
|
+
local removed = 0
|
|
45
|
+
|
|
46
|
+
for _, cacheKey in ipairs(cacheKeys) do
|
|
47
|
+
local indexKey = indexPrefix .. cacheKey
|
|
48
|
+
local tags = redis.call('SMEMBERS', indexKey)
|
|
49
|
+
|
|
50
|
+
for _, tag in ipairs(tags) do
|
|
51
|
+
redis.call('SREM', tagPrefix .. tag, cacheKey)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
redis.call('DEL', indexKey)
|
|
55
|
+
removed = removed + redis.call('DEL', cacheKey)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
redis.call('DEL', KEYS[1])
|
|
59
|
+
|
|
60
|
+
return removed
|
|
61
|
+
`;
|
package/dist/tags.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis';
|
|
2
|
+
import type { DefineCacheResult, ExtractParams } from './define';
|
|
3
|
+
export declare class CacheTagIndex {
|
|
4
|
+
private readonly prefix;
|
|
5
|
+
private readonly redis;
|
|
6
|
+
constructor(prefix: string, redis: Redis);
|
|
7
|
+
remember<T extends string, R>(target: DefineCacheResult<T, R>, params: ExtractParams<T>, data: R | undefined, key: string): Promise<void>;
|
|
8
|
+
forget<T extends string, R>(target: DefineCacheResult<T, R>, params: ExtractParams<T>, key: string): Promise<void>;
|
|
9
|
+
removeTag(tag: string): Promise<number>;
|
|
10
|
+
readKeyTags(key: string): Promise<string[]>;
|
|
11
|
+
rememberTags(key: string, tags: string[]): Promise<void>;
|
|
12
|
+
resolveTags<T extends string, R>(target: DefineCacheResult<T, R>, params: ExtractParams<T>, data: R | undefined): string[];
|
|
13
|
+
private makeTagKey;
|
|
14
|
+
private makeTagPrefix;
|
|
15
|
+
private makeKeyTagsKey;
|
|
16
|
+
private makeKeyTagsPrefix;
|
|
17
|
+
private replaceKeyTags;
|
|
18
|
+
private forgetKeyTags;
|
|
19
|
+
}
|
package/dist/tags.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { FORGET_CACHE_TAGS, REMOVE_CACHE_TAG, REPLACE_CACHE_TAGS } from './tag-scripts.js';
|
|
2
|
+
export class CacheTagIndex {
|
|
3
|
+
prefix;
|
|
4
|
+
redis;
|
|
5
|
+
constructor(prefix, redis) {
|
|
6
|
+
this.prefix = prefix;
|
|
7
|
+
this.redis = redis;
|
|
8
|
+
}
|
|
9
|
+
async remember(target, params, data, key) {
|
|
10
|
+
const tags = this.resolveTags(target, params, data);
|
|
11
|
+
await this.rememberTags(key, tags);
|
|
12
|
+
}
|
|
13
|
+
async forget(target, params, key) {
|
|
14
|
+
const forgotten = await this.forgetKeyTags(key);
|
|
15
|
+
if (forgotten > 0)
|
|
16
|
+
return;
|
|
17
|
+
const legacyTags = this.resolveTags(target, params, undefined);
|
|
18
|
+
if (legacyTags.length === 0)
|
|
19
|
+
return;
|
|
20
|
+
await Promise.all(legacyTags.map(tag => this.redis.srem(this.makeTagKey(tag), key)));
|
|
21
|
+
}
|
|
22
|
+
async removeTag(tag) {
|
|
23
|
+
const removed = await this.redis.eval(REMOVE_CACHE_TAG, 1, this.makeTagKey(tag), this.makeTagPrefix(), this.makeKeyTagsPrefix());
|
|
24
|
+
return Number(removed);
|
|
25
|
+
}
|
|
26
|
+
async readKeyTags(key) {
|
|
27
|
+
return this.redis.smembers(this.makeKeyTagsKey(key));
|
|
28
|
+
}
|
|
29
|
+
async rememberTags(key, tags) {
|
|
30
|
+
await this.replaceKeyTags(key, tags);
|
|
31
|
+
}
|
|
32
|
+
resolveTags(target, params, data) {
|
|
33
|
+
const tags = target.options.tags;
|
|
34
|
+
if (!tags)
|
|
35
|
+
return [];
|
|
36
|
+
const resolved = typeof tags === 'function' ? tags(params, data) : tags;
|
|
37
|
+
return [...new Set(resolved)];
|
|
38
|
+
}
|
|
39
|
+
makeTagKey(tag) {
|
|
40
|
+
return `${this.makeTagPrefix()}${tag}`;
|
|
41
|
+
}
|
|
42
|
+
makeTagPrefix() {
|
|
43
|
+
return `${this.prefix}tag:`;
|
|
44
|
+
}
|
|
45
|
+
makeKeyTagsKey(key) {
|
|
46
|
+
return `${this.makeKeyTagsPrefix()}${key}`;
|
|
47
|
+
}
|
|
48
|
+
makeKeyTagsPrefix() {
|
|
49
|
+
return `${this.prefix}tag-index:`;
|
|
50
|
+
}
|
|
51
|
+
async replaceKeyTags(key, nextTags) {
|
|
52
|
+
await this.redis.eval(REPLACE_CACHE_TAGS, 1, this.makeKeyTagsKey(key), key, this.makeTagPrefix(), ...nextTags);
|
|
53
|
+
}
|
|
54
|
+
async forgetKeyTags(key) {
|
|
55
|
+
const removed = await this.redis.eval(FORGET_CACHE_TAGS, 1, this.makeKeyTagsKey(key), key, this.makeTagPrefix());
|
|
56
|
+
return Number(removed);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hile/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -b && fix-esm-import-path --preserve-import-type ./dist",
|
|
8
8
|
"dev": "tsc -b --watch",
|
|
9
|
-
"test": "vitest run"
|
|
9
|
+
"test": "vitest run && tsc -p tsconfig.typecheck.json"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"vitest": "^4.0.18"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@hile/redis-lock": "^3.0.1",
|
|
24
25
|
"ioredis": "^5.11.0"
|
|
25
26
|
},
|
|
26
|
-
"gitHead": "
|
|
27
|
+
"gitHead": "88f52fb95743f86761778776aff23631fcf9d821"
|
|
27
28
|
}
|