Skip to content

Commit 51ca496

Browse files
Add rule-based segment matcher
1 parent 9f507fe commit 51ca496

File tree

9 files changed

+323
-11
lines changed

9 files changed

+323
-11
lines changed
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { matcherTypes } from '../matcherTypes';
2+
import { matcherFactory } from '..';
3+
import { evaluateFeature } from '../../index';
4+
import { IMatcherDto } from '../../types';
5+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
6+
import { IRBSegment, ISplit } from '../../../dtos/types';
7+
import { IStorageAsync, IStorageSync } from '../../../storages/types';
8+
import { thenable } from '../../../utils/promise/thenable';
9+
10+
const ALWAYS_ON_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-on', 'trafficAllocation': 100, 'trafficAllocationSeed': 1012950810, 'seed': -725161385, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'off', 'changeNumber': 1494364996459, 'algo': 2, 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': null }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': null, 'whitelistMatcherData': null, 'unaryNumericMatcherData': null, 'betweenMatcherData': null }] }, 'partitions': [{ 'treatment': 'on', 'size': 100 }, { 'treatment': 'off', 'size': 0 }], 'label': 'in segment all' }], 'sets': [] } as ISplit;
11+
12+
const STORED_SPLITS: Record<string, ISplit> = {
13+
'always-on': ALWAYS_ON_SPLIT
14+
};
15+
16+
const STORED_SEGMENTS: Record<string, Set<string>> = {
17+
'segment_test': new Set(['emi@split.io']),
18+
'regular_segment': new Set(['nadia@split.io'])
19+
};
20+
21+
const STORED_RBSEGMENTS: Record<string, IRBSegment> = {
22+
'mauro_rule_based_segment': {
23+
changeNumber: 5,
24+
name: 'mauro_rule_based_segment',
25+
status: 'ACTIVE',
26+
excluded: {
27+
keys: ['mauro@split.io', 'gaston@split.io'],
28+
segments: ['segment_test']
29+
},
30+
conditions: [
31+
{
32+
matcherGroup: {
33+
combiner: 'AND',
34+
matchers: [
35+
{
36+
keySelector: {
37+
trafficType: 'user',
38+
attribute: 'location',
39+
},
40+
matcherType: 'WHITELIST',
41+
negate: false,
42+
whitelistMatcherData: {
43+
whitelist: [
44+
'mdp',
45+
'tandil',
46+
'bsas'
47+
]
48+
}
49+
},
50+
{
51+
keySelector: {
52+
trafficType: 'user',
53+
attribute: null
54+
},
55+
matcherType: 'ENDS_WITH',
56+
negate: false,
57+
whitelistMatcherData: {
58+
whitelist: [
59+
'@split.io'
60+
]
61+
}
62+
}
63+
]
64+
}
65+
},
66+
{
67+
matcherGroup: {
68+
combiner: 'AND',
69+
matchers: [
70+
{
71+
keySelector: {
72+
trafficType: 'user',
73+
attribute: null
74+
},
75+
matcherType: 'IN_SEGMENT',
76+
negate: false,
77+
userDefinedSegmentMatcherData: {
78+
segmentName: 'regular_segment'
79+
}
80+
}
81+
]
82+
}
83+
}
84+
]
85+
},
86+
'depend_on_always_on': {
87+
name: 'depend_on_always_on',
88+
changeNumber: 123,
89+
status: 'ACTIVE',
90+
excluded: {
91+
keys: [],
92+
segments: []
93+
},
94+
conditions: [{
95+
matcherGroup: {
96+
combiner: 'AND',
97+
matchers: [{
98+
matcherType: 'IN_SPLIT_TREATMENT',
99+
keySelector: {
100+
trafficType: 'user',
101+
attribute: null
102+
},
103+
negate: false,
104+
dependencyMatcherData: {
105+
split: 'always-on',
106+
treatments: [
107+
'on',
108+
]
109+
}
110+
}]
111+
}
112+
}]
113+
},
114+
'depend_on_mauro_rule_based_segment': {
115+
name: 'depend_on_mauro_rule_based_segment',
116+
changeNumber: 123,
117+
status: 'ACTIVE',
118+
excluded: {
119+
keys: [],
120+
segments: []
121+
},
122+
conditions: [{
123+
matcherGroup: {
124+
combiner: 'AND',
125+
matchers: [{
126+
matcherType: 'IN_RULE_BASED_SEGMENT',
127+
keySelector: {
128+
trafficType: 'user',
129+
attribute: null
130+
},
131+
negate: false,
132+
userDefinedSegmentMatcherData: {
133+
segmentName: 'mauro_rule_based_segment'
134+
}
135+
}]
136+
}
137+
}]
138+
},
139+
};
140+
141+
const mockStorageSync = {
142+
isSync: true,
143+
splits: {
144+
getSplit(name: string) {
145+
return STORED_SPLITS[name];
146+
}
147+
},
148+
segments: {
149+
isInSegment(segmentName: string, matchingKey: string) {
150+
return STORED_SEGMENTS[segmentName] ? STORED_SEGMENTS[segmentName].has(matchingKey) : false;
151+
}
152+
},
153+
rbSegments: {
154+
get(rbsegmentName: string) {
155+
return STORED_RBSEGMENTS[rbsegmentName];
156+
}
157+
}
158+
} as unknown as IStorageSync;
159+
160+
const mockStorageAsync = {
161+
isSync: false,
162+
splits: {
163+
getSplit(name: string) {
164+
return Promise.resolve(STORED_SPLITS[name]);
165+
}
166+
},
167+
segments: {
168+
isInSegment(segmentName: string, matchingKey: string) {
169+
return Promise.resolve(STORED_SEGMENTS[segmentName] ? STORED_SEGMENTS[segmentName].has(matchingKey) : false);
170+
}
171+
},
172+
rbSegments: {
173+
get(rbsegmentName: string) {
174+
return Promise.resolve(STORED_RBSEGMENTS[rbsegmentName]);
175+
}
176+
}
177+
} as unknown as IStorageAsync;
178+
179+
describe.each([
180+
{ mockStorage: mockStorageSync, isAsync: false },
181+
{ mockStorage: mockStorageAsync, isAsync: true }
182+
])('MATCHER IN_RULE_BASED_SEGMENT', ({ mockStorage, isAsync }) => {
183+
test('should support excluded keys, excluded segments, and multiple conditions', async () => {
184+
const matcher = matcherFactory(loggerMock, {
185+
type: matcherTypes.IN_RULE_BASED_SEGMENT,
186+
value: 'mauro_rule_based_segment'
187+
} as IMatcherDto, mockStorage)!;
188+
189+
const dependentMatcher = matcherFactory(loggerMock, {
190+
type: matcherTypes.IN_RULE_BASED_SEGMENT,
191+
value: 'depend_on_mauro_rule_based_segment'
192+
} as IMatcherDto, mockStorage)!;
193+
194+
[matcher, dependentMatcher].forEach(async matcher => {
195+
196+
// should return false if the provided key is excluded (even if some condition is met)
197+
let match = matcher({ key: 'mauro@split.io', attributes: { location: 'mdp' } }, evaluateFeature);
198+
expect(thenable(match)).toBe(isAsync);
199+
expect(await match).toBe(false);
200+
201+
// should return false if the provided key is in some excluded segment (even if some condition is met)
202+
match = matcher({ key: 'emi@split.io', attributes: { location: 'tandil' } }, evaluateFeature);
203+
expect(thenable(match)).toBe(isAsync);
204+
expect(await match).toBe(false);
205+
206+
// should return false if doesn't match any condition
207+
match = matcher({ key: 'zeta@split.io' }, evaluateFeature);
208+
expect(thenable(match)).toBe(isAsync);
209+
expect(await match).toBe(false);
210+
match = matcher({ key: { matchingKey: 'zeta@split.io', bucketingKey: '123' }, attributes: { location: 'italy' } }, evaluateFeature);
211+
expect(thenable(match)).toBe(isAsync);
212+
expect(await match).toBe(false);
213+
214+
// should return true if match the first condition: location attribute in whitelist and key ends with '@split.io'
215+
match = matcher({ key: 'emma@split.io', attributes: { location: 'tandil' } }, evaluateFeature);
216+
expect(thenable(match)).toBe(isAsync);
217+
expect(await match).toBe(true);
218+
219+
// should return true if match the second condition: key in regular_segment
220+
match = matcher({ key: { matchingKey: 'nadia@split.io', bucketingKey: '123' }, attributes: { location: 'mdp' } }, evaluateFeature);
221+
expect(thenable(match)).toBe(isAsync);
222+
expect(await match).toBe(true);
223+
});
224+
});
225+
226+
test('edge cases', async () => {
227+
const matcherNotExist = matcherFactory(loggerMock, {
228+
type: matcherTypes.IN_RULE_BASED_SEGMENT,
229+
value: 'non_existent_segment'
230+
} as IMatcherDto, mockStorageSync)!;
231+
232+
// should return false if the provided segment does not exist
233+
expect(await matcherNotExist({ key: 'a-key' }, evaluateFeature)).toBe(false);
234+
235+
const matcherTrueAlwaysOn = matcherFactory(loggerMock, {
236+
type: matcherTypes.IN_RULE_BASED_SEGMENT,
237+
value: 'depend_on_always_on'
238+
} as IMatcherDto, mockStorageSync)!;
239+
240+
// should support feature flag dependency matcher
241+
expect(await matcherTrueAlwaysOn({ key: 'a-key' }, evaluateFeature)).toBe(true); // Parent split returns one of the expected treatments, so the matcher returns true
242+
});
243+
244+
});

src/evaluator/matchers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { inListSemverMatcherContext } from './semver_inlist';
2424
import { IStorageAsync, IStorageSync } from '../../storages/types';
2525
import { IMatcher, IMatcherDto } from '../types';
2626
import { ILogger } from '../../logger/types';
27+
import { ruleBasedSegmentMatcherContext } from './rbsegment';
2728

2829
const matchers = [
2930
undefined, // UNDEFINED: 0
@@ -50,6 +51,7 @@ const matchers = [
5051
betweenSemverMatcherContext, // BETWEEN_SEMVER: 21
5152
inListSemverMatcherContext, // IN_LIST_SEMVER: 22
5253
largeSegmentMatcherContext, // IN_LARGE_SEGMENT: 23
54+
ruleBasedSegmentMatcherContext // IN_RULE_BASED_SEGMENT: 24
5355
];
5456

5557
/**

src/evaluator/matchers/matcherTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const matcherTypes: Record<string, number> = {
2323
BETWEEN_SEMVER: 21,
2424
IN_LIST_SEMVER: 22,
2525
IN_LARGE_SEGMENT: 23,
26+
IN_RULE_BASED_SEGMENT: 24,
2627
};
2728

2829
export const matcherDataTypes = {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { IRBSegment, MaybeThenable } from '../../dtos/types';
2+
import { IStorageAsync, IStorageSync } from '../../storages/types';
3+
import { ILogger } from '../../logger/types';
4+
import { IDependencyMatcherValue, ISplitEvaluator } from '../types';
5+
import { thenable } from '../../utils/promise/thenable';
6+
import { getMatching, keyParser } from '../../utils/key';
7+
import { parser } from '../parser';
8+
9+
10+
export function ruleBasedSegmentMatcherContext(segmentName: string, storage: IStorageSync | IStorageAsync, log: ILogger) {
11+
12+
return function ruleBasedSegmentMatcher({ key, attributes }: IDependencyMatcherValue, splitEvaluator: ISplitEvaluator): MaybeThenable<boolean> {
13+
14+
function matchConditions(rbsegment: IRBSegment) {
15+
const conditions = rbsegment.conditions;
16+
const evaluator = parser(log, conditions, storage);
17+
18+
const evaluation = evaluator(
19+
keyParser(key),
20+
undefined,
21+
undefined,
22+
undefined,
23+
attributes,
24+
splitEvaluator
25+
);
26+
27+
return thenable(evaluation) ?
28+
evaluation.then(evaluation => evaluation ? true : false) :
29+
evaluation ? true : false;
30+
}
31+
32+
function isExcluded(rbSegment: IRBSegment) {
33+
const matchingKey = getMatching(key);
34+
35+
if (rbSegment.excluded.keys.indexOf(matchingKey) !== -1) return true;
36+
37+
const isInSegment = rbSegment.excluded.segments.map(segmentName => {
38+
return storage.segments.isInSegment(segmentName, matchingKey);
39+
});
40+
41+
return isInSegment.length && thenable(isInSegment[0]) ?
42+
Promise.all(isInSegment).then(results => results.some(result => result)) :
43+
isInSegment.some(result => result);
44+
}
45+
46+
function isInSegment(rbSegment: IRBSegment | null) {
47+
if (!rbSegment) return false;
48+
const excluded = isExcluded(rbSegment);
49+
50+
return thenable(excluded) ?
51+
excluded.then(excluded => excluded ? false : matchConditions(rbSegment)) :
52+
excluded ? false : matchConditions(rbSegment);
53+
}
54+
55+
const rbSegment = storage.rbSegments.get(segmentName);
56+
57+
return thenable(rbSegment) ?
58+
rbSegment.then(isInSegment) :
59+
isInSegment(rbSegment);
60+
};
61+
}

src/evaluator/matchersTransform/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export function matchersTransform(matchers: ISplitMatcher[]): IMatcherDto[] {
9595
type === matcherTypes.LESS_THAN_OR_EQUAL_TO_SEMVER
9696
) {
9797
value = stringMatcherData;
98+
} else if (type === matcherTypes.IN_RULE_BASED_SEGMENT) {
99+
value = segmentTransform(userDefinedSegmentMatcherData as IInSegmentMatcherData);
100+
dataType = matcherDataTypes.NOT_SPECIFIED;
98101
}
99102

100103
return {

src/evaluator/value/sanitize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function getProcessingFunction(matcherTypeID: number, dataType: string) {
6060
case matcherTypes.BETWEEN:
6161
return dataType === 'DATETIME' ? zeroSinceSS : undefined;
6262
case matcherTypes.IN_SPLIT_TREATMENT:
63+
case matcherTypes.IN_RULE_BASED_SEGMENT:
6364
return dependencyProcessor;
6465
default:
6566
return undefined;

src/sync/polling/pollingManagerCS.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ export function pollingManagerCSFactory(
4343
// smart pausing
4444
readiness.splits.on(SDK_SPLITS_ARRIVED, () => {
4545
if (!splitsSyncTask.isRunning()) return; // noop if not doing polling
46-
const splitsHaveSegments = storage.splits.usesSegments();
47-
if (splitsHaveSegments !== mySegmentsSyncTask.isRunning()) {
48-
log.info(POLLING_SMART_PAUSING, [splitsHaveSegments ? 'ON' : 'OFF']);
49-
if (splitsHaveSegments) {
46+
const usingSegments = storage.splits.usesSegments() || storage.rbSegments.usesSegments();
47+
if (usingSegments !== mySegmentsSyncTask.isRunning()) {
48+
log.info(POLLING_SMART_PAUSING, [usingSegments ? 'ON' : 'OFF']);
49+
if (usingSegments) {
5050
startMySegmentsSyncTasks();
5151
} else {
5252
stopMySegmentsSyncTasks();
@@ -59,9 +59,9 @@ export function pollingManagerCSFactory(
5959

6060
// smart ready
6161
function smartReady() {
62-
if (!readiness.isReady() && !storage.splits.usesSegments()) readiness.segments.emit(SDK_SEGMENTS_ARRIVED);
62+
if (!readiness.isReady() && !storage.splits.usesSegments() && !storage.rbSegments.usesSegments()) readiness.segments.emit(SDK_SEGMENTS_ARRIVED);
6363
}
64-
if (!storage.splits.usesSegments()) setTimeout(smartReady, 0);
64+
if (!storage.splits.usesSegments() && !storage.rbSegments.usesSegments()) setTimeout(smartReady, 0);
6565
else readiness.splits.once(SDK_SPLITS_ARRIVED, smartReady);
6666

6767
mySegmentsSyncTasks[matchingKey] = mySegmentsSyncTask;
@@ -77,7 +77,7 @@ export function pollingManagerCSFactory(
7777
log.info(POLLING_START);
7878

7979
splitsSyncTask.start();
80-
if (storage.splits.usesSegments()) startMySegmentsSyncTasks();
80+
if (storage.splits.usesSegments() || storage.rbSegments.usesSegments()) startMySegmentsSyncTasks();
8181
},
8282

8383
// Stop periodic fetching (polling)

0 commit comments

Comments
 (0)