feat: invalidateRemoteConfigsCache() + remote config refetch after same-uid identify (DEV-1236) - #853
Merged
SpertsyanKM merged 6 commits intoJul 30, 2026
Conversation
…dentify DEV-1236 (B4): server-side dynamic targeting re-evaluation is invisible while the SDK serves remote configs from the process-lifetime in-memory cache, and the first identify does not change the uid — so the cache was never dropped on login and the user stayed on the pre-login evaluation forever. - identify() resolving to the SAME uid now drops the cached configs (non-destructively: loading states, pending callbacks and the in-flight generation guard all survive) — the next remoteConfig() call fetches a fresh evaluation with the attached identity and user properties. - New public Qonversion.refreshRemoteConfigs(): drops the cached configs on demand, for the property-then-fetch flow when the app needs the updated targeting evaluation immediately. Tests: the identify contract test now pins the refresh on the same-uid branch (still no permissions-cache clear, still no re-launch), and a manager test pins that refreshRemoteConfigs is non-destructive and generation-guards in-flight loads. testDebugUnitTest, detektAll and :sample compile green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y4V5cx2SMU4VrfPntwfKJ9
… loads, uncached fallbacks Addresses the three-lens review round: - Rename the public API refreshRemoteConfigs() -> invalidateRemoteConfigsCache(): the method performs no fetch, so the name must say what it actually does. KDoc rewritten with the no-network contract, in-flight semantics, concrete use cases, thread-safety note, and cross-refs from setUserProperty / setCustomUserProperty. - Happens-before for any-thread callers: the invalidation generation is now an AtomicInteger bumped synchronously on the caller thread, and every cached config carries the generation it was cached at - the cached fast paths serve it only while the stamp matches the current generation. - A superseded in-flight load (generation moved while the request was flying) is re-issued once per generation for its waiting callbacks instead of delivering the stale evaluation. - Fallback configs are delivered without being cached, on both the single-key and the list paths. - Tests: invalidation folded into the shared entry-point loop; focused re-issue tests (awaited, non-awaited, attach path); background-thread confinement test asserting immediate staleness; concurrent stress test; fallback non-caching tests; identify contract now pins the invalidate -> handlePendingRequests order, the onUserUpdate exclusion, and the repeat-identify early return; vacuous null asserts replaced with state-presence asserts. - Sample: "Invalidate Remote Configs Cache" button on the Remote Configs screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ump on all seams Round-3 fixes from the review panel: - BLOCKER (stranded waiters): a cached fast-path hit now drains queued waiters instead of serving only the direct callback. Previously a re-issue with a null callback landing on a warm state (e.g. a list load cached the same key after the invalidation) returned without firing the queued callbacks - they hung forever. The re-issue itself now also snapshots its waiters and carries them through the retry, so they cannot strand. - Never worse than before: the re-issue keeps the superseded (but valid) evaluation as a baseline - if the retry fails, the baseline is delivered as a success instead of surfacing an error where the caller previously received a stale success. - The re-issue is gated on the loading state still being live: after a user switch replaced the map, an orphaned state no longer fires a request nobody awaits. - The synchronous generation bump now covers every invalidation seam (attach/detach x4, onUserUpdate), not only the public API - an attach from a background thread makes the cache immediately stale for main-thread readers via the shared invalidateOnAnyThread helper. - ApiRateLimitExceeded is now fallback-eligible: with fallbacks no longer cached, offline repeat calls hit the limiter instead of the old cached-fallback fast path, and must still receive the bundled payload rather than a hard error. - Cache hits now flush pending user properties (single-key and list paths, parity with iOS) - a hit must not swallow the flush. - KDoc: dropped the connectivity-restored bullet (obsolete now that fallbacks are never cached - replaced with an explicit reassurance), documented the retry baseline, unified the thread contract with iOS (call from the same thread as other Qonversion calls), and moved the @see prose into the description body so generated docs keep it. - Tests: cache-hit drain, warm-cache re-issue regression (list load warming a superseded single-key state), failed-retry baseline delivery, orphaned state after user switch, attach-from-background immediate staleness, rate-limited fallback delivery, stable-user concurrency stress with the generation-stamp invariant, list mid-flight delivery pin, cache-hit flush pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iform never-worse Round-4 fixes from the review panel: - MAJOR (shared predicate widening leaked into entitlements): the ApiRateLimitExceeded tolerance moved out of the shared shouldFireFallback into an RC-scoped shouldFireRemoteConfigFallback used only by the two remote-config error paths. utils.kt is reverted to its pre-PR shape, so preparePermissionsResult and the entitlements contract are untouched and the Android/iOS blast radius matches exactly (iOS has a single consumer). - MAJOR (baseline lost to the bundle): a failed retry of a superseded load now prefers the stashed baseline over the static bundled fallback - a real user-specific evaluation seconds old outranks shipped-in-binary defaults. The baseline is stashed on the LoadingState, which also makes the never-worse guarantee uniform: callers who join during the retry window receive the baseline too instead of the retry error. - Cache-hit drain is now inline (snapshot + clear + invoke) instead of fireToCallbacks, so resolving stranded waiters no longer marks a still-outstanding load as finished; and it dedups a listener instance that is both queued and passed directly (exactly one delivery). - KDoc: the thread-contract sentence keeps the shared cross-platform wording and now explicitly notes that any-thread calls are safe on Android. - Tests: baseline-over-bundle (fallback-eligible retry failure with a bundle present), late-joiner baseline delivery, reused-callback dedup on a cache hit, list cache-hit flush pinned for both the stable (flush fires) and unstable (gate holds, no POST to a switching uid) cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A retry resolved by the cached fast path consumes its stashed baseline - a leftover stash must not resurface on a later, unrelated failure and deliver an ancient evaluation as a success. Mirrors the iOS cache-hit clear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…umption, pins Non-blocking follow-ups named by the approving panel, applied as their own suggested one-liners: - The cache-hit dedup compares by identity (===), not equals: a host listener with value semantics (data class) must not suppress a distinct caller. - userChangingRequestFailedWithError consumes the retry stash - it is the only drain path that bypasses the response handlers, and a leftover stash would mask a later unrelated failure as a stale success. - The KDoc thread sentence drops the meta-commentary: "On Android this method is also safe to call from any thread." - Tests pin all three stash-consumption paths (successful retry, warm-cache resolution, user-change failure) - the surviving mutants named by the QA reviewer are now killed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SpertsyanKM
deleted the
kamo/dev-1236-sdk-iosandroid-obvyazki-refetch-rc-posle-identify-i-yavnii
branch
July 30, 2026 14:56
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.
B4 from the Remote Config targeting review (dash-mono PR #674). Linear: DEV-1236. Prerequisite of arming server-side dynamic targeting re-evaluation (B2a) in production: re-evaluation is invisible while the SDK serves configs from its process-lifetime in-memory cache.
What changed
identify()resolving to the SAME uid now drops the cached remote configs. The first identify does not change the uid (UPDATE client SET external_identity), so the cache was never invalidated on login and the user stayed on the pre-login targeting evaluation until process restart. The drop is non-destructive: loading states, pending callbacks and the in-flight generation guard survive; the pending-request replay runs strictly after the invalidation, so queued completions fetch a fresh evaluation with the attached identity and user properties. No permissions-cache clear, no re-launch — pinned by the updated identify contract tests (including the order and the repeat-identify early return).Qonversion.invalidateRemoteConfigsCache()— marks the cached configs stale on demand. The name says exactly what it does: no network request, no callback; the nextremoteConfig/remoteConfigListcall fetches a fresh targeting evaluation. Use cases in the KDoc: property batches the targeting depends on, long-session foreground returns; the doc also states you need it neither after identify (automatic) nor to recover from a bundled fallback (fallbacks are never cached). Not needed afteridentify()— that path is automatic.AtomicIntegerbumped synchronously on the caller thread, and every cached config carries a generation stamp — the cached fast paths serve it only while the stamp matches, so a load issued right after the invalidation never serves the stale copy even before the posted main-thread cleanup drains.remoteConfigrequest is flying and callbacks await it, the load is re-issued instead of delivering the stale evaluation. The waiters are snapshotted and carried through the retry with the superseded (but valid) evaluation as a baseline — a failed retry delivers the baseline as a success instead of an error. The re-issue is gated on the loading state still being live (a user switch must not fire a request nobody awaits). An in-flightremoteConfigListcompletes with the evaluation it started with (documented; re-issuing a list would multiply the retry cost across N keys for a one-shot completion — recorded so nobody "fixes" the asymmetry); nothing from it is cached. Cache hits drain queued waiters (a warm state must never strand them) and flush pending user properties (parity with iOS).ApiRateLimitExceededbecame fallback-eligible scoped to remote configs only (a localshouldFireRemoteConfigFallbackpredicate; the sharedshouldFireFallbackand the entitlements path are untouched), so offline repeat calls that hit the local limiter still receive the bundled payload instead of a hard error. A failed retry of a superseded load prefers the stashed baseline over both the error and the bundled payload — a real user-specific evaluation seconds old outranks shipped-in-binary defaults — and the guarantee is uniform: late joiners during the retry window get the baseline too.onUserUpdate, not only the public API, so an attach from a background thread makes the cache immediately stale for main-thread readers.Behavior changes for existing integrations (release notes)
remoteConfig()request in flight whenidentify()completes is re-issued, so its callback resolves after two round trips instead of one (correct-data-for-latency trade; a failed retry falls back to the first response, so reliability is not reduced).remoteConfig()/remoteConfigList()cache hit now flushes pending user properties (parity with iOS) — a previously purely-local read can issue a properties POST when properties are pending.Qonversioninterface → minor version bump; anyone with a custom implementation of the interface must addinvalidateRemoteConfigsCache().Tests
QRemoteConfigManagerTest: invalidation folded into the shared entry-point loop (5 entry points, non-destructive semantics); focused re-issue tests (awaited / non-awaited / attach path); background-thread confinement test asserting immediate staleness via the generation stamp; concurrent invalidate-vs-load stress test; fallback non-caching tests.QProductCenterManagerIdentifyContractTest: same-uid pinsverifyOrder(invalidate → handlePendingRequests)plusonUserUpdateexclusion; repeat-identify early return pinned.testDebugUnitTest(32+4 green),detektAll,:samplecompile green.Sample: an "Invalidate Remote Configs Cache" button on the Remote Configs screen.
Facade delegation (
QonversionInternal) is a 1-line pass-through; the repo has no facade-level tests, consistent with the existing precedent.iOS mirror and cross-platform wrappers follow in the same Linear issue. Release note: ships together with the A6 fixes (#852) in one release.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Y4V5cx2SMU4VrfPntwfKJ9