Skip to content

Commit 2777090

Browse files
authored
[POS][Local Catalog] Ensure we only run one sync at a time per site (#16100)
2 parents 042569a + cb8b923 commit 2777090

File tree

8 files changed

+181
-37
lines changed

8 files changed

+181
-37
lines changed

Modules/Sources/Yosemite/Base/StoresManager.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ public protocol StoresManager {
6565
///
6666
var site: AnyPublisher<Site?, Never> { get }
6767

68+
/// Provides access to the session-scoped POS catalog sync coordinator
69+
///
70+
var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? { get }
71+
6872
/// Indicates if we need a Default StoreID, or there's one already set.
6973
///
7074
var needsDefaultStore: Bool { get }

Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ public class MockStoresManager: StoresManager {
253253
public func shouldAuthenticateAdminPage(for site: Site) -> Bool {
254254
return false
255255
}
256+
257+
public var posCatalogSyncCoordinator: (any POSCatalogSyncCoordinatorProtocol)? {
258+
nil
259+
}
256260
}
257261

258262
private extension MockStoresManager {

Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,29 @@ import GRDB
55
public protocol POSCatalogSyncCoordinatorProtocol {
66
/// Performs a full catalog sync for the specified site
77
/// - Parameter siteID: The site ID to sync catalog for
8+
/// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site
89
func performFullSync(for siteID: Int64) async throws
910

1011
/// Determines if a full sync should be performed based on the age of the last sync
1112
/// - Parameters:
1213
/// - siteID: The site ID to check
1314
/// - maxAge: Maximum age before a sync is considered stale
1415
/// - Returns: True if a sync should be performed
15-
func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) -> Bool
16+
func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool
1617
}
1718

18-
public final class POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
19+
public enum POSCatalogSyncError: Error, Equatable {
20+
case syncAlreadyInProgress(siteID: Int64)
21+
}
22+
23+
public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
1924
private let fullSyncService: POSCatalogFullSyncServiceProtocol
2025
private let settingsStore: SiteSpecificAppSettingsStoreMethodsProtocol
2126
private let grdbManager: GRDBManagerProtocol
2227

28+
/// Tracks ongoing syncs by site ID to prevent duplicates
29+
private var ongoingSyncs: Set<Int64> = []
30+
2331
public init(fullSyncService: POSCatalogFullSyncServiceProtocol,
2432
settingsStore: SiteSpecificAppSettingsStoreMethodsProtocol? = nil,
2533
grdbManager: GRDBManagerProtocol) {
@@ -29,17 +37,29 @@ public final class POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
2937
}
3038

3139
public func performFullSync(for siteID: Int64) async throws {
40+
if ongoingSyncs.contains(siteID) {
41+
DDLogInfo("⚠️ POSCatalogSyncCoordinator: Sync already in progress for site \(siteID)")
42+
throw POSCatalogSyncError.syncAlreadyInProgress(siteID: siteID)
43+
}
44+
45+
// Mark sync as in progress
46+
ongoingSyncs.insert(siteID)
47+
48+
// Ensure cleanup happens regardless of success or failure
49+
defer {
50+
ongoingSyncs.remove(siteID)
51+
}
52+
3253
DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)")
3354

3455
let catalog = try await fullSyncService.startFullSync(for: siteID)
3556

36-
// Record the sync timestamp
3757
settingsStore.setPOSLastFullSyncDate(Date(), for: siteID)
3858

3959
DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)")
4060
}
4161

42-
public func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) -> Bool {
62+
public func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool {
4363
if !siteExistsInDatabase(siteID: siteID) {
4464
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) not found in database, sync needed")
4565
return true

Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ struct POSCatalogSyncCoordinatorTests {
4646
mockSyncService.startFullSyncResult = .success(expectedCatalog)
4747

4848
// When
49-
_ = try await sut.performFullSync(for: sampleSiteID)
49+
try await sut.performFullSync(for: sampleSiteID)
5050
let afterSync = Date()
5151

5252
// Then
@@ -66,7 +66,7 @@ struct POSCatalogSyncCoordinatorTests {
6666

6767
// When/Then
6868
await #expect(throws: expectedError) {
69-
_ = try await sut.performFullSync(for: sampleSiteID)
69+
try await sut.performFullSync(for: sampleSiteID)
7070
}
7171

7272
// Should not store timestamp on failure
@@ -75,59 +75,59 @@ struct POSCatalogSyncCoordinatorTests {
7575

7676
// MARK: - Should Sync Decision Tests
7777

78-
@Test func shouldPerformFullSync_returns_true_when_site_is_not_in_database_with_no_sync_history() {
78+
@Test func shouldPerformFullSync_returns_true_when_site_is_not_in_database_with_no_sync_history() async {
7979
// Given - site doesn't exist in database AND has no sync history
8080
mockSettingsStore.storedDates = [:]
8181
// Note: NOT creating site in database
8282

8383
// When
84-
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
84+
let shouldSync = await sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
8585

8686
// Then - should sync because site doesn't exist in database
8787
#expect(shouldSync == true)
8888
}
8989

90-
@Test func shouldPerformFullSync_returns_true_when_site_is_in_database_with_no_previous_sync() throws {
90+
@Test func shouldPerformFullSync_returns_true_when_site_is_in_database_with_no_previous_sync() async throws {
9191
// Given - no previous sync date stored, but site exists in database
9292
// This is much less likely to happen, but could help at a migration point
9393
mockSettingsStore.storedDates = [:]
9494
try createSiteInDatabase(siteID: sampleSiteID)
9595

9696
// When
97-
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 3600)
97+
let shouldSync = await sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 3600)
9898

9999
// Then
100100
#expect(shouldSync == true)
101101
#expect(mockSettingsStore.getPOSLastFullSyncDateCallCount == 1)
102102
}
103103

104-
@Test func shouldPerformFullSync_returns_true_when_sync_is_stale() throws {
104+
@Test func shouldPerformFullSync_returns_true_when_sync_is_stale() async throws {
105105
// Given - previous sync was 2 hours ago, and site exists in database
106106
let twoHoursAgo = Date().addingTimeInterval(-2 * 60 * 60)
107107
mockSettingsStore.storedDates[sampleSiteID] = twoHoursAgo
108108
try createSiteInDatabase(siteID: sampleSiteID)
109109

110110
// When - max age is 1 hour
111-
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
111+
let shouldSync = await sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
112112

113113
// Then
114114
#expect(shouldSync == true)
115115
}
116116

117-
@Test func shouldPerformFullSync_returns_false_when_sync_is_fresh() throws {
117+
@Test func shouldPerformFullSync_returns_false_when_sync_is_fresh() async throws {
118118
// Given - previous sync was 30 minutes ago, and site exists in database
119119
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
120120
mockSettingsStore.storedDates[sampleSiteID] = thirtyMinutesAgo
121121
try createSiteInDatabase(siteID: sampleSiteID)
122122

123123
// When - max age is 1 hour
124-
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
124+
let shouldSync = await sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
125125

126126
// Then
127127
#expect(shouldSync == false)
128128
}
129129

130-
@Test func shouldPerformFullSync_handles_different_sites_independently() throws {
130+
@Test func shouldPerformFullSync_handles_different_sites_independently() async throws {
131131
// Given
132132
let siteA: Int64 = 123
133133
let siteB: Int64 = 456
@@ -141,55 +141,121 @@ struct POSCatalogSyncCoordinatorTests {
141141
try createSiteInDatabase(siteID: siteB)
142142

143143
// When
144-
let shouldSyncA = sut.shouldPerformFullSync(for: siteA, maxAge: 2 * 60 * 60) // 2 hours
145-
let shouldSyncB = sut.shouldPerformFullSync(for: siteB, maxAge: 2 * 60 * 60) // 2 hours
144+
let shouldSyncA = await sut.shouldPerformFullSync(for: siteA, maxAge: 2 * 60 * 60) // 2 hours
145+
let shouldSyncB = await sut.shouldPerformFullSync(for: siteB, maxAge: 2 * 60 * 60) // 2 hours
146146

147147
// Then
148148
#expect(shouldSyncA == false) // Recent sync exists
149149
#expect(shouldSyncB == true) // No previous sync
150150
}
151151

152-
@Test func shouldPerformFullSync_with_zero_maxAge_always_returns_true() throws {
152+
@Test func shouldPerformFullSync_with_zero_maxAge_always_returns_true() async throws {
153153
// Given - previous sync was just now, and site exists in database
154154
let justNow = Date()
155155
mockSettingsStore.storedDates[sampleSiteID] = justNow
156156
try createSiteInDatabase(siteID: sampleSiteID)
157157

158158
// When - max age is 0 (always sync)
159-
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 0)
159+
let shouldSync = await sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 0)
160160

161161
// Then
162162
#expect(shouldSync == true)
163163
}
164164

165165
// MARK: - Database Check Tests
166166

167-
@Test func shouldPerformFullSync_returns_true_when_site_not_in_database() {
167+
@Test func shouldPerformFullSync_returns_true_when_site_not_in_database() async {
168168
// Given - site does not exist in database, but has recent sync date
169169
let recentSyncDate = Date().addingTimeInterval(-30 * 60) // 30 minutes ago
170170
mockSettingsStore.storedDates[sampleSiteID] = recentSyncDate
171171
// Note: not creating site in database so it won't exist
172172

173173
// When - max age is 1 hour (normally wouldn't sync)
174-
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
174+
let shouldSync = await sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
175175

176176
// Then - should sync because site doesn't exist in database
177177
#expect(shouldSync == true)
178178
}
179179

180-
@Test func shouldPerformFullSync_respects_time_when_site_exists_in_database() throws {
180+
@Test func shouldPerformFullSync_respects_time_when_site_exists_in_database() async throws {
181181
// Given - site exists in database with recent sync date
182182
let recentSyncDate = Date().addingTimeInterval(-30 * 60) // 30 minutes ago
183183
mockSettingsStore.storedDates[sampleSiteID] = recentSyncDate
184184
try createSiteInDatabase(siteID: sampleSiteID)
185185

186186
// When - max age is 1 hour
187-
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
187+
let shouldSync = await sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
188188

189189
// Then - should not sync because site exists and time hasn't passed
190190
#expect(shouldSync == false)
191191
}
192192

193+
// MARK: - Sync Tracking Tests
194+
195+
@Test func performFullSync_throws_error_when_sync_already_in_progress() async throws {
196+
// Given - block the sync service so first sync will wait
197+
let expectedCatalog = POSCatalog(products: [], variations: [])
198+
mockSyncService.startFullSyncResult = .success(expectedCatalog)
199+
mockSyncService.blockNextSync()
200+
201+
// Start first sync in a task (it will block waiting for continuation)
202+
let firstSyncTask = Task {
203+
try await sut.performFullSync(for: sampleSiteID)
204+
}
205+
206+
// Give first sync a moment to start and get blocked
207+
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
208+
209+
// When - try to start second sync while first is blocked
210+
do {
211+
_ = try await sut.performFullSync(for: sampleSiteID)
212+
#expect(Bool(false), "Should have thrown syncAlreadyInProgress error")
213+
} catch let error as POSCatalogSyncError {
214+
// Then
215+
#expect(error == POSCatalogSyncError.syncAlreadyInProgress(siteID: sampleSiteID))
216+
}
217+
218+
// Cleanup - resume the first sync and wait for it to complete
219+
mockSyncService.resumeBlockedSync()
220+
_ = try await firstSyncTask.value
221+
}
222+
223+
@Test func performFullSync_allows_concurrent_syncs_for_different_sites() async throws {
224+
// Given
225+
let siteA: Int64 = 123
226+
let siteB: Int64 = 456
227+
let expectedCatalog = POSCatalog(products: [], variations: [])
228+
mockSyncService.startFullSyncResult = .success(expectedCatalog)
229+
230+
// When - start syncs for different sites concurrently
231+
async let syncA: () = sut.performFullSync(for: siteA)
232+
async let syncB: () = sut.performFullSync(for: siteB)
233+
234+
// Then - both should complete successfully
235+
try await syncA
236+
try await syncB
237+
#expect(mockSyncService.startFullSyncCallCount == 2)
238+
}
239+
240+
@Test func sync_tracking_cleaned_up_on_error() async throws {
241+
// Given
242+
let expectedError = NSError(domain: "test", code: 1, userInfo: nil)
243+
mockSyncService.startFullSyncResult = .failure(expectedError)
244+
245+
// When - sync fails
246+
do {
247+
_ = try await sut.performFullSync(for: sampleSiteID)
248+
#expect(Bool(false), "Should have thrown error")
249+
} catch {
250+
// Expected error
251+
}
252+
253+
// Then - subsequent sync should be allowed
254+
mockSyncService.startFullSyncResult = .success(POSCatalog(products: [], variations: []))
255+
256+
try await sut.performFullSync(for: sampleSiteID)
257+
}
258+
193259
// MARK: - Helper Methods
194260

195261
private func createSiteInDatabase(siteID: Int64) throws {
@@ -204,6 +270,11 @@ struct POSCatalogSyncCoordinatorTests {
204270

205271
final class MockPOSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol {
206272
var startFullSyncResult: Result<POSCatalog, Error> = .success(POSCatalog(products: [], variations: []))
273+
var syncDelay: UInt64 = 0 // nanoseconds to delay before returning
274+
275+
// Controlled sync mechanism
276+
private var syncContinuation: CheckedContinuation<Void, Never>?
277+
private var shouldBlockSync = false
207278

208279
private(set) var startFullSyncCallCount = 0
209280
private(set) var lastSyncSiteID: Int64?
@@ -212,11 +283,33 @@ final class MockPOSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol {
212283
startFullSyncCallCount += 1
213284
lastSyncSiteID = siteID
214285

286+
// If we should block, wait for continuation to be resumed
287+
if shouldBlockSync {
288+
await withCheckedContinuation { continuation in
289+
syncContinuation = continuation
290+
}
291+
}
292+
293+
// Add delay if specified
294+
if syncDelay > 0 {
295+
try await Task.sleep(nanoseconds: syncDelay)
296+
}
297+
215298
switch startFullSyncResult {
216299
case .success(let catalog):
217300
return catalog
218301
case .failure(let error):
219302
throw error
220303
}
221304
}
305+
306+
func blockNextSync() {
307+
shouldBlockSync = true
308+
}
309+
310+
func resumeBlockedSync() {
311+
syncContinuation?.resume()
312+
syncContinuation = nil
313+
shouldBlockSync = false
314+
}
222315
}

WooCommerce/Classes/ServiceLocator/ServiceLocator.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,17 @@ final class ServiceLocator {
299299
static var startupWaitingTimeTracker: AppStartupWaitingTimeTracker {
300300
_startupWaitingTimeTracker
301301
}
302+
303+
/// Provides access point to the `POSCatalogSyncCoordinator`.
304+
/// Returns nil if feature flag is disabled or user is not authenticated.
305+
///
306+
static var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? {
307+
guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) else {
308+
return nil
309+
}
310+
311+
return stores.posCatalogSyncCoordinator
312+
}
302313
}
303314

304315

0 commit comments

Comments
 (0)