Skip to content

Commit 5b84df3

Browse files
refactor: restructure rollout plan data format and improve data loading
1 parent a95edb9 commit 5b84df3

File tree

4 files changed

+142
-77
lines changed

4 files changed

+142
-77
lines changed

src/dtos/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export interface ISegmentChangesResponse {
259259
name: string,
260260
added: string[],
261261
removed: string[],
262-
since: number,
262+
since?: number,
263263
till: number
264264
}
265265

src/storages/__tests__/dataLoader.spec.ts

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { IRBSegment, ISplit } from '../../dtos/types';
66

77
import * as dataLoader from '../dataLoader';
88

9-
describe('setRolloutPlan & getRolloutPlan', () => {
9+
describe('getRolloutPlan & setRolloutPlan (client-side)', () => {
1010
jest.spyOn(dataLoader, 'setRolloutPlan');
1111
const onReadyFromCacheCb = jest.fn();
1212
const onReadyCb = jest.fn();
@@ -19,15 +19,52 @@ describe('setRolloutPlan & getRolloutPlan', () => {
1919
serverStorage.rbSegments.update([{ name: 'rbs1' } as IRBSegment], [], 321);
2020
serverStorage.segments.update('segment1', [fullSettings.core.key as string, otherKey], [], 123);
2121

22+
const expectedRolloutPlan = {
23+
splitChanges: {
24+
ff: { d: [{ name: 'split1' }], t: 123 },
25+
rbs: { d: [{ name: 'rbs1' }], t: 321 }
26+
},
27+
memberships: {
28+
[fullSettings.core.key as string]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } },
29+
[otherKey]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } }
30+
},
31+
segmentChanges: [{
32+
name: 'segment1',
33+
added: [fullSettings.core.key as string, otherKey],
34+
removed: [],
35+
till: 123
36+
}]
37+
};
38+
2239
afterEach(() => {
2340
jest.clearAllMocks();
2441
});
2542

43+
test('using preloaded data (no memberships, no segments)', () => {
44+
const rolloutPlan = dataLoader.getRolloutPlan(loggerMock, serverStorage);
45+
46+
// Load client-side storage with preloaded data
47+
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, initialRolloutPlan: rolloutPlan }, onReadyFromCacheCb, onReadyCb });
48+
expect(dataLoader.setRolloutPlan).toBeCalledTimes(1);
49+
expect(onReadyFromCacheCb).toBeCalledTimes(1);
50+
51+
// Shared client storage
52+
const sharedClientStorage = clientStorage.shared!(otherKey);
53+
expect(dataLoader.setRolloutPlan).toBeCalledTimes(2);
54+
55+
expect(clientStorage.segments.getRegisteredSegments()).toEqual([]);
56+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual([]);
57+
58+
// Get preloaded data from client-side storage
59+
expect(dataLoader.getRolloutPlan(loggerMock, clientStorage)).toEqual(rolloutPlan);
60+
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined, segmentChanges: undefined });
61+
});
62+
2663
test('using preloaded data with memberships', () => {
27-
const rolloutPlanData = dataLoader.getRolloutPlan(loggerMock, serverStorage, [fullSettings.core.key as string, otherKey]);
64+
const rolloutPlan = dataLoader.getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string, otherKey] });
2865

2966
// Load client-side storage with preloaded data
30-
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, initialRolloutPlan: rolloutPlanData }, onReadyFromCacheCb, onReadyCb });
67+
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, initialRolloutPlan: rolloutPlan }, onReadyFromCacheCb, onReadyCb });
3168
expect(dataLoader.setRolloutPlan).toBeCalledTimes(1);
3269
expect(onReadyFromCacheCb).toBeCalledTimes(1);
3370

@@ -39,43 +76,43 @@ describe('setRolloutPlan & getRolloutPlan', () => {
3976
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
4077

4178
// Get preloaded data from client-side storage
42-
expect(dataLoader.getRolloutPlan(loggerMock, clientStorage, [fullSettings.core.key as string, otherKey])).toEqual(rolloutPlanData);
43-
expect(rolloutPlanData).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-
});
79+
expect(dataLoader.getRolloutPlan(loggerMock, clientStorage, { keys: [fullSettings.core.key as string, otherKey] })).toEqual(rolloutPlan);
80+
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, segmentChanges: undefined });
5481
});
5582

5683
test('using preloaded data with segments', () => {
57-
const rolloutPlanData = dataLoader.getRolloutPlan(loggerMock, serverStorage);
84+
const rolloutPlan = dataLoader.getRolloutPlan(loggerMock, serverStorage, { exposeSegments: true });
5885

5986
// Load client-side storage with preloaded data
60-
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, initialRolloutPlan: rolloutPlanData }, onReadyFromCacheCb, onReadyCb });
87+
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, initialRolloutPlan: rolloutPlan }, onReadyFromCacheCb, onReadyCb });
6188
expect(dataLoader.setRolloutPlan).toBeCalledTimes(1);
6289
expect(onReadyFromCacheCb).toBeCalledTimes(1);
6390

6491
// Shared client storage
6592
const sharedClientStorage = clientStorage.shared!(otherKey);
6693
expect(dataLoader.setRolloutPlan).toBeCalledTimes(2);
94+
6795
expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
6896
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
6997

70-
expect(rolloutPlanData).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-
});
98+
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined });
99+
});
100+
101+
test('using preloaded data with memberships and segments', () => {
102+
const rolloutPlan = dataLoader.getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string], exposeSegments: true });
103+
104+
// Load client-side storage with preloaded data
105+
const clientStorage = InMemoryStorageCSFactory({ settings: { ...fullSettings, initialRolloutPlan: rolloutPlan }, onReadyFromCacheCb, onReadyCb });
106+
expect(dataLoader.setRolloutPlan).toBeCalledTimes(1);
107+
expect(onReadyFromCacheCb).toBeCalledTimes(1);
108+
109+
// Shared client storage
110+
const sharedClientStorage = clientStorage.shared!(otherKey);
111+
expect(dataLoader.setRolloutPlan).toBeCalledTimes(2);
112+
113+
expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // main client membership is set via the rollout plan `memberships` field
114+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // shared client membership is set via the rollout plan `segmentChanges` field
115+
116+
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: { [fullSettings.core.key as string]: expectedRolloutPlan.memberships![fullSettings.core.key as string] } });
80117
});
81118
});

src/storages/dataLoader.ts

Lines changed: 59 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,74 +2,73 @@ import SplitIO from '../../types/splitio';
22
import { IRBSegmentsCacheSync, ISegmentsCacheSync, ISplitsCacheSync, IStorageSync } from './types';
33
import { setToArray } from '../utils/lang/sets';
44
import { getMatching } from '../utils/key';
5-
import { IMembershipsResponse, IMySegmentsResponse, IRBSegment, ISplit } from '../dtos/types';
5+
import { IMembershipsResponse, IMySegmentsResponse, ISegmentChangesResponse, ISplitChangesResponse } from '../dtos/types';
66
import { ILogger } from '../logger/types';
7+
import { isObject } from '../utils/lang';
78

89
export type RolloutPlan = {
910
/**
10-
* Change number of feature flags.
11+
* Feature flags and rule-based segments.
1112
*/
12-
since: number;
13+
splitChanges: ISplitChangesResponse;
1314
/**
14-
* List of feature flags.
15-
*/
16-
flags: ISplit[];
17-
/**
18-
* Change number of rule-based segments.
19-
*/
20-
rbSince?: number;
21-
/**
22-
* List of rule-based segments.
23-
*/
24-
rbSegments?: IRBSegment[];
25-
/**
26-
* Optional map of user keys to their memberships.
15+
* Optional map of matching keys to their memberships.
2716
*/
2817
memberships?: {
29-
[key: string]: IMembershipsResponse;
18+
[matchingKey: string]: IMembershipsResponse;
3019
};
3120
/**
32-
* Optional map of standard segments to their list of keys.
21+
* Optional list of standard segments.
3322
* This property is ignored if `memberships` is provided.
3423
*/
35-
segments?: {
36-
[segmentName: string]: string[];
37-
};
24+
segmentChanges?: ISegmentChangesResponse[];
3825
};
3926

27+
/**
28+
* Validates if the given rollout plan is valid.
29+
*/
30+
function validateRolloutPlan(rolloutPlan: unknown): rolloutPlan is RolloutPlan {
31+
if (isObject(rolloutPlan) && isObject((rolloutPlan as any).splitChanges)) return true;
32+
33+
return false;
34+
}
35+
4036
/**
4137
* Sets the given synchronous storage with the provided rollout plan snapshot.
4238
* If `matchingKey` is provided, the storage is handled as a client-side storage (segments and largeSegments are instances of MySegmentsCache).
4339
* Otherwise, the storage is handled as a server-side storage (segments is an instance of SegmentsCache).
4440
*/
4541
export function setRolloutPlan(log: ILogger, rolloutPlan: RolloutPlan, storage: { splits?: ISplitsCacheSync, rbSegments?: IRBSegmentsCacheSync, segments: ISegmentsCacheSync, largeSegments?: ISegmentsCacheSync }, matchingKey?: string) {
4642
// Do not load data if current rollout plan is empty
47-
if (Object.keys(rolloutPlan).length === 0) return;
43+
if (!validateRolloutPlan(rolloutPlan)) {
44+
log.error('storage: invalid rollout plan provided');
45+
return;
46+
}
4847

4948
const { splits, rbSegments, segments, largeSegments } = storage;
49+
const { splitChanges: { ff, rbs } } = rolloutPlan;
5050

5151
log.debug(`storage: set feature flags and segments${matchingKey ? ` for key ${matchingKey}` : ''}`);
5252

53-
if (splits) {
53+
if (splits && ff) {
5454
splits.clear();
55-
splits.update(rolloutPlan.flags || [], [], rolloutPlan.since || -1);
55+
splits.update(ff.d, [], ff.t);
5656
}
5757

58-
if (rbSegments) {
58+
if (rbSegments && rbs) {
5959
rbSegments.clear();
60-
rbSegments.update(rolloutPlan.rbSegments || [], [], rolloutPlan.rbSince || -1);
60+
rbSegments.update(rbs.d, [], rbs.t);
6161
}
6262

63-
const segmentsData = rolloutPlan.segments || {};
63+
const segmentChanges = rolloutPlan.segmentChanges;
6464
if (matchingKey) { // add memberships data (client-side)
6565
let memberships = rolloutPlan.memberships && rolloutPlan.memberships[matchingKey];
66-
if (!memberships && segmentsData) {
66+
if (!memberships && segmentChanges) {
6767
memberships = {
6868
ms: {
69-
k: Object.keys(segmentsData).filter(segmentName => {
70-
const segmentKeys = segmentsData[segmentName];
71-
return segmentKeys.indexOf(matchingKey) > -1;
72-
}).map(segmentName => ({ n: segmentName }))
69+
k: segmentChanges.filter(segment => {
70+
return segment.added.indexOf(matchingKey) > -1;
71+
}).map(segment => ({ n: segment.name }))
7372
}
7473
};
7574
}
@@ -79,10 +78,11 @@ export function setRolloutPlan(log: ILogger, rolloutPlan: RolloutPlan, storage:
7978
if (memberships.ls && largeSegments) largeSegments.resetSegments(memberships.ls!);
8079
}
8180
} else { // add segments data (server-side)
82-
Object.keys(segmentsData).forEach(segmentName => {
83-
const segmentKeys = segmentsData[segmentName];
84-
segments.update(segmentName, segmentKeys, [], -1);
85-
});
81+
if (segmentChanges) {
82+
segmentChanges.forEach(segment => {
83+
segments.update(segment.name, segment.added, segment.removed, segment.till);
84+
});
85+
}
8686
}
8787
}
8888

@@ -91,21 +91,32 @@ export function setRolloutPlan(log: ILogger, rolloutPlan: RolloutPlan, storage:
9191
* If `keys` are provided, the memberships for those keys is returned, to protect segments data.
9292
* Otherwise, the segments data is returned.
9393
*/
94-
export function getRolloutPlan(log: ILogger, storage: IStorageSync, keys?: SplitIO.SplitKey[]): RolloutPlan {
94+
export function getRolloutPlan(log: ILogger, storage: IStorageSync, options: SplitIO.RolloutPlanOptions = {}): RolloutPlan {
9595

96-
log.debug(`storage: get feature flags and segments${keys ? ` for keys ${keys}` : ''}`);
96+
const { keys, exposeSegments } = options;
97+
const { splits, segments, rbSegments } = storage;
98+
99+
log.debug(`storage: get feature flags${keys ? `, and memberships for keys ${keys}` : ''}${exposeSegments ? ', and segments' : ''}`);
97100

98101
return {
99-
since: storage.splits.getChangeNumber(),
100-
flags: storage.splits.getAll(),
101-
rbSince: storage.rbSegments.getChangeNumber(),
102-
rbSegments: storage.rbSegments.getAll(),
103-
segments: keys ?
104-
undefined : // @ts-ignore accessing private prop
105-
Object.keys(storage.segments.segmentCache).reduce((prev, cur) => { // @ts-ignore accessing private prop
106-
prev[cur] = setToArray(storage.segments.segmentCache[cur] as Set<string>);
107-
return prev;
108-
}, {}),
102+
splitChanges: {
103+
ff: {
104+
t: splits.getChangeNumber(),
105+
d: splits.getAll(),
106+
},
107+
rbs: {
108+
t: rbSegments.getChangeNumber(),
109+
d: rbSegments.getAll(),
110+
}
111+
},
112+
segmentChanges: exposeSegments ? // @ts-ignore accessing private prop
113+
Object.keys(segments.segmentCache).map(segmentName => ({
114+
name: segmentName, // @ts-ignore
115+
added: setToArray(segments.segmentCache[segmentName] as Set<string>),
116+
removed: [],
117+
till: segments.getChangeNumber(segmentName)!
118+
})) :
119+
undefined,
109120
memberships: keys ?
110121
keys.reduce<Record<string, IMembershipsResponse>>((prev, key) => {
111122
if (storage.shared) {

types/splitio.d.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,23 @@ declare namespace SplitIO {
10311031
* A JSON-serializable plain object that defines the format of rollout plan data to preload the SDK cache with feature flags and segments.
10321032
*/
10331033
type RolloutPlan = Object;
1034+
/**
1035+
* Options for the `factory.getRolloutPlan` method.
1036+
*/
1037+
type RolloutPlanOptions = {
1038+
/**
1039+
* Optional list of keys to generate the rollout plan snapshot with the memberships of the given keys.
1040+
*
1041+
* @defaultValue `undefined`
1042+
*/
1043+
keys?: SplitKey[];
1044+
/**
1045+
* Optional flag to expose segments data in the rollout plan snapshot.
1046+
*
1047+
* @defaultValue `false`
1048+
*/
1049+
exposeSegments?: boolean;
1050+
};
10341051
/**
10351052
* Impression listener interface. This is the interface that needs to be implemented
10361053
* by the element you provide to the SDK as impression listener.
@@ -1580,7 +1597,7 @@ declare namespace SplitIO {
15801597
* @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.
15811598
* @returns The current snapshot of the SDK rollout plan.
15821599
*/
1583-
getRolloutPlan(keys?: SplitKey[]): RolloutPlan;
1600+
getRolloutPlan(options?: RolloutPlanOptions): RolloutPlan;
15841601
}
15851602
/**
15861603
* This represents the interface for the SDK instance for server-side with asynchronous storage.

0 commit comments

Comments
 (0)