feat(workflows): add WorkflowManager with list fetch, prefetch and offeringId resolution#6882
Conversation
4 builds increased size
RevenueCat 1.0 (1)
|
| Item | Install Size Change |
|---|---|
| DYLD.String Table | ⬆️ 13.1 kB |
| 📝 RevenueCat.WorkflowManager.WorkflowManager | ⬆️ 9.1 kB |
| Code Signature | ⬆️ 936 B |
| DYLD.Exports | ⬆️ 656 B |
| Other | ⬆️ 32.0 kB |
BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.local-source
⚖️ Compare build
📦 Install build
⏱️ Analyze build performance
Total install size change: ⬆️ 18.4 kB (0.15%)
Total download size change: ⬆️ 4.5 kB (0.11%)
Largest size changes
| Item | Install Size Change |
|---|---|
| 📝 RevenueCat.Purchases.init(appUserID,requestFetcher,receiptFetcher... | ⬆️ 4.2 kB |
| 🗑 RevenueCat.Purchases.init(appUserID,requestFetcher,receiptFetcher... | ⬇️ -4.2 kB |
| RevenueCat.PaywallsStrings.value witness | ⬆️ 1.0 kB |
| 📝 RevenueCat.WorkflowsCache.init(deviceCache,dateProvider) | ⬆️ 948 B |
| 📝 RevenueCat.WorkflowManager.warmUpAssets(for) | ⬆️ 920 B |
BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.cocoapods
⚖️ Compare build
📦 Install build
⏱️ Analyze build performance
Total install size change: ⬆️ 22.2 kB (0.08%)
Total download size change: ⬆️ 5.1 kB (0.08%)
Largest size changes
| Item | Install Size Change |
|---|---|
| DYLD.String Table | ⬆️ 5.9 kB |
| 📝 RevenueCat.Purchases.init(appUserID,requestFetcher,receiptFetcher... | ⬆️ 4.2 kB |
| 🗑 RevenueCat.Purchases.init(appUserID,requestFetcher,receiptFetcher... | ⬇️ -4.2 kB |
| RevenueCat.PaywallsStrings.value witness | ⬆️ 1.0 kB |
| 📝 RevenueCat.WorkflowManager.warmUpAssets(for) | ⬆️ 988 B |
BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.spm
⚖️ Compare build
📦 Install build
⏱️ Analyze build performance
Total install size change: ⬆️ 14.3 kB (0.13%)
Total download size change: ⬆️ 4.1 kB (0.09%)
Largest size changes
| Item | Install Size Change |
|---|---|
| 📝 RevenueCat.Purchases.init(appUserID,requestFetcher,receiptFetcher... | ⬆️ 4.2 kB |
| 🗑 RevenueCat.Purchases.init(appUserID,requestFetcher,receiptFetcher... | ⬇️ -4.2 kB |
| RevenueCat.PaywallsStrings.value witness | ⬆️ 1.0 kB |
| 📝 RevenueCat.WorkflowsCache.init(deviceCache,dateProvider) | ⬆️ 948 B |
| 📝 RevenueCat.WorkflowManager.warmUpAssets(for) | ⬆️ 896 B |
🛸 Powered by Emerge Tools
c0aceba to
7559851
Compare
7c5ff4b to
51a9c85
Compare
7559851 to
bfae745
Compare
e5d1332 to
08dcac9
Compare
ea183d1 to
ee563d5
Compare
…feringId resolution
Second of three stacked PRs porting purchases-android#3508 to iOS. Builds on the
WorkflowsCache foundation.
- Add `WorkflowManager` (orchestration layer paralleling `OfferingsManager`):
- `getWorkflow` serves a fresh cached workflow without a backend round-trip,
otherwise fetches, caches the result, and warms up its assets.
- `getWorkflowsList` fetches the paywall workflows list, persists it, builds the
offeringId to workflowId map, and prefetches every entry flagged prefetch=true.
`onComplete` fires only after the list fetch AND all prefetch fetches finish
(lock-guarded counter), and still fires on backend failure (restoring the map
from disk first), so callers are never blocked.
- `workflowId(forOfferingId:)` exposes the map.
- Route `Purchases.workflow(forOfferingIdentifier:)` through `WorkflowManager`,
resolving offeringId to workflowId via the map and falling back to the offering
identifier itself (the map is empty until the list is fetched, so behavior is
unchanged for now). Asset warm-up now lives in `WorkflowManager`.
- Wire `WorkflowsCache` + `WorkflowManager` into `PurchasesFactory`.
Note: iOS already deduplicates in-flight requests via `CallbackCache`, so Android's
network-level request dedup is intentionally not ported; only the higher-level
"complete after list + prefetches" aggregation is. Asset warm-up now happens on a
cache miss rather than on every call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The getWorkflowsList failure path logged the error and fired onComplete() without rehydrating the in-memory offeringId -> workflowId map, so workflowId(forOfferingId:) returned nil after a backend failure even when a valid previously-fetched list was still on disk. Restore it from cachedWorkflowsListResponseFromDisk() before completing, matching the documented WorkflowsCache contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ee563d5 to
1f1480e
Compare
Addresses Bugbot: the previous restore went through cache(workflowsList:), which re-wrote the data to disk and stamped lastUpdated to now(), marking the restored list fresh and suppressing the next backend fetch for the full cache TTL. Add WorkflowsCache.restoreWorkflowsListFromDisk(), which rehydrates the in-memory offeringId -> workflowId map but keeps the entry stale (and leaves the on-disk copy untouched), so workflowId(forOfferingId:) keeps resolving the last known list while the next call still retries the backend. Mirrors the offerings cache disk fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vegaro
left a comment
There was a problem hiding this comment.
biggest thing would be paralelizing, but that can come in a future PR I think
| return | ||
| } | ||
|
|
||
| self.backend.workflowsAPI.getWorkflow(appUserID: appUserID, |
There was a problem hiding this comment.
so one thing I've been working on in RevenueCat/purchases-android#3508 (comment) is to paralelize these calls. Can be done in a future PR, but I think it's a good idea. Right now they're serial becauase of how backend works.
There was a problem hiding this comment.
Agreed it's worth doing. Keeping it out of this PR since it needs a backend-queue change rather than a tweak here, the whole SDK shares one serial OperationQueue (maxConcurrentOperationCount = 1) and GetWorkflowOperation holds the slot through the CDN download, so the fetches serialize. Will tackle it in a follow-up PR, ideally landing it the same way as the Android side.
…tch without offeringId) - Rename WorkflowManager.workflowId(forOfferingId:) to cachedWorkflowId(forOfferingId:) so it's clear the value comes from the cache. - Skip prefetching workflows with a nil offeringId, since they can't be resolved via cachedWorkflowId(forOfferingId:) anyway. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 23ed6c5. Configure here.





Port of RevenueCat/purchases-android#3508 (2 of 3). The cache foundation (#6881) is already merged, so this now sits directly on
main.This is the orchestration layer on top of the
WorkflowsCache.What Changed
WorkflowManager(parallelsOfferingsManager):getWorkflowserves a fresh cached workflow straight fromWorkflowsCache(no backend round-trip); on a miss it fetches, caches, and warms up the assets.getWorkflowsListfetches the paywall workflows list, persists it, builds theofferingId → workflowIdmap, and prefetches every entry flaggedprefetch == true.onCompletefires only after the list fetch and all prefetches finish (lock-guarded counter). On a backend failure it still fires, after restoring the map from disk (kept stale so the next call retries), so whoever's waiting on it is never left hanging.workflowId(forOfferingId:)exposes the map.Purchases.workflow(forOfferingIdentifier:)now goes throughWorkflowManager: it resolves the offeringId via the map and falls back to the offering identifier itself as the workflow key. The map is empty until the list is fetched (that wiring is PR 3), so behavior is unchanged for now.WorkflowsCache+WorkflowManagerintoPurchasesFactory.Notes
CallbackCachealready does. I only ported the higher-level "fire once the list + prefetches are done" aggregation.workflow(forOfferingIdentifier:)call. Same net effect (assets get warmed when the cache is populated), just not redundantly re-warmed on cache hits. Flagging it so it doesn't read as an oversight.WorkflowsCache.restoreWorkflowsListFromDisk()) rather than re-caching it as fresh, so the map keeps resolving but the next fetch still retries the backend, mirroring how the offerings cache serves its disk copy. Android currently marks it fresh, so this intentionally diverges; worth aligning Android later.Note
Medium Risk
Touches paywall workflow loading and caching in Purchases; behavior is mostly gated until list fetch is wired, but cache/TTL and failure-recovery paths affect which workflow id and assets load.
Overview
Introduces
WorkflowManageras the orchestration layer overWorkflowsCacheandWorkflowsAPI, parallel to how offerings useOfferingsManager.getWorkflowreturns fresh in-memory workflow data without a network call; on miss/stale it fetches, caches, and warms paywall assets only on that path (no longer on everyPurchases.workflowcall).getWorkflowsListloads the paywall workflows list, persists it, buildsofferingId → workflowId, and prefetches entries withprefetch == true, withonCompleteafter list + all prefetches (or immediately if list cache is fresh). On list fetch failure it logs a new paywall string, restores the map from disk as stale viaWorkflowsCache.restoreWorkflowsListFromDisk(), and still callsonComplete.Purchasesnow owns sharedWorkflowsCache+WorkflowManager;workflow(forOfferingIdentifier:)resolves workflow id from the map when present, else uses the offering id, and delegates toWorkflowManager. List fetch wiring to offerings is left for a follow-up PR, so runtime behavior is unchanged until then.Tests:
WorkflowManagerTests,PurchasesWorkflowTests, and richerMockWorkflowsAPI; Xcode project entries for new sources.Reviewed by Cursor Bugbot for commit 23ed6c5. Bugbot is set up for automated code reviews on this repo. Configure here.