@jintianxiayu/cache-decorator 1.0.0 → 1.0.2
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/CHANGELOG.md +25 -11
- package/README.md +364 -270
- package/dist/core/cache-error.d.ts +75 -0
- package/dist/core/cache-error.d.ts.map +1 -0
- package/dist/core/cache-error.js +168 -0
- package/dist/core/cache-error.js.map +1 -0
- package/dist/core/cache-logger.d.ts +3 -2
- package/dist/core/cache-logger.d.ts.map +1 -1
- package/dist/core/cache-logger.js +2 -0
- package/dist/core/cache-logger.js.map +1 -1
- package/dist/decorators/cache-evict.d.ts.map +1 -1
- package/dist/decorators/cache-evict.js +15 -10
- package/dist/decorators/cache-evict.js.map +1 -1
- package/dist/decorators/cache.d.ts +6 -0
- package/dist/decorators/cache.d.ts.map +1 -1
- package/dist/decorators/cache.js +113 -36
- package/dist/decorators/cache.js.map +1 -1
- package/jest.config.js +11 -11
- package/package.json +1 -1
- package/src/adapters/ioredis-cache-client.ts +89 -89
- package/src/adapters/node-redis-cache-client.ts +95 -95
- package/src/adapters/redis-key-prefix.ts +38 -38
- package/src/core/cache-error.ts +233 -0
- package/src/core/cache-logger.ts +116 -105
- package/src/core/key-builder.ts +33 -33
- package/src/core/native-cache.ts +55 -55
- package/src/core/pending-cache.ts +29 -29
- package/src/core/redis-cache-client.ts +160 -160
- package/src/core/redis-cache.ts +104 -104
- package/src/decorators/cache-evict.ts +137 -129
- package/src/decorators/cache.ts +323 -203
- package/src/index.ts +11 -11
- package/test/cache-evict-logging.test.ts +398 -362
- package/test/cache-logger.integration.test.ts +159 -129
- package/test/cache-logger.test.ts +156 -153
- package/test/cache-logging.test.ts +929 -544
- package/test/cache.test.ts +1017 -255
- package/test/fixtures/cache-logger-child.mjs +108 -108
- package/test/fixtures/cache-provider-failure-child.mjs +70 -0
- package/test/helpers/legacy-redis-cache.ts +41 -22
- package/test/helpers/package-consumer.ts +231 -231
- package/test/helpers/redis-fixture.ts +142 -142
- package/test/ioredis-cache-client.test.ts +143 -143
- package/test/legacy-redis-cache.test.ts +51 -0
- package/test/native-cache.test.ts +77 -77
- package/test/node-redis-cache-client.test.ts +149 -149
- package/test/pending-cache.test.ts +69 -69
- package/test/redis-cache-client-lifecycle.test.ts +112 -112
- package/test/redis-cache-client-types.test.ts +184 -184
- package/test/redis-cache-client.integration.test.ts +355 -327
- package/test/redis-cache-decorator.test.ts +601 -201
- package/test/redis-cache-provider.test.ts +269 -269
- package/test/type-contract/contract.ts +119 -64
- package/test/type-contract/tsconfig.json +12 -12
- package/tsconfig.json +8 -8
|
@@ -1,201 +1,601 @@
|
|
|
1
|
-
import { createIoredisCacheClient } from '../src/adapters/ioredis-cache-client';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
readonly
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
1
|
+
import { createIoredisCacheClient } from '../src/adapters/ioredis-cache-client';
|
|
2
|
+
import { createNodeRedisCacheClient } from '../src/adapters/node-redis-cache-client';
|
|
3
|
+
import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
|
|
4
|
+
import { IoredisCacheClientSource, NodeRedisCacheClientSource } from '../src/core/redis-cache-client';
|
|
5
|
+
import { KeyBuilder } from '../src/core/key-builder';
|
|
6
|
+
import { MemoryCacheProvider } from '../src/core/native-cache';
|
|
7
|
+
import { RedisCacheProvider } from '../src/core/redis-cache';
|
|
8
|
+
import { CacheEvict } from '../src/decorators/cache-evict';
|
|
9
|
+
import { Cache, type CacheErrorCodec } from '../src/decorators/cache';
|
|
10
|
+
|
|
11
|
+
interface SharedRedisState {
|
|
12
|
+
readonly values: Map<string, string>;
|
|
13
|
+
readonly writes: Array<{ readonly key: string; readonly value: string; readonly ttlSeconds?: number }>;
|
|
14
|
+
getError?: unknown;
|
|
15
|
+
setError?: unknown;
|
|
16
|
+
deleteError?: unknown;
|
|
17
|
+
scanError?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createSharedRedisState(): SharedRedisState {
|
|
21
|
+
return { values: new Map<string, string>(), writes: [] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 创建会模拟 ioredis 透明 keyPrefix 的字符串命令源。 */
|
|
25
|
+
function createSharedIoredisSource(state: SharedRedisState, keyPrefix: string): IoredisCacheClientSource {
|
|
26
|
+
const physicalKey = (key: string): string => `${keyPrefix}${key}`;
|
|
27
|
+
return {
|
|
28
|
+
options: Object.freeze({ keyPrefix }),
|
|
29
|
+
get(key: string): Promise<unknown> {
|
|
30
|
+
if (state.getError !== undefined) {
|
|
31
|
+
return Promise.reject(state.getError);
|
|
32
|
+
}
|
|
33
|
+
return Promise.resolve(state.values.get(physicalKey(key)) ?? null);
|
|
34
|
+
},
|
|
35
|
+
set(key: string, value: string): Promise<unknown> {
|
|
36
|
+
if (state.setError !== undefined) {
|
|
37
|
+
return Promise.reject(state.setError);
|
|
38
|
+
}
|
|
39
|
+
const resolvedKey = physicalKey(key);
|
|
40
|
+
state.values.set(resolvedKey, value);
|
|
41
|
+
state.writes.push({ key: resolvedKey, value });
|
|
42
|
+
return Promise.resolve('OK');
|
|
43
|
+
},
|
|
44
|
+
setex(key: string, ttlSeconds: number, value: string): Promise<unknown> {
|
|
45
|
+
if (state.setError !== undefined) {
|
|
46
|
+
return Promise.reject(state.setError);
|
|
47
|
+
}
|
|
48
|
+
const resolvedKey = physicalKey(key);
|
|
49
|
+
state.values.set(resolvedKey, value);
|
|
50
|
+
state.writes.push({ key: resolvedKey, value, ttlSeconds });
|
|
51
|
+
return Promise.resolve('OK');
|
|
52
|
+
},
|
|
53
|
+
del(...keys: string[]): Promise<unknown> {
|
|
54
|
+
if (state.deleteError !== undefined) {
|
|
55
|
+
return Promise.reject(state.deleteError);
|
|
56
|
+
}
|
|
57
|
+
for (const key of keys) {
|
|
58
|
+
state.values.delete(physicalKey(key));
|
|
59
|
+
}
|
|
60
|
+
return Promise.resolve(keys.length);
|
|
61
|
+
},
|
|
62
|
+
scan(..._args: Parameters<IoredisCacheClientSource['scan']>): Promise<unknown> {
|
|
63
|
+
if (state.scanError !== undefined) {
|
|
64
|
+
return Promise.reject(state.scanError);
|
|
65
|
+
}
|
|
66
|
+
return Promise.resolve(['0', []]);
|
|
67
|
+
},
|
|
68
|
+
flushdb(): Promise<unknown> {
|
|
69
|
+
state.values.clear();
|
|
70
|
+
return Promise.resolve('OK');
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 创建接收显式物理 key 的 node-redis 字符串命令源。 */
|
|
76
|
+
function createSharedNodeRedisSource(state: SharedRedisState): NodeRedisCacheClientSource {
|
|
77
|
+
return {
|
|
78
|
+
get(key: string): Promise<unknown> {
|
|
79
|
+
if (state.getError !== undefined) {
|
|
80
|
+
return Promise.reject(state.getError);
|
|
81
|
+
}
|
|
82
|
+
return Promise.resolve(state.values.get(key) ?? null);
|
|
83
|
+
},
|
|
84
|
+
set(key: string, value: string): Promise<unknown> {
|
|
85
|
+
if (state.setError !== undefined) {
|
|
86
|
+
return Promise.reject(state.setError);
|
|
87
|
+
}
|
|
88
|
+
state.values.set(key, value);
|
|
89
|
+
state.writes.push({ key, value });
|
|
90
|
+
return Promise.resolve('OK');
|
|
91
|
+
},
|
|
92
|
+
setEx(key: string, ttlSeconds: number, value: string): Promise<unknown> {
|
|
93
|
+
if (state.setError !== undefined) {
|
|
94
|
+
return Promise.reject(state.setError);
|
|
95
|
+
}
|
|
96
|
+
state.values.set(key, value);
|
|
97
|
+
state.writes.push({ key, value, ttlSeconds });
|
|
98
|
+
return Promise.resolve('OK');
|
|
99
|
+
},
|
|
100
|
+
del(keys: string | string[]): Promise<unknown> {
|
|
101
|
+
if (state.deleteError !== undefined) {
|
|
102
|
+
return Promise.reject(state.deleteError);
|
|
103
|
+
}
|
|
104
|
+
const deletedKeys = typeof keys === 'string' ? [keys] : keys;
|
|
105
|
+
for (const key of deletedKeys) {
|
|
106
|
+
state.values.delete(key);
|
|
107
|
+
}
|
|
108
|
+
return Promise.resolve(deletedKeys.length);
|
|
109
|
+
},
|
|
110
|
+
scan(): Promise<unknown> {
|
|
111
|
+
if (state.scanError !== undefined) {
|
|
112
|
+
return Promise.reject(state.scanError);
|
|
113
|
+
}
|
|
114
|
+
return Promise.resolve({ cursor: '0', keys: [] });
|
|
115
|
+
},
|
|
116
|
+
flushDb(): Promise<unknown> {
|
|
117
|
+
state.values.clear();
|
|
118
|
+
return Promise.resolve('OK');
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class FakeIoredisCacheClientSource implements IoredisCacheClientSource {
|
|
124
|
+
readonly options = Object.freeze({ keyPrefix: '' });
|
|
125
|
+
readonly writes: Array<{
|
|
126
|
+
readonly key: string;
|
|
127
|
+
readonly value: string;
|
|
128
|
+
readonly ttlSeconds: number | undefined;
|
|
129
|
+
}> = [];
|
|
130
|
+
private readonly values = new Map<string, string>();
|
|
131
|
+
|
|
132
|
+
constructor(private readonly events: string[]) {}
|
|
133
|
+
|
|
134
|
+
get(key: string): Promise<unknown> {
|
|
135
|
+
this.events.push(`get:${key}`);
|
|
136
|
+
return Promise.resolve(this.values.get(key) ?? null);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
set(key: string, value: string): Promise<unknown> {
|
|
140
|
+
this.events.push(`set:${key}`);
|
|
141
|
+
this.writes.push({ key, value, ttlSeconds: undefined });
|
|
142
|
+
this.values.set(key, value);
|
|
143
|
+
return Promise.resolve('OK');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setex(key: string, ttlSeconds: number, value: string): Promise<unknown> {
|
|
147
|
+
this.events.push(`setex:${key}`);
|
|
148
|
+
this.writes.push({ key, value, ttlSeconds });
|
|
149
|
+
this.values.set(key, value);
|
|
150
|
+
return Promise.resolve('OK');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
del(...keys: string[]): Promise<unknown> {
|
|
154
|
+
this.events.push(`del:${keys.join(',')}`);
|
|
155
|
+
let deleted = 0;
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
if (this.values.delete(key)) {
|
|
158
|
+
deleted += 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return Promise.resolve(deleted);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
scan(..._args: Parameters<IoredisCacheClientSource['scan']>): Promise<unknown> {
|
|
165
|
+
return Promise.resolve(['0', []]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
flushdb(): Promise<unknown> {
|
|
169
|
+
this.values.clear();
|
|
170
|
+
return Promise.resolve('OK');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
seed(key: string, value: string): void {
|
|
174
|
+
this.values.set(key, value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
raw(key: string): string | undefined {
|
|
178
|
+
return this.values.get(key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
beforeEach(() => CacheProviderRegistry.clear());
|
|
183
|
+
afterEach(() => CacheProviderRegistry.clear());
|
|
184
|
+
|
|
185
|
+
it('redis-cache-client/D01 默认 Redis Provider 保持装饰器调用方式', async () => {
|
|
186
|
+
const events: string[] = [];
|
|
187
|
+
const source = new FakeIoredisCacheClientSource(events);
|
|
188
|
+
const provider = new RedisCacheProvider(createIoredisCacheClient(source));
|
|
189
|
+
CacheProviderRegistry.register('redis', provider);
|
|
190
|
+
CacheProviderRegistry.setDefault('redis');
|
|
191
|
+
const cacheKey = KeyBuilder.build('d01-users', [1]);
|
|
192
|
+
|
|
193
|
+
class UserService {
|
|
194
|
+
@Cache('d01-users')
|
|
195
|
+
async getUser(id: number): Promise<{ id: number; version: number }> {
|
|
196
|
+
events.push(`business:get:${id}`);
|
|
197
|
+
return { id, version: events.filter((event) => event === `business:get:${id}`).length };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@CacheEvict('d01-users')
|
|
201
|
+
async updateUser(id: number): Promise<{ id: number; updated: boolean }> {
|
|
202
|
+
events.push(`business:update:${id}`);
|
|
203
|
+
return { id, updated: true };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const service = new UserService();
|
|
208
|
+
await expect(service.getUser(1)).resolves.toEqual({ id: 1, version: 1 });
|
|
209
|
+
await expect(service.getUser(1)).resolves.toEqual({ id: 1, version: 1 });
|
|
210
|
+
await expect(service.updateUser(1)).resolves.toEqual({ id: 1, updated: true });
|
|
211
|
+
await expect(service.getUser(1)).resolves.toEqual({ id: 1, version: 2 });
|
|
212
|
+
|
|
213
|
+
expect(events).toEqual([
|
|
214
|
+
`get:${cacheKey}`,
|
|
215
|
+
'business:get:1',
|
|
216
|
+
`set:${cacheKey}`,
|
|
217
|
+
`get:${cacheKey}`,
|
|
218
|
+
'business:update:1',
|
|
219
|
+
`del:${cacheKey}`,
|
|
220
|
+
`get:${cacheKey}`,
|
|
221
|
+
'business:get:1',
|
|
222
|
+
`set:${cacheKey}`,
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('redis-cache-client/D02 指定 Provider 名称保持原行为', async () => {
|
|
227
|
+
const events: string[] = [];
|
|
228
|
+
const redisProvider = new RedisCacheProvider(createIoredisCacheClient(new FakeIoredisCacheClientSource(events)));
|
|
229
|
+
const memoryProvider = new MemoryCacheProvider();
|
|
230
|
+
CacheProviderRegistry.register('redis', redisProvider);
|
|
231
|
+
CacheProviderRegistry.register('memory', memoryProvider);
|
|
232
|
+
CacheProviderRegistry.setDefault('memory');
|
|
233
|
+
const redisKey = KeyBuilder.build('d02-orders', ['active']);
|
|
234
|
+
|
|
235
|
+
class OrderService {
|
|
236
|
+
redisCalls = 0;
|
|
237
|
+
memoryCalls = 0;
|
|
238
|
+
|
|
239
|
+
@Cache('d02-orders', { providerName: 'redis', key: 'active' })
|
|
240
|
+
async getRedisOrder(id: number): Promise<{ id: number; source: string }> {
|
|
241
|
+
this.redisCalls += 1;
|
|
242
|
+
return { id, source: 'redis' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@Cache('d02-orders', { providerName: 'memory', key: 'active' })
|
|
246
|
+
async getMemoryOrder(id: number): Promise<{ id: number; source: string }> {
|
|
247
|
+
this.memoryCalls += 1;
|
|
248
|
+
return { id, source: 'memory' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@CacheEvict('d02-orders', { providerName: 'redis', key: 'active' })
|
|
252
|
+
async evictRedisOrder(): Promise<string> {
|
|
253
|
+
return 'evicted';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const service = new OrderService();
|
|
258
|
+
await expect(service.getRedisOrder(1)).resolves.toEqual({ id: 1, source: 'redis' });
|
|
259
|
+
await expect(service.getRedisOrder(2)).resolves.toEqual({ id: 1, source: 'redis' });
|
|
260
|
+
await expect(service.getMemoryOrder(3)).resolves.toEqual({ id: 3, source: 'memory' });
|
|
261
|
+
await expect(service.getMemoryOrder(4)).resolves.toEqual({ id: 3, source: 'memory' });
|
|
262
|
+
await expect(service.evictRedisOrder()).resolves.toBe('evicted');
|
|
263
|
+
await expect(service.getRedisOrder(5)).resolves.toEqual({ id: 5, source: 'redis' });
|
|
264
|
+
await expect(service.getMemoryOrder(6)).resolves.toEqual({ id: 3, source: 'memory' });
|
|
265
|
+
|
|
266
|
+
expect(service.redisCalls).toBe(2);
|
|
267
|
+
expect(service.memoryCalls).toBe(1);
|
|
268
|
+
expect(events.filter((event) => event === `get:${redisKey}`)).toHaveLength(3);
|
|
269
|
+
expect(events).toContain(`del:${redisKey}`);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('cache-operation-logging/A03 日志改造保持旧版 Redis key、entry、TTL 与淘汰 pattern', async () => {
|
|
273
|
+
const events: string[] = [];
|
|
274
|
+
const source = new FakeIoredisCacheClientSource(events);
|
|
275
|
+
const scan = jest.spyOn(source, 'scan');
|
|
276
|
+
const provider = new RedisCacheProvider(createIoredisCacheClient(source));
|
|
277
|
+
CacheProviderRegistry.register('compat-redis', provider);
|
|
278
|
+
const legacyKey = KeyBuilder.build('compat-users', ['legacy']);
|
|
279
|
+
const freshKey = KeyBuilder.build('compat-users', ['fresh']);
|
|
280
|
+
source.seed(legacyKey, '{"value":{"id":18,"source":"legacy-0.1.3"}}');
|
|
281
|
+
let legacyBusinessCalls = 0;
|
|
282
|
+
|
|
283
|
+
class UserService {
|
|
284
|
+
@Cache('compat-users', { providerName: 'compat-redis', key: 'legacy' })
|
|
285
|
+
async getLegacyUser(): Promise<{ id: number; source: string }> {
|
|
286
|
+
legacyBusinessCalls += 1;
|
|
287
|
+
return { id: 0, source: 'business' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@Cache('compat-users', { providerName: 'compat-redis', key: 'fresh', ttl: 45 })
|
|
291
|
+
async getFreshUser(): Promise<{ id: number; source: string }> {
|
|
292
|
+
return { id: 19, source: 'current' };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
@CacheEvict('compat-users', { providerName: 'compat-redis', allEntries: true })
|
|
296
|
+
async clearUsers(): Promise<boolean> {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const service = new UserService();
|
|
302
|
+
await expect(service.getLegacyUser()).resolves.toEqual({ id: 18, source: 'legacy-0.1.3' });
|
|
303
|
+
expect(legacyBusinessCalls).toBe(0);
|
|
304
|
+
await expect(service.getFreshUser()).resolves.toEqual({ id: 19, source: 'current' });
|
|
305
|
+
expect(source.raw(freshKey)).toBe('{"value":{"id":19,"source":"current"}}');
|
|
306
|
+
expect(source.writes).toContainEqual({
|
|
307
|
+
key: freshKey,
|
|
308
|
+
value: '{"value":{"id":19,"source":"current"}}',
|
|
309
|
+
ttlSeconds: 45,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await expect(service.clearUsers()).resolves.toBe(true);
|
|
313
|
+
expect(scan).toHaveBeenCalledWith('0', 'MATCH', 'compat-users*', 'COUNT', 100);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('标准 Error 通过 ioredis/node-redis 双向共享版本化异常条目', async () => {
|
|
317
|
+
const state = createSharedRedisState();
|
|
318
|
+
const keyPrefix = 'cross-client:';
|
|
319
|
+
const ioProvider = new RedisCacheProvider(createIoredisCacheClient(createSharedIoredisSource(state, keyPrefix)));
|
|
320
|
+
const nodeProvider = new RedisCacheProvider(
|
|
321
|
+
createNodeRedisCacheClient(createSharedNodeRedisSource(state), { keyPrefix })
|
|
322
|
+
);
|
|
323
|
+
CacheProviderRegistry.register('cross-io', ioProvider);
|
|
324
|
+
CacheProviderRegistry.register('cross-node', nodeProvider);
|
|
325
|
+
const ioBusinessError = new Error('written by ioredis');
|
|
326
|
+
ioBusinessError.name = 'IoBusinessError';
|
|
327
|
+
const nodeBusinessError = new Error('written by node-redis');
|
|
328
|
+
nodeBusinessError.name = 'NodeBusinessError';
|
|
329
|
+
let ioReaderCalls = 0;
|
|
330
|
+
let nodeReaderCalls = 0;
|
|
331
|
+
|
|
332
|
+
class ErrorService {
|
|
333
|
+
@Cache('io-to-node-errors', {
|
|
334
|
+
providerName: 'cross-io',
|
|
335
|
+
key: 'shared',
|
|
336
|
+
errorCache: { ttl: 11 },
|
|
337
|
+
})
|
|
338
|
+
async writeWithIoredis(): Promise<never> {
|
|
339
|
+
throw ioBusinessError;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@Cache('io-to-node-errors', {
|
|
343
|
+
providerName: 'cross-node',
|
|
344
|
+
key: 'shared',
|
|
345
|
+
errorCache: { ttl: 11 },
|
|
346
|
+
})
|
|
347
|
+
async readWithNodeRedis(): Promise<never> {
|
|
348
|
+
nodeReaderCalls += 1;
|
|
349
|
+
throw new Error('node reader should not run');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
@Cache('node-to-io-errors', {
|
|
353
|
+
providerName: 'cross-node',
|
|
354
|
+
key: 'shared',
|
|
355
|
+
errorCache: { ttl: 13 },
|
|
356
|
+
})
|
|
357
|
+
async writeWithNodeRedis(): Promise<never> {
|
|
358
|
+
throw nodeBusinessError;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
@Cache('node-to-io-errors', {
|
|
362
|
+
providerName: 'cross-io',
|
|
363
|
+
key: 'shared',
|
|
364
|
+
errorCache: { ttl: 13 },
|
|
365
|
+
})
|
|
366
|
+
async readWithIoredis(): Promise<never> {
|
|
367
|
+
ioReaderCalls += 1;
|
|
368
|
+
throw new Error('ioredis reader should not run');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const service = new ErrorService();
|
|
373
|
+
await expect(service.writeWithIoredis()).rejects.toBe(ioBusinessError);
|
|
374
|
+
await expect(service.readWithNodeRedis()).rejects.toMatchObject({
|
|
375
|
+
name: 'IoBusinessError',
|
|
376
|
+
message: 'written by ioredis',
|
|
377
|
+
});
|
|
378
|
+
await expect(service.writeWithNodeRedis()).rejects.toBe(nodeBusinessError);
|
|
379
|
+
await expect(service.readWithIoredis()).rejects.toMatchObject({
|
|
380
|
+
name: 'NodeBusinessError',
|
|
381
|
+
message: 'written by node-redis',
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(nodeReaderCalls).toBe(0);
|
|
385
|
+
expect(ioReaderCalls).toBe(0);
|
|
386
|
+
expect(state.writes.map(({ ttlSeconds }) => ttlSeconds)).toEqual([11, 13]);
|
|
387
|
+
expect([...state.values.keys()]).toEqual(
|
|
388
|
+
expect.arrayContaining([
|
|
389
|
+
`${keyPrefix}${KeyBuilder.build('io-to-node-errors', ['shared'])}`,
|
|
390
|
+
`${keyPrefix}${KeyBuilder.build('node-to-io-errors', ['shared'])}`,
|
|
391
|
+
])
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('兼容自定义 codec 可跨 Redis 客户端恢复领域异常', async () => {
|
|
396
|
+
class DomainError extends Error {
|
|
397
|
+
constructor(readonly code: string) {
|
|
398
|
+
super(`domain:${code}`);
|
|
399
|
+
this.name = 'DomainError';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const state = createSharedRedisState();
|
|
404
|
+
const keyPrefix = 'custom-codec:';
|
|
405
|
+
const ioProvider = new RedisCacheProvider(createIoredisCacheClient(createSharedIoredisSource(state, keyPrefix)));
|
|
406
|
+
const nodeProvider = new RedisCacheProvider(
|
|
407
|
+
createNodeRedisCacheClient(createSharedNodeRedisSource(state), { keyPrefix })
|
|
408
|
+
);
|
|
409
|
+
CacheProviderRegistry.register('custom-io', ioProvider);
|
|
410
|
+
CacheProviderRegistry.register('custom-node', nodeProvider);
|
|
411
|
+
const codec: CacheErrorCodec = {
|
|
412
|
+
encode(error: unknown): unknown {
|
|
413
|
+
if (!(error instanceof DomainError)) {
|
|
414
|
+
throw new TypeError('unexpected domain error');
|
|
415
|
+
}
|
|
416
|
+
return { code: error.code };
|
|
417
|
+
},
|
|
418
|
+
decode(payload: unknown): unknown {
|
|
419
|
+
if (typeof payload !== 'object' || payload === null || !('code' in payload)) {
|
|
420
|
+
throw new TypeError('invalid domain payload');
|
|
421
|
+
}
|
|
422
|
+
return new DomainError(String(payload.code));
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
const businessError = new DomainError('NOT_FOUND');
|
|
426
|
+
const reader = jest.fn();
|
|
427
|
+
|
|
428
|
+
class DomainService {
|
|
429
|
+
@Cache('cross-domain-errors', {
|
|
430
|
+
providerName: 'custom-io',
|
|
431
|
+
key: 'shared',
|
|
432
|
+
errorCache: { ttl: 7, codec },
|
|
433
|
+
})
|
|
434
|
+
async write(): Promise<never> {
|
|
435
|
+
throw businessError;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
@Cache('cross-domain-errors', {
|
|
439
|
+
providerName: 'custom-node',
|
|
440
|
+
key: 'shared',
|
|
441
|
+
errorCache: { ttl: 7, codec },
|
|
442
|
+
})
|
|
443
|
+
async read(): Promise<never> {
|
|
444
|
+
reader();
|
|
445
|
+
throw new Error('reader should not run');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const service = new DomainService();
|
|
450
|
+
await expect(service.write()).rejects.toBe(businessError);
|
|
451
|
+
await expect(service.read()).rejects.toMatchObject({ name: 'DomainError', code: 'NOT_FOUND' });
|
|
452
|
+
expect(reader).not.toHaveBeenCalled();
|
|
453
|
+
expect(JSON.parse(state.writes[0]!.value)).toEqual({
|
|
454
|
+
error: {
|
|
455
|
+
kind: '@jintianxiayu/cache-decorator/error',
|
|
456
|
+
version: 1,
|
|
457
|
+
payload: { code: 'NOT_FOUND' },
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('Redis 中 legacy 与禁用的异常条目均旁路为 miss 并由成功结果覆盖', async () => {
|
|
463
|
+
const state = createSharedRedisState();
|
|
464
|
+
const keyPrefix = 'bypass:';
|
|
465
|
+
const nodeProvider = new RedisCacheProvider(
|
|
466
|
+
createNodeRedisCacheClient(createSharedNodeRedisSource(state), { keyPrefix })
|
|
467
|
+
);
|
|
468
|
+
CacheProviderRegistry.register('bypass-node', nodeProvider);
|
|
469
|
+
const legacyKey = KeyBuilder.build('legacy-redis-errors', ['shared']);
|
|
470
|
+
const disabledKey = KeyBuilder.build('disabled-redis-errors', ['shared']);
|
|
471
|
+
state.values.set(`${keyPrefix}${legacyKey}`, JSON.stringify({ error: { message: 'legacy' } }));
|
|
472
|
+
state.values.set(
|
|
473
|
+
`${keyPrefix}${disabledKey}`,
|
|
474
|
+
JSON.stringify({
|
|
475
|
+
error: {
|
|
476
|
+
kind: '@jintianxiayu/cache-decorator/error',
|
|
477
|
+
version: 1,
|
|
478
|
+
payload: { type: 'error', name: 'Error', message: 'cached' },
|
|
479
|
+
},
|
|
480
|
+
})
|
|
481
|
+
);
|
|
482
|
+
const legacyBusiness = jest.fn(() => 'legacy-fresh');
|
|
483
|
+
const disabledBusiness = jest.fn(() => 'disabled-fresh');
|
|
484
|
+
|
|
485
|
+
class UserService {
|
|
486
|
+
@Cache('legacy-redis-errors', {
|
|
487
|
+
providerName: 'bypass-node',
|
|
488
|
+
key: 'shared',
|
|
489
|
+
errorCache: { ttl: 5 },
|
|
490
|
+
})
|
|
491
|
+
async readLegacy(): Promise<string> {
|
|
492
|
+
return legacyBusiness();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
@Cache('disabled-redis-errors', { providerName: 'bypass-node', key: 'shared' })
|
|
496
|
+
async readDisabled(): Promise<string> {
|
|
497
|
+
return disabledBusiness();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const service = new UserService();
|
|
502
|
+
await expect(service.readLegacy()).resolves.toBe('legacy-fresh');
|
|
503
|
+
await expect(service.readDisabled()).resolves.toBe('disabled-fresh');
|
|
504
|
+
expect(legacyBusiness).toHaveBeenCalledTimes(1);
|
|
505
|
+
expect(disabledBusiness).toHaveBeenCalledTimes(1);
|
|
506
|
+
expect(JSON.parse(state.values.get(`${keyPrefix}${legacyKey}`)!)).toEqual({ value: 'legacy-fresh' });
|
|
507
|
+
expect(JSON.parse(state.values.get(`${keyPrefix}${disabledKey}`)!)).toEqual({ value: 'disabled-fresh' });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('不可序列化异常 payload 不向 Redis 发送 SET', async () => {
|
|
511
|
+
const state = createSharedRedisState();
|
|
512
|
+
const keyPrefix = 'invalid-payload:';
|
|
513
|
+
const ioProvider = new RedisCacheProvider(createIoredisCacheClient(createSharedIoredisSource(state, keyPrefix)));
|
|
514
|
+
CacheProviderRegistry.register('invalid-payload-io', ioProvider);
|
|
515
|
+
const businessError = new Error('business failure');
|
|
516
|
+
const cyclicPayload: Record<string, unknown> = {};
|
|
517
|
+
cyclicPayload.self = cyclicPayload;
|
|
518
|
+
|
|
519
|
+
class UserService {
|
|
520
|
+
@Cache('invalid-redis-errors', {
|
|
521
|
+
providerName: 'invalid-payload-io',
|
|
522
|
+
errorCache: {
|
|
523
|
+
ttl: 5,
|
|
524
|
+
codec: {
|
|
525
|
+
encode: (): unknown => cyclicPayload,
|
|
526
|
+
decode: (payload: unknown): unknown => payload,
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
})
|
|
530
|
+
async getUser(): Promise<never> {
|
|
531
|
+
throw businessError;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
536
|
+
expect(state.writes).toEqual([]);
|
|
537
|
+
expect(state.values).toEqual(new Map());
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('Redis GET 失败旁路缓存且不调用异常策略', async () => {
|
|
541
|
+
const state = createSharedRedisState();
|
|
542
|
+
const readError = new Error('GET unavailable');
|
|
543
|
+
state.getError = readError;
|
|
544
|
+
const nodeProvider = new RedisCacheProvider(createNodeRedisCacheClient(createSharedNodeRedisSource(state)));
|
|
545
|
+
CacheProviderRegistry.register('failed-get-node', nodeProvider);
|
|
546
|
+
const shouldCache = jest.fn((_error: unknown): boolean => true);
|
|
547
|
+
const encode = jest.fn((error: unknown): unknown => error);
|
|
548
|
+
const decode = jest.fn((payload: unknown): unknown => payload);
|
|
549
|
+
const businessResult = { id: 22 };
|
|
550
|
+
const businessMethod = jest.fn(() => businessResult);
|
|
551
|
+
|
|
552
|
+
class UserService {
|
|
553
|
+
@Cache('failed-get-errors', {
|
|
554
|
+
providerName: 'failed-get-node',
|
|
555
|
+
errorCache: { ttl: 5, shouldCache, codec: { encode, decode } },
|
|
556
|
+
})
|
|
557
|
+
async getUser(): Promise<unknown> {
|
|
558
|
+
return businessMethod();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
await expect(new UserService().getUser()).resolves.toBe(businessResult);
|
|
563
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
564
|
+
expect(shouldCache).not.toHaveBeenCalled();
|
|
565
|
+
expect(encode).not.toHaveBeenCalled();
|
|
566
|
+
expect(decode).not.toHaveBeenCalled();
|
|
567
|
+
expect(state.writes).toEqual([]);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('redis-cache-client/E07 Redis 写入与淘汰命令失败不改变装饰器业务结果', async () => {
|
|
571
|
+
const state = createSharedRedisState();
|
|
572
|
+
state.setError = new Error('SET unavailable');
|
|
573
|
+
state.deleteError = new Error('DEL unavailable');
|
|
574
|
+
state.scanError = new Error('SCAN unavailable');
|
|
575
|
+
const provider = new RedisCacheProvider(createNodeRedisCacheClient(createSharedNodeRedisSource(state)));
|
|
576
|
+
CacheProviderRegistry.register('failed-commands-node', provider);
|
|
577
|
+
|
|
578
|
+
class UserService {
|
|
579
|
+
@Cache('failed-set-users', { providerName: 'failed-commands-node' })
|
|
580
|
+
getUser(): string {
|
|
581
|
+
return 'business-read';
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@CacheEvict('failed-delete-users', { providerName: 'failed-commands-node', key: 'user-24' })
|
|
585
|
+
updateUser(): string {
|
|
586
|
+
return 'business-update';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@CacheEvict('failed-scan-users', { providerName: 'failed-commands-node', allEntries: true })
|
|
590
|
+
clearUsers(): string {
|
|
591
|
+
return 'business-clear';
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const service = new UserService();
|
|
596
|
+
await expect(service.getUser()).resolves.toBe('business-read');
|
|
597
|
+
await expect(service.updateUser()).resolves.toBe('business-update');
|
|
598
|
+
await expect(service.clearUsers()).resolves.toBe('business-clear');
|
|
599
|
+
await Promise.resolve();
|
|
600
|
+
expect(state.writes).toEqual([]);
|
|
601
|
+
});
|