Repository navigation
fix: atomic cache writes and thread-safe repository lifecycle state - #204
Conversation
…riments and callbacks in GrowthBook, move fireSubscriptions to Util class
… ConcurrentHashMap and CopyOnWriteArrayList for assigned experiments and callbacks in GrowthBook, move fireSubscriptions to Util class
… and InMemoryStickyBucketServiceImpl
madhuchavva
left a comment
There was a problem hiding this comment.
Public getter compatibility issue found.
madhuchavva
left a comment
There was a problem hiding this comment.
Requesting changes for the public API compatibility issue noted inline.
# Conflicts: # lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java # lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java # lib/src/main/java/growthbook/sdk/java/repository/NativeJavaGbFeatureRepository.java # lib/src/main/java/growthbook/sdk/java/sandbox/FileCachingManagerImpl.java # lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java # lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTest.java
# Conflicts: # lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java # lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java # lib/src/main/java/growthbook/sdk/java/repository/NativeJavaGbFeatureRepository.java # lib/src/main/java/growthbook/sdk/java/sandbox/FileCachingManagerImpl.java # lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java # lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTest.java
…fix/improve-thread-safety # Conflicts: # lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java # lib/src/main/java/growthbook/sdk/java/sandbox/FileCachingManagerImpl.java # lib/src/test/java/growthbook/sdk/java/multiusermode/GrowthBookClientTest.java
…safety # Conflicts: # lib/src/main/java/growthbook/sdk/java/GrowthBook.java # lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java
3286364 to
50e5ebb
Compare
# Conflicts: # lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java
d451a2e to
9293442
Compare
The thread-safety rewrite of getEvaluationContext() returns a fresh per-call EvaluationContext so each evaluation owns its StackContext. The copy dropped the pluginRegistry (which lives on the context, not Options), so every evaluation ran with a null registry and no plugin received onFeatureEvaluated/onExperimentViewed events. Copy the registry onto the per-call context. Restores the 5 failing plugin tests.
…fety # Conflicts: # lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java # lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java
|
@greptile review |
|
…fety # Conflicts: # growthbook-cache-caffeine/build.gradle # growthbook-cache-jcache/build.gradle
|
All blocking items are addressed |
|
@greptile review |
| try { | ||
| subject.initialize(); | ||
| } catch (Exception ignored) { | ||
| } |
There was a problem hiding this comment.
Shutdown test hides fetch failures This new test initializes the repository against an unmocked localhost endpoint, then catches and ignores every failure. It depends on real network behavior and can pass despite an unexpected initialization error. Repository instructions require unit tests to avoid real network access and prohibit catch-and-ignore; this requirement must be satisfied before merging.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java
Line: 628-631
Comment:
**Shutdown test hides fetch failures** This new test initializes the repository against an unmocked localhost endpoint, then catches and ignores every failure. It depends on real network behavior and can pass despite an unexpected initialization error. Repository instructions require unit tests to avoid real network access and prohibit catch-and-ignore; this requirement must be satisfied before merging.
**Context Used:** AGENTS.md ([source](https://github.com/growthbook/growthbook-sdk-java/blob/main/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed at 4341f4ac: the test (now in GBFeaturesRepositoryMainBehaviorTest) no longer calls initialize(), so there's no network call and no empty catch. It runs in about 1ms.
andywhite37
left a comment
There was a problem hiding this comment.
The atomic cache writes (FileCachingManagerImpl) and the repository lifecycle atomics look good. The concurrent-safety review findings appear resolved.
Decision on GrowthBook: we'll stay with #234's approach (option 2). GrowthBook stays single-threaded by design, as its javadoc says, and GrowthBookClient is the class for shared use across threads. Please drop the GrowthBook thread-safety changes and keep the shared GrowthBookUtils.fireSubscriptions. The per-call StackContext also wouldn't make GrowthBook thread-safe on its own, because the user context it shares is still written to during evaluation (inline).
One test issue remains (inline), plus a few optional cleanups.
Testing:
./gradlew buildon JDK 17 at830a700fpassed locally: 500 tests, 0 failures, 4 skipped. CI is green on JDK 8, 11 and 16 and on Linux and Windows.- Ad hoc: one thread wrote 200 KB payloads while a second
FileCachingManagerImplread the same file. Over 27k reads there were no torn reads and no leftover.tmpfiles. - Manual: ran against a real GrowthBook Cloud connection with SSE. SSE connected, a published flag change arrived live, and
shutdown()disconnected it. The JVM's exit after shutdown matchedmain(about 57s, OkHttp's idle threads). - Not tested: SSE reconnect after a dropped connection; the fallback when the filesystem doesn't support atomic moves.
Address PR review: GrowthBook stays single-threaded by design (growthbook#234, option 2); GrowthBookClient remains the thread-safe entry point. GrowthBook: - revert volatile fields and ConcurrentHashMap/CopyOnWriteArrayList back to HashMap/ArrayList - drop the per-call getEvaluationContext()/getRootEvaluationContext() and the @deprecated public evaluationContext; restore the reset-stack form - keep the shared GrowthBookUtils.fireSubscriptions and the growthbook#234 single-threaded javadoc - revert the related thread-safety tests; keep the keyless-dedup tests that cover the shared fireSubscriptions InMemoryStickyBucketServiceImpl: - use the supplied map directly instead of a hidden synchronized wrapper (callers reading their own map could otherwise see inconsistent state); document that a plain map is single-thread only and shared use needs a ConcurrentMap; null falls back to a ConcurrentHashMap Tests and cleanups: - shutdown_withPollingStrategy_keepsTheDurableCache no longer hits the real network via initialize() nor catches-and-ignores - remove orphaned comments above GrowthBookClient.refreshGlobalContext() - restore explicit imports in GBFeaturesRepository and GrowthBookUtils - drop unused AtomicLong imports in GrowthBookClient test helpers
Resolve conflicts between the thread-safety work and main's feature-refresh listener / test-reorg changes (growthbook#215, growthbook#185): GrowthBookClient: take main's refactor wholesale — experimentSubscriptions .publishIfChanged and globalContextManager supersede this branch's assigned/ callbacks + GrowthBookUtils.fireSubscriptions and the old refreshGlobalContext. Drop the now-unused concurrent-collection and GrowthBookUtils imports. GBFeaturesRepository: combine both sides. Keep this branch's initLock-guarded initialize() that only flips the AtomicBoolean after a successful first fetch, but call main's refreshFeatures(RefreshMode, FeatureRefreshSource) and the source-aware fetchForRemoteEval. Keep main's loadCachedFeaturesIfAvailable (AtomicBoolean) overload while reading cacheManager through its AtomicReference (main's 'this.cacheManager == null' never held for the AtomicReference field). Tests: adopt main's split into GBFeaturesRepositoryMainBehaviorTest. The SSE shutdown tests there set the sseHttpClient field via reflection, which is now an AtomicReference, so wrap the value accordingly. Move this branch's unique shutdown_withPollingStrategy_keepsTheDurableCache there too. Re-add this branch's run_sameResultMultipleTimes_firesCallbackOnce and isOn_enabledFeature tests to GrowthBookClientTest after main's reorganized test. Build: ./gradlew :lib:test green on JDK 17 (589 tests, 0 failures).
|
@greptile review |
| if (!pollScheduler.compareAndSet(null, newScheduler)) { | ||
| newScheduler.shutdown(); | ||
| return; | ||
| } | ||
| newScheduler.scheduleWithFixedDelay(this::pollOnceSafe, this.swrTtlSeconds, this.swrTtlSeconds, TimeUnit.SECONDS); |
There was a problem hiding this comment.
If shutdown() runs while initialize() is fetching features, shutdown can remove the current poller before initialization reaches this compare-and-set. Initialization then installs a new poller without checking shuttingDown, so it keeps making network requests after teardown and is never stopped. SSE startup has the same gap: it can publish a connection after shutdown has cleared it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java
Line: 680-684
Comment:
**Poller starts after shutdown**
If `shutdown()` runs while `initialize()` is fetching features, shutdown can remove the current poller before initialization reaches this compare-and-set. Initialization then installs a new poller without checking `shuttingDown`, so it keeps making network requests after teardown and is never stopped. SSE startup has the same gap: it can publish a connection after shutdown has cleared it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Agreed this race exists, but it looks like it predates this PR. Main's schedulePolling() and SSE setup also have no shuttingDown check, so a shutdown during initialize() can leave a poller behind there too. We'd like to track it as a follow-up rather than expand this PR.
Comments Outside DiffThese findings could not be posted inline.
|
andywhite37
left a comment
There was a problem hiding this comment.
Thanks for the updates. All five earlier comments look addressed:
- The
GrowthBookchanges are reverted, so it stays single-threaded as in #234. - The polling shutdown test no longer makes a network call.
- The leftover comments, the sticky-bucket javadoc and the imports are cleaned up.
The merge with #215 looks right: main's refreshFeatures(..., source) calls are kept, with the atomic fields applied on top.
One optional note is inline. Greptile's two new findings both seem to predate this PR, so they look like follow-ups:
- The shutdown-during-
initialize()poller race (replied inline). - Its outside-diff note about nested evaluations resetting
GrowthBook's shared stack. That code is unchanged from main.
Testing:
./gradlew buildon JDK 17 at4341f4acpassed locally: 604 tests, 0 failures, 4 skipped. CI is green on JDK 8, 11 and 16 and on Linux and Windows.- Manual: ran against a real GrowthBook Cloud connection with SSE. SSE connected, a published flag change arrived live, and
shutdown()disconnected it. The JVM exited at the same time as onmain. - Not tested: SSE reconnect after a dropped connection; the fallback when the filesystem doesn't support atomic moves.
| public static <ValueType> void fireSubscriptions(Map<String, AssignedExperiment> assigned, | ||
| List<ExperimentRunCallback> callbacks, | ||
| Experiment<ValueType> experiment, | ||
| ExperimentResult<ValueType> result | ||
| ) { | ||
| // A key-less experiment needs a non-null stand-in: this dedup map may be a ConcurrentHashMap | ||
| // (GrowthBookClient) which rejects null keys, even though GrowthBook's plain HashMap tolerates | ||
| // them. Mapping it to "" would merge it with an experiment whose key really is "", suppressing | ||
| // one of their callbacks; the prefix keeps the two namespaces disjoint for any key value. | ||
| // This map is internal dedup state and is never exposed. |
There was a problem hiding this comment.
Perceived severity: Low · Optional
After #215, GrowthBookClient checks for changed experiment assignments in its own ExperimentSubscriptionManager, so GrowthBook is now the only caller of this helper. That makes it new public API, which AGENTS.md treats as a contract, without a second caller to justify sharing it. The comment saying GrowthBookClient uses it is also out of date. GrowthBook's HashMap already kept null and "" keys apart before this PR.
Evidence: Code search: GrowthBook.java:366 is the only call site, and ExperimentSubscriptionManager.publishIfChanged has its own copy of the logic.
Suggested fix: Either keep fireSubscriptions as a private method on GrowthBook and drop the public helper, or have ExperimentSubscriptionManager call the helper so there's one implementation. Either way, update the comment to match.
|
@greptile review |
| * class-level lock) means cache managers for different directories do not contend with one | ||
| * another, while operations within a single cache directory remain serialized. | ||
| */ | ||
| private final Object lock = new Object(); |
There was a problem hiding this comment.
Two repositories can create separate cache managers for the same default directory, but this lock protects only one manager. If one manager clears the cache while the other is writing a temporary file, it can delete that file before the move. The save then fails, leaving the next process without the expected cached payload when it needs an offline fallback.
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/src/main/java/growthbook/sdk/java/sandbox/FileCachingManagerImpl.java
Line: 31
Comment:
**Shared cache writes can fail**
Two repositories can create separate cache managers for the same default directory, but this lock protects only one manager. If one manager clears the cache while the other is writing a temporary file, it can delete that file before the move. The save then fails, leaving the next process without the expected cached payload when it needs an offline fallback.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
andywhite37
left a comment
There was a problem hiding this comment.
Approving: the requested changes are in, and the build, CI and a manual SSE check against GrowthBook Cloud all pass at 4341f4ac.
Follow-ups, all of which already exist on main (none block this PR):
- Poller/SSE start after shutdown:
shutdown()duringinitialize()can leave a poller or SSE client running. Both start paths need ashuttingDowncheck. - SSE shutdown clears the durable cache: it wipes the cache directory, which other repositories may share, removing the next process's offline fallback. This also covers Greptile's note about one manager's clear racing another's save.
- Keyless experiments in
GrowthBookClient:ExperimentSubscriptionManagermaps anullkey to"", so a keyless experiment and one keyed""still share an entry and one callback is suppressed. This PR fixed that forGrowthBookonly. - Nested evaluation in
GrowthBook: a callback that evaluates a feature during another evaluation resets the shared stack. - Optional cleanup: the single-caller public
GrowthBookUtils.fireSubscriptions(inline comment above).
Reviewer on leave, and I believe feedback was addressed. We can re-asses when she gets back.
Summary
Thread-safety fixes in the parts of the SDK that are genuinely shared across threads.
Rebased on
main, which landed overlapping work in #234. This PR now contains only what #234 did not cover.Atomic cache writes:
FileCachingManagerImplThe main fix here, and untouched by #234.
saveContentwrites to a temp file and publishes it withFiles.move(..., ATOMIC_MOVE), so a concurrent reader can never observe a half-written payload.*.tmpfiles do not accumulate.saveContentandloadCacheare serialized on a private lock.Covered by
FileCachingManagerImplTest.Repository lifecycle state:
GBFeaturesRepositoryComplements #234 rather than replacing it: that PR made the payload atomic via
FeatureSnapshot; this one does the same for the lifecycle fields it did not touch.initialized,sseAllowedAtomicBooleanexpiresAtAtomicLongsseHttpClient,sseEventSource,cacheManager,pollScheduler,sseRetrySchedulerAtomicReferencerefreshStrategy,sseRequestvolatileschedulePolling()and SSE client setup usecompareAndSet, so two threads cannot both create a scheduler or client.shutdown()usesgetAndSet(null), so each resource is torn down exactly once.Public API is unchanged. The atomics stay private behind explicit getters that keep the original return types (
getParsedFeatures()→Map,getInitialized()→Boolean,getExpiresAt()→Long, …). This addresses the earlier review comment about Lombok exposing the wrappers.Shared helper:
GrowthBookUtilsfireSubscriptionsis extracted fromGrowthBookandGrowthBookClientinto one utility. It keeps the atomiccompute()update and the null-key sentinel that #234 introduced, so key-less experiments still dedupe and still fire callbacks.NativeJavaGbFeatureRepositoryFixes
GbCacheManagerinitialization order.Needs a decision:
GrowthBook(single-user client)This PR makes the single-user client safe to use from several threads:
ConcurrentHashMap/CopyOnWriteArrayListfor assignments and callbacks;volatileon the mutable fields;getEvaluationContext()now returns a per-call copy with its ownStackContextinstead of resetting the shared one.StackContextis per-evaluation scratch state (cycle detection, memoized results), so sharing it lets concurrent evaluations corrupt each other.Warning
#234 took the opposite position and documented
GrowthBookas single-threaded by design, directing shared/long-lived use toGrowthBookClient. Git merged both sides cleanly, so the javadoc and the implementation now disagree.Please tell us which you want:
GrowthBookchanges and keep the javadoc from fix: concurrency groundwork for the multi-user client and features repository #234; this PR then covers only the cache manager and the repository.