Skip to content

fix: atomic cache writes and thread-safe repository lifecycle state - #204

Merged
andywhite37 merged 21 commits into
growthbook:mainfrom
vazarkevych:fix/improve-thread-safety
Oct 7, 2026
Merged

andywhite37 merged 21 commits into
growthbook:mainfrom
vazarkevych:fix/improve-thread-safety

Conversation

@vazarkevych

@vazarkevych vazarkevych commented Apr 16, 2026 •

Copy link
Copy Markdown
Collaborator

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: FileCachingManagerImpl

The main fix here, and untouched by #234.

  • Atomic publish. saveContent writes to a temp file and publishes it with Files.move(..., ATOMIC_MOVE), so a concurrent reader can never observe a half-written payload.
  • No leftover temp files. The temp file is cleaned up when the write or the move fails, so *.tmp files do not accumulate.
  • Serialized access. saveContent and loadCache are serialized on a private lock.

Covered by FileCachingManagerImplTest.

Repository lifecycle state: GBFeaturesRepository

Complements #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.

Field(s) Now
initialized, sseAllowed AtomicBoolean
expiresAt AtomicLong
sseHttpClient, sseEventSource, cacheManager, pollScheduler, sseRetryScheduler AtomicReference
refreshStrategy, sseRequest volatile
  • No check-then-act. schedulePolling() and SSE client setup use compareAndSet, so two threads cannot both create a scheduler or client.
  • Single teardown. shutdown() uses getAndSet(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: GrowthBookUtils

fireSubscriptions is extracted from GrowthBook and GrowthBookClient into one utility. It keeps the atomic compute() update and the null-key sentinel that #234 introduced, so key-less experiments still dedupe and still fire callbacks.

NativeJavaGbFeatureRepository

Fixes GbCacheManager initialization order.

Needs a decision: GrowthBook (single-user client)

This PR makes the single-user client safe to use from several threads:

  • ConcurrentHashMap / CopyOnWriteArrayList for assignments and callbacks;
  • volatile on the mutable fields;
  • the substantive change: getEvaluationContext() now returns a per-call copy with its own StackContext instead of resetting the shared one. StackContext is 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 GrowthBook as single-threaded by design, directing shared/long-lived use to GrowthBookClient. Git merged both sides cleanly, so the javadoc and the implementation now disagree.

Please tell us which you want:

  1. Keep it — I will update the class javadoc to match the implementation.
  2. Drop it — I will remove the GrowthBook changes 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.

@madhuchavva madhuchavva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public getter compatibility issue found.

Comment thread lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java Outdated

@madhuchavva madhuchavva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@vazarkevych
vazarkevych requested a review from madhuchavva June 26, 2026 13:16
…safety

# Conflicts:
#	lib/src/main/java/growthbook/sdk/java/GrowthBook.java
#	lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java
@vazarkevych
vazarkevych force-pushed the fix/improve-thread-safety branch from 3286364 to 50e5ebb Compare June 26, 2026 15:11
# Conflicts:
#	lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java
@vazarkevych
vazarkevych force-pushed the fix/improve-thread-safety branch from d451a2e to 9293442 Compare July 31, 2026 13:10
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
@vazarkevych vazarkevych changed the title fix: improve thread-safe and atomic writing fix: atomic cache writes and thread-safe repository lifecycle state Oct 1, 2026
@andywhite37

Copy link
Copy Markdown
Contributor

@greptile review

@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk] Adds thread-safety to cache and repository lifecycle state.

The PR does not appear safe to merge while concurrent repositories can lose a cache write in their shared default directory.

Reviews (4) · Last reviewed commit: "Merge branch 'main' into fix/improve-thr..." · Reviewed by Greptile

Comment thread lib/src/main/java/growthbook/sdk/java/GrowthBook.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/util/GrowthBookUtils.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/sandbox/FileCachingManagerImpl.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/sandbox/FileCachingManagerImpl.java Outdated
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

All blocking items are addressed

@andywhite37

Copy link
Copy Markdown
Contributor

@greptile review

Comment on lines +628 to +631
try {
subject.initialize();
} catch (Exception ignored) {
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 andywhite37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 build on JDK 17 at 830a700f passed 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 FileCachingManagerImpl read the same file. Over 27k reads there were no torn reads and no leftover .tmp files.
  • 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 matched main (about 57s, OkHttp's idle threads).
  • Not tested: SSE reconnect after a dropped connection; the fallback when the filesystem doesn't support atomic moves.

Comment thread lib/src/main/java/growthbook/sdk/java/GrowthBook.java Outdated
Comment thread lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java Outdated
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).
@andywhite37

Copy link
Copy Markdown
Contributor

@greptile review

Comment on lines +680 to +684
if (!pollScheduler.compareAndSet(null, newScheduler)) {
newScheduler.shutdown();
return;
}
newScheduler.scheduleWithFixedDelay(this::pollOnceSafe, this.swrTtlSeconds, this.swrTtlSeconds, TimeUnit.SECONDS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@greptile-apps

greptile-apps Bot commented Oct 7, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Nested evaluations lose stack state lib/src/main/java/growthbook/sdk/java/GrowthBook.java:254 ▶

    A feature-usage callback or plugin runs during evaluation and can evaluate another feature on the same GrowthBook. That nested call replaces the stack on the shared EvaluationContext. When the outer evaluation resumes, its cycle-detection and memoization state has been discarded, so it can produce an incorrect feature result.

@andywhite37 andywhite37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates. All five earlier comments look addressed:

  • The GrowthBook changes 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 build on JDK 17 at 4341f4ac passed 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 on main.
  • Not tested: SSE reconnect after a dropped connection; the fallback when the filesystem doesn't support atomic moves.

Comment on lines +919 to +928
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@andywhite37

Copy link
Copy Markdown
Contributor

@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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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 andywhite37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() during initialize() can leave a poller or SSE client running. Both start paths need a shuttingDown check.
  • 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: ExperimentSubscriptionManager maps a null key to "", so a keyless experiment and one keyed "" still share an entry and one callback is suppressed. This PR fixed that for GrowthBook only.
  • 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).

@andywhite37
andywhite37 dismissed madhuchavva’s stale review October 7, 2026 20:15

Reviewer on leave, and I believe feedback was addressed. We can re-asses when she gets back.

@andywhite37
andywhite37 merged commit 01fd716 into growthbook:main Oct 7, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants