Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
01eb0e6
fix: back InMemoryStickyBucketService default storage with a concurre…
madhuchavva Aug 20, 2026
ca62644
fix: make GrowthBookClient subscription state safe under concurrent r…
madhuchavva Aug 20, 2026
51027c9
fix: dispatch feature refresh callbacks via CopyOnWriteArrayList
madhuchavva Aug 20, 2026
b9d7e56
fix: run SWR polling on a named daemon thread
madhuchavva Aug 20, 2026
7fe101e
fix: swap features and saved groups as one atomic FeatureSnapshot
madhuchavva Aug 20, 2026
2d86554
build: enforce Java 8 API level with guarded options.release
madhuchavva Aug 20, 2026
12e771c
doc: document single-user GrowthBook as single-threaded by design
madhuchavva Aug 20, 2026
58886f3
fix: tolerate key-less experiments in subscription dedup map
madhuchavva Aug 20, 2026
35fdf4e
test: stub getFeatureSnapshot on mocked repositories via new FeatureS…
madhuchavva Aug 20, 2026
ff5d0c3
add AsyncStickyBucketService, sync-offload adapter, and doc-writer seam
madhuchavva Aug 20, 2026
dbb82a8
add StickyBucketManager: coalesced reads, authoritative overlay, seri…
madhuchavva Aug 20, 2026
4c79840
add async sticky options, validation, and context seams
madhuchavva Aug 20, 2026
dd54119
wire async sticky bucketing into GrowthBookClient: scoped reads, fire…
madhuchavva Aug 20, 2026
cb8a5bd
Merge origin/main into mc/async-sticky-read-path
Oct 21, 2026
e36d138
fix: close sticky bucket manager eviction, read, and shutdown races
Oct 21, 2026
56a0729
Merge origin/main into mc/async-sticky-read-path
Nov 6, 2026
2007e31
fix: key async sticky reads like evaluation and honour async-only sti…
Nov 6, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public ConfigDiagnostics map(Options options, @Nullable GBFeaturesRepository rep
.retryMaxAttempts(retryMaxAttempts(options, repository))
.enabled(Boolean.TRUE.equals(options.getEnabled()))
.qaMode(Boolean.TRUE.equals(options.getIsQaMode()))
.stickyBucketingEnabled(options.getStickyBucketService() != null)
.stickyBucketingEnabled(options.isStickyBucketingConfigured())
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import growthbook.sdk.java.multiusermode.configurations.EvaluationContext;
import growthbook.sdk.java.multiusermode.usage.TrackingCallbackWithUser;
import growthbook.sdk.java.plugin.PluginRegistry;
import growthbook.sdk.java.stickyBucketing.StickyBucketDocWriter;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Nullable;
Expand Down Expand Up @@ -299,8 +300,10 @@ public <ValueType> ExperimentResult<ValueType> evaluateExperiment(Experiment<Val
docModel.getStickyAssignmentsDocument()
);

// save doc
if (context.getOptions().getStickyBucketService() != null) {
StickyBucketDocWriter docWriter = context.getStickyBucketDocWriter();
if (docWriter != null) {
docWriter.write(docModel.getStickyAssignmentsDocument());
} else if (context.getOptions().getStickyBucketService() != null) {
try {
context.getOptions().getStickyBucketService().saveAssignments(docModel.getStickyAssignmentsDocument());
} catch (RuntimeException e) {
Expand Down Expand Up @@ -465,7 +468,7 @@ private <ValueType> String trackingKey(Experiment<ValueType> experiment, Experim

private <ValueType> boolean isStickyBucketingEnabledForExperiment(EvaluationContext context,
Experiment<ValueType> experiment) {
return context.getOptions().getStickyBucketService() != null
return context.getOptions().isStickyBucketingConfigured()
&& !Boolean.TRUE.equals(experiment.getDisableStickyBucketing());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ private <T> FeatureResult<T> evaluateForcedRule(FeatureRule<T> rule, String key,
return null;
}

boolean stickyBucketingEnabled = context.getOptions().getStickyBucketService() != null
boolean stickyBucketingEnabled = context.getOptions().isStickyBucketingConfigured()
&& !Boolean.TRUE.equals(rule.getDisableStickyBucketing());
String fallback = stickyBucketingEnabled ? rule.getFallbackAttribute() : null;

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import growthbook.sdk.java.model.FeatureResult;
import growthbook.sdk.java.plugin.PluginRegistry;
import growthbook.sdk.java.stickyBucketing.StickyBucketDocWriter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Nullable;
Expand All @@ -24,10 +27,25 @@ public class EvaluationContext {
* Plugins registered with the owning GrowthBook instance. Carried per
* evaluation context (not on the shared {@link Options}) so that separate
* SDK instances built from the same {@code Options} stay isolated.
* Excluded from equals/hashCode/toString: identity-equality object, and its
* toString is noise in debug logs.
*/
@Nullable
@EqualsAndHashCode.Exclude
@ToString.Exclude
private PluginRegistry pluginRegistry;

/**
* Persistence seam for newly assigned sticky bucket documents, set by the
* owning multi-user client. When null the evaluator calls the configured
* synchronous service directly (legacy path, unchanged). Excluded from
* equals/hashCode/toString: lambdas have identity equality.
*/
@Nullable
@EqualsAndHashCode.Exclude
@ToString.Exclude
private StickyBucketDocWriter stickyBucketDocWriter;

public EvaluationContext(GlobalContext global, UserContext user, StackContext stack, Options options) {
this.global = global;
this.user = user;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
import growthbook.sdk.java.util.ForcedVariationsUtils;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Nullable;
import java.util.List;
import java.util.Map;
import java.util.Set;

@Data
@Slf4j
Expand Down Expand Up @@ -70,4 +74,18 @@ public void setForcedVariations(@Nullable Map<String, ?> forcedVariations) {
@Getter
@Nullable
private Map<String, Object> forcedFeatureValues;

/**
* Lazily derived sticky-bucket identifier attributes for this feature
* snapshot (hashAttribute/fallbackAttribute across experiment rules).
* Memoized here because a new GlobalContext is built on every feature
* refresh, so invalidation is free. A cache, not state — excluded from
* equals/hashCode/toString.
*/
@Getter
@Setter
@Nullable
@EqualsAndHashCode.Exclude
@ToString.Exclude
private volatile Set<String> derivedStickyIdentifierAttributes;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import growthbook.sdk.java.retry.FeatureFetchRetryPolicy;
import growthbook.sdk.java.sandbox.GbCacheManager;
import growthbook.sdk.java.sandbox.CacheMode;
import growthbook.sdk.java.stickyBucketing.AsyncStickyBucketService;
import growthbook.sdk.java.stickyBucketing.InMemoryStickyBucketServiceImpl;
import growthbook.sdk.java.stickyBucketing.StickyBucketService;
import growthbook.sdk.java.util.ForcedVariationsUtils;
Expand Down Expand Up @@ -84,6 +85,8 @@ public Options(@Nullable Boolean enabled,
null,
null,
null,
null,
null,
null
);
}
Expand Down Expand Up @@ -119,7 +122,11 @@ public Options(@Nullable Boolean enabled,
@Nullable Integer remoteEvalCacheTtlSeconds,
@Nullable Duration backgroundFetchInterval,
@Nullable FeatureFetchRetryPolicy retryPolicy,
@Nullable List<GrowthBookPlugin> plugins
@Nullable List<GrowthBookPlugin> plugins,
@Nullable AsyncStickyBucketService asyncStickyBucketService,
@Nullable Executor asyncExecutor,
@Nullable Long stickyBucketCacheTtlSeconds,
@Nullable Integer stickyBucketCacheSize
) {
this(
enabled,
Expand Down Expand Up @@ -151,7 +158,11 @@ public Options(@Nullable Boolean enabled,
retryPolicy,
null,
null,
plugins
plugins,
asyncStickyBucketService,
asyncExecutor,
stickyBucketCacheTtlSeconds,
stickyBucketCacheSize
);
}

Expand Down Expand Up @@ -223,7 +234,102 @@ public Options(@Nullable Boolean enabled,
retryPolicy,
null,
sseReconnectOnFailure,
plugins
plugins,
null,
null,
null,
null
);
}

/**
* Backward-compatible constructor matching {@code main}'s 0.11.x positional signature, before
* {@link #featureRefreshListenerExecutor} and {@link #sseReconnectOnFailure} were introduced.
*/
public Options(@Nullable Boolean enabled,
Boolean isQaMode,
@Nullable Boolean isCacheDisabled,
Boolean allowUrlOverrides,
@Nullable String url,
@Nullable String apiHost,
@Nullable String clientKey,
@Nullable String decryptionKey,
@Nullable List<String> stickyBucketIdentifierAttributes,
@Nullable StickyBucketService stickyBucketService,
@Nullable TrackingCallbackWithUser trackingCallBackWithUser,
@Nullable FeatureUsageCallbackWithUser featureUsageCallbackWithUser,
@Nullable FeatureRefreshStrategy refreshStrategy,
@Nullable Integer swrTtlSeconds,
@Nullable FeatureRefreshCallback featureRefreshCallback,
@Nullable JsonObject globalAttributes,
@Nullable Map<String, Object> globalForcedFeatureValues,
@Nullable Map<String, ?> globalForcedVariationsMap,
@Nullable GbCacheManager cacheManager,
@Nullable CacheMode cacheMode,
@Nullable String cacheDirectory,
@Nullable Boolean remoteEval,
@Nullable List<String> cacheKeyAttributes,
@Nullable Integer remoteEvalCacheSize,
@Nullable Integer remoteEvalCacheTtlSeconds,
@Nullable Duration backgroundFetchInterval,
@Nullable FeatureFetchRetryPolicy retryPolicy,
@Nullable List<GrowthBookPlugin> plugins
) {
this(
enabled, isQaMode, isCacheDisabled, allowUrlOverrides, url, apiHost, clientKey,
decryptionKey, stickyBucketIdentifierAttributes, stickyBucketService,
trackingCallBackWithUser, featureUsageCallbackWithUser, refreshStrategy, swrTtlSeconds,
featureRefreshCallback, globalAttributes, globalForcedFeatureValues,
globalForcedVariationsMap, cacheManager, cacheMode, cacheDirectory, remoteEval,
cacheKeyAttributes, remoteEvalCacheSize, remoteEvalCacheTtlSeconds,
backgroundFetchInterval, retryPolicy, null, null, plugins, null, null, null, null
);
}

/**
* Full positional signature on {@code main} before the async sticky-bucket options were
* introduced. Kept as a delegating overload so the published 30-argument arity is preserved.
*/
public Options(@Nullable Boolean enabled,
Boolean isQaMode,
@Nullable Boolean isCacheDisabled,
Boolean allowUrlOverrides,
@Nullable String url,
@Nullable String apiHost,
@Nullable String clientKey,
@Nullable String decryptionKey,
@Nullable List<String> stickyBucketIdentifierAttributes,
@Nullable StickyBucketService stickyBucketService,
@Nullable TrackingCallbackWithUser trackingCallBackWithUser,
@Nullable FeatureUsageCallbackWithUser featureUsageCallbackWithUser,
@Nullable FeatureRefreshStrategy refreshStrategy,
@Nullable Integer swrTtlSeconds,
@Nullable FeatureRefreshCallback featureRefreshCallback,
@Nullable JsonObject globalAttributes,
@Nullable Map<String, Object> globalForcedFeatureValues,
@Nullable Map<String, ?> globalForcedVariationsMap,
@Nullable GbCacheManager cacheManager,
@Nullable CacheMode cacheMode,
@Nullable String cacheDirectory,
@Nullable Boolean remoteEval,
@Nullable List<String> cacheKeyAttributes,
@Nullable Integer remoteEvalCacheSize,
@Nullable Integer remoteEvalCacheTtlSeconds,
@Nullable Duration backgroundFetchInterval,
@Nullable FeatureFetchRetryPolicy retryPolicy,
@Nullable Executor featureRefreshListenerExecutor,
@Nullable Boolean sseReconnectOnFailure,
@Nullable List<GrowthBookPlugin> plugins
) {
this(
enabled, isQaMode, isCacheDisabled, allowUrlOverrides, url, apiHost, clientKey,
decryptionKey, stickyBucketIdentifierAttributes, stickyBucketService,
trackingCallBackWithUser, featureUsageCallbackWithUser, refreshStrategy, swrTtlSeconds,
featureRefreshCallback, globalAttributes, globalForcedFeatureValues,
globalForcedVariationsMap, cacheManager, cacheMode, cacheDirectory, remoteEval,
cacheKeyAttributes, remoteEvalCacheSize, remoteEvalCacheTtlSeconds,
backgroundFetchInterval, retryPolicy, featureRefreshListenerExecutor,
sseReconnectOnFailure, plugins, null, null, null, null
);
}

Expand Down Expand Up @@ -257,7 +363,11 @@ public Options(@Nullable Boolean enabled,
@Nullable FeatureFetchRetryPolicy retryPolicy,
@Nullable Executor featureRefreshListenerExecutor,
@Nullable Boolean sseReconnectOnFailure,
@Nullable List<GrowthBookPlugin> plugins
@Nullable List<GrowthBookPlugin> plugins,
@Nullable AsyncStickyBucketService asyncStickyBucketService,
@Nullable Executor asyncExecutor,
@Nullable Long stickyBucketCacheTtlSeconds,
@Nullable Integer stickyBucketCacheSize
) {
this.enabled = enabled == null || enabled;
this.isQaMode = isQaMode != null && isQaMode;
Expand Down Expand Up @@ -289,6 +399,10 @@ public Options(@Nullable Boolean enabled,
this.featureRefreshListenerExecutor = featureRefreshListenerExecutor;
this.sseReconnectOnFailure = sseReconnectOnFailure;
this.plugins = plugins;
this.asyncStickyBucketService = asyncStickyBucketService;
this.asyncExecutor = asyncExecutor;
this.stickyBucketCacheTtlSeconds = stickyBucketCacheTtlSeconds;
this.stickyBucketCacheSize = stickyBucketCacheSize == null ? 1000 : stickyBucketCacheSize;
}

/**
Expand Down Expand Up @@ -478,6 +592,42 @@ public FeatureRefreshStrategy getRefreshingStrategy() {
@Nullable
private Boolean sseReconnectOnFailure;

/**
* Non-blocking sticky bucket service for the multi-user client. Mutually
* exclusive with {@link #stickyBucketService} — configure exactly one.
* See {@link AsyncStickyBucketService} for the contract.
*/
@Nullable
private AsyncStickyBucketService asyncStickyBucketService;

/**
* Executor for the client's asynchronous work: offloading blocking
* {@link StickyBucketService} calls and composing async evaluations. When
* null, the client creates its own bounded daemon pool and shuts it down in
* {@code shutdown()}; a caller-supplied executor is never shut down by the
* SDK. On JDK 21+, pass {@code Executors.newVirtualThreadPerTaskExecutor()}
* so blocking offloads scale with the store instead of a fixed pool.
*/
@Nullable
private Executor asyncExecutor;

/**
* Opt-in TTL, in seconds, for caching sticky bucket reads per user
* attributes. Null or non-positive (the default) disables caching:
* assignments are fetched per evaluation, so assignment changes made by
* other processes are picked up promptly. Enabling trades that freshness
* for fewer store lookups on hot users.
*/
@Nullable
private Long stickyBucketCacheTtlSeconds;

/**
* Maximum entries in the opt-in sticky bucket read cache (default 1000).
* Non-positive disables caching. Only meaningful when
* {@link #stickyBucketCacheTtlSeconds} is set.
*/
private Integer stickyBucketCacheSize;

public boolean isSseReconnectOnFailure() {
return sseReconnectOnFailure == null || sseReconnectOnFailure;
}
Expand All @@ -493,6 +643,15 @@ public void setInMemoryStickyBucketService() {
this.setStickyBucketService(new InMemoryStickyBucketServiceImpl());
}

/**
* Whether either flavor of sticky bucket service is configured.
*
* @return true when a sync or async sticky bucket service is set
*/
public boolean isStickyBucketingConfigured() {
return this.stickyBucketService != null || this.asyncStickyBucketService != null;
}

public void setGlobalAttributes(@Nullable String attributesJson) {
this.attributesJson = attributesJson;
this.globalAttributes = TransformationUtil.transformAttributes(attributesJson);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,17 @@ public static List<String> findViolations(@Nullable Options options) {
checkBackgroundFetchInterval(options.getBackgroundFetchInterval(), violations);
checkRemoteEvalCacheTtl(options.getRemoteEvalCacheTtlSeconds(), violations);
checkCacheConfiguration(options, violations);
checkStickyBucketConfiguration(options, violations);
violations.addAll(RemoteEvalOptionsValidator.remoteEvalViolations(options));
return Collections.unmodifiableList(violations);
}

private static void checkStickyBucketConfiguration(Options options, List<String> violations) {
if (options.getStickyBucketService() != null && options.getAsyncStickyBucketService() != null) {
violations.add("configure either stickyBucketService or asyncStickyBucketService, not both");
}
}

private static void checkApiHost(@Nullable String apiHost, List<String> violations) {
if (StringUtils.isBlank(apiHost)) {
violations.add("apiHost is required");
Expand Down
Loading
Loading