Skip to content

Commit c386303

Browse files
Fixed cache eviction for TLRU cache entries that were overwritten. Changed default ttl of TLRU assignment cache.
1 parent 001ae6e commit c386303

File tree

6 files changed

+113
-57
lines changed

6 files changed

+113
-57
lines changed

src/cache/lru-cache.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -97,22 +97,3 @@ export class LRUCache implements Map<string, string> {
9797
return this;
9898
}
9999
}
100-
101-
/**
102-
* Time-aware, least-recently-used (TLRU), variant of LRU where entries have valid lifetime.
103-
* @param {number} maxSize - Maximum cache size
104-
* @param {number} ttl - Time in milliseconds after which cache entry will evict itself
105-
**/
106-
export class TLRUCache extends LRUCache {
107-
constructor(readonly maxSize: number, readonly ttl: number) {
108-
super(maxSize);
109-
}
110-
111-
set(key: string, value: string): this {
112-
const cache = super.set(key, value);
113-
setTimeout(() => {
114-
this.delete(key);
115-
}, this.ttl);
116-
return cache;
117-
}
118-
}

src/cache/tlru-cache.spec.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
import { TLRUCache } from './lru-cache';
1+
import { TLRUCache } from './tlru-cache';
22

33
describe('TLRU Cache', () => {
44
let cache: TLRUCache;
5-
const expectedCacheTimeoutMs = 50;
5+
const expectedCacheTimeoutMs = 10;
66

77
beforeEach(async () => {
88
cache = new TLRUCache(2, expectedCacheTimeoutMs);
99
});
1010

11-
afterAll(async () => {
11+
afterEach(async () => {
1212
jest.restoreAllMocks();
13+
jest.clearAllTimers();
1314
});
1415

15-
it('should evict cache after timeout', async () => {
16+
it('should evict cache after timeout', () => {
1617
jest.useFakeTimers();
1718
jest.spyOn(global, 'setTimeout');
1819

@@ -23,4 +24,38 @@ describe('TLRU Cache', () => {
2324
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), expectedCacheTimeoutMs);
2425
expect(cache.get('a')).toBeUndefined();
2526
});
27+
28+
/**
29+
* This test assumes implementation which is not ideal, but that's
30+
* the only way I know of how to go around timers in jest
31+
**/
32+
it('should overwrite existing cache entry', () => {
33+
jest.useFakeTimers();
34+
jest.spyOn(global, 'setTimeout');
35+
jest.spyOn(global, 'clearTimeout');
36+
37+
cache.set('a', 'apple');
38+
jest.advanceTimersByTime(expectedCacheTimeoutMs - 1);
39+
cache.set('a', 'avocado');
40+
41+
expect(setTimeout).toHaveBeenCalledTimes(2);
42+
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), expectedCacheTimeoutMs);
43+
expect(clearTimeout).toHaveBeenCalledTimes(1);
44+
// spin the clock by 5sec. After that time cache entry should be still valid.
45+
jest.advanceTimersByTime(expectedCacheTimeoutMs / 2);
46+
47+
// setting assertion in a weird way because calling cache.get()
48+
// will reset eviction timer which will mess up next assertion
49+
let avocadoInCache = false;
50+
cache.forEach((value, key) => {
51+
if (key === 'a' && value === 'avocado') {
52+
avocadoInCache = true;
53+
}
54+
});
55+
expect(avocadoInCache).toBe(true);
56+
57+
// after another spin of 5 sec, cache entry should evict itself
58+
jest.advanceTimersByTime(expectedCacheTimeoutMs / 2);
59+
expect(cache.has('a')).toBeFalsy();
60+
});
2661
});

src/cache/tlru-cache.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,51 @@ import { LRUCache } from './lru-cache';
66
* @param {number} ttl - Time in milliseconds after which cache entry will evict itself
77
**/
88
export class TLRUCache extends LRUCache {
9+
private readonly cacheEntriesTimoutIds = new Map<string, ReturnType<typeof setTimeout>>();
910
constructor(readonly maxSize: number, readonly ttl: number) {
1011
super(maxSize);
1112
}
1213

13-
set(key: string, value: string): this {
14-
const cache = super.set(key, value);
15-
setTimeout(() => {
14+
private clearCacheEntryTimeout(key: string): void {
15+
const timeoutId = this.cacheEntriesTimoutIds.get(key);
16+
clearTimeout(timeoutId);
17+
this.cacheEntriesTimoutIds.delete(key);
18+
}
19+
20+
private setCacheEntryTimout(key: string): void {
21+
const timeOutId = setTimeout(() => {
1622
this.delete(key);
1723
}, this.ttl);
24+
25+
this.cacheEntriesTimoutIds.set(key, timeOutId);
26+
}
27+
28+
delete(key: string): boolean {
29+
this.clearCacheEntryTimeout(key);
30+
return super.delete(key);
31+
}
32+
33+
get(key: string): string | undefined {
34+
const value = super.get(key);
35+
36+
// Whenever we get a cache hit, we need to reset the timer
37+
// for eviction, because it is now considered most recently
38+
// accessed thus the timer should start over. Not doing that
39+
// will cause a de-sync that will stop proper eviction
40+
this.clearCacheEntryTimeout(key);
41+
if (value) {
42+
this.setCacheEntryTimout(key);
43+
}
44+
return value;
45+
}
46+
47+
set(key: string, value: string): this {
48+
const cache = super.set(key, value);
49+
if (this.cacheEntriesTimoutIds.has(key)) {
50+
this.clearCacheEntryTimeout(key);
51+
}
52+
this.setCacheEntryTimout(key);
53+
1854
return cache;
1955
}
2056
}

src/cache/tlru-in-memory-assignment-cache.spec.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,40 @@ import { TLRUInMemoryAssignmentCache } from './tlru-in-memory-assignment-cache';
22

33
describe('ExpiringLRUInMemoryAssignmentCache', () => {
44
let cache: TLRUInMemoryAssignmentCache;
5-
const defaultTimout = 60_000; // 10 minutes
5+
const defaultTimout = 600_000; // 10 minutes
66

77
beforeAll(() => {
88
jest.useFakeTimers();
99
cache = new TLRUInMemoryAssignmentCache(2);
1010
});
1111

12+
afterAll(() => {
13+
jest.clearAllTimers();
14+
});
15+
1216
it(`assignment cache's timeout should default to 10 minutes `, () => {
13-
const key1 = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
14-
cache.set(key1);
17+
const cacheEntry = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
18+
cache.set(cacheEntry);
1519
jest.advanceTimersByTime(defaultTimout);
16-
expect(cache.has(key1)).toBeFalsy();
20+
expect(cache.has(cacheEntry)).toBeFalsy();
1721
});
1822

1923
it(`assignment cache's timeout value is used on construction`, () => {
2024
const expectedTimout = 88;
2125
cache = new TLRUInMemoryAssignmentCache(2, expectedTimout);
22-
const key1 = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
23-
cache.set(key1);
26+
const cacheEntry = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
27+
cache.set(cacheEntry);
2428
jest.advanceTimersByTime(expectedTimout);
25-
expect(cache.has(key1)).toBeFalsy();
29+
expect(cache.has(cacheEntry)).toBeFalsy();
2630
});
2731

2832
it(`cache shouldn't be invalidated before timeout`, () => {
29-
const key1 = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
30-
cache.set(key1);
33+
const cacheEntry = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
34+
cache.set(cacheEntry);
3135

32-
expect(cache.has(key1)).toBeTruthy();
36+
expect(cache.has(cacheEntry)).toBeTruthy();
3337

3438
jest.advanceTimersByTime(defaultTimout);
35-
expect(cache.has(key1)).toBeFalsy();
39+
expect(cache.has(cacheEntry)).toBeFalsy();
3640
});
3741
});

src/cache/tlru-in-memory-assignment-cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { TLRUCache } from './tlru-cache';
1111
* @param {number} ttl - Time in milliseconds after cache will expire.
1212
*/
1313
export class TLRUInMemoryAssignmentCache extends AbstractAssignmentCache<TLRUCache> {
14-
constructor(maxSize: number, ttl = 60_000) {
14+
constructor(maxSize: number, ttl = 600_000) {
1515
super(new TLRUCache(maxSize, ttl));
1616
}
1717
}

src/client/eppo-client.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,25 @@ export default class EppoClient {
253253
return this.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue);
254254
}
255255

256+
/**
257+
* Maps a subject to a boolean variation for a given experiment.
258+
*
259+
* @param flagKey feature flag identifier
260+
* @param subjectKey an identifier of the experiment subject, for example a user ID.
261+
* @param subjectAttributes optional attributes associated with the subject, for example name and email.
262+
* @param defaultValue default value to return if the subject is not part of the experiment sample
263+
* @returns a boolean variation value if the subject is part of the experiment sample, otherwise the default value
264+
*/
265+
public getBooleanAssignment(
266+
flagKey: string,
267+
subjectKey: string,
268+
subjectAttributes: Attributes,
269+
defaultValue: boolean,
270+
): boolean {
271+
return this.getBooleanAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue)
272+
.variation;
273+
}
274+
256275
/**
257276
* Maps a subject to a boolean variation for a given experiment and provides additional details about the
258277
* variation assigned and the reason for the assignment.
@@ -637,25 +656,6 @@ export default class EppoClient {
637656
return result;
638657
}
639658

640-
/**
641-
* Maps a subject to a boolean variation for a given experiment.
642-
*
643-
* @param flagKey feature flag identifier
644-
* @param subjectKey an identifier of the experiment subject, for example a user ID.
645-
* @param subjectAttributes optional attributes associated with the subject, for example name and email.
646-
* @param defaultValue default value to return if the subject is not part of the experiment sample
647-
* @returns a boolean variation value if the subject is part of the experiment sample, otherwise the default value
648-
*/
649-
public getBooleanAssignment(
650-
flagKey: string,
651-
subjectKey: string,
652-
subjectAttributes: Attributes,
653-
defaultValue: boolean,
654-
): boolean {
655-
return this.getBooleanAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue)
656-
.variation;
657-
}
658-
659659
private ensureActionsWithContextualAttributes(
660660
actions: BanditActions,
661661
): Record<string, ContextAttributes> {

0 commit comments

Comments
 (0)