Skip to content

feat(workflows): serve stale workflow detail while revalidating#6961

Merged
facumenzella merged 2 commits into
mainfrom
facu/workflow-detail-swr
Jun 10, 2026
Merged

feat(workflows): serve stale workflow detail while revalidating#6961
facumenzella merged 2 commits into
mainfrom
facu/workflow-detail-swr

Conversation

@facumenzella

@facumenzella facumenzella commented Jun 9, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids (this ports purchases-android#3540)

Motivation

A workflow whose cached detail went stale currently blocks on a backend round-trip before the paywall can render. This brings the workflow detail fetch in line with how OfferingsManager already serves offerings: serve the cached value instantly and refresh in the background.

Description

Port of purchases-android#3540 (detail only). WorkflowManager.getWorkflow now uses stale-while-revalidate:

  • fresh hit serves the cached value, no network
  • stale-but-present hit serves the cached value immediately, then refreshes the cache in the background (a success updates the cache, a failure is logged and swallowed, never delivered to the caller)
  • miss (or stale with revalidation disabled) blocks on the fetch
flowchart TD
    A([getWorkflow]) --> B{cached entry present?}
    B -->|no| MISS["block on backend fetch,<br/>deliver result to caller"]
    B -->|yes| C{still fresh?}
    C -->|yes| FRESH["serve cached, no network"]
    C -->|stale| SWR{staleWhileRevalidate?}
    SWR -->|"false (prefetch)"| MISS
    SWR -->|"true (on-demand)"| STALE["capture generation G,<br/>serve stale value to caller,<br/>then refresh in background<br/>(failures logged, not delivered)"]
    MISS --> WRITE["guarded cache write"]
    STALE --> WRITE
    WRITE --> G{generation still G?}
    G -->|yes| OK["update cache + warm assets"]
    G -->|"no: clearCache on login/logout"| DROP["drop write,<br/>keeps prev user's detail out"]
Loading

Prefetch passes staleWhileRevalidate: false, so it keeps forcing a fresh fetch and persisting its envelope rather than serving and persisting a stale value.

The background refresh captures its generation guard before serving, so a clearCache() (login/logout) racing the serve still drops the write, keeping the previous user's detail out of the new user's cache (consistent with #6944).

The workflows list is intentionally left as is: it already rides offerings' stale-while-revalidate, and the one place it blocks (deliverEnsuringWorkflowsList on the fresh-offerings path) exists on purpose to guarantee cachedWorkflowId(forOfferingId:) resolves right after getOfferings.

AI session context

Metadata

Goal

Give the workflow detail fetch the same stale-while-revalidate behavior offerings already have, mirroring purchases-android#3540. Detail only; the workflows list is explicitly out of scope.

Initial Prompt

The human asked whether iOS should serve-cache-then-fetch for workflows like Android #3540, framed as "do the same as offerings for both list and details". After comparing the current iOS code (offerings already SWR; detail blocks on stale; list blocks but rides offerings' SWR), the agent recommended porting #3540 for the detail and leaving the list alone. The human agreed ("let's port that then"), then asked to send the change to Codex for review.

Agent Contribution

  • Added staleWhileRevalidate: Bool = true to getWorkflow; extracted the backend-fetch + cache + warm-up body into a private fetchAndCacheWorkflow shared by the blocking path and the background refresh.
  • Implemented the three-branch SWR logic; prefetch opts out with false.
  • Added PaywallsStrings.error_refreshing_workflow for the swallowed background-refresh failure.
  • TDD: wrote the serve-stale and failure-swallowing tests first, watched them fail against the block-on-stale code, then implemented. Mutation-checked the prefetch opt-out (flipping it to true reds the persist-fresh test).

Human Decisions

Codex Review and Resolution

  • Codex returned a "blocking" verdict claiming branch 2 wraps the refresh in Task {}, opening an async window for a cross-user leak. That mechanism is incorrect: the helper is called synchronously, no Task, no await.
  • Its underlying point was still valid: the served value and the generation are two separate lock-free reads, and the SWR change had widened the gap by putting completion() between them. Fix applied: capture the generation before serving, so the background write binds to the serve-time generation (same guarantee the blocking path and fix(workflows): Don't leak the previous user's workflows after a login or logout #6944 have). The residual sub-statement race is the same lock-free read window fix(workflows): Don't leak the previous user's workflows after a login or logout #6944 accepted by design and is not chased here.
  • Corrected a misleading comment (concurrent refreshes do coalesce at WorkflowsAPI's callback cache). Added two coverage tests Codex suggested.

Files / Symbols Touched

  • Sources/Purchasing/WorkflowManager.swift - getWorkflow(..., staleWhileRevalidate:), new fetchAndCacheWorkflow, prefetch opt-out.
  • Sources/Logging/Strings/PaywallsStrings.swift - error_refreshing_workflow.
  • Tests/UnitTests/Purchasing/WorkflowManagerTests.swift - 6 new tests.

Validation

  • xcodebuild test (iPhone 15 sim) for WorkflowManagerTests (48), PurchasesWorkflowTests (42), WorkflowsCacheTests: 91 passing, 0 failures.
  • swiftlint on changed files: clean.
  • Manual verification: Not run.

Validation Gaps

  • The clear-during-refresh test drives the generation guard directly. The true sub-statement race (a clear landing between reading the cached value and capturing the generation) is the same lock-free read window the workflows cache accepts by design and is not deterministically reproducible without a production-only seam.

Review Focus

  • getWorkflow's generation is captured before completion on the SWR path; the background refresh delivers no result to the caller.
  • Prefetch passes staleWhileRevalidate: false (a stale prefetch must fetch and persist fresh, not serve stale).
  • WorkflowManager is internal and the new parameter is optional/defaulted, so no swiftinterface change.

Non-goals / Out of Scope

  • The workflows list (kept non-SWR by design) and the offerings cache.

Port of purchases-android#3540. getWorkflow now uses stale-while-revalidate
like OfferingsManager: a stale-but-present detail is served immediately and
the cache is refreshed in the background (a success updates the cache, a
failure is logged and swallowed). Prefetch opts out via
staleWhileRevalidate: false, so it keeps forcing a fresh fetch and persisting
its envelope instead of serving a stale value.

The background refresh captures its generation guard before serving the
cached value, so a clearCache() (login/logout) racing the serve still drops
the write, keeping the previous user's detail out of the new user's cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@facumenzella facumenzella added the pr:feat A new feature label Jun 9, 2026
@facumenzella facumenzella marked this pull request as ready for review June 9, 2026 13:10
@facumenzella facumenzella requested a review from a team as a code owner June 9, 2026 13:10
@facumenzella facumenzella requested a review from vegaro June 9, 2026 13:10

@vegaro vegaro 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 this makes sense. We need to do the same for getWorkflows right? Otherwise it blocks on a stale cache

@facumenzella facumenzella added pr:other and removed pr:feat A new feature labels Jun 10, 2026
@facumenzella facumenzella merged commit fe77328 into main Jun 10, 2026
19 of 22 checks passed
@facumenzella facumenzella deleted the facu/workflow-detail-swr branch June 10, 2026 09:01
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.

2 participants