Skip to content

Commit e273ddd

Browse files
jaeoptMat001claude
authored
[AI-FSSDK] [FSSDK-12760] add localHoldouts to datafile for backward compatibility (#453)
* [AI-FSSDK] [FSSDK-12760] add localHoldouts to datafile for backward compatibility * [FSSDK-12760] Fix duplicate ID collision, log level, and brittle test - Log a warning when the same holdout ID appears in both datafile sections (the later entry overwrites the earlier one in the ID map) - Downgrade missing-includedRules log from Error to Warning — this is a data quality issue, not a runtime failure - Replace brittle TestMapHoldoutsErrorMessageWording (asserted on specific words) with TestMapHoldoutsDuplicateIDsAcrossSectionsLogsWarning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * [FSSDK-12760] Empty commit to re-trigger CI * chore: trigger CI rerun Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use rule Key instead of ID for local holdout lookup (#454) GetLocalDecisionForRule was called with experiment.ID, but ruleHoldoutsMap is keyed by rule Keys (from datafile includedRules). The mismatch caused local holdouts to silently never match. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * [FSSDK-12670] Retrigger CI * [FSSDK-12670] retrigger CI * Trigger CI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: revert to using experiment ID for local holdout lookup The datafile `includedRules` contains experiment IDs, not keys. PR #454 incorrectly changed the lookup to use Key, causing ruleHoldoutsMap lookups to never match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update ODP test to expect no identify events for single identifier TestOdpEventsEarlyEventsDispatched was waiting for identify events that are now correctly blocked by the single-identifier guard. Updated to verify no events are sent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Matjaz Pirnovar <Mat001@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7a318ec commit e273ddd

7 files changed

Lines changed: 573 additions & 171 deletions

File tree

pkg/client/optimizely_user_context_odp_test.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -210,26 +210,21 @@ func (o *OptimizelyUserContextODPTestSuite) TestFetchQualifiedSegmentsParameters
210210
segmentManager.AssertExpectations(o.T())
211211
}
212212

213-
func (o *OptimizelyUserContextODPTestSuite) TestOdpEventsEarlyEventsDispatched() {
213+
func (o *OptimizelyUserContextODPTestSuite) TestOdpIdentifySkippedForSingleIdentifier() {
214214
eventAPIManager := &MockEventAPIManager{}
215215
eventManager := event.NewBatchEventManager(event.WithAPIManager(eventAPIManager), event.WithFlushInterval(0))
216216
odpManager := odp.NewOdpManager("", false, odp.WithEventManager(eventManager))
217217
factory := OptimizelyFactory{Datafile: o.datafile, odpManager: odpManager}
218218
optimizelyClient, _ := factory.Client()
219-
eventAPIManager.wg.Add(1)
220-
// identified event will be sent
221-
_ = optimizelyClient.CreateUserContext(o.userID, nil)
222-
eventAPIManager.wg.Wait()
223-
224-
o.Equal(1, len(eventAPIManager.eventsSent))
225219

226-
expectedEvents := 100
227-
eventAPIManager.wg.Add(expectedEvents)
228-
for i := 0; i < expectedEvents; i++ {
220+
// Server-side SDKs only have fs_user_id (single identifier),
221+
// so CreateUserContext should NOT dispatch an identify event.
222+
_ = optimizelyClient.CreateUserContext(o.userID, nil)
223+
for i := 0; i < 100; i++ {
229224
_ = optimizelyClient.CreateUserContext(fmt.Sprintf("%d", i), nil)
230225
}
231-
eventAPIManager.wg.Wait()
232-
o.Equal(expectedEvents+1, len(eventAPIManager.eventsSent))
226+
227+
o.Equal(0, len(eventAPIManager.eventsSent))
233228
}
234229

235230
// Tests with live ODP server

pkg/config/datafileprojectconfig/config.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ type DatafileProjectConfig struct {
6262
flagVariationsMap map[string][]entities.Variation
6363
holdouts []entities.Holdout
6464
holdoutIDMap map[string]entities.Holdout
65-
// ruleHoldoutsMap maps rule IDs to local holdouts targeting those rules
65+
// ruleHoldoutsMap maps rule IDs to local holdouts (from the `localHoldouts` datafile section) targeting those rules.
6666
ruleHoldoutsMap map[string][]entities.Holdout
67-
// globalHoldouts holds only global holdouts (IncludedRules == nil)
67+
// globalHoldouts holds running entries from the `holdouts` datafile section.
6868
globalHoldouts []entities.Holdout
6969
}
7070

@@ -287,14 +287,14 @@ func (c DatafileProjectConfig) GetRegion() string {
287287
return c.region
288288
}
289289

290-
// GetGlobalHoldouts returns all global holdouts (those with IncludedRules == nil).
291-
// These are evaluated at flag level, before any per-rule evaluation.
290+
// GetGlobalHoldouts returns all running global holdouts from the `holdouts` datafile section.
291+
// Evaluated at flag level before any per-rule evaluation; section membership is the sole signal for scope.
292292
func (c DatafileProjectConfig) GetGlobalHoldouts() []entities.Holdout {
293293
return c.globalHoldouts
294294
}
295295

296-
// GetHoldoutsForRule returns all local holdouts that target the given rule ID.
297-
// These are evaluated per-rule, after forced decisions, before audience/traffic checks.
296+
// GetHoldoutsForRule returns running local holdouts (from the `localHoldouts` datafile section)
297+
// that target the given rule ID. Evaluated per-rule, after forced decisions, before audience/traffic checks.
298298
func (c DatafileProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout {
299299
if holdouts, exists := c.ruleHoldoutsMap[ruleID]; exists {
300300
return holdouts
@@ -348,7 +348,11 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
348348

349349
audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...))
350350
flagVariationsMap := mappers.MapFlagVariations(featureMap)
351-
holdouts, holdoutIDMap, globalHoldouts, ruleHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts)
351+
// Two top-level holdout sections drive scope (FSSDK-12760):
352+
// `holdouts` → global holdouts (every entry; `includedRules` stripped)
353+
// `localHoldouts` → local holdouts (must carry `includedRules`; otherwise logged and skipped)
354+
// Older datafiles without `localHoldouts` continue to work unchanged.
355+
holdouts, holdoutIDMap, globalHoldouts, ruleHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, datafile.LocalHoldouts, logger)
352356

353357
attributeKeyMap := make(map[string]entities.Attribute)
354358
attributeIDToKeyMap := make(map[string]string)

pkg/config/datafileprojectconfig/config_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,3 +767,131 @@ func TestGetHoldoutsForRuleWithNoHoldouts(t *testing.T) {
767767
assert.Len(t, actual, 0)
768768
assert.Equal(t, []entities.Holdout{}, actual)
769769
}
770+
771+
// ---------------------------------------------------------------------------
772+
// FSSDK-12760 — `localHoldouts` JSON section end-to-end parsing
773+
// ---------------------------------------------------------------------------
774+
775+
// TestNewDatafileProjectConfigPartitionsHoldoutsByDatafileSection exercises the
776+
// full datafile → entity pipeline to confirm the `holdouts` and `localHoldouts`
777+
// top-level keys are partitioned strictly by section membership.
778+
func TestNewDatafileProjectConfigPartitionsHoldoutsByDatafileSection(t *testing.T) {
779+
datafile := []byte(`{
780+
"version": "4",
781+
"accountId": "123",
782+
"projectId": "456",
783+
"revision": "1",
784+
"holdouts": [
785+
{
786+
"id": "g1",
787+
"key": "global_holdout",
788+
"status": "Running",
789+
"audienceIds": [],
790+
"variations": [{"id": "vg1", "key": "v"}],
791+
"trafficAllocation": [{"entityId": "vg1", "endOfRange": 10000}]
792+
}
793+
],
794+
"localHoldouts": [
795+
{
796+
"id": "l1",
797+
"key": "local_holdout_rule_x",
798+
"status": "Running",
799+
"audienceIds": [],
800+
"includedRules": ["rule_x"],
801+
"variations": [{"id": "vl1", "key": "v"}],
802+
"trafficAllocation": [{"entityId": "vl1", "endOfRange": 5000}]
803+
}
804+
]
805+
}`)
806+
807+
config, err := NewDatafileProjectConfig(datafile, logging.GetLogger("", "DatafileProjectConfig"))
808+
assert.NoError(t, err)
809+
assert.NotNil(t, config)
810+
811+
// Global section → global holdouts; never registers against any rule.
812+
globals := config.GetGlobalHoldouts()
813+
assert.Len(t, globals, 1)
814+
assert.Equal(t, "g1", globals[0].ID)
815+
assert.True(t, globals[0].IsGlobal())
816+
817+
// Local section → per-rule map; never appears in global list.
818+
ruleX := config.GetHoldoutsForRule("rule_x")
819+
assert.Len(t, ruleX, 1)
820+
assert.Equal(t, "l1", ruleX[0].ID)
821+
assert.False(t, ruleX[0].IsGlobal())
822+
823+
// Global id must not have leaked into the per-rule map.
824+
for _, h := range ruleX {
825+
assert.NotEqual(t, "g1", h.ID)
826+
}
827+
}
828+
829+
// TestNewDatafileProjectConfigBackwardCompatNoLocalHoldoutsSection verifies that
830+
// older datafiles emitted before FSSDK-12760 (no `localHoldouts` key) are parsed
831+
// exactly like before — every entry in `holdouts` is global, no errors.
832+
func TestNewDatafileProjectConfigBackwardCompatNoLocalHoldoutsSection(t *testing.T) {
833+
datafile := []byte(`{
834+
"version": "4",
835+
"accountId": "123",
836+
"projectId": "456",
837+
"revision": "1",
838+
"holdouts": [
839+
{
840+
"id": "old1",
841+
"key": "old_global_holdout",
842+
"status": "Running",
843+
"audienceIds": [],
844+
"variations": [{"id": "v1", "key": "v"}],
845+
"trafficAllocation": [{"entityId": "v1", "endOfRange": 10000}]
846+
}
847+
]
848+
}`)
849+
850+
config, err := NewDatafileProjectConfig(datafile, logging.GetLogger("", "DatafileProjectConfig"))
851+
assert.NoError(t, err)
852+
assert.NotNil(t, config)
853+
854+
globals := config.GetGlobalHoldouts()
855+
assert.Len(t, globals, 1)
856+
assert.Equal(t, "old1", globals[0].ID)
857+
assert.True(t, globals[0].IsGlobal())
858+
859+
// No localHoldouts section means no per-rule entries.
860+
assert.Empty(t, config.GetHoldoutsForRule("any_rule"))
861+
}
862+
863+
// TestNewDatafileProjectConfigGlobalSectionStripsIncludedRules verifies that any
864+
// `includedRules` field accidentally present on an entry in the `holdouts` (global)
865+
// section is stripped during parsing — section membership is the sole signal for scope.
866+
func TestNewDatafileProjectConfigGlobalSectionStripsIncludedRules(t *testing.T) {
867+
datafile := []byte(`{
868+
"version": "4",
869+
"accountId": "123",
870+
"projectId": "456",
871+
"revision": "1",
872+
"holdouts": [
873+
{
874+
"id": "g_with_rules",
875+
"key": "global_with_stray_rules",
876+
"status": "Running",
877+
"audienceIds": [],
878+
"includedRules": ["rule_should_be_ignored"],
879+
"variations": [{"id": "v1", "key": "v"}],
880+
"trafficAllocation": [{"entityId": "v1", "endOfRange": 10000}]
881+
}
882+
]
883+
}`)
884+
885+
config, err := NewDatafileProjectConfig(datafile, logging.GetLogger("", "DatafileProjectConfig"))
886+
assert.NoError(t, err)
887+
assert.NotNil(t, config)
888+
889+
// Still classified as global; IncludedRules was stripped on the entity.
890+
globals := config.GetGlobalHoldouts()
891+
assert.Len(t, globals, 1)
892+
assert.True(t, globals[0].IsGlobal())
893+
assert.Nil(t, globals[0].IncludedRules)
894+
895+
// The stray rule id must NOT show up in any per-rule lookup.
896+
assert.Empty(t, config.GetHoldoutsForRule("rule_should_be_ignored"))
897+
}

pkg/config/datafileprojectconfig/entities/entities.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ type Rollout struct {
114114
Experiments []Experiment `json:"experiments"`
115115
}
116116

117-
// Holdout represents a holdout from the Optimizely datafile
117+
// Holdout represents a holdout from the Optimizely datafile.
118+
// Scope (global vs local) is set by the datafile section the entry came from
119+
// (`holdouts` vs `localHoldouts`); `IncludedRules` is stripped on `holdouts` entries
120+
// and required on `localHoldouts` entries.
118121
type Holdout struct {
119122
ID string `json:"id"`
120123
Key string `json:"key"`
@@ -123,9 +126,8 @@ type Holdout struct {
123126
AudienceConditions interface{} `json:"audienceConditions"`
124127
Variations []Variation `json:"variations"`
125128
TrafficAllocation []TrafficAllocation `json:"trafficAllocation"`
126-
// IncludedRules is optional. nil = global holdout (applies to all rules across all flags).
127-
// Non-nil array = local holdout (applies only to the specified rule IDs).
128-
// An empty non-nil array means a local holdout that targets no rules.
129+
// IncludedRules carries per-rule targeting for local holdouts. Required on
130+
// `localHoldouts` entries; ignored/stripped on `holdouts` entries at parse time.
129131
IncludedRules *[]string `json:"includedRules,omitempty"`
130132
}
131133

@@ -146,6 +148,7 @@ type Datafile struct {
146148
Events []Event `json:"events"`
147149
Rollouts []Rollout `json:"rollouts"`
148150
Holdouts []Holdout `json:"holdouts,omitempty"`
151+
LocalHoldouts []Holdout `json:"localHoldouts,omitempty"`
149152
Integrations []Integration `json:"integrations"`
150153
TypedAudiences []Audience `json:"typedAudiences"`
151154
Variables []string `json:"variables"`

pkg/config/datafileprojectconfig/mappers/holdout.go

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,27 @@
1818
package mappers
1919

2020
import (
21+
"fmt"
22+
2123
datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
2224
"github.com/optimizely/go-sdk/v2/pkg/entities"
25+
"github.com/optimizely/go-sdk/v2/pkg/logging"
2326
)
2427

25-
// MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities.
26-
// Global holdouts (IncludedRules == nil) are returned in globalHoldouts for flag-level evaluation.
27-
// Local holdouts (IncludedRules != nil) are indexed by rule ID in ruleHoldoutsMap.
28-
func MapHoldouts(holdouts []datafileEntities.Holdout) (
28+
// MapHoldouts maps the two top-level datafile holdout sections to SDK Holdout entities.
29+
//
30+
// Section membership is the sole signal for scope (FSSDK-12760):
31+
// - `holdouts` entries are global; any `includedRules` field on them is stripped.
32+
// - `localHoldouts` entries are local and MUST carry a non-nil `includedRules` list;
33+
// entries missing it are logged via `logger` and excluded (no fallback to global).
34+
//
35+
// Returns the combined entity list, an id->entity map, the global-only list, and a
36+
// per-rule map for local holdouts.
37+
func MapHoldouts(
38+
globalHoldoutsRaw []datafileEntities.Holdout,
39+
localHoldoutsRaw []datafileEntities.Holdout,
40+
logger logging.OptimizelyLogProducer,
41+
) (
2942
holdoutList []entities.Holdout,
3043
holdoutIDMap map[string]entities.Holdout,
3144
globalHoldouts []entities.Holdout,
@@ -36,29 +49,83 @@ func MapHoldouts(holdouts []datafileEntities.Holdout) (
3649
globalHoldouts = []entities.Holdout{}
3750
ruleHoldoutsMap = make(map[string][]entities.Holdout)
3851

39-
for _, holdout := range holdouts {
40-
// Only process running holdouts
52+
// Process global holdouts: drop any `IncludedRules` so section membership alone
53+
// determines scope, even if the datafile incorrectly includes one.
54+
for _, holdout := range globalHoldoutsRaw {
4155
if holdout.Status != string(entities.HoldoutStatusRunning) {
4256
continue
4357
}
4458

45-
mappedHoldout := mapHoldout(holdout)
59+
sanitized := holdout
60+
sanitized.IncludedRules = nil
61+
62+
mappedHoldout := mapHoldout(sanitized)
4663
holdoutList = append(holdoutList, mappedHoldout)
47-
holdoutIDMap[holdout.ID] = mappedHoldout
48-
49-
if mappedHoldout.IsGlobal() {
50-
globalHoldouts = append(globalHoldouts, mappedHoldout)
51-
} else {
52-
// Local holdout: applies only to the specified rule IDs
53-
for _, ruleID := range *mappedHoldout.IncludedRules {
54-
ruleHoldoutsMap[ruleID] = append(ruleHoldoutsMap[ruleID], mappedHoldout)
64+
if _, exists := holdoutIDMap[mappedHoldout.ID]; exists && logger != nil {
65+
logger.Warning(
66+
fmt.Sprintf(
67+
"Duplicate holdout ID %q encountered across datafile sections; later entry overwrites earlier one.",
68+
mappedHoldout.ID,
69+
),
70+
)
71+
}
72+
holdoutIDMap[mappedHoldout.ID] = mappedHoldout
73+
globalHoldouts = append(globalHoldouts, mappedHoldout)
74+
}
75+
76+
// Process local holdouts: `IncludedRules` is REQUIRED. Entries missing it (nil
77+
// pointer) are invalid per spec — log and skip, never promote to global.
78+
for _, holdout := range localHoldoutsRaw {
79+
if holdout.Status != string(entities.HoldoutStatusRunning) {
80+
continue
81+
}
82+
83+
if holdout.IncludedRules == nil {
84+
if logger != nil {
85+
logger.Warning(
86+
fmt.Sprintf(
87+
"Local holdout %q is missing required \"includedRules\" field and will be excluded from evaluation.",
88+
holdoutLabel(holdout),
89+
),
90+
)
5591
}
92+
continue
93+
}
94+
95+
mappedHoldout := mapHoldout(holdout)
96+
holdoutList = append(holdoutList, mappedHoldout)
97+
if _, exists := holdoutIDMap[mappedHoldout.ID]; exists && logger != nil {
98+
logger.Warning(
99+
fmt.Sprintf(
100+
"Duplicate holdout ID %q encountered across datafile sections; later entry overwrites earlier one.",
101+
mappedHoldout.ID,
102+
),
103+
)
104+
}
105+
holdoutIDMap[mappedHoldout.ID] = mappedHoldout
106+
107+
// Register the local holdout for each rule it targets. An empty IncludedRules
108+
// slice is valid (matches no rules) but is not promoted to global.
109+
for _, ruleID := range *mappedHoldout.IncludedRules {
110+
ruleHoldoutsMap[ruleID] = append(ruleHoldoutsMap[ruleID], mappedHoldout)
56111
}
57112
}
58113

59114
return holdoutList, holdoutIDMap, globalHoldouts, ruleHoldoutsMap
60115
}
61116

117+
// holdoutLabel returns a stable, user-facing label for an invalid local holdout
118+
// log message. Prefers the holdout key (human-readable) and falls back to the id.
119+
func holdoutLabel(h datafileEntities.Holdout) string {
120+
if h.Key != "" {
121+
return h.Key
122+
}
123+
if h.ID != "" {
124+
return h.ID
125+
}
126+
return "<unknown>"
127+
}
128+
62129
func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout {
63130
var audienceConditionTree *entities.TreeNode
64131
var err error

0 commit comments

Comments
 (0)