fix: thread-safe cache writes and feature update handling - #114
vazarkevych wants to merge 1 commit into
Conversation
|
@vazarkevych - thanks for identifying these tricky issues. I guess the most pressing issue here is: the cache-miss stampede in and, P2 list includes callback list mutation during notification, sticky bucket boolean lock. but the blast radius is limited. so, I’d salvage this by porting these ideas onto current |
madhuchavva
left a comment
There was a problem hiding this comment.
resolve the conflicts and address the review comments please
874a5b2 to
3d78f53
Compare
Thanks for the advice — agreed on the priorities. I've rebuilt this branch on top of current main (post remote-eval) rather than merging, so the stale conflicts are gone |
All the items from your list are in: thread-safe The only non-obvious bit is the async coalescing lock — it's keyed by (event-loop id, cache key) to avoid reusing a lock bound to a finished loop. |
3d78f53 to
767dbc0
Compare
|
Rebased again - main moved ~130 commits since my last push (now 3.1.1), so the Two changes from what I described above:
Also added Everything else stands as described - remote-eval cache keys, force_refresh, |
767dbc0 to
a0a65ce
Compare
Rebased onto current main. Three problems, all only observable under real concurrency: `InMemoryFeatureCache` mutated a shared dict with no synchronization. It now guards get/set/clear with a lock. `FeatureRepository.load_features` / `load_features_async` had no coalescing, so every thread or coroutine that missed a cold cache issued its own request to the API/CDN — a cache-miss stampede. Fetches are now serialized per cache key: the first caller fetches, the rest wait and read the freshly cached value. Async locks are keyed by (event-loop id, cache key) so a lock bound to a finished loop is never reused on another one. Remote-eval is deliberately left off the new lock: its responses are per-user and the async client already coalesces them via `_remote_eval_inflight`. The feature-update callback list could be mutated while it was being iterated for notification. Registration and removal are now guarded, and notification iterates a snapshot. `FeatureCache.get_current_state` handed out its internal `savedGroups` and `contextualBandits` dicts by reference; both are now copied, matching `features`. The sticky-bucket part of the original branch is dropped: main has since replaced that boolean-flag cache with per-attributes inflight coalescing, an opt-in TTL cache and a per-key save pipeline, which covers the same ground. Adds three concurrency regression tests, each failing before this change. Remote-eval cache keys, force_refresh, SSE invalidation and async client behavior are unchanged; the full suite (971 tests) passes.
a0a65ce to
77b69af
Compare
Problem
Several race conditions existed in cache and feature-update handling:
InMemoryFeatureCachehad no locking — concurrent reads/writes could corrupt cache entries.FeatureRepository.load_features/load_features_asynchad no fetch coalescing — on a cold cache, many threads/coroutinesrequesting the same SDK payload could all hit the GrowthBook API/CDN at once (cache-miss stampede). Note: Python's GIL does not help
here, as it is released during the blocking HTTP fetch.
_feature_update_callbackswas mutated and iterated without a lock — concurrentadd/remove/notifycould raiseRuntimeError: list changed size during iteration.FeatureCache.get_current_statereturned mutable references tosavedGroupsandcontextualBanditsinstead of copies.Changes
This branch was rebuilt on top of current main to avoid the stale conflicts from the original PR:
InMemoryFeatureCache:threading.Lockaroundget/set/clear.load_features/load_features_async: on a miss, only the first caller fetches for a givencache key; others wait and read the freshly-cached value (double-checked under the lock). Cache hits return before acquiring the
coalescing lock, so the hot path only pays the cache's own short lock. Async locks are keyed by (event-loop id, cache key) to avoid
reusing a lock bound to a finished loop (the cross-loop
asyncio.Lockhang fixed earlier in main).add/removeguarded by a dedicated_callbacks_lock;_notifycopies the list under the lock anditerates outside it, preventing both the iteration error and deadlocks from slow callbacks.
FeatureCache.get_current_state: returnsdict()copies ofsavedGroupsandcontextualBandits.coalescing, a TTL cache and a per-key save pipeline.
Tests
tests/test_thread_safety.py— sync stampede, async stampede, and callback-list mutation during notification. Each fails on mainwithout this change. Full suite: 974 passing.
_compute_cache_key) are untouched; the remote-eval path keeps its existing_remote_eval_inflightcoalescing and is intentionally left out of the new lock.
force_refreshsemantics (the re-check under the lock also honorsforce_refresh, so SSE invalidation still triggers a refetch).Concurrent
force_refreshcallers each refetch, by design.