Skip to content

feat: invalidateRemoteConfigsCache() + remote config refetch after same-uid identify (DEV-1236) - #853

Merged
SpertsyanKM merged 6 commits into
mainfrom
kamo/dev-1236-sdk-iosandroid-obvyazki-refetch-rc-posle-identify-i-yavnii
Jul 30, 2026
Merged

feat: invalidateRemoteConfigsCache() + remote config refetch after same-uid identify (DEV-1236)#853
SpertsyanKM merged 6 commits into
mainfrom
kamo/dev-1236-sdk-iosandroid-obvyazki-refetch-rc-posle-identify-i-yavnii

Conversation

@SpertsyanKM

@SpertsyanKM SpertsyanKM commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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

  1. 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).
  2. New public API Qonversion.invalidateRemoteConfigsCache() — marks the cached configs stale on demand. The name says exactly what it does: no network request, no callback; the next remoteConfig / remoteConfigList call 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 after identify() — that path is automatic.
  3. Happens-before for any-thread callers. The invalidation generation is an AtomicInteger bumped 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.
  4. Superseded in-flight loads are re-issued, never worse than before. If the generation moves while a remoteConfig request 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-flight remoteConfigList completes 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).
  5. Fallback configs are no longer cached (single-key and list paths): a bundled fallback delivered on network failure previously pinned itself into the cache until the next invalidation; now the next call retries the network. ApiRateLimitExceeded became fallback-eligible scoped to remote configs only (a local shouldFireRemoteConfigFallback predicate; the shared shouldFireFallback and 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.
  6. Every invalidation seam bumps synchronously — the caller-thread generation bump covers attach/detach and 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)

  • A remoteConfig() request in flight when identify() 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).
  • Fallback configs are no longer cached: offline apps issue one fast-failing request per read instead of one per session, recover automatically when connectivity returns, and receive the bundled payload even when the local rate limiter short-circuits the request (remote configs only — rate-limited entitlement checks are unchanged).
  • A warm 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.
  • New abstract member on the public Qonversion interface → minor version bump; anyone with a custom implementation of the interface must add invalidateRemoteConfigsCache().

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 pins verifyOrder(invalidate → handlePendingRequests) plus onUserUpdate exclusion; repeat-identify early return pinned. testDebugUnitTest (32+4 green), detektAll, :sample compile 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

…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>
@SpertsyanKM SpertsyanKM changed the title feat: refreshRemoteConfigs() + remote config refetch after same-uid identify (DEV-1236) feat: invalidateRemoteConfigsCache() + remote config refetch after same-uid identify (DEV-1236) Jul 30, 2026
SpertsyanKM and others added 4 commits July 30, 2026 16:34
…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
SpertsyanKM merged commit 46df6d9 into main Jul 30, 2026
1 check passed
@SpertsyanKM
SpertsyanKM deleted the kamo/dev-1236-sdk-iosandroid-obvyazki-refetch-rc-posle-identify-i-yavnii branch July 30, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant