Skip to content

Commit 2173a07

Browse files
Convert IConfigurationStore to IAsyncWriteSyncReadStore (FF-1980) (#59)
* Convert implementation of ConfigurationStore to support async writing (FF-1980) * add pino pretty and prefix logs with `[Eppo SDK] * remove one level of nesting * return T | null * Revert "add pino pretty and prefix logs with `[Eppo SDK]" This reverts commit b9db61a. * log manually for now * v3.0.3
1 parent f8ff413 commit 2173a07

12 files changed

+302
-47
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": "3.0.2",
3+
"version": "3.0.3",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [

src/application-logger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pino from 'pino';
22

3+
export const loggerPrefix = '[Eppo SDK]';
4+
35
// Create a Pino logger instance
46
export const logger = pino({
57
level: process.env.NODE_ENV === 'production' ? 'warn' : 'info',

src/client/eppo-client.spec.ts

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,40 +11,16 @@ import {
1111
validateTestAssignments,
1212
} from '../../test/testHelpers';
1313
import { IAssignmentLogger } from '../assignment-logger';
14-
import { IConfigurationStore } from '../configuration-store';
14+
import { IConfigurationStore } from '../configuration-store/configuration-store';
15+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
1516
import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
1617
import FlagConfigurationRequestor from '../flag-configuration-requestor';
1718
import FetchHttpClient from '../http-client';
18-
import { Flag, VariationType } from '../interfaces';
19+
import { Flag, ObfuscatedFlag, VariationType } from '../interfaces';
1920

2021
import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client';
2122

22-
class TestConfigurationStore implements IConfigurationStore {
23-
private store: Record<string, string> = {};
24-
private _isInitialized = false;
25-
26-
public get<T>(key: string): T {
27-
const rval = this.store[key];
28-
return rval ? JSON.parse(rval) : null;
29-
}
30-
31-
public setEntries<T>(entries: Record<string, T>) {
32-
Object.entries(entries).forEach(([key, val]) => {
33-
this.store[key] = JSON.stringify(val);
34-
});
35-
this._isInitialized = true;
36-
}
37-
38-
public getKeys(): string[] {
39-
return Object.keys(this.store);
40-
}
41-
42-
public isInitialized(): boolean {
43-
return this._isInitialized;
44-
}
45-
}
46-
47-
export async function init(configurationStore: IConfigurationStore) {
23+
export async function init(configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
4824
const httpClient = new FetchHttpClient(
4925
'http://127.0.0.1:4000',
5026
{
@@ -68,7 +44,7 @@ describe('EppoClient E2E test', () => {
6844
json: () => Promise.resolve(ufc),
6945
});
7046
}) as jest.Mock;
71-
const storage = new TestConfigurationStore();
47+
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
7248

7349
beforeAll(async () => {
7450
await init(storage);
@@ -312,7 +288,7 @@ describe('EppoClient E2E test', () => {
312288
});
313289

314290
it('returns null if getStringAssignment was called for the subject before any UFC was loaded', () => {
315-
const localClient = new EppoClient(new TestConfigurationStore());
291+
const localClient = new EppoClient(new MemoryOnlyConfigurationStore());
316292
expect(localClient.getStringAssignment(flagKey, 'subject-1', {}, 'hello world')).toEqual(
317293
'hello world',
318294
);
@@ -580,7 +556,7 @@ describe('EppoClient E2E test', () => {
580556

581557
describe('Eppo Client constructed with configuration request parameters', () => {
582558
let client: EppoClient;
583-
let thisStorage: IConfigurationStore;
559+
let thisStorage: IConfigurationStore<Flag | ObfuscatedFlag>;
584560
let requestConfiguration: FlagConfigurationRequestParameters;
585561

586562
const flagKey = 'numeric_flag';
@@ -606,7 +582,7 @@ describe('EppoClient E2E test', () => {
606582
sdkVersion: '1.0.0',
607583
};
608584

609-
thisStorage = new TestConfigurationStore();
585+
thisStorage = new MemoryOnlyConfigurationStore();
610586

611587
// We only want to fake setTimeout() and clearTimeout()
612588
jest.useFakeTimers({

src/client/eppo-client.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
NonExpiringInMemoryAssignmentCache,
66
} from '../assignment-cache';
77
import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger';
8-
import { IConfigurationStore } from '../configuration-store';
8+
import { IConfigurationStore } from '../configuration-store/configuration-store';
99
import {
1010
BASE_URL as DEFAULT_BASE_URL,
1111
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
@@ -116,13 +116,13 @@ export default class EppoClient implements IEppoClient {
116116
private isGracefulFailureMode = true;
117117
private isObfuscated = false;
118118
private assignmentCache: AssignmentCache<Cacheable> | undefined;
119-
private configurationStore: IConfigurationStore;
119+
private configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>;
120120
private configurationRequestParameters: FlagConfigurationRequestParameters | undefined;
121121
private requestPoller: IPoller | undefined;
122122
private evaluator: Evaluator;
123123

124124
constructor(
125-
configurationStore: IConfigurationStore,
125+
configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>,
126126
configurationRequestParameters?: FlagConfigurationRequestParameters,
127127
obfuscated = false,
128128
) {
@@ -387,7 +387,9 @@ export default class EppoClient implements IEppoClient {
387387
}
388388

389389
private getObfuscatedFlag(flagKey: string): Flag | null {
390-
const flag: ObfuscatedFlag | null = this.configurationStore.get(getMD5Hash(flagKey));
390+
const flag: ObfuscatedFlag | null = this.configurationStore.get(
391+
getMD5Hash(flagKey),
392+
) as ObfuscatedFlag;
391393
return flag ? decodeFlag(flag) : null;
392394
}
393395

src/configuration-store.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* ConfigurationStore interface
3+
*
4+
* The interface guides implementation
5+
* of a policy for handling a mixture of async and sync storage.
6+
*
7+
* The goal is to support remote API responses to be written to the sync and async store,
8+
* while also supporting reading from the sync store to maintain public SDK APIs.
9+
*
10+
* Implementation is handled in upstream libraries to best support their use case, some ideas:
11+
*
12+
* - Javascript frontend:
13+
* - SyncStore: backed by localStorage
14+
* - AsyncStore: backed by IndexedDB or chrome.storage.local
15+
*
16+
* - NodeJS backend:
17+
* - SyncStore: backed by LRU cache
18+
* - AsyncStore: none
19+
*
20+
* The policy choices surrounding the use of one or more underlying storages are
21+
* implementation specific and handled upstream.
22+
*/
23+
export interface IConfigurationStore<T> {
24+
servingStore: ISyncStore<T>;
25+
persistentStore: IAsyncStore<T> | null;
26+
27+
init(): Promise<void>;
28+
get(key: string): T | null;
29+
getKeys(): string[];
30+
isInitialized(): boolean;
31+
setEntries(entries: Record<string, T>): Promise<void>;
32+
}
33+
34+
export interface ISyncStore<T> {
35+
get(key: string): T | null;
36+
getKeys(): string[];
37+
isInitialized(): boolean;
38+
setEntries(entries: Record<string, T>): void;
39+
}
40+
41+
export interface IAsyncStore<T> {
42+
isInitialized(): boolean;
43+
getEntries(): Promise<Record<string, T>>;
44+
setEntries(entries: Record<string, T>): Promise<void>;
45+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { IAsyncStore, ISyncStore } from './configuration-store';
2+
import { HybridConfigurationStore } from './hybrid.store';
3+
4+
describe('HybridConfigurationStore', () => {
5+
let syncStoreMock: ISyncStore<string>;
6+
let asyncStoreMock: IAsyncStore<string>;
7+
let store: HybridConfigurationStore<string>;
8+
9+
beforeEach(() => {
10+
syncStoreMock = {
11+
get: jest.fn(),
12+
getKeys: jest.fn(),
13+
isInitialized: jest.fn(),
14+
setEntries: jest.fn(),
15+
};
16+
17+
asyncStoreMock = {
18+
getEntries: jest.fn(),
19+
isInitialized: jest.fn(),
20+
setEntries: jest.fn(),
21+
};
22+
23+
store = new HybridConfigurationStore(syncStoreMock, asyncStoreMock);
24+
});
25+
26+
describe('init', () => {
27+
it('should initialize the serving store with entries from the persistent store if the persistent store is initialized', async () => {
28+
const entries = { key1: 'value1', key2: 'value2' };
29+
(asyncStoreMock.isInitialized as jest.Mock).mockReturnValue(true);
30+
(asyncStoreMock.getEntries as jest.Mock).mockResolvedValue(entries);
31+
32+
await store.init();
33+
34+
expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries);
35+
});
36+
});
37+
38+
describe('isInitialized', () => {
39+
it('should return true if both stores are initialized', () => {
40+
(syncStoreMock.isInitialized as jest.Mock).mockReturnValue(true);
41+
(asyncStoreMock.isInitialized as jest.Mock).mockReturnValue(true);
42+
43+
expect(store.isInitialized()).toBe(true);
44+
});
45+
46+
it('should return false if either store is not initialized', () => {
47+
(syncStoreMock.isInitialized as jest.Mock).mockReturnValue(false);
48+
(asyncStoreMock.isInitialized as jest.Mock).mockReturnValue(true);
49+
50+
expect(store.isInitialized()).toBe(false);
51+
});
52+
});
53+
54+
describe('setEntries', () => {
55+
it('should set entries in both stores if the persistent store is present', async () => {
56+
const entries = { key1: 'value1', key2: 'value2' };
57+
await store.setEntries(entries);
58+
59+
expect(asyncStoreMock.setEntries).toHaveBeenCalledWith(entries);
60+
expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries);
61+
});
62+
63+
it('should only set entries in the serving store if the persistent store is null', async () => {
64+
const mixedStoreWithNull = new HybridConfigurationStore(syncStoreMock, null);
65+
const entries = { key1: 'value1', key2: 'value2' };
66+
await mixedStoreWithNull.setEntries(entries);
67+
68+
expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries);
69+
});
70+
});
71+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { logger, loggerPrefix } from '../application-logger';
2+
3+
import { IAsyncStore, IConfigurationStore, ISyncStore } from './configuration-store';
4+
5+
export class HybridConfigurationStore<T> implements IConfigurationStore<T> {
6+
servingStore: ISyncStore<T>;
7+
persistentStore: IAsyncStore<T> | null;
8+
9+
constructor(servingStore: ISyncStore<T>, persistentStore: IAsyncStore<T> | null) {
10+
this.servingStore = servingStore;
11+
this.persistentStore = persistentStore;
12+
}
13+
14+
/**
15+
* Initialize the configuration store by loading the entries from the persistent store into the serving store.
16+
*/
17+
async init(): Promise<void> {
18+
if (!this.persistentStore) {
19+
return;
20+
}
21+
22+
if (!this.persistentStore.isInitialized()) {
23+
/**
24+
* The initial remote request to the remote API failed
25+
* or never happened because we are in the cool down period.
26+
*
27+
* Shows a log message that the assignments served from the serving store
28+
* may be stale.
29+
*/
30+
logger.warn(
31+
`${loggerPrefix} Persistent store is not initialized from remote configuration. Serving assignments that may be stale.`,
32+
);
33+
}
34+
35+
const entries = await this.persistentStore.getEntries();
36+
this.servingStore.setEntries(entries);
37+
}
38+
39+
public isInitialized(): boolean {
40+
return this.servingStore.isInitialized() && (this.persistentStore?.isInitialized() ?? true);
41+
}
42+
43+
public get(key: string): T | null {
44+
if (!this.servingStore.isInitialized()) {
45+
logger.warn(`${loggerPrefix} getting a value from a ServingStore that is not initialized.`);
46+
}
47+
return this.servingStore.get(key);
48+
}
49+
50+
public getKeys(): string[] {
51+
return this.servingStore.getKeys();
52+
}
53+
54+
public async setEntries(entries: Record<string, T>): Promise<void> {
55+
if (this.persistentStore) {
56+
// Persistence store is now initialized and should mark itself accordingly.
57+
await this.persistentStore.setEntries(entries);
58+
}
59+
this.servingStore.setEntries(entries);
60+
}
61+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { MemoryOnlyConfigurationStore } from './memory.store';
2+
3+
describe('MemoryOnlyConfigurationStore', () => {
4+
let memoryStore: MemoryOnlyConfigurationStore<string>;
5+
6+
beforeEach(() => {
7+
memoryStore = new MemoryOnlyConfigurationStore();
8+
});
9+
10+
it('should initialize without any entries', () => {
11+
expect(memoryStore.isInitialized()).toBe(false);
12+
expect(memoryStore.getKeys()).toEqual([]);
13+
});
14+
15+
it('should return null for non-existent keys', () => {
16+
expect(memoryStore.get('nonexistent')).toBeNull();
17+
});
18+
19+
it('should allow setting and retrieving entries', async () => {
20+
await memoryStore.setEntries({ key1: 'value1', key2: 'value2' });
21+
expect(memoryStore.get('key1')).toBe('value1');
22+
expect(memoryStore.get('key2')).toBe('value2');
23+
});
24+
25+
it('should report initialized after setting entries', async () => {
26+
await memoryStore.setEntries({ key1: 'value1' });
27+
expect(memoryStore.isInitialized()).toBe(true);
28+
});
29+
30+
it('should return all keys', async () => {
31+
await memoryStore.setEntries({ key1: 'value1', key2: 'value2', key3: 'value3' });
32+
expect(memoryStore.getKeys()).toEqual(['key1', 'key2', 'key3']);
33+
});
34+
35+
it('should overwrite existing entries', async () => {
36+
await memoryStore.setEntries({ key1: 'value1' });
37+
await memoryStore.setEntries({ key1: 'newValue1' });
38+
expect(memoryStore.get('key1')).toBe('newValue1');
39+
});
40+
});

0 commit comments

Comments
 (0)