Skip to content

Commit 9d60c39

Browse files
authored
feat: Read entries out of AbstractAssignmentCache (#89)
* feat: Read entries out of `AbstractAssignmentCache` * make these protected * comments * new Set-based assignment cache * fix imports * update tests to reflect new logic * extract key to string method * export symbols * update comments * go back to map implementation * fix tests * expose map store * fix assignment cache key/value handling * export this fn * fix nits
1 parent 6265238 commit 9d60c39

File tree

7 files changed

+102
-41
lines changed

7 files changed

+102
-41
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
assignmentCacheKeyToString,
3+
assignmentCacheValueToString,
4+
NonExpiringInMemoryAssignmentCache,
5+
} from './abstract-assignment-cache';
6+
7+
describe('NonExpiringInMemoryAssignmentCache', () => {
8+
it('read and write entries', () => {
9+
const cache = new NonExpiringInMemoryAssignmentCache();
10+
const key1 = { subjectKey: 'a', flagKey: 'b', allocationKey: 'c', variationKey: 'd' };
11+
const key2 = { subjectKey: '1', flagKey: '2', allocationKey: '3', variationKey: '4' };
12+
cache.set(key1);
13+
expect(cache.has(key1)).toBeTruthy();
14+
expect(cache.has(key2)).toBeFalsy();
15+
cache.set(key2);
16+
expect(cache.has(key2)).toBeTruthy();
17+
// this makes an assumption about the internal implementation of the cache, which is not ideal
18+
// but it's the only way to test the cache without exposing the internal state
19+
expect(Array.from(cache.entries())).toEqual([
20+
[assignmentCacheKeyToString(key1), assignmentCacheValueToString(key1)],
21+
[assignmentCacheKeyToString(key2), assignmentCacheValueToString(key2)],
22+
]);
23+
24+
expect(cache.has({ ...key1, allocationKey: 'c1' })).toBeFalsy();
25+
expect(cache.has({ ...key2, variationKey: 'd1' })).toBeFalsy();
26+
});
27+
});

src/cache/assignment-cache.ts renamed to src/cache/abstract-assignment-cache.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,42 @@ import { getMD5Hash } from '../obfuscation';
22

33
import { LRUCache } from './lru-cache';
44

5+
export type AssignmentCacheValue = {
6+
allocationKey: string;
7+
variationKey: string;
8+
};
9+
510
export type AssignmentCacheKey = {
611
subjectKey: string;
712
flagKey: string;
8-
allocationKey: string;
9-
variationKey: string;
1013
};
1114

15+
export type AssignmentCacheEntry = AssignmentCacheKey & AssignmentCacheValue;
16+
17+
/** Converts an {@link AssignmentCacheKey} to a string. */
18+
export function assignmentCacheKeyToString({ subjectKey, flagKey }: AssignmentCacheKey): string {
19+
return getMD5Hash([subjectKey, flagKey].join(';'));
20+
}
21+
22+
export function assignmentCacheValueToString({
23+
allocationKey,
24+
variationKey,
25+
}: AssignmentCacheValue): string {
26+
return getMD5Hash([allocationKey, variationKey].join(';'));
27+
}
28+
1229
export interface AsyncMap<K, V> {
1330
get(key: K): Promise<V | undefined>;
31+
1432
set(key: K, value: V): Promise<void>;
33+
1534
has(key: K): Promise<boolean>;
1635
}
1736

1837
export interface AssignmentCache {
19-
set(key: AssignmentCacheKey): void;
20-
has(key: AssignmentCacheKey): boolean;
38+
set(key: AssignmentCacheEntry): void;
39+
40+
has(key: AssignmentCacheEntry): boolean;
2141
}
2242

2343
export abstract class AbstractAssignmentCache<T extends Map<string, string>>
@@ -26,30 +46,29 @@ export abstract class AbstractAssignmentCache<T extends Map<string, string>>
2646
// key -> variation value hash
2747
protected constructor(protected readonly delegate: T) {}
2848

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);
49+
/** Returns whether the provided {@link AssignmentCacheEntry} is present in the cache. */
50+
has(entry: AssignmentCacheEntry): boolean {
51+
return this.get(entry) === assignmentCacheValueToString(entry);
4152
}
4253

4354
private get(key: AssignmentCacheKey): string | undefined {
44-
return this.delegate.get(this.toCacheKeyString(key));
55+
return this.delegate.get(assignmentCacheKeyToString(key));
4556
}
4657

47-
set(key: AssignmentCacheKey): void {
48-
this.delegate.set(this.toCacheKeyString(key), getMD5Hash(key.variationKey));
58+
/**
59+
* Stores the provided {@link AssignmentCacheEntry} in the cache. If the key already exists, it
60+
* will be overwritten.
61+
*/
62+
set(entry: AssignmentCacheEntry): void {
63+
this.delegate.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry));
4964
}
5065

51-
private toCacheKeyString({ subjectKey, flagKey, allocationKey }: AssignmentCacheKey): string {
52-
return [`subject:${subjectKey}`, `flag:${flagKey}`, `allocation:${allocationKey}`].join(';');
66+
/**
67+
* Returns an array with all {@link AssignmentCacheEntry} entries in the cache as an array of
68+
* {@link string}s.
69+
*/
70+
entries(): IterableIterator<[string, string]> {
71+
return this.delegate.entries();
5372
}
5473
}
5574

@@ -62,8 +81,8 @@ export abstract class AbstractAssignmentCache<T extends Map<string, string>>
6281
export class NonExpiringInMemoryAssignmentCache extends AbstractAssignmentCache<
6382
Map<string, string>
6483
> {
65-
constructor() {
66-
super(new Map<string, string>());
84+
constructor(store = new Map<string, string>()) {
85+
super(store);
6786
}
6887
}
6988

src/cache/lru-cache.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('LRUCache', () => {
1313
});
1414

1515
it('should return undefined for missing values', () => {
16-
expect(cache.get('missing')).toBeUndefined();
16+
expect(cache.get('missing')).toBeFalsy();
1717
});
1818

1919
it('should overwrite existing values', () => {
@@ -26,7 +26,7 @@ describe('LRUCache', () => {
2626
cache.set('a', 'apple');
2727
cache.set('b', 'banana');
2828
cache.set('c', 'cherry');
29-
expect(cache.get('a')).toBeUndefined();
29+
expect(cache.get('a')).toBeFalsy();
3030
expect(cache.get('b')).toBe('banana');
3131
expect(cache.get('c')).toBe('cherry');
3232
});
@@ -37,7 +37,7 @@ describe('LRUCache', () => {
3737
cache.get('a'); // Access 'a' to make it recently used
3838
cache.set('c', 'cherry');
3939
expect(cache.get('a')).toBe('apple');
40-
expect(cache.get('b')).toBeUndefined();
40+
expect(cache.get('b')).toBeFalsy();
4141
expect(cache.get('c')).toBe('cherry');
4242
});
4343

@@ -50,15 +50,15 @@ describe('LRUCache', () => {
5050
it('should handle the cache capacity of zero', () => {
5151
const zeroCache = new LRUCache(0);
5252
zeroCache.set('a', 'apple');
53-
expect(zeroCache.get('a')).toBeUndefined();
53+
expect(zeroCache.get('a')).toBeFalsy();
5454
});
5555

5656
it('should handle the cache capacity of one', () => {
5757
const oneCache = new LRUCache(1);
5858
oneCache.set('a', 'apple');
5959
expect(oneCache.get('a')).toBe('apple');
6060
oneCache.set('b', 'banana');
61-
expect(oneCache.get('a')).toBeUndefined();
61+
expect(oneCache.get('a')).toBeFalsy();
6262
expect(oneCache.get('b')).toBe('banana');
6363
});
6464
});

src/cache/lru-cache.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
/**
2-
* LRUCache is a cache that stores a maximum number of items.
2+
* LRUCache is a simple implementation of a Least Recently Used (LRU) cache.
33
*
4-
* Items are removed from the cache when the cache is full.
4+
* Old items are evicted when the cache reaches its capacity.
55
*
6-
* The cache is implemented as a Map, which is a built-in JavaScript object.
7-
* The Map object holds key-value pairs and remembers the order of key-value pairs as they were inserted.
6+
* The cache is implemented as a Map, which maintains insertion order:
7+
* ```
8+
* Iteration happens in insertion order, which corresponds to the order in which each key-value pair
9+
* was first inserted into the map by the set() method (that is, there wasn't a key with the same
10+
* value already in the map when set() was called).
11+
* ```
12+
* Source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
813
*/
914
export class LRUCache implements Map<string, string> {
1015
private readonly cache = new Map<string, string>();

src/client/eppo-client.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
AssignmentCache,
66
LRUInMemoryAssignmentCache,
77
NonExpiringInMemoryAssignmentCache,
8-
} from '../cache/assignment-cache';
8+
} from '../cache/abstract-assignment-cache';
99
import { IConfigurationStore } from '../configuration-store/configuration-store';
1010
import {
1111
BASE_URL as DEFAULT_BASE_URL,
@@ -540,17 +540,17 @@ export default class EppoClient implements IEppoClient {
540540
}
541541

542542
// assignment logger may be null while waiting for initialization
543-
if (this.assignmentLogger == null) {
543+
if (!this.assignmentLogger) {
544544
this.queuedEvents.length < MAX_EVENT_QUEUE_SIZE && this.queuedEvents.push(event);
545545
return;
546546
}
547547
try {
548548
this.assignmentLogger.logAssignment(event);
549549
this.assignmentCache?.set({
550-
flagKey: flagKey,
551-
subjectKey: result.subjectKey,
552-
allocationKey: result.allocationKey ?? '__eppo_no_allocation',
553-
variationKey: result.variation?.key ?? '__eppo_no_variation',
550+
flagKey,
551+
subjectKey,
552+
allocationKey: allocationKey ?? '__eppo_no_allocation',
553+
variationKey: variation?.key ?? '__eppo_no_variation',
554554
});
555555
} catch (error) {
556556
logger.error(`[Eppo SDK] Error logging assignment event: ${error.message}`);

src/configuration-store/hybrid.store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { IAsyncStore, IConfigurationStore, ISyncStore } from './configuration-st
44

55
export class HybridConfigurationStore<T> implements IConfigurationStore<T> {
66
constructor(
7-
private readonly servingStore: ISyncStore<T>,
8-
private readonly persistentStore: IAsyncStore<T> | null,
7+
protected readonly servingStore: ISyncStore<T>,
8+
protected readonly persistentStore: IAsyncStore<T> | null,
99
) {}
1010

1111
/**

src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
NonExpiringInMemoryAssignmentCache,
88
LRUInMemoryAssignmentCache,
99
AsyncMap,
10-
} from './cache/assignment-cache';
10+
AssignmentCacheKey,
11+
AssignmentCacheValue,
12+
AssignmentCacheEntry,
13+
assignmentCacheKeyToString,
14+
assignmentCacheValueToString,
15+
} from './cache/abstract-assignment-cache';
1116
import EppoClient, { FlagConfigurationRequestParameters, IEppoClient } from './client/eppo-client';
1217
import {
1318
IConfigurationStore,
@@ -45,10 +50,15 @@ export {
4550
MemoryOnlyConfigurationStore,
4651

4752
// Assignment cache
53+
AssignmentCacheKey,
54+
AssignmentCacheValue,
55+
AssignmentCacheEntry,
4856
AssignmentCache,
4957
AsyncMap,
5058
NonExpiringInMemoryAssignmentCache,
5159
LRUInMemoryAssignmentCache,
60+
assignmentCacheKeyToString,
61+
assignmentCacheValueToString,
5262

5363
// Interfaces
5464
FlagConfigurationRequestParameters,

0 commit comments

Comments
 (0)