Skip to content

Commit 1543123

Browse files
committed
feat: @CacheEvict에 debounceMs 옵션 추가
동일 키에 대한 캐시 무효화 요청이 짧은 시간 내에 집중될 때 debounceMs 창 내의 중복 요청을 하나로 병합하여 불필요한 evict를 방지 (cherry picked from commit 29c6c57)
1 parent 14c03c6 commit 1543123

8 files changed

Lines changed: 150 additions & 10 deletions

File tree

README.ja.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ async clearAll() { ... }
204204
// メソッド実行前に削除
205205
@CacheEvict({ name: 'users', allEntries: true, beforeInvocation: true })
206206
async refreshUsers() { ... }
207+
208+
// debounce削除 — 短時間に同一キーへの無効化リクエストが集中する場合に有効
209+
@CacheEvict({ name: 'reports', allEntries: true, debounceMs: 3000 })
210+
async onDataChanged() { ... }
207211
```
208212

209213
### 組み合わせ使用
@@ -242,6 +246,7 @@ async getUser(id: string) { ... }
242246
| `allEntries` | `boolean` | `false` | ネームスペース全体を削除 |
243247
| `beforeInvocation` | `boolean` | `false` | メソッド実行前に削除 |
244248
| `condition` | `(...args) => boolean` || `false`なら削除をスキップ |
249+
| `debounceMs` | `number` || 指定した時間(ms)以内の同一キーへの重複削除リクエストを1回にまとめる |
245250

246251
---
247252

README.ko.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ async clearAll() { ... }
204204
// 메서드 실행 전 삭제
205205
@CacheEvict({ name: 'users', allEntries: true, beforeInvocation: true })
206206
async refreshUsers() { ... }
207+
208+
// debounce 삭제 — 짧은 시간 내 동일 키에 무효화 요청이 폭발적으로 들어올 때 유용
209+
@CacheEvict({ name: 'reports', allEntries: true, debounceMs: 3000 })
210+
async onDataChanged() { ... }
207211
```
208212

209213
### 복합 사용
@@ -242,6 +246,7 @@ async getUser(id: string) { ... }
242246
| `allEntries` | `boolean` | `false` | 네임스페이스 전체 삭제 |
243247
| `beforeInvocation` | `boolean` | `false` | 메서드 실행 전 삭제 |
244248
| `condition` | `(...args) => boolean` || `false`면 삭제 건너뜀 |
249+
| `debounceMs` | `number` || 지정한 시간(ms) 내 동일 키에 대한 중복 삭제 요청을 하나로 병합 |
245250

246251
---
247252

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ async clearAll() { ... }
204204
// Evict before method execution
205205
@CacheEvict({ name: 'users', allEntries: true, beforeInvocation: true })
206206
async refreshUsers() { ... }
207+
208+
// Debounce eviction — useful when the same key may be invalidated many times in quick succession
209+
@CacheEvict({ name: 'reports', allEntries: true, debounceMs: 3000 })
210+
async onDataChanged() { ... }
207211
```
208212

209213
### Combined usage
@@ -242,6 +246,7 @@ async getUser(id: string) { ... }
242246
| `allEntries` | `boolean` | `false` | Evict entire namespace |
243247
| `beforeInvocation` | `boolean` | `false` | Evict before method execution |
244248
| `condition` | `(...args) => boolean` || Skip eviction if `false` |
249+
| `debounceMs` | `number` || Collapse bursts: multiple evictions for the same key within this window (ms) are merged into one |
245250

246251
---
247252

src/cachex/core/cache-evict-option.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,17 @@ export interface CacheEvictOption {
5757
* @default false
5858
*/
5959
beforeInvocation?: boolean;
60+
61+
/**
62+
* Debounce delay in milliseconds.
63+
* Multiple evict calls targeting the same key(s) within this window
64+
* are collapsed into a single eviction executed at the end of the window.
65+
*
66+
* Note: when used with `beforeInvocation: true`, the method runs immediately
67+
* but the actual eviction is still deferred by `debounceMs`.
68+
*
69+
* @default undefined (disabled — eviction is immediate)
70+
* @example debounceMs: 3000 // coalesce bursts within 3 seconds
71+
*/
72+
debounceMs?: number;
6073
}

src/cachex/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type CacheEvictContext = {
2323
keys: string[];
2424
cacheProvider: CacheProvider;
2525
allEntries?: boolean;
26+
debounceMs?: number;
2627
};
2728

2829
export type CacheKeyContext = {

src/cachex/support/__test__/cache-operations.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,87 @@ describe('CacheOperations', () => {
257257
await cacheOperations.bulkEvict(context);
258258
expect(mockCacheProvider.evict).toHaveBeenCalledTimes(1);
259259
});
260+
261+
describe('debounceMs', () => {
262+
beforeEach(() => jest.useFakeTimers());
263+
264+
it('should collapse multiple calls within the debounce window into one eviction', async () => {
265+
const context: CacheEvictContext = {
266+
keys: ['k1'],
267+
cacheProvider: mockCacheProvider,
268+
debounceMs: 100,
269+
};
270+
271+
void cacheOperations.bulkEvict(context);
272+
void cacheOperations.bulkEvict(context);
273+
void cacheOperations.bulkEvict(context);
274+
275+
expect(mockCacheProvider.evict).not.toHaveBeenCalled();
276+
277+
jest.advanceTimersByTime(100);
278+
await Promise.resolve(); // flush microtasks
279+
280+
expect(mockCacheProvider.evict).toHaveBeenCalledTimes(1);
281+
});
282+
283+
it('should evict immediately when debounceMs is not set', async () => {
284+
const context: CacheEvictContext = { keys: ['k1'], cacheProvider: mockCacheProvider };
285+
await cacheOperations.bulkEvict(context);
286+
expect(mockCacheProvider.evict).toHaveBeenCalledTimes(1);
287+
});
288+
289+
it('should debounce independently for different key sets', async () => {
290+
void cacheOperations.bulkEvict({
291+
keys: ['k1'],
292+
cacheProvider: mockCacheProvider,
293+
debounceMs: 100,
294+
});
295+
void cacheOperations.bulkEvict({
296+
keys: ['k2'],
297+
cacheProvider: mockCacheProvider,
298+
debounceMs: 100,
299+
});
300+
301+
jest.advanceTimersByTime(100);
302+
await Promise.resolve();
303+
304+
expect(mockCacheProvider.evict).toHaveBeenCalledTimes(2);
305+
});
306+
307+
it('should reset the timer when called again within the window', async () => {
308+
const context: CacheEvictContext = {
309+
keys: ['k1'],
310+
cacheProvider: mockCacheProvider,
311+
debounceMs: 100,
312+
};
313+
314+
void cacheOperations.bulkEvict(context);
315+
jest.advanceTimersByTime(50);
316+
void cacheOperations.bulkEvict(context); // resets timer
317+
318+
jest.advanceTimersByTime(50); // 100ms total elapsed, but timer was reset at 50ms
319+
expect(mockCacheProvider.evict).not.toHaveBeenCalled();
320+
321+
jest.advanceTimersByTime(50); // 50ms after reset — timer fires now
322+
await Promise.resolve();
323+
324+
expect(mockCacheProvider.evict).toHaveBeenCalledTimes(1);
325+
});
326+
327+
it('should clear pending timers on module destroy without executing eviction', () => {
328+
const context: CacheEvictContext = {
329+
keys: ['k1'],
330+
cacheProvider: mockCacheProvider,
331+
debounceMs: 100,
332+
};
333+
void cacheOperations.bulkEvict(context);
334+
335+
cacheOperations.onModuleDestroy();
336+
jest.advanceTimersByTime(200);
337+
338+
expect(mockCacheProvider.evict).not.toHaveBeenCalled();
339+
});
340+
});
260341
});
261342

262343
it('should increase the wait time exponentially with each retry attempt', async () => {

src/cachex/support/cache-aspect-support.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export class CacheAspectSupport {
5757
keys,
5858
cacheProvider,
5959
allEntries: option.allEntries,
60+
debounceMs: option.debounceMs,
6061
};
6162

6263
if (option.beforeInvocation) {

src/cachex/support/cache-operations.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Inject, Injectable, Logger } from '@nestjs/common';
1+
import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
22

33
import type { CacheModuleConfig, CompressionConfig, SwrConfig } from '../core';
44
import {
@@ -18,7 +18,7 @@ interface ResolvedSwrConfig {
1818
}
1919

2020
@Injectable()
21-
export class CacheOperations {
21+
export class CacheOperations implements OnModuleDestroy {
2222
private readonly logger = new Logger(CacheOperations.name);
2323

2424
private readonly globalSwrConfig: SwrConfig;
@@ -31,6 +31,9 @@ export class CacheOperations {
3131
/** In-process single-flight: collapses concurrent requests for the same key into one Promise. */
3232
private readonly inflightMap = new Map<string, Promise<unknown>>();
3333

34+
/** Pending debounce timers for @CacheEvict. Map key: NUL-joined cache keys. */
35+
private readonly evictDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
36+
3437
private readonly pubSubTimeoutMs: number;
3538

3639
constructor(@Inject(CACHE_MODULE_CONFIG) config: CacheModuleConfig) {
@@ -69,17 +72,43 @@ export class CacheOperations {
6972
}
7073

7174
async bulkEvict(context: CacheEvictContext): Promise<void> {
72-
const { keys, cacheProvider, allEntries = false } = context;
75+
const { keys, cacheProvider, allEntries = false, debounceMs } = context;
7376

74-
try {
75-
if (allEntries) {
76-
await this.deleteByPatterns(cacheProvider, keys);
77-
} else {
78-
await this.deleteByKeys(cacheProvider, keys);
77+
const doEvict = async () => {
78+
try {
79+
if (allEntries) {
80+
await this.deleteByPatterns(cacheProvider, keys);
81+
} else {
82+
await this.deleteByKeys(cacheProvider, keys);
83+
}
84+
} catch (error) {
85+
this.logger.error('Cache eviction failed', error);
7986
}
80-
} catch (error) {
81-
this.logger.error('Cache eviction failed', error);
87+
};
88+
89+
if (!debounceMs || debounceMs <= 0) {
90+
return doEvict();
91+
}
92+
93+
// NUL character cannot appear in Redis keys, so it is safe as a separator
94+
const timerId = keys.join('\0');
95+
const existing = this.evictDebounceTimers.get(timerId);
96+
if (existing) clearTimeout(existing);
97+
98+
this.evictDebounceTimers.set(
99+
timerId,
100+
setTimeout(() => {
101+
this.evictDebounceTimers.delete(timerId);
102+
void doEvict();
103+
}, debounceMs),
104+
);
105+
}
106+
107+
onModuleDestroy(): void {
108+
for (const timer of this.evictDebounceTimers.values()) {
109+
clearTimeout(timer);
82110
}
111+
this.evictDebounceTimers.clear();
83112
}
84113

85114
/**

0 commit comments

Comments
 (0)