Async sticky bucket service support for GrowthBookClient - #128
Open
madhuchavva wants to merge 8 commits into
Open
Async sticky bucket service support for GrowthBookClient#128madhuchavva wants to merge 8 commits into
madhuchavva wants to merge 8 commits into
Conversation
…sage, subscriptions)
This was referenced Aug 11, 2026
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.
Closes #127
Summary
Adds async sticky bucket service support to
GrowthBookClient, mirroring the JS SDK's design: evaluation stays synchronous and CPU-only, while all sticky bucket I/O moves off the event loop — reads are prefetched (awaited natively for async services, executor-offloaded for sync ones) and writes are fire-and-forget. Use case: network-backed sticky bucketing (Redis, DynamoDB) in high-concurrency asyncio services without blocking the loop.Why not an async evaluation core
Feature evaluation (
core.py) is pure CPU — hashing, bucketing, condition matching — and never performs I/O. The TS SDK, the most async-native GrowthBook SDK, deliberately keepsevalFeature/runsynchronous for the same reason (sticky-bucket-service.ts is Promise-based; GrowthBook.tsevalFeatureis not). An async core would force either an event loop into the syncGrowthBookclass or a duplicatedcore_async.py, and would double thecases.jsonconformance surface. This PR reproduces the JS shape instead: async at the I/O edges only.What's included
New public API (
common_types.py, exported from package root):Plus
GrowthBookClient.flush_sticky_bucket_saves()for serverless environments (close()drains automatically). Existing syncAbstractStickyBucketServiceimplementations keep working with both clients, unchanged. The syncGrowthBookclass rejects async services at construction with aValueError(it has no loop to await them on).Read path (
growthbook_client.py::_refresh_sticky_buckets) — before, a sync service call ran directly on the event loop, guarded by a bool flag that was not a lock:The bool flag was dead weight while the body had no awaits, but becomes a real race once suspension points exist; the
asyncio.Lockalso coalesces concurrent refreshes for identical attributes (verified by test: 10 concurrent evals → 1 fetch).Write path (
core.pystep 13.5) — before, core called the service directly from synchronousrun_experiment, blocking the loop mid-await client.eval_feature(...):The callback is a defaulted field on
EvaluationContext(not a threaded parameter, whicheval_prereqsre-entry would drop). The in-memory assignment doc is still updated synchronously during eval, so read-your-writes holds while persistence completes in the background — the same mechanism as JS'sstickyBucketAssignmentDocs. Scheduled saves are held as strong refs (barecreate_taskresults can be GC'd mid-write), failures are logged and never raised into eval.Bug fix (own commit) — core replaced the assignment-docs dict when it was empty instead of mutating it in place (
if not docs: docs = {}), severing the shared-cache reference. With an initially-empty service, every re-eval saw no existing assignments, flaggedchanged=True, and re-saved. Now mutates in place; regression test included.Async user callbacks —
on_experiment_viewed,on_feature_usage, and subscription callbacks may now be coroutine functions; they are scheduled on the loop (previously a returned coroutine was silently dropped) and drained inclose(). Sync callbacks are invoked exactly as before.Cleanups —
stop_refreshruns blockingstopAutoRefresh(timeout=10)in the executor (was blocking the loop up to 10s on shutdown, inconsistent with the SSE teardown path that already used the executor);is_on/is_off/get_feature_valuedelegate toeval_featureinstead of duplicating its body.Measured impact
Benchmark harness is checked in at
tests/scripts/benchmark_async_client.py(simulated asyncio service: 100 concurrent request handlers, 1000 requests, sticky bucket service with 1 ms simulated network latency; feature loading mocked). "Before" rows were produced by running the same harness againstmain(v2.3.1).Wins and observations
core.pyhas no measurable cost, and this number is also the argument for not making evaluation itself async — there is no I/O in it to overlap.run_in_executor. That delta is the concrete reason for customers with network-backed services to implement the newAbstractAsyncStickyBucketServicerather than keep a sync service, beyond avoiding executor-pool pressure.close()drains in-flight saves (1000/1000 persisted).Trade-offs
AbstractStickyBucketServicesubclass. A thinsync → asyncadapter can be added later without breaking anything.isinstancedispatch requires subclassing the ABC. A duck-typed async service that doesn't inheritAbstractAsyncStickyBucketServiceis treated as sync and its coroutine dropped (with an error log). AProtocolwould be structurally looser but loses the sharedget_key/get_all_assignmentsdefaults.await client.flush_sticky_bucket_saves().ThreadPoolExecutor(max ≈min(32, cpu+4)workers). A pathologically slow sticky backend can crowd out otherrun_in_executorusers in the process.What's NOT changed
GrowthBook(sync) behavior is byte-identical: it sets no callback, so core takes the exact pre-PR code path. The full sync conformance suite passes untouched.Follow-ups (Pipeline)
_eval_lockstill serializes evaluations in CDN mode, including across the sticky prefetch await. Fix: immutable feature-snapshot swap.Test plan
pytest tests/ -q --ignore=tests/scripts— 807 passed (was 785)cases.jsonstickyBucketconformance case now runs against BOTH service flavors (sync parametrization + async)flush_sticky_bucket_saves(); failing save logged, never raised; unchanged assignment not re-saved (regression test for the dict-replacement bug)GrowthBook(sticky_bucket_service=AsyncService())raisesValueErroron_experiment_viewed/on_feature_usage/subscription all invoked and drained byclose()