Skip to content

Commit b09adb3

Browse files
authored
feat(flag-evaluation): optimize ANY_OF and NOT_ANY_OFF operators (#413)
Expose a new evaluation interface which lets us optimize the ANY_OF and NOT_ANY_OFF operators. This works through the use of a one-time pre-evaluation step which converts lists used in ANY_OF and NOT_ANY_OFF evaluation to sets for quicker lookups.
1 parent 88128cd commit b09adb3

2 files changed

Lines changed: 161 additions & 2 deletions

File tree

packages/flag-evaluation/src/index.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export interface ContextFilter {
116116
field: string;
117117
operator: ContextFilterOperator;
118118
values?: string[];
119+
valueSet?: Set<string>;
119120
}
120121

121122
/**
@@ -286,6 +287,7 @@ export function evaluate(
286287
fieldValue: string,
287288
operator: ContextFilterOperator,
288289
values: string[],
290+
valueSet?: Set<string>,
289291
): boolean {
290292
const value = values[0];
291293

@@ -331,9 +333,11 @@ export function evaluate(
331333
case "IS_NOT":
332334
return fieldValue !== value;
333335
case "ANY_OF":
334-
return values.includes(fieldValue);
336+
return valueSet ? valueSet.has(fieldValue) : values.includes(fieldValue);
335337
case "NOT_ANY_OF":
336-
return !values.includes(fieldValue);
338+
return valueSet
339+
? !valueSet.has(fieldValue)
340+
: !values.includes(fieldValue);
337341
case "IS_TRUE":
338342
return fieldValue == "true";
339343
case "IS_FALSE":
@@ -362,6 +366,7 @@ function evaluateRecursively(
362366
context[filter.field],
363367
filter.operator,
364368
filter.values || [],
369+
filter.valueSet,
365370
);
366371
case "rolloutPercentage": {
367372
if (!(filter.partialRolloutAttribute in context)) {
@@ -463,3 +468,47 @@ export function evaluateFeatureRules<T extends RuleValue>({
463468
missingContextFields,
464469
};
465470
}
471+
472+
export function newEvaluator<T extends RuleValue>(rules: Rule<T>[]) {
473+
function translateRule(rule: RuleFilter): RuleFilter {
474+
if (rule.type === "group") {
475+
return {
476+
...rule,
477+
filters: rule.filters.map(translateRule),
478+
};
479+
}
480+
481+
if (
482+
rule.type === "context" &&
483+
(rule.operator === "ANY_OF" || rule.operator === "NOT_ANY_OF")
484+
) {
485+
return {
486+
...rule,
487+
valueSet: new Set(rule.values ?? []),
488+
};
489+
}
490+
491+
return { ...rule };
492+
}
493+
494+
const translatedRules = rules.map((rule) => {
495+
const { filter } = rule;
496+
const translatedFilter = translateRule(filter);
497+
498+
return {
499+
...rule,
500+
filter: translatedFilter,
501+
};
502+
});
503+
504+
return function evaluateOptimized(
505+
context: Record<string, unknown>,
506+
featureKey: string,
507+
) {
508+
return evaluateFeatureRules({
509+
context,
510+
featureKey,
511+
rules: translatedRules,
512+
});
513+
};
514+
}

packages/flag-evaluation/test/index.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
EvaluationParams,
77
flattenJSON,
88
hashInt,
9+
newEvaluator,
910
unflattenJSON,
1011
} from "../src";
1112

@@ -239,6 +240,115 @@ describe("evaluate feature targeting integration ", () => {
239240
ruleEvaluationResults: [false],
240241
});
241242
});
243+
244+
it("evaluates optimized rule evaluations correctly", async () => {
245+
const res = newEvaluator([
246+
{
247+
value: true,
248+
filter: {
249+
type: "group",
250+
operator: "and",
251+
filters: [
252+
{
253+
type: "context",
254+
field: "company.id",
255+
operator: "IS",
256+
values: ["company1"],
257+
},
258+
{
259+
type: "rolloutPercentage",
260+
key: "flag",
261+
partialRolloutAttribute: "company.id",
262+
partialRolloutThreshold: 99999,
263+
},
264+
{
265+
type: "group",
266+
operator: "or",
267+
filters: [
268+
{
269+
type: "context",
270+
field: "company.id",
271+
operator: "ANY_OF",
272+
values: ["company2"],
273+
},
274+
{
275+
type: "negation",
276+
filter: {
277+
type: "context",
278+
field: "company.id",
279+
operator: "IS",
280+
values: ["company3"],
281+
},
282+
},
283+
],
284+
},
285+
{
286+
type: "negation",
287+
filter: {
288+
type: "constant",
289+
value: false,
290+
},
291+
},
292+
],
293+
},
294+
},
295+
])(
296+
{
297+
"company.id": "company1",
298+
},
299+
"feature",
300+
);
301+
302+
expect(res).toEqual({
303+
value: true,
304+
context: {
305+
"company.id": "company1",
306+
},
307+
featureKey: "feature",
308+
missingContextFields: [],
309+
reason: "rule #0 matched",
310+
ruleEvaluationResults: [true],
311+
});
312+
});
313+
314+
it.each([
315+
{
316+
context: { "company.id": "company1" },
317+
expected: true,
318+
},
319+
{
320+
context: { "company.id": "company2" },
321+
expected: true,
322+
},
323+
{
324+
context: { "company.id": "company3" },
325+
expected: false,
326+
},
327+
])(
328+
"%#: evaluates optimized rule evaluations correctly",
329+
async ({ context, expected }) => {
330+
const evaluator = newEvaluator([
331+
{
332+
value: true,
333+
filter: {
334+
type: "group",
335+
operator: "and",
336+
filters: [
337+
{
338+
type: "context",
339+
field: "company.id",
340+
operator: "ANY_OF",
341+
values: ["company1", "company2"],
342+
},
343+
],
344+
},
345+
},
346+
]);
347+
348+
const res = evaluator(context, "feature");
349+
expect(res.value ?? false).toEqual(expected);
350+
},
351+
);
242352
});
243353

244354
describe("operator evaluation", () => {

0 commit comments

Comments
 (0)