Skip to content

Commit 0b833ca

Browse files
feat(go): Materialization fallback
1 parent e16f983 commit 0b833ca

10 files changed

+545
-122
lines changed

openfeature-provider/go/README.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ The `ProviderConfig` struct contains all configuration options for the provider:
8686
#### Optional Fields
8787

8888
- `Logger` (*slog.Logger): Custom logger for provider operations. If not provided, a default text logger is created. See [Logging](#logging) for details.
89+
- `StickyResolveStrategy` (StickyResolveStrategy): Strategy for handling sticky resolve scenarios. Defaults to `RemoteResolverFallback`. See [Sticky Resolve](#sticky-resolve) for details.
8990
- `ResolverStateServiceAddr` (string): Custom address for the resolver state service. Defaults to `edge-grpc.spotify.com`
9091
- `FlagLoggerServiceAddr` (string): Custom address for the flag logger service. Defaults to `edge-grpc.spotify.com`
9192
- `AuthServiceAddr` (string): Custom address for the auth service. Defaults to `edge-grpc.spotify.com`
@@ -110,6 +111,118 @@ provider, err := confidence.NewProviderWithStateProvider(ctx,
110111

111112
**Important**: This configuration disables automatic state fetching and exposure logging. For production deployments, always use `NewProvider()` with `ProviderConfig`.
112113

114+
## Sticky Resolve
115+
116+
Sticky Resolve ensures users receive the same variant throughout an experiment, even if their targeting attributes change or you pause new assignments.
117+
118+
**Two main use cases:**
119+
1. **Consistent experience** - User moves countries but keeps the same variant
120+
2. **Pause intake** - Stop new assignments while maintaining existing ones
121+
122+
### Default: Server-Side Storage (RemoteResolverFallback)
123+
124+
By default, the provider uses `RemoteResolverFallback` which automatically handles sticky assignments via network calls to Confidence when needed.
125+
126+
**Flow:**
127+
1. Local WASM resolver attempts to resolve
128+
2. If sticky data needed → network call to Confidence
129+
3. Confidence checks its sticky repository, returns variant
130+
4. Assignment stored server-side with 90-day TTL (auto-renewed on access)
131+
132+
**Server-side configuration (in Confidence UI):**
133+
- Optionally skip targeting criteria for sticky assignments
134+
- Pause/resume new entity intake
135+
- Automatic TTL management
136+
137+
### Custom: Local Storage (MaterializationRepository)
138+
139+
Implement `MaterializationRepository` to store assignments locally and eliminate network calls:
140+
141+
```go
142+
type MaterializationRepository interface {
143+
StickyResolveStrategy
144+
145+
// LoadMaterializedAssignmentsForUnit loads assignments for a unit.
146+
// Returns a map of materialization name to MaterializationInfo.
147+
LoadMaterializedAssignmentsForUnit(ctx context.Context, unit, materialization string) (map[string]*MaterializationInfo, error)
148+
149+
// StoreAssignment stores materialization assignments for a unit.
150+
StoreAssignment(ctx context.Context, unit string, assignments map[string]*MaterializationInfo) error
151+
}
152+
153+
type MaterializationInfo struct {
154+
// UnitInMaterialization indicates if the unit exists in the materialization
155+
UnitInMaterialization bool
156+
// RuleToVariant maps rule IDs to their assigned variant names
157+
RuleToVariant map[string]string
158+
}
159+
```
160+
161+
### Example: In-Memory Repository
162+
163+
```go
164+
type InMemoryMaterializationRepository struct {
165+
mu sync.RWMutex
166+
storage map[string]map[string]*confidence.MaterializationInfo // unit -> materialization -> info
167+
}
168+
169+
func (r *InMemoryMaterializationRepository) LoadMaterializedAssignmentsForUnit(
170+
ctx context.Context, unit, materialization string,
171+
) (map[string]*confidence.MaterializationInfo, error) {
172+
r.mu.RLock()
173+
defer r.mu.RUnlock()
174+
175+
if unitData, ok := r.storage[unit]; ok {
176+
result := make(map[string]*confidence.MaterializationInfo)
177+
for k, v := range unitData {
178+
result[k] = v
179+
}
180+
return result, nil
181+
}
182+
return make(map[string]*confidence.MaterializationInfo), nil
183+
}
184+
185+
func (r *InMemoryMaterializationRepository) StoreAssignment(
186+
ctx context.Context, unit string, assignments map[string]*confidence.MaterializationInfo,
187+
) error {
188+
r.mu.Lock()
189+
defer r.mu.Unlock()
190+
191+
if r.storage == nil {
192+
r.storage = make(map[string]map[string]*confidence.MaterializationInfo)
193+
}
194+
if r.storage[unit] == nil {
195+
r.storage[unit] = make(map[string]*confidence.MaterializationInfo)
196+
}
197+
198+
for k, v := range assignments {
199+
r.storage[unit][k] = v
200+
}
201+
return nil
202+
}
203+
204+
func (r *InMemoryMaterializationRepository) Close() {}
205+
```
206+
207+
**Usage:**
208+
209+
```go
210+
provider, err := confidence.NewProvider(ctx, confidence.ProviderConfig{
211+
APIClientID: "your-api-client-id",
212+
APIClientSecret: "your-api-client-secret",
213+
ClientSecret: "your-client-secret",
214+
StickyResolveStrategy: &InMemoryMaterializationRepository{},
215+
})
216+
```
217+
218+
### Choosing a Strategy
219+
220+
| Strategy | Best For | Trade-offs |
221+
|----------|----------|------------|
222+
| **RemoteResolverFallback** (default) | Most apps | Simple, managed by Confidence. Network calls when needed. |
223+
| **MaterializationRepository** (in-memory) | Single-instance apps, testing | Fast, no network. Lost on restart. |
224+
| **MaterializationRepository** (Redis/DB) | Distributed/Multi instance apps | No network calls. Requires storage infra. |
225+
113226
## Credentials
114227

115228
You need two types of credentials from your [Confidence dashboard](https://confidence.spotify.com/):

openfeature-provider/go/confidence/integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,13 @@ func createProviderWithTestState(
191191
runtime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
192192

193193
// Create SwapWasmResolverApi without initial state (lazy initialization)
194-
resolverAPI, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, logger, slog.New(slog.NewTextHandler(os.Stderr, nil)))
194+
resolverAPI, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, logger, slog.New(slog.NewTextHandler(os.Stderr, nil)), nil)
195195
if err != nil {
196196
return nil, err
197197
}
198198

199199
// Create provider with the client secret from test state
200200
// The test state includes client secret: mkjJruAATQWjeY7foFIWfVAcBWnci2YF
201-
provider := NewLocalResolverProvider(resolverAPI, stateProvider, logger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))
201+
provider := NewLocalResolverProvider(resolverAPI, stateProvider, logger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)), nil)
202202
return provider, nil
203203
}

openfeature-provider/go/confidence/local_resolver_provider.go

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,16 @@ const defaultPollIntervalSeconds = 10
2121
// LocalResolverProvider implements the OpenFeature FeatureProvider interface
2222
// for local flag resolution using the Confidence WASM resolver
2323
type LocalResolverProvider struct {
24-
resolverAPI WasmResolverApi
25-
stateProvider StateProvider
26-
flagLogger FlagLogger
27-
clientSecret string
28-
logger *slog.Logger
29-
cancelFunc context.CancelFunc
30-
wg sync.WaitGroup
31-
mu sync.Mutex
32-
pollInterval time.Duration
24+
resolverAPI WasmResolverApi
25+
stateProvider StateProvider
26+
flagLogger FlagLogger
27+
clientSecret string
28+
logger *slog.Logger
29+
cancelFunc context.CancelFunc
30+
wg sync.WaitGroup
31+
mu sync.Mutex
32+
pollInterval time.Duration
33+
stickyResolveStrategy StickyResolveStrategy
3334
}
3435

3536
// Compile-time interface conformance checks
@@ -45,6 +46,7 @@ func NewLocalResolverProvider(
4546
flagLogger FlagLogger,
4647
clientSecret string,
4748
logger *slog.Logger,
49+
stickyResolveStrategy StickyResolveStrategy,
4850
) *LocalResolverProvider {
4951
// Create a default logger if none provided
5052
if logger == nil {
@@ -54,12 +56,13 @@ func NewLocalResolverProvider(
5456
}
5557

5658
return &LocalResolverProvider{
57-
resolverAPI: resolverAPI,
58-
stateProvider: stateProvider,
59-
flagLogger: flagLogger,
60-
clientSecret: clientSecret,
61-
logger: logger,
62-
pollInterval: getPollIntervalSeconds(),
59+
resolverAPI: resolverAPI,
60+
stateProvider: stateProvider,
61+
flagLogger: flagLogger,
62+
clientSecret: clientSecret,
63+
logger: logger,
64+
pollInterval: getPollIntervalSeconds(),
65+
stickyResolveStrategy: stickyResolveStrategy,
6366
}
6467
}
6568

@@ -261,16 +264,23 @@ func (p *LocalResolverProvider) ObjectEvaluation(
261264
},
262265
}
263266

267+
// Determine if we should fail fast on sticky (use fallback) or not
268+
// ResolverFallback uses fail fast, MaterializationRepository retries with loaded data
269+
failFastOnSticky := false
270+
if _, ok := p.stickyResolveStrategy.(ResolverFallback); ok {
271+
failFastOnSticky = true
272+
}
273+
264274
// Create ResolveWithSticky request
265275
stickyRequest := &resolver.ResolveWithStickyRequest{
266276
ResolveRequest: request,
267277
MaterializationsPerUnit: make(map[string]*resolver.MaterializationMap),
268-
FailFastOnSticky: true,
278+
FailFastOnSticky: failFastOnSticky,
269279
NotProcessSticky: false,
270280
}
271281

272282
// Resolve flags with sticky support
273-
stickyResponse, err := p.resolverAPI.ResolveWithSticky(stickyRequest)
283+
response, err := p.resolverAPI.ResolveWithSticky(ctx, stickyRequest)
274284
if err != nil {
275285
p.logger.Error("Failed to resolve flag", "flag", flagPath, "error", err)
276286
return openfeature.InterfaceResolutionDetail{
@@ -282,31 +292,6 @@ func (p *LocalResolverProvider) ObjectEvaluation(
282292
}
283293
}
284294

285-
// Extract the actual resolve response from the sticky response
286-
var response *resolver.ResolveFlagsResponse
287-
switch result := stickyResponse.ResolveResult.(type) {
288-
case *resolver.ResolveWithStickyResponse_Success_:
289-
response = result.Success.Response
290-
case *resolver.ResolveWithStickyResponse_MissingMaterializations_:
291-
p.logger.Error("Missing materializations for flag", "flag", flagPath)
292-
return openfeature.InterfaceResolutionDetail{
293-
Value: defaultValue,
294-
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
295-
Reason: openfeature.ErrorReason,
296-
ResolutionError: openfeature.NewGeneralResolutionError("missing materializations"),
297-
},
298-
}
299-
default:
300-
p.logger.Error("Unexpected resolve result type for flag", "flag", flagPath)
301-
return openfeature.InterfaceResolutionDetail{
302-
Value: defaultValue,
303-
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
304-
Reason: openfeature.ErrorReason,
305-
ResolutionError: openfeature.NewGeneralResolutionError("unexpected resolve result"),
306-
},
307-
}
308-
}
309-
310295
// Check if flag was found
311296
if len(response.ResolvedFlags) == 0 {
312297
p.logger.Info("No active flag was found", "flag", flagPath)
@@ -450,6 +435,14 @@ func (p *LocalResolverProvider) Shutdown() {
450435
}
451436
}
452437

438+
// Close sticky resolve strategy
439+
if p.stickyResolveStrategy != nil {
440+
p.stickyResolveStrategy.Close()
441+
if p.logger != nil {
442+
p.logger.Info("Closed sticky resolve strategy")
443+
}
444+
}
445+
453446
if p.logger != nil {
454447
p.logger.Info("Provider shut down")
455448
}

openfeature-provider/go/confidence/local_resolver_provider_resolve_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func TestLocalResolverProvider_ReturnsDefaultOnError(t *testing.T) {
3434
stateBytes, _ := proto.Marshal(state)
3535

3636
flagLogger := NewNoOpWasmFlagLogger()
37-
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)))
37+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)), nil)
3838
if err != nil {
3939
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
4040
}
@@ -46,7 +46,7 @@ func TestLocalResolverProvider_ReturnsDefaultOnError(t *testing.T) {
4646
}
4747

4848
// Use different client secret that won't match
49-
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))))
49+
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)), nil))
5050
client := openfeature.NewClient("test-client")
5151

5252
evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{
@@ -85,7 +85,7 @@ func TestLocalResolverProvider_ReturnsCorrectValue(t *testing.T) {
8585
testAcctID := loadTestAccountID(t)
8686

8787
flagLogger := NewNoOpWasmFlagLogger()
88-
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)))
88+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)), nil)
8989
if err != nil {
9090
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
9191
}
@@ -97,7 +97,7 @@ func TestLocalResolverProvider_ReturnsCorrectValue(t *testing.T) {
9797
}
9898

9999
// Use the correct client secret from test data
100-
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil))))
100+
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)), nil))
101101
client := openfeature.NewClient("test-client")
102102

103103
evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{
@@ -172,7 +172,7 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) {
172172
testAcctID := loadTestAccountID(t)
173173

174174
flagLogger := NewNoOpWasmFlagLogger()
175-
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)))
175+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)), nil)
176176
if err != nil {
177177
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
178178
}
@@ -183,7 +183,7 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) {
183183
t.Fatalf("Failed to initialize swap with state: %v", err)
184184
}
185185

186-
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil))))
186+
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)), nil))
187187
client := openfeature.NewClient("test-client")
188188

189189
evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{
@@ -217,7 +217,7 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) {
217217
accountId := "test-account"
218218

219219
flagLogger := NewNoOpWasmFlagLogger()
220-
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)))
220+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, slog.New(slog.NewTextHandler(os.Stderr, nil)), nil)
221221
if err != nil {
222222
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
223223
}
@@ -228,7 +228,7 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) {
228228
t.Fatalf("Failed to initialize swap with state: %v", err)
229229
}
230230

231-
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))))
231+
openfeature.SetProviderAndWait(NewLocalResolverProvider(swap, nil, nil, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)), nil))
232232
client := openfeature.NewClient("test-client")
233233

234234
evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{
@@ -239,8 +239,8 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) {
239239
result, error := client.BooleanValueDetails(ctx, "sticky-test-flag.enabled", defaultValue, evalCtx)
240240
if error == nil {
241241
t.Error("Expected error when materializations missing, got nil")
242-
} else if error.Error() != "error code: GENERAL: missing materializations" {
243-
t.Errorf("Expected 'error code: GENERAL: missing materializations', got: %v", error.Error())
242+
} else if error.Error() != "error code: GENERAL: resolve failed: missing materializations and no sticky resolve strategy configured" {
243+
t.Errorf("Expected 'error code: GENERAL: resolve failed: missing materializations and no sticky resolve strategy configured', got: %v", error.Error())
244244
}
245245

246246
if result.Value != defaultValue {

0 commit comments

Comments
 (0)