Skip to content

Commit d7945ff

Browse files
Add bandit assignment cache (#120)
* test in place * use an assignment cache to deduplicate bandit action assignments * feedback from self-review of PR * fix type and lint errors * bump minor version * just hash whole cache value * feedback from PR * dedupe when queuing assignments as well * Bump package.json version correctly Co-authored-by: Leo Romanovsky <[email protected]> * feedback from PR --------- Co-authored-by: Leo Romanovsky <[email protected]>
1 parent 90dabbc commit d7945ff

File tree

5 files changed

+242
-29
lines changed

5 files changed

+242
-29
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.0.2",
3+
"version": "4.1.0",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [

src/cache/abstract-assignment-cache.spec.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from './abstract-assignment-cache';
66

77
describe('NonExpiringInMemoryAssignmentCache', () => {
8-
it('read and write entries', () => {
8+
it('read and write variation entries', () => {
99
const cache = new NonExpiringInMemoryAssignmentCache();
1010
const key1 = { subjectKey: 'a', flagKey: 'b', allocationKey: 'c', variationKey: 'd' };
1111
const key2 = { subjectKey: '1', flagKey: '2', allocationKey: '3', variationKey: '4' };
@@ -14,7 +14,7 @@ describe('NonExpiringInMemoryAssignmentCache', () => {
1414
expect(cache.has(key2)).toBeFalsy();
1515
cache.set(key2);
1616
expect(cache.has(key2)).toBeTruthy();
17-
// this makes an assumption about the internal implementation of the cache, which is not ideal
17+
// this makes an assumption about the internal implementation of the cache, which is not ideal,
1818
// but it's the only way to test the cache without exposing the internal state
1919
expect(Array.from(cache.entries())).toEqual([
2020
[assignmentCacheKeyToString(key1), assignmentCacheValueToString(key1)],
@@ -24,4 +24,24 @@ describe('NonExpiringInMemoryAssignmentCache', () => {
2424
expect(cache.has({ ...key1, allocationKey: 'c1' })).toBeFalsy();
2525
expect(cache.has({ ...key2, variationKey: 'd1' })).toBeFalsy();
2626
});
27+
28+
it('read and write bandit entries', () => {
29+
const cache = new NonExpiringInMemoryAssignmentCache();
30+
const key1 = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
31+
const key2 = { subjectKey: '1', flagKey: '2', banditKey: '3', actionKey: '4' };
32+
cache.set(key1);
33+
expect(cache.has(key1)).toBeTruthy();
34+
expect(cache.has(key2)).toBeFalsy();
35+
cache.set(key2);
36+
expect(cache.has(key2)).toBeTruthy();
37+
// this makes an assumption about the internal implementation of the cache, which is not ideal,
38+
// but it's the only way to test the cache without exposing the internal state
39+
expect(Array.from(cache.entries())).toEqual([
40+
[assignmentCacheKeyToString(key1), assignmentCacheValueToString(key1)],
41+
[assignmentCacheKeyToString(key2), assignmentCacheValueToString(key2)],
42+
]);
43+
44+
expect(cache.has({ ...key1, banditKey: 'c1' })).toBeFalsy();
45+
expect(cache.has({ ...key2, actionKey: 'd1' })).toBeFalsy();
46+
});
2747
});

src/cache/abstract-assignment-cache.ts

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

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

5-
export type AssignmentCacheValue = {
6-
allocationKey: string;
7-
variationKey: string;
8-
};
9-
5+
/**
6+
* Assignment cache keys are only on the subject and flag level, while the entire value is used
7+
* for uniqueness checking. This way that if an assigned variation or bandit action changes for a
8+
* flag, it evicts the old one. Then, if an older assignment is later reassigned, it will be treated
9+
* as new.
10+
*/
1011
export type AssignmentCacheKey = {
1112
subjectKey: string;
1213
flagKey: string;
1314
};
1415

16+
export type CacheKeyPair<T extends string, U extends string> = {
17+
[K in T]: string;
18+
} & {
19+
[K in U]: string;
20+
};
21+
22+
type VariationCacheValue = CacheKeyPair<'allocationKey', 'variationKey'>;
23+
type BanditCacheValue = CacheKeyPair<'banditKey', 'actionKey'>;
24+
export type AssignmentCacheValue = VariationCacheValue | BanditCacheValue;
25+
1526
export type AssignmentCacheEntry = AssignmentCacheKey & AssignmentCacheValue;
1627

1728
/** Converts an {@link AssignmentCacheKey} to a string. */
1829
export function assignmentCacheKeyToString({ subjectKey, flagKey }: AssignmentCacheKey): string {
1930
return getMD5Hash([subjectKey, flagKey].join(';'));
2031
}
2132

22-
export function assignmentCacheValueToString({
23-
allocationKey,
24-
variationKey,
25-
}: AssignmentCacheValue): string {
26-
return getMD5Hash([allocationKey, variationKey].join(';'));
33+
/** Converts an {@link AssignmentCacheValue} to a string. */
34+
export function assignmentCacheValueToString(cacheValue: AssignmentCacheValue): string {
35+
return getMD5Hash(Object.values(cacheValue).join(';'));
2736
}
2837

2938
export interface AsyncMap<K, V> {

src/client/eppo-client-with-bandits.spec.ts

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import {
88
} from '../../test/testHelpers';
99
import ApiEndpoints from '../api-endpoints';
1010
import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger';
11-
import { BanditEvaluator } from '../bandit-evaluator';
11+
import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator';
1212
import { IBanditEvent, IBanditLogger } from '../bandit-logger';
1313
import ConfigurationRequestor from '../configuration-requestor';
1414
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
15+
import { Evaluator, FlagEvaluation } from '../evaluator';
1516
import {
1617
AllocationEvaluationCode,
1718
IFlagEvaluationDetails,
@@ -20,7 +21,7 @@ import FetchHttpClient from '../http-client';
2021
import { BanditVariation, BanditParameters, Flag } from '../interfaces';
2122
import { Attributes, ContextAttributes } from '../types';
2223

23-
import EppoClient from './eppo-client';
24+
import EppoClient, { IAssignmentDetails } from './eppo-client';
2425

2526
describe('EppoClient Bandits E2E test', () => {
2627
const flagStore = new MemoryOnlyConfigurationStore<Flag>();
@@ -204,6 +205,8 @@ describe('EppoClient Bandits E2E test', () => {
204205
});
205206

206207
it('Flushed queued logging events when a logger is set', () => {
208+
client.useLRUInMemoryAssignmentCache(5);
209+
client.useLRUInMemoryBanditAssignmentCache(5);
207210
client.setAssignmentLogger(null as unknown as IAssignmentLogger);
208211
client.setBanditLogger(null as unknown as IBanditLogger);
209212
const banditAssignment = client.getBanditAction(
@@ -220,6 +223,20 @@ describe('EppoClient Bandits E2E test', () => {
220223
expect(mockLogAssignment).not.toHaveBeenCalled();
221224
expect(mockLogBanditAction).not.toHaveBeenCalled();
222225

226+
const repeatAssignment = client.getBanditAction(
227+
flagKey,
228+
subjectKey,
229+
subjectAttributes,
230+
actions,
231+
'control',
232+
);
233+
234+
expect(repeatAssignment.variation).toBe('banner_bandit');
235+
expect(repeatAssignment.action).toBe('adidas');
236+
237+
expect(mockLogAssignment).not.toHaveBeenCalled();
238+
expect(mockLogBanditAction).not.toHaveBeenCalled();
239+
223240
client.setAssignmentLogger({ logAssignment: mockLogAssignment });
224241
client.setBanditLogger({ logBanditAction: mockLogBanditAction });
225242

@@ -429,5 +446,137 @@ describe('EppoClient Bandits E2E test', () => {
429446
expect(mockLogBanditAction.mock.calls[1][0].actionProbability).toBeCloseTo(0.256);
430447
});
431448
});
449+
450+
describe('Assignment logging deduplication', () => {
451+
let mockEvaluateFlag: jest.SpyInstance;
452+
let mockEvaluateBandit: jest.SpyInstance;
453+
// The below two variables allow easily changing what the mock evaluation functions return throughout the test
454+
let variationToReturn: string;
455+
let actionToReturn: string | null;
456+
457+
// Convenience method for repeatedly making the exact same assignment call
458+
function requestClientBanditAction(): Omit<IAssignmentDetails<string>, 'evaluationDetails'> {
459+
return client.getBanditAction(
460+
flagKey,
461+
subjectKey,
462+
subjectAttributes,
463+
['toyota', 'honda'],
464+
'control',
465+
);
466+
}
467+
468+
beforeAll(() => {
469+
mockEvaluateFlag = jest
470+
.spyOn(Evaluator.prototype, 'evaluateFlag')
471+
.mockImplementation(() => {
472+
return {
473+
flagKey,
474+
subjectKey,
475+
subjectAttributes,
476+
allocationKey: 'mock-allocation',
477+
variation: { key: variationToReturn, value: variationToReturn },
478+
extraLogging: {},
479+
doLog: true,
480+
flagEvaluationDetails: {
481+
flagEvaluationCode: 'MATCH',
482+
flagEvaluationDescription: 'Mocked evaluation',
483+
},
484+
} as FlagEvaluation;
485+
});
486+
487+
mockEvaluateBandit = jest
488+
.spyOn(BanditEvaluator.prototype, 'evaluateBandit')
489+
.mockImplementation(() => {
490+
return {
491+
flagKey,
492+
subjectKey,
493+
subjectAttributes: { numericAttributes: {}, categoricalAttributes: {} },
494+
actionKey: actionToReturn,
495+
actionAttributes: { numericAttributes: {}, categoricalAttributes: {} },
496+
actionScore: 10,
497+
actionWeight: 0.5,
498+
gamma: 1.0,
499+
optimalityGap: 5,
500+
} as BanditEvaluation;
501+
});
502+
});
503+
504+
beforeEach(() => {
505+
client.useNonExpiringInMemoryAssignmentCache();
506+
client.useNonExpiringInMemoryBanditAssignmentCache();
507+
});
508+
509+
afterEach(() => {
510+
client.disableAssignmentCache();
511+
client.disableBanditAssignmentCache();
512+
});
513+
514+
afterAll(() => {
515+
mockEvaluateFlag.mockClear();
516+
mockEvaluateBandit.mockClear();
517+
});
518+
519+
it('handles bandit actions appropriately', async () => {
520+
// First assign to non-bandit variation
521+
variationToReturn = 'non-bandit';
522+
actionToReturn = null;
523+
const firstNonBanditAssignment = requestClientBanditAction();
524+
525+
expect(firstNonBanditAssignment.variation).toBe('non-bandit');
526+
expect(firstNonBanditAssignment.action).toBeNull();
527+
expect(mockLogAssignment).toHaveBeenCalledTimes(1); // new variation assignment
528+
expect(mockLogBanditAction).not.toHaveBeenCalled(); // no bandit assignment
529+
530+
// Assign bandit action
531+
variationToReturn = 'banner_bandit';
532+
actionToReturn = 'toyota';
533+
const firstBanditAssignment = requestClientBanditAction();
534+
535+
expect(firstBanditAssignment.variation).toBe('banner_bandit');
536+
expect(firstBanditAssignment.action).toBe('toyota');
537+
expect(mockLogAssignment).toHaveBeenCalledTimes(2); // new variation assignment
538+
expect(mockLogBanditAction).toHaveBeenCalledTimes(1); // new bandit assignment
539+
540+
// Repeat bandit action assignment
541+
variationToReturn = 'banner_bandit';
542+
actionToReturn = 'toyota';
543+
const secondBanditAssignment = requestClientBanditAction();
544+
545+
expect(secondBanditAssignment.variation).toBe('banner_bandit');
546+
expect(secondBanditAssignment.action).toBe('toyota');
547+
expect(mockLogAssignment).toHaveBeenCalledTimes(2); // repeat variation assignment
548+
expect(mockLogBanditAction).toHaveBeenCalledTimes(1); // repeat bandit assignment
549+
550+
// New bandit action assignment
551+
variationToReturn = 'banner_bandit';
552+
actionToReturn = 'honda';
553+
const thirdBanditAssignment = requestClientBanditAction();
554+
555+
expect(thirdBanditAssignment.variation).toBe('banner_bandit');
556+
expect(thirdBanditAssignment.action).toBe('honda');
557+
expect(mockLogAssignment).toHaveBeenCalledTimes(2); // repeat variation assignment
558+
expect(mockLogBanditAction).toHaveBeenCalledTimes(2); // new bandit assignment
559+
560+
// Flip-flop to an earlier action assignment
561+
variationToReturn = 'banner_bandit';
562+
actionToReturn = 'toyota';
563+
const fourthBanditAssignment = requestClientBanditAction();
564+
565+
expect(fourthBanditAssignment.variation).toBe('banner_bandit');
566+
expect(fourthBanditAssignment.action).toBe('toyota');
567+
expect(mockLogAssignment).toHaveBeenCalledTimes(2); // repeat variation assignment
568+
expect(mockLogBanditAction).toHaveBeenCalledTimes(3); // "new" bandit assignment
569+
570+
// Flip-flop back to non-bandit assignment
571+
variationToReturn = 'non-bandit';
572+
actionToReturn = null;
573+
const secondNonBanditAssignment = requestClientBanditAction();
574+
575+
expect(secondNonBanditAssignment.variation).toBe('non-bandit');
576+
expect(secondNonBanditAssignment.action).toBeNull();
577+
expect(mockLogAssignment).toHaveBeenCalledTimes(3); // "new" variation assignment
578+
expect(mockLogBanditAction).toHaveBeenCalledTimes(3); // no bandit assignment
579+
});
580+
});
432581
});
433582
});

src/client/eppo-client.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export default class EppoClient {
7575
private banditLogger?: IBanditLogger;
7676
private isGracefulFailureMode = true;
7777
private assignmentCache?: AssignmentCache;
78+
private banditAssignmentCache?: AssignmentCache;
7879
private requestPoller?: IPoller;
7980
private readonly evaluator = new Evaluator();
8081
private readonly banditEvaluator = new BanditEvaluator();
@@ -651,16 +652,34 @@ export default class EppoClient {
651652
}
652653

653654
private logBanditAction(banditEvent: IBanditEvent): void {
654-
if (!this.banditLogger) {
655-
// No bandit logger set; enqueue the event in case a logger is later set
656-
if (this.queuedBanditEvents.length < MAX_EVENT_QUEUE_SIZE) {
657-
this.queuedBanditEvents.push(banditEvent);
658-
}
655+
// First we check if this bandit action has been logged before
656+
const subjectKey = banditEvent.subject;
657+
const flagKey = banditEvent.featureFlag;
658+
const banditKey = banditEvent.bandit;
659+
const actionKey = banditEvent.action ?? '__eppo_no_action';
660+
661+
const banditAssignmentCacheProperties = {
662+
flagKey,
663+
subjectKey,
664+
banditKey,
665+
actionKey,
666+
};
667+
668+
if (this.banditAssignmentCache?.has(banditAssignmentCacheProperties)) {
669+
// Ignore repeat assignment
659670
return;
660671
}
661-
// If here, we have a logger
672+
673+
// If here, we have a logger and a new assignment to be logged
662674
try {
663-
this.banditLogger.logBanditAction(banditEvent);
675+
if (this.banditLogger) {
676+
this.banditLogger.logBanditAction(banditEvent);
677+
} else if (this.queuedBanditEvents.length < MAX_EVENT_QUEUE_SIZE) {
678+
// If no logger defined, queue up the events (up to a max) to flush if a logger is later defined
679+
this.queuedBanditEvents.push(banditEvent);
680+
}
681+
// Record in the assignment cache, if active, to deduplicate subsequent repeat assignments
682+
this.banditAssignmentCache?.set(banditAssignmentCacheProperties);
664683
} catch (err) {
665684
logger.warn('Error encountered logging bandit action', err);
666685
}
@@ -904,6 +923,22 @@ export default class EppoClient {
904923
this.assignmentCache = cache;
905924
}
906925

926+
public disableBanditAssignmentCache() {
927+
this.banditAssignmentCache = undefined;
928+
}
929+
930+
public useNonExpiringInMemoryBanditAssignmentCache() {
931+
this.banditAssignmentCache = new NonExpiringInMemoryAssignmentCache();
932+
}
933+
934+
public useLRUInMemoryBanditAssignmentCache(maxSize: number) {
935+
this.banditAssignmentCache = new LRUInMemoryAssignmentCache(maxSize);
936+
}
937+
938+
public useCustomBanditAssignmentCache(cache: AssignmentCache) {
939+
this.banditAssignmentCache = cache;
940+
}
941+
907942
public setIsGracefulFailureMode(gracefulFailureMode: boolean) {
908943
this.isGracefulFailureMode = gracefulFailureMode;
909944
}
@@ -956,14 +991,14 @@ export default class EppoClient {
956991
}
957992
}
958993

959-
// assignment logger may be null while waiting for initialization
960-
if (!this.assignmentLogger) {
961-
this.queuedAssignmentEvents.length < MAX_EVENT_QUEUE_SIZE &&
962-
this.queuedAssignmentEvents.push(event);
963-
return;
964-
}
965994
try {
966-
this.assignmentLogger.logAssignment(event);
995+
if (this.assignmentLogger) {
996+
this.assignmentLogger.logAssignment(event);
997+
} else if (this.queuedAssignmentEvents.length < MAX_EVENT_QUEUE_SIZE) {
998+
// assignment logger may be null while waiting for initialization, queue up events (up to a max)
999+
// to be flushed when set
1000+
this.queuedAssignmentEvents.push(event);
1001+
}
9671002
this.assignmentCache?.set({
9681003
flagKey,
9691004
subjectKey,

0 commit comments

Comments
 (0)