Repository navigation
feat: CompletableFuture evaluation API, non-blocking initializeAsync, and Loom-safe monitors - #236
Open
madhuchavva wants to merge 8 commits into
Open
madhuchavva wants to merge 8 commits into
madhuchavva wants to merge 8 commits into
Conversation
Re-implement the CompletableFuture evaluation API on main's delegated GrowthBookClient: initializeAsync offloads validation and fetch to the client executor; isOn/isOff/evalFeature/getFeatureValue/getFeature/run gain async twins that resolve sticky-bucket docs off the store's completion thread via a single executor hop, returning already-complete futures when no sticky service is configured. Always resolve an owned-or-supplied async executor. The CAS lifecycle hardening and async fetch are already covered by main's architecture; take its repository package. Drop the stale committed test-cases.json in favour of main's cases/ corpus.
Collaborator
|
@greptile review |
[Medium impact] No new blocking issue was established in these changes. Reviews (2) · Last reviewed commit: "fix: make async eval API shutdown-safe, ..." · Reviewed by Greptile |
added 2 commits
October 21, 2026 17:08
The merge of mc/async-sticky-read-path into mc/async-eval-api left getEvalContext inconsistent: buildEvalContext referenced an out-of-scope snapshot and getEvalContextAsync still called the pre-review one-arg stickyIdentifierAttributesFor, so :lib:compileJava failed. - Thread the single captured GlobalContext snapshot through buildEvalContext and getEvalContextAsync, so identifier derivation and evaluation use one snapshot on both the sync and async paths (mid-read refresh consistency). - Pass the inline experiment's hashAttribute/fallbackAttribute through runAsync, matching the sync run() fix so an inline experiment's persisted assignment is honored on the async path too. :lib:test green on JDK 17.
… store thread Addresses review findings on the CompletableFuture evaluation surface. GrowthBookClient: - initializeAsync/async evaluations no longer hang when shutdown discards their queued executor task. In-flight futures are tracked; shutdown completes pending initializations with false and pending evaluations exceptionally, so a caller is never left waiting forever. - getFeatureValueAsync now recovers a failed context future to the default value (both overloads, via the primary), matching the synchronous getFeatureValue contract instead of surfacing a sticky-store failure to the caller. - getEvalContextAsync uses handleAsync (not thenApplyAsync) so BOTH success and failure hop to the offload executor: a rejecting store never runs caller continuations (whenComplete/exceptionally) on its own completion thread (e.g. a Netty event loop during an outage). Tests (GrowthBookClientAsyncApiTest): - Replaced the com.sun HttpServer helper with WireMock, per the repo's no-real-network rule; the shutdown-during-initialize case is now expressed deterministically by saturating the async executor (no network, no sleeps). - Moved the ForeignThreadStickyService test double to the bottom of the class. - Added coverage: failed fetch runs caller continuations off the store thread; getFeatureValueAsync falls back to the default on a sticky-store failure. :lib:test green on JDK 17.
Collaborator
|
@greptile review |
Brings in main (spec 0.12.0, #284) and the sticky read-key fix from the base PR. Conflict: GrowthBookClient — this branch added the async evaluation methods and the base brought reducePayload from main at the same spot; keep both.
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: CompletableFuture evaluation API, non-blocking initializeAsync, and virtual-thread-safe monitors
An awaitable async surface for the multi-user client — I/O at the edges, evaluation stays synchronous and CPU-only, and no monitor is held across I/O (Loom-safe)
opt-in · additive & backward compatible · no new dependencies · JS/Python parity
Branch:
mc/async-eval-api→ stacked on #235 (async sticky bucket service)Commits:
9b5d84b,2e824bb,31c4492(feature) ·937a243,a3c5e01(review follow-ups)Summary
Adds the public async surface to the multi-user
GrowthBookClient:CompletableFuturetwins of the primary evaluation methods,initializeAsync()built on OkHttpenqueue()with scheduled (neverslept) retry backoff,
Evaluation itself stays synchronous and CPU-only; the futures make the I/O at the edges awaitable.
The change is additive — the sync API and its zero-hop path are unchanged.
What's included
GrowthBookClient:evalFeatureAsync,isOnAsync,isOffAsync,getFeatureAsync(FeatureKey),getFeatureValueAsync(string + typed-key overloads),runAsync.The remaining typed-key conveniences compose
(
getFeatureAsync(key, user).thenApply(FeatureResult::getValue)). With no sticky service and noremote eval the returned futures are already complete — zero overhead.
client's I/O thread. There is exactly one executor hop, at the foreign-completion boundary — a
Lettuce/Netty event loop or OkHttp dispatcher never runs evaluation, the user's tracking callback,
or caller continuations. The hop doubles as the defensive copy:
cancel()detaches the caller'sstage only, never the shared coalesced fetch or other callers.
initializeAsync()— mirrorsinitialize()'s no-throw contract (completesfalseon failure,never exceptionally). The initial fetch goes through new repository internals
(
GBFeaturesRepository.initializeAsync()/ async feature fetch) using OkHttpenqueue()and adaemon scheduler for retry backoff — no thread (caller or pool) is parked through network round
trips or the up-to-~15s of retry delays the blocking path sleeps. Same retry policy,
cache-freshness skip, and cache-fallback failure handling as the sync path. Remote-eval mode keeps
blocking initialization internally, offloaded wholesale.
Calland cancels it atshutdown; pending fetch futures are completed exceptionally, and scheduled retries are cancelled —
an
initializeAsync()future can never outliveshutdown()unresolved.only with sticky bucketing) — construction is free (no threads until first submit; the
idle-timeout pool holds zero threads when quiet).
Virtual-thread-safe monitors (JDK 21 consumers; pre-JDK-24 VTs pin carriers inside
synchronized)shutdown()— wassynchronizedwhile blocking for seconds (sticky flush, plugin close),stalling every synchronized member and pinning a carrier; now CAS-guarded and idempotent.
ensureRemoteEvalReady()— performed blocking HTTP insidesynchronized(this)on the evalpath; now a one-time-init future (one caller initializes outside locks, concurrent callers await;
failures reset so the next call retries, preserving prior semantics).
getRemoteEvalService()/getRemoteEvalCache()—synchronizedon the remote-eval eval hotpath; now double-checked volatile reads with a
ReentrantLockslow path.Review follow-ups (correctness fixes — commit
a3c5e01)Code review surfaced three P1s on the async surface plus two test-hygiene issues; all are fixed:
initializeAsync()task stayed queued;shutdownNow()discarded it without completing the future.In-flight futures are now tracked — shutdown completes pending initializations with
falseandpending evaluations exceptionally, so a caller is never left waiting.
getFeatureValueAsyncnow recovers a failed contextfuture (e.g. a sticky-store failure) to the default value, matching the synchronous
getFeatureValuecontract instead of surfacing the failure. Covers both overloads.thenApplyAsync, which hopsonly successful completions; a failure propagated on the store's completion thread, so a caller's
whenComplete/exceptionallycould run on a Netty event loop. NowhandleAsyncmoves bothsuccess and failure onto the executor.
com.sunHttpServer — replaced with WireMock per the repo's no-real-network rule;the shutdown-during-initialize case is now deterministic (saturated executor, no network/sleeps).
ForeignThreadStickyServicemoved to the bottom of the class.Tests
./gradlew :lib:testpasses in full on JDK 17 — new classGrowthBookClientAsyncApiTest(11tests), latch-gated with no sleeps:
getFeatureValueAsyncfalls back to the defaultfalseon invalid options; a shutdown mid-initialize completes the futurefalse, never hangs;shutdown()is idempotentFollow-ups
enqueuefor the remote-eval POST) — the async path offloadsthe existing blocking call for now.
ExperimentTrackersizing, untrack-on-failure) — next PR.