Keyed sticky prefetch cache, lock-free evaluation, tracking retry - #129
Open
madhuchavva wants to merge 4 commits into
Open
Keyed sticky prefetch cache, lock-free evaluation, tracking retry#129madhuchavva wants to merge 4 commits into
madhuchavva wants to merge 4 commits into
Conversation
2 tasks
madhuchavva
force-pushed
the
mc/sticky-cache-eval-lock-tracking
branch
from
August 14, 2026 03:10
b55b9b7 to
1466d0c
Compare
This was referenced Aug 14, 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.
Follow-up to #128 (stacked on its branch). Branch was rebuilt after external review; see the findings comment on #127.
Summary
Removes the eval-wide lock, makes concurrent sticky-bucket fetching cancellation-safe, and replaces the originally-proposed indefinite LRU cache with JS-parity per-eval fetching plus an explicit opt-in TTL cache. Also fixes the async-tracking retry asymmetry. Net effect on the checked-in benchmark: distinct-user throughput with an async sticky service goes from ~342 rps to ~19,900 rps (~58x) with p50 request latency dropping from 310 ms to 4.3 ms — with bounded (or zero) staleness, unlike the earlier unbounded-cache draft.
What's included
Lock-free evaluation via immutable snapshot swap —
_eval_lock(the shared_context_lock) serialized every CDN-mode evaluation, including across the sticky prefetch await._feature_update_callbacknow builds a NEWGlobalContextand swaps the reference atomically; each evaluation captures the current snapshot once and runs without locks:In-flight evaluations finish against their captured snapshot; the next evaluation sees the new one (tested by swapping features mid-eval).
Cancellation-safe per-key coalesced sticky fetch — concurrent evals with identical attributes share one inflight fetch; distinct attributes fetch in parallel (previously a single global lock serialized all fetches). Waiters await
asyncio.shield(inflight): a cancelled waiter can no longer propagate its cancellation into the shared future (which made the OWNER's successful fetch die withInvalidStateError— reproduced, now regression-tested). Owner cancellation semantics are defined: the shared future is cancelled and waiters retry, one becoming the new owner (also tested).Cache policy: per-eval fetch by default, opt-in TTL cache — the earlier draft of this PR cached fetched assignments indefinitely per attributes dict (LRU-bounded), which meant assignments written by another worker could stay invisible forever in a low-cardinality service, and its claimed JS parity was wrong: the JS SDK's server-side
GrowthBookClient.applyStickyBucketsfetches assignments fresh for each supplied context. Default behavior now matches that: every evaluation fetches (coalesced when concurrent), so cross-worker writes are visible on the next eval. For deployments that prefer fewer service round-trips,Options.sticky_bucket_cache_ttl(seconds, default 0 = disabled) enables a bounded-staleness cache, LRU-limited byOptions.sticky_bucket_cache_size; cache hits still re-apply this process's own writes from Async sticky bucket service support for GrowthBookClient #128's authoritative doc map.Validated cache sizing —
sticky_bucket_cache_size <= 0(orttl <= 0) now cleanly disables caching; previously a negative size crashed evaluation withKeyErrorfrompopitem()on an empty cache (reproduced, regression-tested). This matches the existingremote_eval_cache_sizeconvention (negative = cache holds nothing).Async tracking callbacks retried on failure — Async sticky bucket service support for GrowthBookClient #128 marked an experiment as tracked when the async
on_experiment_viewedcoroutine was scheduled; if it later failed, the impression was lost forever (a failing sync callback is retried on the next eval). The dedup key is now un-marked when the scheduled coroutine fails, restoring parity.Measured impact (tests/scripts/benchmark_async_client.py, 100-way concurrency, 1000 requests, 1 ms service latency, default cache-off policy)
Event-loop lag stays sub-7 ms in every scenario. The gains come from removing the locks and parallelizing fetches — NOT from caching (these numbers are with the cache disabled). The remaining sync-vs-async gap (3k vs 19.9k rps) is the default executor's thread-pool ceiling — the concrete case for implementing
AbstractAsyncStickyBucketServiceon network-backed stores.Behavior notes for review
get_all_assignmentsoverride (single Redis MGET) keeps the per-eval cost to one round-trip.sticky_bucket_cache_ttlseconds — bounded, documented staleness rather than the earlier draft's indefinite masking.Test plan
pytest tests/ -q --ignore=tests/scripts— 816 passed (809 in Async sticky bucket service support for GrowthBookClient #128 + 7 new)mypy growthbook/growthbook*.py tests/typing_probe.py --implicit-optional— cleanOut of scope (deliberately)