Repository navigation
feat: bootstrap from an inline feature payload (offline cold start) - #258
vazarkevych wants to merge 6 commits into
Conversation
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.
|
@greptile review |
|
…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
left a comment
There was a problem hiding this comment.
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.
…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
|
@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.
|
@greptile review |
andywhite37
left a comment
There was a problem hiding this comment.
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.
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 · additiveBranch:
feat/offline-bootstrap→mainCommit:
06d9c59Summary
Adds an optional inline bootstrap payload (
Options.initialPayload/GBFeaturesRepository.builder().initialPayload(...)). When set, the SDK seeds its feature state fromthat payload during
initialize()— before any network call — so evaluations work immediately, evenoffline. The payload is in the same shape the features endpoint returns
(
{"features": {...}, "savedGroups": {...}}, orencryptedFeatureswhen 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
initialize()blocks on the first fetch, and with anunreachable 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.
init({payload})/initSync().Behavior
isFromCache=true): does not write the file cache, does not advancelastSuccessfulFetchAtMillisFeatureRefreshEventwith sourceFeatureRefreshSource.INITIAL_PAYLOADandisLoadedFromCache() == true— never mistaken for a network successinitialize()returnstrue; the fetch failure is still reported to listeners (isSuccessful() == false) but does not abort startupGrowthBookClient.initialize()rejects it via options validation; the repository builder throwsIllegalArgumentException; a decryption failure surfaces as a clear startup errorHow 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]What was done
1.
GBFeaturesRepository(repository/GBFeaturesRepository.java)initialPayloadfield + getter; threaded through the constructor chain via delegatingoverloads (existing positional arities preserved).
validateInitialPayload(...)— structural JSON check at construction (fail fast).seedInitialPayload()— seeds viaonResponseJson(payload, true)and reportsFeatureRefreshSource.INITIAL_PAYLOAD/loadedFromCache=true; invoked at the top ofinitialize().2.
Options/OptionsValidator(multiusermode/configurations/)Options.initialPayload(+ builder) added via a delegating overload (30-arg signature preserved).OptionsValidator.checkInitialPayloadrejects a non-JSON-object payload up front.3. Wiring + model
GrowthBookClientRepositoryFactorypassesoptions.getInitialPayload()into the repository builder.FeatureRefreshSourcegainsINITIAL_PAYLOAD(all values documented).Files
model/FeatureRefreshSource.javaINITIAL_PAYLOADenum valuerepository/GBFeaturesRepository.javainitialPayloadfield/getter;validateInitialPayload;seedInitialPayload;initialize()hook; builder parammultiusermode/configurations/Options.javainitialPayloadfield + builder; 30-arg backward-compat ctormultiusermode/configurations/OptionsValidator.javacheckInitialPayloadmultiusermode/internal/GrowthBookClientRepositoryFactory.javainitialPayloadinto the builderGrowthBookClientTestFixtures.javainitialPayloadrepository/GBFeaturesRepositoryInitialPayloadTest.javamultiusermode/GrowthBookClientInitialPayloadTest.javaREADME.mdUsage
Java — offline-capable cold start
Repository-level:
Public API surface
Options.builder().initialPayload(String)GBFeaturesRepository.builder().initialPayload(String)FeatureRefreshSource.INITIAL_PAYLOADOptions/GBFeaturesRepositoryconstructor aritiesTests
GBFeaturesRepositoryInitialPayloadTest(6)lastSuccessfulFetchAtMillisstays 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 asINITIAL_PAYLOAD/loadedFromCache=truewhile the offline fetch failure is still surfacedGrowthBookClientInitialPayloadTest(2)initialize()returnstrueandisOn()evaluates from the seed; malformed payload →initialize()returnsfalse./gradlew buildpasses on JDK 17; the full:libsuite is green.Compatibility & scope notes
widening an existing signature in place;
OptionsTestenforces the preserved constructor arities.feat/offline-bootstrapbranch, adapted. Publicmain's constructorsare simpler than internal's (no
requestTimeout/ custom-header /eventLoggerparams yet), soinitialPayloadis threaded as the trailing parameter through main's actual constructor chain.GrowthBookClient. Single-contextGrowthBook/GBContextalready accepts features directly (featureSnapshot/featuresJson), so itneeds no equivalent and is untouched.