Skip to content

Commit 8d87fa8

Browse files
committed
[AI-FSSDK] [FSSDK-12760] add localHoldouts to datafile for backward compatibility
1 parent aea8778 commit 8d87fa8

9 files changed

Lines changed: 414 additions & 33 deletions

File tree

core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ public DatafileProjectConfig(String accountId, String projectId, String version,
136136
);
137137
}
138138

139-
// v4 constructor
139+
// v4 constructor (legacy — single holdouts list). For backward compatibility with callers
140+
// that pre-date the 'localHoldouts' section split, entries are auto-partitioned by their
141+
// entity-level includedRules field: null -> global section, non-null -> local section.
142+
// Prefer the 21-arg constructor when you have explicit datafile sections.
140143
public DatafileProjectConfig(String accountId,
141144
boolean anonymizeIP,
142145
boolean sendFlagDecisions,
@@ -157,6 +160,63 @@ public DatafileProjectConfig(String accountId,
157160
List<Group> groups,
158161
List<Rollout> rollouts,
159162
List<Integration> integrations) {
163+
this(accountId, anonymizeIP, sendFlagDecisions, botFiltering, region, projectId, revision,
164+
sdkKey, environmentKey, version, attributes, audiences, typedAudiences, events,
165+
experiments,
166+
partitionLegacyHoldoutsByScope(holdouts, true),
167+
partitionLegacyHoldoutsByScope(holdouts, false),
168+
featureFlags, groups, rollouts, integrations);
169+
}
170+
171+
// Helper for the legacy v4 constructor: splits a mixed holdouts list by entity-level scope
172+
// so callers that haven't migrated to the section-aware constructor still get the right
173+
// global/local routing. {@code wantGlobal=true} returns entries with {@code includedRules == null};
174+
// {@code wantGlobal=false} returns entries with {@code includedRules != null}.
175+
private static List<Holdout> partitionLegacyHoldoutsByScope(List<Holdout> holdouts, boolean wantGlobal) {
176+
if (holdouts == null || holdouts.isEmpty()) {
177+
return Collections.emptyList();
178+
}
179+
List<Holdout> partition = new ArrayList<Holdout>();
180+
for (Holdout holdout : holdouts) {
181+
if (holdout.isGlobal() == wantGlobal) {
182+
partition.add(holdout);
183+
}
184+
}
185+
return partition;
186+
}
187+
188+
// v4 constructor with separate localHoldouts section.
189+
//
190+
// The two top-level datafile sections drive holdout scoping (Gen 3+):
191+
// - 'holdouts' -> all entries are global holdouts (every flag).
192+
// Any 'includedRules' on these entries is IGNORED (HoldoutConfig
193+
// strips it at parse time so section membership is the sole signal).
194+
// - 'localHoldouts' -> all entries are local holdouts (rule-scoped via includedRules).
195+
// Entries missing/empty includedRules are logged and skipped.
196+
//
197+
// Backward compatibility: older datafiles without a 'localHoldouts' section continue to
198+
// work unchanged. Pass {@code null} or an empty list for {@code localHoldouts}.
199+
public DatafileProjectConfig(String accountId,
200+
boolean anonymizeIP,
201+
boolean sendFlagDecisions,
202+
Boolean botFiltering,
203+
String region,
204+
String projectId,
205+
String revision,
206+
String sdkKey,
207+
String environmentKey,
208+
String version,
209+
List<Attribute> attributes,
210+
List<Audience> audiences,
211+
List<Audience> typedAudiences,
212+
List<EventType> events,
213+
List<Experiment> experiments,
214+
List<Holdout> holdouts,
215+
List<Holdout> localHoldouts,
216+
List<FeatureFlag> featureFlags,
217+
List<Group> groups,
218+
List<Rollout> rollouts,
219+
List<Integration> integrations) {
160220
this.accountId = accountId;
161221
this.projectId = projectId;
162222
this.version = version;
@@ -200,10 +260,12 @@ public DatafileProjectConfig(String accountId,
200260

201261
this.experiments = Collections.unmodifiableList(allExperiments);
202262

203-
if (holdouts == null) {
263+
List<Holdout> globalHoldoutsSection = holdouts != null ? holdouts : Collections.<Holdout>emptyList();
264+
List<Holdout> localHoldoutsSection = localHoldouts != null ? localHoldouts : Collections.<Holdout>emptyList();
265+
if (globalHoldoutsSection.isEmpty() && localHoldoutsSection.isEmpty()) {
204266
this.holdoutConfig = new HoldoutConfig();
205-
} else {
206-
this.holdoutConfig = new HoldoutConfig(holdouts);
267+
} else {
268+
this.holdoutConfig = new HoldoutConfig(globalHoldoutsSection, localHoldoutsSection);
207269
}
208270

209271
String publicKeyForODP = "";

core-api/src/main/java/com/optimizely/ab/config/Holdout.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,8 @@ public class Holdout implements ExperimentCore {
4545
private final List<TrafficAllocation> trafficAllocation;
4646

4747
/**
48-
* Optional list of rule IDs this holdout targets. When null, the holdout is global
49-
* (applies to all rules across all flags). When non-null (even empty), it is a local
50-
* holdout that only applies to the specified rule IDs.
48+
* Per-rule targeting for local holdouts. Scope comes from the datafile section, not
49+
* this field; HoldoutConfig strips it on entries from the global 'holdouts' section.
5150
*/
5251
@Nullable
5352
private final List<String> includedRules;
@@ -183,7 +182,9 @@ public List<String> getIncludedRules() {
183182

184183
/**
185184
* Returns true if this holdout is global (applies to all rules across all flags).
186-
* A holdout is global when includedRules is null.
185+
* Scope is determined by the datafile section ('holdouts' vs 'localHoldouts');
186+
* HoldoutConfig strips 'includedRules' from global-section entries, so this stays
187+
* consistent with section membership.
187188
*
188189
* @return true if this is a global holdout, false if it is a local holdout
189190
*/

core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java

Lines changed: 129 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,63 +27,148 @@
2727
import javax.annotation.Nonnull;
2828
import javax.annotation.Nullable;
2929

30+
import org.slf4j.Logger;
31+
import org.slf4j.LoggerFactory;
32+
3033
/**
31-
* HoldoutConfig manages collections of Holdout objects, distinguishing between global holdouts
32-
* (which apply to all rules) and local holdouts (which target specific rule IDs).
34+
* HoldoutConfig manages collections of Holdout objects partitioned by datafile section.
35+
*
36+
* <p>Two top-level datafile sections drive holdout scoping (Gen 3+):
37+
* <ul>
38+
* <li>{@code holdouts} — every entry is a global holdout (applied to every flag).
39+
* Any {@code includedRules} field on these entries is IGNORED
40+
* and stripped at parse time; section membership alone determines
41+
* scope.</li>
42+
* <li>{@code localHoldouts} — every entry is a local holdout (rule-scoped via
43+
* {@code includedRules}). Entries missing or with empty
44+
* {@code includedRules} are invalid and skipped with an error log.</li>
45+
* </ul>
46+
*
47+
* <p>Backward compatibility: older datafiles that only emit the {@code holdouts} section
48+
* continue to work unchanged — every entry is treated as global, matching pre-localHoldouts
49+
* behavior. The {@code localHoldouts} key is simply absent and parsed as an empty list.
3350
*/
3451
public class HoldoutConfig {
52+
private static final Logger logger = LoggerFactory.getLogger(HoldoutConfig.class);
53+
3554
private List<Holdout> allHoldouts;
3655
private Map<String, Holdout> holdoutIdMap;
3756

38-
/** Global holdouts: holdouts where includedRules == null. Evaluated at flag level. */
57+
/** Global holdouts: entries from the datafile 'holdouts' section. Evaluated at flag level. */
3958
private List<Holdout> globalHoldouts;
4059

4160
/** Rule-level map: ruleId -> list of local holdouts targeting that rule. */
4261
private Map<String, List<Holdout>> ruleHoldoutsMap;
4362

4463
/**
45-
* Initializes a new HoldoutConfig with an empty list of holdouts.
64+
* Initializes a new HoldoutConfig with no holdouts.
4665
*/
4766
public HoldoutConfig() {
48-
this(Collections.emptyList());
67+
this(Collections.<Holdout>emptyList(), Collections.<Holdout>emptyList());
4968
}
5069

5170
/**
52-
* Initializes a new HoldoutConfig with the specified holdouts.
71+
* Backward-compatible constructor: treats every entry as if it came from the global
72+
* 'holdouts' section. Any {@code includedRules} field on these entries is preserved
73+
* (legacy classification is by entity-level {@code includedRules}, used only by callers
74+
* who pre-date the section split).
5375
*
5476
* @param allHoldouts The list of holdouts to manage
77+
* @deprecated Prefer {@link #HoldoutConfig(List, List)} so global vs. local scope is
78+
* driven by datafile section membership.
5579
*/
80+
@Deprecated
5681
public HoldoutConfig(@Nonnull List<Holdout> allHoldouts) {
5782
this.allHoldouts = new ArrayList<>(allHoldouts);
5883
this.holdoutIdMap = new HashMap<>();
5984
this.globalHoldouts = new ArrayList<>();
6085
this.ruleHoldoutsMap = new HashMap<>();
61-
updateHoldoutMapping();
86+
updateLegacyHoldoutMapping();
87+
}
88+
89+
/**
90+
* Initializes a new HoldoutConfig from the two top-level datafile sections.
91+
*
92+
* <p>Entries in {@code globalHoldoutsFromSection} are treated as global regardless of
93+
* any {@code includedRules} field they may carry; that field is stripped so section
94+
* membership is the sole signal for scope.
95+
*
96+
* <p>Entries in {@code localHoldoutsFromSection} must carry a non-empty
97+
* {@code includedRules} list. Invalid entries (null or empty {@code includedRules})
98+
* are logged at ERROR and excluded from evaluation — they do NOT fall back to
99+
* global application (the partition between sections is hard).
100+
*
101+
* @param globalHoldoutsFromSection Entries from the datafile 'holdouts' section
102+
* @param localHoldoutsFromSection Entries from the datafile 'localHoldouts' section
103+
*/
104+
public HoldoutConfig(@Nonnull List<Holdout> globalHoldoutsFromSection,
105+
@Nonnull List<Holdout> localHoldoutsFromSection) {
106+
this.allHoldouts = new ArrayList<>();
107+
this.holdoutIdMap = new HashMap<>();
108+
this.globalHoldouts = new ArrayList<>();
109+
this.ruleHoldoutsMap = new HashMap<>();
110+
updateHoldoutMapping(globalHoldoutsFromSection, localHoldoutsFromSection);
62111
}
63112

64113
/**
65-
* Updates internal mappings:
66-
* - holdoutIdMap: id -> Holdout
67-
* - globalHoldouts: holdouts where includedRules == null
68-
* - ruleHoldoutsMap: ruleId -> list of holdouts that include that rule
114+
* Section-aware mapping: enforces that scope comes from the datafile section, not the
115+
* {@code includedRules} field. Stale {@code includedRules} values on global-section
116+
* entries are stripped; invalid local-section entries are logged and skipped.
69117
*/
70-
private void updateHoldoutMapping() {
71-
holdoutIdMap.clear();
72-
globalHoldouts.clear();
73-
ruleHoldoutsMap.clear();
118+
private void updateHoldoutMapping(@Nonnull List<Holdout> globalHoldoutsFromSection,
119+
@Nonnull List<Holdout> localHoldoutsFromSection) {
120+
// Process global holdouts: section membership is the sole signal for scope.
121+
// Strip any stale 'includedRules' so the entity is unambiguously global (isGlobal -> true),
122+
// even if the datafile incorrectly includes one.
123+
for (Holdout holdout : globalHoldoutsFromSection) {
124+
Holdout sanitized = holdout.isGlobal() ? holdout : stripIncludedRules(holdout);
74125

126+
allHoldouts.add(sanitized);
127+
holdoutIdMap.put(sanitized.getId(), sanitized);
128+
globalHoldouts.add(sanitized);
129+
}
130+
131+
// Process local holdouts: every entry must carry an 'includedRules' field.
132+
// Entries with null/missing includedRules are invalid per spec — log an error and
133+
// exclude them from evaluation (do NOT fall back to global application).
134+
// An empty includedRules list is valid but inert: the entity is tracked in the id
135+
// map but is not registered under any rule (matches Python reference semantics).
136+
for (Holdout holdout : localHoldoutsFromSection) {
137+
List<String> includedRules = holdout.getIncludedRules();
138+
if (includedRules == null) {
139+
logger.error(
140+
"Local holdout \"{}\" is missing required 'includedRules' field and will be excluded from evaluation.",
141+
holdout.getKey() != null ? holdout.getKey() : holdout.getId());
142+
continue;
143+
}
144+
145+
allHoldouts.add(holdout);
146+
holdoutIdMap.put(holdout.getId(), holdout);
147+
for (String ruleId : includedRules) {
148+
if (!ruleHoldoutsMap.containsKey(ruleId)) {
149+
ruleHoldoutsMap.put(ruleId, new ArrayList<Holdout>());
150+
}
151+
ruleHoldoutsMap.get(ruleId).add(holdout);
152+
}
153+
}
154+
}
155+
156+
/**
157+
* Legacy mapping used by the deprecated single-list constructor. Classifies each entry
158+
* by its entity-level {@code includedRules} (null -> global, non-null -> local).
159+
* Preserved unchanged for callers that have not migrated to section-aware construction.
160+
*/
161+
private void updateLegacyHoldoutMapping() {
75162
for (Holdout holdout : allHoldouts) {
76163
holdoutIdMap.put(holdout.getId(), holdout);
77164

78165
if (holdout.isGlobal()) {
79-
// includedRules == null: global holdout — applies to all rules
80166
globalHoldouts.add(holdout);
81167
} else {
82-
// includedRules != null: local holdout — add to each targeted rule
83168
List<String> includedRules = holdout.getIncludedRules();
84169
for (String ruleId : includedRules) {
85170
if (!ruleHoldoutsMap.containsKey(ruleId)) {
86-
ruleHoldoutsMap.put(ruleId, new ArrayList<>());
171+
ruleHoldoutsMap.put(ruleId, new ArrayList<Holdout>());
87172
}
88173
ruleHoldoutsMap.get(ruleId).add(holdout);
89174
}
@@ -92,8 +177,28 @@ private void updateHoldoutMapping() {
92177
}
93178

94179
/**
95-
* Returns all global holdouts (holdouts where includedRules == null).
180+
* Returns a copy of the given holdout with {@code includedRules} forced to null, so the
181+
* entity is unambiguously classified as global. Used only when a stale {@code includedRules}
182+
* appears on an entry coming from the global 'holdouts' section.
183+
*/
184+
private static Holdout stripIncludedRules(Holdout holdout) {
185+
return new Holdout(
186+
holdout.getId(),
187+
holdout.getKey(),
188+
holdout.getStatus(),
189+
holdout.getAudienceIds(),
190+
holdout.getAudienceConditions(),
191+
holdout.getVariations(),
192+
holdout.getTrafficAllocation(),
193+
null
194+
);
195+
}
196+
197+
/**
198+
* Returns all global holdouts (entries from the datafile 'holdouts' section).
96199
* These are evaluated at the flag level, before any rules are evaluated.
200+
* Section membership in 'holdouts' is the sole signal for global scope — any
201+
* 'includedRules' field on these entries is ignored.
97202
*
98203
* @return An unmodifiable list of global holdouts
99204
*/
@@ -102,16 +207,17 @@ public List<Holdout> getGlobalHoldouts() {
102207
}
103208

104209
/**
105-
* Returns local holdouts targeting a specific rule ID.
106-
* These are evaluated per-rule, after the forced decision check and before regular rule evaluation.
210+
* Returns local holdouts targeting a specific rule ID. Local holdouts come from the
211+
* datafile 'localHoldouts' section and are scoped per-rule via 'includedRules'.
212+
* Evaluated per-rule, after the forced decision check and before regular rule evaluation.
107213
*
108214
* @param ruleId The rule identifier to look up
109215
* @return An unmodifiable list of local holdouts targeting that rule, or empty list if none
110216
*/
111217
@Nonnull
112218
public List<Holdout> getHoldoutsForRule(@Nonnull String ruleId) {
113219
List<Holdout> holdouts = ruleHoldoutsMap.get(ruleId);
114-
return holdouts != null ? Collections.unmodifiableList(holdouts) : Collections.emptyList();
220+
return holdouts != null ? Collections.unmodifiableList(holdouts) : Collections.<Holdout>emptyList();
115221
}
116222

117223
/**
@@ -140,7 +246,7 @@ public Holdout getHoldout(@Nonnull String id) {
140246
}
141247

142248
/**
143-
* Returns all holdouts managed by this config.
249+
* Returns all holdouts managed by this config (both global and local sections, in that order).
144250
*
145251
* @return An unmodifiable list of all holdouts
146252
*/

core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa
7272
holdouts = Collections.emptyList();
7373
}
7474

75+
// Parse optional 'localHoldouts' top-level section (Gen 3+ datafile shape).
76+
// Absent in legacy datafiles — handled by passing an empty list to the constructor.
77+
List<Holdout> localHoldouts;
78+
if (jsonObject.has("localHoldouts")) {
79+
localHoldouts = context.deserialize(jsonObject.get("localHoldouts").getAsJsonArray(), holdoutsType);
80+
} else {
81+
localHoldouts = Collections.emptyList();
82+
}
83+
7584
List<Attribute> attributes;
7685
attributes = context.deserialize(jsonObject.get("attributes"), attributesType);
7786

@@ -143,6 +152,7 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa
143152
events,
144153
experiments,
145154
holdouts,
155+
localHoldouts,
146156
featureFlags,
147157
groups,
148158
rollouts,

core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte
5252
holdouts = Collections.emptyList();
5353
}
5454

55+
// Parse optional 'localHoldouts' top-level section (Gen 3+ datafile shape).
56+
// Absent in legacy datafiles — handled by passing an empty list to the constructor.
57+
List<Holdout> localHoldouts;
58+
if (node.has("localHoldouts")) {
59+
localHoldouts = JacksonHelpers.arrayNodeToList(node.get("localHoldouts"), Holdout.class, codec);
60+
} else {
61+
localHoldouts = Collections.emptyList();
62+
}
63+
5564
List<Audience> audiences = Collections.emptyList();
5665
if (node.has("audiences")) {
5766
audiences = JacksonHelpers.arrayNodeToList(node.get("audiences"), Audience.class, codec);
@@ -117,6 +126,7 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte
117126
events,
118127
experiments,
119128
holdouts,
129+
localHoldouts,
120130
featureFlags,
121131
groups,
122132
rollouts,

0 commit comments

Comments
 (0)