Skip to content

Commit 2c3074f

Browse files
authored
feat(node-sdk): use optimized flag evaluation (#442)
Use the new facility from the `flag-evaluation` package to ensure low CPU even with huge lists of targets.
1 parent b4ad5e1 commit 2c3074f

File tree

3 files changed

+93
-91
lines changed

3 files changed

+93
-91
lines changed

packages/node-sdk/src/client.ts

Lines changed: 80 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import fs from "fs";
22

3-
import { evaluateFeatureRules, flattenJSON } from "@bucketco/flag-evaluation";
3+
import {
4+
EvaluationResult,
5+
flattenJSON,
6+
newEvaluator,
7+
} from "@bucketco/flag-evaluation";
48

59
import BatchBuffer from "./batch-buffer";
610
import {
@@ -19,15 +23,14 @@ import inRequestCache from "./inRequestCache";
1923
import periodicallyUpdatingCache from "./periodicallyUpdatingCache";
2024
import { newRateLimiter } from "./rate-limiter";
2125
import type {
26+
CachedFeatureDefinition,
2227
CacheStrategy,
2328
EvaluatedFeaturesAPIResponse,
24-
FeatureAPIResponse,
2529
FeatureDefinition,
2630
FeatureOverrides,
2731
FeatureOverridesFn,
2832
IdType,
2933
RawFeature,
30-
RawFeatureRemoteConfig,
3134
} from "./types";
3235
import {
3336
Attributes,
@@ -127,7 +130,7 @@ export class BucketClient {
127130
};
128131
httpClient: HttpClient;
129132

130-
private featuresCache: Cache<FeaturesAPIResponse>;
133+
private featuresCache: Cache<CachedFeatureDefinition[]>;
131134
private batchBuffer: BatchBuffer<BulkEvent>;
132135
private rateLimiter: ReturnType<typeof newRateLimiter>;
133136

@@ -334,18 +337,40 @@ export class BucketClient {
334337
this.logger.warn("features cache: invalid response", res);
335338
return undefined;
336339
}
337-
return res;
340+
341+
return res.features.map((featureDef) => {
342+
return {
343+
...featureDef,
344+
enabledEvaluator: newEvaluator(
345+
featureDef.targeting.rules.map((rule) => ({
346+
filter: rule.filter,
347+
value: true,
348+
})),
349+
),
350+
configEvaluator: featureDef.config
351+
? newEvaluator(
352+
featureDef.config?.variants.map((variant) => ({
353+
filter: variant.filter,
354+
value: {
355+
key: variant.key,
356+
payload: variant.payload,
357+
},
358+
})),
359+
)
360+
: undefined,
361+
} satisfies CachedFeatureDefinition;
362+
});
338363
};
339364

340365
if (this._config.cacheStrategy === "periodically-update") {
341-
this.featuresCache = periodicallyUpdatingCache<FeaturesAPIResponse>(
366+
this.featuresCache = periodicallyUpdatingCache<CachedFeatureDefinition[]>(
342367
this._config.refetchInterval,
343368
this._config.staleWarningInterval,
344369
this.logger,
345370
fetchFeatures,
346371
);
347372
} else {
348-
this.featuresCache = inRequestCache<FeaturesAPIResponse>(
373+
this.featuresCache = inRequestCache<CachedFeatureDefinition[]>(
349374
this._config.refetchInterval,
350375
this.logger,
351376
fetchFeatures,
@@ -582,7 +607,7 @@ export class BucketClient {
582607
* @returns The features definitions.
583608
*/
584609
public async getFeatureDefinitions(): Promise<FeatureDefinition[]> {
585-
const features = this.featuresCache.get()?.features || [];
610+
const features = this.featuresCache.get() || [];
586611
return features.map((f) => ({
587612
key: f.key,
588613
description: f.description,
@@ -1022,72 +1047,46 @@ export class BucketClient {
10221047
}
10231048

10241049
void this.syncContext(options);
1025-
let featureDefinitions: FeaturesAPIResponse["features"];
1050+
let featureDefinitions: CachedFeatureDefinition[] = [];
10261051

1027-
if (this._config.offline) {
1028-
featureDefinitions = [];
1029-
} else {
1030-
const fetchedFeatures = this.featuresCache.get();
1031-
if (!fetchedFeatures) {
1052+
if (!this._config.offline) {
1053+
const featureDefs = this.featuresCache.get();
1054+
if (!featureDefs) {
10321055
this.logger.warn(
10331056
"no feature definitions available, using fallback features.",
10341057
);
10351058
return this._config.fallbackFeatures || {};
10361059
}
1037-
1038-
featureDefinitions = fetchedFeatures.features;
1060+
featureDefinitions = featureDefs;
10391061
}
10401062

1041-
const featureMap = featureDefinitions.reduce(
1042-
(acc, f) => {
1043-
acc[f.key] = f;
1044-
return acc;
1045-
},
1046-
{} as Record<string, FeatureAPIResponse>,
1047-
);
1048-
10491063
const { enableTracking = true, meta: _, ...context } = options;
10501064

1051-
const evaluated = featureDefinitions.map((feature) =>
1052-
evaluateFeatureRules({
1053-
featureKey: feature.key,
1054-
rules: feature.targeting.rules.map((r) => ({ ...r, value: true })),
1055-
context,
1056-
}),
1057-
);
1058-
1059-
const evaluatedConfigs = evaluated.reduce(
1060-
(acc, { featureKey }) => {
1061-
const feature = featureMap[featureKey];
1062-
if (feature.config) {
1063-
const variant = evaluateFeatureRules({
1064-
featureKey,
1065-
rules: feature.config.variants.map(({ filter, ...rest }) => ({
1066-
filter,
1067-
value: rest,
1068-
})),
1069-
context,
1070-
});
1071-
1072-
if (variant.value) {
1073-
acc[featureKey] = {
1074-
...variant.value,
1075-
targetingVersion: feature.config.version,
1076-
ruleEvaluationResults: variant.ruleEvaluationResults,
1077-
missingContextFields: variant.missingContextFields,
1078-
};
1079-
}
1080-
}
1081-
return acc;
1082-
},
1083-
{} as Record<string, RawFeatureRemoteConfig>,
1084-
);
1065+
const evaluated = featureDefinitions.map((feature) => ({
1066+
featureKey: feature.key,
1067+
targetingVersion: feature.targeting.version,
1068+
configVersion: feature.config?.version,
1069+
enabledResult: feature.enabledEvaluator(context, feature.key),
1070+
configResult:
1071+
feature.configEvaluator?.(context, feature.key) ??
1072+
({
1073+
featureKey: feature.key,
1074+
context,
1075+
value: undefined,
1076+
ruleEvaluationResults: [],
1077+
missingContextFields: [],
1078+
} satisfies EvaluationResult<any>),
1079+
}));
10851080

10861081
this.warnMissingFeatureContextFields(
10871082
context,
1088-
evaluated.map(({ featureKey, missingContextFields }) => ({
1083+
evaluated.map(({ featureKey, enabledResult, configResult }) => ({
10891084
key: featureKey,
1090-
missingContextFields,
1085+
missingContextFields: enabledResult.missingContextFields ?? [],
1086+
config: {
1087+
key: configResult.value,
1088+
missingContextFields: configResult.missingContextFields ?? [],
1089+
},
10911090
})),
10921091
);
10931092

@@ -1099,23 +1098,23 @@ export class BucketClient {
10991098
this.sendFeatureEvent({
11001099
action: "evaluate",
11011100
key: res.featureKey,
1102-
targetingVersion: featureMap[res.featureKey].targeting.version,
1103-
evalResult: res.value ?? false,
1104-
evalContext: res.context,
1105-
evalRuleResults: res.ruleEvaluationResults,
1106-
evalMissingFields: res.missingContextFields,
1101+
targetingVersion: res.targetingVersion,
1102+
evalResult: res.enabledResult.value ?? false,
1103+
evalContext: res.enabledResult.context,
1104+
evalRuleResults: res.enabledResult.ruleEvaluationResults,
1105+
evalMissingFields: res.enabledResult.missingContextFields,
11071106
}),
11081107
);
11091108

1110-
const config = evaluatedConfigs[res.featureKey];
1111-
if (config) {
1109+
const config = res.configResult;
1110+
if (config.value) {
11121111
outPromises.push(
11131112
this.sendFeatureEvent({
11141113
action: "evaluate-config",
11151114
key: res.featureKey,
1116-
targetingVersion: config.targetingVersion,
1117-
evalResult: { key: config.key, payload: config.payload },
1118-
evalContext: res.context,
1115+
targetingVersion: res.configVersion,
1116+
evalResult: config.value,
1117+
evalContext: config.context,
11191118
evalRuleResults: config.ruleEvaluationResults,
11201119
evalMissingFields: config.missingContextFields,
11211120
}),
@@ -1144,11 +1143,17 @@ export class BucketClient {
11441143
(acc, res) => {
11451144
acc[res.featureKey as keyof TypedFeatures] = {
11461145
key: res.featureKey,
1147-
isEnabled: res.value ?? false,
1148-
config: evaluatedConfigs[res.featureKey],
1149-
ruleEvaluationResults: res.ruleEvaluationResults,
1150-
missingContextFields: res.missingContextFields,
1151-
targetingVersion: featureMap[res.featureKey].targeting.version,
1146+
isEnabled: res.enabledResult.value ?? false,
1147+
ruleEvaluationResults: res.enabledResult.ruleEvaluationResults,
1148+
missingContextFields: res.enabledResult.missingContextFields,
1149+
targetingVersion: res.targetingVersion,
1150+
config: {
1151+
key: res.configResult?.value?.key,
1152+
payload: res.configResult?.value?.payload,
1153+
targetingVersion: res.configVersion,
1154+
ruleEvaluationResults: res.configResult?.ruleEvaluationResults,
1155+
missingContextFields: res.configResult?.missingContextFields,
1156+
},
11521157
};
11531158
return acc;
11541159
},

packages/node-sdk/src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-empty-object-type */
22

3-
import { RuleFilter } from "@bucketco/flag-evaluation";
3+
import { newEvaluator, RuleFilter } from "@bucketco/flag-evaluation";
44

55
/**
66
* Describes the meta context associated with tracking.
@@ -365,6 +365,17 @@ export type FeaturesAPIResponse = {
365365
features: FeatureAPIResponse[];
366366
};
367367

368+
/**
369+
* (Internal) Feature definitions with the addition of a pre-prepared
370+
* evaluators functions for the rules.
371+
*
372+
* @internal
373+
*/
374+
export type CachedFeatureDefinition = FeatureAPIResponse & {
375+
enabledEvaluator: ReturnType<typeof newEvaluator<boolean>>;
376+
configEvaluator: ReturnType<typeof newEvaluator<any>> | undefined;
377+
};
378+
368379
/**
369380
* (Internal) Describes the response of the evaluated features endpoint.
370381
*

packages/node-sdk/test/client.test.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
vi,
1111
} from "vitest";
1212

13-
import { evaluateFeatureRules, flattenJSON } from "@bucketco/flag-evaluation";
13+
import { flattenJSON } from "@bucketco/flag-evaluation";
1414

1515
import { BoundBucketClient, BucketClient } from "../src";
1616
import {
@@ -30,15 +30,6 @@ import { ClientOptions, Context, FeaturesAPIResponse } from "../src/types";
3030

3131
const BULK_ENDPOINT = "https://api.example.com/bulk";
3232

33-
vi.mock("@bucketco/flag-evaluation", async (importOriginal) => {
34-
const original = (await importOriginal()) as any;
35-
36-
return {
37-
...original,
38-
evaluateFeatureRules: vi.fn(original.evaluateFeatureRules),
39-
};
40-
});
41-
4233
vi.mock("../src/rate-limiter", async (importOriginal) => {
4334
const original = (await importOriginal()) as any;
4435

@@ -1370,7 +1361,6 @@ describe("BucketClient", () => {
13701361

13711362
await client.flush();
13721363

1373-
expect(evaluateFeatureRules).toHaveBeenCalledTimes(3);
13741364
expect(httpClient.post).toHaveBeenCalledTimes(1);
13751365
});
13761366

@@ -1427,7 +1417,6 @@ describe("BucketClient", () => {
14271417

14281418
await client.flush();
14291419

1430-
expect(evaluateFeatureRules).toHaveBeenCalledTimes(3);
14311420
expect(httpClient.post).toHaveBeenCalledTimes(1);
14321421
});
14331422

@@ -1458,7 +1447,6 @@ describe("BucketClient", () => {
14581447

14591448
await client.flush();
14601449

1461-
expect(evaluateFeatureRules).toHaveBeenCalledTimes(3);
14621450
expect(httpClient.post).toHaveBeenCalledTimes(1);
14631451
});
14641452

@@ -1489,7 +1477,6 @@ describe("BucketClient", () => {
14891477

14901478
await client.flush();
14911479

1492-
expect(evaluateFeatureRules).toHaveBeenCalledTimes(3);
14931480
expect(httpClient.post).not.toHaveBeenCalled();
14941481
});
14951482

@@ -1517,7 +1504,6 @@ describe("BucketClient", () => {
15171504

15181505
await client.flush();
15191506

1520-
expect(evaluateFeatureRules).toHaveBeenCalledTimes(3);
15211507
expect(httpClient.post).toHaveBeenCalledTimes(1);
15221508
});
15231509

0 commit comments

Comments
 (0)