Skip to content

Commit e618b7f

Browse files
Polishing
1 parent 60ccbf8 commit e618b7f

File tree

6 files changed

+147
-97
lines changed

6 files changed

+147
-97
lines changed

CHANGES.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
2.5.0 (August XX, 2025)
2-
- Added `factory.getState()` method for standalone server-side SDKs, which returns the rollout plan snapshot from the storage.
2+
- Added `factory.getCache()` method for standalone server-side SDKs, which returns the rollout plan snapshot from the storage.
33
- Added `preloadedData` configuration option for standalone client-side SDKs, which allows preloading the SDK storage with a snapshot of the rollout plan.
44
- Updated internal storage factory to emit the SDK_READY_FROM_CACHE event when it corresponds, to clean up the initialization flow.
55

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,81 @@
11
import { InMemoryStorageFactory } from '../inMemory/InMemoryStorage';
22
import { InMemoryStorageCSFactory } from '../inMemory/InMemoryStorageCS';
33
import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks';
4+
import { loggerMock } from '../../logger/__tests__/sdkLogger.mock';
5+
import { IRBSegment, ISplit } from '../../dtos/types';
46

57
import * as dataLoader from '../dataLoader';
68

7-
test('loadData & getSnapshot', () => {
8-
jest.spyOn(dataLoader, 'loadData');
9+
describe('setCache & getCache', () => {
10+
jest.spyOn(dataLoader, 'setCache');
911
const onReadyFromCacheCb = jest.fn();
10-
// @ts-expect-error
11-
const serverStorage = InMemoryStorageFactory({ settings: fullSettings }); // @ts-expect-error
12-
serverStorage.splits.update([{ name: 'split1' }], [], 123); // @ts-expect-error
13-
serverStorage.rbSegments.update([{ name: 'rbs1' }], [], 321);
14-
serverStorage.segments.update('segment1', [fullSettings.core.key as string], [], 123);
15-
16-
const preloadedData = dataLoader.getSnapshot(serverStorage, [fullSettings.core.key as string]);
17-
18-
// @ts-expect-error
19-
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, preloadedData }, onReadyFromCacheCb });
20-
21-
// Assert
22-
expect(dataLoader.loadData).toBeCalledTimes(1);
23-
expect(onReadyFromCacheCb).toBeCalledTimes(1);
24-
expect(dataLoader.getSnapshot(clientStorage, [fullSettings.core.key as string])).toEqual(preloadedData);
25-
expect(preloadedData).toEqual({
26-
since: 123,
27-
flags: [{ name: 'split1' }],
28-
rbSince: 321,
29-
rbSegments: [{ name: 'rbs1' }],
30-
memberships: { [fullSettings.core.key as string]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } } },
31-
segments: undefined
12+
const onReadyCb = jest.fn();
13+
14+
const otherKey = 'otherKey';
15+
16+
// @ts-expect-error Load server-side storage
17+
const serverStorage = InMemoryStorageFactory({ settings: fullSettings });
18+
serverStorage.splits.update([{ name: 'split1' } as ISplit], [], 123);
19+
serverStorage.rbSegments.update([{ name: 'rbs1' } as IRBSegment], [], 321);
20+
serverStorage.segments.update('segment1', [fullSettings.core.key as string, otherKey], [], 123);
21+
22+
afterEach(() => {
23+
jest.clearAllMocks();
24+
});
25+
26+
test('using preloaded data with memberships', () => {
27+
const preloadedData = dataLoader.getCache(loggerMock, serverStorage, [fullSettings.core.key as string, otherKey]);
28+
29+
// Load client-side storage with preloaded data
30+
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, preloadedData }, onReadyFromCacheCb, onReadyCb });
31+
expect(dataLoader.setCache).toBeCalledTimes(1);
32+
expect(onReadyFromCacheCb).toBeCalledTimes(1);
33+
34+
// Shared client storage
35+
const sharedClientStorage = clientStorage.shared!(otherKey);
36+
expect(dataLoader.setCache).toBeCalledTimes(2);
37+
38+
expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
39+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
40+
41+
// Get preloaded data from client-side storage
42+
expect(dataLoader.getCache(loggerMock, clientStorage, [fullSettings.core.key as string, otherKey])).toEqual(preloadedData);
43+
expect(preloadedData).toEqual({
44+
since: 123,
45+
flags: [{ name: 'split1' }],
46+
rbSince: 321,
47+
rbSegments: [{ name: 'rbs1' }],
48+
memberships: {
49+
[fullSettings.core.key as string]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } },
50+
[otherKey]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } }
51+
},
52+
segments: undefined
53+
});
54+
});
55+
56+
test('using preloaded data with segments', () => {
57+
const preloadedData = dataLoader.getCache(loggerMock, serverStorage);
58+
59+
// Load client-side storage with preloaded data
60+
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, preloadedData }, onReadyFromCacheCb, onReadyCb });
61+
expect(dataLoader.setCache).toBeCalledTimes(1);
62+
expect(onReadyFromCacheCb).toBeCalledTimes(1);
63+
64+
// Shared client storage
65+
const sharedClientStorage = clientStorage.shared!(otherKey);
66+
expect(dataLoader.setCache).toBeCalledTimes(2);
67+
expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
68+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
69+
70+
expect(preloadedData).toEqual({
71+
since: 123,
72+
flags: [{ name: 'split1' }],
73+
rbSince: 321,
74+
rbSegments: [{ name: 'rbs1' }],
75+
memberships: undefined,
76+
segments: {
77+
segment1: [fullSettings.core.key as string, otherKey]
78+
}
79+
});
3280
});
3381
});

src/storages/dataLoader.ts

Lines changed: 38 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,113 +3,100 @@ import { IRBSegmentsCacheSync, ISegmentsCacheSync, ISplitsCacheSync, IStorageSyn
33
import { setToArray } from '../utils/lang/sets';
44
import { getMatching } from '../utils/key';
55
import { IMembershipsResponse, IMySegmentsResponse, IRBSegment, ISplit } from '../dtos/types';
6+
import { ILogger } from '../logger/types';
67

78
/**
8-
*
9-
* @param preloadedData - validated data
10-
* @param storage - object containing `splits` and `segments` cache (client-side variant)
11-
* @param userKey - user key (matching key) of the provided MySegmentsCache
12-
*
13-
* @TODO load data even if current data is more recent?
14-
* @TODO extend to load largeSegments
15-
* @TODO extend to load data on shared mySegments storages. Be specific when emitting SDK_READY_FROM_CACHE on shared clients. Maybe the serializer should provide the `useSegments` flag.
16-
* @TODO add logs, and input validation in this module, in favor of size reduction.
17-
* @TODO unit tests
9+
* Sets the given synchronous storage with the provided preloaded data snapshot.
10+
* If `matchingKey` is provided, the storage is handled as a client-side storage (segments and largeSegments are instances of MySegmentsCache).
11+
* Otherwise, the storage is handled as a server-side storage (segments is an instance of SegmentsCache).
1812
*/
19-
export function loadData(preloadedData: SplitIO.PreloadedData, storage: { splits?: ISplitsCacheSync, rbSegments?: IRBSegmentsCacheSync, segments: ISegmentsCacheSync, largeSegments?: ISegmentsCacheSync }, matchingKey?: string) {
13+
export function setCache(log: ILogger, preloadedData: SplitIO.PreloadedData, storage: { splits?: ISplitsCacheSync, rbSegments?: IRBSegmentsCacheSync, segments: ISegmentsCacheSync, largeSegments?: ISegmentsCacheSync }, matchingKey?: string) {
2014
// Do not load data if current preloadedData is empty
2115
if (Object.keys(preloadedData).length === 0) return;
2216

23-
const { segments = {}, since = -1, flags = [], rbSince = -1, rbSegments = [] } = preloadedData;
17+
const { splits, rbSegments, segments, largeSegments } = storage;
2418

25-
if (storage.splits) {
26-
const storedSince = storage.splits.getChangeNumber();
19+
log.debug(`set cache${matchingKey ? ` for key ${matchingKey}` : ''}`);
2720

28-
// Do not load data if current data is more recent
29-
if (storedSince > since) return;
30-
31-
// cleaning up the localStorage data, since some cached splits might need be part of the preloaded data
32-
storage.splits.clear();
33-
34-
// splitsData in an object where the property is the split name and the pertaining value is a stringified json of its data
35-
storage.splits.update(flags as ISplit[], [], since);
21+
if (splits) {
22+
splits.clear();
23+
splits.update(preloadedData.flags as ISplit[] || [], [], preloadedData.since || -1);
3624
}
3725

38-
if (storage.rbSegments) {
39-
const storedSince = storage.rbSegments.getChangeNumber();
40-
41-
// Do not load data if current data is more recent
42-
if (storedSince > rbSince) return;
43-
44-
// cleaning up the localStorage data, since some cached splits might need be part of the preloaded data
45-
storage.rbSegments.clear();
46-
47-
// splitsData in an object where the property is the split name and the pertaining value is a stringified json of its data
48-
storage.rbSegments.update(rbSegments as IRBSegment[], [], rbSince);
26+
if (rbSegments) {
27+
rbSegments.clear();
28+
rbSegments.update(preloadedData.rbSegments as IRBSegment[] || [], [], preloadedData.rbSince || -1);
4929
}
5030

31+
const segmentsData = preloadedData.segments || {};
5132
if (matchingKey) { // add memberships data (client-side)
5233
let memberships = preloadedData.memberships && preloadedData.memberships[matchingKey];
53-
if (!memberships && segments) {
34+
if (!memberships && segmentsData) {
5435
memberships = {
5536
ms: {
56-
k: Object.keys(segments).filter(segmentName => {
57-
const segmentKeys = segments[segmentName];
37+
k: Object.keys(segmentsData).filter(segmentName => {
38+
const segmentKeys = segmentsData[segmentName];
5839
return segmentKeys.indexOf(matchingKey) > -1;
5940
}).map(segmentName => ({ n: segmentName }))
6041
}
6142
};
6243
}
6344

6445
if (memberships) {
65-
if ((memberships as IMembershipsResponse).ms) storage.segments.resetSegments((memberships as IMembershipsResponse).ms!);
66-
if ((memberships as IMembershipsResponse).ls && storage.largeSegments) storage.largeSegments.resetSegments((memberships as IMembershipsResponse).ls!);
46+
if ((memberships as IMembershipsResponse).ms) segments.resetSegments((memberships as IMembershipsResponse).ms!);
47+
if ((memberships as IMembershipsResponse).ls && largeSegments) largeSegments.resetSegments((memberships as IMembershipsResponse).ls!);
6748
}
6849
} else { // add segments data (server-side)
69-
Object.keys(segments).forEach(segmentName => {
70-
const segmentKeys = segments[segmentName];
71-
storage.segments.update(segmentName, segmentKeys, [], -1);
50+
Object.keys(segmentsData).forEach(segmentName => {
51+
const segmentKeys = segmentsData[segmentName];
52+
segments.update(segmentName, segmentKeys, [], -1);
7253
});
7354
}
7455
}
7556

76-
export function getSnapshot(storage: IStorageSync, userKeys?: SplitIO.SplitKey[]): SplitIO.PreloadedData {
57+
/**
58+
* Gets the preloaded data snapshot from the given synchronous storage.
59+
* If `keys` are provided, the memberships for those keys is returned, to protect segments data.
60+
* Otherwise, the segments data is returned.
61+
*/
62+
export function getCache(log: ILogger, storage: IStorageSync, keys?: SplitIO.SplitKey[]): SplitIO.PreloadedData {
63+
64+
log.debug(`get cache${keys ? ` for keys ${keys}` : ''}`);
65+
7766
return {
7867
since: storage.splits.getChangeNumber(),
7968
flags: storage.splits.getAll(),
8069
rbSince: storage.rbSegments.getChangeNumber(),
8170
rbSegments: storage.rbSegments.getAll(),
82-
segments: userKeys ?
71+
segments: keys ?
8372
undefined : // @ts-ignore accessing private prop
8473
Object.keys(storage.segments.segmentCache).reduce((prev, cur) => { // @ts-ignore accessing private prop
8574
prev[cur] = setToArray(storage.segments.segmentCache[cur] as Set<string>);
8675
return prev;
8776
}, {}),
88-
memberships: userKeys ?
89-
userKeys.reduce<Record<string, IMembershipsResponse>>((prev, userKey) => {
77+
memberships: keys ?
78+
keys.reduce<Record<string, IMembershipsResponse>>((prev, key) => {
9079
if (storage.shared) {
9180
// Client-side segments
9281
// @ts-ignore accessing private prop
93-
const sharedStorage = storage.shared(userKey);
94-
prev[getMatching(userKey)] = {
82+
const sharedStorage = storage.shared(key);
83+
prev[getMatching(key)] = {
9584
ms: {
9685
// @ts-ignore accessing private prop
9786
k: Object.keys(sharedStorage.segments.segmentCache).map(segmentName => ({ n: segmentName })),
98-
// cn: sharedStorage.segments.getChangeNumber()
9987
},
10088
ls: sharedStorage.largeSegments ? {
10189
// @ts-ignore accessing private prop
10290
k: Object.keys(sharedStorage.largeSegments.segmentCache).map(segmentName => ({ n: segmentName })),
103-
// cn: sharedStorage.largeSegments.getChangeNumber()
10491
} : undefined
10592
};
10693
} else {
107-
prev[getMatching(userKey)] = {
94+
prev[getMatching(key)] = {
10895
ms: {
10996
// Server-side segments
11097
// @ts-ignore accessing private prop
11198
k: Object.keys(storage.segments.segmentCache).reduce<IMySegmentsResponse['k']>((prev, segmentName) => { // @ts-ignore accessing private prop
112-
return storage.segments.segmentCache[segmentName].has(userKey) ?
99+
return storage.segments.segmentCache[segmentName].has(key) ?
113100
prev!.concat({ n: segmentName }) :
114101
prev;
115102
}, [])

src/storages/inMemory/InMemoryStorageCS.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { LOCALHOST_MODE, STORAGE_MEMORY } from '../../utils/constants';
88
import { shouldRecordTelemetry, TelemetryCacheInMemory } from './TelemetryCacheInMemory';
99
import { UniqueKeysCacheInMemoryCS } from './UniqueKeysCacheInMemoryCS';
1010
import { getMatching } from '../../utils/key';
11-
import { loadData } from '../dataLoader';
11+
import { setCache } from '../dataLoader';
1212
import { RBSegmentsCacheInMemory } from './RBSegmentsCacheInMemory';
1313

1414
/**
@@ -17,7 +17,9 @@ import { RBSegmentsCacheInMemory } from './RBSegmentsCacheInMemory';
1717
* @param params - parameters required by EventsCacheSync
1818
*/
1919
export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorageSync {
20-
const { settings: { scheduler: { impressionsQueueSize, eventsQueueSize }, sync: { __splitFiltersValidation }, preloadedData }, onReadyFromCacheCb } = params;
20+
const { settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize }, sync: { __splitFiltersValidation }, preloadedData }, onReadyFromCacheCb } = params;
21+
22+
const storages: Record<string, IStorageSync> = {};
2123

2224
const splits = new SplitsCacheInMemory(__splitFiltersValidation);
2325
const rbSegments = new RBSegmentsCacheInMemory();
@@ -39,26 +41,30 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag
3941

4042
// When using shared instantiation with MEMORY we reuse everything but segments (they are unique per key)
4143
shared(matchingKey: string) {
42-
const segments = new MySegmentsCacheInMemory();
43-
const largeSegments = new MySegmentsCacheInMemory();
44+
if (!storages[matchingKey]) {
45+
const segments = new MySegmentsCacheInMemory();
46+
const largeSegments = new MySegmentsCacheInMemory();
4447

45-
if (preloadedData) {
46-
loadData(preloadedData, { segments, largeSegments }, matchingKey);
47-
}
48+
if (preloadedData) {
49+
setCache(log, preloadedData, { segments, largeSegments }, matchingKey);
50+
}
4851

49-
return {
50-
splits: this.splits,
51-
rbSegments: this.rbSegments,
52-
segments,
53-
largeSegments,
54-
impressions: this.impressions,
55-
impressionCounts: this.impressionCounts,
56-
events: this.events,
57-
telemetry: this.telemetry,
58-
uniqueKeys: this.uniqueKeys,
52+
storages[matchingKey] = {
53+
splits: this.splits,
54+
rbSegments: this.rbSegments,
55+
segments,
56+
largeSegments,
57+
impressions: this.impressions,
58+
impressionCounts: this.impressionCounts,
59+
events: this.events,
60+
telemetry: this.telemetry,
61+
uniqueKeys: this.uniqueKeys,
62+
63+
destroy() { }
64+
};
65+
}
5966

60-
destroy() { }
61-
};
67+
return storages[matchingKey];
6268
},
6369
};
6470

@@ -72,9 +78,11 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag
7278
storage.uniqueKeys.track = noopTrack;
7379
}
7480

81+
const matchingKey = getMatching(params.settings.core.key);
82+
storages[matchingKey] = storage;
7583

7684
if (preloadedData) {
77-
loadData(preloadedData, storage, getMatching(params.settings.core.key));
85+
setCache(log, preloadedData, storage, matchingKey);
7886
if (splits.getChangeNumber() > -1) onReadyFromCacheCb();
7987
}
8088

src/storages/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ export interface IStorageBase<
466466
telemetry?: TTelemetryCache,
467467
uniqueKeys: TUniqueKeysCache,
468468
destroy(): void | Promise<void>,
469-
shared?: (matchingKey: string, onReadyCb: (error?: any) => void) => this
469+
shared?: (matchingKey: string, onReadyCb?: (error?: any) => void) => this
470470
}
471471

472472
export interface IStorageSync extends IStorageBase<

types/splitio.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,6 +1603,13 @@ declare namespace SplitIO {
16031603
* @returns The manager instance.
16041604
*/
16051605
manager(): IManager;
1606+
/**
1607+
* Returns the current snapshot of the SDK rollout plan in cache.
1608+
*
1609+
* @param keys - Optional list of keys to generate the rollout plan snapshot with the memberships of the given keys, rather than the complete segments data.
1610+
* @returns The current snapshot of the SDK rollout plan.
1611+
*/
1612+
getCache(keys?: SplitKey[]): PreloadedData,
16061613
}
16071614
/**
16081615
* This represents the interface for the SDK instance for server-side with asynchronous storage.

0 commit comments

Comments
 (0)