Fix nasty bug executing api calls forever

sync2
Laurent 1 year ago
parent e8f2b21563
commit e02b12e8e2
  1. 151
      LeStorage/ApiCallCollection.swift
  2. 16
      LeStorage/StoreCenter.swift

@ -7,15 +7,14 @@
import Foundation import Foundation
protocol SomeCallCollection { protocol SomeCallCollection {
func findCallById(_ id: String) async -> (any SomeCall)? func findCallById(_ id: String) async -> (any SomeCall)?
func deleteById(_ id: String) async func deleteById(_ id: String) async
func hasPendingCalls() async -> Bool func hasPendingCalls() async -> Bool
func contentOfFile() async -> String? func contentOfFile() async -> String?
func reset() async func reset() async
} }
@ -24,19 +23,19 @@ protocol SomeCallCollection {
/// The Api calls are serialized and stored in a JSON file /// The Api calls are serialized and stored in a JSON file
/// Failing Api calls are stored forever and will be executed again later /// Failing Api calls are stored forever and will be executed again later
actor ApiCallCollection<T: Storable>: SomeCallCollection { actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// The list of api calls /// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = [] fileprivate(set) var items: [ApiCall<T>] = []
/// The number of time an execution loop has been called /// The number of time an execution loop has been called
fileprivate var _attemptLoops: Int = 0 fileprivate var _attemptLoops: Int = 0
/// Indicates if the collection is currently retrying ApiCalls /// Indicates if the collection is currently retrying ApiCalls
fileprivate var _isRetryingCalls: Bool = false fileprivate var _isRescheduling: Bool = false
/// The task of waiting and executing ApiCalls /// The task of waiting and executing ApiCalls
fileprivate var _reschedulingTask: Task<Void, any Error>? = nil fileprivate var _reschedulingTask: Task<Void, any Error>? = nil
/// Indicates whether the collection content has changed /// Indicates whether the collection content has changed
/// Initiates a write when true /// Initiates a write when true
fileprivate var _hasChanged: Bool = false { fileprivate var _hasChanged: Bool = false {
@ -47,7 +46,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
} }
} }
/// Starts the JSON file decoding synchronously or asynchronously /// Starts the JSON file decoding synchronously or asynchronously
/// Reschedule Api calls if not empty /// Reschedule Api calls if not empty
func loadFromFile() throws { func loadFromFile() throws {
@ -59,15 +58,15 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
fileprivate func _urlForJSONFile() throws -> URL { fileprivate func _urlForJSONFile() throws -> URL {
return try ApiCall<T>.urlForJSONFile() return try ApiCall<T>.urlForJSONFile()
} }
/// Decodes the json file into the items array /// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws { fileprivate func _decodeJSONFile() throws {
let fileURL = try self._urlForJSONFile() let fileURL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: fileURL.path()) { if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL) let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? [] let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
// Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items") // Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items")
self.items = decoded self.items = decoded
} }
} }
@ -76,17 +75,17 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
fileprivate func _write() { fileprivate func _write() {
let fileName = ApiCall<T>.fileName() let fileName = ApiCall<T>.fileName()
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
// Logger.log("Start write to \(fileName)...") // Logger.log("Start write to \(fileName)...")
do { do {
let jsonString: String = try self.items.jsonString() let jsonString: String = try self.items.jsonString()
try T.writeToStorageDirectory(content: jsonString, fileName: fileName) try T.writeToStorageDirectory(content: jsonString, fileName: fileName)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
// Logger.log("End write") // Logger.log("End write")
} }
} }
/// Adds or update an API call instance /// Adds or update an API call instance
func addOrUpdate(_ instance: ApiCall<T>) { func addOrUpdate(_ instance: ApiCall<T>) {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
@ -96,13 +95,14 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
self._hasChanged = true self._hasChanged = true
} }
/// Deletes an API call by [id] /// Deletes an API call by [id]
func deleteById(_ id: String) { func deleteById(_ id: String) {
self.items.removeAll(where: { $0.id == id }) self.items.removeAll(where: { $0.id == id })
Logger.log("\(T.resourceName()) > Delete by id, count after deletion = \(self.items.count)")
self._hasChanged = true self._hasChanged = true
} }
/// Deletes a call by a data id /// Deletes a call by a data id
func deleteByDataId(_ dataId: String) { func deleteByDataId(_ dataId: String) {
if let apiCallIndex = self.items.firstIndex(where: { $0.dataId == dataId }) { if let apiCallIndex = self.items.firstIndex(where: { $0.dataId == dataId }) {
@ -110,12 +110,12 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
self._hasChanged = true self._hasChanged = true
} }
} }
/// Returns the Api call associated with the provided id /// Returns the Api call associated with the provided id
func findById(_ id: String) -> ApiCall<T>? { func findById(_ id: String) -> ApiCall<T>? {
return self.items.first(where: { $0.id == id }) return self.items.first(where: { $0.id == id })
} }
/// Returns the Api call associated with the provided id /// Returns the Api call associated with the provided id
func findCallById(_ id: String) async -> (any SomeCall)? { func findCallById(_ id: String) async -> (any SomeCall)? {
return self.findById(id) return self.findById(id)
@ -125,7 +125,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
func reset() { func reset() {
self._reschedulingTask?.cancel() self._reschedulingTask?.cancel()
self.items.removeAll() self.items.removeAll()
do { do {
let url: URL = try self._urlForJSONFile() let url: URL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: url.path()) { if FileManager.default.fileExists(atPath: url.path()) {
@ -136,48 +136,60 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
} }
/// Reschedule the execution of API calls fileprivate func _wait() async {
fileprivate func _rescheduleApiCalls() {
let delay = pow(2, self._attemptLoops)
let seconds = NSDecimalNumber(decimal: delay).intValue
Logger.log("\(T.resourceName()): wait for \(seconds) sec")
do {
try await Task.sleep(until: .now + .seconds(seconds))
} catch {
Logger.error(error)
}
}
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
Task {
await self._rescheduleApiCalls()
}
}
/// Reschedule the execution of API calls
fileprivate func _rescheduleApiCalls() async {
guard !self._isRescheduling else { return }
self._isRescheduling = true
guard self.items.isNotEmpty else { guard self.items.isNotEmpty else {
return return
} }
self._isRetryingCalls = true
self._attemptLoops += 1 self._attemptLoops += 1
self._reschedulingTask = Task {
let delay = pow(2, self._attemptLoops)
let seconds = NSDecimalNumber(decimal: delay).intValue
Logger.log("\(T.resourceName()): wait for \(seconds) sec")
try await Task.sleep(until: .now + .seconds(seconds))
let apiCallsCopy = self.items
for apiCall in apiCallsCopy {
apiCall.attemptsCount += 1
apiCall.lastAttemptDate = Date()
do {
let _ = try await self._executeApiCall(apiCall)
} catch {
Logger.error(error)
}
}
self._hasChanged = true await self._wait()
let apiCallsCopy = self.items
for (index, apiCall) in apiCallsCopy.enumerated() {
apiCall.attemptsCount += 1
apiCall.lastAttemptDate = Date()
if self.items.isEmpty { do {
self._isRetryingCalls = false let _ = try await self._executeApiCall(apiCall)
} else { } catch {
self._rescheduleApiCalls() Logger.error(error)
} }
} }
self._isRescheduling = false
if self.items.isNotEmpty {
await self._rescheduleApiCalls()
}
} }
// MARK: - Synchronization // MARK: - Synchronization
/// Returns an APICall instance for the Storable [instance] and an HTTP [method] /// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// The method updates existing calls or creates a new one /// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? { fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
@ -185,13 +197,13 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
if let existingCall = self.items.first(where: { $0.dataId == instance.stringId }) { if let existingCall = self.items.first(where: { $0.dataId == instance.stringId }) {
switch method { switch method {
case .delete: case .delete:
self.deleteById(existingCall.id) // delete the existing call as we don't need it self.deleteById(existingCall.id) // delete the existing call as we don't need it
if existingCall.method == HTTPMethod.post { if existingCall.method == HTTPMethod.post {
return nil // if the post has not been done, we can just stop here return nil // if the post has not been done, we can just stop here
} else { } else {
return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete
} }
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
existingCall.body = try instance.jsonString() existingCall.body = try instance.jsonString()
return existingCall return existingCall
} }
@ -199,7 +211,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
return try self._createCall(instance, method: method) return try self._createCall(instance, method: method)
} }
} }
/// Creates an API call for the Storable [instance] and an HTTP [method] /// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> { fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString() let jsonString = try instance.jsonString()
@ -212,14 +224,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
apiCall.attemptsCount += 1 apiCall.attemptsCount += 1
self.addOrUpdate(apiCall) self.addOrUpdate(apiCall)
} }
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if !self._isRetryingCalls {
self._rescheduleApiCalls()
}
}
/// Sends an insert api call for the provided [instance] /// Sends an insert api call for the provided [instance]
func sendInsertion(_ instance: T) async throws -> T? { func sendInsertion(_ instance: T) async throws -> T? {
do { do {
@ -229,9 +234,9 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
Logger.error(error) Logger.error(error)
} }
return nil return nil
} }
/// Sends an update api call for the provided [instance] /// Sends an update api call for the provided [instance]
func sendUpdate(_ instance: T) async throws -> T? { func sendUpdate(_ instance: T) async throws -> T? {
do { do {
@ -242,7 +247,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
return nil return nil
} }
/// Sends an delete api call for the provided [instance] /// Sends an delete api call for the provided [instance]
func sendDeletion(_ instance: T) async throws -> T? { func sendDeletion(_ instance: T) async throws -> T? {
do { do {
@ -253,7 +258,7 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
return nil return nil
} }
/// Initiates the process of sending the data with the server /// Initiates the process of sending the data with the server
fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws -> T? { fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws -> T? {
if let apiCall = try self._callForInstance(instance, method: method) { if let apiCall = try self._callForInstance(instance, method: method) {
@ -263,13 +268,13 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
return nil return nil
} }
} }
/// Executes an API call /// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert /// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws -> T { fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws -> T {
return try await StoreCenter.main.execute(apiCall: apiCall) return try await StoreCenter.main.execute(apiCall: apiCall)
} }
/// Returns the content of the API call file as a String /// Returns the content of the API call file as a String
func contentOfFile() -> String? { func contentOfFile() -> String? {
guard let fileURL = try? self._urlForJSONFile() else { return nil } guard let fileURL = try? self._urlForJSONFile() else { return nil }
@ -278,10 +283,10 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
return nil return nil
} }
/// Returns if the API call collection is not empty /// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool { func hasPendingCalls() -> Bool {
return self.items.isNotEmpty return self.items.isNotEmpty
} }
} }

@ -154,13 +154,15 @@ public class StoreCenter {
/// Instantiates and loads an ApiCallCollection with the provided type /// Instantiates and loads an ApiCallCollection with the provided type
public func loadApiCallCollection<T: Storable>(type: T.Type) { public func loadApiCallCollection<T: Storable>(type: T.Type) {
let apiCallCollection = ApiCallCollection<T>() if self._apiCallCollections[T.resourceName()] == nil {
self._apiCallCollections[T.resourceName()] = apiCallCollection let apiCallCollection = ApiCallCollection<T>()
Task { self._apiCallCollections[T.resourceName()] = apiCallCollection
do { Task {
try await apiCallCollection.loadFromFile() do {
} catch { try await apiCallCollection.loadFromFile()
Logger.error(error) } catch {
Logger.error(error)
}
} }
} }
} }

Loading…
Cancel
Save