diff --git a/openfeature-provider-local/README.md b/openfeature-provider-local/README.md index 1beafecf..bf848375 100644 --- a/openfeature-provider-local/README.md +++ b/openfeature-provider-local/README.md @@ -90,6 +90,27 @@ You need two types of credentials: Both can be obtained from your Confidence dashboard. +## Sticky Resolve + +The provider supports **Sticky Resolve** for consistent variant assignments across flag evaluations. This ensures users receive the same variant even when their targeting attributes change, and enables pausing experiment intake. + +**By default, sticky assignments are managed by Confidence servers.** When sticky assignment data is needed, the provider makes a network call to Confidence, which maintains the sticky repository server-side with automatic 90-day TTL management. This is a fully supported production approach that requires no additional setup. + + +Optionally, you can implement a custom `MaterializationRepository` to manage sticky assignments in your own storage (Redis, database, etc.) to eliminate network calls and improve latency: + +```java +// Optional: Custom storage for sticky assignments +MaterializationRepository repository = new RedisMaterializationRepository(jedisPool, "myapp"); +OpenFeatureLocalResolveProvider provider = new OpenFeatureLocalResolveProvider( + apiSecret, + clientSecret, + repository +); +``` + +For detailed information on how sticky resolve works and how to implement custom storage backends, see [STICKY_RESOLVE.md](STICKY_RESOLVE.md). + ## Requirements - Java 17+ diff --git a/openfeature-provider-local/STICKY_RESOLVE.md b/openfeature-provider-local/STICKY_RESOLVE.md new file mode 100644 index 00000000..1d565954 --- /dev/null +++ b/openfeature-provider-local/STICKY_RESOLVE.md @@ -0,0 +1,91 @@ +# Sticky Resolve Documentation + +## Overview + +Sticky Resolve ensures users receive the same variant throughout an experiment, even if their targeting attributes change or you pause new assignments. + +**Two main use cases:** +1. **Consistent experience** - User moves countries but keeps the same variant +2. **Pause intake** - Stop new assignments while maintaining existing ones + +**Default behavior:** Sticky assignments are managed by Confidence servers with automatic 90-day TTL. When needed, the provider makes a network call to Confidence. No setup required. + +## How It Works + +### Default: Server-Side Storage (RemoteResolverFallback) + +**Flow:** +1. Local WASM resolver attempts to resolve +2. If sticky data needed → network call to Confidence +3. Confidence checks its sticky repository, returns variant +4. Assignment stored server-side with 90-day TTL (auto-renewed on access) + +**Server-side configuration (in Confidence UI):** +- Optionally skip targeting criteria for sticky assignments +- Pause/resume new entity intake +- Automatic TTL management + +### Custom: Local Storage (MaterializationRepository) + +Implement `MaterializationRepository` to store assignments locally and eliminate network calls. + +**Interface:** +```java +public interface MaterializationRepository extends StickyResolveStrategy { + // Load assignments for a unit (e.g., user ID) + CompletableFuture> loadMaterializedAssignmentsForUnit( + String unit, String materialization); + + // Store new assignments + CompletableFuture storeAssignment( + String unit, Map assignments); +} +``` + +**MaterializationInfo structure:** +```java +record MaterializationInfo( + boolean isUnitInMaterialization, + Map ruleToVariant // rule ID -> variant name +) +``` + +## Implementation Examples + +### In-Memory (Testing/Development) + +[Here is an example](src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java) on how to implement a simple in-memory `MaterializationRepository`. The same approach can be used with other more persistent storages (like Redis or similar) which is highly recommended for production use cases. + +#### Usage + +```java +MaterializationRepository repository = new InMemoryMaterializationRepoExample(); + +OpenFeatureLocalResolveProvider provider = new OpenFeatureLocalResolveProvider( + apiSecret, + clientSecret, + repository +); +``` + +## Best Practices + +1. **Fail gracefully** - Storage errors shouldn't fail flag resolution +2. **Use 90-day TTL** - Match Confidence's default behavior, renew on read +3. **Connection pooling** - Use pools for Redis/DB connections +4. **Monitor metrics** - Track cache hit rate, storage latency, errors +5. **Test both paths** - Missing assignments (cold start) and existing assignments + +## When to Use Custom Storage + +| Strategy | Best For | Trade-offs | +|----------|----------|------------| +| **RemoteResolverFallback** (default) | Most apps | Simple, managed by Confidence. Network calls when needed. | +| **MaterializationRepository** (in-memory) | Single-instance apps, testing | Fast, no network. Lost on restart. | +| **MaterializationRepository** (Redis/DB) | Distributed/Multi instance apps | No network calls. Requires storage infra. | + +**Start with the default.** Only implement custom storage if you need to eliminate network calls or work offline. + +## Additional Resources + +- [Confidence Sticky Assignments Documentation](https://confidence.spotify.com/docs/flags/audience#sticky-assignments) diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java new file mode 100644 index 00000000..f921d5b6 --- /dev/null +++ b/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java @@ -0,0 +1,88 @@ +package com.spotify.confidence; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class InMemoryMaterializationRepoExample implements MaterializationRepository { + + private static final Logger logger = + LoggerFactory.getLogger(InMemoryMaterializationRepoExample.class); + private final Map> storage = new ConcurrentHashMap<>(); + + /** + * Helper method to create a map with a default, empty MaterializationInfo. + * + * @param key The key to use in the returned map. + * @return A map containing the key and a default MaterializationInfo object. + */ + private static Map createEmptyMap(String key) { + final MaterializationInfo emptyInfo = new MaterializationInfo(false, new HashMap<>()); + final Map map = new HashMap<>(); + map.put(key, emptyInfo); + return map; + } + + @Override + public CompletableFuture> loadMaterializedAssignmentsForUnit( + String unit, String materialization) { + final Map unitAssignments = storage.get(unit); + if (unitAssignments != null) { + if (unitAssignments.containsKey(materialization)) { + final Map result = new HashMap<>(); + result.put(materialization, unitAssignments.get(materialization)); + logger.debug("Cache hit for unit: {}, materialization: {}", unit, materialization); + return CompletableFuture.supplyAsync(() -> result); + } else { + logger.debug( + "Materialization {} not found in cached data for unit: {}", materialization, unit); + return CompletableFuture.completedFuture(createEmptyMap(materialization)); + } + } + + // If unitAssignments was null (cache miss for the unit), return an empty map structure. + return CompletableFuture.completedFuture(createEmptyMap(materialization)); + } + + @Override + public CompletableFuture storeAssignment( + String unit, Map assignments) { + if (unit == null) { + return CompletableFuture.completedFuture(null); + } + + // Use 'compute' for an atomic update operation on the ConcurrentHashMap. + storage.compute( + unit, + (k, existingEntry) -> { + if (existingEntry == null) { + // If no entry exists, create a new one. + // We create a new HashMap to avoid storing a reference to the potentially mutable + // 'assignments' map. + return assignments == null ? new HashMap<>() : new HashMap<>(assignments); + } else { + // If an entry exists, merge the new assignments into it. + // This is equivalent to Kotlin's 'existingEntry.plus(assignments ?: emptyMap())'. + final Map newEntry = new HashMap<>(existingEntry); + if (assignments != null) { + newEntry.putAll(assignments); + } + return newEntry; + } + }); + + final int assignmentCount = (assignments != null) ? assignments.size() : 0; + logger.debug("Stored {} assignments for unit: {}", assignmentCount, unit); + + return CompletableFuture.completedFuture(null); + } + + @Override + public void close() { + storage.clear(); + logger.debug("In-memory storage cleared."); + } +}