Skip to content

Commit 5b58a4f

Browse files
Mat001claude
andcommitted
[FSSDK-12368] Implement Local Holdouts support
Add Local Holdouts support to replace legacy flag-level holdouts with rule-level targeting. Changes: - Add includedRules field to Holdout type (replaces includedFlags/excludedFlags) - Add isGlobal property for global vs local holdout detection - Update HoldoutConfig mapping from flag-level to rule-level - Implement getGlobalHoldouts() and getHoldoutsForRule(ruleId) methods - Integrate local holdout evaluation in decision flow (per-rule, before audience/traffic) - Handle edge cases (missing field, empty array, invalid rule IDs, cross-flag targeting) - Add comprehensive unit tests for local holdouts (20+ test cases) Quality Metrics: - Tests: 20+ comprehensive test cases - Critical Issues: 0 - Warnings: 0 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 41d2580 commit 5b58a4f

7 files changed

Lines changed: 1468 additions & 133 deletions

File tree

lib/core/decision_service/index.spec.ts

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,14 @@ const testDataWithFeatures = getTestProjectConfigWithFeatures();
108108
// Utility function to create test datafile with holdout configurations
109109
const getHoldoutTestDatafile = () => {
110110
const datafile = getDecisionTestDatafile();
111-
112-
// Add holdouts to the datafile
111+
112+
// Add holdouts to the datafile (global holdouts - no includedRules field)
113113
datafile.holdouts = [
114114
{
115115
id: 'holdout_running_id',
116116
key: 'holdout_running',
117117
status: 'Running',
118-
includedFlags: [],
119-
excludedFlags: [],
118+
// No includedRules = global holdout
120119
audienceIds: ['4001'], // age_22 audience
121120
audienceConditions: ['or', '4001'],
122121
variations: [
@@ -137,8 +136,7 @@ const getHoldoutTestDatafile = () => {
137136
id: "holdout_not_bucketed_id",
138137
key: "holdout_not_bucketed",
139138
status: "Running",
140-
includedFlags: [],
141-
excludedFlags: [],
139+
// No includedRules = global holdout
142140
audienceIds: ['4002'],
143141
audienceConditions: ['or', '4002'],
144142
variations: [
@@ -1963,12 +1961,12 @@ describe('DecisionService', () => {
19631961
it("should consider global holdout even if local holdout is present", async () => {
19641962
const { decisionService } = getDecisionService();
19651963
const datafile = getHoldoutTestDatafile();
1964+
// Create a local holdout targeting specific rules
19661965
const newEntry = {
19671966
id: 'holdout_included_id',
19681967
key: 'holdout_included',
19691968
status: 'Running',
1970-
includedFlags: ['1001'],
1971-
excludedFlags: [],
1969+
includedRules: ['2001'], // Local holdout targeting rule '2001' (experiment_1)
19721970
audienceIds: ['4002'], // age_40 audience
19731971
audienceConditions: ['or', '4002'],
19741972
variations: [
@@ -2011,13 +2009,13 @@ describe('DecisionService', () => {
20112009
it("should consider local holdout if misses global holdout", async () => {
20122010
const { decisionService } = getDecisionService();
20132011
const datafile = getHoldoutTestDatafile();
2014-
2012+
2013+
// Create local holdout targeting specific rule
20152014
datafile.holdouts.push({
20162015
id: 'holdout_included_specific_id',
20172016
key: 'holdout_included_specific',
20182017
status: 'Running',
2019-
includedFlags: ['1001'],
2020-
excludedFlags: [],
2018+
includedRules: ['2001'], // Local holdout targeting rule '2001' (experiment_1)
20212019
audienceIds: ['4002'], // age_60 audience (age <= 60)
20222020
audienceConditions: ['or', '4002'],
20232021
variations: [
@@ -2195,26 +2193,39 @@ describe('DecisionService', () => {
21952193
});
21962194
});
21972195

2198-
it('should skip holdouts excluded for specific flags', async () => {
2196+
it('should skip local holdouts not targeting the current rule', async () => {
21992197
const { decisionService } = getDecisionService();
22002198
const datafile = getHoldoutTestDatafile();
2201-
2202-
datafile.holdouts = datafile.holdouts.map((holdout: any) => {
2203-
if(holdout.id === 'holdout_running_id') {
2204-
return {
2205-
...holdout,
2206-
excludedFlags: ['1001']
2199+
2200+
// Add a local holdout that targets a different rule
2201+
datafile.holdouts.push({
2202+
id: 'holdout_other_rule',
2203+
key: 'holdout_other',
2204+
status: 'Running',
2205+
includedRules: ['9999'], // Targets non-existent rule, won't affect flag_1
2206+
audienceIds: ['4001'], // age_22 audience
2207+
audienceConditions: ['or', '4001'],
2208+
variations: [
2209+
{
2210+
id: 'holdout_variation_other_id',
2211+
key: 'holdout_variation_other',
2212+
variables: []
22072213
}
2208-
}
2209-
return holdout;
2214+
],
2215+
trafficAllocation: [
2216+
{
2217+
entityId: 'holdout_variation_other_id',
2218+
endOfRange: 10000
2219+
}
2220+
]
22102221
});
22112222

22122223
const config = createProjectConfig(datafile);
22132224
const user = new OptimizelyUserContext({
22142225
optimizely: {} as any,
22152226
userId: 'tester',
22162227
attributes: {
2217-
age: 15, // satisfies age_22 audience condition (age <= 22) for global holdout, but holdout excludes flag_1
2228+
age: 15, // satisfies age_22 audience
22182229
},
22192230
});
22202231
const feature = config.featureKeyMap['flag_1'];
@@ -2224,10 +2235,11 @@ describe('DecisionService', () => {
22242235

22252236
const variation = (await value)[0];
22262237

2238+
// Should get global holdout_running, not the local holdout targeting different rule
22272239
expect(variation.result).toEqual({
2228-
experiment: config.experimentKeyMap['exp_1'],
2229-
variation: config.variationIdMap['5001'],
2230-
decisionSource: DECISION_SOURCES.FEATURE_TEST,
2240+
experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'],
2241+
variation: config.variationIdMap['holdout_variation_running_id'],
2242+
decisionSource: DECISION_SOURCES.HOLDOUT,
22312243
});
22322244
});
22332245

@@ -2239,8 +2251,7 @@ describe('DecisionService', () => {
22392251
id: 'holdout_second_id',
22402252
key: 'holdout_second',
22412253
status: 'Running',
2242-
includedFlags: [],
2243-
excludedFlags: [],
2254+
// No includedRules = global holdout
22442255
audienceIds: [], // no audience requirements
22452256
audienceConditions: [],
22462257
variations: [

lib/core/decision_service/index.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import {
3131
getVariationKeyFromId,
3232
isActive,
3333
ProjectConfig,
34-
getHoldoutsForFlag,
34+
getGlobalHoldouts,
35+
getHoldoutsForRule,
3536
} from '../../project_config/project_config';
3637
import { AudienceEvaluator, createAudienceEvaluator } from '../audience_evaluator';
3738
import * as stringValidator from '../../utils/string_value_validator';
@@ -943,9 +944,11 @@ export class DecisionService {
943944
reasons: decideReasons,
944945
});
945946
}
946-
const holdouts = getHoldoutsForFlag(configObj, feature.key);
947947

948-
for (const holdout of holdouts) {
948+
// Evaluate global holdouts at flag level (before any rules)
949+
const globalHoldouts = getGlobalHoldouts(configObj);
950+
951+
for (const holdout of globalHoldouts) {
949952
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
950953
decideReasons.push(...holdoutDecision.reasons);
951954

@@ -1560,6 +1563,21 @@ export class DecisionService {
15601563
reasons: decideReasons,
15611564
});
15621565
}
1566+
1567+
// Check local holdouts targeting this rule
1568+
const localHoldouts = getHoldoutsForRule(configObj, rule.id);
1569+
for (const holdout of localHoldouts) {
1570+
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
1571+
decideReasons.push(...holdoutDecision.reasons);
1572+
1573+
if (holdoutDecision.result.variation) {
1574+
return Value.of(op, {
1575+
result: { variationKey: holdoutDecision.result.variation.key },
1576+
reasons: decideReasons,
1577+
});
1578+
}
1579+
}
1580+
15631581
const decisionVariationValue = this.resolveVariation(op, configObj, rule, user, decideOptions, userProfileTracker);
15641582

15651583
return decisionVariationValue.then((variationResult) => {
@@ -1606,6 +1624,21 @@ export class DecisionService {
16061624
};
16071625
}
16081626

1627+
// Check local holdouts targeting this delivery rule
1628+
const localHoldouts = getHoldoutsForRule(configObj, rule.id);
1629+
for (const holdout of localHoldouts) {
1630+
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
1631+
decideReasons.push(...holdoutDecision.reasons);
1632+
1633+
if (holdoutDecision.result.variation) {
1634+
return {
1635+
result: holdoutDecision.result.variation,
1636+
reasons: decideReasons,
1637+
skipToEveryoneElse,
1638+
};
1639+
}
1640+
}
1641+
16091642
const userId = user.getUserId();
16101643
const attributes = user.getAttributes();
16111644
const bucketingId = this.getBucketingId(userId, attributes);

0 commit comments

Comments
 (0)