Skip to content

Commit cbd2015

Browse files
authored
feat: Simplify AssignmentCache interface (#83)
* feat: Simplify AssignmentCache interface * cleanup * reverting these unrelated changes * minor cleanup
1 parent 888e2a3 commit cbd2015

File tree

11 files changed

+181
-141
lines changed

11 files changed

+181
-141
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"devDependencies": {
4444
"@types/jest": "^29.5.11",
4545
"@types/js-base64": "^3.3.1",
46+
"@types/lodash": "^4.17.5",
4647
"@types/md5": "^2.3.2",
4748
"@types/semver": "^7.5.6",
4849
"@typescript-eslint/eslint-plugin": "^5.13.0",
@@ -55,6 +56,7 @@
5556
"eslint-plugin-promise": "^6.0.0",
5657
"jest": "^29.7.0",
5758
"jest-environment-jsdom": "^29.7.0",
59+
"lodash": "^4.17.21",
5860
"prettier": "^2.7.1",
5961
"terser-webpack-plugin": "^5.3.3",
6062
"testdouble": "^3.20.1",

src/assignment-cache.ts

Lines changed: 0 additions & 75 deletions
This file was deleted.

src/cache/assignment-cache.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { getMD5Hash } from '../obfuscation';
2+
3+
import { LRUCache } from './lru-cache';
4+
5+
export type AssignmentCacheKey = {
6+
subjectKey: string;
7+
flagKey: string;
8+
allocationKey: string;
9+
variationKey: string;
10+
};
11+
12+
export interface AsyncMap<K, V> {
13+
get(key: K): Promise<V | undefined>;
14+
set(key: K, value: V): Promise<void>;
15+
has(key: K): Promise<boolean>;
16+
}
17+
18+
export interface AssignmentCache {
19+
set(key: AssignmentCacheKey): void;
20+
has(key: AssignmentCacheKey): boolean;
21+
}
22+
23+
export abstract class AssignmentCacheImpl<T extends Map<string, string>>
24+
implements AssignmentCache
25+
{
26+
// key -> variation value hash
27+
protected constructor(protected readonly delegate: T) {}
28+
29+
has(key: AssignmentCacheKey): boolean {
30+
const isPresent = this.delegate.has(this.toCacheKeyString(key));
31+
if (!isPresent) {
32+
// no cache key present
33+
return false;
34+
}
35+
36+
// the subject has been assigned to a different variation
37+
// than was previously logged.
38+
// in this case we need to log the assignment again.
39+
const cachedValue = this.get(key);
40+
return cachedValue === getMD5Hash(key.variationKey);
41+
}
42+
43+
private get(key: AssignmentCacheKey): string | undefined {
44+
return this.delegate.get(this.toCacheKeyString(key));
45+
}
46+
47+
set(key: AssignmentCacheKey): void {
48+
this.delegate.set(this.toCacheKeyString(key), getMD5Hash(key.variationKey));
49+
}
50+
51+
private toCacheKeyString({ subjectKey, flagKey, allocationKey }: AssignmentCacheKey): string {
52+
return [`subject:${subjectKey}`, `flag:${flagKey}`, `allocation:${allocationKey}`].join(';');
53+
}
54+
}
55+
56+
/**
57+
* A cache that never expires.
58+
*
59+
* The primary use case is for client-side SDKs, where the cache is only used
60+
* for a single user.
61+
*/
62+
export class NonExpiringInMemoryAssignmentCache extends AssignmentCacheImpl<Map<string, string>> {
63+
constructor() {
64+
super(new Map<string, string>());
65+
}
66+
}
67+
68+
/**
69+
* A cache that uses the LRU algorithm to evict the least recently used items.
70+
*
71+
* It is used to limit the size of the cache.
72+
*
73+
* The primary use case is for server-side SDKs, where the cache is shared across
74+
* multiple users. In this case, the cache size should be set to the maximum number
75+
* of users that can be active at the same time.
76+
*/
77+
export class LRUInMemoryAssignmentCache extends AssignmentCacheImpl<LRUCache> {
78+
constructor(maxSize: number) {
79+
super(new LRUCache(maxSize));
80+
}
81+
}
File renamed without changes.

src/lru-cache.ts renamed to src/cache/lru-cache.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,52 @@
66
* The cache is implemented as a Map, which is a built-in JavaScript object.
77
* The Map object holds key-value pairs and remembers the order of key-value pairs as they were inserted.
88
*/
9-
export class LRUCache {
10-
private capacity: number;
11-
private cache: Map<string, string>;
9+
export class LRUCache implements Map<string, string> {
10+
private readonly cache = new Map<string, string>();
11+
[Symbol.toStringTag]: string;
1212

13-
constructor(capacity: number) {
14-
this.capacity = capacity;
15-
this.cache = new Map<string, string>();
13+
constructor(private readonly capacity: number) {}
14+
15+
[Symbol.iterator](): IterableIterator<[string, string]> {
16+
return this.cache[Symbol.iterator]();
17+
}
18+
19+
forEach(
20+
callbackFn: (value: string, key: string, map: Map<string, string>) => void,
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
thisArg?: any,
23+
): void {
24+
this.cache.forEach(callbackFn, thisArg);
25+
}
26+
27+
readonly size: number = this.cache.size;
28+
29+
entries(): IterableIterator<[string, string]> {
30+
return this.cache.entries();
31+
}
32+
33+
clear() {
34+
this.cache.clear();
35+
}
36+
37+
delete(key: string): boolean {
38+
return this.cache.delete(key);
39+
}
40+
41+
keys(): IterableIterator<string> {
42+
return this.cache.keys();
43+
}
44+
45+
values(): IterableIterator<string> {
46+
return this.cache.values();
1647
}
1748

1849
has(key: string): boolean {
1950
return this.cache.has(key);
2051
}
2152

2253
get(key: string): string | undefined {
23-
if (!this.cache.has(key)) {
54+
if (!this.has(key)) {
2455
return undefined;
2556
}
2657

@@ -32,16 +63,16 @@ export class LRUCache {
3263
// This is crucial for maintaining the correct order of elements in the cache,
3364
// which directly impacts which item is considered the least recently used (LRU) and
3465
// thus eligible for eviction when the cache reaches its capacity.
35-
this.cache.delete(key);
66+
this.delete(key);
3667
this.cache.set(key, value);
3768
}
3869

3970
return value;
4071
}
4172

42-
set(key: string, value: string): void {
73+
set(key: string, value: string): this {
4374
if (this.capacity === 0) {
44-
return;
75+
return this;
4576
}
4677

4778
if (this.cache.has(key)) {
@@ -52,9 +83,10 @@ export class LRUCache {
5283
// Therefore, the first key represents the oldest entry, which is the least recently used item in our cache.
5384
// We use Map.prototype.keys().next().value to obtain this oldest key and then delete it from the cache.
5485
const oldestKey = this.cache.keys().next().value;
55-
this.cache.delete(oldestKey);
86+
this.delete(oldestKey);
5687
}
5788

5889
this.cache.set(key, value);
90+
return this;
5991
}
6092
}

src/client/eppo-client.spec.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { times } from 'lodash';
12
import * as td from 'testdouble';
23

34
import {
@@ -165,19 +166,17 @@ describe('EppoClient E2E test', () => {
165166
client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value');
166167
client.setLogger(mockLogger);
167168
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1);
168-
169169
client.setLogger(mockLogger);
170170
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1);
171171
});
172172

173173
it('Does not invoke logger for events that exceed queue size', () => {
174174
const mockLogger = td.object<IAssignmentLogger>();
175-
176175
const client = new EppoClient(storage);
177176

178-
for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) {
179-
client.getStringAssignment(flagKey, `subject-to-be-logged-${i}`, {}, 'default-value');
180-
}
177+
times(MAX_EVENT_QUEUE_SIZE + 100, (i) =>
178+
client.getStringAssignment(flagKey, `subject-to-be-logged-${i}`, {}, 'default-value'),
179+
);
181180
client.setLogger(mockLogger);
182181
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(MAX_EVENT_QUEUE_SIZE);
183182
});
@@ -364,7 +363,7 @@ describe('EppoClient E2E test', () => {
364363
client.setLogger(mockLogger);
365364
});
366365

367-
it('logs duplicate assignments without an assignment cache', () => {
366+
it('logs duplicate assignments without an assignment cache', async () => {
368367
client.disableAssignmentCache();
369368

370369
client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
@@ -374,12 +373,10 @@ describe('EppoClient E2E test', () => {
374373
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2);
375374
});
376375

377-
it('does not log duplicate assignments', () => {
376+
it('does not log duplicate assignments', async () => {
378377
client.useNonExpiringInMemoryAssignmentCache();
379-
380378
client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
381379
client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
382-
383380
// call count should be 1 because the second call is a cache hit and not logged.
384381
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1);
385382
});
@@ -415,8 +412,8 @@ describe('EppoClient E2E test', () => {
415412
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2);
416413
});
417414

418-
it('logs for each unique flag', () => {
419-
storage.setEntries({
415+
it('logs for each unique flag', async () => {
416+
await storage.setEntries({
420417
[flagKey]: mockFlag,
421418
'flag-2': {
422419
...mockFlag,

0 commit comments

Comments
 (0)