Skip to content

feat: CompletableFuture evaluation API, non-blocking initializeAsync, and Loom-safe monitors - #236

Open
madhuchavva wants to merge 8 commits into
mc/async-sticky-read-pathfrom
mc/async-eval-api
Open

madhuchavva wants to merge 8 commits into
mc/async-sticky-read-pathfrom
mc/async-eval-api

Conversation

@madhuchavva

@madhuchavva madhuchavva commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

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

build
language level
new tests

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:

  • CompletableFuture twins of the primary evaluation methods,
  • a genuinely non-blocking initializeAsync() built on OkHttp enqueue() with scheduled (never
    slept) retry backoff,
  • removal of the monitors that blocked across I/O — safe for virtual-thread (Loom) deployments.

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

  • Async evaluation API on 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 no
    remote eval the returned futures are already complete — zero overhead.
  • Threading contract (Javadoc'd): completion runs on the client's executor, never on a store
    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's
    stage only, never the shared coalesced fetch or other callers.
  • Sync-path behavior unchanged: sync callers keep the zero-hop path.
  • initializeAsync() — mirrors initialize()'s no-throw contract (completes false on failure,
    never exceptionally). The initial fetch goes through new repository internals
    (GBFeaturesRepository.initializeAsync() / async feature fetch) using OkHttp enqueue() and a
    daemon 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.
  • Hang-proof shutdown interplay: the repository retains the in-flight Call and cancels it at
    shutdown; pending fetch futures are completed exceptionally, and scheduled retries are cancelled —
    an initializeAsync() future can never outlive shutdown() unresolved.
  • The client-owned executor is now created whenever the caller doesn't supply one (previously
    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() — was synchronized while blocking for seconds (sticky flush, plugin close),
    stalling every synchronized member and pinning a carrier; now CAS-guarded and idempotent.
  • ensureRemoteEvalReady() — performed blocking HTTP inside synchronized(this) on the eval
    path; 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() — synchronized on the remote-eval eval hot
    path; now double-checked volatile reads with a ReentrantLock slow 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:

  1. Initialization could hang after shutdown. If the executor's workers were busy, an
    initializeAsync() task stayed queued; shutdownNow() discarded it without completing the future.
    In-flight futures are now tracked — shutdown completes pending initializations with false and
    pending evaluations exceptionally, so a caller is never left waiting.
  2. Failed reads skipped fallback values. getFeatureValueAsync now recovers a failed context
    future (e.g. a sticky-store failure) to the default value, matching the synchronous
    getFeatureValue contract instead of surfacing the failure. Covers both overloads.
  3. Error handlers ran on store threads. The fetch continuation used thenApplyAsync, which hops
    only successful completions; a failure propagated on the store's completion thread, so a caller's
    whenComplete/exceptionally could run on a Netty event loop. Now handleAsync moves both
    success and failure onto the executor.
  4. Tests used com.sun HttpServer — replaced with WireMock per the repo's no-real-network rule;
    the shutdown-during-initialize case is now deterministic (saturated executor, no network/sleeps).
  5. Test double placement — ForeignThreadStickyService moved to the bottom of the class.

Tests

./gradlew :lib:test passes in full on JDK 17 — new class GrowthBookClientAsyncApiTest (11
tests), latch-gated with no sleeps:

Area Covers
Parity async results match the sync API; with no sticky/remote-eval the futures are already complete
Thread boundary evaluation and the user's tracking callback never run on the store's completion thread — on success and on a failed fetch
Cancellation cancelling one caller doesn't poison the shared coalesced fetch
Failure semantics a sticky-store failure completes the future exceptionally; getFeatureValueAsync falls back to the default
initializeAsync loads features without blocking (WireMock); completes false on invalid options; a shutdown mid-initialize completes the future false, never hangs; shutdown() is idempotent

Follow-ups

  • Non-blocking remote-eval internals (enqueue for the remote-eval POST) — the async path offloads
    the existing blocking call for now.
  • Callback/dedup hardening (ExperimentTracker sizing, untrack-on-failure) — next PR.
  • README examples land with the release-facing docs PR.

@andywhite37 andywhite37 added effort:large Substantial review and testing, often across APIs, components, or environments. needs-human-input A specific decision or manual task blocks progress; see the queue entry. priority:normal Regular queue priority; no evidence of urgency or low value. risk:high Could substantially affect correctness, isolation, compatibility, or lifecycle behavior. labels Oct 16, 2026
@andywhite37 andywhite37 removed the needs-human-input A specific decision or manual task blocks progress; see the queue entry. label Oct 19, 2026
@vazarkevych
vazarkevych added this pull request to stack #280 October 21, 2026 07:13
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.
@vazarkevych

Copy link
Copy Markdown
Collaborator

@greptile review

@greptile-apps

greptile-apps Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[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

Volodymyr Nazarkevych 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.
@vazarkevych

Copy link
Copy Markdown
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort:large Substantial review and testing, often across APIs, components, or environments. priority:normal Regular queue priority; no evidence of urgency or low value. risk:high Could substantially affect correctness, isolation, compatibility, or lifecycle behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants