Skip to content

Commit ac575d0

Browse files
authored
feat: Optimize segment lookup for large segments. (#235)
1 parent 27e5454 commit ac575d0

File tree

5 files changed

+203
-5
lines changed

5 files changed

+203
-5
lines changed

packages/shared/sdk-server/__tests__/store/serialization.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
deserializePatch,
77
replacer,
88
reviver,
9+
serializeSegment,
910
} from '../../src/store/serialization';
1011

1112
const flagWithAttributeNameInClause = {
@@ -339,3 +340,109 @@ it('given bad json', () => {
339340
expect(deserializePatch(data)).toBeUndefined();
340341
expect(deserializeDelete(data)).toBeUndefined();
341342
});
343+
344+
it('deserialization creates a set for a large number of includes/excludes', () => {
345+
const included = [...Array(500).keys()].map((i) => (i + 1).toString());
346+
const excluded = [...Array(500).keys()].map((i) => (i + 10).toString());
347+
348+
const jsonString = makeSerializedPatchData(undefined, {
349+
key: 'test-segment-1',
350+
included,
351+
excluded,
352+
includedContexts: [],
353+
excludedContexts: [],
354+
salt: 'saltyA',
355+
rules: [],
356+
version: 0,
357+
deleted: false,
358+
});
359+
360+
const res = deserializePatch(jsonString);
361+
const segment = res?.data as Segment;
362+
expect(segment.included).toBeUndefined();
363+
expect(segment.excluded).toBeUndefined();
364+
365+
expect([...segment.generated_includedSet!]).toEqual(included);
366+
expect([...segment.generated_excludedSet!]).toEqual(excluded);
367+
});
368+
369+
it('deserialization creates a set for a large number of included/excluded context values', () => {
370+
const included = [...Array(500).keys()].map((i) => (i + 10).toString());
371+
const excluded = [...Array(500).keys()].map((i) => (i + 1).toString());
372+
373+
const jsonString = makeSerializedPatchData(undefined, {
374+
key: 'test-segment-1',
375+
included: [],
376+
excluded: [],
377+
includedContexts: [{ contextKind: 'org', values: included }],
378+
excludedContexts: [{ contextKind: 'user', values: excluded }],
379+
salt: 'saltyA',
380+
rules: [],
381+
version: 0,
382+
deleted: false,
383+
});
384+
385+
const res = deserializePatch(jsonString);
386+
const segment = res?.data as Segment;
387+
388+
expect([...segment.includedContexts![0].generated_valuesSet!]).toEqual(included);
389+
expect([...segment.excludedContexts![0].generated_valuesSet!]).toEqual(excluded);
390+
});
391+
392+
it('serialization converts sets back to arrays for included/excluded', () => {
393+
const included = [...Array(500).keys()].map((i) => (i + 1).toString());
394+
const excluded = [...Array(500).keys()].map((i) => (i + 10).toString());
395+
396+
const jsonString = makeSerializedPatchData(undefined, {
397+
key: 'test-segment-1',
398+
included,
399+
excluded,
400+
includedContexts: [],
401+
excludedContexts: [],
402+
salt: 'saltyA',
403+
rules: [],
404+
version: 0,
405+
deleted: false,
406+
});
407+
408+
const res = deserializePatch(jsonString);
409+
const segment = res?.data as Segment;
410+
411+
const serializedSegment = serializeSegment(segment);
412+
// Just json parse. We don't want it to automatically re-populate the sets.
413+
const jsonDeserialized = JSON.parse(serializedSegment);
414+
415+
expect(jsonDeserialized.included).toEqual(included);
416+
expect(jsonDeserialized.excluded).toEqual(excluded);
417+
expect(jsonDeserialized.generated_includedSet).toBeUndefined();
418+
expect(jsonDeserialized.generated_excludedSet).toBeUndefined();
419+
});
420+
421+
it('serialization converts sets back to arrays for includedContexts/excludedContexts', () => {
422+
const included = [...Array(500).keys()].map((i) => (i + 1).toString());
423+
const excluded = [...Array(500).keys()].map((i) => (i + 10).toString());
424+
425+
const jsonString = makeSerializedPatchData(undefined, {
426+
key: 'test-segment-1',
427+
included: [],
428+
excluded: [],
429+
includedContexts: [{ contextKind: 'org', values: included }],
430+
excludedContexts: [{ contextKind: 'user', values: excluded }],
431+
salt: 'saltyA',
432+
rules: [],
433+
version: 0,
434+
deleted: false,
435+
});
436+
437+
const res = deserializePatch(jsonString);
438+
const segment = res?.data as Segment;
439+
440+
const serializedSegment = serializeSegment(segment);
441+
// Just json parse. We don't want it to automatically re-populate the sets.
442+
const jsonDeserialized = JSON.parse(serializedSegment);
443+
444+
expect(jsonDeserialized.includedContexts[0].values).toEqual(included);
445+
expect(jsonDeserialized.excludedContexts[0].values).toEqual(excluded);
446+
expect(jsonDeserialized.includedContexts[0].generated_valuesSet).toBeUndefined();
447+
expect(jsonDeserialized.excludedContexts[0].generated_valuesSet).toBeUndefined();
448+
});

packages/shared/sdk-server/src/evaluation/data/Segment.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Versioned } from './Versioned';
77
export interface Segment extends Versioned {
88
included?: string[];
99
excluded?: string[];
10+
1011
includedContexts?: SegmentTarget[];
1112
excludedContexts?: SegmentTarget[];
1213
rules?: SegmentRule[];
@@ -17,4 +18,9 @@ export interface Segment extends Versioned {
1718

1819
// This field is not part of the schema, but it is populated during parsing.
1920
bucketByAttributeReference?: AttributeReference;
21+
22+
// When there are a large number targets for a segment then
23+
// we put them into sets during de-serialization.
24+
generated_includedSet?: Set<string>;
25+
generated_excludedSet?: Set<string>;
2026
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
export interface SegmentTarget {
22
contextKind: string;
33
values: string[];
4+
// When there are a large number of values we put them into a set.
5+
// This set is generated during deserialization, and changed back to a list
6+
// during serialization.
7+
generated_valuesSet?: Set<string>;
48
}

packages/shared/sdk-server/src/evaluation/matchSegmentTargets.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,33 @@ function segmentSearch(
77
context: Context,
88
contextTargets?: SegmentTarget[],
99
userTargets?: string[],
10+
userTargetSet?: Set<string>,
1011
): boolean {
1112
if (contextTargets) {
1213
for (let targetIndex = 0; targetIndex < contextTargets.length; targetIndex += 1) {
1314
const target = contextTargets[targetIndex];
1415
const key = context.key(target.contextKind);
15-
if (key && target.values.includes(key)) {
16-
return true;
16+
if (key) {
17+
if (target.generated_valuesSet) {
18+
// Only check generated_valuesSet if present.
19+
if (target.generated_valuesSet.has(key)) {
20+
return true;
21+
}
22+
} else if (target.values.includes(key)) {
23+
return true;
24+
}
1725
}
1826
}
1927
}
2028

21-
if (userTargets) {
29+
if (userTargetSet) {
30+
const userKey = context.key('user');
31+
if (userKey) {
32+
if (userTargetSet.has(userKey)) {
33+
return true;
34+
}
35+
}
36+
} else if (userTargets) {
2237
const userKey = context.key('user');
2338
if (userKey) {
2439
if (userTargets.includes(userKey)) {
@@ -33,11 +48,21 @@ export default function matchSegmentTargets(
3348
segment: Segment,
3449
context: Context,
3550
): boolean | undefined {
36-
const included = segmentSearch(context, segment.includedContexts, segment.included);
51+
const included = segmentSearch(
52+
context,
53+
segment.includedContexts,
54+
segment.included,
55+
segment.generated_includedSet,
56+
);
3757
if (included) {
3858
return true;
3959
}
40-
const excluded = segmentSearch(context, segment.excludedContexts, segment.excluded);
60+
const excluded = segmentSearch(
61+
context,
62+
segment.excludedContexts,
63+
segment.excluded,
64+
segment.generated_excludedSet,
65+
);
4166
if (excluded) {
4267
// The match was an exclusion, so it should be negated.
4368
return !excluded;

packages/shared/sdk-server/src/store/serialization.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { Rollout } from '../evaluation/data/Rollout';
1010
import { Segment } from '../evaluation/data/Segment';
1111
import VersionedDataKinds, { VersionedDataKind } from './VersionedDataKinds';
1212

13+
// The max size where we use an array instead of a set.
14+
const TARGET_LIST_ARRAY_CUTOFF = 100;
15+
1316
/**
1417
* @internal
1518
*/
@@ -51,6 +54,30 @@ export function replacer(this: any, key: string, value: any): any {
5154
return undefined;
5255
}
5356
}
57+
if (value.generated_includedSet) {
58+
value.included = [...value.generated_includedSet];
59+
delete value.generated_includedSet;
60+
}
61+
if (value.generated_excludedSet) {
62+
value.excluded = [...value.generated_excludedSet];
63+
delete value.generated_excludedSet;
64+
}
65+
if (value.includedContexts) {
66+
value.includedContexts.forEach((target: any) => {
67+
if (target.generated_valuesSet) {
68+
target.values = [...target.generated_valuesSet];
69+
}
70+
delete target.generated_valuesSet;
71+
});
72+
}
73+
if (value.excludedContexts) {
74+
value.excludedContexts.forEach((target: any) => {
75+
if (target.generated_valuesSet) {
76+
target.values = [...target.generated_valuesSet];
77+
}
78+
delete target.generated_valuesSet;
79+
});
80+
}
5481
return value;
5582
}
5683

@@ -104,6 +131,35 @@ export function processFlag(flag: Flag) {
104131
* @internal
105132
*/
106133
export function processSegment(segment: Segment) {
134+
if (segment?.included?.length && segment.included.length > TARGET_LIST_ARRAY_CUTOFF) {
135+
segment.generated_includedSet = new Set(segment.included);
136+
delete segment.included;
137+
}
138+
if (segment?.excluded?.length && segment.excluded.length > TARGET_LIST_ARRAY_CUTOFF) {
139+
segment.generated_excludedSet = new Set(segment.excluded);
140+
delete segment.excluded;
141+
}
142+
143+
if (segment?.includedContexts?.length) {
144+
segment.includedContexts.forEach((target) => {
145+
if (target?.values?.length && target.values.length > TARGET_LIST_ARRAY_CUTOFF) {
146+
target.generated_valuesSet = new Set(target.values);
147+
// Currently typing is non-optional, so we don't delete it.
148+
target.values = [];
149+
}
150+
});
151+
}
152+
153+
if (segment?.excludedContexts?.length) {
154+
segment.excludedContexts.forEach((target) => {
155+
if (target?.values?.length && target.values.length > TARGET_LIST_ARRAY_CUTOFF) {
156+
target.generated_valuesSet = new Set(target.values);
157+
// Currently typing is non-optional, so we don't delete it.
158+
target.values = [];
159+
}
160+
});
161+
}
162+
107163
segment?.rules?.forEach((rule) => {
108164
if (rule.bucketBy) {
109165
// Rules before U2C would have had literals for attributes.

0 commit comments

Comments
 (0)