Skip to content

Commit 7f83681

Browse files
Merge pull request #395 from splitio/rb_segments_matcher
[Rule-based segments] Add rule-based segment matcher
2 parents d4f67a4 + 58747c5 commit 7f83681

File tree

18 files changed

+381
-80
lines changed

18 files changed

+381
-80
lines changed

src/evaluator/matchers/__tests__/dependency.spec.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { IMatcher, IMatcherDto } from '../../types';
55
import { IStorageSync } from '../../../storages/types';
66
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
77
import { ISplit } from '../../../dtos/types';
8-
9-
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;
10-
const ALWAYS_OFF_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-off', 'trafficAllocation': 100, 'trafficAllocationSeed': -331690370, 'seed': 403891040, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'on', 'changeNumber': 1494365020316, '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': 0 }, { 'treatment': 'off', 'size': 100 }], 'label': 'in segment all' }], 'sets':[] } as ISplit;
8+
import { ALWAYS_ON_SPLIT, ALWAYS_OFF_SPLIT } from '../../../storages/__tests__/testUtils';
119

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

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/storages/AbstractSplitsCacheSync.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync {
8080
* Given a parsed split, it returns a boolean flagging if its conditions use segments matchers (rules & whitelists).
8181
* This util is intended to simplify the implementation of `splitsCache::usesSegments` method
8282
*/
83-
export function usesSegments(split: ISplit | IRBSegment) {
84-
const conditions = split.conditions || [];
83+
export function usesSegments(ruleEntity: ISplit | IRBSegment) {
84+
const conditions = ruleEntity.conditions || [];
8585
for (let i = 0; i < conditions.length; i++) {
8686
const matchers = conditions[i].matcherGroup.matchers;
8787

@@ -91,5 +91,8 @@ export function usesSegments(split: ISplit | IRBSegment) {
9191
}
9292
}
9393

94+
const excluded = (ruleEntity as IRBSegment).excluded;
95+
if (excluded && excluded.segments && excluded.segments.length > 0) return true;
96+
9497
return false;
9598
}

src/storages/KeyBuilder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export class KeyBuilder {
3737
return `${this.prefix}.split.`;
3838
}
3939

40-
buildRBSegmentKey(splitName: string) {
41-
return `${this.prefix}.rbsegment.${splitName}`;
40+
buildRBSegmentKey(rbsegmentName: string) {
41+
return `${this.prefix}.rbsegment.${rbsegmentName}`;
4242
}
4343

4444
buildRBSegmentsTillKey() {

0 commit comments

Comments
 (0)