Skip to content

feat: bootstrap from an inline feature payload (offline cold start) - #258

Open
vazarkevych wants to merge 6 commits into
mainfrom
feat/offline-bootstrap
Open

vazarkevych wants to merge 6 commits into
mainfrom
feat/offline-bootstrap

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

feat: bootstrap from an inline feature payload (offline cold start)

Seed features from an inline JSON payload so the SDK serves flags instantly — before the first network fetch

offline-capable cold start · TS init({payload}) parity · additive

build
language level
new tests

Branch: feat/offline-bootstrap → main
Commit: 06d9c59


Summary

Adds an optional inline bootstrap payload (Options.initialPayload /
GBFeaturesRepository.builder().initialPayload(...)). When set, the SDK seeds its feature state from
that payload during initialize() — before any network call — so evaluations work immediately, even
offline. The payload is in the same shape the features endpoint returns
({"features": {...}, "savedGroups": {...}}, or encryptedFeatures when a decryption key is set).

The payload is only a bridge: the first successful network refresh replaces it with live data.
The change is additive and backward compatible (new builder option; existing behavior unchanged
when it is not set).


Why

  • Instant / offline cold start. By default initialize() blocks on the first fetch, and with an
    unreachable API and no cache the SDK starts empty. A bundled/stored snapshot lets the app evaluate
    flags from the first millisecond and keep working air-gapped.
  • Deterministic tests. Callers can seed a known payload instead of standing up a mock server.
  • Parity with the TypeScript SDK's init({payload}) / initSync().

Behavior

Aspect Behavior
Seeding Reuses the cache-load path (isFromCache=true): does not write the file cache, does not advance lastSuccessfulFetchAtMillis
Reporting Emitted as a distinct FeatureRefreshEvent with source FeatureRefreshSource.INITIAL_PAYLOAD and isLoadedFromCache() == true — never mistaken for a network success
Offline With a seed and an unreachable API, initialize() returns true; the fetch failure is still reported to listeners (isSuccessful() == false) but does not abort startup
Override The first successful network refresh replaces the seeded features
Malformed Fails fast: GrowthBookClient.initialize() rejects it via options validation; the repository builder throws IllegalArgumentException; a decryption failure surfaces as a clear startup error

How it wires up

flowchart LR
    O["Options.initialPayload"] --> F[GrowthBookClientRepositoryFactory]
    F -->|builder.initialPayload| R[GBFeaturesRepository]
    R -->|initialize| S["seedInitialPayload()"]
    S -->|onResponseJson isFromCache=true| ST[feature state]
    S -->|notifySuccess INITIAL_PAYLOAD, loadedFromCache=true| L[listeners/metrics]
    R -->|then| N[normal network refresh → replaces seed]
Loading

What was done

1. GBFeaturesRepository (repository/GBFeaturesRepository.java)

  • New initialPayload field + getter; threaded through the constructor chain via delegating
    overloads (existing positional arities preserved).
  • validateInitialPayload(...) — structural JSON check at construction (fail fast).
  • seedInitialPayload() — seeds via onResponseJson(payload, true) and reports
    FeatureRefreshSource.INITIAL_PAYLOAD / loadedFromCache=true; invoked at the top of initialize().

2. Options / OptionsValidator (multiusermode/configurations/)

  • Options.initialPayload (+ builder) added via a delegating overload (30-arg signature preserved).
  • OptionsValidator.checkInitialPayload rejects a non-JSON-object payload up front.

3. Wiring + model

  • GrowthBookClientRepositoryFactory passes options.getInitialPayload() into the repository builder.
  • FeatureRefreshSource gains INITIAL_PAYLOAD (all values documented).

Files

File Change
model/FeatureRefreshSource.java + INITIAL_PAYLOAD enum value
repository/GBFeaturesRepository.java initialPayload field/getter; validateInitialPayload; seedInitialPayload; initialize() hook; builder param
multiusermode/configurations/Options.java initialPayload field + builder; 30-arg backward-compat ctor
multiusermode/configurations/OptionsValidator.java checkInitialPayload
multiusermode/internal/GrowthBookClientRepositoryFactory.java thread initialPayload into the builder
GrowthBookClientTestFixtures.java builder stub for initialPayload
repository/GBFeaturesRepositoryInitialPayloadTest.java new — 6 cases
multiusermode/GrowthBookClientInitialPayloadTest.java new — 2 cases
README.md "Bootstrapping from a local payload" section

Usage

Java — offline-capable cold start
String bootstrap = "{\"features\":{\"my-flag\":{\"defaultValue\":true}},\"savedGroups\":{}}";

Options options = Options.builder()
        .apiHost("https://cdn.growthbook.io")
        .clientKey("sdk-abc123")
        .initialPayload(bootstrap)   // seed before the first network call
        .build();

GrowthBookClient gb = new GrowthBookClient(options);
gb.initialize();                                      // true even if the API is unreachable
gb.isOn("my-flag", UserContext.builder().build());   // true, from the payload

Repository-level:

GBFeaturesRepository repo = GBFeaturesRepository.builder()
        .apiHost("https://cdn.growthbook.io")
        .clientKey("sdk-abc123")
        .decryptionKey("<key>")      // optional; required if the payload uses encryptedFeatures
        .initialPayload(bootstrap)
        .build();
repo.initialize();

Public API surface

Consumer touches Kind Stability
Options.builder().initialPayload(String) new builder option additive
GBFeaturesRepository.builder().initialPayload(String) new builder option additive
FeatureRefreshSource.INITIAL_PAYLOAD new enum value additive
existing Options / GBFeaturesRepository constructor arities preserved via delegating overloads no break

Tests

Suite Covers
GBFeaturesRepositoryInitialPayloadTest (6) offline seed (no network, lastSuccessfulFetchAtMillis stays 0); live-server refresh overrides the seed; malformed payload fails fast at construction; encrypted payload decrypted with a key; wrong key → startup error; seed reported as INITIAL_PAYLOAD/loadedFromCache=true while the offline fetch failure is still surfaced
GrowthBookClientInitialPayloadTest (2) offline initialize() returns true and isOn() evaluates from the seed; malformed payload → initialize() returns false

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


Compatibility & scope notes

  • Additive / backward compatible. New parameters are added via delegating overloads, never by
    widening an existing signature in place; OptionsTest enforces the preserved constructor arities.
  • Ported from the internal feat/offline-bootstrap branch, adapted. Public main's constructors
    are simpler than internal's (no requestTimeout / custom-header / eventLogger params yet), so
    initialPayload is threaded as the trailing parameter through main's actual constructor chain.
  • Scope. Applies to the feature repository and the multi-user GrowthBookClient. Single-context
    GrowthBook/GBContext already accepts features directly (featureSnapshot/featuresJson), so it
    needs no equivalent and is untouched.
  • Build: requires JDK 17 (the default JDK 26 crashes the Lombok version used here), matching CI.

Add an optional inline bootstrap payload so the SDK can serve features immediately
at cold start without waiting for the first network fetch — enabling an instant,
offline-capable start and deterministic tests.

- Options.initialPayload + GBFeaturesRepository.builder().initialPayload(...) accept a
  JSON payload in the features-endpoint shape (or encryptedFeatures when a decryptionKey
  is set). GrowthBookClientRepositoryFactory threads it into the repository.
- GBFeaturesRepository seeds state from the payload in initialize() before the first
  refresh, reusing the cache-load path so it does not write the file cache or advance
  lastSuccessfulFetchAtMillis. The seed is reported with FeatureRefreshSource.INITIAL_PAYLOAD
  and loadedFromCache=true; the first successful network refresh replaces it.
- Malformed payloads fail fast: OptionsValidator rejects them at GrowthBookClient.initialize()
  and the repository builder throws IllegalArgumentException. Decryption failures surface as a
  clear startup error instead of a silent fallback.

New parameters are added via delegating overloads (preserving existing constructor
arities); FeatureRefreshSource gains INITIAL_PAYLOAD. Tests cover offline seed,
live-server override, malformed/ encrypted payloads, and listener/metrics reporting.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] The changes since the last review appear safe to merge.

Reviews (3) · Last reviewed commit: "test: gate the global-context race test ..." · Reviewed by Greptile

Comment thread lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java Outdated
Comment thread README.md Outdated
…tests

Address review feedback on the inline bootstrap payload:

- Instant cold start (no blocking startup): when a seed is applied, the first refresh for
  STALE_WHILE_REVALIDATE now runs in the background (FORCE) instead of blocking initialize()
  on a possibly slow/unreachable network call. SSE uses FORCE on the seeded initial fetch.
- No stale seed via borrowed cache freshness: FORCE means the post-seed refresh is never
  skipped by shouldSkipRefresh, so a fresher file cache (or live server) supersedes the seed
  instead of the seed keeping an older snapshot active until backgroundFetchInterval expires.
- Tests: the two affected repository tests now await the background refresh deterministically
  via a FeatureRefreshListener latch (no sleeps). GrowthBookClientInitialPayloadTest models the
  unreachable API with a WireMock 500 instead of a presumed-closed port (no real network).
- README: the encrypted-bootstrap example no longer combines a plaintext payload with a
  decryptionKey (a non-null key always selects the encrypted path); shown as a separate snippet.

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

Bootstrap initialization appears to permit stale client state, reuse unrelated cache freshness after failure, and block SSE startup.

JDK 17 ./gradlew test: 594 passed, 3 skipped. Three isolated checks reproduced the findings below. CI is green. No manual testing suggested; these SDK behaviors were reproduced deterministically.

Comment thread lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java Outdated
@andywhite37
andywhite37 self-requested a review October 8, 2026 21:28
Volodymyr Nazarkevych added 3 commits October 9, 2026 10:22
…obber a concurrent refresh

A seeded cold start schedules its first refresh in the background, which
can race GlobalContextManager publication: initialize() reads the seed
snapshot, the background refresh publishes the live flags, then
initialize() overwrites them with the stale seed. Identical later
responses do not repair it because featuresChanged is false, so the
client stays pinned to the seed.

Route initialize() and refresh() through a single lock-guarded
read-snapshot-then-publish step. Both paths read the repository's current
snapshot, so serializing the read and the publish makes the newer
snapshot win regardless of ordering.
The stale-while-revalidate seeded path refreshes in the background so a
seeded initialize() returns immediately, but the server-sent-events path
still ran a synchronous FORCE fetch (plus retries) before opening the
stream, blocking initialize() on a slow or unreachable network despite a
valid seed. Run the seeded SSE startup on the background scheduler: the
FORCE fetch still establishes SSE support and current features before the
stream opens, exactly as the blocking path did, but off the caller thread
so the seed serves immediately. The startup skips SSE if shutdown ran
first and tears down a stream it opened while losing a race with shutdown.

Also fold the duplicated inline-payload JSON-object validation in
OptionsValidator and GBFeaturesRepository into a shared
GrowthBookJsonUtils.jsonObjectViolation helper.
# Conflicts:
#	lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

The race test used a two-second latch timeout to decide when initialize's snapshot
read could finish, which relied on real time and waited two seconds on every run.
Drive it with explicit gates instead: initialize parks inside publishSnapshot while
holding the lock, and the test asserts the refresher is BLOCKED on the monitor and has
not read a snapshot before releasing initialize. @timeout remains only as a hang guard.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

@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 client-state race and blocking SSE startup appear fixed. The cache-freshness finding remains reproducible.

JDK 17 ./gradlew test: 609 passed, 4 skipped, including the new regression tests. An isolated check reproduced the remaining bug. CI is green. No manual testing suggested; the affected SDK behavior is covered programmatically.

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.

2 participants