Skip to content

other(offerings): gate getOfferings delivery on ui_config and stale-cache paths#7181

Merged
facumenzella merged 11 commits into
mainfrom
facu/offerings-gate-ui-config
Jul 10, 2026
Merged

other(offerings): gate getOfferings delivery on ui_config and stale-cache paths#7181
facumenzella merged 11 commits into
mainfrom
facu/offerings-gate-ui-config

Conversation

@facumenzella

@facumenzella facumenzella commented Jul 9, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

With remote config on, getOfferings shouldn't return until the paywall config data it depends on is queryable: offerings, the workflows topic, and ui_config (team-confirmed contract). Android already implements this; iOS was only waiting for the workflows topic, and skipped the gate entirely when serving a stale cache. Aligning also makes the iOS and Android launch measurements comparable: Android's numbers include the ui_config round trips, iOS's structurally excluded them.

Description

deliverWhenConfigReady now resolves two things concurrently before delivering: the workflows topic + its prefetch blobs (as before), and the ui_config body via the existing UiConfigProvider. Both stay best-effort (each resolves nil on failure, so delivery can never be stranded). The stale-memory-cache path now goes through the same gate, with the background refresh kicked first so the gate can't delay it.

Two behavior changes to consciously accept:

  1. Cold launches with workflows on get slower: ui_config's blob fetches now happen inside getOfferings instead of after it. That's the point — the paywall has its styling in hand when getOfferings returns (parity with WorkflowManager.onPaywallConfigReady on Android).
  2. Stale cache hits before the first config sync now wait for it. The gate is a no-op once config has synced, so stale hits keep returning fast everywhere but the very first launch.
On-simulator proof (before/after logs)

Cold launches of PaywallsTester (Debug, ENABLE_REMOTE_CONFIG, live stress project 5f07e7e3 with 3 active topics / 20 referenced blobs), app container erased before each launch, SDK verbose logs captured via log stream. The GATE-PROOF markers wrap the app's getOfferings call.

BEFORE (origin/main)getOfferings returns without the ui_config blobs ever being fetched (a later paywall render pays those round trips):

18:07:42.697  GATE-PROOF: calling getOfferings
18:07:42.758  VERBOSE: Refreshing remote config for domain 'app' (manifestPresent: false, ...)
              [16 inline blobs stored]
18:07:44.090  DEBUG: Persisted remote config for domain 'app' with 3 active topics and 20 referenced blobs.
18:07:44.994  GATE-PROOF: getOfferings RETURNED (offerings: default, error: none)
              [zero "bytes downloaded from" lines in the entire run: ui_config never fetched]

AFTER (this branch) — delivery completes the same millisecond the last ui_config blob lands; the app is render-ready when getOfferings returns:

18:06:06.484  GATE-PROOF: calling getOfferings
18:06:06.541  VERBOSE: Refreshing remote config for domain 'app' (manifestPresent: false, ...)
              [16 inline blobs stored]
18:06:07.750  DEBUG: Persisted remote config for domain 'app' with 3 active topics and 20 referenced blobs.
18:06:08.567  VERBOSE: Stored remote config blob 'p0ix...Gf5_' with 4879 bytes downloaded from config.revenuecat-static.com/...
18:06:08.571  VERBOSE: Stored remote config blob 'Jewn...Pwp7' with 53900 bytes downloaded from config.revenuecat-static.com/...
18:06:08.651  VERBOSE: Stored remote config blob 'xXyp...1a1p' with 65 bytes downloaded from config.revenuecat-static.com/...
18:06:08.652  VERBOSE: Stored remote config blob 'jMWA...FWub' with 66 bytes downloaded from config.revenuecat-static.com/...
18:06:08.652  GATE-PROOF: getOfferings RETURNED (offerings: default, error: none)

Total cold getOfferings in these samples: 2.30s before vs 2.17s after (the four downloads are small and parallel; network variance dominates) — the contract is met at negligible cost for this payload, and the render-time fetches disappear.

AI session context

AI Context

Metadata

  • PR: this PR
  • Branch: facu/offerings-gate-ui-config
  • Author / human owner: facumenzella
  • Agent(s): Claude Code (Fable 5), Codex (plan critique + review loop via codex-companion)
  • Session source: current conversation (SDK config benchmark session)
  • Generated: 2026-07-09
  • Context document version: 2 (refreshed after the stale-delivery simplification)

Goal

Make getOfferings (remote config on) return only when offerings, the workflows topic, and ui_config are all queryable, matching Android's contract.

Initial Prompt

While reconciling why Android's launch benchmark showed ~70% workflows overhead vs iOS's ~20%, the human asked "are you sure on iOS we're waiting for all the blobs to be downloaded, to actually return getOfferings?" — investigation showed iOS gates only on workflows-topic prefetch blobs, while Android additionally awaits the ui_config body. The human confirmed with the team: "on the SDK side we can't return getOfferings (when workflows is on) until uiconfig, offerings y workflows are ready", and instructed: check how Android does it and do the same.

Important Follow-up Prompts

  • "What I would do before writing a plan, is check how android does it and do the same" — the plan was written from Android's merged sources (WorkflowManager.onPaywallConfigReady, OfferingsManager call sites), not from memory.
  • Android colleague's confirmation that ui_config blobs are fetched on demand during getOfferings (not prefetched during config sync), which is where Android's gate cost lives.

Agent Contribution

  • Verified both platforms' gating in source: iOS awaitTopicAndPrefetchBlobsReady(.workflows) only; Android same PLUS uiConfigProvider.getUiConfig(), gating cached (incl. stale) and fresh deliveries.
  • Wrote the plan (~/Developer/rc/plans/2026-07-09-ios-getofferings-paywall-config-gate.md), ran it through a Codex critique (1 finding applied: the mock needed a holdable blob-read hook; 2 accepted-as-is notes), implemented, and proved RED → GREEN.
  • Empirical evidence from the benchmark app tier: ui_config's 4 blobs finished downloading ~120ms AFTER getOfferings returned on cold config launches (hundreds of samples), confirming the gap this PR closes.

Human Decisions

  • Decision: the contract itself (team sync): getOfferings must not return until offerings + workflows + ui_config are ready.
  • Decision: mirror Android's implementation shape rather than design an iOS-specific gate.

Key Implementation Decisions

  • Decision: widen deliverWhenConfigReady with a concurrent async let pair (workflows readiness, UiConfigProvider.getUiConfig()); both non-throwing and nil-on-failure, so Android's "best-effort, never strand delivery" semantics fall out of the types with no extra error handling.
  • Decision: gate the stale-memory-cache path too (Android gates all cached deliveries), kicking the background refresh BEFORE the gated dispatch so the gate cannot delay the refresh.
    • Rejected: keeping the stale path ungated (violates the confirmed contract on first launch).
  • Decision (simplification, after review): stale delivery returns the captured memoryCachedOfferings snapshot, matching Android's vendCachedOfferingsAndMaybeRefresh (verified in origin Android source: it delivers the captured cachedOfferings param and does not hand the background refresh's result back to the caller). This replaced an earlier opportunistic "return the refresh result if it beats the gate" variant, deleting the Atomic<Offerings?>, the main-actor read-ordering, and the race class those attracted. Tradeoff (accepted, Android-identical): a stale snapshot may be returned even if the refresh already finished; the refresh still updates the cache for the next call.
  • Decision: UiConfigProvider is created locally inside deliverWhenConfigReady (it is stateless), not stored on the manager — one fewer property.
  • Decision: background cache refreshes (nil completion) skip the readiness gate entirely, so they don't await or decode config for a no-op delivery.

Files / Symbols Touched

  • Sources/Purchasing/OfferingsManager.swiftdeliverWhenConfigReady (widened to workflows + ui_config, provider created locally), offerings(appUserID:...) cached/stale delivery gated on the captured snapshot, handleOfferingsBackendResult skips the gate on nil-completion background refreshes.
  • Tests/UnitTests/Purchasing/OfferingsManagerTests.swift — 3 new tests (ui_config hold, best-effort failure, concurrency of the two waits); testGetOfferingsFromStaleMemoryCacheDeliversImmediately rewritten to ...GatesDeliveryAndStillRefreshes (the old test pinned the behavior this PR removes).
  • Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swiftMockRemoteConfigManager gains a holdable blob-read hook (shouldStoreBlobDataCompletion / completeStoredBlobReads()), mirroring the existing topic hook.

Validation

  • RED → GREEN: the 3 behavior tests failed against the unchanged SDK (hold-based ui_config tests + stale gating), pass after; the best-effort test passes in both states by design.
  • OfferingsManagerTests: 45/45. Full UnitTests suite: no failures introduced (the only failing class, BackendGetCustomerCenterConfigTests snapshots, fails identically on pristine origin/main in this environment — machine-specific references).
  • swift build (SPM): green. SwiftLint: clean (the stale-branch edit initially tripped function_body_length; resolved by collapsing the duplicated gated dispatch).
  • Codex review loop: converged after 4 passes. Applied: a plan-stage mock gap; the stale-refresh capture fix; the shared-slot post-gate read removal (identity-leak hazard) with two pinning tests. Rejected with reasons: "don't gate stale delivery" (contradicts the team-confirmed contract and Android parity) and "guarantee refresh-result delivery on the stale path" (contract requires config readiness, not refresh delivery; a guarantee would chain stale hits to the network round trip — the opportunistic delivery is strictly better than Android parity and never delivers wrong data).
  • On-simulator before/after proof captured (see the proof section above): main returns getOfferings with ui_config never fetched; this branch completes delivery the same millisecond the last ui_config blob stores.

Validation Gaps

  • No integration/device test of the end-to-end timing change; the benchmark app tier (PR Add SDKConfigBenchmark: legacy vs config endpoint benchmark suite #7174) will measure it after merge (cold config numbers expected to rise; last_blob_stored should no longer exceed offerings).
  • tvOS path unverified beyond compilation (getUiConfig() has a tvOS variant; workflows aren't supported there).

Review Focus

  • Is gating the stale-cache path acceptable UX (first-launch-only wait) given the contract?
  • async let pair semantics: both children awaited, neither throws — any cancellation path that could strand deliver()?
  • Should the ui_config wait also cover the fetchCurrent/uiPreviewMode path (currently ungated, pre-existing)?

Risks / Reviewer Notes

  • Risk: cold-launch getOfferings latency increases by the ui_config fetch cost when its blobs are CDN-delivered; that is the intended contract, and khepri's inline-budget decisions control the size of the cost.
  • Risk: getUiConfig() has no decoded cache, so the gate's decode work repeats at render; Android pays the same shape of cost (accepted parity).

Note

Medium Risk
Changes launch-time getOfferings latency and first-launch stale-cache timing when remote config is enabled; behavior is intentional for Android parity but affects paywall readiness contracts.

Overview
Aligns iOS getOfferings (remote config on) with Android: callers don't get offerings until workflows (topic + prefetch blobs) and ui_config are ready to query, not workflows alone.

deliverWhenConfigReady now async let-awaits both awaitTopicAndPrefetchBlobsReady(.workflows) and UiConfigProvider.getUiConfig() in parallel; both are best-effort (nil on failure, no stranded completions). All memory-cache deliveries—including stale hits—use this gate and return the snapshot captured at request time (background refresh still runs first for stale; it may finish before the gate but won't change what this call returns). Diagnostics latency for cache hits is recorded inside the gate. Background cache updates with a nil completion skip the gate and only write the cache.

Tests add holdable ui_config blob reads on MockRemoteConfigManager, multi-waiter topic completion, and coverage for ui_config wait, concurrent gates, stale gating, snapshot identity, and gate-skipped background refresh.

Reviewed by Cursor Bugbot for commit e66bdf9. Bugbot is set up for automated code reviews on this repo. Configure here.

…he paths

getOfferings (with remote config enabled) must not return until the paywall
config data it depends on is queryable. Two gaps closed:

Delivery already waited for the workflows topic and its prefetch blobs; it
now also resolves the ui_config body, concurrently, so a paywall render right
after getOfferings has its styling in hand without a further round trip. Both
steps remain best-effort: either failing resolves nil rather than stranding
delivery.

And the stale-memory-cache path previously bypassed the gate entirely,
returning before config readiness. Stale delivery now goes through the same
gate, with the background refresh kicked first so the gate cannot delay it.
The gate is a no-op once config has synced, so stale cache hits still return
fast everywhere but the very first launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
@facumenzella facumenzella added the pr:fix A bug fix label Jul 9, 2026
@facumenzella
facumenzella marked this pull request as ready for review July 9, 2026 15:31
@facumenzella
facumenzella requested a review from a team as a code owner July 9, 2026 15:31
facumenzella and others added 2 commits July 9, 2026 17:40
…ns the gate

Review findings: the gated stale delivery captured the pre-gate snapshot, so
a background refresh finishing during the wait still returned stale packages;
delivery now re-reads the cache when the gate opens. The mock's topic hook
stores every waiter (the stale test has two: gated delivery + background
refresh) so completing it is deterministic. Also trims comment verbosity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
…ness gate

Convergence-pass findings: the post-gate cache re-read could deliver an
unrelated write (identity or locale change repopulating the single shared
slot) to a request that started before it. The stale path's refresh now
reports its result through its own completion, same-request by construction,
and gated delivery prefers that over the captured snapshot; the shared slot
is never read after the gate. Two pinning tests: a fresh-path delivery always
returns its captured snapshot, and a stale-path delivery can return the
captured snapshot or its own refresh result but never an unrelated slot write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0da3fc0. Configure here.

Comment thread Sources/Purchasing/OfferingsManager.swift
Bugbot follow-up on the stale-path delivery: the refresh completion writes
its result on the main actor while the gated delivery read it from the gate
task, so a refresh that finished first could still be invisible to the
delivery. The read now happens inside the delivery's main-actor hop, where
the write also lands, so main-queue ordering makes any earlier-enqueued
refresh result visible. Delivery remains best-effort by design: the contract
gates on config readiness, not on refresh completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
@facumenzella facumenzella added pr:other and removed pr:fix A bug fix labels Jul 10, 2026
@facumenzella facumenzella changed the title fix(offerings): gate getOfferings delivery on ui_config and stale-cache paths other(offerings): gate getOfferings delivery on ui_config and stale-cache paths Jul 10, 2026
Comment thread Sources/Purchasing/OfferingsManager.swift Outdated

@rickvdl rickvdl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! I think this looks good! But would like an extra pair of eyes from @ajpallares before approving

facumenzella and others added 4 commits July 10, 2026 09:31
Cut the causal-chain narration and the duplicated main-actor-ordering
explanation across the two cached-delivery helpers down to the load-bearing
constraint each. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
Per review: drop the opportunistic 'return the refreshed offerings if the
background refresh beats the config gate' behavior. It was never required by
the contract (which gates on config readiness, not snapshot freshness) and
was the sole reason for the Atomic plumbing, the main-actor read-ordering,
and the race findings that followed. Stale delivery now returns the snapshot
captured for the request, matching the pre-existing stale-cache model; the
background refresh still updates the cache for the next call. UiConfigProvider
is stateless, so it's created in the gate instead of stored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
A stale-path background refresh passes a nil completion: it only updates the
cache, with nothing to deliver. It still went through deliverWhenConfigReady,
which awaits (and decodes) workflows + ui_config for a no-op dispatch. Skip
the gate when there is no completion, so background refreshes don't pay the
readiness wait or the ui_config decode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
The cached getOfferings diagnostic fired before deliverWhenConfigReady, so
its recorded latency excluded the readiness wait this PR adds, making a gated
cache hit look as fast as an ungated one. Move the tracking into the gated
delivery so the metric reflects the true latency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H

@ajpallares ajpallares left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it looks great! Just a couple of small comments, suggesting to add/improve tests

Comment thread Sources/Purchasing/OfferingsManager.swift
Comment thread Tests/UnitTests/Purchasing/OfferingsManagerTests.swift
…elivery

Review follow-ups from ajpallares on #7181: a test locking that a background
refresh (nil completion) caches without entering the readiness gate; an
assertion on the delivered offerings in the ui_config-failure path; and a
test pinning that delivery still fires when both readiness branches resolve
empty/failed (the non-throwing, always-awaited guarantee).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXR3hncr2daCqrnXdU5N4H
@facumenzella
facumenzella enabled auto-merge (squash) July 10, 2026 09:04
@facumenzella
facumenzella merged commit 96fb341 into main Jul 10, 2026
18 of 19 checks passed
@facumenzella
facumenzella deleted the facu/offerings-gate-ui-config branch July 10, 2026 09:16
This was referenced Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants