Skip to content

Commit 34ff6c3

Browse files
committed
refactor: introduce configuration feed
1 parent 51f4abb commit 34ff6c3

26 files changed

+1063
-795
lines changed
411 KB
Loading

docs/configuration-lifecycle.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Configuration Lifecycle
2+
3+
This document explains how configuration is managed throughout its lifecycle in the Eppo SDK.
4+
5+
## Components Overview
6+
7+
The SDK's configuration management is built around several key components that work together:
8+
9+
- **ConfigurationFeed**: A broadcast channel that serves as the central communication point between components
10+
- **ConfigurationStore**: Maintains the currently active configuration used for all evaluations
11+
- **ConfigurationPoller**: Periodically fetches new configurations from the Eppo API
12+
- **PersistentConfigurationCache**: Persists configuration between application restarts
13+
14+
## Communication Flow
15+
16+
The ConfigurationFeed acts as a central hub through which different components communicate:
17+
18+
![](./configuration-lifecycle.excalidraw.png)
19+
20+
When a new configuration is received (either from network or cache), it's broadcast through the ConfigurationFeed. Components subscribe to this feed to react to configuration changes. Importantly, configurations broadcast on the ConfigurationFeed are not necessarily activated - they may never be activated at all, as they represent only the latest discovered configurations. For components interested in the currently active configuration, the ConfigurationStore provides its own broadcast channel that only emits when configurations become active.
21+
22+
## Initialization Process
23+
24+
During initialization, the client:
25+
26+
1. **Configuration Loading Strategy**:
27+
- `stale-while-revalidate`: Uses cached config if within `maxStaleSeconds`, while fetching fresh data
28+
- `only-if-cached`: Uses cached config without network requests
29+
- `no-cache`: Always fetches fresh configuration
30+
- `none`: Uses only initial configuration without loading/fetching
31+
32+
2. **Loading cached configuration**:
33+
- If `initialConfiguration` is provided, uses it immediately
34+
- Otherwise, tries to load cached configuration
35+
36+
3. **Network Fetching**:
37+
- If fetching is needed, attempts to fetch until success or timeout
38+
- Applies backoff with jitter between retry attempts (with shorter period than normal polling)
39+
- Broadcasts fetched configuration via ConfigurationFeed
40+
41+
4. **Completion**:
42+
- Initialization completes when either:
43+
- Fresh configuration is fetched (for network strategies)
44+
- Cache is loaded (for cache-only strategies)
45+
- Timeout is reached (using best available configuration)
46+
47+
## Ongoing Configuration Management
48+
49+
After initialization:
50+
51+
1. **Polling** (if enabled):
52+
- ConfigurationPoller periodically fetches new configurations
53+
- Uses exponential backoff with jitter for retries on failure
54+
- Broadcasts new configurations through ConfigurationFeed
55+
56+
2. **Configuration Activation**:
57+
- When ConfigurationStore receives new configurations, it activates them based on strategy:
58+
- `always`: Activate immediately
59+
- `stale`: Activate if current config exceeds `maxStaleSeconds`
60+
- `empty`: Activate if current config is empty
61+
- `next-load`: Store for next initialization
62+
63+
3. **Persistent Storage**:
64+
- PersistentConfigurationCache listens to ConfigurationFeed
65+
- Automatically stores new configurations to persistent storage
66+
- Provides cached configurations on initialization
67+
68+
## Evaluation
69+
70+
For all feature flag evaluations, EppoClient always uses the currently active configuration from ConfigurationStore. This ensures consistent behavior even as configurations are updated in the background.

src/broadcast.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export type Listener<T extends unknown[]> = (...args: T) => void;
2+
3+
/**
4+
* A broadcast channel for dispatching events to multiple listeners.
5+
*
6+
* @internal
7+
*/
8+
export class BroadcastChannel<T extends unknown[]> {
9+
private listeners: Array<Listener<T>> = [];
10+
11+
public addListener(listener: Listener<T>): () => void {
12+
this.listeners.push(listener);
13+
return () => this.removeListener(listener);
14+
}
15+
16+
public removeListener(listener: Listener<T>): void {
17+
const idx = this.listeners.indexOf(listener);
18+
if (idx !== -1) {
19+
this.listeners.splice(idx, 1);
20+
}
21+
}
22+
23+
public broadcast(...args: T): void {
24+
for (const listener of this.listeners) {
25+
try {
26+
listener(...args);
27+
} catch {
28+
// ignore
29+
}
30+
}
31+
}
32+
}

src/client/eppo-client-assignment-details.spec.ts

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as fs from 'fs';
33
import {
44
IAssignmentTestCase,
55
MOCK_UFC_RESPONSE_FILE,
6+
readMockUfcConfiguration,
67
readMockUFCResponse,
78
} from '../../test/testHelpers';
89
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
@@ -13,28 +14,25 @@ import { AttributeType } from '../types';
1314

1415
import EppoClient, { IAssignmentDetails } from './eppo-client';
1516
import { initConfiguration } from './test-utils';
17+
import { read } from 'fs';
1618

1719
describe('EppoClient get*AssignmentDetails', () => {
1820
const testStart = Date.now();
1921

20-
global.fetch = jest.fn(() => {
21-
const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE);
22+
let client: EppoClient;
2223

23-
return Promise.resolve({
24-
ok: true,
25-
status: 200,
26-
json: () => Promise.resolve(ufc),
24+
beforeEach(() => {
25+
client = new EppoClient({
26+
sdkKey: 'dummy',
27+
sdkName: 'js-client-sdk-common',
28+
sdkVersion: '1.0.0',
29+
baseUrl: 'http://127.0.0.1:4000',
30+
configuration: { initialConfiguration: readMockUfcConfiguration() },
2731
});
28-
}) as jest.Mock;
29-
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
30-
31-
beforeAll(async () => {
32-
await initConfiguration(storage);
32+
client.setIsGracefulFailureMode(false);
3333
});
3434

3535
it('should set the details for a matched rule', () => {
36-
const client = new EppoClient({ flagConfigurationStore: storage });
37-
client.setIsGracefulFailureMode(false);
3836
const subjectAttributes = { email: '[email protected]', country: 'US' };
3937
const result = client.getIntegerAssignmentDetails(
4038
'integer-flag',
@@ -85,8 +83,6 @@ describe('EppoClient get*AssignmentDetails', () => {
8583
});
8684

8785
it('should set the details for a matched split', () => {
88-
const client = new EppoClient({ flagConfigurationStore: storage });
89-
client.setIsGracefulFailureMode(false);
9086
const subjectAttributes = { email: '[email protected]', country: 'Brazil' };
9187
const result = client.getIntegerAssignmentDetails(
9288
'integer-flag',
@@ -128,8 +124,6 @@ describe('EppoClient get*AssignmentDetails', () => {
128124
});
129125

130126
it('should handle matching a split allocation with a matched rule', () => {
131-
const client = new EppoClient({ flagConfigurationStore: storage });
132-
client.setIsGracefulFailureMode(false);
133127
const subjectAttributes = { id: 'alice', email: '[email protected]', country: 'Brazil' };
134128
const result = client.getStringAssignmentDetails(
135129
'new-user-onboarding',
@@ -190,8 +184,6 @@ describe('EppoClient get*AssignmentDetails', () => {
190184
});
191185

192186
it('should handle unrecognized flags', () => {
193-
const client = new EppoClient({ flagConfigurationStore: storage });
194-
client.setIsGracefulFailureMode(false);
195187
const result = client.getIntegerAssignmentDetails('asdf', 'alice', {}, 0);
196188
expect(result).toEqual({
197189
variation: 0,
@@ -215,7 +207,6 @@ describe('EppoClient get*AssignmentDetails', () => {
215207
});
216208

217209
it('should handle type mismatches with graceful failure mode enabled', () => {
218-
const client = new EppoClient({ flagConfigurationStore: storage });
219210
client.setIsGracefulFailureMode(true);
220211
const result = client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true);
221212
expect(result).toEqual({
@@ -252,7 +243,6 @@ describe('EppoClient get*AssignmentDetails', () => {
252243
});
253244

254245
it('should throw an error for type mismatches with graceful failure mode disabled', () => {
255-
const client = new EppoClient({ flagConfigurationStore: storage });
256246
client.setIsGracefulFailureMode(false);
257247
expect(() => client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true)).toThrow();
258248
});
@@ -277,22 +267,6 @@ describe('EppoClient get*AssignmentDetails', () => {
277267
}
278268
};
279269

280-
beforeAll(async () => {
281-
global.fetch = jest.fn(() => {
282-
return Promise.resolve({
283-
ok: true,
284-
status: 200,
285-
json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)),
286-
});
287-
}) as jest.Mock;
288-
289-
await initConfiguration(storage);
290-
});
291-
292-
afterAll(() => {
293-
jest.restoreAllMocks();
294-
});
295-
296270
describe.each(getTestFilePaths())('for file: %s', (testFilePath: string) => {
297271
const testCase = parseJSON(testFilePath);
298272
describe.each(testCase.subjects.map(({ subjectKey }) => subjectKey))(
@@ -302,9 +276,6 @@ describe('EppoClient get*AssignmentDetails', () => {
302276
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
303277
const subject = subjects.find((subject) => subject.subjectKey === subjectKey)!;
304278

305-
const client = new EppoClient({ flagConfigurationStore: storage });
306-
client.setIsGracefulFailureMode(false);
307-
308279
const focusOn = {
309280
testFilePath: '', // focus on test file paths (don't forget to set back to empty string!)
310281
subjectKey: '', // focus on subject (don't forget to set back to empty string!)

src/client/eppo-client-experiment-container.spec.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers';
1+
import { MOCK_UFC_RESPONSE_FILE, readMockUfcConfiguration, readMockUFCResponse } from '../../test/testHelpers';
22
import * as applicationLogger from '../application-logger';
33
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
44
import { Flag, ObfuscatedFlag } from '../interfaces';
@@ -9,15 +9,6 @@ import { initConfiguration } from './test-utils';
99
type Container = { name: string };
1010

1111
describe('getExperimentContainerEntry', () => {
12-
global.fetch = jest.fn(() => {
13-
const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE);
14-
return Promise.resolve({
15-
ok: true,
16-
status: 200,
17-
json: () => Promise.resolve(ufc),
18-
});
19-
}) as jest.Mock;
20-
2112
const controlContainer: Container = { name: 'Control Container' };
2213
const treatment1Container: Container = { name: 'Treatment Variation 1 Container' };
2314
const treatment2Container: Container = { name: 'Treatment Variation 2 Container' };
@@ -29,9 +20,16 @@ describe('getExperimentContainerEntry', () => {
2920
let loggerWarnSpy: jest.SpyInstance;
3021

3122
beforeEach(async () => {
32-
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
33-
await initConfiguration(storage);
34-
client = new EppoClient({ flagConfigurationStore: storage });
23+
client = new EppoClient({
24+
configuration: {
25+
initializationStrategy: 'none',
26+
initialConfiguration: readMockUfcConfiguration(),
27+
},
28+
sdkKey: 'dummy',
29+
sdkName: 'js-client-sdk-common',
30+
sdkVersion: '1.0.0',
31+
baseUrl: 'http://127.0.0.1:4000',
32+
});
3533
client.setIsGracefulFailureMode(true);
3634
flagExperiment = {
3735
flagKey: 'my-key',

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

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
testCasesByFileName,
88
BanditTestCase,
99
BANDIT_TEST_DATA_DIR,
10+
readMockBanditsConfiguration,
1011
} from '../../test/testHelpers';
1112
import ApiEndpoints from '../api-endpoints';
1213
import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger';
@@ -32,9 +33,6 @@ import { Attributes, BanditActions, ContextAttributes } from '../types';
3233
import EppoClient, { IAssignmentDetails } from './eppo-client';
3334

3435
describe('EppoClient Bandits E2E test', () => {
35-
const flagStore = new MemoryOnlyConfigurationStore<Flag>();
36-
const banditVariationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
37-
const banditModelStore = new MemoryOnlyConfigurationStore<BanditParameters>();
3836
let client: EppoClient;
3937
const mockLogAssignment = jest.fn();
4038
const mockLogBanditAction = jest.fn();
@@ -63,22 +61,18 @@ describe('EppoClient Bandits E2E test', () => {
6361
sdkVersion: '1.0.0',
6462
},
6563
});
66-
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
67-
const configurationRequestor = new ConfigurationRequestor(
68-
httpClient,
69-
flagStore,
70-
banditVariationStore,
71-
banditModelStore,
72-
);
73-
await configurationRequestor.fetchAndStoreConfigurations();
7464
});
7565

7666
beforeEach(() => {
7767
client = new EppoClient({
78-
flagConfigurationStore: flagStore,
79-
banditVariationConfigurationStore: banditVariationStore,
80-
banditModelConfigurationStore: banditModelStore,
81-
isObfuscated: false,
68+
sdkKey: 'dummy',
69+
sdkName: 'js-client-sdk-common',
70+
sdkVersion: '1.0.0',
71+
baseUrl: 'http://127.0.0.1:4000',
72+
configuration: {
73+
initializationStrategy: 'none',
74+
initialConfiguration: readMockBanditsConfiguration(),
75+
},
8276
});
8377
client.setIsGracefulFailureMode(false);
8478
client.setAssignmentLogger({ logAssignment: mockLogAssignment });

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
1+
import { Configuration } from '../configuration';
12
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
23
import { Flag, FormatEnum, ObfuscatedFlag, VariationType } from '../interfaces';
34
import * as overrideValidatorModule from '../override-validator';
45

56
import EppoClient from './eppo-client';
67

78
describe('EppoClient', () => {
8-
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
9-
109
function setUnobfuscatedFlagEntries(
11-
entries: Record<string, Flag | ObfuscatedFlag>,
12-
): Promise<boolean> {
13-
storage.setFormat(FormatEnum.SERVER);
14-
return storage.setEntries(entries);
10+
entries: Record<string, Flag>,
11+
): EppoClient {
12+
return new EppoClient({
13+
sdkKey: 'dummy',
14+
sdkName: 'js-client-sdk-common',
15+
sdkVersion: '1.0.0',
16+
baseUrl: 'http://127.0.0.1:4000',
17+
configuration: {
18+
initialConfiguration: Configuration.fromResponses({
19+
flags: {
20+
fetchedAt: new Date().toISOString(),
21+
response: {
22+
format: FormatEnum.SERVER,
23+
flags: entries,
24+
createdAt: new Date().toISOString(),
25+
environment: {
26+
name: 'test',
27+
},
28+
banditReferences: {},
29+
},
30+
}
31+
})
32+
},
33+
});
1534
}
1635

1736
const flagKey = 'mock-flag';
@@ -51,9 +70,8 @@ describe('EppoClient', () => {
5170
let subjectKey: string;
5271

5372
beforeEach(async () => {
54-
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
73+
client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
5574
subjectKey = 'subject-10';
56-
client = new EppoClient({ flagConfigurationStore: storage });
5775
});
5876

5977
describe('parseOverrides', () => {

0 commit comments

Comments
 (0)