Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
7 changes: 7 additions & 0 deletions lib/src/main/java/growthbook/sdk/java/GrowthBook.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p><b>Threading:</b> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,12 +44,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;
Expand All @@ -57,7 +59,7 @@
public class GrowthBookClient {

private final Options options;
private List<ExperimentRunCallback> callbacks;
private final List<ExperimentRunCallback> callbacks;
private final FeatureEvaluator featureEvaluator;
private final Map<String, AssignedExperiment> assigned;
private final ExperimentEvaluator experimentEvaluatorEvaluator;
Expand All @@ -79,8 +81,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());
Expand Down Expand Up @@ -577,19 +581,29 @@ public void onError(Throwable throwable) {
}

private <ValueType> void fireSubscriptions(Experiment<ValueType> experiment, ExperimentResult<ValueType> 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);
// 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():
// 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);
Expand Down Expand Up @@ -630,9 +644,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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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.
*
* <p>The repository swaps a single {@code AtomicReference<FeatureSnapshot>} 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<String, Feature<?>> parsedFeatures;
private final JsonObject parsedSavedGroups;

FeatureSnapshot(String featuresJson,
String savedGroupsJson,
Map<String, Feature<?>> parsedFeatures,
JsonObject parsedSavedGroups) {
this.featuresJson = featuresJson;
this.savedGroupsJson = savedGroupsJson;
this.parsedFeatures = parsedFeatures;
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<String, Feature<?>> parsedFeatures,
JsonObject parsedSavedGroups) {
return new FeatureSnapshot(featuresJson, savedGroupsJson, parsedFeatures, parsedSavedGroups);
}

public String getFeaturesJson() {
return featuresJson;
}

public String getSavedGroupsJson() {
return savedGroupsJson;
}

public Map<String, Feature<?>> getParsedFeatures() {
return parsedFeatures;
}

public JsonObject getParsedSavedGroups() {
return parsedSavedGroups;
}
}
Loading
Loading