Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
return false
}

func stopOngoingSyncs(for siteID: Int64) async {
// Preview implementation - no-op
}
}

#endif
10 changes: 10 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/BatchedRequestLoader.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Networking
import Alamofire

/// Protocol for determining which errors should be retried.
protocol RetryErrorEvaluator {
Expand All @@ -9,6 +10,15 @@ protocol RetryErrorEvaluator {
/// Default implementation that retries network errors and server errors.
struct DefaultRetryErrorEvaluator: RetryErrorEvaluator {
func shouldRetry(_ error: Error) -> Bool {
// Don't retry cancellation errors
if error is CancellationError {
return false
}

if let afError = error as? AFError, case .explicitlyCancelled = afError {
return false
}

if let networkError = error as? NetworkError {
switch networkError {
case .invalidCookieNonce:
Expand Down
76 changes: 73 additions & 3 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public protocol POSCatalogSyncCoordinatorProtocol {
/// - maxDays: Maximum number of days before a sync is considered stale
/// - Returns: True if the last sync is older than the specified days or if there has been no sync
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool

/// Stops all ongoing sync tasks for the specified site
/// - Parameter siteID: The site ID to stop syncs for
func stopOngoingSyncs(for siteID: Int64) async
}

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

/// Tracks ongoing full sync tasks by site ID for cancellation
private var ongoingFullSyncTasks: [Int64: Task<Void, Error>] = [:]

/// Tracks ongoing incremental sync tasks by site ID for cancellation
private var ongoingIncrementalSyncTasks: [Int64: Task<Void, Error>] = [:]

/// Observable model for full sync state updates
public nonisolated let fullSyncStateModel: POSCatalogSyncStateModel = .init()

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

do {
// Create a task to perform the sync
let syncTask = Task<Void, Error> {
_ = try await fullSyncService.startFullSync(for: siteID,
regenerateCatalog: regenerateCatalog,
allowCellular: allowCellular)
}

// Store the task for potential cancellation
ongoingFullSyncTasks[siteID] = syncTask

defer {
ongoingFullSyncTasks.removeValue(forKey: siteID)
}

do {
try await syncTask.value
emitSyncState(.syncCompleted(siteID: siteID))
} catch AFError.explicitlyCancelled, is CancellationError {
if isFirstSync {
Expand Down Expand Up @@ -245,10 +267,22 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {

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

do {
// Create a task to perform the sync
let syncTask = Task<Void, Error> {
try await incrementalSyncService.startIncrementalSync(for: siteID,
lastFullSyncDate: lastFullSyncDate,
lastIncrementalSyncDate: lastIncrementalSyncDate(for: siteID))
lastIncrementalSyncDate: await lastIncrementalSyncDate(for: siteID))
}

// Store the task for potential cancellation
ongoingIncrementalSyncTasks[siteID] = syncTask

defer {
ongoingIncrementalSyncTasks.removeValue(forKey: siteID)
}

do {
try await syncTask.value
} catch AFError.explicitlyCancelled, is CancellationError {
throw POSCatalogSyncError.requestCancelled
}
Expand Down Expand Up @@ -349,6 +383,42 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {

return lastFullSync < thresholdDate
}

public func stopOngoingSyncs(for siteID: Int64) async {
DDLogInfo("🛑 POSCatalogSyncCoordinator: Stopping ongoing syncs for site \(siteID)")

// Cancel ongoing full sync task if exists
if let fullSyncTask = ongoingFullSyncTasks[siteID] {
fullSyncTask.cancel()
ongoingFullSyncTasks.removeValue(forKey: siteID)
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cancelled full sync task for site \(siteID)")
}

// Cancel ongoing incremental sync task if exists
if let incrementalSyncTask = ongoingIncrementalSyncTasks[siteID] {
incrementalSyncTask.cancel()
ongoingIncrementalSyncTasks.removeValue(forKey: siteID)
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cancelled incremental sync task for site \(siteID)")
}

// Clean up incremental sync tracking
if ongoingIncrementalSyncs.contains(siteID) {
ongoingIncrementalSyncs.remove(siteID)
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cleaned up incremental sync tracking for site \(siteID)")
}

// Update sync state to reflect that syncs are being stopped
// This will prevent new syncs from starting for this site
if let currentState = fullSyncStateModel.state[siteID] {
switch currentState {
case .initialSyncStarted, .syncStarted:
emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled))
DDLogInfo("🛑 POSCatalogSyncCoordinator: Updated sync state to cancelled for site \(siteID)")
default:
break
}
}
}
}

// MARK: - Syncing State
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,6 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
return isSyncStaleResult
}

func stopOngoingSyncs(for siteID: Int64) async {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ final class MockPOSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServi
private(set) var lastFullSyncDate: Date?
private(set) var lastIncrementalSyncDate: Date?

private var syncContinuation: CheckedContinuation<Void, Never>?
private var syncContinuations: [CheckedContinuation<Void, Never>] = []
private var shouldBlockSync = false
private var syncBlockedContinuations: [CheckedContinuation<Void, Never>] = []

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

if shouldBlockSync {
await withCheckedContinuation { continuation in
syncContinuation = continuation
syncContinuations.append(continuation)
// Signal that a sync is now blocked and ready
if !syncBlockedContinuations.isEmpty {
syncBlockedContinuations.removeFirst().resume()
}
}
}

Expand All @@ -38,9 +43,15 @@ extension MockPOSCatalogIncrementalSyncService {
shouldBlockSync = true
}

func waitUntilSyncBlocked() async {
await withCheckedContinuation { continuation in
syncBlockedContinuations.append(continuation)
}
}

func resumeBlockedSync() {
syncContinuation?.resume()
syncContinuation = nil
syncContinuations.forEach { $0.resume() }
syncContinuations.removeAll()
shouldBlockSync = false
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Testing
import Alamofire
@testable import Networking
@testable import Yosemite

Expand Down Expand Up @@ -121,6 +122,68 @@ struct BatchedRequestLoaderTests {
}
#expect(await attemptCount.value == 3) // maxRetries = 3
}

// MARK: - DefaultRetryErrorEvaluator Tests

@Test func defaultRetryErrorEvaluator_does_not_retry_cancellation_error() {
// Given
let sut = DefaultRetryErrorEvaluator()
let error = CancellationError()

// When
let shouldRetry = sut.shouldRetry(error)

// Then
#expect(!shouldRetry)
}

@Test func defaultRetryErrorEvaluator_does_not_retry_alamofire_explicitly_cancelled() {
// Given
let sut = DefaultRetryErrorEvaluator()
let error = AFError.explicitlyCancelled

// When
let shouldRetry = sut.shouldRetry(error)

// Then
#expect(!shouldRetry)
}

@Test func defaultRetryErrorEvaluator_does_not_retry_invalid_cookie_nonce() {
// Given
let sut = DefaultRetryErrorEvaluator()
let error = NetworkError.invalidCookieNonce

// When
let shouldRetry = sut.shouldRetry(error)

// Then
#expect(!shouldRetry)
}

@Test func defaultRetryErrorEvaluator_retries_network_errors() {
// Given
let sut = DefaultRetryErrorEvaluator()
let error = NetworkError.timeout()

// When
let shouldRetry = sut.shouldRetry(error)

// Then
#expect(shouldRetry)
}

@Test func defaultRetryErrorEvaluator_retries_url_errors() {
// Given
let sut = DefaultRetryErrorEvaluator()
let error = URLError(.networkConnectionLost)

// When
let shouldRetry = sut.shouldRetry(error)

// Then
#expect(shouldRetry)
}
}

// MARK: - Mock Error Evaluator
Expand Down
Loading