From a45070e3ea31b49dea9102a444a8a0907976f20b Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Wed, 8 Oct 2025 19:39:32 +0200 Subject: [PATCH 1/4] docs: add docs about sticky resolve --- openfeature-provider-local/README.md | 21 +++ openfeature-provider-local/STICKY_RESOLVE.md | 183 +++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 openfeature-provider-local/STICKY_RESOLVE.md 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..4d054b97 --- /dev/null +++ b/openfeature-provider-local/STICKY_RESOLVE.md @@ -0,0 +1,183 @@ +# 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) + +```java +public class InMemoryMaterializationRepository implements MaterializationRepository { + private final Map> storage = new ConcurrentHashMap<>(); + + @Override + public CompletableFuture> loadMaterializedAssignmentsForUnit( + String unit, String materialization) { + return CompletableFuture.supplyAsync(() -> { + Map unitAssignments = storage.get(unit); + if (unitAssignments == null || !unitAssignments.containsKey(materialization)) { + return Map.of(); + } + return Map.of(materialization, unitAssignments.get(materialization)); + }); + } + + @Override + public CompletableFuture storeAssignment( + String unit, Map assignments) { + return CompletableFuture.runAsync(() -> { + storage.compute(unit, (key, existing) -> { + if (existing == null) { + return new ConcurrentHashMap<>(assignments); + } + existing.putAll(assignments); + return existing; + }); + }); + } + + @Override + public void close() { + storage.clear(); + } +} +``` + +### Redis (Production) + +```java +public class RedisMaterializationRepository implements MaterializationRepository { + private final JedisPool jedisPool; + private final ObjectMapper objectMapper = new ObjectMapper(); + private static final int TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days + + public RedisMaterializationRepository(JedisPool jedisPool) { + this.jedisPool = jedisPool; + } + + @Override + public CompletableFuture> loadMaterializedAssignmentsForUnit( + String unit, String materialization) { + return CompletableFuture.supplyAsync(() -> { + try (var jedis = jedisPool.getResource()) { + String key = "sticky:" + unit + ":" + materialization; + String value = jedis.get(key); + + if (value == null) return Map.of(); + + // Renew TTL on read (matching Confidence behavior) + jedis.expire(key, TTL_SECONDS); + + MaterializationInfo info = objectMapper.readValue(value, MaterializationInfo.class); + return Map.of(materialization, info); + } catch (Exception e) { + throw new RuntimeException("Failed to load from Redis", e); + } + }); + } + + @Override + public CompletableFuture storeAssignment( + String unit, Map assignments) { + return CompletableFuture.runAsync(() -> { + try (var jedis = jedisPool.getResource()) { + for (var entry : assignments.entrySet()) { + String key = "sticky:" + unit + ":" + entry.getKey(); + String value = objectMapper.writeValueAsString(entry.getValue()); + jedis.setex(key, TTL_SECONDS, value); + } + } catch (Exception e) { + // Don't fail resolve on storage errors + System.err.println("Failed to store to Redis: " + e.getMessage()); + } + }); + } + + @Override + public void close() { + jedisPool.close(); + } +} +``` + +### Usage + +```java +MaterializationRepository repository = new RedisMaterializationRepository(jedisPool); + +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) | High-traffic apps, offline scenarios | 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) From e0fdefd711a158030432b44b95d7b2c1b93e89fd Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 9 Oct 2025 17:03:34 +0200 Subject: [PATCH 2/4] test: add example InMemoryMaterializationRepo --- openfeature-provider-local/STICKY_RESOLVE.md | 100 +----------------- .../InMemoryMaterializationRepoExample.java | 89 ++++++++++++++++ 2 files changed, 93 insertions(+), 96 deletions(-) create mode 100644 openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java diff --git a/openfeature-provider-local/STICKY_RESOLVE.md b/openfeature-provider-local/STICKY_RESOLVE.md index 4d054b97..1d565954 100644 --- a/openfeature-provider-local/STICKY_RESOLVE.md +++ b/openfeature-provider-local/STICKY_RESOLVE.md @@ -54,104 +54,12 @@ record MaterializationInfo( ### In-Memory (Testing/Development) -```java -public class InMemoryMaterializationRepository implements MaterializationRepository { - private final Map> storage = new ConcurrentHashMap<>(); - - @Override - public CompletableFuture> loadMaterializedAssignmentsForUnit( - String unit, String materialization) { - return CompletableFuture.supplyAsync(() -> { - Map unitAssignments = storage.get(unit); - if (unitAssignments == null || !unitAssignments.containsKey(materialization)) { - return Map.of(); - } - return Map.of(materialization, unitAssignments.get(materialization)); - }); - } - - @Override - public CompletableFuture storeAssignment( - String unit, Map assignments) { - return CompletableFuture.runAsync(() -> { - storage.compute(unit, (key, existing) -> { - if (existing == null) { - return new ConcurrentHashMap<>(assignments); - } - existing.putAll(assignments); - return existing; - }); - }); - } - - @Override - public void close() { - storage.clear(); - } -} -``` - -### Redis (Production) - -```java -public class RedisMaterializationRepository implements MaterializationRepository { - private final JedisPool jedisPool; - private final ObjectMapper objectMapper = new ObjectMapper(); - private static final int TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days - - public RedisMaterializationRepository(JedisPool jedisPool) { - this.jedisPool = jedisPool; - } - - @Override - public CompletableFuture> loadMaterializedAssignmentsForUnit( - String unit, String materialization) { - return CompletableFuture.supplyAsync(() -> { - try (var jedis = jedisPool.getResource()) { - String key = "sticky:" + unit + ":" + materialization; - String value = jedis.get(key); - - if (value == null) return Map.of(); - - // Renew TTL on read (matching Confidence behavior) - jedis.expire(key, TTL_SECONDS); - - MaterializationInfo info = objectMapper.readValue(value, MaterializationInfo.class); - return Map.of(materialization, info); - } catch (Exception e) { - throw new RuntimeException("Failed to load from Redis", e); - } - }); - } - - @Override - public CompletableFuture storeAssignment( - String unit, Map assignments) { - return CompletableFuture.runAsync(() -> { - try (var jedis = jedisPool.getResource()) { - for (var entry : assignments.entrySet()) { - String key = "sticky:" + unit + ":" + entry.getKey(); - String value = objectMapper.writeValueAsString(entry.getValue()); - jedis.setex(key, TTL_SECONDS, value); - } - } catch (Exception e) { - // Don't fail resolve on storage errors - System.err.println("Failed to store to Redis: " + e.getMessage()); - } - }); - } - - @Override - public void close() { - jedisPool.close(); - } -} -``` +[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 +#### Usage ```java -MaterializationRepository repository = new RedisMaterializationRepository(jedisPool); +MaterializationRepository repository = new InMemoryMaterializationRepoExample(); OpenFeatureLocalResolveProvider provider = new OpenFeatureLocalResolveProvider( apiSecret, @@ -174,7 +82,7 @@ OpenFeatureLocalResolveProvider provider = new OpenFeatureLocalResolveProvider( |----------|----------|------------| | **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) | High-traffic apps, offline scenarios | No network calls. Requires storage infra. | +| **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. 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..488f56ed --- /dev/null +++ b/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java @@ -0,0 +1,89 @@ +package org.example; + +import com.spotify.confidence.MaterializationInfo; +import com.spotify.confidence.MaterializationRepository; +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(InMemoryMaterializationRepo.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) { + MaterializationInfo emptyInfo = new MaterializationInfo(false, new HashMap<>()); + Map map = new HashMap<>(); + map.put(key, emptyInfo); + return map; + } + + @Override + public CompletableFuture> loadMaterializedAssignmentsForUnit( + String unit, String materialization) { + Map unitAssignments = storage.get(unit); + if (unitAssignments != null) { + if (unitAssignments.containsKey(materialization)) { + 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())'. + Map newEntry = new HashMap<>(existingEntry); + if (assignments != null) { + newEntry.putAll(assignments); + } + return newEntry; + } + }); + + 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."); + } +} From 38d777ce6958e36d13fdb65b84b5ef6b5736f12e Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 9 Oct 2025 18:38:53 +0200 Subject: [PATCH 3/4] fix: add final modifier to local variables for checkstyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed checkstyle violations by adding final modifier to local variables in InMemoryMaterializationRepoExample test file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../InMemoryMaterializationRepoExample.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 488f56ed..1e9fcfc9 100644 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java +++ b/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java @@ -21,8 +21,8 @@ public class InMemoryMaterializationRepoExample implements MaterializationReposi * @return A map containing the key and a default MaterializationInfo object. */ private static Map createEmptyMap(String key) { - MaterializationInfo emptyInfo = new MaterializationInfo(false, new HashMap<>()); - Map map = new HashMap<>(); + final MaterializationInfo emptyInfo = new MaterializationInfo(false, new HashMap<>()); + final Map map = new HashMap<>(); map.put(key, emptyInfo); return map; } @@ -30,10 +30,10 @@ private static Map createEmptyMap(String key) { @Override public CompletableFuture> loadMaterializedAssignmentsForUnit( String unit, String materialization) { - Map unitAssignments = storage.get(unit); + final Map unitAssignments = storage.get(unit); if (unitAssignments != null) { if (unitAssignments.containsKey(materialization)) { - Map result = new HashMap<>(); + final Map result = new HashMap<>(); result.put(materialization, unitAssignments.get(materialization)); logger.debug("Cache hit for unit: {}, materialization: {}", unit, materialization); return CompletableFuture.supplyAsync(() -> result); @@ -67,7 +67,7 @@ public CompletableFuture storeAssignment( } else { // If an entry exists, merge the new assignments into it. // This is equivalent to Kotlin's 'existingEntry.plus(assignments ?: emptyMap())'. - Map newEntry = new HashMap<>(existingEntry); + final Map newEntry = new HashMap<>(existingEntry); if (assignments != null) { newEntry.putAll(assignments); } @@ -75,7 +75,7 @@ public CompletableFuture storeAssignment( } }); - int assignmentCount = (assignments != null) ? assignments.size() : 0; + final int assignmentCount = (assignments != null) ? assignments.size() : 0; logger.debug("Stored {} assignments for unit: {}", assignmentCount, unit); return CompletableFuture.completedFuture(null); From 760fe7785c29840c1dde542c5970364312d481ec Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 9 Oct 2025 18:38:53 +0200 Subject: [PATCH 4/4] fix: add final modifier to local variables for checkstyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed checkstyle violations by adding final modifier to local variables in InMemoryMaterializationRepoExample test file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../confidence/InMemoryMaterializationRepoExample.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 index 1e9fcfc9..f921d5b6 100644 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java +++ b/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java @@ -1,7 +1,5 @@ -package org.example; +package com.spotify.confidence; -import com.spotify.confidence.MaterializationInfo; -import com.spotify.confidence.MaterializationRepository; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -11,7 +9,8 @@ public class InMemoryMaterializationRepoExample implements MaterializationRepository { - private static final Logger logger = LoggerFactory.getLogger(InMemoryMaterializationRepo.class); + private static final Logger logger = + LoggerFactory.getLogger(InMemoryMaterializationRepoExample.class); private final Map> storage = new ConcurrentHashMap<>(); /**