Skip to content

feat: merge semantics and thread-safe global attributes - #257

Open
vazarkevych wants to merge 3 commits into
mainfrom
feat/merge-semantics
Open

vazarkevych wants to merge 3 commits into
mainfrom
feat/merge-semantics

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

feat: merge semantics and thread-safe global attributes

updateGlobalAttributes (merge) for the shared GrowthBookClient, on a thread-safe attribute store

TypeScript-SDK parity · additive · fixes a latent data race on the shared path

build
language level
new tests

Branch: feat/merge-semantics → main
Commit: 01b4b18


Summary

Two related changes to the global attributes shared across evaluations on the multi-user path:

  1. Merge semantics — new updateGlobalAttributes(String) / updateGlobalAttributes(JsonObject) on
    Options and GrowthBookClient, matching the GrowthBook TypeScript SDK's updateAttributes():
    add new keys, overwrite existing, preserve the rest, and remove a key whose value is JSON null.
    setGlobalAttributes (replace) is unchanged.
  2. Thread-safe attribute store — the previously separate, non-atomically-updated
    globalAttributes (JsonObject) and attributesJson (String) fields are collapsed into one
    AtomicReference<String> holding the canonical JSON. Readers parse a fresh, private copy; writers
    swap the whole string atomically.

Both are additive and backward compatible (setGlobalAttributes keeps its behavior; the merge
methods are new).


Why

  • Parity / ergonomics. Without merge, a caller that wants to change one attribute must read all
    attributes, merge by hand, and setGlobalAttributes the whole set. updateGlobalAttributes makes
    the common "add or change one key" case a one-liner, matching the TS SDK.
  • A real latent data race. GrowthBookClient is designed to be shared across threads, and
    setGlobalAttributes is a runtime setter. The old code stored a shared mutable JsonObject and
    updated 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

Before After
Storage JsonObject globalAttributes + String attributesJson (two fields) one AtomicReference<String> (canonical JSON)
getGlobalAttributes() returned the shared stored object returns a fresh, caller-owned copy per call
getAttributesJson() nullable never null ("{}" when unset)
write two fields set non-atomically whole string swapped atomically
add one attribute read + merge by hand + setGlobalAttributes updateGlobalAttributes("{\"plan\":\"pro\"}")

How it wires up

flowchart LR
    C[GrowthBookClient.updateGlobalAttributes] --> O[Options.updateGlobalAttributes]
    O --> AR["attributesJson : AtomicReference&lt;String&gt;<br/>updateAndGet(merge)"]
    C --> INV[clearRemoteEvalCache]
    EV[isOn / evalFeature] --> M[toUserContextWithMergedAttributes]
    M --> G["Options.getGlobalAttributes()<br/>fresh parse per call"]
    AR --> G
Loading

Attributes are not held in the cached GlobalContext (that holds only features / saved groups /
forced values); they flow per-request through toUserContextWithMergedAttributes, which reads a fresh
copy — so a merge is visible on the very next isOn(). In remote-eval mode clearRemoteEvalCache()
forces the next evaluation to refetch.


What was done

1. Options (multiusermode/configurations/Options.java)

  • Replaced the two fields with a final AtomicReference<String> attributesJson (default "{}"),
    excluded from toString/equals and 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(...) (JSON null removes a key; null/malformed/empty is a no-op).

2. GrowthBookClient (multiusermode/GrowthBookClient.java)

  • Added updateGlobalAttributes(String) / updateGlobalAttributes(JsonObject) → delegate to Options
    then clearRemoteEvalCache(), mirroring setGlobalAttributes.
  • Simplified toUserContextWithMergedAttributes to consume the fresh getGlobalAttributes() copy
    directly (dropped a redundant defensive gson.fromJson deep-copy).

3. Docs

  • README.md: "Global attributes: replace vs merge" section.

Files

File Change
multiusermode/configurations/Options.java thread-safe AtomicReference<String> store; getGlobalAttributes/getAttributesJson/setGlobalAttributes/updateGlobalAttributes
multiusermode/GrowthBookClient.java updateGlobalAttributes(String/JsonObject) + cache invalidation; simplified attribute merge
OptionsGlobalAttributesTest.java new — 9 cases incl. a concurrency test proving atomic merges lose no keys
GrowthBookClientGlobalAttributesTest.java new — 3 end-to-end cases (local eval + remote-eval cache invalidation)
GrowthBookClientTest.java 1 assertion updated to the new never-null getAttributesJson() contract
README.md replace-vs-merge section

Usage

Java — replace vs merge
gb.setGlobalAttributes("{\"id\":\"1\"}");            // { "id": "1" }
gb.updateGlobalAttributes("{\"plan\":\"pro\"}");     // { "id": "1", "plan": "pro" }  <- "id" preserved
gb.updateGlobalAttributes("{\"plan\":null}");        // { "id": "1" }                 <- "plan" removed

// JsonObject overload:
gb.updateGlobalAttributes(myJsonObject);

Public API surface

Consumer touches Kind Stability
GrowthBookClient.updateGlobalAttributes(String) / (JsonObject) new methods additive
Options.updateGlobalAttributes(String) / (JsonObject) new methods additive
Options.setGlobalAttributes(String) unchanged behavior no break
Options.getGlobalAttributes() now returns a fresh copy (was the stored object) behavior change, non-breaking
Options.getAttributesJson() now never null ("{}" when unset) behavior change, non-breaking

Tests

Suite Covers
OptionsGlobalAttributesTest (9) merge add/overwrite/preserve; JSON-null removes a key; JsonObject overload; null/malformed/empty no-op; set still replaces; attributesJson stays consistent; concurrency — 8 threads × 500 iters, each merging a distinct key, asserting no key is ever lost
GrowthBookClientGlobalAttributesTest (3) set then update both visible to evaluation; update preserves prior keys; remote-eval update invalidates the response cache so the next eval refetches

./gradlew build passes on JDK 17; the full :lib suite is green.


Compatibility & scope notes

  • Additive / backward compatible. No signatures removed. The two observable refinements
    (getGlobalAttributes() fresh-copy, getAttributesJson() never-null) are shipped as feat:; one
    existing test was updated to the new contract.
  • Ported from the internal feat/merge-semantics branch, adapted. The internal
    GlobalContextManager/RemoteEvalCoordinator indirection does not exist in this repo (it lives in
    separate, not-yet-merged PRs), so the wiring goes through GrowthBookClient directly. The internal
    consumer changes there only undid a String→JsonObject parse that the public getter already does,
    so they are not needed here. The internal drive-by @UtilityClass change to TransformationUtil was
    intentionally left out (unrelated to the feature).
  • Known pre-existing limitation (not a regression). In REMOTE_EVAL_STRATEGY SSE-push mode the
    repository's requestBodyForRemoteEval snapshot is captured at build time and not refreshed by
    attribute changes; setGlobalAttributes has the identical limitation. The per-request remote-eval
    path does use fresh attributes.
  • Build: requires JDK 17 (the default JDK 26 crashes the Lombok version used here), matching CI.

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.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] No new blocking issue was established.

Reviews (2) · Last reviewed commit: "Merge branch 'main' into feat/merge-sema..." · Reviewed by Greptile

Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java Outdated
…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
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

This branch has not been deployed

No deployments
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.

1 participant