Skip to content

Commit b067ab4

Browse files
feat(go): Materialization fallback
1 parent 6c8d959 commit b067ab4

10 files changed

+560
-128
lines changed

openfeature-provider/go/README.md

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

8484
- `Logger` (*slog.Logger): Custom logger for provider operations. If not provided, a default text logger is created. See [Logging](#logging) for details.
85+
- `StickyResolveStrategy` (StickyResolveStrategy): Strategy for handling sticky resolve scenarios. Defaults to `RemoteResolverFallback`. See [Sticky Resolve](#sticky-resolve) for details.
8586
- `ConnFactory` (func): Custom gRPC connection factory for advanced use cases (e.g., custom interceptors, TLS configuration)
8687

8788
#### Advanced: Testing with Custom State Provider
@@ -102,6 +103,118 @@ provider, err := confidence.NewProviderWithStateProvider(ctx,
102103

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

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

107220
Get your client secret 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)