Skip to content

Commit 3f02ce0

Browse files
Selki/ff3396 expiring bandit cache (#80)
* Switched to Expiring LRU cache for bandit actions * Expiring bandit cache * linter * bandit cache tests fix
1 parent 7c7bf90 commit 3f02ce0

File tree

1 file changed

+87
-0
lines changed

1 file changed

+87
-0
lines changed

src/index.spec.ts

Lines changed: 87 additions & 0 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
const apiKey = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk';
@@ -313,6 +316,90 @@ describe('EppoClient E2E test', () => {
313316
// Ensure that this test case correctly checked some test assignments
314317
expect(numAssignmentsChecked).toBeGreaterThan(0);
315318
});
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(flagKey, bobKey, bobAttributes, bobActions, 'default');
372+
}
373+
expect(banditLoggerSpy).toHaveBeenCalledTimes(1);
374+
});
375+
it('Should log bandit assignment if cached entry is expired', async () => {
376+
jest.useFakeTimers();
377+
banditLoggerSpy.mockReset();
378+
379+
const client = getInstance();
380+
client.useExpiringInMemoryBanditAssignmentCache(2);
381+
382+
client.getBanditAction(flagKey, bobKey, bobAttributes, bobActions, 'default');
383+
jest.advanceTimersByTime(defaultBanditCacheTTL);
384+
client.getBanditAction(flagKey, bobKey, bobAttributes, bobActions, 'default');
385+
expect(banditLoggerSpy).toHaveBeenCalledTimes(2);
386+
});
387+
388+
it('Should invalidate least used cache entry if cache reaches max size', async () => {
389+
banditLoggerSpy.mockReset();
390+
const client = getInstance();
391+
client.useExpiringInMemoryBanditAssignmentCache(2);
392+
393+
client.getBanditAction(flagKey, bobKey, bobAttributes, bobActions, 'default');
394+
client.getBanditAction(flagKey, aliceKey, aliceAttributes, aliceActions, 'default');
395+
client.getBanditAction(flagKey, charlieKey, charlieAttributes, charlieActions, 'default');
396+
// even though bob was called 2nd time within cache validity time
397+
// we expect assignment to be logged because max cache size is 2
398+
// and currently storing alice and charlie
399+
client.getBanditAction(flagKey, bobKey, bobAttributes, bobActions, 'default');
400+
expect(banditLoggerSpy).toHaveBeenCalledTimes(4);
401+
});
402+
});
316403
});
317404

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

0 commit comments

Comments
 (0)