Skip to content

Commit cc6f33d

Browse files
authored
feat: hybrid assignment cache to dedupe logging by the async init methods for eppo clients (#74)
* Comment code that isn't ready * Confirm functionality of hybrid assignment cache * Add hybrid assignment cache to both async init methods * Remove useNonExpiringInMemoryAssignmentCache * Add hybrid cache test for variation change * Change flag key to check for a new logAssignment call * Md5 hash storage key suffix * Move and edit comment * v3.5.3
1 parent 9d06e91 commit cc6f33d

10 files changed

+387
-29
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/react-native-sdk",
3-
"version": "3.5.2",
3+
"version": "3.5.3",
44
"description": "Eppo React Native SDK",
55
"main": "lib/commonjs/index",
66
"module": "lib/module/index",
@@ -53,7 +53,7 @@
5353
"dependencies": {
5454
"@eppo/js-client-sdk-common": "4.8.4",
5555
"@react-native-async-storage/async-storage": "^1.18.0",
56-
"md5": "^2.3.0"
56+
"spark-md5": "^3.0.2"
5757
},
5858
"devDependencies": {
5959
"@evilmartians/lefthook": "^1.2.2",
@@ -62,6 +62,7 @@
6262
"@types/jest": "^28.1.2",
6363
"@types/react": "~17.0.21",
6464
"@types/react-native": "0.70.0",
65+
"@types/spark-md5": "^3.0.5",
6566
"del-cli": "^5.0.0",
6667
"eslint": "^8.4.1",
6768
"eslint-config-prettier": "^8.5.0",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { AsyncStorageAssignmentCache } from '../cache/async-storage-assignment-cache';
2+
import HybridAssignmentCache from '../cache/hybrid-assignment-cache';
3+
import SimpleAssignmentCache from '../cache/simple-assignment-cache';
4+
5+
describe('HybridStorageAssignmentCache', () => {
6+
let asyncStorageCache: AsyncStorageAssignmentCache;
7+
let simpleCache: SimpleAssignmentCache;
8+
let hybridCache: HybridAssignmentCache;
9+
10+
beforeEach(() => {
11+
asyncStorageCache = new AsyncStorageAssignmentCache('test');
12+
simpleCache = new SimpleAssignmentCache();
13+
hybridCache = new HybridAssignmentCache(simpleCache, asyncStorageCache);
14+
});
15+
16+
it('has should return false if cache is empty', async () => {
17+
const cacheKey = {
18+
subjectKey: 'subject-1',
19+
flagKey: 'flag-1',
20+
allocationKey: 'allocation-1',
21+
variationKey: 'control',
22+
};
23+
await hybridCache.init();
24+
expect(hybridCache.has(cacheKey)).toBeFalsy();
25+
});
26+
27+
it('has should return true if cache key is present', async () => {
28+
const cacheKey = {
29+
subjectKey: 'subject-1',
30+
flagKey: 'flag-1',
31+
allocationKey: 'allocation-1',
32+
variationKey: 'control',
33+
};
34+
await hybridCache.init();
35+
expect(hybridCache.has(cacheKey)).toBeFalsy();
36+
expect(simpleCache.has(cacheKey)).toBeFalsy();
37+
hybridCache.set(cacheKey);
38+
expect(hybridCache.has(cacheKey)).toBeTruthy();
39+
expect(simpleCache.has(cacheKey)).toBeTruthy();
40+
});
41+
42+
it('should populate asyncStorageCache from simpleCache', async () => {
43+
const key1 = {
44+
subjectKey: 'subject-1',
45+
flagKey: 'flag-1',
46+
allocationKey: 'allocation-1',
47+
variationKey: 'control',
48+
};
49+
const key2 = {
50+
subjectKey: 'subject-2',
51+
flagKey: 'flag-2',
52+
allocationKey: 'allocation-2',
53+
variationKey: 'control',
54+
};
55+
expect(simpleCache.has(key1)).toBeFalsy();
56+
asyncStorageCache.set(key1);
57+
asyncStorageCache.set(key2);
58+
await hybridCache.init();
59+
expect(simpleCache.has(key1)).toBeTruthy();
60+
expect(simpleCache.has(key2)).toBeTruthy();
61+
expect(simpleCache.has({ ...key1, allocationKey: 'foo' })).toBeFalsy();
62+
});
63+
64+
it('should handle variation changes for same subject, flag, and allocation', async () => {
65+
const baseKey = {
66+
subjectKey: 'subject-1',
67+
flagKey: 'flag-1',
68+
allocationKey: 'allocation-1',
69+
};
70+
71+
const originalAssignment = {
72+
...baseKey,
73+
variationKey: 'control',
74+
};
75+
76+
const newAssignment = {
77+
...baseKey,
78+
variationKey: 'treatment',
79+
};
80+
81+
await hybridCache.init();
82+
hybridCache.set(originalAssignment);
83+
expect(hybridCache.has(originalAssignment)).toBeTruthy();
84+
85+
hybridCache.set(newAssignment);
86+
87+
expect(hybridCache.has(originalAssignment)).toBeFalsy();
88+
expect(hybridCache.has(newAssignment)).toBeTruthy();
89+
});
90+
});

src/__tests__/index.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,42 @@ describe('EppoPrecomputedReactNativeClient E2E test', () => {
397397
format: 'PRECOMPUTED',
398398
});
399399
});
400+
401+
it('deduplicates assignment logging', () => {
402+
// Reset the mock logger and assignment cache before this test
403+
const assignmentCacheMockLogger = td.object<IAssignmentLogger>();
404+
globalClient.useNonExpiringInMemoryAssignmentCache();
405+
globalClient.setAssignmentLogger(assignmentCacheMockLogger);
406+
407+
expect(
408+
td.explain(assignmentCacheMockLogger.logAssignment).callCount
409+
).toEqual(0);
410+
globalClient.getStringAssignment('string-flag', 'default');
411+
expect(
412+
td.explain(assignmentCacheMockLogger.logAssignment).callCount
413+
).toEqual(1);
414+
globalClient.getStringAssignment('string-flag', 'default');
415+
expect(
416+
td.explain(assignmentCacheMockLogger.logAssignment).callCount
417+
).toEqual(1);
418+
globalClient.getBooleanAssignment('boolean-flag', false);
419+
expect(
420+
td.explain(assignmentCacheMockLogger.logAssignment).callCount
421+
).toEqual(2);
422+
423+
expect(
424+
td.explain(assignmentCacheMockLogger.logAssignment).calls[0]?.args[0]
425+
).toMatchObject({
426+
featureFlag: 'string-flag',
427+
subject: 'test-subject',
428+
});
429+
expect(
430+
td.explain(assignmentCacheMockLogger.logAssignment).calls[1]?.args[0]
431+
).toMatchObject({
432+
featureFlag: 'boolean-flag',
433+
subject: 'test-subject',
434+
});
435+
});
400436
});
401437

402438
describe('getPrecomputedInstance', () => {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { AssignmentCache } from '@eppo/js-client-sdk-common';
2+
3+
import SimpleAssignmentCache from './simple-assignment-cache';
4+
import HybridAssignmentCache from './hybrid-assignment-cache';
5+
import { AsyncStorageAssignmentCache } from './async-storage-assignment-cache';
6+
7+
export function assignmentCacheFactory({
8+
forceMemoryOnly = false,
9+
storageKeySuffix,
10+
}: {
11+
forceMemoryOnly?: boolean;
12+
storageKeySuffix: string;
13+
}): AssignmentCache {
14+
const simpleCache = new SimpleAssignmentCache();
15+
if (forceMemoryOnly) {
16+
return simpleCache;
17+
}
18+
const asyncStorageCache = new AsyncStorageAssignmentCache(storageKeySuffix);
19+
const hybridAssignmentCache = new HybridAssignmentCache(
20+
simpleCache,
21+
asyncStorageCache
22+
);
23+
return hybridAssignmentCache;
24+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { AbstractAssignmentCache } from '@eppo/js-client-sdk-common';
2+
3+
import type {
4+
BulkReadAssignmentCache,
5+
BulkWriteAssignmentCache,
6+
} from './hybrid-assignment-cache';
7+
import { AsyncStorageAssignmentShim } from './async-storage-assignment-shim';
8+
9+
export class AsyncStorageAssignmentCache
10+
extends AbstractAssignmentCache<AsyncStorageAssignmentShim>
11+
implements BulkReadAssignmentCache, BulkWriteAssignmentCache
12+
{
13+
constructor(storageKeySuffix: string) {
14+
super(new AsyncStorageAssignmentShim(storageKeySuffix));
15+
}
16+
17+
setEntries(entries: [string, string][]): void {
18+
entries.forEach(([key, value]) => {
19+
if (key && value) {
20+
this.delegate.set(key, value);
21+
}
22+
});
23+
}
24+
25+
getEntries(): Promise<[string, string][]> {
26+
return Promise.resolve(Array.from(this.entries()));
27+
}
28+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
3+
export class AsyncStorageAssignmentShim implements Map<string, string> {
4+
private readonly storageKey: string;
5+
private cache: Map<string, string>;
6+
7+
public constructor(storageKeySuffix: string) {
8+
const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : '';
9+
this.storageKey = `eppo-assignment${keySuffix}`;
10+
this.cache = new Map();
11+
this.initCache();
12+
}
13+
14+
private async initCache(): Promise<void> {
15+
const stored = await AsyncStorage.getItem(this.storageKey);
16+
this.cache = stored ? new Map(JSON.parse(stored)) : new Map();
17+
}
18+
19+
private async persistCache(): Promise<void> {
20+
await AsyncStorage.setItem(
21+
this.storageKey,
22+
JSON.stringify(Array.from(this.cache.entries()))
23+
);
24+
}
25+
26+
clear(): void {
27+
this.cache.clear();
28+
AsyncStorage.removeItem(this.storageKey).catch(console.error);
29+
}
30+
31+
delete(key: string): boolean {
32+
const result = this.cache.delete(key);
33+
this.persistCache().catch(console.error);
34+
return result;
35+
}
36+
37+
forEach(
38+
callbackfn: (value: string, key: string, map: Map<string, string>) => void,
39+
thisArg?: any
40+
): void {
41+
this.cache.forEach(callbackfn, thisArg);
42+
}
43+
44+
get size(): number {
45+
return this.cache.size;
46+
}
47+
48+
entries(): IterableIterator<[string, string]> {
49+
return this.cache.entries();
50+
}
51+
52+
keys(): IterableIterator<string> {
53+
return this.cache.keys();
54+
}
55+
56+
values(): IterableIterator<string> {
57+
return this.cache.values();
58+
}
59+
60+
[Symbol.iterator](): IterableIterator<[string, string]> {
61+
return this.cache[Symbol.iterator]();
62+
}
63+
64+
get [Symbol.toStringTag](): string {
65+
return this.cache[Symbol.toStringTag];
66+
}
67+
68+
has(key: string): boolean {
69+
return this.cache.has(key);
70+
}
71+
72+
get(key: string): string | undefined {
73+
return this.cache.get(key);
74+
}
75+
76+
set(key: string, value: string): this {
77+
this.cache.set(key, value);
78+
this.persistCache().catch(console.error);
79+
return this;
80+
}
81+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type {
2+
AssignmentCache,
3+
AssignmentCacheEntry,
4+
} from '@eppo/js-client-sdk-common';
5+
6+
/** An {@link AssignmentCache} that can write (set) multiple entries at once (in bulk). */
7+
export type BulkWriteAssignmentCache = AssignmentCache & {
8+
/** Sets all entries in the cache to the provided array of [key, value] pairs. */
9+
setEntries(entries: [string, string][]): void;
10+
};
11+
12+
/** An {@link AssignmentCache} that can read (get) all entries at once. */
13+
export type BulkReadAssignmentCache = AssignmentCache & {
14+
/** Returns all entries in the cache as an array of [key, value] pairs. */
15+
getEntries(): Promise<[string, string][]>;
16+
};
17+
18+
/**
19+
* An {@link AssignmentCache} implementation that, upon `init`, reads from a persistent and async {@link BulkReadAssignmentCache}
20+
* and writes to a synchronous {@link BulkWriteAssignmentCache} for serving the cache.
21+
* */
22+
export default class HybridAssignmentCache implements AssignmentCache {
23+
constructor(
24+
private readonly servingStore: BulkWriteAssignmentCache,
25+
private readonly persistentStore: BulkReadAssignmentCache
26+
) {}
27+
28+
async init(): Promise<void> {
29+
const entries = await this.persistentStore.getEntries();
30+
if (entries) {
31+
this.servingStore.setEntries(entries);
32+
}
33+
}
34+
35+
set(key: AssignmentCacheEntry): void {
36+
this.servingStore.set(key);
37+
this.persistentStore.set(key);
38+
}
39+
40+
has(key: AssignmentCacheEntry): boolean {
41+
return this.servingStore.has(key);
42+
}
43+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
AbstractAssignmentCache,
3+
NonExpiringInMemoryAssignmentCache,
4+
AssignmentCacheEntry,
5+
} from '@eppo/js-client-sdk-common';
6+
7+
import type {
8+
BulkReadAssignmentCache,
9+
BulkWriteAssignmentCache,
10+
} from './hybrid-assignment-cache';
11+
12+
/** An {@link BulkWriteAssignmentCache} assignment cache backed by an in-memory {@link Map} */
13+
export default class SimpleAssignmentCache
14+
implements BulkWriteAssignmentCache, BulkReadAssignmentCache
15+
{
16+
private readonly store: Map<string, string>;
17+
private readonly cache: AbstractAssignmentCache<Map<string, string>>;
18+
19+
constructor() {
20+
this.store = new Map<string, string>();
21+
this.cache = new NonExpiringInMemoryAssignmentCache(this.store);
22+
}
23+
24+
set(key: AssignmentCacheEntry): void {
25+
this.cache.set(key);
26+
}
27+
28+
has(key: AssignmentCacheEntry): boolean {
29+
return this.cache.has(key);
30+
}
31+
32+
setEntries(entries: [string, string][]): void {
33+
const { store } = this;
34+
// it's important to call store.set() directly here because we want to set the raw entries into the cache, bypassing
35+
// the AbstractAssignmentCache logic, which takes an AssignmentCacheKey instead.
36+
entries.forEach(([key, value]) => store.set(key, value));
37+
}
38+
39+
getEntries(): Promise<[string, string][]> {
40+
return Promise.resolve(Array.from(this.cache.entries()));
41+
}
42+
}

0 commit comments

Comments
 (0)