Skip to content

Commit b947919

Browse files
Expiring bandit cache
1 parent 92cd45a commit b947919

File tree

2 files changed

+150
-22
lines changed

2 files changed

+150
-22
lines changed

src/index.spec.ts

Lines changed: 146 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@eppo/js-client-sdk-common';
1212
import { BanditParameters, BanditVariation } from '@eppo/js-client-sdk-common/dist/interfaces';
1313
import { ContextAttributes } from '@eppo/js-client-sdk-common/dist/types';
14+
import {Attributes} from "@eppo/js-client-sdk-common/src/types";
1415
import * as td from 'testdouble';
1516

1617
import apiServer, { TEST_BANDIT_API_KEY, TEST_SERVER_PORT } from '../test/mockApiServer';
@@ -27,6 +28,8 @@ import {
2728

2829
import { getInstance, IAssignmentEvent, IAssignmentLogger, init } from '.';
2930

31+
import SpyInstance = jest.SpyInstance;
32+
3033
const { DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } = constants;
3134

3235
describe('EppoClient E2E test', () => {
@@ -189,12 +192,12 @@ describe('EppoClient E2E test', () => {
189192
it('returns the default value when ufc config is absent', () => {
190193
const mockConfigStore = td.object<IConfigurationStore<Flag>>();
191194
td.when(mockConfigStore.get(flagKey)).thenReturn(null);
192-
const client = new EppoClient(
193-
mockConfigStore,
194-
mockBanditVariationStore,
195-
mockBanditModelStore,
196-
requestParamsStub,
197-
);
195+
const client = new EppoClient({
196+
flagConfigurationStore: mockConfigStore,
197+
banditVariationConfigurationStore: mockBanditVariationStore,
198+
banditModelConfigurationStore: mockBanditModelStore,
199+
configurationRequestParameters: requestParamsStub,
200+
});
198201
const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value');
199202
expect(assignment).toEqual('default-value');
200203
});
@@ -203,12 +206,12 @@ describe('EppoClient E2E test', () => {
203206
const mockConfigStore = td.object<IConfigurationStore<Flag>>();
204207
td.when(mockConfigStore.get(flagKey)).thenReturn(mockUfcFlagConfig);
205208
const subjectAttributes = { foo: 3 };
206-
const client = new EppoClient(
207-
mockConfigStore,
208-
mockBanditVariationStore,
209-
mockBanditModelStore,
210-
requestParamsStub,
211-
);
209+
const client = new EppoClient({
210+
flagConfigurationStore: mockConfigStore,
211+
banditVariationConfigurationStore: mockBanditVariationStore,
212+
banditModelConfigurationStore: mockBanditModelStore,
213+
configurationRequestParameters: requestParamsStub,
214+
});
212215
const mockLogger = td.object<IAssignmentLogger>();
213216
client.setAssignmentLogger(mockLogger);
214217
const assignment = client.getStringAssignment(
@@ -233,12 +236,12 @@ describe('EppoClient E2E test', () => {
233236
const mockConfigStore = td.object<IConfigurationStore<Flag>>();
234237
td.when(mockConfigStore.get(flagKey)).thenReturn(mockUfcFlagConfig);
235238
const subjectAttributes = { foo: 3 };
236-
const client = new EppoClient(
237-
mockConfigStore,
238-
mockBanditVariationStore,
239-
mockBanditModelStore,
240-
requestParamsStub,
241-
);
239+
const client = new EppoClient({
240+
flagConfigurationStore: mockConfigStore,
241+
banditVariationConfigurationStore: mockBanditVariationStore,
242+
banditModelConfigurationStore: mockBanditModelStore,
243+
configurationRequestParameters: requestParamsStub,
244+
});
242245
const mockLogger = td.object<IAssignmentLogger>();
243246
td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(
244247
new Error('logging error'),
@@ -312,6 +315,131 @@ describe('EppoClient E2E test', () => {
312315
// Ensure that this test case correctly checked some test assignments
313316
expect(numAssignmentsChecked).toBeGreaterThan(0);
314317
});
318+
319+
320+
describe('Bandit assignment cache', () => {
321+
const flagKey = 'banner_bandit_flag'; // piggyback off a shared test data flag
322+
const bobKey = 'bob';
323+
const bobAttributes: Attributes = { age: 25, country: 'USA', gender_identity: 'female' };
324+
const bobActions: Record<string, Attributes> = {
325+
nike: { brand_affinity: 1.5, loyalty_tier: 'silver' },
326+
adidas: { brand_affinity: -1.0, loyalty_tier: 'bronze' },
327+
reebok: { brand_affinity: 0.5, loyalty_tier: 'gold' },
328+
};
329+
330+
const aliceKey = 'alice';
331+
const aliceAttributes: Attributes = {age: 25, country: 'USA', gender_identity: 'female' };
332+
const aliceActions: Record<string, Attributes> = {
333+
nike: { brand_affinity: 1.5, loyalty_tier: 'silver' },
334+
adidas: {brand_affinity: -1.0, loyalty_tier: 'bronze' },
335+
reebok: {brand_affinity: 0.5, loyalty_tier: 'gold' },
336+
}
337+
const charlieKey = 'charlie';
338+
const charlieAttributes: Attributes = {age: 25, country: 'USA', gender_identity: 'female' };
339+
const charlieActions: Record<string, Attributes> = {
340+
nike: { brand_affinity: 1.0, loyalty_tier: 'gold' },
341+
adidas: {brand_affinity: 1.0, loyalty_tier: 'silver' },
342+
puma: {},
343+
}
344+
345+
let banditLoggerSpy: SpyInstance<void, [banditEvent: IBanditEvent]>;
346+
const defaultBanditCacheTTL = 600_000
347+
348+
beforeAll(async () => {
349+
const dummyBanditLogger: IBanditLogger = {
350+
logBanditAction(banditEvent: IBanditEvent) {
351+
console.log(
352+
`Bandit ${banditEvent.bandit} assigned ${banditEvent.subject} the action ${banditEvent.action}`,
353+
);
354+
},
355+
};
356+
banditLoggerSpy = jest.spyOn(dummyBanditLogger, 'logBanditAction');
357+
await init({
358+
apiKey: TEST_BANDIT_API_KEY, // Flag to dummy test server we want bandit-related files
359+
baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`,
360+
assignmentLogger: mockLogger,
361+
banditLogger: dummyBanditLogger,
362+
});
363+
});
364+
365+
it('Should not log bandit assignment if cached version is still valid', async () => {
366+
const client = getInstance();
367+
client.useExpiringInMemoryBanditAssignmentCache(2);
368+
369+
// Let's say someone is rage refreshing - we want to log assignment only once
370+
for (const _ of Array(3).keys()) {
371+
client.getBanditAction(
372+
flagKey,
373+
bobKey,
374+
bobAttributes,
375+
bobActions,
376+
'default',
377+
);
378+
}
379+
expect(banditLoggerSpy).toHaveBeenCalledTimes(1)
380+
});
381+
it('Should log bandit assignment if cached entry is expired', async () => {
382+
jest.useFakeTimers();
383+
384+
const client = getInstance();
385+
client.useExpiringInMemoryBanditAssignmentCache(2);
386+
387+
client.getBanditAction(
388+
flagKey,
389+
bobKey,
390+
bobAttributes,
391+
bobActions,
392+
'default',
393+
);
394+
jest.advanceTimersByTime(defaultBanditCacheTTL);
395+
client.getBanditAction(
396+
flagKey,
397+
bobKey,
398+
bobAttributes,
399+
bobActions,
400+
'default',
401+
);
402+
expect(banditLoggerSpy).toHaveBeenCalledTimes(2)
403+
})
404+
405+
it('Should invalidate least used cache entry if cache reaches max size', async () => {
406+
const client = getInstance();
407+
client.useExpiringInMemoryBanditAssignmentCache(2);
408+
409+
client.getBanditAction(
410+
flagKey,
411+
bobKey,
412+
bobAttributes,
413+
bobActions,
414+
'default',
415+
);
416+
client.getBanditAction(
417+
flagKey,
418+
aliceKey,
419+
aliceAttributes,
420+
aliceActions,
421+
'default',
422+
);
423+
client.getBanditAction(
424+
flagKey,
425+
charlieKey,
426+
charlieAttributes,
427+
charlieActions,
428+
'default'
429+
);
430+
// even though bob was called 2nd time within cache validity time
431+
// we expect assignment to be logged because max cache size is 2
432+
// and currently storing alice and charlie
433+
client.getBanditAction(
434+
flagKey,
435+
bobKey,
436+
bobAttributes,
437+
bobActions,
438+
'default',
439+
);
440+
expect(banditLoggerSpy).toHaveBeenCalledTimes(4);
441+
});
442+
});
315443
});
316444

317445
describe('initialization errors', () => {

src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ let clientInstance: EppoClient;
9292
export async function init(config: IClientConfig): Promise<EppoClient> {
9393
validation.validateNotBlank(config.apiKey, 'API key required');
9494

95-
const requestConfiguration: FlagConfigurationRequestParameters = {
95+
const configurationRequestParameters: FlagConfigurationRequestParameters = {
9696
apiKey: config.apiKey,
9797
sdkName,
9898
sdkVersion,
@@ -110,12 +110,12 @@ export async function init(config: IClientConfig): Promise<EppoClient> {
110110
const banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
111111
const banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
112112

113-
clientInstance = new EppoClient(
113+
clientInstance = new EppoClient({
114114
flagConfigurationStore,
115115
banditVariationConfigurationStore,
116116
banditModelConfigurationStore,
117-
requestConfiguration,
118-
);
117+
configurationRequestParameters,
118+
});
119119
clientInstance.setAssignmentLogger(config.assignmentLogger);
120120
if (config.banditLogger) {
121121
clientInstance.setBanditLogger(config.banditLogger);

0 commit comments

Comments
 (0)