From d2a220e56b90c643288ddf03fed30670a78ef8b9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:07:05 -0700 Subject: [PATCH] feat: serve flag overrides while uninitialized and mark override evaluations in events --- go.mod | 6 +- go.sum | 6 ++ ldclient.go | 14 ++- ldclient_events.go | 1 + ldclient_overrides_test.go | 195 +++++++++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 ldclient_overrides_test.go diff --git a/go.mod b/go.mod index ea0d3817..75ebda12 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,9 @@ require ( github.com/launchdarkly/eventsource v1.10.0 github.com/launchdarkly/go-jsonstream/v3 v3.1.1 github.com/launchdarkly/go-ntlm-proxy-auth v1.0.3 - github.com/launchdarkly/go-sdk-common/v3 v3.5.0 - github.com/launchdarkly/go-sdk-events/v3 v3.6.2 - github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d + github.com/launchdarkly/go-sdk-common/v3 v3.5.1-0.20260707203913-381c033e045f + github.com/launchdarkly/go-sdk-events/v3 v3.6.3-0.20260707205009-8af496ae2ab5 + github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707205605-a8cc07261329 github.com/launchdarkly/go-test-helpers/v3 v3.1.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index a97f5509..f75e21ff 100644 --- a/go.sum +++ b/go.sum @@ -27,14 +27,20 @@ github.com/launchdarkly/go-ntlmssp v1.0.3 h1:rFxOnnEJ2DzJ+NU0plhXqnldJUwn3wWJFTW github.com/launchdarkly/go-ntlmssp v1.0.3/go.mod h1:P1z6fX/y9zgBvfnZP7AKWilW9AX5M3czsa1S4Zpp2nM= github.com/launchdarkly/go-sdk-common/v3 v3.5.0 h1:DsfTimg4BZO2hQPeeeEZYZVsP25pAWEdq/rKv/b7HJU= github.com/launchdarkly/go-sdk-common/v3 v3.5.0/go.mod h1:6MNeeP8b2VtsM6I3TbShCHW/+tYh2c+p5dB+ilS69sg= +github.com/launchdarkly/go-sdk-common/v3 v3.5.1-0.20260707203913-381c033e045f h1:wsrL79voFEPH70Dc6E6B9dnqWlS9nnwiKwHHw3SX7Fw= +github.com/launchdarkly/go-sdk-common/v3 v3.5.1-0.20260707203913-381c033e045f/go.mod h1:6MNeeP8b2VtsM6I3TbShCHW/+tYh2c+p5dB+ilS69sg= github.com/launchdarkly/go-sdk-events/v3 v3.6.2 h1:X5sxi1Hz5Cj8wOyXiNnJzCcCGiGIy9IOSEk31lCfDXM= github.com/launchdarkly/go-sdk-events/v3 v3.6.2/go.mod h1:s86HRgbVAHjoQjQEOzgOB+ZKWlU4wwHAeDa/5N6MwO0= +github.com/launchdarkly/go-sdk-events/v3 v3.6.3-0.20260707205009-8af496ae2ab5 h1:19MHm77s549uaFl3iP/+g7T98FNQt1PF5sPITH93HKo= +github.com/launchdarkly/go-sdk-events/v3 v3.6.3-0.20260707205009-8af496ae2ab5/go.mod h1:s86HRgbVAHjoQjQEOzgOB+ZKWlU4wwHAeDa/5N6MwO0= github.com/launchdarkly/go-semver v1.0.3 h1:agIy/RN3SqeQDIfKkl+oFslEdeIs7pgsJBs3CdCcGQM= github.com/launchdarkly/go-semver v1.0.3/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 h1:rTgcYAFraGFj7sBMB2b7JCYCm0b9kph4FaMX02t4osQ= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d h1:ZtI+ZGVhnUaLOLaG20tt7hN3G+i5PiqE2lFZNMzvUQM= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707205605-a8cc07261329 h1:+Jg/Xrqg5ZaOdagIHv386xVkm/EbjXWcZTn1h1DQPls= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707205605-a8cc07261329/go.mod h1:rDPT4v+kM13xg1hgTZBwiv3UEgArn8jJi+TIelyx36k= github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= diff --git a/ldclient.go b/ldclient.go index b24074b9..7f2757ac 100644 --- a/ldclient.go +++ b/ldclient.go @@ -790,9 +790,14 @@ func (client *LDClient) AllFlagsState(context ldcontext.Context, options ...flag client.loggers.Warn("Called AllFlagsState in offline mode. Returning empty state") valid = false } else if client.dataSystem.DataAvailability() != datasystem.Refreshed { - if client.dataSystem.DataAvailability() == datasystem.Cached { + switch { + case client.dataSystem.DataAvailability() == datasystem.Cached: client.loggers.Warn("Called AllFlagsState before client initialization; using last known values from data store") - } else { + case client.dataSystem.HasOverrides(): + // The merged view holds only the override entries, so the state is valid but + // contains only the overridden flags. + client.loggers.Warn("Called AllFlagsState before client initialization; returning only flags from the override layer") //nolint:lll + default: client.loggers.Warn("Called AllFlagsState before client initialization. Data store not available; returning empty state") //nolint:lll valid = false } @@ -1348,6 +1353,7 @@ func (client *LDClient) variationAndFlag( Version: flag.Version, RequireFullEvent: flag.TrackEvents, DebugEventsUntilDate: flag.DebugEventsUntilDate, + IsOverride: flag.IsOverride, }, ldevents.Context(context), result.Detail, @@ -1394,7 +1400,9 @@ func (client *LDClient) evaluateInternal( if client.dataSystem.DataAvailability() != datasystem.Refreshed { if client.dataSystem.DataAvailability() == datasystem.Cached { client.loggers.Warn("Feature flag evaluation called before LaunchDarkly client initialization completed; using last known values from data store") //nolint:lll - } else { + } else if !client.dataSystem.HasFlagOverride(key) { + // A flag present in the override layer is served even though no data is available + // from LaunchDarkly; the store read below sees it through the override overlay. return evalErrorResult(ldreason.EvalErrorClientNotReady, nil, ErrClientNotInitialized) } } diff --git a/ldclient_events.go b/ldclient_events.go index cb2d0de4..1d7bd6e3 100644 --- a/ldclient_events.go +++ b/ldclient_events.go @@ -77,6 +77,7 @@ func newEventsScope(client *LDClient, withReasons bool) eventsScope { Version: params.PrerequisiteFlag.Version, RequireFullEvent: params.PrerequisiteFlag.TrackEvents, DebugEventsUntilDate: params.PrerequisiteFlag.DebugEventsUntilDate, + IsOverride: params.PrerequisiteFlag.IsOverride, }, ldevents.Context(params.Context), params.PrerequisiteResult.Detail, diff --git a/ldclient_overrides_test.go b/ldclient_overrides_test.go new file mode 100644 index 00000000..2e0c07cc --- /dev/null +++ b/ldclient_overrides_test.go @@ -0,0 +1,195 @@ +package ldclient + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/launchdarkly/go-sdk-common/v3/ldreason" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + ldevents "github.com/launchdarkly/go-sdk-events/v3" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel" + "github.com/launchdarkly/go-server-sdk/v7/internal/datakinds" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest" + "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest/mocks" + "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" + "github.com/launchdarkly/go-server-sdk/v7/subsystems" + st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes" + + "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hangingSynchronizer is a DataSynchronizer that connects but never yields data, leaving +// the client permanently uninitialized. +type hangingSynchronizer struct { + quit chan struct{} +} + +func newHangingSynchronizer() *hangingSynchronizer { + return &hangingSynchronizer{quit: make(chan struct{})} +} + +func (h *hangingSynchronizer) Name() string { return "HangingSynchronizer" } + +func (h *hangingSynchronizer) Fetch(ds subsystems.DataSelector, ctx context.Context) (*subsystems.Basis, bool, error) { + return nil, false, errors.New("no data available") +} + +func (h *hangingSynchronizer) Sync(ds subsystems.DataSelector) <-chan subsystems.DataSynchronizerResult { + results := make(chan subsystems.DataSynchronizerResult) + go func() { + <-h.quit + close(results) + }() + return results +} + +func (h *hangingSynchronizer) Close() error { + close(h.quit) + return nil +} + +func (h *hangingSynchronizer) Build(subsystems.ClientContext) (subsystems.DataSynchronizer, error) { + return h, nil +} + +func overrideTestFlagData(flags ...ldmodel.FeatureFlag) []st.Collection { + coll := st.Collection{Kind: datakinds.Features} + for _, flag := range flags { + coll.Items = append(coll.Items, + st.KeyedItemDescriptor{Key: flag.Key, Item: sharedtest.FlagDescriptor(flag)}) + } + return []st.Collection{coll} +} + +func singleValueFlag(key string, value ldvalue.Value) ldmodel.FeatureFlag { + return ldbuilders.NewFlagBuilder(key).SingleVariation(value).Build() +} + +// makeUninitializedClientWithOverrides builds a client whose data system can never obtain +// LaunchDarkly data, with the given override source contents. +func makeUninitializedClientWithOverrides( + t *testing.T, + source *sharedtest.TestOverrideSource, + events ldevents.EventProcessor, +) *LDClient { + t.Helper() + config := Config{ + Logging: ldcomponents.Logging().Loggers(ldlogtest.NewMockLog().Loggers), + DataSystem: ldcomponents.DataSystem().Custom(). + Synchronizers(newHangingSynchronizer()). + Overrides(source), + } + if events == nil { + config.Events = ldcomponents.NoEvents() + } else { + config.Events = mocks.SingleComponentConfigurer[ldevents.EventProcessor]{Instance: events} + } + client, _ := MakeCustomClient(testSdkKey, config, time.Duration(0)) + require.NotNil(t, client) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func TestOverrideIsServedWhenClientIsNotInitialized(t *testing.T) { + source := sharedtest.NewTestOverrideSource( + overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true)))) + client := makeUninitializedClientWithOverrides(t, source, nil) + + require.False(t, client.Initialized()) + + value, detail, err := client.BoolVariationDetail("overridden-flag", evalTestUser, false) + require.NoError(t, err) + assert.True(t, value) + assert.Equal(t, ldreason.EvalReasonOff, detail.Reason.GetKind()) + assert.True(t, detail.Reason.IsOverride()) +} + +func TestNonOverriddenFlagStillShortCircuitsWhenClientIsNotInitialized(t *testing.T) { + source := sharedtest.NewTestOverrideSource( + overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true)))) + client := makeUninitializedClientWithOverrides(t, source, nil) + + value, detail, err := client.BoolVariationDetail("other-flag", evalTestUser, false) + assert.Equal(t, ErrClientNotInitialized, err) + assert.False(t, value) + assert.Equal(t, ldreason.NewEvalReasonError(ldreason.EvalErrorClientNotReady), detail.Reason) +} + +func TestOverrideRemovalRestoresShortCircuit(t *testing.T) { + source := sharedtest.NewTestOverrideSource( + overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true)))) + client := makeUninitializedClientWithOverrides(t, source, nil) + + value, err := client.BoolVariation("overridden-flag", evalTestUser, false) + require.NoError(t, err) + require.True(t, value) + + source.SetOverrides(nil) + + value, err = client.BoolVariation("overridden-flag", evalTestUser, false) + assert.Equal(t, ErrClientNotInitialized, err) + assert.False(t, value) +} + +func TestAllFlagsStateContainsOnlyOverridesWhenClientIsNotInitialized(t *testing.T) { + source := sharedtest.NewTestOverrideSource( + overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true)))) + client := makeUninitializedClientWithOverrides(t, source, nil) + + state := client.AllFlagsState(evalTestUser) + assert.True(t, state.IsValid()) + values := state.ToValuesMap() + require.Len(t, values, 1) + assert.Equal(t, ldvalue.Bool(true), values["overridden-flag"]) +} + +func TestAllFlagsStateIsInvalidWhenNotInitializedAndOverrideLayerIsEmpty(t *testing.T) { + source := sharedtest.NewTestOverrideSource(nil) + client := makeUninitializedClientWithOverrides(t, source, nil) + + state := client.AllFlagsState(evalTestUser) + assert.False(t, state.IsValid()) + assert.Len(t, state.ToValuesMap(), 0) +} + +func TestOverrideEvaluationEventsCarryOverrideMarker(t *testing.T) { + events := &mocks.CapturingEventProcessor{} + source := sharedtest.NewTestOverrideSource( + overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true)))) + client := makeUninitializedClientWithOverrides(t, source, events) + + _, err := client.BoolVariation("overridden-flag", evalTestUser, false) + require.NoError(t, err) + + var evalData []ldevents.EvaluationData + for _, e := range events.Events { + if ed, ok := e.(ldevents.EvaluationData); ok { + evalData = append(evalData, ed) + } + } + require.Len(t, evalData, 1) + assert.True(t, evalData[0].IsOverride) + assert.Equal(t, "overridden-flag", evalData[0].Key) +} + +func TestFlagTrackerIsNotifiedOfOverrideChanges(t *testing.T) { + source := sharedtest.NewTestOverrideSource(nil) + client := makeUninitializedClientWithOverrides(t, source, nil) + + listener := client.GetFlagTracker().AddFlagChangeListener() + + source.SetOverrides(overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true)))) + + select { + case event := <-listener: + assert.Equal(t, "overridden-flag", event.Key) + case <-time.After(5 * time.Second): + require.FailNow(t, "timed out waiting for flag change event") + } +}