Skip to content

Commit 71cd364

Browse files
assignment logging cache prevents duplicate invocations (FF-1069) (#25)
* assignment logging cache prevents duplicate invocations (FF-1069) * add flagKey and remove variationKey from assignment cache key * additional test cases * additional test case. improve naming/clarity * rename hasAssigned to hasLoggedAssignment * add hashed variation value to cache key; handle case of variation changing * add comments to test * handle variation changing * fix comment * refactor into abstract class * instantiate correctly * remove debug * remove import * rename to setLastLoggedAssignment * split into 3 publish methods * fix comment * remove type * remove unused comment * refactor into beforeEach block * require node version 16 * bump to 1.7.0 --------- Co-authored-by: Eric Petzel <[email protected]>
1 parent b6f2fe6 commit 71cd364

File tree

10 files changed

+361
-5
lines changed

10 files changed

+361
-5
lines changed

.github/workflows/lint-test-sdk.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
- name: Use Node.js
1313
uses: actions/setup-node@v1
1414
with:
15-
node-version: '18.x'
15+
node-version: '16.x'
1616
- uses: actions/cache@v2
1717
with:
1818
path: './node_modules'
@@ -35,7 +35,7 @@ jobs:
3535
- name: Use Node.js
3636
uses: actions/setup-node@v1
3737
with:
38-
node-version: '18.x'
38+
node-version: '16.x'
3939
- uses: actions/cache@v2
4040
with:
4141
path: './node_modules'
@@ -45,4 +45,4 @@ jobs:
4545
working-directory: ./
4646
- name: Run typecheck
4747
run: yarn typecheck
48-
working-directory: ./
48+
working-directory: ./

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
- uses: actions/checkout@v3
1111
- uses: actions/setup-node@v3
1212
with:
13-
node-version: 12
13+
node-version: '16.x'
1414
- run: yarn install
1515
- run: yarn test
1616
- uses: JS-DevTools/npm-publish@v1

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
engine-strict=true

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "1.6.0",
3+
"version": "1.7.0",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [
77
"/dist"
88
],
99
"types": "./dist/index.d.ts",
10+
"engines": {
11+
"node": ">=16.20"
12+
},
1013
"exports": {
1114
".": {
1215
"types": "./dist/index.d.ts",
@@ -61,6 +64,7 @@
6164
},
6265
"dependencies": {
6366
"axios": "^0.27.2",
67+
"lru-cache": "^10.0.1",
6468
"md5": "^2.3.0"
6569
}
6670
}

src/assignment-cache.ts

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

src/client/eppo-client.spec.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,212 @@ describe('EppoClient E2E test', () => {
369369
expect(assignment).toEqual('control');
370370
});
371371

372+
describe('assignment logging deduplication', () => {
373+
let client: EppoClient;
374+
let mockLogger: IAssignmentLogger;
375+
376+
beforeEach(() => {
377+
mockLogger = td.object<IAssignmentLogger>();
378+
379+
storage.setEntries({ [flagKey]: mockExperimentConfig });
380+
client = new EppoClient(storage);
381+
client.setLogger(mockLogger);
382+
});
383+
384+
it('logs duplicate assignments without an assignment cache', () => {
385+
client.disableAssignmentCache();
386+
387+
client.getAssignment('subject-10', flagKey);
388+
client.getAssignment('subject-10', flagKey);
389+
390+
// call count should be 2 because there is no cache.
391+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2);
392+
});
393+
394+
it('does not log duplicate assignments', () => {
395+
client.useNonExpiringAssignmentCache();
396+
397+
client.getAssignment('subject-10', flagKey);
398+
client.getAssignment('subject-10', flagKey);
399+
400+
// call count should be 1 because the second call is a cache hit and not logged.
401+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1);
402+
});
403+
404+
it('logs assignment again after the lru cache is full', () => {
405+
client.useLRUAssignmentCache(2);
406+
407+
client.getAssignment('subject-10', flagKey); // logged
408+
client.getAssignment('subject-10', flagKey); // cached
409+
410+
client.getAssignment('subject-11', flagKey); // logged
411+
client.getAssignment('subject-11', flagKey); // cached
412+
413+
client.getAssignment('subject-12', flagKey); // cache evicted subject-10, logged
414+
client.getAssignment('subject-10', flagKey); // previously evicted, logged
415+
client.getAssignment('subject-12', flagKey); // cached
416+
417+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4);
418+
});
419+
420+
it('does not cache assignments if the logger had an exception', () => {
421+
td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(
422+
new Error('logging error'),
423+
);
424+
425+
const client = new EppoClient(storage);
426+
client.setLogger(mockLogger);
427+
428+
client.getAssignment('subject-10', flagKey);
429+
client.getAssignment('subject-10', flagKey);
430+
431+
// call count should be 2 because the first call had an exception
432+
// therefore we are not sure the logger was successful and try again.
433+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2);
434+
});
435+
436+
it('logs for each unique flag', () => {
437+
storage.setEntries({
438+
[flagKey]: mockExperimentConfig,
439+
'flag-2': {
440+
...mockExperimentConfig,
441+
name: 'flag-2',
442+
},
443+
'flag-3': {
444+
...mockExperimentConfig,
445+
name: 'flag-3',
446+
},
447+
});
448+
449+
client.useNonExpiringAssignmentCache();
450+
451+
client.getAssignment('subject-10', flagKey);
452+
client.getAssignment('subject-10', flagKey);
453+
client.getAssignment('subject-10', 'flag-2');
454+
client.getAssignment('subject-10', 'flag-2');
455+
client.getAssignment('subject-10', 'flag-3');
456+
client.getAssignment('subject-10', 'flag-3');
457+
client.getAssignment('subject-10', flagKey);
458+
client.getAssignment('subject-10', 'flag-2');
459+
client.getAssignment('subject-10', 'flag-3');
460+
461+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3);
462+
});
463+
464+
it('logs twice for the same flag when rollout increases/flag changes', () => {
465+
client.useNonExpiringAssignmentCache();
466+
467+
storage.setEntries({
468+
[flagKey]: {
469+
...mockExperimentConfig,
470+
allocations: {
471+
allocation1: {
472+
percentExposure: 1,
473+
variations: [
474+
{
475+
name: 'control',
476+
value: 'control',
477+
typedValue: 'control',
478+
shardRange: {
479+
start: 0,
480+
end: 100,
481+
},
482+
},
483+
{
484+
name: 'treatment',
485+
value: 'treatment',
486+
typedValue: 'treatment',
487+
shardRange: {
488+
start: 0,
489+
end: 0,
490+
},
491+
},
492+
],
493+
},
494+
},
495+
},
496+
});
497+
client.getAssignment('subject-10', flagKey);
498+
499+
storage.setEntries({
500+
[flagKey]: {
501+
...mockExperimentConfig,
502+
allocations: {
503+
allocation1: {
504+
percentExposure: 1,
505+
variations: [
506+
{
507+
name: 'control',
508+
value: 'control',
509+
typedValue: 'control',
510+
shardRange: {
511+
start: 0,
512+
end: 0,
513+
},
514+
},
515+
{
516+
name: 'treatment',
517+
value: 'treatment',
518+
typedValue: 'treatment',
519+
shardRange: {
520+
start: 0,
521+
end: 100,
522+
},
523+
},
524+
],
525+
},
526+
},
527+
},
528+
});
529+
client.getAssignment('subject-10', flagKey);
530+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2);
531+
});
532+
533+
it('logs the same subject/flag/variation after two changes', () => {
534+
client.useNonExpiringAssignmentCache();
535+
536+
// original configuration version
537+
storage.setEntries({ [flagKey]: mockExperimentConfig });
538+
539+
client.getAssignment('subject-10', flagKey); // log this assignment
540+
client.getAssignment('subject-10', flagKey); // cache hit, don't log
541+
542+
// change the flag
543+
storage.setEntries({
544+
[flagKey]: {
545+
...mockExperimentConfig,
546+
allocations: {
547+
allocation1: {
548+
percentExposure: 1,
549+
variations: [
550+
{
551+
name: 'some-new-treatment',
552+
value: 'some-new-treatment',
553+
typedValue: 'some-new-treatment',
554+
shardRange: {
555+
start: 0,
556+
end: 100,
557+
},
558+
},
559+
],
560+
},
561+
},
562+
},
563+
});
564+
565+
client.getAssignment('subject-10', flagKey); // log this assignment
566+
client.getAssignment('subject-10', flagKey); // cache hit, don't log
567+
568+
// change the flag again, back to the original
569+
storage.setEntries({ [flagKey]: mockExperimentConfig });
570+
571+
client.getAssignment('subject-10', flagKey); // important: log this assignment
572+
client.getAssignment('subject-10', flagKey); // cache hit, don't log
573+
574+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3);
575+
});
576+
});
577+
372578
it('only returns variation if subject matches rules', () => {
373579
const entry = {
374580
...mockExperimentConfig,

0 commit comments

Comments
 (0)