From 01eb0e655950790f42480a1aef8b8774aaf749df Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:18:50 -0700 Subject: [PATCH 1/9] fix: back InMemoryStickyBucketService default storage with a concurrent map --- .../multiusermode/configurations/Options.java | 4 +- .../InMemoryStickyBucketServiceImpl.java | 13 ++- ...tickyBucketServiceImplConcurrencyTest.java | 87 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 lib/src/test/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImplConcurrencyTest.java diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java index 97708c42..d343051b 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java @@ -327,7 +327,9 @@ public StickyBucketService getStickyBucketService() { } public void setInMemoryStickyBucketService() { - this.setStickyBucketService(new InMemoryStickyBucketServiceImpl(new HashMap<>())); + // Thread-safe backing map: this Options instance configures the multi-user + // GrowthBookClient, which evaluates (and therefore saves assignments) concurrently. + this.setStickyBucketService(new InMemoryStickyBucketServiceImpl()); } public void setGlobalAttributes(@Nullable String attributesJson) { diff --git a/lib/src/main/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImpl.java b/lib/src/main/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImpl.java index 17bb2de9..6a9dd508 100644 --- a/lib/src/main/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImpl.java +++ b/lib/src/main/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImpl.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * For simple bucket persistence using the in memory's storage(Map) (can be polyfilled for other environments) @@ -11,10 +12,20 @@ public class InMemoryStickyBucketServiceImpl implements StickyBucketService { private final Map localStorage; + /** + * Constructs a new {@code InMemoryStickyBucketServiceImpl} backed by a thread-safe map, + * suitable for use with {@code GrowthBookClient} where evaluations run concurrently. + */ + public InMemoryStickyBucketServiceImpl() { + this(new ConcurrentHashMap<>()); + } + /** * Constructs a new {@code InMemoryStickyBucketServiceImpl} with the specified local storage. * - * @param localStorage a map to store sticky assignments documents in memory. + * @param localStorage a map to store sticky assignments documents in memory. Pass a + * thread-safe map (e.g. {@link ConcurrentHashMap}) when the service is + * shared by a {@code GrowthBookClient} evaluating on multiple threads. */ public InMemoryStickyBucketServiceImpl(Map localStorage) { this.localStorage = localStorage; diff --git a/lib/src/test/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImplConcurrencyTest.java b/lib/src/test/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImplConcurrencyTest.java new file mode 100644 index 00000000..ec2f6a53 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/stickyBucketing/InMemoryStickyBucketServiceImplConcurrencyTest.java @@ -0,0 +1,87 @@ +package growthbook.sdk.java.stickyBucketing; + +import growthbook.sdk.java.model.StickyAssignmentsDocument; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The no-arg constructor must be safe under the multi-user client's concurrent + * evaluations: parallel saves and reads against one instance. (A plain HashMap + * here is a structural data race, not just a lost update.) + */ +class InMemoryStickyBucketServiceImplConcurrencyTest { + + private static final int THREADS = 8; + private static final int DOCS_PER_THREAD = 500; + + @Test + @Timeout(30) + void concurrentSavesAndReadsAreSafeWithDefaultConstructor() throws Exception { + InMemoryStickyBucketServiceImpl service = new InMemoryStickyBucketServiceImpl(); + + List failures = new CopyOnWriteArrayList<>(); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(THREADS); + AtomicBoolean stop = new AtomicBoolean(false); + + for (int t = 0; t < THREADS; t++) { + final int threadId = t; + Thread writer = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < DOCS_PER_THREAD; i++) { + Map assignments = new HashMap<>(); + assignments.put("exp__0", "v" + i); + service.saveAssignments(new StickyAssignmentsDocument( + "id", threadId + "-" + i, assignments)); + } + } catch (Throwable e) { + failures.add(e); + } finally { + done.countDown(); + } + }); + writer.setDaemon(true); + writer.start(); + } + + Thread reader = new Thread(() -> { + try { + start.await(); + while (!stop.get()) { + service.getAllAssignments(Collections.singletonMap("id", "0-0")); + } + } catch (Throwable e) { + failures.add(e); + } + }); + reader.setDaemon(true); + reader.start(); + + start.countDown(); + assertTrue(done.await(20, TimeUnit.SECONDS), "writers did not finish"); + stop.set(true); + reader.join(TimeUnit.SECONDS.toMillis(5)); + + assertEquals(0, failures.size(), "concurrent save/read threw: " + failures); + for (int t = 0; t < THREADS; t++) { + for (int i = 0; i < DOCS_PER_THREAD; i++) { + assertNotNull(service.getAssignments("id", t + "-" + i), + "lost document id||" + t + "-" + i); + } + } + } +} From ca6264446ec058a98234f961b4ac45e43a92aeb8 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:19:54 -0700 Subject: [PATCH 2/9] fix: make GrowthBookClient subscription state safe under concurrent run and subscribe --- .../java/multiusermode/GrowthBookClient.java | 41 +++-- .../GrowthBookClientConcurrencyTest.java | 148 ++++++++++++++++++ 2 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientConcurrencyTest.java diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java index 71b0b7fb..9dad84e7 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java @@ -43,12 +43,13 @@ import javax.annotation.Nullable; import java.time.Duration; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -57,7 +58,7 @@ public class GrowthBookClient { private final Options options; - private List callbacks; + private final List callbacks; private final FeatureEvaluator featureEvaluator; private final Map assigned; private final ExperimentEvaluator experimentEvaluatorEvaluator; @@ -79,8 +80,10 @@ public GrowthBookClient() { public GrowthBookClient(Options opts) { this.options = opts == null ? Options.builder().build() : opts; - this.assigned = new HashMap<>(); - this.callbacks = new ArrayList<>(); + // Shared across request threads: run()/subscribe() mutate these on a client + // that is documented as one-instance-for-all-requests. + this.assigned = new ConcurrentHashMap<>(); + this.callbacks = new CopyOnWriteArrayList<>(); this.featureEvaluator = new FeatureEvaluator(); this.experimentEvaluatorEvaluator = new ExperimentEvaluator(); this.diagnosticsProvider = new GrowthBookClientDiagnosticsProvider(this.options, clientStateView()); @@ -578,18 +581,26 @@ public void onError(Throwable throwable) { private void fireSubscriptions(Experiment experiment, ExperimentResult result) { String key = experiment.getKey(); - // If assigned variation has changed, fire subscriptions - AssignedExperiment prev = this.assigned.get(key); - if (prev == null - || !Objects.equals(prev.getInExperiment(), result.getInExperiment()) - || !Objects.equals(prev.getVariationId(), result.getVariationId())) { - AssignedExperiment current = new AssignedExperiment( - experiment.getKey(), - result.getInExperiment(), - result.getVariationId() - ); - this.assigned.put(key, current); + // If assigned variation has changed, fire subscriptions. The change check and + // the publish must be one atomic step or two concurrent run() calls can both + // observe the stale value and double-fire. Callbacks run outside compute(): + // user code must never execute inside a ConcurrentHashMap bin lock. + boolean[] changed = {false}; + this.assigned.compute(key, (k, prev) -> { + if (prev == null + || !Objects.equals(prev.getInExperiment(), result.getInExperiment()) + || !Objects.equals(prev.getVariationId(), result.getVariationId())) { + changed[0] = true; + return new AssignedExperiment( + experiment.getKey(), + result.getInExperiment(), + result.getVariationId() + ); + } + return prev; + }); + if (changed[0]) { for (ExperimentRunCallback cb : this.callbacks) { try { cb.onRun(experiment, result); diff --git a/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientConcurrencyTest.java b/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientConcurrencyTest.java new file mode 100644 index 00000000..51e33af0 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientConcurrencyTest.java @@ -0,0 +1,148 @@ +package growthbook.sdk.java.multiusermode; + +import growthbook.sdk.java.callback.ExperimentRunCallback; +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.multiusermode.configurations.UserContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression tests for shared-state races on the multi-user client: run() and + * subscribe() are documented as safe on one shared instance, so the assigned + * map and callback list must tolerate concurrent use. + */ +class GrowthBookClientConcurrencyTest { + + private static final int THREADS = 8; + private static final int EXPERIMENTS_PER_THREAD = 50; + + @Test + @Timeout(30) + void concurrentRunAndSubscribeDoesNotCorruptSharedState() throws Exception { + GrowthBookClient client = new GrowthBookClient(); + + List failures = new CopyOnWriteArrayList<>(); + AtomicInteger subscriptionFires = new AtomicInteger(0); + client.subscribe(new ExperimentRunCallback() { + @Override + public void onRun(Experiment experiment, + growthbook.sdk.java.model.ExperimentResult result) { + subscriptionFires.incrementAndGet(); + } + }); + + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(THREADS); + List threads = new ArrayList<>(); + for (int t = 0; t < THREADS; t++) { + final int threadId = t; + Thread thread = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < EXPERIMENTS_PER_THREAD; i++) { + Experiment experiment = Experiment.builder() + .key("exp-" + threadId + "-" + i) + .variations(new ArrayList<>(Arrays.asList("control", "variant"))) + .build(); + UserContext user = UserContext.builder() + .attributesJson("{\"id\":\"user-" + threadId + "-" + i + "\"}") + .build(); + client.run(experiment, user); + // Concurrent registration while other threads dispatch: + // previously an unsynchronized ArrayList add. + client.subscribe(new ExperimentRunCallback() { + @Override + public void onRun(Experiment e, + growthbook.sdk.java.model.ExperimentResult r) { + // no-op + } + }); + } + } catch (Throwable e) { + failures.add(e); + } finally { + done.countDown(); + } + }); + thread.setDaemon(true); + threads.add(thread); + thread.start(); + } + + start.countDown(); + assertTrue(done.await(20, TimeUnit.SECONDS), "workers did not finish"); + for (Thread thread : threads) { + thread.join(TimeUnit.SECONDS.toMillis(5)); + } + + assertEquals(0, failures.size(), "concurrent run()/subscribe() threw: " + failures); + // Every distinct experiment key fired the pre-registered subscription + // exactly once (each key is assigned once, and the change-check + + // publish is atomic, so no double fire). + assertEquals(THREADS * EXPERIMENTS_PER_THREAD, subscriptionFires.get()); + } + + @Test + @Timeout(30) + void sameExperimentAcrossThreadsFiresSubscriptionOnce() throws Exception { + GrowthBookClient client = new GrowthBookClient(); + + Map firesByKey = new ConcurrentHashMap<>(); + client.subscribe(new ExperimentRunCallback() { + @Override + public void onRun(Experiment experiment, + growthbook.sdk.java.model.ExperimentResult result) { + firesByKey.computeIfAbsent(experiment.getKey(), k -> new AtomicInteger()).incrementAndGet(); + } + }); + + Experiment experiment = Experiment.builder() + .key("shared-exp") + .variations(new ArrayList<>(Arrays.asList("control", "variant"))) + .build(); + // Same user everywhere: the assigned variation never changes, so the + // atomic change-check must collapse all fires into exactly one. + UserContext user = UserContext.builder() + .attributesJson("{\"id\":\"same-user\"}") + .build(); + + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(THREADS); + List failures = new CopyOnWriteArrayList<>(); + for (int t = 0; t < THREADS; t++) { + Thread thread = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < EXPERIMENTS_PER_THREAD; i++) { + client.run(experiment, user); + } + } catch (Throwable e) { + failures.add(e); + } finally { + done.countDown(); + } + }); + thread.setDaemon(true); + thread.start(); + } + + start.countDown(); + assertTrue(done.await(20, TimeUnit.SECONDS), "workers did not finish"); + assertEquals(0, failures.size(), "concurrent run() threw: " + failures); + assertEquals(1, firesByKey.get("shared-exp").get(), + "unchanged assignment must fire the subscription exactly once"); + } +} From 51027c96a39f54388e7bdf7bb59bbc851d76510d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:20:36 -0700 Subject: [PATCH 3/9] fix: dispatch feature refresh callbacks via CopyOnWriteArrayList --- .../java/repository/GBFeaturesRepository.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java b/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java index 64737b71..ce91996a 100644 --- a/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java +++ b/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java @@ -51,11 +51,11 @@ import java.net.HttpURLConnection; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; @@ -179,9 +179,11 @@ public class GBFeaturesRepository implements IGBFeaturesRepository { private OkHttpClient sseHttpClient; /** - * Optional callbacks for getting updates when features are refreshed + * Optional callbacks for getting updates when features are refreshed. + * CopyOnWriteArrayList: registration/clearing happens on caller threads while + * the poll/SSE/retry background threads iterate the list during dispatch. */ - private final ArrayList refreshCallbacks = new ArrayList<>(); + private final CopyOnWriteArrayList refreshCallbacks = new CopyOnWriteArrayList<>(); /** * Flag to know whether GBFeatureRepository is initialized @@ -549,17 +551,15 @@ public Boolean getLastRefreshLoadedFromCache() { * @param callback This callback will be called when features are refreshed */ @Override - public synchronized void onFeaturesRefresh(FeatureRefreshCallback callback) { + public void onFeaturesRefresh(FeatureRefreshCallback callback) { if (callback == null) { return; } - if (!this.refreshCallbacks.contains(callback)) { - this.refreshCallbacks.add(callback); - } + this.refreshCallbacks.addIfAbsent(callback); } @Override - public synchronized void clearCallbacks() { + public void clearCallbacks() { this.refreshCallbacks.clear(); } From b9d7e567cde9dacd3585ebfc9bf50429953bcdcf Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:21:07 -0700 Subject: [PATCH 4/9] fix: run SWR polling on a named daemon thread --- .../sdk/java/repository/GBFeaturesRepository.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java b/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java index ce91996a..249869ed 100644 --- a/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java +++ b/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java @@ -83,6 +83,11 @@ public class GBFeaturesRepository implements IGBFeaturesRepository { thread.setDaemon(true); return thread; }; + private static final ThreadFactory POLL_THREAD_FACTORY = runnable -> { + Thread thread = new Thread(runnable, "growthbook-feature-poll"); + thread.setDaemon(true); + return thread; + }; /** * Thread-safe LRU cache with max 100 entries to prevent unbounded growth @@ -583,8 +588,9 @@ private GbCacheManager createCacheManager() { private void schedulePolling() { if (pollScheduler != null || this.refreshStrategy == FeatureRefreshStrategy.SERVER_SENT_EVENTS) return; - // create single threaded executor - pollScheduler = Executors.newSingleThreadScheduledExecutor(); + // Named daemon thread: a non-daemon poller would keep the JVM alive + // when the application exits without calling shutdown(). + pollScheduler = Executors.newSingleThreadScheduledExecutor(POLL_THREAD_FACTORY); pollScheduler.scheduleWithFixedDelay(this::pollOnceSafe, this.swrTtlSeconds, this.swrTtlSeconds, TimeUnit.SECONDS); } From 7fe101e96b724c1254ec769120cc5273f1507f5c Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:21:23 -0700 Subject: [PATCH 5/9] fix: swap features and saved groups as one atomic FeatureSnapshot --- .../java/multiusermode/GrowthBookClient.java | 8 +- .../sdk/java/repository/FeatureSnapshot.java | 56 ++++++ .../java/repository/GBFeaturesRepository.java | 68 +++++-- .../GBFeaturesRepositoryConcurrencyTest.java | 182 ++++++++++++++++++ 4 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java create mode 100644 lib/src/test/java/growthbook/sdk/java/repository/GBFeaturesRepositoryConcurrencyTest.java diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java index 9dad84e7..4490b119 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java @@ -32,6 +32,7 @@ import growthbook.sdk.java.remoteeval.RemoteEvalResponse; import growthbook.sdk.java.remoteeval.RemoteEvalService; import growthbook.sdk.java.repository.FeatureRefreshStrategy; +import growthbook.sdk.java.repository.FeatureSnapshot; import growthbook.sdk.java.repository.GBFeaturesRepository; import growthbook.sdk.java.repository.RefreshMode; import growthbook.sdk.java.sandbox.CacheManagerFactory; @@ -641,9 +642,12 @@ private synchronized void replaceGlobalContextFrom(GBFeaturesRepository refreshe } private GlobalContext buildGlobalContext(GBFeaturesRepository sourceRepository) { + // Read the payload as ONE snapshot: two separate getter calls could pair + // new features with old saved groups if a refresh lands in between. + FeatureSnapshot featureSnapshot = sourceRepository.getFeatureSnapshot(); return GlobalContext.builder() - .features(sourceRepository.getParsedFeatures()) - .savedGroups(sourceRepository.getParsedSavedGroups()) + .features(featureSnapshot.getParsedFeatures()) + .savedGroups(featureSnapshot.getParsedSavedGroups()) .enabled(this.options.getEnabled()) .qaMode(this.options.getIsQaMode()) .forcedFeatureValues(this.options.getGlobalForcedFeatureValues()) diff --git a/lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java b/lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java new file mode 100644 index 00000000..796225c0 --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java @@ -0,0 +1,56 @@ +package growthbook.sdk.java.repository; + +import com.google.gson.JsonObject; +import growthbook.sdk.java.model.Feature; + +import java.util.Collections; +import java.util.Map; + +/** + * Immutable view of one successfully processed features payload: the raw JSON + * strings and their parsed forms, captured together. + * + *

The repository swaps a single {@code AtomicReference} on + * refresh, so a reader always observes features and saved groups from the SAME + * payload. Reading the parts through separate getters across a concurrent + * refresh could otherwise pair new features with old saved groups. + */ +public final class FeatureSnapshot { + public static final FeatureSnapshot EMPTY = new FeatureSnapshot( + GBFeaturesRepository.EMPTY_JSON_OBJECT_STRING, + GBFeaturesRepository.EMPTY_JSON_OBJECT_STRING, + Collections.emptyMap(), + new JsonObject() + ); + + private final String featuresJson; + private final String savedGroupsJson; + private final Map> parsedFeatures; + private final JsonObject parsedSavedGroups; + + FeatureSnapshot(String featuresJson, + String savedGroupsJson, + Map> parsedFeatures, + JsonObject parsedSavedGroups) { + this.featuresJson = featuresJson; + this.savedGroupsJson = savedGroupsJson; + this.parsedFeatures = parsedFeatures; + this.parsedSavedGroups = parsedSavedGroups; + } + + public String getFeaturesJson() { + return featuresJson; + } + + public String getSavedGroupsJson() { + return savedGroupsJson; + } + + public Map> getParsedFeatures() { + return parsedFeatures; + } + + public JsonObject getParsedSavedGroups() { + return parsedSavedGroups; + } +} diff --git a/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java b/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java index 249869ed..78aef77c 100644 --- a/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java +++ b/lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java @@ -51,7 +51,6 @@ import java.net.HttpURLConnection; import java.time.Duration; import java.time.Instant; -import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -207,14 +206,34 @@ public class GBFeaturesRepository implements IGBFeaturesRepository { @Nullable private EventSource sseEventSource = null; + /** + * The current features payload — raw JSON and parsed forms captured together + * from one successful refresh and swapped atomically, so readers can never + * observe features from one payload paired with saved groups from another. + */ + private final AtomicReference snapshot = new AtomicReference<>(FeatureSnapshot.EMPTY); + + /** + * The current features payload as one immutable snapshot. Prefer this over the + * individual getters when consuming more than one part of the payload. + * + * @return the snapshot from the most recent successful refresh + */ + public FeatureSnapshot getFeatureSnapshot() { + return this.snapshot.get(); + } + /** * Allows you to get the saved groups JSON from the provided {@link GBFeaturesRepository#getFeaturesEndpoint()}. * You must call {@link GBFeaturesRepository#initialize()} before calling this method * or your saved groups would not have loaded. + * + * @return saved groups JSON string */ - @Getter @Nullable - private volatile String savedGroupsJson = EMPTY_JSON_OBJECT_STRING; + public String getSavedGroupsJson() { + return this.snapshot.get().getSavedGroupsJson(); + } /** * Allows you to get the features JSON from the provided {@link GBFeaturesRepository#getFeaturesEndpoint()}. @@ -223,19 +242,23 @@ public class GBFeaturesRepository implements IGBFeaturesRepository { * * @return feature data JSON in a type of String. Handle refresh strategy */ - @Getter - private volatile String featuresJson = EMPTY_JSON_OBJECT_STRING; + public String getFeaturesJson() { + return this.snapshot.get().getFeaturesJson(); + } /** * Keys are unique identifiers for the features and the values are Feature objects. * Feature definitions - To be pulled from API / Cache + * + * @return parsed feature definitions */ - //@Getter - @Getter - private volatile Map> parsedFeatures = new HashMap<>(); + public Map> getParsedFeatures() { + return this.snapshot.get().getParsedFeatures(); + } - @Getter - private volatile JsonObject parsedSavedGroups = new JsonObject(); + public JsonObject getParsedSavedGroups() { + return this.snapshot.get().getParsedSavedGroups(); + } public void setCacheManager(GbCacheManager cacheManager) { if (!isCacheDisabled) { @@ -699,7 +722,7 @@ public void onFeaturesResponse(String featuresJsonResponse) throws FeatureFetchE @Override public void onFeaturesUpdated() { - onRefreshSuccess(featuresJson); + onRefreshSuccess(getFeaturesJson()); recordRefreshSuccess(false); } } @@ -1018,18 +1041,21 @@ private void onResponseJson(String responseJsonString, boolean isFromCache) thro refreshedFeatures = featuresJsonElement.toString().trim(); } - this.featuresJson = refreshedFeatures; - this.savedGroupsJson = refreshedSavedGroups; - - Map> newParsed = TransformationUtil.transformFeatures(this.featuresJson); - JsonObject newSaved = TransformationUtil.transformSavedGroups(this.savedGroupsJson); - this.parsedFeatures = newParsed; - this.parsedSavedGroups = newSaved == null ? new JsonObject() : newSaved; + Map> newParsed = TransformationUtil.transformFeatures(refreshedFeatures); + JsonObject newSaved = TransformationUtil.transformSavedGroups(refreshedSavedGroups); + // One atomic swap: readers never see this payload's features paired + // with a previous payload's saved groups (or vice versa). + this.snapshot.set(new FeatureSnapshot( + refreshedFeatures, + refreshedSavedGroups, + newParsed, + newSaved == null ? new JsonObject() : newSaved + )); this.hasFeatureData.set(true); if (!isFromCache) { this.lastSuccessfulFetchAtMillis.set(System.currentTimeMillis()); - this.onRefreshSuccess(this.featuresJson); + this.onRefreshSuccess(refreshedFeatures); } // bump TTL only after successful processing this.refreshExpiresAt(); @@ -1061,7 +1087,7 @@ private void onRefreshFailed(Throwable throwable) { } public int getActiveFeatureCount() { - return this.parsedFeatures == null ? 0 : this.parsedFeatures.size(); + return this.snapshot.get().getParsedFeatures().size(); } /** @@ -1076,7 +1102,7 @@ private void onSuccess(Response response) throws FeatureFetchException { if (response.code() == HttpURLConnection.HTTP_NOT_MODIFIED) { log.info("Features not modified (304). Using existing data."); this.refreshExpiresAt(); - this.onRefreshSuccess(this.featuresJson); + this.onRefreshSuccess(getFeaturesJson()); recordRefreshSuccess(false); return; } diff --git a/lib/src/test/java/growthbook/sdk/java/repository/GBFeaturesRepositoryConcurrencyTest.java b/lib/src/test/java/growthbook/sdk/java/repository/GBFeaturesRepositoryConcurrencyTest.java new file mode 100644 index 00000000..66a28b55 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/repository/GBFeaturesRepositoryConcurrencyTest.java @@ -0,0 +1,182 @@ +package growthbook.sdk.java.repository; + +import growthbook.sdk.java.callback.FeatureRefreshCallback; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression tests for repository-internal races: callback registration on + * caller threads vs. dispatch on the poll/SSE background threads, torn + * features/saved-groups reads across a concurrent refresh, and the polling + * scheduler pinning the JVM with a non-daemon thread. + */ +class GBFeaturesRepositoryConcurrencyTest { + + private static GBFeaturesRepository newRepository() { + return GBFeaturesRepository.builder() + .apiHost("https://cdn.growthbook.io") + .clientKey("sdk-concurrency-test") + .isCacheDisabled(true) + .build(); + } + + private static Method privateMethod(String name, Class... params) throws Exception { + Method method = GBFeaturesRepository.class.getDeclaredMethod(name, params); + method.setAccessible(true); + return method; + } + + @Test + @Timeout(30) + void registeringCallbacksWhileDispatchingDoesNotThrowCme() throws Exception { + GBFeaturesRepository repository = newRepository(); + Method onRefreshSuccess = privateMethod("onRefreshSuccess", String.class); + + List failures = new CopyOnWriteArrayList<>(); + CountDownLatch start = new CountDownLatch(1); + AtomicBoolean stop = new AtomicBoolean(false); + + // Dispatcher: iterates the callback list, as the poll/SSE threads do. + Thread dispatcher = new Thread(() -> { + try { + start.await(); + while (!stop.get()) { + onRefreshSuccess.invoke(repository, "{}"); + } + } catch (Throwable e) { + failures.add(e); + } + }); + dispatcher.setDaemon(true); + + // Registrar: adds callbacks, as initialize() does on the caller thread. + Thread registrar = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < 5_000; i++) { + repository.onFeaturesRefresh(new FeatureRefreshCallback() { + @Override + public void onRefresh(String featuresJson) { + } + + @Override + public void onError(Throwable throwable) { + } + }); + } + repository.clearCallbacks(); + } catch (Throwable e) { + failures.add(e); + } finally { + stop.set(true); + } + }); + registrar.setDaemon(true); + + dispatcher.start(); + registrar.start(); + start.countDown(); + registrar.join(TimeUnit.SECONDS.toMillis(20)); + dispatcher.join(TimeUnit.SECONDS.toMillis(20)); + + assertEquals(0, failures.size(), + "registration concurrent with dispatch threw (was ConcurrentModificationException): " + failures); + } + + @Test + @Timeout(30) + void featureSnapshotIsNeverTornAcrossConcurrentRefresh() throws Exception { + GBFeaturesRepository repository = newRepository(); + Method onResponseJson = privateMethod("onResponseJson", String.class, boolean.class); + + String payloadA = "{\"features\":{\"marker\":{\"defaultValue\":\"A\"}},\"savedGroups\":{\"marker\":[\"A\"]}}"; + String payloadB = "{\"features\":{\"marker\":{\"defaultValue\":\"B\"}},\"savedGroups\":{\"marker\":[\"B\"]}}"; + + List failures = new CopyOnWriteArrayList<>(); + CountDownLatch start = new CountDownLatch(1); + AtomicBoolean stop = new AtomicBoolean(false); + + Thread writer = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < 2_000; i++) { + onResponseJson.invoke(repository, (i % 2 == 0) ? payloadA : payloadB, true); + } + } catch (Throwable e) { + failures.add(e); + } finally { + stop.set(true); + } + }); + writer.setDaemon(true); + + Thread reader = new Thread(() -> { + try { + start.await(); + while (!stop.get()) { + FeatureSnapshot snapshot = repository.getFeatureSnapshot(); + Map features = snapshot.getParsedFeatures(); + if (features.isEmpty()) { + continue; // initial EMPTY snapshot + } + // Both halves must come from the SAME payload. + String featureMarker = snapshot.getFeaturesJson().contains("\"A\"") ? "A" : "B"; + String savedGroupMarker = snapshot.getParsedSavedGroups() + .getAsJsonArray("marker").get(0).getAsString(); + assertEquals(featureMarker, savedGroupMarker, + "torn snapshot: features from one payload, saved groups from another"); + } + } catch (Throwable e) { + failures.add(e); + } + }); + reader.setDaemon(true); + + writer.start(); + reader.start(); + start.countDown(); + writer.join(TimeUnit.SECONDS.toMillis(20)); + reader.join(TimeUnit.SECONDS.toMillis(20)); + + assertEquals(0, failures.size(), "snapshot consistency check failed: " + failures); + } + + @Test + @Timeout(30) + void pollingSchedulerUsesNamedDaemonThread() throws Exception { + GBFeaturesRepository repository = newRepository(); + try { + privateMethod("schedulePolling").invoke(repository); + + Thread pollThread = null; + // The scheduled executor creates its worker eagerly on first schedule; + // scan for it without sleeping. + for (int i = 0; i < 1_000 && pollThread == null; i++) { + for (Thread thread : Thread.getAllStackTraces().keySet()) { + if ("growthbook-feature-poll".equals(thread.getName())) { + pollThread = thread; + break; + } + } + } + + assertNotNull(pollThread, "polling worker thread not found by name"); + assertTrue(pollThread.isDaemon(), + "polling thread must be a daemon or it pins the JVM when the app exits without shutdown()"); + } finally { + repository.shutdown(); + } + } +} From 2d86554a404d68725c6d248185ae45114457ce8d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:21:23 -0700 Subject: [PATCH 6/9] build: enforce Java 8 API level with guarded options.release --- lib/build.gradle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/build.gradle b/lib/build.gradle index 445fdc88..295b2372 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -29,6 +29,15 @@ plugins { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 +// Fail the build when a post-Java-8 API sneaks in, regardless of the JDK that +// compiles it: sourceCompatibility alone checks language level, not API usage. +// Guarded because JDK 8's javac has no --release flag (CI still builds on 8). +tasks.withType(JavaCompile).configureEach { + if (JavaVersion.current().isJava9Compatible()) { + options.release = 8 + } +} + group = 'com.github.growthbook' version = findProperty('version') ?: 'main-SNAPSHOT' From 12e771c4639e450e686e2cde3e845ccc5ce9a495 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:21:23 -0700 Subject: [PATCH 7/9] doc: document single-user GrowthBook as single-threaded by design --- lib/src/main/java/growthbook/sdk/java/GrowthBook.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java index aa53a564..164c3a85 100644 --- a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java +++ b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java @@ -45,6 +45,13 @@ * GrowthBook SDK class. * Build a context with {@link GBContext#builder()} or the {@link GBContext} constructor * and pass it as an argument to the class constructor. + * + *

Threading: this class is single-threaded by design — one instance serves one + * user, typically created per request and discarded after it (matching the JavaScript + * SDK's per-user {@code GrowthBook} class). Instances share one mutable evaluation + * context internally and must not be used from multiple threads concurrently. For a + * long-lived instance shared across requests and threads, use + * {@link growthbook.sdk.java.multiusermode.GrowthBookClient} instead. */ @Slf4j public class GrowthBook implements IGrowthBook { From 58886f3b24240ba457a5b416e685e91b522a5520 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:52:53 -0700 Subject: [PATCH 8/9] fix: tolerate key-less experiments in subscription dedup map --- .../growthbook/sdk/java/multiusermode/GrowthBookClient.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java index 4490b119..033bb010 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java @@ -581,7 +581,9 @@ public void onError(Throwable throwable) { } private void fireSubscriptions(Experiment experiment, ExperimentResult result) { - String key = experiment.getKey(); + // ConcurrentHashMap rejects null keys (the previous HashMap tolerated them); + // a key-less experiment still dedupes, under one shared sentinel entry. + String key = experiment.getKey() != null ? experiment.getKey() : ""; // If assigned variation has changed, fire subscriptions. The change check and // the publish must be one atomic step or two concurrent run() calls can both // observe the stale value and double-fire. Callbacks run outside compute(): From 35fdf4e92c07e1eaeddbd181d9efda1b8f876d62 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 19 Aug 2026 20:52:53 -0700 Subject: [PATCH 9/9] test: stub getFeatureSnapshot on mocked repositories via new FeatureSnapshot.of --- .../sdk/java/repository/FeatureSnapshot.java | 18 ++++++++++++++++++ .../multiusermode/GrowthBookClientTest.java | 5 +++++ .../GrowthBookClientTestFixtures.java | 2 ++ 3 files changed, 25 insertions(+) diff --git a/lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java b/lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java index 796225c0..ef63f1d6 100644 --- a/lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java +++ b/lib/src/main/java/growthbook/sdk/java/repository/FeatureSnapshot.java @@ -38,6 +38,24 @@ public final class FeatureSnapshot { this.parsedSavedGroups = parsedSavedGroups; } + /** + * Builds a snapshot from pre-parsed parts. Intended for tests and callers + * constructing synthetic payloads; the repository builds its own snapshots + * from fetched responses. + * + * @param featuresJson raw features JSON + * @param savedGroupsJson raw saved groups JSON + * @param parsedFeatures parsed feature definitions + * @param parsedSavedGroups parsed saved groups + * @return an immutable snapshot of the supplied parts + */ + public static FeatureSnapshot of(String featuresJson, + String savedGroupsJson, + Map> parsedFeatures, + JsonObject parsedSavedGroups) { + return new FeatureSnapshot(featuresJson, savedGroupsJson, parsedFeatures, parsedSavedGroups); + } + public String getFeaturesJson() { return featuresJson; } diff --git a/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTest.java b/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTest.java index de057a43..7a6bf2d6 100644 --- a/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTest.java +++ b/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTest.java @@ -1,5 +1,6 @@ package growthbook.sdk.java.multiusermode; +import com.google.gson.JsonObject; import com.sun.net.httpserver.HttpServer; import growthbook.sdk.java.callback.ExperimentRunCallback; import growthbook.sdk.java.callback.FeatureRefreshCallback; @@ -16,6 +17,7 @@ import growthbook.sdk.java.multiusermode.configurations.UserContext; import growthbook.sdk.java.multiusermode.util.TransformationUtil; import growthbook.sdk.java.repository.FeatureRefreshStrategy; +import growthbook.sdk.java.repository.FeatureSnapshot; import growthbook.sdk.java.repository.GBFeaturesRepository; import growthbook.sdk.java.repository.RefreshMode; import growthbook.sdk.java.testhelpers.TestCasesJsonHelper; @@ -438,6 +440,7 @@ void refreshGlobalContext_repositoryUpdated_updatesGlobalContextFeatures() { Map> newFeatures = new HashMap<>(); when(mockRepository.getParsedFeatures()).thenReturn(newFeatures); + when(mockRepository.getFeatureSnapshot()).thenReturn(FeatureSnapshot.of("{}", "{}", newFeatures, new JsonObject())); try (MockedStatic mockedStatic = mockStatic(GBFeaturesRepository.class)) { mockedStatic.when(GBFeaturesRepository::builder).thenReturn(mockBuilder); @@ -476,6 +479,7 @@ void getFeatureValue_floatFeature_returnsCorrectValue() { Map> parsedFeatures = TransformationUtil.transformFeatures(demoFeaturesJson); when(mockRepository.getParsedFeatures()).thenReturn(parsedFeatures); + when(mockRepository.getFeatureSnapshot()).thenReturn(FeatureSnapshot.of("{}", "{}", parsedFeatures, new JsonObject())); try (MockedStatic mockedStatic = mockStatic(GBFeaturesRepository.class)) { mockedStatic.when(GBFeaturesRepository::builder).thenReturn(mockBuilder); @@ -516,6 +520,7 @@ void isOn_enabledFeature_returnsTrueAndIsOffReturnsFalse() { Map> parsedFeatures = TransformationUtil.transformFeatures(demoFeaturesJson); when(mockRepository.getParsedFeatures()).thenReturn(parsedFeatures); + when(mockRepository.getFeatureSnapshot()).thenReturn(FeatureSnapshot.of("{}", "{}", parsedFeatures, new JsonObject())); try (MockedStatic mockedStatic = mockStatic(GBFeaturesRepository.class)) { mockedStatic.when(GBFeaturesRepository::builder).thenReturn(mockBuilder); diff --git a/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTestFixtures.java b/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTestFixtures.java index 72820f40..59a48757 100644 --- a/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTestFixtures.java +++ b/lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTestFixtures.java @@ -5,6 +5,7 @@ import growthbook.sdk.java.model.Feature; import growthbook.sdk.java.multiusermode.configurations.Options; import growthbook.sdk.java.repository.FeatureRefreshStrategy; +import growthbook.sdk.java.repository.FeatureSnapshot; import growthbook.sdk.java.repository.GBFeaturesRepository; import java.util.HashMap; @@ -34,6 +35,7 @@ static GBFeaturesRepository createMockRepository() { when(repository.getFeaturesJson()).thenReturn("{}"); when(repository.getSavedGroupsJson()).thenReturn("{}"); when(repository.getParsedFeatures()).thenReturn(features); + when(repository.getFeatureSnapshot()).thenReturn(FeatureSnapshot.of("{}", "{}", features, new JsonObject())); when(repository.getParsedSavedGroups()).thenReturn(new JsonObject()); when(repository.getRefreshStrategy()).thenReturn(FeatureRefreshStrategy.STALE_WHILE_REVALIDATE); when(repository.hasFeatureData()).thenReturn(true);