Skip to content

Commit be12839

Browse files
authored
fix: ScheduledExecutorService for Feature Refresh, Cache updates and evalPath Optimization (#168)
* feat: introduce CacheMode, factory, and safe cache fallbacks * add missing methods * support nullable cache manager * handle null case for cache manager * support null for cache manager * add in-memory cache manager * fix warnings and formatting * remove serialization churn & feature refresh patch * remove attr overrides * add polling task * add tests for polling * add cache and refresh strategies
1 parent 9f9a21c commit be12839

File tree

11 files changed

+342
-127
lines changed

11 files changed

+342
-127
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,29 @@ growthBook.isOn("featureKey");
131131
```
132132

133133
## Usage
134+
## Caching & Refresh Strategy
135+
136+
### Cache modes
137+
138+
The SDK supports multiple cache modes for persisting feature payloads:
139+
140+
- FILE: persist to a writable directory (configurable). Defaults to a safe OS-specific cache dir (or `java.io.tmpdir`).
141+
- MEMORY: in-process cache only (no filesystem writes).
142+
- NONE: no cache I/O. Runtime still holds the latest fetched features.
143+
- CUSTOM: supply your own `GbCacheManager` implementation.
144+
145+
You can configure these through `Options` when using `GrowthBookClient`, or via the repository builder’s `cacheManager` directly. When cache is disabled (`isCacheDisabled=true`), the repository won’t attempt any persistence.
146+
147+
### STALE_WHILE_REVALIDATE (default)
148+
149+
With the SWR strategy, the repository will:
150+
151+
- Perform an initial synchronous fetch during `initialize()`.
152+
- Start a lightweight background poller that revalidates features on a fixed delay (by default equal to the TTL). The poller is protected against overlapping runs and logs start/end of each polling cycle.
153+
- Keep the latest features in memory and invoke registered `FeatureRefreshCallback`s when updated so the `GlobalContext` stays fresh.
154+
155+
For SSE connections, the repository establishes a server‑sent events stream and updates as changes arrive; the SWR poller is not used.
156+
134157

135158
- The `evalFeature()` method evaluates a feature based on the provided parameters.
136159
It takes three arguments: a string representing the unique identifier of the feature,

lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.*;
44

55
import com.google.gson.JsonElement;
6+
import com.google.gson.JsonObject;
67
import growthbook.sdk.java.callback.ExperimentRunCallback;
78
import growthbook.sdk.java.callback.FeatureRefreshCallback;
89
import growthbook.sdk.java.evaluators.ExperimentEvaluator;
@@ -17,8 +18,10 @@
1718
import growthbook.sdk.java.multiusermode.configurations.GlobalContext;
1819
import growthbook.sdk.java.multiusermode.configurations.Options;
1920
import growthbook.sdk.java.multiusermode.configurations.UserContext;
20-
import growthbook.sdk.java.multiusermode.util.TransformationUtil;
2121
import growthbook.sdk.java.repository.GBFeaturesRepository;
22+
import growthbook.sdk.java.sandbox.CacheManagerFactory;
23+
import growthbook.sdk.java.sandbox.CacheMode;
24+
import growthbook.sdk.java.sandbox.GbCacheManager;
2225
import growthbook.sdk.java.util.GrowthBookJsonUtils;
2326
import lombok.extern.slf4j.Slf4j;
2427

@@ -58,14 +61,20 @@ public boolean initialize() {
5861
try {
5962

6063
if (repository == null) {
64+
GbCacheManager cm = this.options.getCacheManager() != null
65+
? this.options.getCacheManager()
66+
: CacheManagerFactory.create(this.options.getCacheMode(), this.options.getCacheDirectory()
67+
);
68+
6169
repository = GBFeaturesRepository.builder()
6270
.apiHost(this.options.getApiHost())
6371
.clientKey(this.options.getClientKey())
6472
.decryptionKey(this.options.getDecryptionKey())
6573
.refreshStrategy(this.options.getRefreshStrategy())
66-
.isCacheDisabled(this.options.getIsCacheDisabled())
67-
.cacheManager(this.options.getCacheManager())
68-
.requestBodyForRemoteEval(configurePayloadForRemoteEval(this.options)) // if we don't want to pre-fetch for remote eval we can delete this line
74+
.isCacheDisabled(this.options.getIsCacheDisabled() || this.options.getCacheMode() == CacheMode.NONE)
75+
.cacheManager(cm)
76+
// if we don't want to pre-fetch for remote eval we can delete this line
77+
.requestBodyForRemoteEval(configurePayloadForRemoteEval(this.options))
6978
.build();
7079

7180
// Add featureRefreshCallback
@@ -227,31 +236,19 @@ public void onError(Throwable throwable) {
227236
}
228237

229238
private EvaluationContext getEvalContext(UserContext userContext) {
230-
// Refresh features on each evaluation to ensure cache refresh is triggered
231-
this.globalContext.setFeatures(repository.getParsedFeatures());
232-
233-
HashMap<String, JsonElement> globalAttributes = null;
239+
// Merge attributes using JsonObject to avoid parse/serialize churn
240+
JsonObject merged = new JsonObject();
234241
if (this.options.getGlobalAttributes() != null) {
235-
globalAttributes = GrowthBookJsonUtils.getInstance().gson.fromJson(this.options.getGlobalAttributes(), HashMap.class);
236-
}
237-
238-
HashMap<String, JsonElement> userAttributes = null;
239-
if (userContext.getAttributes() != null) {
240-
userAttributes = GrowthBookJsonUtils.getInstance().gson.fromJson(userContext.getAttributes(), HashMap.class);
242+
merged = GrowthBookJsonUtils.getInstance().gson.fromJson(this.options.getGlobalAttributes(), JsonObject.class);
243+
if (merged == null) merged = new JsonObject();
241244
}
242-
243-
if (globalAttributes != null) {
244-
if (userAttributes != null) {
245-
globalAttributes.putAll(userAttributes);
245+
JsonObject userAttrs = userContext.getAttributes();
246+
if (userAttrs != null) {
247+
for (Map.Entry<String, JsonElement> e : userAttrs.entrySet()) {
248+
merged.add(e.getKey(), e.getValue());
246249
}
247-
} else {
248-
globalAttributes = userAttributes;
249250
}
250-
251-
String attributesJson = GrowthBookJsonUtils.getInstance().gson.toJson(globalAttributes);
252-
253-
UserContext updatedUserContext = userContext.witAttributesJson(attributesJson);
254-
251+
UserContext updatedUserContext = userContext.withAttributes(merged);
255252
return new EvaluationContext(this.globalContext, updatedUserContext, new EvaluationContext.StackContext(), this.options);
256253
}
257254

lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import growthbook.sdk.java.multiusermode.util.TransformationUtil;
1111
import growthbook.sdk.java.repository.FeatureRefreshStrategy;
1212
import growthbook.sdk.java.sandbox.GbCacheManager;
13+
import growthbook.sdk.java.sandbox.CacheMode;
1314
import growthbook.sdk.java.stickyBucketing.InMemoryStickyBucketServiceImpl;
1415
import growthbook.sdk.java.stickyBucketing.StickyBucketService;
1516
import lombok.Builder;
@@ -43,7 +44,9 @@ public Options(@Nullable Boolean enabled,
4344
@Nullable JsonObject globalAttributes,
4445
@Nullable Map<String, Object> globalForcedFeatureValues,
4546
@Nullable Map<String, Integer> globalForcedVariationsMap,
46-
@Nullable GbCacheManager cacheManager
47+
@Nullable GbCacheManager cacheManager,
48+
@Nullable CacheMode cacheMode,
49+
@Nullable String cacheDirectory
4750

4851
) {
4952
this.enabled = enabled == null || enabled;
@@ -64,6 +67,8 @@ public Options(@Nullable Boolean enabled,
6467
this.globalForcedFeatureValues = globalForcedFeatureValues;
6568
this.globalForcedVariationsMap = globalForcedVariationsMap;
6669
this.cacheManager = cacheManager;
70+
this.cacheMode = cacheMode == null ? CacheMode.AUTO : cacheMode;
71+
this.cacheDirectory = cacheDirectory;
6772
}
6873

6974
/**
@@ -178,6 +183,16 @@ public FeatureRefreshStrategy getRefreshingStrategy() {
178183
@Nullable
179184
private GbCacheManager cacheManager;
180185

186+
// New cache configuration
187+
private CacheMode cacheMode;
188+
189+
@Nullable
190+
private String cacheDirectory;
191+
192+
public CacheMode getCacheMode() { return cacheMode == null ? CacheMode.AUTO : cacheMode; }
193+
@Nullable
194+
public String getCacheDirectory() { return cacheDirectory; }
195+
181196
@Nullable
182197
public StickyBucketService getStickyBucketService() {
183198
return stickyBucketService;

lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/UserContext.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,19 @@ private UserContext(UserContextBuilder userContextBuilder) {
4242
}
4343

4444
public UserContext witAttributesJson(String attributesJson) {
45+
// Build a new context using only the provided attributesJson for attributes
4546
return new UserContextBuilder()
4647
.attributesJson(attributesJson)
47-
.attributes(this.attributes)
48+
.forcedVariationsMap(this.forcedVariationsMap)
49+
.forcedFeatureValues(this.forcedFeatureValues)
50+
.url(this.url)
51+
.stickyBucketAssignmentDocs(this.stickyBucketAssignmentDocs)
52+
.build();
53+
}
54+
55+
public UserContext withAttributes(JsonObject attributes) {
56+
return new UserContextBuilder()
57+
.attributes(attributes == null ? new JsonObject() : attributes)
4858
.forcedVariationsMap(this.forcedVariationsMap)
4959
.forcedFeatureValues(this.forcedFeatureValues)
5060
.url(this.url)
@@ -105,7 +115,10 @@ public static class UserContextBuilder {
105115

106116
public UserContextBuilder attributesJson(String attributesJson) {
107117
this.attributesJson = attributesJson;
108-
this.attributes = TransformationUtil.transformAttributes(attributesJson);
118+
// Only transform if attributes not explicitly provided
119+
if (this.attributes == null) {
120+
this.attributes = TransformationUtil.transformAttributes(attributesJson);
121+
}
109122
return this;
110123
}
111124

0 commit comments

Comments
 (0)