Skip to content

Commit 908b4b9

Browse files
authored
feat: Add new hybrid assignment cache implementation (#80)
* feat: Integrate new AssignmentCache API * hybrid assignment cache * better assertions * fix import * fix test * clear localStorage before each test * code review comments * fix all the bugs * instantiate and init assignment cache * fix commentg * write test * bump dependency and fix imports * use protected fields from super
1 parent 5b8af87 commit 908b4b9

17 files changed

+500
-118
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,6 @@
5959
"webpack-cli": "^4.10.0"
6060
},
6161
"dependencies": {
62-
"@eppo/js-client-sdk-common": "3.2.2"
62+
"@eppo/js-client-sdk-common": "3.3.1"
6363
}
6464
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import { assignmentCacheFactory } from './assignment-cache-factory';
6+
import HybridAssignmentCache from './hybrid-assignment-cache';
7+
8+
import StorageArea = chrome.storage.StorageArea;
9+
10+
describe('AssignmentCacheFactory', () => {
11+
// TODO: Extract test-only function for this
12+
const fakeStore: { [k: string]: string } = {};
13+
14+
const get = jest.fn((key?: string) => {
15+
return new Promise((resolve) => {
16+
if (!key) {
17+
resolve(fakeStore);
18+
} else {
19+
resolve({ [key]: fakeStore[key] });
20+
}
21+
});
22+
}) as jest.Mock;
23+
24+
const set = jest.fn((items: { [key: string]: string }) => {
25+
return new Promise((resolve) => {
26+
Object.assign(fakeStore, items);
27+
resolve(undefined);
28+
});
29+
}) as jest.Mock;
30+
31+
const mockChromeStorage = { get, set } as unknown as StorageArea;
32+
33+
beforeEach(() => {
34+
window.localStorage.clear();
35+
Object.keys(fakeStore).forEach((key) => delete fakeStore[key]);
36+
});
37+
38+
it('should create a hybrid cache if chrome storage is available', () => {
39+
const cache = assignmentCacheFactory({
40+
chromeStorage: mockChromeStorage,
41+
storageKeySuffix: 'foo',
42+
});
43+
expect(cache).toBeInstanceOf(HybridAssignmentCache);
44+
expect(Object.keys(fakeStore)).toHaveLength(0);
45+
cache.set({ subjectKey: 'foo', flagKey: 'bar', allocationKey: 'baz', variationKey: 'qux' });
46+
expect(Object.keys(fakeStore)).toHaveLength(1);
47+
});
48+
49+
it('should create a hybrid cache if local storage is available', () => {
50+
const cache = assignmentCacheFactory({
51+
storageKeySuffix: 'foo',
52+
});
53+
expect(cache).toBeInstanceOf(HybridAssignmentCache);
54+
expect(localStorage.length).toEqual(0);
55+
cache.set({ subjectKey: 'foo', flagKey: 'bar', allocationKey: 'baz', variationKey: 'qux' });
56+
// chrome storage is not being used
57+
expect(Object.keys(fakeStore)).toHaveLength(0);
58+
// local storage is being used
59+
expect(localStorage.length).toEqual(1);
60+
});
61+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AssignmentCache } from '@eppo/js-client-sdk-common';
2+
3+
import { hasWindowLocalStorage } from '../configuration-factory';
4+
5+
import ChromeStorageAssignmentCache from './chrome-storage-assignment-cache';
6+
import HybridAssignmentCache from './hybrid-assignment-cache';
7+
import { LocalStorageAssignmentCache } from './local-storage-assignment-cache';
8+
import SimpleAssignmentCache from './simple-assignment-cache';
9+
10+
export function assignmentCacheFactory({
11+
chromeStorage,
12+
storageKeySuffix,
13+
}: {
14+
storageKeySuffix: string;
15+
chromeStorage?: chrome.storage.StorageArea;
16+
}): AssignmentCache {
17+
const hasLocalStorage = hasWindowLocalStorage();
18+
const simpleCache = new SimpleAssignmentCache();
19+
if (chromeStorage) {
20+
const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage);
21+
return new HybridAssignmentCache(simpleCache, chromeStorageCache);
22+
} else {
23+
if (hasLocalStorage) {
24+
const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix);
25+
return new HybridAssignmentCache(simpleCache, localStorageCache);
26+
} else {
27+
return simpleCache;
28+
}
29+
}
30+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {
2+
assignmentCacheKeyToString,
3+
assignmentCacheValueToString,
4+
AssignmentCacheEntry,
5+
} from '@eppo/js-client-sdk-common';
6+
7+
import ChromeStorageAsyncMap from './chrome-storage-async-map';
8+
import { BulkReadAssignmentCache } from './hybrid-assignment-cache';
9+
10+
export default class ChromeStorageAssignmentCache implements BulkReadAssignmentCache {
11+
private readonly storage: ChromeStorageAsyncMap<string>;
12+
13+
constructor(chromeStorage: chrome.storage.StorageArea) {
14+
this.storage = new ChromeStorageAsyncMap(chromeStorage);
15+
}
16+
17+
set(entry: AssignmentCacheEntry): void {
18+
// "fire-and-forget" - we intentionally don't wait for the promise to resolve
19+
// noinspection JSIgnoredPromiseFromCall
20+
this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry));
21+
}
22+
23+
has(_: AssignmentCacheEntry): boolean {
24+
throw new Error(
25+
'This should never be called for ChromeStorageAssignmentCache, use getEntries() instead.',
26+
);
27+
}
28+
29+
async getEntries(): Promise<[string, string][]> {
30+
const entries = await this.storage.entries();
31+
return Object.entries(entries).map(([key, value]) => [key, value] as [string, string]);
32+
}
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { AsyncMap } from '@eppo/js-client-sdk-common';
2+
3+
/** Chrome storage-backed {@link AsyncMap}. */
4+
export default class ChromeStorageAsyncMap<T> implements AsyncMap<string, T> {
5+
constructor(private readonly storage: chrome.storage.StorageArea) {}
6+
7+
async has(key: string): Promise<boolean> {
8+
const value = await this.get(key);
9+
return !!value;
10+
}
11+
12+
async get(key: string): Promise<T | undefined> {
13+
const subset = await this.storage.get(key);
14+
return subset?.[key] ?? undefined;
15+
}
16+
17+
async entries(): Promise<{ [p: string]: T }> {
18+
return await this.storage.get(null);
19+
}
20+
21+
async set(key: string, value: T) {
22+
await this.storage.set({ [key]: value });
23+
}
24+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import ChromeStorageAssignmentCache from './chrome-storage-assignment-cache';
6+
import HybridAssignmentCache from './hybrid-assignment-cache';
7+
import { LocalStorageAssignmentCache } from './local-storage-assignment-cache';
8+
9+
import StorageArea = chrome.storage.StorageArea;
10+
11+
describe('HybridStorageAssignmentCache', () => {
12+
const fakeStore: Record<string, string> = {};
13+
14+
const get = jest.fn((key?: string) => {
15+
return new Promise((resolve) => {
16+
if (!key) {
17+
resolve(fakeStore);
18+
} else {
19+
resolve({ [key]: fakeStore[key] });
20+
}
21+
});
22+
}) as jest.Mock;
23+
24+
const set = jest.fn((items: { [key: string]: string }) => {
25+
return new Promise((resolve) => {
26+
Object.assign(fakeStore, items);
27+
resolve(undefined);
28+
});
29+
}) as jest.Mock;
30+
31+
const mockChromeStorage = { get, set } as unknown as StorageArea;
32+
const chromeStorageCache = new ChromeStorageAssignmentCache(mockChromeStorage);
33+
const localStorageCache = new LocalStorageAssignmentCache('test');
34+
const hybridCache = new HybridAssignmentCache(localStorageCache, chromeStorageCache);
35+
36+
beforeEach(() => {
37+
window.localStorage.clear();
38+
});
39+
40+
it('has should return false if cache is empty', async () => {
41+
const cacheKey = {
42+
subjectKey: 'subject-1',
43+
flagKey: 'flag-1',
44+
allocationKey: 'allocation-1',
45+
variationKey: 'control',
46+
};
47+
await hybridCache.init();
48+
expect(hybridCache.has(cacheKey)).toBeFalsy();
49+
});
50+
51+
it('has should return true if cache key is present', async () => {
52+
const cacheKey = {
53+
subjectKey: 'subject-1',
54+
flagKey: 'flag-1',
55+
allocationKey: 'allocation-1',
56+
variationKey: 'control',
57+
};
58+
await hybridCache.init();
59+
expect(hybridCache.has(cacheKey)).toBeFalsy();
60+
expect(localStorageCache.has(cacheKey)).toBeFalsy();
61+
hybridCache.set(cacheKey);
62+
expect(hybridCache.has(cacheKey)).toBeTruthy();
63+
expect(localStorageCache.has(cacheKey)).toBeTruthy();
64+
});
65+
66+
it('should populate localStorageCache from chromeStorageCache', async () => {
67+
const key1 = {
68+
subjectKey: 'subject-1',
69+
flagKey: 'flag-1',
70+
allocationKey: 'allocation-1',
71+
variationKey: 'control',
72+
};
73+
const key2 = {
74+
subjectKey: 'subject-2',
75+
flagKey: 'flag-2',
76+
allocationKey: 'allocation-2',
77+
variationKey: 'control',
78+
};
79+
expect(localStorageCache.has(key1)).toBeFalsy();
80+
chromeStorageCache.set(key1);
81+
chromeStorageCache.set(key2);
82+
await hybridCache.init();
83+
expect(localStorageCache.has(key1)).toBeTruthy();
84+
expect(localStorageCache.has(key2)).toBeTruthy();
85+
expect(localStorageCache.has({ ...key1, allocationKey: 'foo' })).toBeFalsy();
86+
});
87+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { AssignmentCache, AssignmentCacheEntry } from '@eppo/js-client-sdk-common';
2+
3+
/** An {@link AssignmentCache} that can write (set) multiple entries at once (in bulk). */
4+
export type BulkWriteAssignmentCache = AssignmentCache & {
5+
/** Sets all entries in the cache to the provided array of [key, value] pairs. */
6+
setEntries(entries: [string, string][]): void;
7+
};
8+
9+
/** An {@link AssignmentCache} that can read (get) all entries at once. */
10+
export type BulkReadAssignmentCache = AssignmentCache & {
11+
/** Returns all entries in the cache as an array of [key, value] pairs. */
12+
getEntries(): Promise<[string, string][]>;
13+
};
14+
15+
/**
16+
* An {@link AssignmentCache} implementation that, upon `init`, reads from a persistent and async {@link BulkReadAssignmentCache}
17+
* and writes to a synchronous {@link BulkWriteAssignmentCache} for serving the cache.
18+
* */
19+
export default class HybridAssignmentCache implements AssignmentCache {
20+
constructor(
21+
private readonly servingStore: BulkWriteAssignmentCache,
22+
private readonly persistentStore: BulkReadAssignmentCache,
23+
) {}
24+
25+
async init(): Promise<void> {
26+
const entries = await this.persistentStore.getEntries();
27+
if (entries) {
28+
this.servingStore.setEntries(entries);
29+
}
30+
}
31+
32+
set(key: AssignmentCacheEntry): void {
33+
this.servingStore.set(key);
34+
this.persistentStore.set(key);
35+
}
36+
37+
has(key: AssignmentCacheEntry): boolean {
38+
return this.servingStore.has(key);
39+
}
40+
}

src/local-storage-assignment-cache.spec.ts renamed to src/cache/local-storage-assignment-cache.spec.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,23 @@ describe('LocalStorageAssignmentCache', () => {
88
it('typical behavior', () => {
99
const cache = new LocalStorageAssignmentCache('test');
1010
expect(
11-
cache.hasLoggedAssignment({
11+
cache.has({
1212
subjectKey: 'subject-1',
1313
flagKey: 'flag-1',
1414
allocationKey: 'allocation-1',
1515
variationKey: 'control',
1616
}),
1717
).toEqual(false);
1818

19-
cache.setLastLoggedAssignment({
19+
cache.set({
2020
subjectKey: 'subject-1',
2121
flagKey: 'flag-1',
2222
allocationKey: 'allocation-1',
2323
variationKey: 'control',
2424
});
2525

2626
expect(
27-
cache.hasLoggedAssignment({
27+
cache.has({
2828
subjectKey: 'subject-1',
2929
flagKey: 'flag-1',
3030
allocationKey: 'allocation-1',
@@ -33,15 +33,15 @@ describe('LocalStorageAssignmentCache', () => {
3333
).toEqual(true); // this key has been logged
3434

3535
// change variation
36-
cache.setLastLoggedAssignment({
36+
cache.set({
3737
subjectKey: 'subject-1',
3838
flagKey: 'flag-1',
3939
allocationKey: 'allocation-1',
4040
variationKey: 'variant',
4141
});
4242

4343
expect(
44-
cache.hasLoggedAssignment({
44+
cache.has({
4545
subjectKey: 'subject-1',
4646
flagKey: 'flag-1',
4747
allocationKey: 'allocation-1',
@@ -62,53 +62,53 @@ describe('LocalStorageAssignmentCache', () => {
6262
allocationKey: 'allocation-1',
6363
};
6464

65-
cacheA.setLastLoggedAssignment({
65+
cacheA.set({
6666
variationKey: 'variation-A',
6767
...constantAssignmentProperties,
6868
});
6969

7070
expect(
71-
cacheA.hasLoggedAssignment({
71+
cacheA.has({
7272
variationKey: 'variation-A',
7373
...constantAssignmentProperties,
7474
}),
7575
).toEqual(true);
7676

7777
expect(
78-
cacheB.hasLoggedAssignment({
78+
cacheB.has({
7979
variationKey: 'variation-A',
8080
...constantAssignmentProperties,
8181
}),
8282
).toEqual(false);
8383

84-
cacheB.setLastLoggedAssignment({
84+
cacheB.set({
8585
variationKey: 'variation-B',
8686
...constantAssignmentProperties,
8787
});
8888

8989
expect(
90-
cacheA.hasLoggedAssignment({
90+
cacheA.has({
9191
variationKey: 'variation-A',
9292
...constantAssignmentProperties,
9393
}),
9494
).toEqual(true);
9595

9696
expect(
97-
cacheB.hasLoggedAssignment({
97+
cacheB.has({
9898
variationKey: 'variation-A',
9999
...constantAssignmentProperties,
100100
}),
101101
).toEqual(false);
102102

103103
expect(
104-
cacheA.hasLoggedAssignment({
104+
cacheA.has({
105105
variationKey: 'variation-B',
106106
...constantAssignmentProperties,
107107
}),
108108
).toEqual(false);
109109

110110
expect(
111-
cacheB.hasLoggedAssignment({
111+
cacheB.has({
112112
variationKey: 'variation-B',
113113
...constantAssignmentProperties,
114114
}),

0 commit comments

Comments
 (0)