Repository navigation
feat: async sticky bucket service with non-blocking reads and fire-and-forget writes - #235
Open
madhuchavva wants to merge 17 commits into
Open
madhuchavva wants to merge 17 commits into
madhuchavva wants to merge 17 commits into
Conversation
…-and-forget writes, flush, prefetch
This was referenced Oct 19, 2026
Contributor
Re-implement the async sticky-bucket read path on main's delegated GrowthBookClient architecture (internal managers): wire StickyBucketManager through the constructor, scoped-identifier async reads in getEvalContext, the StickyBucketDocWriter seam in ExperimentEvaluator, prefetch/flush, and drain-before-shutdown ordering. Reconcile Options positional constructors to carry the async params while preserving published arities. Take main's mature repository package and release-please files.
vazarkevych
force-pushed
the
mc/async-sticky-read-path
branch
from
October 21, 2026 13:05
f037ded to
cb8a5bd
Compare
Collaborator
|
@greptile review |
[Medium impact] No new blocking issue remains under the supplied stack assessment. Reviews (2) · Last reviewed commit: "fix: close sticky bucket manager evictio..." · Reviewed by Greptile |
Addresses review findings on the async sticky bucketing path. All fixes are scoped to the multi-user client; the legacy single-user path is untouched. StickyBucketManager: - Lost-write race: recordAndSave now merges the document into the authoritative map under the SaveState lock inside a validate-retry loop. Eviction locks the same state before removing it, so a freshly recorded assignment can no longer be pruned between the merge and the in-flight transition (previously driveSave could read a null snapshot and silently skip the save once >1000 docs were resident). - Reads lose protection too early: overlay is now applied while the fetch is still registered (its startSequence still guards eviction) and the entry is removed only after the future completes; each caller gets its own copy. A slow reader can no longer have a needed document pruned before its overlay runs. - Stale cached reads: pruning now invalidates read-cache entries whose attribute map addresses an evicted document key, so a cached miss cannot outlive the local document it would have been overlaid with. - Shutdown hang: close() completes every in-flight fetch exceptionally before the offload executor is torn down, so a queued read task discarded by shutdownNow() can no longer leave an evaluation thread blocked forever. - overlay returns a ConcurrentHashMap, making a reused prefetched context safe to evaluate concurrently. GrowthBookClient / GlobalContextManager: - Refresh consistency: getEvalContext captures one GlobalContext snapshot and uses it for both identifier derivation and evaluation, so a mid-read refresh cannot pair new rules with documents fetched for the old identifiers. - Inline experiments: run() feeds the experiment's hashAttribute/fallbackAttribute into the sticky read, so an inline experiment's persisted assignment is honored even with no configured or feature-derived identifiers. AsyncStickyBucketService: - Default getAllAssignments tolerates a null CompletionStage from getAssignments (treated as an absent document instead of NPE-ing the whole batch). Docs: - flushStickyBucketSaves/flush reworded: a completed flush means all saves were attempted, not that every assignment reached the store (rejected saves are logged, not retried). Tests: eviction invalidates a stale cached read; close() unblocks an in-flight read; inline-experiment identifier is fetched without configured/derived identifiers.
Collaborator
|
@greptile review |
added 2 commits
November 6, 2026 09:11
Brings in spec 0.12.0 alignment (#284), the 0.12.0 release and the $inGroup/$notInGroup absent-attribute tests (#287). Conflicts: - GrowthBookClient: keep both new imports (FeatureRule from this branch, ReducePayloadOptions from main). - GrowthBookClientTest: keep main's two sticky-preload regression tests. They fail against the scoped async read path here; the next commit fixes the code they exposed and scopes them to identifier attributes.
…cky bucketing
After the merge with spec 0.12.0, evaluation writes sticky documents
under hash values ({"id": 1.0} -> id||1; booleans, zero and empty
strings are not hash values), but the scoped async read path still
looked them up with JsonElement#getAsString ("1.0", "true", ""), so a
written assignment was never found again. Sticky identifier values now
go through StickyAttributeValues, which formats them exactly like
GrowthBookUtils#toHashValue; a test pins the two together.
FeatureEvaluator and the config diagnostics still checked only the sync
StickyBucketService, so with just an AsyncStickyBucketService a rollout
ignored its fallbackAttribute while experiments used it. Both now use
Options#isStickyBucketingConfigured, as ExperimentEvaluator already did.
main's two sticky-preload regression tests name their sticky identifier
attributes, since reads here are scoped to identifier attributes the way
the JS SDK derives them.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: async sticky bucketing for the multi-user client — non-blocking reads, fire-and-forget writes
Sticky-bucket I/O moves off the evaluating thread: identifier-scoped coalesced reads, merged fire-and-forget writes, prefetch, and flush
opt-in · additive & backward compatible · no new dependencies · JS/Python parity
Branch:
mc/async-sticky-read-path→ stacked on #234 (concurrency groundwork)Commits:
ff5d0c3,dbb82a8,dd54119(feature) ·e36d138(review follow-ups)Summary
Adds an asynchronous sticky-bucketing path to the multi-user
GrowthBookClientso sticky-bucketI/O no longer runs on the evaluating thread. Evaluation stays synchronous and CPU-only (matching the
JavaScript and Python SDKs) — reads and writes move to the edges.
The change is additive and opt-in. The legacy single-user
GrowthBook+GBContextpath isuntouched, and a multi-user client with no sticky-bucket service configured behaves exactly as before.
AsyncStickyBucketService— aCompletionStage-based sibling ofStickyBucketService.Natively-async stores (Lettuce
RedisFuture, ReactorMono#toFuture(), …) plug in withoutadaptation; existing blocking
StickyBucketServiceimplementations need no porting — the clientoffloads their calls to its executor via
SyncOffloadStickyBucketAdapter.and concurrent reads for the same identifiers collapse to one store call.
process's own writes (read-your-writes), and a slow or stale store read can never roll back an
assignment. Writes are merged and serialized per document key.
prefetchStickyBuckets(userContext)— JS-style prefetch so a request does its one sticky readup front, then evaluates many features/experiments with zero further sticky I/O.
flushStickyBucketSaves()— awaits all outstanding save attempts (see Durability). Calledautomatically by
shutdown().Evaluation is wired to a
StickyBucketDocWriterseam (same shape as the Python SDK'sEvaluationContext.save_sticky_bucket_doc); when no writer is set the evaluator falls back to thesynchronous
StickyBucketService.saveAssignments— the legacy path, unchanged.Public API (all additive)
AsyncStickyBucketService(getAssignments,saveAssignments, batchedgetAllAssignments, sharedgetKey)SyncOffloadStickyBucketAdapter— present a blockingStickyBucketServiceas asyncStickyBucketDocWriter— evaluation → client persistence seamGrowthBookClient.prefetchStickyBuckets(UserContext)→CompletableFuture<UserContext>GrowthBookClient.flushStickyBucketSaves()→CompletableFuture<Void>Options:asyncStickyBucketService,asyncExecutor,stickyBucketCacheTtlSeconds,stickyBucketCacheSize; newOptions.isStickyBucketingConfigured()(sticky bucketing now activatesfor either service flavour). Existing positional constructors are preserved via delegating overloads.
StickyBucketManageris package-private — its invariants are implementation detail, reached onlythrough the client's methods. No new third-party dependency (JDK + Guava + Lombok + SLF4J, all
already on the classpath).
Behaviour changes (multi-user client only; changelog-flagged)
store on every evaluation; now only the sticky-identifier attributes are, and concurrent identical
reads share one call.
unmerged
saveAssignmentsran inside evaluation. Durability is now reached viaflushStickyBucketSaves()/shutdown().bucketing — matching the JS/Python SDKs, which never evaluate without sticky data when a store is
configured. Multi-user clients using a blocking
StickyBucketServicethat relied on silent fallbackshould note this.
Durability
flushStickyBucketSaves()(andshutdown()) complete when every pending save has been attempted— not a guarantee that every assignment reached the store. A write the store rejects is logged and not
retried (JS/Python parity); the authoritative in-process map still holds the document.
Concurrency
StickyBucketManageris shared across threads with nosynchronized(virtual-thread friendly) and noside effects inside
ConcurrentHashMapcompute lambdas; store calls are launched outside all locks.Design invariants (ported from the Python 2.4.0 design, growthbook-python #128/#129): authoritative
overlay, per-key save serialization, coalesced reads, bounded memory.
Review follow-ups (correctness fixes — commit
e36d138)Code review surfaced five P1 concurrency defects and a few lower-severity issues; all are fixed in this
branch:
recordAndSavenow merges the document under theper-key
SaveStatelock inside a validate-retry loop; eviction locks the same state before removingit, so a freshly recorded assignment can no longer be pruned between the merge and the in-flight
transition (previously
driveSavecould read anullsnapshot and silently skip the save oncefetch is still registered (its
startSequencestill guards eviction); the in-flight entry isremoved only after completion, and each caller gets its own copy — a slow reader can no longer have
a needed document pruned before its overlay runs.
map addresses an evicted document key, so a cached miss can't outlive the local document it would
have been overlaid with.
close()completes every pending fetch exceptionallybefore the offload executor is torn down, so a queued read task discarded by
shutdownNow()can nolonger leave an evaluation thread blocked forever.
getEvalContextcaptures oneGlobalContextsnapshot anduses it for both identifier derivation and evaluation, so a mid-read refresh can't pair new rules
with documents fetched for the old identifiers.
Also:
run(experiment, …)now loads the inline experiment's ownhashAttribute/fallbackAttribute,so its persisted assignment is honoured even with no configured or feature-derived identifiers; the
overlay returns a
ConcurrentHashMapso a reused prefetched context is safe to evaluate concurrently;the default
getAllAssignmentstolerates anullCompletionStage; and the flush Javadoc no longeroverstates durability.
Tests
./gradlew :lib:testpasses in full on JDK 17 — 2 new test classes (29 tests), deterministic byconstruction (hand-completed service doubles, latch-gated executors, no sleeps or real time):
StickyBucketManagerTestclose()unblocks an in-flight read, opt-in TTL cache semanticsGrowthBookClientStickyBucketTest