Repository navigation
feat: merge semantics and thread-safe global attributes - #257
Open
vazarkevych wants to merge 3 commits into
Open
vazarkevych wants to merge 3 commits into
vazarkevych wants to merge 3 commits into
Conversation
Add updateGlobalAttributes(String) / updateGlobalAttributes(JsonObject) to Options
and GrowthBookClient, giving the shared multi-user path the merge semantics the TS
SDK has: new keys added, existing overwritten, unmentioned preserved, and a JSON
null value removes a key. null/malformed input is a no-op. setGlobalAttributes
(replace) is unchanged, and both methods invalidate the remote-eval response cache.
Global attributes are now held as a single canonical JSON string inside an
AtomicReference, replacing the previously separate (and non-atomically updated)
globalAttributes JsonObject + attributesJson String fields. getGlobalAttributes()
parses a fresh, caller-owned object on every call and writers swap the whole string
atomically, so concurrent evaluations on the shared GrowthBookClient can never
observe a partially merged snapshot or mutate state seen by another thread.
getAttributesJson() now always returns the canonical string (never null; "{}" when
unset); toUserContextWithMergedAttributes consumes the fresh copy directly.
Ported from the internal feat/merge-semantics branch; the internal
GlobalContextManager/RemoteEvalCoordinator indirection does not exist here, so the
wiring goes through GrowthBookClient directly and the internal consumer adaptations
(which only undid String->JsonObject parsing) are unnecessary — the public getter
already returns a JsonObject.
Collaborator
Author
|
@greptile review |
|
…on, tighten remote-eval test - Restore a deprecated Options.setAttributesJson(String) forwarding to setGlobalAttributes, so the public setter previously generated by Lombok is not removed without a deprecation cycle. - Options.updateGlobalAttributes now returns whether a merge was applied; GrowthBookClient only invalidates the remote-eval cache when it was, so repeated null/empty/malformed updates no longer force needless refetches for other users. - remoteEvalUpdateInvalidatesCache restricts cacheKeyAttributes to "id" (so adding "plan" does not change the cache key and the refetch can only come from invalidation) and asserts the refetch request body carries the merged id + plan attributes.
# Conflicts: # README.md # lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java # lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java
Collaborator
Author
|
@greptile review |
This branch has not been deployed
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.
feat: merge semantics and thread-safe global attributes
updateGlobalAttributes(merge) for the sharedGrowthBookClient, on a thread-safe attribute storeTypeScript-SDK parity · additive · fixes a latent data race on the shared path
Branch:
feat/merge-semantics→mainCommit:
01b4b18Summary
Two related changes to the global attributes shared across evaluations on the multi-user path:
updateGlobalAttributes(String)/updateGlobalAttributes(JsonObject)onOptionsandGrowthBookClient, matching the GrowthBook TypeScript SDK'supdateAttributes():add new keys, overwrite existing, preserve the rest, and remove a key whose value is JSON
null.setGlobalAttributes(replace) is unchanged.globalAttributes(JsonObject) andattributesJson(String) fields are collapsed into oneAtomicReference<String>holding the canonical JSON. Readers parse a fresh, private copy; writersswap the whole string atomically.
Both are additive and backward compatible (
setGlobalAttributeskeeps its behavior; the mergemethods are new).
Why
attributes, merge by hand, and
setGlobalAttributesthe whole set.updateGlobalAttributesmakesthe common "add or change one key" case a one-liner, matching the TS SDK.
GrowthBookClientis designed to be shared across threads, andsetGlobalAttributesis a runtime setter. The old code stored a shared mutableJsonObjectandupdated two fields non-atomically, so a concurrent reader could observe a half-updated state or
mutate an object another thread was reading. The new store removes both hazards.
Before / after
JsonObject globalAttributes+String attributesJson(two fields)AtomicReference<String>(canonical JSON)getGlobalAttributes()getAttributesJson()"{}"when unset)setGlobalAttributesupdateGlobalAttributes("{\"plan\":\"pro\"}")How it wires up
flowchart LR C[GrowthBookClient.updateGlobalAttributes] --> O[Options.updateGlobalAttributes] O --> AR["attributesJson : AtomicReference<String><br/>updateAndGet(merge)"] C --> INV[clearRemoteEvalCache] EV[isOn / evalFeature] --> M[toUserContextWithMergedAttributes] M --> G["Options.getGlobalAttributes()<br/>fresh parse per call"] AR --> GAttributes are not held in the cached
GlobalContext(that holds only features / saved groups /forced values); they flow per-request through
toUserContextWithMergedAttributes, which reads a freshcopy — so a merge is visible on the very next
isOn(). In remote-eval modeclearRemoteEvalCache()forces the next evaluation to refetch.
What was done
1.
Options(multiusermode/configurations/Options.java)final AtomicReference<String> attributesJson(default"{}"),excluded from
toString/equalsand from Lombok getters/setters.getGlobalAttributes()→TransformationUtil.transformAttributes(attributesJson.get())(fresh copy).getAttributesJson()→ the canonical string (never null).setGlobalAttributes(replace) stores the canonical string;updateGlobalAttributes(String/JsonObject)merges via
attributesJson.updateAndGet(...)(JSONnullremoves a key; null/malformed/empty is a no-op).2.
GrowthBookClient(multiusermode/GrowthBookClient.java)updateGlobalAttributes(String)/updateGlobalAttributes(JsonObject)→ delegate toOptionsthen
clearRemoteEvalCache(), mirroringsetGlobalAttributes.toUserContextWithMergedAttributesto consume the freshgetGlobalAttributes()copydirectly (dropped a redundant defensive
gson.fromJsondeep-copy).3. Docs
README.md: "Global attributes: replace vs merge" section.Files
multiusermode/configurations/Options.javaAtomicReference<String>store;getGlobalAttributes/getAttributesJson/setGlobalAttributes/updateGlobalAttributesmultiusermode/GrowthBookClient.javaupdateGlobalAttributes(String/JsonObject)+ cache invalidation; simplified attribute mergeOptionsGlobalAttributesTest.javaGrowthBookClientGlobalAttributesTest.javaGrowthBookClientTest.javagetAttributesJson()contractREADME.mdUsage
Java — replace vs merge
Public API surface
GrowthBookClient.updateGlobalAttributes(String)/(JsonObject)Options.updateGlobalAttributes(String)/(JsonObject)Options.setGlobalAttributes(String)Options.getGlobalAttributes()Options.getAttributesJson()"{}"when unset)Tests
OptionsGlobalAttributesTest(9)setstill replaces;attributesJsonstays consistent; concurrency — 8 threads × 500 iters, each merging a distinct key, asserting no key is ever lostGrowthBookClientGlobalAttributesTest(3)setthenupdateboth visible to evaluation;updatepreserves prior keys; remote-evalupdateinvalidates the response cache so the next eval refetches./gradlew buildpasses on JDK 17; the full:libsuite is green.Compatibility & scope notes
(
getGlobalAttributes()fresh-copy,getAttributesJson()never-null) are shipped asfeat:; oneexisting test was updated to the new contract.
feat/merge-semanticsbranch, adapted. The internalGlobalContextManager/RemoteEvalCoordinatorindirection does not exist in this repo (it lives inseparate, not-yet-merged PRs), so the wiring goes through
GrowthBookClientdirectly. The internalconsumer changes there only undid a
String→JsonObjectparse that the public getter already does,so they are not needed here. The internal drive-by
@UtilityClasschange toTransformationUtilwasintentionally left out (unrelated to the feature).
REMOTE_EVAL_STRATEGYSSE-push mode therepository's
requestBodyForRemoteEvalsnapshot is captured at build time and not refreshed byattribute changes;
setGlobalAttributeshas the identical limitation. The per-request remote-evalpath does use fresh attributes.