Skip to content

Commit e45cb14

Browse files
authored
feat: thread local local wasm resolver (#291)
* thread local swapwasmresolverapi * add retry strategy for chickory exceptions * fixup! add retry strategy for chickory exceptions * pass the retry strategy * pre create the wasm modules * fixup! pre create the wasm modules * log num instances * use deterministic thread id mapping * fixup! use deterministic thread id mapping
1 parent 2426be4 commit e45cb14

12 files changed

Lines changed: 512 additions & 56 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.spotify.confidence;
2+
3+
import com.dylibso.chicory.wasm.ChicoryException;
4+
import java.util.function.Supplier;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
8+
/**
9+
* A retry strategy that retries operations with exponential backoff for Chicory WASM runtime
10+
* exceptions.
11+
*/
12+
class ExponentialRetryStrategy implements RetryStrategy {
13+
private static final Logger logger = LoggerFactory.getLogger(ExponentialRetryStrategy.class);
14+
15+
private final int maxRetries;
16+
private final long initialBackoffMs;
17+
private final double backoffMultiplier;
18+
19+
/**
20+
* Creates an exponential retry strategy with the specified configuration.
21+
*
22+
* @param maxRetries Maximum number of retries (not including the initial attempt)
23+
* @param initialBackoffMs Initial backoff delay in milliseconds
24+
* @param backoffMultiplier Multiplier for exponential backoff
25+
*/
26+
public ExponentialRetryStrategy(int maxRetries, long initialBackoffMs, double backoffMultiplier) {
27+
this.maxRetries = maxRetries;
28+
this.initialBackoffMs = initialBackoffMs;
29+
this.backoffMultiplier = backoffMultiplier;
30+
}
31+
32+
/**
33+
* Creates an exponential retry strategy with default configuration (3 retries, 100ms initial).
34+
*/
35+
public ExponentialRetryStrategy() {
36+
this(3, 100, 2.0);
37+
}
38+
39+
@Override
40+
public <T> T execute(Supplier<T> operation, String operationName) {
41+
RuntimeException lastException = null;
42+
long backoffMs = initialBackoffMs;
43+
44+
for (int attempt = 0; attempt <= maxRetries; attempt++) {
45+
try {
46+
return operation.get();
47+
} catch (ChicoryException e) {
48+
logger.warn("{} attempt {} failed: {}", operationName, attempt + 1, e.getMessage());
49+
lastException = e;
50+
if (attempt < maxRetries) {
51+
try {
52+
Thread.sleep(backoffMs);
53+
} catch (InterruptedException ie) {
54+
Thread.currentThread().interrupt();
55+
throw new RuntimeException(operationName + " interrupted during retry backoff", ie);
56+
}
57+
backoffMs = (long) (backoffMs * backoffMultiplier);
58+
}
59+
}
60+
}
61+
62+
throw new RuntimeException(
63+
operationName + " failed after " + (maxRetries + 1) + " attempts", lastException);
64+
}
65+
}

openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class LocalResolverServiceFactory implements ResolverServiceFactory {
3535
private final AtomicReference<ResolverState> resolverStateHolder;
3636
private final ResolveTokenConverter resolveTokenConverter;
3737

38-
private final SwapWasmResolverApi wasmResolveApi;
38+
private final ResolverApi wasmResolveApi;
3939
private final Supplier<Instant> timeSupplier;
4040
private final Supplier<String> resolveIdSupplier;
4141
private final FlagLogger flagLogger;
@@ -67,22 +67,27 @@ static FlagResolverService from(
6767
ApiSecret apiSecret,
6868
String clientSecret,
6969
boolean isWasm,
70-
StickyResolveStrategy stickyResolveStrategy) {
71-
return createFlagResolverService(apiSecret, clientSecret, isWasm, stickyResolveStrategy);
70+
StickyResolveStrategy stickyResolveStrategy,
71+
RetryStrategy retryStrategy) {
72+
return createFlagResolverService(
73+
apiSecret, clientSecret, isWasm, stickyResolveStrategy, retryStrategy);
7274
}
7375

7476
static FlagResolverService from(
7577
AccountStateProvider accountStateProvider,
7678
String accountId,
77-
StickyResolveStrategy stickyResolveStrategy) {
78-
return createFlagResolverService(accountStateProvider, accountId, stickyResolveStrategy);
79+
StickyResolveStrategy stickyResolveStrategy,
80+
RetryStrategy retryStrategy) {
81+
return createFlagResolverService(
82+
accountStateProvider, accountId, stickyResolveStrategy, retryStrategy);
7983
}
8084

8185
private static FlagResolverService createFlagResolverService(
8286
ApiSecret apiSecret,
8387
String clientSecret,
8488
boolean isWasm,
85-
StickyResolveStrategy stickyResolveStrategy) {
89+
StickyResolveStrategy stickyResolveStrategy,
90+
RetryStrategy retryStrategy) {
8691
final var channel = createConfidenceChannel();
8792
final AuthServiceBlockingStub authService = AuthServiceGrpc.newBlockingStub(channel);
8893
final TokenHolder tokenHolder =
@@ -106,12 +111,13 @@ private static FlagResolverService createFlagResolverService(
106111

107112
final var wasmFlagLogger = new GrpcWasmFlagLogger(apiSecret);
108113
if (isWasm) {
109-
final SwapWasmResolverApi wasmResolverApi =
110-
new SwapWasmResolverApi(
114+
final ResolverApi wasmResolverApi =
115+
new ThreadLocalSwapWasmResolverApi(
111116
wasmFlagLogger,
112117
sidecarFlagsAdminFetcher.rawStateHolder().get().toByteArray(),
113118
sidecarFlagsAdminFetcher.accountId,
114-
stickyResolveStrategy);
119+
stickyResolveStrategy,
120+
retryStrategy);
115121
flagsFetcherExecutor.scheduleAtFixedRate(
116122
sidecarFlagsAdminFetcher::reload,
117123
pollIntervalSeconds,
@@ -167,7 +173,8 @@ private static boolean getFailFast(StickyResolveStrategy stickyResolveStrategy)
167173
private static FlagResolverService createFlagResolverService(
168174
AccountStateProvider accountStateProvider,
169175
String accountId,
170-
StickyResolveStrategy stickyResolveStrategy) {
176+
StickyResolveStrategy stickyResolveStrategy,
177+
RetryStrategy retryStrategy) {
171178
final var mode = System.getenv("LOCAL_RESOLVE_MODE");
172179
if (!(mode == null || mode.equals("WASM"))) {
173180
throw new RuntimeException("Only WASM mode supported with AccountStateProvider");
@@ -178,9 +185,9 @@ private static FlagResolverService createFlagResolverService(
178185
.orElse(Duration.ofMinutes(5).toSeconds());
179186
final byte[] resolverStateProtobuf = accountStateProvider.provide();
180187
final WasmFlagLogger flagLogger = request -> WriteFlagLogsResponse.getDefaultInstance();
181-
final SwapWasmResolverApi wasmResolverApi =
182-
new SwapWasmResolverApi(
183-
flagLogger, resolverStateProtobuf, accountId, stickyResolveStrategy);
188+
final ResolverApi wasmResolverApi =
189+
new ThreadLocalSwapWasmResolverApi(
190+
flagLogger, resolverStateProtobuf, accountId, stickyResolveStrategy, retryStrategy);
184191
flagsFetcherExecutor.scheduleAtFixedRate(
185192
() -> {
186193
wasmResolverApi.updateStateAndFlushLogs(accountStateProvider.provide(), accountId);
@@ -208,7 +215,7 @@ private static FlagResolverService createFlagResolverService(
208215
}
209216

210217
LocalResolverServiceFactory(
211-
SwapWasmResolverApi wasmResolveApi,
218+
ResolverApi wasmResolveApi,
212219
AtomicReference<ResolverState> resolverStateHolder,
213220
ResolveTokenConverter resolveTokenConverter,
214221
FlagLogger flagLogger,
@@ -224,7 +231,7 @@ private static FlagResolverService createFlagResolverService(
224231
}
225232

226233
LocalResolverServiceFactory(
227-
SwapWasmResolverApi wasmResolveApi,
234+
ResolverApi wasmResolveApi,
228235
AtomicReference<ResolverState> resolverStateHolder,
229236
ResolveTokenConverter resolveTokenConverter,
230237
Supplier<Instant> timeSupplier,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.spotify.confidence;
2+
3+
import java.util.function.Supplier;
4+
5+
/** A retry strategy that doesn't retry - executes the operation once and returns or throws. */
6+
class NoRetryStrategy implements RetryStrategy {
7+
@Override
8+
public <T> T execute(Supplier<T> operation, String operationName) {
9+
return operation.get();
10+
}
11+
}

openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,12 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider {
7171
private final StickyResolveStrategy stickyResolveStrategy;
7272

7373
/**
74-
* Creates a new OpenFeature provider for local flag resolution with default fallback strategy.
74+
* Creates a new OpenFeature provider for local flag resolution with default fallback strategy and
75+
* no retry.
7576
*
7677
* <p>This constructor uses {@link RemoteResolverFallback} as the default sticky resolve strategy,
7778
* which provides fallback to the remote Confidence service when the WASM resolver encounters
78-
* missing materializations.
79+
* missing materializations. By default, no retry strategy is applied.
7980
*
8081
* <p>The provider will automatically determine the resolution mode (WASM or Java) based on the
8182
* {@code LOCAL_RESOLVE_MODE} environment variable, defaulting to WASM mode.
@@ -89,15 +90,14 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider {
8990
* @since 0.2.4
9091
*/
9192
public OpenFeatureLocalResolveProvider(ApiSecret apiSecret, String clientSecret) {
92-
this(apiSecret, clientSecret, new RemoteResolverFallback());
93+
this(apiSecret, clientSecret, new RemoteResolverFallback(), new NoRetryStrategy());
9394
}
9495

9596
/**
96-
* Creates a new OpenFeature provider for local flag resolution with configurable exposure
97-
* logging.
97+
* Creates a new OpenFeature provider for local flag resolution with configurable sticky resolve
98+
* strategy and no retry.
9899
*
99-
* <p>This is the primary constructor that allows full control over the provider configuration.
100-
* The provider will automatically determine the resolution mode (WASM or Java) based on the
100+
* <p>The provider will automatically determine the resolution mode (WASM or Java) based on the
101101
* {@code LOCAL_RESOLVE_MODE} environment variable, defaulting to WASM mode.
102102
*
103103
* @param apiSecret the API credentials containing client ID and client secret for authenticating
@@ -111,16 +111,45 @@ public OpenFeatureLocalResolveProvider(ApiSecret apiSecret, String clientSecret)
111111
*/
112112
public OpenFeatureLocalResolveProvider(
113113
ApiSecret apiSecret, String clientSecret, StickyResolveStrategy stickyResolveStrategy) {
114+
this(apiSecret, clientSecret, stickyResolveStrategy, new NoRetryStrategy());
115+
}
116+
117+
/**
118+
* Creates a new OpenFeature provider for local flag resolution with full configuration control.
119+
*
120+
* <p>This is the primary constructor that allows full control over the provider configuration,
121+
* including retry strategy. The provider will automatically determine the resolution mode (WASM
122+
* or Java) based on the {@code LOCAL_RESOLVE_MODE} environment variable, defaulting to WASM mode.
123+
*
124+
* @param apiSecret the API credentials containing client ID and client secret for authenticating
125+
* with the Confidence service. Create using {@code new ApiSecret("client-id",
126+
* "client-secret")}
127+
* @param clientSecret the client secret for your application, used for flag resolution
128+
* authentication. This is different from the API secret and is specific to your application
129+
* configuration
130+
* @param stickyResolveStrategy the strategy to use for handling sticky flag resolution
131+
* @param retryStrategy the retry strategy for WASM operations (use {@link NoRetryStrategy} to
132+
* disable retries or {@link ExponentialRetryStrategy} for exponential backoff)
133+
* @since 0.2.4
134+
*/
135+
public OpenFeatureLocalResolveProvider(
136+
ApiSecret apiSecret,
137+
String clientSecret,
138+
StickyResolveStrategy stickyResolveStrategy,
139+
RetryStrategy retryStrategy) {
114140
final var env = System.getenv("LOCAL_RESOLVE_MODE");
115141
if (env != null && env.equals("WASM")) {
116142
this.flagResolverService =
117-
LocalResolverServiceFactory.from(apiSecret, clientSecret, true, stickyResolveStrategy);
143+
LocalResolverServiceFactory.from(
144+
apiSecret, clientSecret, true, stickyResolveStrategy, retryStrategy);
118145
} else if (env != null && env.equals("JAVA")) {
119146
this.flagResolverService =
120-
LocalResolverServiceFactory.from(apiSecret, clientSecret, false, stickyResolveStrategy);
147+
LocalResolverServiceFactory.from(
148+
apiSecret, clientSecret, false, stickyResolveStrategy, retryStrategy);
121149
} else {
122150
this.flagResolverService =
123-
LocalResolverServiceFactory.from(apiSecret, clientSecret, true, stickyResolveStrategy);
151+
LocalResolverServiceFactory.from(
152+
apiSecret, clientSecret, true, stickyResolveStrategy, retryStrategy);
124153
}
125154
this.stickyResolveStrategy = stickyResolveStrategy;
126155
this.clientSecret = clientSecret;
@@ -141,10 +170,37 @@ public OpenFeatureLocalResolveProvider(
141170
String accountId,
142171
String clientSecret,
143172
StickyResolveStrategy stickyResolveStrategy) {
173+
this(
174+
accountStateProvider,
175+
accountId,
176+
clientSecret,
177+
stickyResolveStrategy,
178+
new NoRetryStrategy());
179+
}
180+
181+
/**
182+
* To be used for testing purposes only! This constructor allows to inject flags state for testing
183+
* the WASM resolver with full control over retry strategy.
184+
*
185+
* @param accountStateProvider a functional interface that provides AccountState instances
186+
* @param accountId the account ID
187+
* @param clientSecret the flag client key used to filter the flags
188+
* @param stickyResolveStrategy the strategy to use for handling sticky flag resolution
189+
* @param retryStrategy the retry strategy for WASM operations
190+
* @since 0.2.4
191+
*/
192+
@VisibleForTesting
193+
public OpenFeatureLocalResolveProvider(
194+
AccountStateProvider accountStateProvider,
195+
String accountId,
196+
String clientSecret,
197+
StickyResolveStrategy stickyResolveStrategy,
198+
RetryStrategy retryStrategy) {
144199
this.stickyResolveStrategy = stickyResolveStrategy;
145200
this.clientSecret = clientSecret;
146201
this.flagResolverService =
147-
LocalResolverServiceFactory.from(accountStateProvider, accountId, stickyResolveStrategy);
202+
LocalResolverServiceFactory.from(
203+
accountStateProvider, accountId, stickyResolveStrategy, retryStrategy);
148204
}
149205

150206
@Override
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.spotify.confidence;
2+
3+
import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyRequest;
4+
import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest;
5+
import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse;
6+
import java.util.concurrent.CompletableFuture;
7+
8+
/** Common interface for WASM-based flag resolver implementations. */
9+
interface ResolverApi {
10+
11+
/**
12+
* Resolves flags with sticky assignment support.
13+
*
14+
* @param request The resolve request with sticky context
15+
* @return A future containing the resolve response
16+
*/
17+
CompletableFuture<ResolveFlagsResponse> resolveWithSticky(ResolveWithStickyRequest request);
18+
19+
/**
20+
* Resolves flags without sticky assignment support.
21+
*
22+
* @param request The resolve request
23+
* @return The resolve response
24+
*/
25+
ResolveFlagsResponse resolve(ResolveFlagsRequest request);
26+
27+
/**
28+
* Updates the resolver state and flushes any pending logs.
29+
*
30+
* @param state The new resolver state
31+
* @param accountId The account ID
32+
*/
33+
void updateStateAndFlushLogs(byte[] state, String accountId);
34+
35+
/** Closes the resolver and releases any resources. */
36+
void close();
37+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.spotify.confidence;
2+
3+
import java.util.function.Supplier;
4+
5+
/** Strategy for retrying operations that fail with transient errors. */
6+
interface RetryStrategy {
7+
/**
8+
* Executes an operation with the configured retry strategy.
9+
*
10+
* @param operation The operation to execute
11+
* @param operationName Name of the operation for error messages
12+
* @param <T> Return type of the operation
13+
* @return The result of the operation
14+
* @throws RuntimeException if the operation fails
15+
*/
16+
<T> T execute(Supplier<T> operation, String operationName);
17+
}

0 commit comments

Comments
 (0)