Skip to content

Commit 21d797a

Browse files
authored
[Local Catalog] Stop ongoing syncs when the user logs out (#16329)
2 parents 1b2dd0a + f205b47 commit 21d797a

File tree

10 files changed

+352
-19
lines changed

10 files changed

+352
-19
lines changed

Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
641641
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
642642
return false
643643
}
644+
645+
func stopOngoingSyncs(for siteID: Int64) async {
646+
// Preview implementation - no-op
647+
}
644648
}
645649

646650
#endif

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import Networking
3+
import Alamofire
34

45
/// Protocol for determining which errors should be retried.
56
protocol RetryErrorEvaluator {
@@ -9,6 +10,15 @@ protocol RetryErrorEvaluator {
910
/// Default implementation that retries network errors and server errors.
1011
struct DefaultRetryErrorEvaluator: RetryErrorEvaluator {
1112
func shouldRetry(_ error: Error) -> Bool {
13+
// Don't retry cancellation errors
14+
if error is CancellationError {
15+
return false
16+
}
17+
18+
if let afError = error as? AFError, case .explicitlyCancelled = afError {
19+
return false
20+
}
21+
1222
if let networkError = error as? NetworkError {
1323
switch networkError {
1424
case .invalidCookieNonce:

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

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ public protocol POSCatalogSyncCoordinatorProtocol {
4242
/// - maxDays: Maximum number of days before a sync is considered stale
4343
/// - Returns: True if the last sync is older than the specified days or if there has been no sync
4444
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool
45+
46+
/// Stops all ongoing sync tasks for the specified site
47+
/// - Parameter siteID: The site ID to stop syncs for
48+
func stopOngoingSyncs(for siteID: Int64) async
4549
}
4650

4751
public extension POSCatalogSyncCoordinatorProtocol {
@@ -81,6 +85,12 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
8185
/// Tracks ongoing incremental syncs by site ID to prevent duplicates
8286
private var ongoingIncrementalSyncs: Set<Int64> = []
8387

88+
/// Tracks ongoing full sync tasks by site ID for cancellation
89+
private var ongoingFullSyncTasks: [Int64: Task<Void, Error>] = [:]
90+
91+
/// Tracks ongoing incremental sync tasks by site ID for cancellation
92+
private var ongoingIncrementalSyncTasks: [Int64: Task<Void, Error>] = [:]
93+
8494
/// Observable model for full sync state updates
8595
public nonisolated let fullSyncStateModel: POSCatalogSyncStateModel = .init()
8696

@@ -117,10 +127,22 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
117127
let allowCellular = isFirstSync || siteSettings.getPOSLocalCatalogCellularDataAllowed(siteID: siteID)
118128
DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)")
119129

120-
do {
130+
// Create a task to perform the sync
131+
let syncTask = Task<Void, Error> {
121132
_ = try await fullSyncService.startFullSync(for: siteID,
122133
regenerateCatalog: regenerateCatalog,
123134
allowCellular: allowCellular)
135+
}
136+
137+
// Store the task for potential cancellation
138+
ongoingFullSyncTasks[siteID] = syncTask
139+
140+
defer {
141+
ongoingFullSyncTasks.removeValue(forKey: siteID)
142+
}
143+
144+
do {
145+
try await syncTask.value
124146
emitSyncState(.syncCompleted(siteID: siteID))
125147
} catch AFError.explicitlyCancelled, is CancellationError {
126148
if isFirstSync {
@@ -245,10 +267,22 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
245267

246268
DDLogInfo("🔄 POSCatalogSyncCoordinator starting incremental sync for site \(siteID)")
247269

248-
do {
270+
// Create a task to perform the sync
271+
let syncTask = Task<Void, Error> {
249272
try await incrementalSyncService.startIncrementalSync(for: siteID,
250273
lastFullSyncDate: lastFullSyncDate,
251-
lastIncrementalSyncDate: lastIncrementalSyncDate(for: siteID))
274+
lastIncrementalSyncDate: await lastIncrementalSyncDate(for: siteID))
275+
}
276+
277+
// Store the task for potential cancellation
278+
ongoingIncrementalSyncTasks[siteID] = syncTask
279+
280+
defer {
281+
ongoingIncrementalSyncTasks.removeValue(forKey: siteID)
282+
}
283+
284+
do {
285+
try await syncTask.value
252286
} catch AFError.explicitlyCancelled, is CancellationError {
253287
throw POSCatalogSyncError.requestCancelled
254288
}
@@ -349,6 +383,42 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
349383

350384
return lastFullSync < thresholdDate
351385
}
386+
387+
public func stopOngoingSyncs(for siteID: Int64) async {
388+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Stopping ongoing syncs for site \(siteID)")
389+
390+
// Cancel ongoing full sync task if exists
391+
if let fullSyncTask = ongoingFullSyncTasks[siteID] {
392+
fullSyncTask.cancel()
393+
ongoingFullSyncTasks.removeValue(forKey: siteID)
394+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cancelled full sync task for site \(siteID)")
395+
}
396+
397+
// Cancel ongoing incremental sync task if exists
398+
if let incrementalSyncTask = ongoingIncrementalSyncTasks[siteID] {
399+
incrementalSyncTask.cancel()
400+
ongoingIncrementalSyncTasks.removeValue(forKey: siteID)
401+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cancelled incremental sync task for site \(siteID)")
402+
}
403+
404+
// Clean up incremental sync tracking
405+
if ongoingIncrementalSyncs.contains(siteID) {
406+
ongoingIncrementalSyncs.remove(siteID)
407+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cleaned up incremental sync tracking for site \(siteID)")
408+
}
409+
410+
// Update sync state to reflect that syncs are being stopped
411+
// This will prevent new syncs from starting for this site
412+
if let currentState = fullSyncStateModel.state[siteID] {
413+
switch currentState {
414+
case .initialSyncStarted, .syncStarted:
415+
emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled))
416+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Updated sync state to cancelled for site \(siteID)")
417+
default:
418+
break
419+
}
420+
}
421+
}
352422
}
353423

354424
// MARK: - Syncing State

Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,6 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
7474
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
7575
return isSyncStaleResult
7676
}
77+
78+
func stopOngoingSyncs(for siteID: Int64) async {}
7779
}

Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ final class MockPOSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServi
99
private(set) var lastFullSyncDate: Date?
1010
private(set) var lastIncrementalSyncDate: Date?
1111

12-
private var syncContinuation: CheckedContinuation<Void, Never>?
12+
private var syncContinuations: [CheckedContinuation<Void, Never>] = []
1313
private var shouldBlockSync = false
14+
private var syncBlockedContinuations: [CheckedContinuation<Void, Never>] = []
1415

1516
func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws {
1617
startIncrementalSyncCallCount += 1
@@ -20,7 +21,11 @@ final class MockPOSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServi
2021

2122
if shouldBlockSync {
2223
await withCheckedContinuation { continuation in
23-
syncContinuation = continuation
24+
syncContinuations.append(continuation)
25+
// Signal that a sync is now blocked and ready
26+
if !syncBlockedContinuations.isEmpty {
27+
syncBlockedContinuations.removeFirst().resume()
28+
}
2429
}
2530
}
2631

@@ -38,9 +43,15 @@ extension MockPOSCatalogIncrementalSyncService {
3843
shouldBlockSync = true
3944
}
4045

46+
func waitUntilSyncBlocked() async {
47+
await withCheckedContinuation { continuation in
48+
syncBlockedContinuations.append(continuation)
49+
}
50+
}
51+
4152
func resumeBlockedSync() {
42-
syncContinuation?.resume()
43-
syncContinuation = nil
53+
syncContinuations.forEach { $0.resume() }
54+
syncContinuations.removeAll()
4455
shouldBlockSync = false
4556
}
4657
}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import Testing
3+
import Alamofire
34
@testable import Networking
45
@testable import Yosemite
56

@@ -121,6 +122,68 @@ struct BatchedRequestLoaderTests {
121122
}
122123
#expect(await attemptCount.value == 3) // maxRetries = 3
123124
}
125+
126+
// MARK: - DefaultRetryErrorEvaluator Tests
127+
128+
@Test func defaultRetryErrorEvaluator_does_not_retry_cancellation_error() {
129+
// Given
130+
let sut = DefaultRetryErrorEvaluator()
131+
let error = CancellationError()
132+
133+
// When
134+
let shouldRetry = sut.shouldRetry(error)
135+
136+
// Then
137+
#expect(!shouldRetry)
138+
}
139+
140+
@Test func defaultRetryErrorEvaluator_does_not_retry_alamofire_explicitly_cancelled() {
141+
// Given
142+
let sut = DefaultRetryErrorEvaluator()
143+
let error = AFError.explicitlyCancelled
144+
145+
// When
146+
let shouldRetry = sut.shouldRetry(error)
147+
148+
// Then
149+
#expect(!shouldRetry)
150+
}
151+
152+
@Test func defaultRetryErrorEvaluator_does_not_retry_invalid_cookie_nonce() {
153+
// Given
154+
let sut = DefaultRetryErrorEvaluator()
155+
let error = NetworkError.invalidCookieNonce
156+
157+
// When
158+
let shouldRetry = sut.shouldRetry(error)
159+
160+
// Then
161+
#expect(!shouldRetry)
162+
}
163+
164+
@Test func defaultRetryErrorEvaluator_retries_network_errors() {
165+
// Given
166+
let sut = DefaultRetryErrorEvaluator()
167+
let error = NetworkError.timeout()
168+
169+
// When
170+
let shouldRetry = sut.shouldRetry(error)
171+
172+
// Then
173+
#expect(shouldRetry)
174+
}
175+
176+
@Test func defaultRetryErrorEvaluator_retries_url_errors() {
177+
// Given
178+
let sut = DefaultRetryErrorEvaluator()
179+
let error = URLError(.networkConnectionLost)
180+
181+
// When
182+
let shouldRetry = sut.shouldRetry(error)
183+
184+
// Then
185+
#expect(shouldRetry)
186+
}
124187
}
125188

126189
// MARK: - Mock Error Evaluator

0 commit comments

Comments
 (0)