Makes rescheduling work!

multistore
Laurent 2 years ago
parent b8579f1fd3
commit cab41af854
  1. 4
      LeStorage.xcodeproj/project.pbxproj
  2. 13
      LeStorage/ApiCall.swift
  3. 2
      LeStorage/ModelObject.swift
  4. 47
      LeStorage/Services.swift
  5. 92
      LeStorage/Store.swift
  6. 89
      LeStorage/StoredCollection.swift
  7. 20
      README.md

@ -21,6 +21,7 @@
C4A47D672B6FF83A00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D662B6FF83A00ADC637 /* ApiCall.swift */; };
C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */; };
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D6C2B71364600ADC637 /* ModelObject.swift */; };
C4A47D6F2B7154F600ADC637 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = C4A47D6E2B7154F600ADC637 /* README.md */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -49,6 +50,7 @@
C4A47D662B6FF83A00ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; };
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Extension.swift"; sourceTree = "<group>"; };
C4A47D6C2B71364600ADC637 /* ModelObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelObject.swift; sourceTree = "<group>"; };
C4A47D6E2B7154F600ADC637 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -91,6 +93,7 @@
C425D4362B6D24E1002A7B48 /* LeStorage */ = {
isa = PBXGroup;
children = (
C4A47D6E2B7154F600ADC637 /* README.md */,
C425D4372B6D24E1002A7B48 /* LeStorage.h */,
C425D4382B6D24E1002A7B48 /* LeStorage.docc */,
C4A47D602B6D3C1300ADC637 /* Services.swift */,
@ -215,6 +218,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C4A47D6F2B7154F600ADC637 /* README.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

@ -14,33 +14,38 @@ protocol SomeCall : Storable {
class ApiCall<T : Storable> : ModelObject, Storable, SomeCall {
static func resourceName() -> String { return "apicalls" }
static func resourceName() -> String { return "apicalls_" + T.resourceName() }
var id: String = Store.randomId()
/// The http URL of the call
var url: String
/// The HTTP method of the call: post...
var method: String
/// The id of the underlying data
var dataId: String
/// The content of the call
var body: Data
/// The number of times the call has been executed
var attemptsCount: Int = 1
var attemptsCount: Int = 0
/// The date of the last execution
var lastAttemptDate: Date = Date()
init(url: String, method: String, body: Data) {
init(url: String, method: String, dataId: String, body: Data) {
self.url = url
self.method = method
self.dataId = dataId
self.body = body
}
/// Executes the api call
func execute() throws {
Task {
self.lastAttemptDate = Date()
try await Store.main.execute(apiCall: self)
}
}

@ -9,8 +9,6 @@ import Foundation
open class ModelObject {
public var id: String = Store.randomId()
public init() { }
open func deleteDependencies() throws {

@ -1,8 +1,8 @@
//
// ChatService.swift
// Chat
// Services.swift
// LeStorage
//
// Created by Laurent Morvillier on 11/12/2023.
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
@ -37,10 +37,11 @@ class Services {
// MARK: - Base
fileprivate func runRequest<T : Decodable>(_ request: URLRequest, apiCallId: String? = nil) async throws -> T {
Logger.log("Run request...")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
Logger.log("request ended with status code = \(statusCode)")
switch statusCode {
case 200...300:
if let apiCallId,
@ -48,12 +49,13 @@ class Services {
try Store.main.deleteApiCallById(apiCallId, collectionName: collectionName)
}
default:
Store.main.startCallsRescheduling()
if let apiCallId, let type = (T.self as? any Storable.Type) {
try Store.main.startCallsRescheduling(apiCallId: apiCallId, type: type)
}
}
Logger.log("status code = \(statusCode)")
}
Logger.log("response = \(String(data: task.0, encoding: .utf8))")
Logger.log("response = \(String(describing: String(data: task.0, encoding: .utf8)))")
return try jsonDecoder.decode(T.self, from: task.0)
}
@ -61,17 +63,17 @@ class Services {
return try self._baseRequest(servicePath: servicePath, method: .get)
}
fileprivate func postRequest(servicePath: String) throws -> URLRequest {
return try self._baseRequest(servicePath: servicePath, method: .post)
}
fileprivate func putRequest(servicePath: String) throws -> URLRequest {
return try self._baseRequest(servicePath: servicePath, method: .put)
}
fileprivate func deleteRequest(servicePath: String) throws -> URLRequest {
return try self._baseRequest(servicePath: servicePath, method: .delete)
}
// fileprivate func postRequest(servicePath: String) throws -> URLRequest {
// return try self._baseRequest(servicePath: servicePath, method: .post)
// }
//
// fileprivate func putRequest(servicePath: String) throws -> URLRequest {
// return try self._baseRequest(servicePath: servicePath, method: .put)
// }
//
// fileprivate func deleteRequest(servicePath: String) throws -> URLRequest {
// return try self._baseRequest(servicePath: servicePath, method: .delete)
// }
fileprivate func _baseRequest(servicePath: String, method: Method) throws -> URLRequest {
let urlString = _baseURL + servicePath
@ -109,11 +111,15 @@ class Services {
fileprivate func _createCall<T : Storable>(method: Method, instance: T) throws -> ApiCall<T> {
let data = try instance.jsonData()
let url = self._baseURL + T.resourceName() + "/"
return ApiCall(url: url, method: method.rawValue, body: data)
return ApiCall(url: url, method: method.rawValue, dataId: String(instance.id), body: data)
}
func runApiCall<T : Storable>(_ apiCall: ApiCall<T>) async throws -> T {
apiCall.lastAttemptDate = Date()
apiCall.attemptsCount += 1
try Store.main.registerApiCall(apiCall)
let request = try self._request(from: apiCall)
return try await self.runRequest(request, apiCallId: apiCall.id)
}
@ -124,8 +130,9 @@ class Services {
}
var request = URLRequest(url: url)
request.httpMethod = apiCall.method
request.httpBody = apiCall.body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return request
}
}

@ -34,20 +34,22 @@ public class Store {
fileprivate var _collections: [String : any SomeCollection] = [:]
fileprivate var _apiCallsCollections: [String : any SomeCollection] = [:]
fileprivate var _apiCallsTimer: Timer? = nil
fileprivate var _reschedulingCount: Int = 0
// fileprivate var _apiCallTimers: [String: Timer] = [:]
public init() { }
public func registerCollection<T : Storable>(synchronized: Bool) -> StoredCollection<T> {
// register collection
let collection = StoredCollection<T>(synchronized: synchronized, store: self)
let collection = StoredCollection<T>(synchronized: synchronized, store: self, loadCompletion: { _ in
})
self._collections[T.resourceName()] = collection
if synchronized { // register additional collection for api calls
let apiCallCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: self)
let apiCallCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: self, loadCompletion: { apiCallCollection in
self._reloadTimers(collection: apiCallCollection)
})
self._apiCallsCollections[T.resourceName()] = apiCallCollection
}
@ -87,6 +89,12 @@ public class Store {
// MARK: - Api call rescheduling
fileprivate func _reloadTimers<T : Storable>(collection: StoredCollection<ApiCall<T>>) {
for apiCall in collection {
self.startCallsRescheduling(apiCall: apiCall)
}
}
func apiCallCollection<T : Storable>() throws -> StoredCollection<ApiCall<T>> {
if let apiCallCollection = self._apiCallsCollections[T.resourceName()] as? StoredCollection<ApiCall<T>> {
return apiCallCollection
@ -96,15 +104,25 @@ public class Store {
func registerApiCall<T : Storable>(_ apiCall: ApiCall<T>) throws {
let collection: StoredCollection<ApiCall<T>> = try self.apiCallCollection()
collection.addOrUpdate(instance: apiCall)
}
func deleteApiCallById<T : Storable> (_ id: String, type: T.Type) throws {
let collection: StoredCollection<ApiCall<T>> = try self.apiCallCollection()
try collection.deleteById(id)
if let existingDataCall = collection.first(where: { $0.dataId == apiCall.dataId }) {
switch apiCall.method {
case Method.put.rawValue:
existingDataCall.body = apiCall.body
collection.addOrUpdate(instance: existingDataCall)
case Method.delete.rawValue:
try self.deleteApiCallById(existingDataCall.id, collectionName: T.resourceName())
default:
collection.addOrUpdate(instance: apiCall) // rewrite new attempt values
}
} else {
collection.addOrUpdate(instance: apiCall)
}
}
func deleteApiCallById(_ id: String, collectionName: String) throws {
if let collection = self._apiCallsCollections[collectionName] {
try collection.deleteById(id)
return
@ -112,43 +130,29 @@ public class Store {
throw StoreError.collectionNotRegistered(type: collectionName)
}
func deleteApiCall<T : Storable> (_ apiCall: ApiCall<T>) throws {
let collection: StoredCollection<ApiCall<T>> = try self.apiCallCollection()
try collection.delete(instance: apiCall)
}
func startCallsRescheduling() {
func startCallsRescheduling<T : Storable>(apiCall: ApiCall<T>) {
let delay = pow(2, 0 + apiCall.attemptsCount)
let seconds = NSDecimalNumber(decimal: delay).intValue
Logger.log("Rerun request in \(seconds) seconds...")
self._reschedulingCount += 1
DispatchQueue(label: "queue.scheduling", qos: .utility)
.asyncAfter(deadline: .now() + .seconds(seconds)) {
Logger.log("Try to execute api call...")
Task {
do {
_ = try await self._executeApiCall(apiCall)
} catch {
Logger.error(error)
}
}
}
let delay = pow(2, 1 + self._reschedulingCount)
let seconds = NSDecimalNumber(decimal: delay).doubleValue
self._apiCallsTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { timer in
self._executeApiCalls()
})
}
fileprivate func _executeApiCalls() {
DispatchQueue(label: "lestorage.queue.network").async {
do {
for collection in self._apiCallsCollections.values {
if let apiCalls = collection.allItems() as? [any SomeCall] {
let sortedCalls = apiCalls.sorted(keyPath: \.lastAttemptDate, ascending: true)
for apiCall in sortedCalls {
try apiCall.execute()
}
} else {
Logger.w("_apiCallsCollections item not castable to [any SomeCall] ")
}
}
} catch {
Logger.error(error)
}
func startCallsRescheduling<T : Storable>(apiCallId: String, type: T.Type) throws {
let apiCallCollection: StoredCollection<ApiCall<T>> = try self.apiCallCollection()
if let apiCall = apiCallCollection.findById(apiCallId) {
self.startCallsRescheduling(apiCall: apiCall)
}
}

@ -12,7 +12,12 @@ protocol SomeCollection : Identifiable {
func deleteById(_ id: String) throws
}
public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollection, ObservableObject {
extension Notification.Name {
public static let CollectionDidLoad: Notification.Name = Notification.Name.init("notification.collectionDidLoad")
public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange")
}
public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollection {
/// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL
let synchronized: Bool
@ -23,25 +28,65 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec
/// The reference to the Store
fileprivate var _store: Store
fileprivate var loadCompletion: (StoredCollection<T>) -> ()
/// Returns the default filename for the collection
fileprivate var _fileName: String {
return T.resourceName() + ".json"
}
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false {
didSet {
if self._hasChanged == true {
self._scheduleWrite()
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidChange, object: self)
}
self._hasChanged = false
}
}
}
init(synchronized: Bool, store: Store) {
init(synchronized: Bool, store: Store, loadCompletion: @escaping (StoredCollection<T>) -> ()) {
self.synchronized = synchronized
self._store = store
self.loadCompletion = loadCompletion
self._load()
}
/// Returns the default filename for the collection
fileprivate var _fileName: String {
return T.resourceName() + ".json"
/// Launches a load operation if the file exists
fileprivate func _load() {
do {
let url = try FileUtils.directoryURLForFileName(self._fileName)
if FileManager.default.fileExists(atPath: url.path()) {
self._loadAsync()
}
} catch {
Logger.log(error)
}
}
/// Loads asynchronously into memory the objects contained inside the collection file
fileprivate func _loadAsync() {
DispatchQueue(label: "lestorage.queue.read", qos: .background).async {
do {
let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName)
if let decoded: [T] = try jsonString.decodeArray() {
DispatchQueue.main.sync {
Logger.log("loaded \(self._fileName) with \(decoded.count) items")
self.items = decoded
self.loadCompletion(self)
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
}
}
} catch {
Logger.error(error) // TODO how to notify the main project
}
}
}
/// Adds or updates the provided instance inside the collection
@ -120,44 +165,10 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec
}
}
/// Launches a load operation if the file exists
fileprivate func _load() {
do {
let url = try FileUtils.directoryURLForFileName(self._fileName)
if FileManager.default.fileExists(atPath: url.path()) {
self._loadAsync()
}
} catch {
Logger.log(error)
}
}
/// Loads asynchronously into memory the objects contained inside the collection file
fileprivate func _loadAsync() {
DispatchQueue(label: "lestorage.queue.read", qos: .background).async {
do {
let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName)
if let decoded: [T] = try jsonString.decodeArray() {
DispatchQueue.main.sync {
Logger.log("loaded \(self._fileName) with \(decoded.count) items")
self.items = decoded
}
}
} catch {
Logger.error(error) // TODO how to notify the main project
}
}
}
// MARK: - Synchronization
/// Sends an insert api call for the provided [instance]
fileprivate func _sendInsertionIfNecessary(_ instance: T) {
Logger.log("_sendInsertionIfNecessary...")
guard self.synchronized else {
return
}

@ -1,2 +1,22 @@
# LeStorage
**1. RULES**
- To store data in the json format inside files,
you first need to create some model class, for example `Car`
- You make `Car` inherit `ModelObject`, and implement `Storable`
- To get the `StoredCollection` that manages all your cars and stores them for you, you do
`Store.main.registerCollection()` to retrieve a collection.
**2. Sync**
- When registering your collection, you can choose to have it synchronized. To do that:
- Set `Store.main.synchronizationApiURL`
- Pass `synchronized: true` when registering the collection
- For each of your `ModelObject`, make sure that `resourceName()` returns the resource path of the endpoint, for example "cars"
- Synchronization is expected to be done with a rest_framework API on a django server
- On Django, when using cascading delete foreign, you'll want to avoid sending useless delete API calls to django, so override the `deleteDependencies` function of your ModelObject and call `Store.main.deleteDependencies` for the objects you also want to delete to reproduce the cascading effect
- On your Django serializers, you want to define the following on your foreign keys to avoid having a URL instead of just the id:
`car_id = serializers.PrimaryKeyRelatedField(queryset=Car.objects.all())`

Loading…
Cancel
Save