Skip to content

Conversation

@joshheald
Copy link
Contributor

@joshheald joshheald commented Oct 24, 2025

Description

This PR updates the POSCatalogSyncCoordinator to check both catalog eligibility, and how long it's been since the POS was opened, before allowing any syncs.

The full criteria are:

From catalog eligibility checker

  • Local Feature flag enabled
  • POS tab eligible
  • Products + variations < 1000

Newly added in sync coordinator

  • POS accessed in the last 30 days, or
  • First sync in the last 30 days

Testing information

Syncs happen on longer schedules than is convenient for testing. I tend to change the code to reduce the delay for each type, to test them.

This PR doesn't include all the incremental sync triggers. It's best to rely on scheduled syncs for testing.

Places to tweak schedules

  • ForegroundPOSCatalogSyncDispatcher.syncInterval already uses seconds, just reduce it
  • POSCatalogSyncCoordinatorProtocol.performSmartSync(for:) defines 24 hours in seconds for the threshold to do a full sync, you can reduce that. If you don't do that, it'll still do an incremental sync
  • POSCatalogSyncCoordinator.checkSyncEligibility(for:) uses .day a lot to decide whether we should block syncs for people who don't open POS. You can change those to .second to make it happen a lot faster!
  • Forcing a background sync task to run – force BackgroundTaskSchedule.getNextTask() to always return .posCatalogSync, then put a breakpoint on line 55 of BackgroundTaskRefreshDispatcher, and background the app. When you hit the breakpoint, use lldb to execute this: e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.automattic.woocommerce.refresh.pos.catalog.sync"] then when it finishes, continue execution – the sync task should run.

Scenarios I covered:

  • No sync scenarios (background and foreground)
    • Catalog too large
    • Feature flag off
    • Mismatching country/currency combination
    • Unsupported country
  • Eligible catalog size, valid country/currency combination, feature flag enabled
    • Background and foreground syncs happen after a new install
    • Background and foreground syncs stop happening after "30 days" if you don't open the POS
    • Background and foreground syncs restart on a normal schedule after you open the POS

Note that there's a race condition on switching stores. The POS assumes you're ineligible for local catalog if you open POS before we're able to check the catalog size. That result isn't cached, so if you close and re-open, it will rectify that issue.

Once we remove the async check for catalog size, the race condition will go away too.

  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

joshheald and others added 8 commits October 24, 2025 17:06
- Add four new AppSettingsAction cases for getting/setting POS dates
- Implement action handlers in AppSettingsStore using SiteSpecificAppSettingsStoreMethods
- setPOSLastOpenedDate/getPOSLastOpenedDate for tracking last opened date
- setFirstPOSCatalogSyncDate/getFirstPOSCatalogSyncDate for tracking first sync

This provides a clean action-based API for accessing the date tracking
infrastructure added in the previous commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
When the POS tab is selected, dispatch an action to record the current
date as the last opened date. This will be used for sync eligibility
checking to ensure syncs only continue for active POS users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Add catalogEligibilityChecker and siteSettings parameters to the
coordinator's initializer:
- catalogEligibilityChecker: optional closure to check catalog eligibility
- siteSettings: protocol for accessing POS date settings (defaults to new instance)

The coordinator creates its own SiteSpecificAppSettingsStoreMethods instance
by default (following the pattern used by POSSearchHistoryService), so no
changes are needed in AuthenticatedState.

Updated tests to pass mock siteSettings explicitly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Add eligibility checking to POSCatalogSyncCoordinator.performSmartSync:
- Check catalog eligibility first (via optional closure)
- Check temporal eligibility based on 30-day criteria:
  * New users: eligible for 30 days from first sync
  * Existing users (>30 days from first sync): must have opened POS within 30 days
- Record first sync date after successful sync
- Log eligibility decisions for debugging

The coordinator now enforces that background syncs only continue for sites
where POS is actively being used, preventing indefinite syncing for abandoned
POS installations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Add setCatalogEligibilityChecker method to allow setting the eligibility
checker after initialization, and wire it up in MainTabBarController.

Since POSTabCoordinator (which has the catalog eligibility service) is
created after the sync coordinator in AuthenticatedState, we set the
eligibility checker closure after the tab coordinator is initialized.

The closure refreshes and checks the eligibility state from
POSLocalCatalogEligibilityService, providing the catalog eligibility
check needed for the 30-day sync criteria.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Make setCatalogEligibilityChecker nonisolated to avoid actor isolation
issues when called from MainTabBarController. The method internally uses
Task to safely set the property on the actor.

The small race condition window (setter might not complete before first
sync) is acceptable because:
- The temporal 30-day eligibility check always works regardless
- Worst case: first sync might skip catalog eligibility check
- The setter completes nearly instantly in practice

Moving the coordinator to MainTabBarController would break background sync
access via ServiceLocator.stores, so this approach is preferred.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Added 11 tests covering all eligibility scenarios:
- Catalog ineligibility blocks sync
- New users can sync initially
- First sync date is recorded once
- 30-day grace period for new users
- Existing users past grace period need recent POS opens
- Boundary conditions (exactly 30 days)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
…ecker

Changes:
- Add eligibility checks to performFullSyncIfApplicable and performIncrementalSyncIfApplicable
- Record first sync date after all sync types (not just smart sync)
- Remove catalogSizeChecker parameter from POSCatalogSyncCoordinator (catalog size checking now handled by eligibilityChecker)
- Remove catalogSizeLimit parameter (no longer needed)
- Remove all catalog size tests (functionality now tested via eligibility service)
- Simplify shouldPerformFullSync and shouldPerformIncrementalSync methods

The catalog eligibility checker (from POSLocalCatalogEligibilityService) now handles both catalog size checks and temporal eligibility checks, eliminating the need for separate size checking logic in the coordinator.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@joshheald joshheald added this to the 23.6 milestone Oct 24, 2025
@joshheald joshheald added type: task An internally driven task. feature: POS labels Oct 24, 2025
@joshheald joshheald force-pushed the woomob-1518-woo-poslocal-catalog-follow-criteria-for-background-and branch from 1a056a2 to f22ea9e Compare October 24, 2025 16:23
@dangermattic
Copy link
Collaborator

dangermattic commented Oct 24, 2025

2 Warnings
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.
⚠️ This PR is assigned to the milestone 23.6. This milestone is due in less than 2 days.
Please make sure to get it merged by then or assign it to a milestone with a later deadline.

Generated by 🚫 Danger

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Oct 24, 2025

App Icon📲 You can test the changes from this Pull Request in WooCommerce iOS Prototype by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS Prototype
Build Numberpr16281-bc226bc
Version23.5
Bundle IDcom.automattic.alpha.woocommerce
Commitbc226bc
Installation URL7vqit31g7se10
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

joshheald and others added 12 commits October 27, 2025 10:34
Changes:
- Move POSLocalCatalogEligibilityService to Yosemite module
- Move POSLocalCatalogEligibilityServiceProtocol to Yosemite
- Change service to accept boolean feature flag instead of FeatureFlagService
- Make POSLocalCatalogEligibilityServiceProtocol inherit from POSCatalogEligibilityChecking
- Update StoresManager to use POSLocalCatalogEligibilityServiceProtocol type
- Remove setCatalogEligibilityChecker() method from sync coordinator
- Change catalogEligibilityChecker property to let (immutable)
- Create service in AuthenticatedState alongside sync coordinator
- Pass service directly in coordinator init (no late binding)
- Simplify MainTabBarController (remove async Task creation)
- Update all mocks to remove setCatalogEligibilityChecker() method
- Make service properties public for Yosemite module export
- Update tests to use boolean parameter

This eliminates the nonisolated setter workaround and fixes the wiring
issue where the eligibility checker was always nil.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@joshheald joshheald force-pushed the woomob-1518-woo-poslocal-catalog-follow-criteria-for-background-and branch from 0492912 to f6df1ce Compare October 28, 2025 15:42
@joshheald
Copy link
Contributor Author

@iamgabrielma This is a long one, sorry about that. Improving the init order for checking eligibility took a lot of attempts, and I also had to make sure all the fiddly details were working correctly.

No rush on this, take your time.

@joshheald joshheald marked this pull request as ready for review October 28, 2025 16:47
@iamgabrielma iamgabrielma self-assigned this Oct 29, 2025
Copy link
Contributor

@iamgabrielma iamgabrielma left a comment

Choose a reason for hiding this comment

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

I'm half-way the review, not sure if I'll finish it EOD. Sharing some comments for the moment 👍

I see 9 test failures in CI coming from POSLocalCatalogEligibilityServiceTests, confirmed (17) locally as well:

Exactly at size limit returns eligible in POSLocalCatalogEligibilityServiceTests
Catalog one over limit returns ineligible in POSLocalCatalogEligibilityServiceTests
Catalog size check failure returns ineligible in POSLocalCatalogEligibilityServiceTests
Second call uses cached state in POSLocalCatalogEligibilityServiceTests
Refresh bypasses cache in POSLocalCatalogEligibilityServiceTests
Refresh updates cache in POSLocalCatalogEligibilityServiceTests
Feature flag disabled returns ineligible in POSLocalCatalogEligibilityServiceTests
Custom size limit is respected in POSLocalCatalogEligibilityServiceTests

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice to edit this directly from the template 💯

Copy link
Contributor

Choose a reason for hiding this comment

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

I see this one and Models+Copiable.generated.swift are generated as artifacts from rake generate after editing the template 👍

/// Get catalog eligibility for a specific site
/// - Parameter siteID: The site ID to check eligibility for
/// - Returns: Cached eligibility state, or eligible if not yet checked
func catalogEligibility(for siteID: Int64) async -> POSLocalCatalogEligibilityState
Copy link
Contributor

Choose a reason for hiding this comment

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

❓ Just for context: When we presentPOSView and check for eligibility state, if we land in POSLocalCatalogIneligibleReason.catalogSizeTooLarge or others, we'd just fallback to fetching from remote and not even initializing the GRDB infrastructure?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, that's right

Comment on lines 1369 to 1387
func setPOSLastOpenedDate(siteID: Int64, date: Date, onCompletion: (Result<Void, Error>) -> Void) {
siteSpecificAppSettingsStoreMethods.setPOSLastOpenedDate(siteID: siteID, date: date)
onCompletion(.success(()))
}

func getPOSLastOpenedDate(siteID: Int64, onCompletion: (Date?) -> Void) {
let date = siteSpecificAppSettingsStoreMethods.getPOSLastOpenedDate(siteID: siteID)
onCompletion(date)
}

func setFirstPOSCatalogSyncDate(siteID: Int64, date: Date, onCompletion: (Result<Void, Error>) -> Void) {
siteSpecificAppSettingsStoreMethods.setFirstPOSCatalogSyncDate(siteID: siteID, date: date)
onCompletion(.success(()))
}

func getFirstPOSCatalogSyncDate(siteID: Int64, onCompletion: (Date?) -> Void) {
let date = siteSpecificAppSettingsStoreMethods.getFirstPOSCatalogSyncDate(siteID: siteID)
onCompletion(date)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Are the signatures for setPOSLastOpenedDate and setFirstPOSCatalogSyncDate correct here? Those helpers cannot surface errors as are just forwarding the action, right? Should these look more like the following?

func setPOSLastOpenedDate(siteID: Int64, date: Date, onCompletion: () -> Void)
func setFirstPOSCatalogSyncDate(siteID: Int64, date: Date, onCompletion: () -> Void)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch : 80d776a

@joshheald
Copy link
Contributor Author

I'm half-way the review, not sure if I'll finish it EOD. Sharing some comments for the moment 👍

I see 9 test failures in CI coming from POSLocalCatalogEligibilityServiceTests, confirmed (17) locally as well:

Exactly at size limit returns eligible in POSLocalCatalogEligibilityServiceTests
Catalog one over limit returns ineligible in POSLocalCatalogEligibilityServiceTests
Catalog size check failure returns ineligible in POSLocalCatalogEligibilityServiceTests
Second call uses cached state in POSLocalCatalogEligibilityServiceTests
Refresh bypasses cache in POSLocalCatalogEligibilityServiceTests
Refresh updates cache in POSLocalCatalogEligibilityServiceTests
Feature flag disabled returns ineligible in POSLocalCatalogEligibilityServiceTests
Custom size limit is respected in POSLocalCatalogEligibilityServiceTests

Hnnng. Back to yellow for me. I was sure these passed last night. Maybe something in my last commit... I'll fix it up this morning.

@joshheald
Copy link
Contributor Author

joshheald commented Oct 29, 2025

Hnnng. Back to yellow for me. I was sure these passed last night. Maybe something in my last commit... I'll fix it up this morning.

Oh awesome. 9 do fail locally for me too Xcode just tells me they succeeded in the notification. If I go and look in the build log, it shows me the failures. It just works.

Copy link
Contributor

@iamgabrielma iamgabrielma left a comment

Choose a reason for hiding this comment

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

LGTM! Just dropped a couple of additional comments, as well as a question regarding non-eligibility due to catalog size.

I've set incremental syncs to 10 seconds and full syncs to 30 seconds:

Eligible:
✅ Background and foreground syncs happen after a new install
✅ Background and foreground syncs stop happening after "30 days" if you don't open the POS

	📋 POSCatalogSyncCoordinator: Site -1 eligible (within grace period: 5 days since first sync)
	📋 POSCatalogSyncCoordinator: Site -1 eligible (within grace period: 15 days since first sync)
	📋 POSCatalogSyncCoordinator: Site -1 eligible (within grace period: 25 days since first sync)
	📋 POSCatalogSyncCoordinator: Site -1 ineligible (past 30-day grace period, no recent POS open)

✅ Background and foreground syncs restart on a normal schedule after you open the POS

	📋 POSCatalogSyncCoordinator: Site -1 eligible (last opened 2 days ago)
	📋 POSCatalogSyncCoordinator: Site -1 eligible (last opened 12 days ago)
	📋 POSCatalogSyncCoordinator: Site -1 eligible (last opened 22 days ago)
	📋 POSCatalogSyncCoordinator: Site -1 ineligible (POS last opened 32 days ago)

Non-Eligible:
✅ Feature flag off
✅ Mismatching country/currency combination , Unsupported country
⚠️ defaultCatalogSizeLimit

When setting defaultCatalogSizeLimit to 10, I see logged both that is not eligible as well as that the sync has been "completed", maybe is just a logging thing meant to say that the task is done rather than there was an actual sync, dropping it here just in case is unexpected:

🔄 ForegroundPOSCatalogSyncDispatcher: Starting foreground sync dispatcher for site -1
⏱️ ForegroundPOSCatalogSyncDispatcher: Starting timer (interval: 10s, initial delay: 5s)
📋 POSLocalCatalogEligibilityService: Site -1 catalog size 193 exceeds limit 10
📋 POSLocalCatalogEligibilityService: Site -1 catalog size 193 exceeds limit 10
🔄 ForegroundPOSCatalogSyncDispatcher: Starting sync for site -1
📋 POSCatalogSyncCoordinator: Site -1 - Catalog ineligible
📋 POSCatalogSyncCoordinator: Sync skipped - site -1 is not eligible
✅ ForegroundPOSCatalogSyncDispatcher: Sync completed for site -1
🔄 ForegroundPOSCatalogSyncDispatcher: Starting sync for site -1
📋 POSCatalogSyncCoordinator: Site -1 - Catalog ineligible
📋 POSCatalogSyncCoordinator: Sync skipped - site -1 is not eligible
✅ ForegroundPOSCatalogSyncDispatcher: Sync completed for site -1
🔄 ForegroundPOSCatalogSyncDispatcher: Starting sync for site -1
📋 POSCatalogSyncCoordinator: Site -1 - Catalog ineligible
📋 POSCatalogSyncCoordinator: Sync skipped - site -1 is not eligible
✅ ForegroundPOSCatalogSyncDispatcher: Sync completed for site -1
🔄 ForegroundPOSCatalogSyncDispatcher: Starting sync for site -1
📋 POSCatalogSyncCoordinator: Site -1 - Catalog ineligible
📋 POSCatalogSyncCoordinator: Sync skipped - site -1 is not eligible
✅ ForegroundPOSCatalogSyncDispatcher: Sync completed for site -1

private let noticePresenter: NoticePresenter
private let productImageUploader: ProductImageUploaderProtocol
private let stores: StoresManager
private(set) var stores: StoresManager
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we don't need this change?

guard let self else { return }

// Get local catalog eligibility as bool from service
// Service is created asynchronously in init, might not be ready yet
Copy link
Contributor

Choose a reason for hiding this comment

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

When testing POS settings these past days, some of the times I didn't see the Local catalog card loading along the other settings. Do you think await service.catalogEligibility(for: siteID) might resolve this? I'll keep an eye in any case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's likely that you're seeing the race condition after switching stores or logging in here.

We're deliberately not checking the eligibility within POS, only before we enter. This is to avoid the situation where some of POS is using local catalog, and some is using the network directly. So don't start trying to check eligibility in Settings, just trust what you're inited with 😊 Remember – we'll move to everyone using local catalog as soon as we can.

If we await catalog eligibility here, we make the app unresponsive until we get an answer. We could add this as another loading state, but since we need the answer before we can decide which item controller to use, it's another separate loading state before we make the real POS view. For a temporary solution, and an unlikely situation, it seems better to assume ineligibility and open straight away if we don't know for sure.

If eligilble users fall in to this state, they can go out of POS and back in again, it'll resolve itself that way. No need to restart the app or anything. TBH, I think it's the best compromise we have available.

@joshheald
Copy link
Contributor Author

Thanks for the review!

When setting defaultCatalogSizeLimit to 10, I see logged both that is not eligible as well as that the sync has been "completed", maybe is just a logging thing meant to say that the task is done rather than there was an actual sync, dropping it here just in case is unexpected:

Yes, it's expected. The sync doesn't happen, but the dispatcher doesn't know that as it's the coordinator which actually does the syncing, using the eligibility service to check whether it should or not.

I considered throwing to avoid that, but I think we'll just tidy up the logs at a later point before release. We are logging a lot, and it's useful for development as we pull these threads together, but it will become confusing if we don't thin them out before release.

@joshheald joshheald enabled auto-merge October 30, 2025 09:19
@joshheald joshheald merged commit fe18ea4 into trunk Oct 30, 2025
13 checks passed
@joshheald joshheald deleted the woomob-1518-woo-poslocal-catalog-follow-criteria-for-background-and branch October 30, 2025 09:39
@sentry
Copy link

sentry bot commented Nov 4, 2025

Issues attributed to commits in this pull request

This pull request was merged and Sentry observed the following issues:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: POS type: task An internally driven task.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants