Skip to content

Commit 710519d

Browse files
authored
[Woo POS][Local Catalog] Check catalog size in sync coordinator (#16119)
2 parents 13a4bc6 + cea0e8e commit 710519d

File tree

11 files changed

+813
-11
lines changed

11 files changed

+813
-11
lines changed

Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ public protocol POSCatalogSyncRemoteProtocol {
3939
/// - pageNumber: Page number for pagination.
4040
/// - Returns: Paginated list of POS product variations.
4141
func loadProductVariations(siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProductVariation>
42+
43+
/// Gets the total count of products for the specified site.
44+
///
45+
/// - Parameter siteID: Site ID to get product count for.
46+
/// - Returns: Total number of products.
47+
func getProductCount(siteID: Int64) async throws -> Int
48+
49+
/// Gets the total count of product variations for the specified site.
50+
///
51+
/// - Parameter siteID: Site ID to get variation count for.
52+
/// - Returns: Total number of variations.
53+
func getProductVariationCount(siteID: Int64) async throws -> Int
4254
}
4355

4456
/// POS Catalog Sync: Remote Endpoints
@@ -174,6 +186,58 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
174186

175187
return createPagedItems(items: variations, responseHeaders: responseHeaders, currentPageNumber: pageNumber)
176188
}
189+
190+
// MARK: - Count Endpoints
191+
192+
/// Gets the total count of products for the specified site.
193+
///
194+
/// - Parameter siteID: Site ID to get product count for.
195+
/// - Returns: Total number of products.
196+
public func getProductCount(siteID: Int64) async throws -> Int {
197+
let path = Path.products
198+
let parameters = [
199+
ParameterKey.page: String(1),
200+
ParameterKey.perPage: String(1),
201+
ParameterKey.fields: POSProductVariation.requestFields.first ?? ""
202+
]
203+
204+
let request = JetpackRequest(
205+
wooApiVersion: .mark3,
206+
method: .get,
207+
siteID: siteID,
208+
path: path,
209+
parameters: parameters,
210+
availableAsRESTRequest: true
211+
)
212+
let responseHeaders = try await enqueueWithResponseHeaders(request)
213+
214+
return totalItemsCount(from: responseHeaders) ?? 0
215+
}
216+
217+
/// Gets the total count of product variations for the specified site.
218+
///
219+
/// - Parameter siteID: Site ID to get variation count for.
220+
/// - Returns: Total number of variations.
221+
public func getProductVariationCount(siteID: Int64) async throws -> Int {
222+
let path = Path.variations
223+
let parameters = [
224+
ParameterKey.page: String(1),
225+
ParameterKey.perPage: String(1),
226+
ParameterKey.fields: POSProductVariation.requestFields.first ?? ""
227+
]
228+
229+
let request = JetpackRequest(
230+
wooApiVersion: .wcAnalytics,
231+
method: .get,
232+
siteID: siteID,
233+
path: path,
234+
parameters: parameters,
235+
availableAsRESTRequest: true
236+
)
237+
let responseHeaders = try await enqueueWithResponseHeaders(request)
238+
239+
return totalItemsCount(from: responseHeaders) ?? 0
240+
}
177241
}
178242

179243
// MARK: - Constants

Modules/Sources/NetworkingCore/Remote/Remote.swift

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,23 @@ open class Remote: NSObject {
248248
throw mapNetworkError(error: error, for: request)
249249
}
250250
}
251+
252+
/// Enqueues the specified Network Request using Swift Concurrency, for fetching the headers
253+
///
254+
/// - Important:
255+
/// - No data will be parsed. This is intended for use with `HEAD` requests, but will make whatever request you specify
256+
///
257+
/// - Parameter request: Request that should be performed.
258+
/// - Returns: The headers from the response
259+
public func enqueueWithResponseHeaders(_ request: Request) async throws -> [String: String] {
260+
do {
261+
let (_, headers) = try await network.responseDataAndHeaders(for: request)
262+
return headers ?? [:]
263+
} catch {
264+
handleResponseError(error: error, for: request)
265+
throw mapNetworkError(error: error, for: request)
266+
}
267+
}
251268
}
252269

253270
private extension Remote {
@@ -382,12 +399,16 @@ public extension Remote {
382399

383400
let hasMorePages = totalPages.map { currentPageNumber < $0 } ?? true
384401

402+
let totalItems = totalItemsCount(from: responseHeaders)
403+
404+
return PagedItems(items: items, hasMorePages: hasMorePages, totalItems: totalItems)
405+
}
406+
407+
func totalItemsCount(from responseHeaders: [String: String]?) -> Int? {
385408
// Extract total count from response headers (case insensitive)
386-
let totalItems = responseHeaders?.first(where: {
409+
responseHeaders?.first(where: {
387410
$0.key.lowercased() == PaginationHeaderKey.totalCount.lowercased()
388411
}).flatMap { Int($0.value) }
389-
390-
return PagedItems(items: items, hasMorePages: hasMorePages, totalItems: totalItems)
391412
}
392413
}
393414

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Foundation
2+
import Networking
3+
4+
/// Protocol for checking the size of a remote POS catalog
5+
public protocol POSCatalogSizeCheckerProtocol {
6+
/// Checks the size of the remote catalog for the specified site
7+
/// - Parameter siteID: The site ID to check catalog size for
8+
/// - Returns: The size information of the catalog
9+
/// - Throws: Network or parsing errors
10+
func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize
11+
}
12+
13+
/// Implementation of catalog size checker that uses the sync remote to get counts
14+
public struct POSCatalogSizeChecker: POSCatalogSizeCheckerProtocol {
15+
private let syncRemote: POSCatalogSyncRemoteProtocol
16+
17+
public init(syncRemote: POSCatalogSyncRemoteProtocol) {
18+
self.syncRemote = syncRemote
19+
}
20+
21+
public func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize {
22+
// Make concurrent requests to get both counts
23+
async let productCount = syncRemote.getProductCount(siteID: siteID)
24+
async let variationCount = syncRemote.getProductVariationCount(siteID: siteID)
25+
26+
do {
27+
return try await POSCatalogSize(
28+
productCount: productCount,
29+
variationCount: variationCount
30+
)
31+
} catch {
32+
DDLogError(
33+
"⚠️ Failed to check POS catalog size for site \(siteID): \(error)"
34+
)
35+
throw error
36+
}
37+
}
38+
}
39+
40+
public struct POSCatalogSize: Equatable {
41+
/// Number of products in the catalog
42+
public let productCount: Int
43+
44+
/// Number of product variations in the catalog
45+
public let variationCount: Int
46+
47+
/// Total number of items (products + variations)
48+
public var totalCount: Int {
49+
productCount + variationCount
50+
}
51+
52+
public init(productCount: Int, variationCount: Int) {
53+
self.productCount = productCount
54+
self.variationCount = variationCount
55+
}
56+
}

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

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
3434
private let incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol
3535
private let grdbManager: GRDBManagerProtocol
3636
private let maxIncrementalSyncAge: TimeInterval
37+
private let catalogSizeLimit: Int
38+
private let catalogSizeChecker: POSCatalogSizeCheckerProtocol
3739

3840
/// Tracks ongoing full syncs by site ID to prevent duplicates
3941
private var ongoingSyncs: Set<Int64> = []
@@ -43,11 +45,15 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
4345
public init(fullSyncService: POSCatalogFullSyncServiceProtocol,
4446
incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol,
4547
grdbManager: GRDBManagerProtocol,
46-
maxIncrementalSyncAge: TimeInterval = 300) {
48+
maxIncrementalSyncAge: TimeInterval = 300,
49+
catalogSizeLimit: Int? = nil,
50+
catalogSizeChecker: POSCatalogSizeCheckerProtocol) {
4751
self.fullSyncService = fullSyncService
4852
self.incrementalSyncService = incrementalSyncService
4953
self.grdbManager = grdbManager
5054
self.maxIncrementalSyncAge = maxIncrementalSyncAge
55+
self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultSizeLimitForPOSCatalog
56+
self.catalogSizeChecker = catalogSizeChecker
5157
}
5258

5359
public func performFullSync(for siteID: Int64) async throws {
@@ -71,7 +77,20 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
7177
DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)")
7278
}
7379

80+
/// Determines if a full sync should be performed based on the age of the last sync
81+
/// - Parameters:
82+
/// - siteID: The site ID to check
83+
/// - maxAge: Maximum age before a sync is considered stale
84+
/// - Returns: True if a sync should be performed
7485
public func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool {
86+
return await shouldPerformFullSync(for: siteID, maxAge: maxAge, maxCatalogSize: catalogSizeLimit)
87+
}
88+
89+
private func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval, maxCatalogSize: Int) async -> Bool {
90+
guard await isCatalogSizeWithinLimit(for: siteID, maxCatalogSize: maxCatalogSize) else {
91+
return false
92+
}
93+
7594
if !siteExistsInDatabase(siteID: siteID) {
7695
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) not found in database, sync needed")
7796
return true
@@ -86,20 +105,35 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
86105
let shouldSync = age > maxAge
87106

88107
if shouldSync {
89-
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync needed")
108+
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago " +
109+
"(max: \(Int(maxAge))s), sync needed")
90110
} else {
91-
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync not needed")
111+
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago " +
112+
"(max: \(Int(maxAge))s), sync not needed")
92113
}
93114

94115
return shouldSync
95116
}
96117

118+
/// Performs an incremental sync if applicable based on sync conditions
119+
/// - Parameters:
120+
/// - siteID: The site ID to sync catalog for
121+
/// - forceSync: Whether to bypass age checks and always sync
122+
/// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site
97123
public func performIncrementalSyncIfApplicable(for siteID: Int64, forceSync: Bool) async throws {
124+
try await performIncrementalSyncIfApplicable(for: siteID, forceSync: forceSync, maxCatalogSize: catalogSizeLimit)
125+
}
126+
127+
private func performIncrementalSyncIfApplicable(for siteID: Int64, forceSync: Bool, maxCatalogSize: Int) async throws {
98128
if ongoingIncrementalSyncs.contains(siteID) {
99129
DDLogInfo("⚠️ POSCatalogSyncCoordinator: Incremental sync already in progress for site \(siteID)")
100130
throw POSCatalogSyncError.syncAlreadyInProgress(siteID: siteID)
101131
}
102132

133+
guard await isCatalogSizeWithinLimit(for: siteID, maxCatalogSize: maxCatalogSize) else {
134+
return
135+
}
136+
103137
guard let lastFullSyncDate = await lastFullSyncDate(for: siteID) else {
104138
DDLogInfo("📋 POSCatalogSyncCoordinator: No full sync performed yet for site \(siteID), skipping incremental sync")
105139
return
@@ -130,6 +164,28 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
130164

131165
// MARK: - Private
132166

167+
/// Checks if the catalog size is within the specified sync limit
168+
/// - Parameters:
169+
/// - siteID: The site ID to check
170+
/// - maxCatalogSize: Maximum allowed catalog size for syncing
171+
/// - Returns: True if catalog size is within limit or if size cannot be determined
172+
private func isCatalogSizeWithinLimit(for siteID: Int64, maxCatalogSize: Int) async -> Bool {
173+
guard let catalogSize = try? await catalogSizeChecker.checkCatalogSize(for: siteID) else {
174+
DDLogError("📋 POSCatalogSyncCoordinator: Could not get catalog size for site \(siteID)")
175+
return false
176+
}
177+
178+
guard catalogSize.totalCount <= maxCatalogSize else {
179+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) has catalog size \(catalogSize.totalCount), " +
180+
"greater than \(maxCatalogSize), should not sync.")
181+
return false
182+
}
183+
184+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) has catalog size \(catalogSize.totalCount), with " +
185+
"\(catalogSize.productCount) products and \(catalogSize.variationCount) variations")
186+
return true
187+
}
188+
133189
private func lastFullSyncDate(for siteID: Int64) async -> Date? {
134190
do {
135191
return try await grdbManager.databaseConnection.read { db in
@@ -164,3 +220,9 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
164220
}
165221
}
166222
}
223+
224+
private extension POSCatalogSyncCoordinator {
225+
enum Constants {
226+
static let defaultSizeLimitForPOSCatalog = 1000
227+
}
228+
}

0 commit comments

Comments
 (0)