@jintianxiayu/cache-decorator 0.1.3 → 1.0.0
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 +18 -0
- package/README.md +270 -131
- package/dist/adapters/ioredis-cache-client.d.ts +9 -0
- package/dist/adapters/ioredis-cache-client.d.ts.map +1 -0
- package/dist/adapters/ioredis-cache-client.js +73 -0
- package/dist/adapters/ioredis-cache-client.js.map +1 -0
- package/dist/adapters/node-redis-cache-client.d.ts +10 -0
- package/dist/adapters/node-redis-cache-client.d.ts.map +1 -0
- package/dist/adapters/node-redis-cache-client.js +81 -0
- package/dist/adapters/node-redis-cache-client.js.map +1 -0
- package/dist/adapters/redis-key-prefix.d.ts +22 -0
- package/dist/adapters/redis-key-prefix.d.ts.map +1 -0
- package/dist/adapters/redis-key-prefix.js +41 -0
- package/dist/adapters/redis-key-prefix.js.map +1 -0
- package/dist/core/cache-logger.d.ts +28 -0
- package/dist/core/cache-logger.d.ts.map +1 -0
- package/dist/core/cache-logger.js +70 -0
- package/dist/core/cache-logger.js.map +1 -0
- package/dist/core/cache-provider-registry.d.ts.map +1 -1
- package/dist/core/cache-provider-registry.js.map +1 -1
- package/dist/core/cache-provider.d.ts.map +1 -1
- package/dist/core/key-builder.d.ts.map +1 -1
- package/dist/core/key-builder.js +3 -2
- package/dist/core/key-builder.js.map +1 -1
- package/dist/core/native-cache.d.ts.map +1 -1
- package/dist/core/native-cache.js +2 -1
- package/dist/core/native-cache.js.map +1 -1
- package/dist/core/pending-cache.d.ts.map +1 -1
- package/dist/core/pending-cache.js +6 -3
- package/dist/core/pending-cache.js.map +1 -1
- package/dist/core/redis-cache-client.d.ts +156 -0
- package/dist/core/redis-cache-client.d.ts.map +1 -0
- package/dist/core/redis-cache-client.js +3 -0
- package/dist/core/redis-cache-client.js.map +1 -0
- package/dist/core/redis-cache.d.ts +39 -7
- package/dist/core/redis-cache.d.ts.map +1 -1
- package/dist/core/redis-cache.js +63 -22
- package/dist/core/redis-cache.js.map +1 -1
- package/dist/decorators/cache-evict.d.ts +1 -1
- package/dist/decorators/cache-evict.d.ts.map +1 -1
- package/dist/decorators/cache-evict.js +68 -14
- package/dist/decorators/cache-evict.js.map +1 -1
- package/dist/decorators/cache.d.ts +1 -1
- package/dist/decorators/cache.d.ts.map +1 -1
- package/dist/decorators/cache.js +85 -23
- package/dist/decorators/cache.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/jest.config.js +11 -11
- package/package.json +26 -29
- package/src/adapters/ioredis-cache-client.ts +89 -0
- package/src/adapters/node-redis-cache-client.ts +95 -0
- package/src/adapters/redis-key-prefix.ts +38 -0
- package/src/core/cache-logger.ts +105 -0
- package/src/core/key-builder.ts +33 -31
- package/src/core/native-cache.ts +55 -53
- package/src/core/pending-cache.ts +29 -26
- package/src/core/redis-cache-client.ts +160 -0
- package/src/core/redis-cache.ts +104 -56
- package/src/decorators/cache-evict.ts +129 -69
- package/src/decorators/cache.ts +203 -125
- package/src/index.ts +11 -8
- package/test/cache-evict-logging.test.ts +362 -0
- package/test/cache-logger.integration.test.ts +129 -0
- package/test/cache-logger.test.ts +153 -0
- package/test/cache-logging.test.ts +544 -0
- package/test/cache.test.ts +255 -256
- package/test/fixtures/cache-logger-child.mjs +108 -0
- package/test/helpers/legacy-redis-cache.ts +22 -0
- package/test/helpers/package-consumer.ts +231 -0
- package/test/helpers/redis-fixture.ts +142 -0
- package/test/ioredis-cache-client.test.ts +143 -0
- package/test/native-cache.test.ts +77 -71
- package/test/node-redis-cache-client.test.ts +149 -0
- package/test/pending-cache.test.ts +69 -58
- package/test/redis-cache-client-lifecycle.test.ts +112 -0
- package/test/redis-cache-client-types.test.ts +184 -0
- package/test/redis-cache-client.integration.test.ts +327 -0
- package/test/redis-cache-decorator.test.ts +201 -0
- package/test/redis-cache-package.integration.test.ts +328 -0
- package/test/redis-cache-provider.test.ts +269 -0
- package/test/type-contract/contract.ts +64 -0
- package/test/type-contract/tsconfig.json +12 -0
- package/tsconfig.json +8 -8
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
jest.mock('@jintianxiayu/logger', () => ({
|
|
2
|
+
LoggerFactory: {
|
|
3
|
+
getLogger: jest.fn(),
|
|
4
|
+
},
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { LoggerFactory, type LoggerInterface } from '@jintianxiayu/logger';
|
|
8
|
+
import type { CacheProvider } from '../src/core/cache-provider';
|
|
9
|
+
import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
|
|
10
|
+
import { KeyBuilder } from '../src/core/key-builder';
|
|
11
|
+
import { CacheEvict } from '../src/decorators/cache-evict';
|
|
12
|
+
|
|
13
|
+
interface EvictProviderHarness {
|
|
14
|
+
readonly provider: CacheProvider;
|
|
15
|
+
readonly delete: jest.Mock<unknown, [string]>;
|
|
16
|
+
readonly deleteByPattern: jest.Mock<unknown, [string]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CacheLogMetadata {
|
|
20
|
+
readonly event: string;
|
|
21
|
+
readonly cacheName: string;
|
|
22
|
+
readonly methodName: string;
|
|
23
|
+
readonly providerName: string;
|
|
24
|
+
readonly scope?: string;
|
|
25
|
+
readonly reason?: string;
|
|
26
|
+
readonly operation?: string;
|
|
27
|
+
readonly error?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mockLogger: jest.Mocked<LoggerInterface> = {
|
|
31
|
+
debug: jest.fn(),
|
|
32
|
+
info: jest.fn(),
|
|
33
|
+
warn: jest.fn(),
|
|
34
|
+
error: jest.fn(),
|
|
35
|
+
};
|
|
36
|
+
const mockGetLogger = jest.mocked(LoggerFactory.getLogger);
|
|
37
|
+
|
|
38
|
+
/** 创建可观察单 key 与全量删除调用的测试 Provider。 */
|
|
39
|
+
function createProvider(): EvictProviderHarness {
|
|
40
|
+
const deleteEntry = jest.fn<unknown, [string]>();
|
|
41
|
+
const deleteByPattern = jest.fn<unknown, [string]>();
|
|
42
|
+
const provider: CacheProvider = {
|
|
43
|
+
get: <Value>(_key: string): Value | undefined => undefined,
|
|
44
|
+
set: <Value>(_key: string, _value: Value, _ttl?: number): void => {
|
|
45
|
+
return;
|
|
46
|
+
},
|
|
47
|
+
delete: (key: string): void | Promise<void> => deleteEntry(key) as void | Promise<void>,
|
|
48
|
+
clear: (): void => {
|
|
49
|
+
return;
|
|
50
|
+
},
|
|
51
|
+
deleteByPattern: (pattern: string): void | Promise<void> => deleteByPattern(pattern) as void | Promise<void>,
|
|
52
|
+
};
|
|
53
|
+
return { provider, delete: deleteEntry, deleteByPattern };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loggedMetadata(method: jest.Mock): CacheLogMetadata[] {
|
|
57
|
+
return method.mock.calls.map((call) => call[1] as CacheLogMetadata);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function events(method: jest.Mock): string[] {
|
|
61
|
+
return loggedMetadata(method).map(({ event }) => event);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
jest.clearAllMocks();
|
|
66
|
+
mockGetLogger.mockReturnValue(mockLogger);
|
|
67
|
+
CacheProviderRegistry.clear();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
jest.restoreAllMocks();
|
|
72
|
+
CacheProviderRegistry.clear();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('cache-operation-logging/E03 业务失败记录 skipped 且完全跳过淘汰准备', async () => {
|
|
76
|
+
const provider = createProvider();
|
|
77
|
+
CacheProviderRegistry.register('skipped-provider', provider.provider);
|
|
78
|
+
const registryGet = jest.spyOn(CacheProviderRegistry, 'get');
|
|
79
|
+
const resolver = jest.fn(() => 'unused-key');
|
|
80
|
+
const businessError = new Error('business update failed');
|
|
81
|
+
|
|
82
|
+
class UserService {
|
|
83
|
+
@CacheEvict('skipped-users', { providerName: 'skipped-provider', key: resolver })
|
|
84
|
+
updateUser(): never {
|
|
85
|
+
throw businessError;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await expect(new UserService().updateUser()).rejects.toBe(businessError);
|
|
90
|
+
expect(registryGet).not.toHaveBeenCalled();
|
|
91
|
+
expect(resolver).not.toHaveBeenCalled();
|
|
92
|
+
expect(provider.delete).not.toHaveBeenCalled();
|
|
93
|
+
expect(provider.deleteByPattern).not.toHaveBeenCalled();
|
|
94
|
+
expect(loggedMetadata(mockLogger.warn)).toEqual([
|
|
95
|
+
{
|
|
96
|
+
event: 'cache.evict_skipped',
|
|
97
|
+
cacheName: 'skipped-users',
|
|
98
|
+
methodName: 'updateUser',
|
|
99
|
+
providerName: 'skipped-provider',
|
|
100
|
+
reason: 'business_error',
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
expect(mockLogger.debug).not.toHaveBeenCalled();
|
|
104
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('cache-operation-logging/K02 单 key resolver 失败后记录 fallback 并只删除默认 key', async () => {
|
|
108
|
+
const provider = createProvider();
|
|
109
|
+
CacheProviderRegistry.register('fallback-evict-provider', provider.provider);
|
|
110
|
+
const resolver = jest.fn(() => {
|
|
111
|
+
throw new Error('resolver failed with secret');
|
|
112
|
+
});
|
|
113
|
+
const businessResult = { updated: true };
|
|
114
|
+
|
|
115
|
+
class UserService {
|
|
116
|
+
@CacheEvict('fallback-evict-users', { providerName: 'fallback-evict-provider', key: resolver })
|
|
117
|
+
updateUser(id: number): { updated: boolean } {
|
|
118
|
+
return { ...businessResult, id };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = await new UserService().updateUser(13);
|
|
123
|
+
expect(result).toEqual({ ...businessResult, id: 13 });
|
|
124
|
+
expect(resolver).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(provider.delete).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(provider.delete).toHaveBeenCalledWith(KeyBuilder.build('fallback-evict-users', [13]));
|
|
127
|
+
expect(provider.deleteByPattern).not.toHaveBeenCalled();
|
|
128
|
+
expect(loggedMetadata(mockLogger.warn)).toEqual([
|
|
129
|
+
{
|
|
130
|
+
event: 'cache.key_fallback',
|
|
131
|
+
cacheName: 'fallback-evict-users',
|
|
132
|
+
methodName: 'updateUser',
|
|
133
|
+
providerName: 'fallback-evict-provider',
|
|
134
|
+
reason: 'resolver_error',
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
expect(JSON.stringify(loggedMetadata(mockLogger.warn))).not.toContain('resolver failed with secret');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('cache-operation-logging/K03 allEntries 忽略 resolver 且不记录 fallback', async () => {
|
|
141
|
+
const provider = createProvider();
|
|
142
|
+
CacheProviderRegistry.register('all-entries-provider', provider.provider);
|
|
143
|
+
const resolver = jest.fn(() => {
|
|
144
|
+
throw new Error('must not run');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
class UserService {
|
|
148
|
+
@CacheEvict('all-users', {
|
|
149
|
+
providerName: 'all-entries-provider',
|
|
150
|
+
allEntries: true,
|
|
151
|
+
key: resolver,
|
|
152
|
+
})
|
|
153
|
+
clearUsers(): string {
|
|
154
|
+
return 'cleared';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await expect(new UserService().clearUsers()).resolves.toBe('cleared');
|
|
159
|
+
expect(resolver).not.toHaveBeenCalled();
|
|
160
|
+
expect(provider.delete).not.toHaveBeenCalled();
|
|
161
|
+
expect(provider.deleteByPattern).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(provider.deleteByPattern).toHaveBeenCalledWith('all-users*');
|
|
163
|
+
expect(events(mockLogger.warn)).not.toContain('cache.key_fallback');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('cache-operation-logging/E01 单 key 删除返回控制权后记录 dispatched', async () => {
|
|
167
|
+
const provider = createProvider();
|
|
168
|
+
CacheProviderRegistry.register('single-evict-provider', provider.provider);
|
|
169
|
+
const businessResult = { deleted: true };
|
|
170
|
+
|
|
171
|
+
class UserService {
|
|
172
|
+
@CacheEvict('single-users', { providerName: 'single-evict-provider', key: 'user-14' })
|
|
173
|
+
deleteUser(): { deleted: boolean } {
|
|
174
|
+
return businessResult;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await expect(new UserService().deleteUser()).resolves.toBe(businessResult);
|
|
179
|
+
expect(provider.delete).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(provider.deleteByPattern).not.toHaveBeenCalled();
|
|
181
|
+
expect(loggedMetadata(mockLogger.debug)).toEqual([
|
|
182
|
+
{
|
|
183
|
+
event: 'cache.evict_dispatched',
|
|
184
|
+
cacheName: 'single-users',
|
|
185
|
+
methodName: 'deleteUser',
|
|
186
|
+
providerName: 'single-evict-provider',
|
|
187
|
+
scope: 'key',
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('cache-operation-logging/E02 allEntries 等待删除完成后才记录 completed', async () => {
|
|
193
|
+
let resolveEviction: (() => void) | undefined;
|
|
194
|
+
const evictionPromise = new Promise<void>((resolve) => {
|
|
195
|
+
resolveEviction = resolve;
|
|
196
|
+
});
|
|
197
|
+
const provider = createProvider();
|
|
198
|
+
provider.deleteByPattern.mockReturnValue(evictionPromise);
|
|
199
|
+
CacheProviderRegistry.register('awaited-evict-provider', provider.provider);
|
|
200
|
+
|
|
201
|
+
class UserService {
|
|
202
|
+
@CacheEvict('awaited-users', { providerName: 'awaited-evict-provider', allEntries: true })
|
|
203
|
+
clearUsers(): string {
|
|
204
|
+
return 'cleared';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const operation = new UserService().clearUsers() as unknown as Promise<string>;
|
|
209
|
+
await Promise.resolve();
|
|
210
|
+
expect(provider.deleteByPattern).toHaveBeenCalledWith('awaited-users*');
|
|
211
|
+
expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
|
|
212
|
+
|
|
213
|
+
if (!resolveEviction) {
|
|
214
|
+
throw new Error('Eviction resolver was not initialized');
|
|
215
|
+
}
|
|
216
|
+
resolveEviction();
|
|
217
|
+
await expect(operation).resolves.toBe('cleared');
|
|
218
|
+
expect(loggedMetadata(mockLogger.debug)).toEqual([
|
|
219
|
+
{
|
|
220
|
+
event: 'cache.evict_completed',
|
|
221
|
+
cacheName: 'awaited-users',
|
|
222
|
+
methodName: 'clearUsers',
|
|
223
|
+
providerName: 'awaited-evict-provider',
|
|
224
|
+
scope: 'allEntries',
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('cache-operation-logging/E04 重复淘汰每次只执行一次实际删除和一次日志', async () => {
|
|
230
|
+
const provider = createProvider();
|
|
231
|
+
CacheProviderRegistry.register('repeat-evict-provider', provider.provider);
|
|
232
|
+
|
|
233
|
+
class UserService {
|
|
234
|
+
@CacheEvict('repeat-evict-users', { providerName: 'repeat-evict-provider', key: 'shared' })
|
|
235
|
+
deleteUser(): boolean {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const service = new UserService();
|
|
241
|
+
await service.deleteUser();
|
|
242
|
+
await service.deleteUser();
|
|
243
|
+
expect(provider.delete).toHaveBeenCalledTimes(2);
|
|
244
|
+
expect(events(mockLogger.debug)).toEqual(['cache.evict_dispatched', 'cache.evict_dispatched']);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('cache-operation-logging/F03 allEntries 删除失败记录同一错误且不报告 completed', async () => {
|
|
248
|
+
const evictionError = new Error('redis scan failed');
|
|
249
|
+
const provider = createProvider();
|
|
250
|
+
provider.deleteByPattern.mockRejectedValue(evictionError);
|
|
251
|
+
CacheProviderRegistry.register('failed-all-evict-provider', provider.provider);
|
|
252
|
+
|
|
253
|
+
class UserService {
|
|
254
|
+
@CacheEvict('failed-all-users', { providerName: 'failed-all-evict-provider', allEntries: true })
|
|
255
|
+
clearUsers(): boolean {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await expect(new UserService().clearUsers()).rejects.toBe(evictionError);
|
|
261
|
+
expect(provider.deleteByPattern).toHaveBeenCalledTimes(1);
|
|
262
|
+
expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
|
|
263
|
+
expect(loggedMetadata(mockLogger.error)).toEqual([
|
|
264
|
+
{
|
|
265
|
+
event: 'cache.operation_failed',
|
|
266
|
+
cacheName: 'failed-all-users',
|
|
267
|
+
methodName: 'clearUsers',
|
|
268
|
+
providerName: 'failed-all-evict-provider',
|
|
269
|
+
operation: 'evict',
|
|
270
|
+
error: evictionError,
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('cache-operation-logging/F05 异步单 key 删除拒绝不会被等待或消费', async () => {
|
|
276
|
+
let rejectEviction: ((reason?: unknown) => void) | undefined;
|
|
277
|
+
const evictionError = new Error('async delete failed');
|
|
278
|
+
const evictionPromise = new Promise<void>((_resolve, reject) => {
|
|
279
|
+
rejectEviction = reject;
|
|
280
|
+
});
|
|
281
|
+
const observedEvictionFailure = evictionPromise.catch((error: unknown) => error);
|
|
282
|
+
const thenSpy = jest.spyOn(evictionPromise, 'then');
|
|
283
|
+
const provider = createProvider();
|
|
284
|
+
provider.delete.mockReturnValue(evictionPromise);
|
|
285
|
+
CacheProviderRegistry.register('async-delete-provider', provider.provider);
|
|
286
|
+
|
|
287
|
+
class UserService {
|
|
288
|
+
@CacheEvict('async-delete-users', { providerName: 'async-delete-provider', key: 'user-15' })
|
|
289
|
+
deleteUser(): number {
|
|
290
|
+
return 15;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await expect(new UserService().deleteUser()).resolves.toBe(15);
|
|
295
|
+
expect(provider.delete).toHaveBeenCalledTimes(1);
|
|
296
|
+
expect(thenSpy).not.toHaveBeenCalled();
|
|
297
|
+
expect(events(mockLogger.debug)).toEqual(['cache.evict_dispatched']);
|
|
298
|
+
expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
|
|
299
|
+
|
|
300
|
+
if (!rejectEviction) {
|
|
301
|
+
throw new Error('Eviction rejecter was not initialized');
|
|
302
|
+
}
|
|
303
|
+
rejectEviction(evictionError);
|
|
304
|
+
await expect(observedEvictionFailure).resolves.toBe(evictionError);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('淘汰 Provider 解析失败记录 provider_resolution 且不删除', async () => {
|
|
308
|
+
const registryError = new Error('evict provider missing');
|
|
309
|
+
jest.spyOn(CacheProviderRegistry, 'get').mockImplementation(() => {
|
|
310
|
+
throw registryError;
|
|
311
|
+
});
|
|
312
|
+
const businessMethod = jest.fn(() => 'updated');
|
|
313
|
+
|
|
314
|
+
class UserService {
|
|
315
|
+
@CacheEvict('missing-evict-users', { providerName: 'missing-evict-provider' })
|
|
316
|
+
updateUser(): string {
|
|
317
|
+
return businessMethod();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await expect(new UserService().updateUser()).rejects.toBe(registryError);
|
|
322
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
323
|
+
expect(loggedMetadata(mockLogger.error)).toEqual([
|
|
324
|
+
{
|
|
325
|
+
event: 'cache.operation_failed',
|
|
326
|
+
cacheName: 'missing-evict-users',
|
|
327
|
+
methodName: 'updateUser',
|
|
328
|
+
providerName: 'missing-evict-provider',
|
|
329
|
+
operation: 'provider_resolution',
|
|
330
|
+
error: registryError,
|
|
331
|
+
},
|
|
332
|
+
]);
|
|
333
|
+
expect(mockLogger.debug).not.toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('同步单 key 删除失败记录 evict operation 且传播同一错误', async () => {
|
|
337
|
+
const evictionError = new Error('synchronous delete failed');
|
|
338
|
+
const provider = createProvider();
|
|
339
|
+
provider.delete.mockImplementation(() => {
|
|
340
|
+
throw evictionError;
|
|
341
|
+
});
|
|
342
|
+
CacheProviderRegistry.register('sync-delete-provider', provider.provider);
|
|
343
|
+
|
|
344
|
+
class UserService {
|
|
345
|
+
@CacheEvict('sync-delete-users', { providerName: 'sync-delete-provider' })
|
|
346
|
+
deleteUser(id: number): number {
|
|
347
|
+
return id;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await expect(new UserService().deleteUser(16)).rejects.toBe(evictionError);
|
|
352
|
+
expect(provider.delete).toHaveBeenCalledTimes(1);
|
|
353
|
+
expect(events(mockLogger.debug)).not.toContain('cache.evict_dispatched');
|
|
354
|
+
expect(loggedMetadata(mockLogger.error)).toContainEqual({
|
|
355
|
+
event: 'cache.operation_failed',
|
|
356
|
+
cacheName: 'sync-delete-users',
|
|
357
|
+
methodName: 'deleteUser',
|
|
358
|
+
providerName: 'sync-delete-provider',
|
|
359
|
+
operation: 'evict',
|
|
360
|
+
error: evictionError,
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { runCommand } from './helpers/package-consumer';
|
|
5
|
+
|
|
6
|
+
jest.setTimeout(30_000);
|
|
7
|
+
|
|
8
|
+
interface CacheLogMetadata {
|
|
9
|
+
readonly event: string;
|
|
10
|
+
readonly cacheName: string;
|
|
11
|
+
readonly methodName: string;
|
|
12
|
+
readonly providerName: string;
|
|
13
|
+
readonly entryType?: string;
|
|
14
|
+
readonly operation?: string;
|
|
15
|
+
readonly error?: {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly password: string;
|
|
19
|
+
readonly email: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface JsonLogEvent {
|
|
24
|
+
readonly level: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly traceId: string;
|
|
28
|
+
readonly meta: CacheLogMetadata;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ChildResult {
|
|
32
|
+
readonly code: number | null;
|
|
33
|
+
readonly stdout: string;
|
|
34
|
+
readonly stderr: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const packageRoot = resolve(__dirname, '..');
|
|
38
|
+
const loggerRoot = resolve(packageRoot, '../logger');
|
|
39
|
+
const fixture = join(__dirname, 'fixtures', 'cache-logger-child.mjs');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 在隔离进程运行真实 Logger fixture,确保关闭流程完整刷新结构化输出。
|
|
43
|
+
* @param profileLevel cache 命名 profile 的筛选级别。
|
|
44
|
+
* @returns 子进程退出码和完整标准流。
|
|
45
|
+
* @throws 子进程无法启动时拒绝。
|
|
46
|
+
*/
|
|
47
|
+
function runFixture(profileLevel: 'debug' | 'error'): Promise<ChildResult> {
|
|
48
|
+
return new Promise((resolveResult, reject) => {
|
|
49
|
+
const child = spawn(process.execPath, [fixture, profileLevel], {
|
|
50
|
+
cwd: packageRoot,
|
|
51
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
let stdout = '';
|
|
55
|
+
let stderr = '';
|
|
56
|
+
child.stdout.setEncoding('utf8');
|
|
57
|
+
child.stderr.setEncoding('utf8');
|
|
58
|
+
child.stdout.on('data', (chunk: string) => (stdout += chunk));
|
|
59
|
+
child.stderr.on('data', (chunk: string) => (stderr += chunk));
|
|
60
|
+
child.once('error', reject);
|
|
61
|
+
child.once('exit', (code) => resolveResult({ code, stdout, stderr }));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseEvents(output: string): JsonLogEvent[] {
|
|
66
|
+
return output
|
|
67
|
+
.split(/\r?\n/)
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((line) => JSON.parse(line) as JsonLogEvent);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeAll(() => {
|
|
73
|
+
const compiler = require.resolve('typescript/bin/tsc');
|
|
74
|
+
runCommand({ executable: process.execPath, args: [compiler, '-p', loggerRoot], cwd: loggerRoot });
|
|
75
|
+
runCommand({ executable: process.execPath, args: [compiler, '-p', packageRoot], cwd: packageRoot });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('cache-operation-logging/M03 cache-operation-logging/M04 使用真实命名 Logger 脱敏并关联 traceId', async () => {
|
|
79
|
+
const result = await runFixture('debug');
|
|
80
|
+
expect(result.code).toBe(0);
|
|
81
|
+
expect(result.stderr).toBe('');
|
|
82
|
+
expect(result.stdout).not.toContain('provider-password-secret');
|
|
83
|
+
expect(result.stdout).not.toContain('provider@example.com');
|
|
84
|
+
expect(result.stdout).not.toContain('business-value-secret');
|
|
85
|
+
|
|
86
|
+
const output = parseEvents(result.stdout);
|
|
87
|
+
expect(output.map(({ level, meta }) => `${level}:${meta.event}`)).toEqual([
|
|
88
|
+
'debug:cache.miss',
|
|
89
|
+
'debug:cache.write_dispatched',
|
|
90
|
+
'error:cache.operation_failed',
|
|
91
|
+
]);
|
|
92
|
+
expect(output.every(({ name }) => name === '@jintianxiayu/cache-decorator')).toBe(true);
|
|
93
|
+
expect(output.every(({ traceId }) => traceId === 'cache-fixture-trace')).toBe(true);
|
|
94
|
+
expect(output.every(({ meta }) => !('traceId' in meta))).toBe(true);
|
|
95
|
+
expect(output[2]?.meta).toMatchObject({
|
|
96
|
+
event: 'cache.operation_failed',
|
|
97
|
+
cacheName: 'fixture-failure-users',
|
|
98
|
+
methodName: 'getUser',
|
|
99
|
+
providerName: 'failing',
|
|
100
|
+
operation: 'read',
|
|
101
|
+
error: {
|
|
102
|
+
name: 'Error',
|
|
103
|
+
message: 'redis unavailable',
|
|
104
|
+
password: '********',
|
|
105
|
+
email: 'pr***@example.com',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('cache-operation-logging/Logger 配置筛选输出且复用应用提供的唯一 Logger runtime', async () => {
|
|
111
|
+
const result = await runFixture('error');
|
|
112
|
+
expect(result.code).toBe(0);
|
|
113
|
+
expect(result.stderr).toBe('');
|
|
114
|
+
const output = parseEvents(result.stdout);
|
|
115
|
+
expect(output).toHaveLength(1);
|
|
116
|
+
expect(output[0]).toMatchObject({
|
|
117
|
+
level: 'error',
|
|
118
|
+
name: '@jintianxiayu/cache-decorator',
|
|
119
|
+
traceId: 'cache-fixture-trace',
|
|
120
|
+
meta: { event: 'cache.operation_failed', operation: 'read' },
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('cache decorator 源码不读取或写入 LoggerContext', () => {
|
|
125
|
+
const sources = ['src/core/cache-logger.ts', 'src/decorators/cache.ts', 'src/decorators/cache-evict.ts']
|
|
126
|
+
.map((file) => readFileSync(join(packageRoot, file), 'utf8'))
|
|
127
|
+
.join('\n');
|
|
128
|
+
expect(sources).not.toContain('LoggerContext');
|
|
129
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { LoggerInterface } from '@jintianxiayu/logger';
|
|
2
|
+
|
|
3
|
+
type CacheLoggerModule = typeof import('../src/core/cache-logger');
|
|
4
|
+
|
|
5
|
+
interface CacheLoggerHarness {
|
|
6
|
+
readonly cacheLogger: CacheLoggerModule;
|
|
7
|
+
readonly getLogger: jest.Mock<LoggerInterface, [string]>;
|
|
8
|
+
readonly logger: jest.Mocked<LoggerInterface>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const logContext = {
|
|
12
|
+
cacheName: 'users',
|
|
13
|
+
methodName: 'findUser',
|
|
14
|
+
providerName: 'default',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
function createMockLogger(): jest.Mocked<LoggerInterface> {
|
|
18
|
+
return {
|
|
19
|
+
debug: jest.fn(),
|
|
20
|
+
info: jest.fn(),
|
|
21
|
+
warn: jest.fn(),
|
|
22
|
+
error: jest.fn(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 为每个用例重新加载 cache Logger 模块,隔离模块级 Logger 缓存并允许注入获取失败。
|
|
28
|
+
* @param getLoggerImplementation 可选的 LoggerFactory.getLogger 替代实现。
|
|
29
|
+
* @returns 独立模块、Logger 获取 mock 和四个 level 方法 mock。
|
|
30
|
+
* @throws 隔离模块未能同步加载时抛出。
|
|
31
|
+
*/
|
|
32
|
+
function loadCacheLogger(getLoggerImplementation?: (name: string) => LoggerInterface): CacheLoggerHarness {
|
|
33
|
+
jest.resetModules();
|
|
34
|
+
const logger = createMockLogger();
|
|
35
|
+
const getLogger = jest.fn<LoggerInterface, [string]>(getLoggerImplementation ?? (() => logger));
|
|
36
|
+
jest.doMock('@jintianxiayu/logger', () => ({ LoggerFactory: { getLogger } }));
|
|
37
|
+
let cacheLogger: CacheLoggerModule | undefined;
|
|
38
|
+
jest.isolateModules(() => {
|
|
39
|
+
cacheLogger = jest.requireActual<CacheLoggerModule>('../src/core/cache-logger');
|
|
40
|
+
});
|
|
41
|
+
if (!cacheLogger) {
|
|
42
|
+
throw new Error('Cache logger module was not loaded');
|
|
43
|
+
}
|
|
44
|
+
return { cacheLogger, getLogger, logger };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
jest.resetModules();
|
|
49
|
+
jest.dontMock('@jintianxiayu/logger');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('cache-operation-logging/L01 导入和 decorator 求值不初始化 Logger', () => {
|
|
53
|
+
const { getLogger } = loadCacheLogger();
|
|
54
|
+
const { Cache } = jest.requireActual<typeof import('../src/decorators/cache')>('../src/decorators/cache');
|
|
55
|
+
const { CacheEvict } = jest.requireActual<typeof import('../src/decorators/cache-evict')>(
|
|
56
|
+
'../src/decorators/cache-evict'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
Cache('users');
|
|
60
|
+
CacheEvict('users');
|
|
61
|
+
|
|
62
|
+
expect(getLogger).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('cache-operation-logging/L02 首次运行期事件获取并复用应用命名 Logger', () => {
|
|
66
|
+
const { cacheLogger, getLogger, logger } = loadCacheLogger();
|
|
67
|
+
|
|
68
|
+
cacheLogger.logCacheEvent('cache.miss', logContext);
|
|
69
|
+
cacheLogger.logCacheEvent('cache.hit', { ...logContext, entryType: 'value' });
|
|
70
|
+
|
|
71
|
+
expect(getLogger).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(getLogger).toHaveBeenCalledWith('@jintianxiayu/cache-decorator');
|
|
73
|
+
expect(logger.debug).toHaveBeenCalledTimes(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('cache-operation-logging/正常缓存决策使用 debug', () => {
|
|
77
|
+
const { cacheLogger, logger } = loadCacheLogger();
|
|
78
|
+
const events = [
|
|
79
|
+
'cache.pending_hit',
|
|
80
|
+
'cache.hit',
|
|
81
|
+
'cache.miss',
|
|
82
|
+
'cache.write_dispatched',
|
|
83
|
+
'cache.evict_dispatched',
|
|
84
|
+
'cache.evict_completed',
|
|
85
|
+
] as const;
|
|
86
|
+
|
|
87
|
+
for (const event of events) {
|
|
88
|
+
cacheLogger.logCacheEvent(event, logContext);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(logger.debug).toHaveBeenCalledTimes(events.length);
|
|
92
|
+
expect(logger.debug.mock.calls.map((call) => (call[1] as { event: string }).event)).toEqual(events);
|
|
93
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
94
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
95
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('cache-operation-logging/可恢复回退与淘汰跳过使用 warn', () => {
|
|
99
|
+
const { cacheLogger, logger } = loadCacheLogger();
|
|
100
|
+
|
|
101
|
+
cacheLogger.logCacheEvent('cache.key_fallback', { ...logContext, reason: 'resolver_error' });
|
|
102
|
+
cacheLogger.logCacheEvent('cache.evict_skipped', { ...logContext, reason: 'business_error' });
|
|
103
|
+
|
|
104
|
+
expect(logger.warn).toHaveBeenCalledTimes(2);
|
|
105
|
+
expect(logger.warn.mock.calls.map((call) => (call[1] as { event: string }).event)).toEqual([
|
|
106
|
+
'cache.key_fallback',
|
|
107
|
+
'cache.evict_skipped',
|
|
108
|
+
]);
|
|
109
|
+
expect(logger.debug).not.toHaveBeenCalled();
|
|
110
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
111
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('cache-operation-logging/缓存基础设施失败使用 error', () => {
|
|
115
|
+
const { cacheLogger, logger } = loadCacheLogger();
|
|
116
|
+
const providerError = new Error('redis unavailable');
|
|
117
|
+
|
|
118
|
+
cacheLogger.logCacheEvent('cache.operation_failed', {
|
|
119
|
+
...logContext,
|
|
120
|
+
operation: 'read',
|
|
121
|
+
error: providerError,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
125
|
+
'Cache operation failed',
|
|
126
|
+
expect.objectContaining({ event: 'cache.operation_failed', operation: 'read', error: providerError })
|
|
127
|
+
);
|
|
128
|
+
expect(logger.debug).not.toHaveBeenCalled();
|
|
129
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
130
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('Logger 获取或写入同步失败时不向缓存调用方抛出', () => {
|
|
134
|
+
const getLoggerError = new Error('invalid logger config');
|
|
135
|
+
const failedGet = loadCacheLogger(() => {
|
|
136
|
+
throw getLoggerError;
|
|
137
|
+
});
|
|
138
|
+
expect(() => failedGet.cacheLogger.logCacheEvent('cache.miss', logContext)).not.toThrow();
|
|
139
|
+
|
|
140
|
+
const failedWrite = loadCacheLogger();
|
|
141
|
+
failedWrite.logger.debug.mockImplementationOnce(() => {
|
|
142
|
+
throw new Error('transport failed');
|
|
143
|
+
});
|
|
144
|
+
expect(() => failedWrite.cacheLogger.logCacheEvent('cache.miss', logContext)).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('Provider 日志标签将缺省和空名称稳定映射为 default', () => {
|
|
148
|
+
const { cacheLogger } = loadCacheLogger();
|
|
149
|
+
|
|
150
|
+
expect(cacheLogger.cacheProviderLabel(undefined)).toBe('default');
|
|
151
|
+
expect(cacheLogger.cacheProviderLabel('')).toBe('default');
|
|
152
|
+
expect(cacheLogger.cacheProviderLabel('redis-primary')).toBe('redis-primary');
|
|
153
|
+
});
|