Skip to content

feat(internal): add flag/segment override layer, overlay store, and change notification diff - #408

Merged
kinyoklion merged 8 commits into
feat/overridesfrom
rlamb/sdk-2655/internal-overrides
Sep 21, 2026
Merged

kinyoklion merged 8 commits into
feat/overridesfrom
rlamb/sdk-2655/internal-overrides

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Jul 7, 2026

Copy link
Copy Markdown
Member

Adds internal/overrides, the core of the flag-overrides feature (OVERRIDE spec): a runtime-mutable layer of flag and segment definitions that will take precedence over LaunchDarkly data at the store read boundary. Nothing consumes this package yet -- FDv2 wiring and the public source API come next -- so this PR is the data structures and their semantics in isolation.

  • Layer: thread-safe holder of override entries, replaced wholesale on every update (an update is a full snapshot; entries absent from it are removed). Entries are stored as shallow copies carrying the typed-model override marker. The copy shares its nested slices with the source's entity and the layer never writes to them, so a source may retain its entities and supply them again. Entities from deserialization or the builders already carry their preprocessing caches; the evaluator scans values when a cache is absent. A read of an empty layer is a single atomic check, so a configured-but-unpopulated layer costs almost nothing per evaluation.
  • Overlay: a ReadOnlyStore that merges the layer over a base store -- the override entry wins per key on Get (including over deleted-item tombstones), GetAll is the union, and initialization status delegates to the base untouched. When the base store fails and the layer holds entries, GetAll returns the layer's entries alone with no error, so an all-flags read during a persistent-store outage still carries the overrides that per-key reads serve; an empty layer still returns the base error. Serving overrides from an uninitialized base works because an uninitialized memory store reports not-found rather than erroring.
  • Sink: applies serialized layer replacements and computes which flags to notify. The seed set is every added, removed, or changed override entry -- added and removed keys always count, because the marker alone changes what an evaluation reports, and same-key changes compare by serialized form since the layer is rebuilt from scratch each time (pointer or version comparison would notify every retained entry, or nothing). Fan-out then runs through dependency trackers built over both the old and the new merged views, because a replacement can rewire dependencies -- e.g. removing a flag override restores the LaunchDarkly definition's prerequisite edges, and dependents of the override's own references only exist in the old view. A base read failure degrades fan-out but never drops the directly changed keys.

Depends on go-server-sdk-evaluation carrying the ldmodel override marker (launchdarkly/go-server-sdk-evaluation#57); go.mod points at a pseudo-version of that branch and will be pinned to the released version before merge. Based on #404 (the dependency-tracker consolidation this reuses).

SDK-2655


Note

Overview
Introduces internal/overrides, the isolated core for runtime flag/segment overrides: a wholesale-replacement Layer (marked shallow copies, no mutation of source entities), a Overlay ReadOnlyStore that wins on per-key reads and unions on GetAll (including overrides when the base is uninitialized or GetAll fails), and a Sink that applies updates and notifies flag listeners using serialized diff seeds plus dependency fan-out across old and new merged views.

Adds internal/filedata.Poller, an interval-based multi-file watcher (mod time/size, create/delete) meant to complement or replace unreliable fs notifications and feed reload paths.

go.mod moves to pseudo-versions of go-sdk-common and go-server-sdk-evaluation for the ldmodel override marker; nothing in the SDK wires these packages yet.

Reviewed by Cursor Bugbot for commit 8480fc6. Bugbot is set up for automated code reviews on this repo. Configure here.

Base automatically changed from rlamb/sdk-2655/toposort-dependency-tracker to v7 July 8, 2026 19:36
@kinyoklion
kinyoklion force-pushed the rlamb/sdk-2655/internal-overrides branch from 3624348 to cc8c47c Compare September 15, 2026 21:46
@kinyoklion
kinyoklion changed the base branch from v7 to feat/overrides September 17, 2026 21:15
…ogress

A file system that does not respond must not block shutdown. Close now signals the loop and returns, matching the Reloader, and the doc states that onChange can run once more shortly after Close. The doc uses ASCII, states that only modification time and size are compared, and drops the claim about temporarily unreadable files. Tests pin the Close contract, same-size and same-time changes, single firing per change, and changes to the first of several files.
@kinyoklion
kinyoklion force-pushed the rlamb/sdk-2655/internal-overrides branch from b677459 to 5e63a76 Compare September 17, 2026 21:28
Callers read through the overlay and act on the result, so a separate existence check is not needed.
@kinyoklion
kinyoklion changed the base branch from feat/overrides to rlamb/sdk-2654/filedata-poller September 17, 2026 22:17
@kinyoklion
kinyoklion marked this pull request as ready for review September 17, 2026 22:21
@kinyoklion
kinyoklion requested a review from a team as a code owner September 17, 2026 22:21

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5fbd58d. Configure here.

Comment thread internal/overrides/layer.go
Comment thread internal/overrides/layer.go Outdated
The marked copy was shallow, and the preprocessing it ran wrote caches into the nested slices it still shared with the source's entity. A source that retains an entity and supplies it again while evaluation reads the stored copy raced on those writes. The copy now sets the marker and nothing else. Entities from deserialization or the builders already carry their caches, and the evaluator scans values when a cache is absent, so evaluation is unchanged.
Base automatically changed from rlamb/sdk-2654/filedata-poller to feat/overrides September 18, 2026 16:23
…e fails

A per-key read returns an override entry whatever the state of the base store. An all-flags read returned the base store's error instead and dropped the override entries with it. It now returns the layer's entries alone when the base fails and the layer holds entries. An empty layer still returns the error, so a client without overrides behaves as before.
The test builds raw entities without preprocessing caches and pristine twins of them. After the layer stores the entities, a deep comparison shows every field of the supplied entities is unchanged, including the caches inside the shared rules, clauses, and targets. The stored copies then evaluate through the accessors' fallbacks. The check is deterministic and does not depend on the race detector.
@kinyoklion
kinyoklion merged commit ee56728 into feat/overrides Sep 21, 2026
27 checks passed
@kinyoklion
kinyoklion deleted the rlamb/sdk-2655/internal-overrides branch September 21, 2026 16:48
kinyoklion added a commit that referenced this pull request Sep 21, 2026
Connects the override layer to the outside world:

- New public seam in `subsystems`: `OverrideSource` (implemented by
sources; `Start(sink)`/`Close`) and `OverrideSink` (implemented by the
SDK; `SetOverrides` takes a full replacement snapshot of flag/segment
collections and the SDK applies the override marker itself). Both are
new interfaces -- nothing is added to any existing exported interface.
- Configuration: a new `OverrideSource` field on
`subsystems.DataSystemConfiguration` and an `Overrides(...)` builder
method on `ldcomponents.DataSystemConfigurationBuilder`, so the eventual
usage is `ldcomponents.DataSystem().Default().Overrides(...)`. A single
source, not a list: combining multiple documents (and the duplicate-key
policy that implies) is the source's job, per the spec's multi-file
semantics.
- FDv2 owns the lifecycle: when a source is configured, it builds the
layer and overlay, returns the overlay from `Store()` (all three client
read paths -- evaluation, prerequisites/segments, all-flags -- go
through that one accessor), starts the source synchronously before the
run loop so a synchronously-loading source has its overrides in place
before the client evaluates anything, and closes it first on `Stop()`.
With no source configured, `Store()` returns the raw store and nothing
about the system changes. The relay data destination and persistence
keep seeing the raw store, and data availability is untouched by
overrides. Overrides are inert in offline/disabled mode.
- The data system exposes no probe of the override layer's contents. The
client decides the not-initialized short-circuit from the store read
itself (next PR), which avoids a check-then-read race across a layer
replacement.
- `internal/sharedtest` gains a programmatic `TestOverrideSource`, which
is both the test vehicle here and the reference implementation of the
seam.

### Configuration in use

An override source is any type that implements
`subsystems.OverrideSource`. The builder takes a
`ComponentConfigurer` so the source is built with the client context,
like every other component.

```go
// pinnedFlags is an override source that forces a fixed set of flags on a running client.
type pinnedFlags struct {
	sink subsystems.OverrideSink
}

// Start receives the sink and pushes the initial snapshot. The SDK calls Start before the data
// system runs, so a source that loads synchronously has its overrides in place before the first
// evaluation.
func (p *pinnedFlags) Start(sink subsystems.OverrideSink) {
	p.sink = sink
	sink.SetOverrides(loadPinnedFlags()) // []ldstoretypes.Collection
}

// Close is called first when the client stops.
func (p *pinnedFlags) Close() error { return nil }

// Build satisfies subsystems.ComponentConfigurer[subsystems.OverrideSource].
func (p *pinnedFlags) Build(subsystems.ClientContext) (subsystems.OverrideSource, error) {
	return p, nil
}

config := ld.Config{
	DataSystem: ldcomponents.DataSystem().Default().Overrides(&pinnedFlags{}),
}
client, err := ld.MakeCustomClient(sdkKey, config, 10*time.Second)

// Later, replace the whole snapshot. Flags not present in the new data are no longer overridden;
// an empty slice removes every override.
source.sink.SetOverrides(newPinnedFlags())
```

FDv1 behavior is unchanged; overrides are structurally FDv2-only since
the configuration hangs off `Config.DataSystem`.

Based on #408 (the override layer this wires up).

SDK-2655

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Adds a **public override seam** so operators can pin flag/segment
definitions that win over LaunchDarkly data at read time, without
touching initialization or data-source status.
> 
> New `subsystems.OverrideSource` / `OverrideSink` types and
`DataSystemConfiguration.OverrideSource`, plus
`ldcomponents.DataSystemConfigurationBuilder.Overrides(...)` for
configuration.
> 
> **FDv2** now optionally builds an override layer and
`overrides.Overlay`, returns the overlay from `Store()` when configured,
starts the source (with an SDK `Sink`) **before** the run loop so
synchronous loads apply before evaluation, closes the source on
`Stop()`, and skips all of this when the data system is disabled.
Persistence, relay, and `DataAvailability()` keep using the underlying
store unchanged.
> 
> Includes `internal/sharedtest.TestOverrideSource` and FDv2 integration
tests (overlay vs raw store, flag-change events, lifecycle).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
843e62b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants