Skip to content

Commit c8b64f5

Browse files
authored
[Local Catalog] Create full sync service that syncs catalog with batches of paginated products/variations requests (#16071)
2 parents ff9ee39 + 7ea02f9 commit c8b64f5

File tree

5 files changed

+486
-3
lines changed

5 files changed

+486
-3
lines changed

Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,49 @@
11
import Foundation
22

3+
/// Protocol for POS Catalog Sync Remote operations.
4+
public protocol POSCatalogSyncRemoteProtocol {
5+
/// Loads POS products modified after the specified date for incremental sync.
6+
///
7+
/// - Parameters:
8+
/// - modifiedAfter: Only products modified after this date will be returned.
9+
/// - siteID: Site ID to load products from.
10+
/// - pageNumber: Page number for pagination.
11+
/// - Returns: Paginated list of POS products.
12+
// TODO - remove the periphery ignore comment when the incremental sync is integrated with POS.
13+
// periphery:ignore
14+
func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProduct>
15+
16+
/// Loads POS product variations modified after the specified date for incremental sync.
17+
///
18+
/// - Parameters:
19+
/// - modifiedAfter: Only variations modified after this date will be returned.
20+
/// - siteID: Site ID to load variations from.
21+
/// - pageNumber: Page number for pagination.
22+
/// - Returns: Paginated list of POS product variations.
23+
// TODO - remove the periphery ignore comment when the incremental sync is integrated with POS.
24+
// periphery:ignore
25+
func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProductVariation>
26+
27+
/// Loads POS products for full sync.
28+
///
29+
/// - Parameters:
30+
/// - siteID: Site ID to load products from.
31+
/// - pageNumber: Page number for pagination.
32+
/// - Returns: Paginated list of POS products.
33+
func loadProducts(siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProduct>
34+
35+
/// Loads POS product variations for full sync.
36+
///
37+
/// - Parameters:
38+
/// - siteID: Site ID to load variations from.
39+
/// - pageNumber: Page number for pagination.
40+
/// - Returns: Paginated list of POS product variations.
41+
func loadProductVariations(siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProductVariation>
42+
}
43+
344
/// POS Catalog Sync: Remote Endpoints
445
///
5-
public class POSCatalogSyncRemote: Remote {
46+
public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
647
private let dateFormatter = ISO8601DateFormatter()
748

849
// MARK: - Incremental Sync Endpoints

Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ extension Alamofire.MultipartFormData: MultipartFormData {
2424
/// AlamofireWrapper: Encapsulates all of the Alamofire OP's
2525
///
2626
public class AlamofireNetwork: Network {
27+
/// Lazy-initialized session manager. Use ensuresSessionManagerIsInitialized=true to avoid race conditions with concurrent requests.
2728
private lazy var alamofireSession: Alamofire.Session = {
2829
let sessionConfiguration = URLSessionConfiguration.default
2930
let sessionManager = makeSession(configuration: sessionConfiguration)
@@ -48,10 +49,16 @@ public class AlamofireNetwork: Network {
4849

4950
/// Public Initializer
5051
///
51-
///
52+
/// - Parameters:
53+
/// - credentials: Authentication credentials for requests.
54+
/// - selectedSite: Publisher for site selection changes.
55+
/// - sessionManager: Optional pre-configured session manager.
56+
/// - ensuresSessionManagerIsInitialized: If true, the session is always set during initialization immediately to avoid lazy initialization race conditions.
57+
/// Defaults to false for backward compatibility. Set to true when making concurrent requests immediately after initialization.
5258
public required init(credentials: Credentials?,
5359
selectedSite: AnyPublisher<JetpackSite?, Never>? = nil,
54-
sessionManager: Alamofire.Session? = nil) {
60+
sessionManager: Alamofire.Session? = nil,
61+
ensuresSessionManagerIsInitialized: Bool = false) {
5562
self.credentials = credentials
5663
self.selectedSite = selectedSite
5764
self.requestConverter = {
@@ -70,6 +77,8 @@ public class AlamofireNetwork: Network {
7077
self.requestAuthenticator = RequestProcessor(requestAuthenticator: DefaultRequestAuthenticator(credentials: credentials))
7178
if let sessionManager {
7279
self.alamofireSession = sessionManager
80+
} else if ensuresSessionManagerIsInitialized {
81+
self.alamofireSession = makeSession(configuration: URLSessionConfiguration.default)
7382
}
7483
}
7584

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import Foundation
2+
import protocol Networking.POSCatalogSyncRemoteProtocol
3+
import class Networking.AlamofireNetwork
4+
import class Networking.POSCatalogSyncRemote
5+
import CocoaLumberjackSwift
6+
7+
// TODO - remove the periphery ignore comment when the catalog is integrated with POS.
8+
// periphery:ignore
9+
public protocol POSCatalogFullSyncServiceProtocol {
10+
/// Starts a full catalog sync process
11+
/// - Parameter siteID: The site ID to sync catalog for
12+
/// - Returns: The synced catalog containing products and variations
13+
func startFullSync(for siteID: Int64) async throws -> POSCatalog
14+
}
15+
16+
/// POS catalog from full sync.
17+
// TODO - remove the periphery ignore comment when the catalog is integrated with POS.
18+
// periphery:ignore
19+
public struct POSCatalog {
20+
public let products: [POSProduct]
21+
public let variations: [POSProductVariation]
22+
}
23+
24+
// TODO - remove the periphery ignore comment when the service is integrated with POS.
25+
// periphery:ignore
26+
public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol {
27+
private let syncRemote: POSCatalogSyncRemoteProtocol
28+
private let batchSize: Int
29+
30+
public convenience init?(credentials: Credentials?, batchSize: Int = 2) {
31+
guard let credentials else {
32+
DDLogError("⛔️ Could not create POSCatalogFullSyncService due missing credentials")
33+
return nil
34+
}
35+
let network = AlamofireNetwork(credentials: credentials, ensuresSessionManagerIsInitialized: true)
36+
let syncRemote = POSCatalogSyncRemote(network: network)
37+
self.init(syncRemote: syncRemote, batchSize: batchSize)
38+
}
39+
40+
init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int) {
41+
self.syncRemote = syncRemote
42+
self.batchSize = batchSize
43+
}
44+
45+
// MARK: - Protocol Conformance
46+
47+
public func startFullSync(for siteID: Int64) async throws -> POSCatalog {
48+
DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)")
49+
50+
do {
51+
let catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote)
52+
DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")
53+
return catalog
54+
} catch {
55+
throw error
56+
}
57+
}
58+
}
59+
60+
// MARK: - Remote Loading
61+
62+
private extension POSCatalogFullSyncService {
63+
func loadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog {
64+
// Loads products and variations in batches in parallel.
65+
async let productsTask = loadAllProducts(for: siteID, syncRemote: syncRemote)
66+
async let variationsTask = loadAllProductVariations(for: siteID, syncRemote: syncRemote)
67+
68+
let (products, variations) = try await (productsTask, variationsTask)
69+
return POSCatalog(products: products, variations: variations)
70+
}
71+
72+
func loadAllProducts(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> [POSProduct] {
73+
DDLogInfo("🔄 Starting products sync for site ID: \(siteID)")
74+
75+
var allProducts: [POSProduct] = []
76+
var currentPage = 1
77+
var hasMorePages = true
78+
79+
while hasMorePages {
80+
let pagesToFetch = Array(currentPage..<(currentPage + batchSize))
81+
82+
let batchResults = try await withThrowingTaskGroup(of: PageResult<POSProduct>.self) { group in
83+
for pageNumber in pagesToFetch {
84+
group.addTask {
85+
let result = try await syncRemote.loadProducts(siteID: siteID, pageNumber: pageNumber)
86+
return PageResult(pageNumber: pageNumber, items: result)
87+
}
88+
}
89+
90+
var results: [PageResult<POSProduct>] = []
91+
for try await result in group {
92+
results.append(result)
93+
}
94+
return results.sorted(by: { $0.pageNumber < $1.pageNumber })
95+
}
96+
97+
// Processes results in order and checks if there are more pages.
98+
let newProducts = batchResults.flatMap { $0.items.items }
99+
allProducts.append(contentsOf: newProducts)
100+
101+
let highestPageResult = batchResults.last?.items
102+
hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newProducts.isEmpty
103+
currentPage += batchSize
104+
105+
DDLogInfo("📥 Loaded batch: \(batchResults.count) pages, total products: \(allProducts.count), hasMorePages: \(hasMorePages)")
106+
}
107+
108+
DDLogInfo("✅ Products sync complete: \(allProducts.count) products loaded")
109+
return allProducts
110+
}
111+
112+
func loadAllProductVariations(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> [POSProductVariation] {
113+
DDLogInfo("🔄 Starting variations sync for site ID: \(siteID)")
114+
115+
var allVariations: [POSProductVariation] = []
116+
var currentPage = 1
117+
var hasMorePages = true
118+
119+
while hasMorePages {
120+
let pagesToFetch = Array(currentPage..<(currentPage + batchSize))
121+
122+
let batchResults = try await withThrowingTaskGroup(of: PageResult<POSProductVariation>.self) { group in
123+
for pageNumber in pagesToFetch {
124+
group.addTask {
125+
let result = try await syncRemote.loadProductVariations(siteID: siteID, pageNumber: pageNumber)
126+
return PageResult(pageNumber: pageNumber, items: result)
127+
}
128+
}
129+
130+
var results: [PageResult<POSProductVariation>] = []
131+
for try await result in group {
132+
results.append(result)
133+
}
134+
return results.sorted(by: { $0.pageNumber < $1.pageNumber })
135+
}
136+
137+
// Processes results in order and checks if there are more pages.
138+
let newVariations = batchResults.flatMap { $0.items.items }
139+
allVariations.append(contentsOf: newVariations)
140+
141+
let highestPageResult = batchResults.last?.items
142+
hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newVariations.isEmpty
143+
currentPage += batchSize
144+
145+
DDLogInfo("📥 Loaded batch: \(batchResults.count) pages, total variations: \(allVariations.count), hasMorePages: \(hasMorePages)")
146+
}
147+
148+
DDLogInfo("✅ Variations sync complete: \(allVariations.count) variations loaded")
149+
return allVariations
150+
}
151+
}
152+
153+
private struct PageResult<T> {
154+
let pageNumber: Int
155+
let items: PagedItems<T>
156+
}

Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,50 @@ final class AlamofireNetworkTests: XCTestCase {
167167
// Then
168168
XCTAssertTrue(result.isSuccess)
169169
}
170+
171+
// MARK: - Session Initialization Tests
172+
173+
func test_concurrent_requests_do_not_fail_with_sessionDeinitialized_error_when_ensuresSessionManagerIsInitialized_is_true() async throws {
174+
// Given
175+
let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: -1, path: "test")
176+
let network = AlamofireNetwork(credentials: nil, ensuresSessionManagerIsInitialized: true)
177+
178+
// When
179+
async let request1 = network.responseDataAndHeaders(for: request)
180+
async let request2 = network.responseDataAndHeaders(for: request)
181+
async let request3 = network.responseDataAndHeaders(for: request)
182+
183+
do {
184+
_ = try await [request1, request2, request3]
185+
XCTFail("Requests should fail")
186+
} catch Alamofire.AFError.sessionDeinitialized {
187+
XCTFail("Requests should not fail with sessionDeinitialized error")
188+
} catch {
189+
// Then
190+
XCTAssertTrue(true)
191+
}
192+
}
193+
194+
func test_concurrent_requests_fail_with_sessionDeinitialized_error_when_ensuresSessionManagerIsInitialized_is_false() async throws {
195+
// Given
196+
let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 1, path: "test")
197+
let network = AlamofireNetwork(credentials: nil, ensuresSessionManagerIsInitialized: false)
198+
199+
// When
200+
async let request1 = network.responseDataAndHeaders(for: request)
201+
async let request2 = network.responseDataAndHeaders(for: request)
202+
async let request3 = network.responseDataAndHeaders(for: request)
203+
204+
do {
205+
_ = try await [request1, request2, request3]
206+
XCTFail("Requests should fail with sessionDeinitialized error")
207+
} catch Alamofire.AFError.sessionDeinitialized {
208+
// Then
209+
XCTAssertTrue(true)
210+
} catch {
211+
XCTFail("Requests should fail with sessionDeinitialized error")
212+
}
213+
}
170214
}
171215

172216
private extension AlamofireNetworkTests {

0 commit comments

Comments
 (0)