From 52e490e7e590b2adff9451e7cf9940f87e583d1d Mon Sep 17 00:00:00 2001 From: Fabrizio Brancati Date: Thu, 18 Jul 2024 15:35:31 +0200 Subject: [PATCH] Add AsyncConcurrentOperation --- Sources/Queuer/AsyncConcurrentOperation.swift | 187 ++++++++++++++++++ Sources/Queuer/Queuer.swift | 41 ++++ .../AsyncConcurrentOperationTests.swift | 54 +++++ 3 files changed, 282 insertions(+) create mode 100644 Sources/Queuer/AsyncConcurrentOperation.swift create mode 100644 Tests/QueuerTests/AsyncConcurrentOperationTests.swift diff --git a/Sources/Queuer/AsyncConcurrentOperation.swift b/Sources/Queuer/AsyncConcurrentOperation.swift new file mode 100644 index 0000000..fd6ac8d --- /dev/null +++ b/Sources/Queuer/AsyncConcurrentOperation.swift @@ -0,0 +1,187 @@ +// +// ConcurrentOperation.swift +// Queuer +// +// MIT License +// +// Copyright (c) 2017 - 2024 Fabrizio Brancati +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +/// It allows asynchronous tasks, has a pause and resume states, +/// can be easily added to a queue and can be created with a block. +@available(macOS 10.15, *) +open class AsyncConcurrentOperation: Operation, @unchecked Sendable { + /// `Operation`'s execution block. + public var executionBlock: ((_ operation: AsyncConcurrentOperation) async -> Void)? + + /// Set if the `Operation` is executing. + private var _executing = false { + willSet { + willChangeValue(forKey: "isExecuting") + } + didSet { + didChangeValue(forKey: "isExecuting") + } + } + + /// Set if the `Operation` is executing. + override open var isExecuting: Bool { + return _executing + } + + /// Set if the `Operation` is finished. + private var _finished = false { + willSet { + willChangeValue(forKey: "isFinished") + } + didSet { + didChangeValue(forKey: "isFinished") + } + } + + /// Set if the `Operation` is finished. + override open var isFinished: Bool { + return _finished + } + + /// You should use `success` if you want the retry feature. + /// Set it to `false` if the `Operation` has failed, otherwise `true`. + /// Default is `true` to avoid retries. + open var success = true + + /// Maximum allowed retries. + /// Default are 3 retries. + open var maximumRetries = 3 + + /// Current retry attempt. + open private(set) var currentAttempt = 1 + + /// Allows for manual retries. + /// If set to `true`, `retry()` function must be manually called. + /// Default is `false` to automatically retry. + open var manualRetry = false + + /// Specify if the `Operation` should retry another time. + internal var shouldRetry = true + + /// Manually control the `finish(success:)` call of the `Operation`. + /// If set to `true` it is the developer's responsibility to call the `finish(success:)` method, + /// either by passing `false` or `true` to the function. + open var manualFinish = false + + /// Keep track of the last executed attempt. + /// This avoids running the `executionBlock` more than once per retry. + private var lastExecutedAttempt = 0 + + /// Creates the `Operation` with an execution block. + /// + /// - Parameters: + /// - name: Operation name. + /// - executionBlock: Execution block. + public init(name: String? = nil, executionBlock: ((_ operation: AsyncConcurrentOperation) async -> Void)? = nil) { + super.init() + + self.name = name + self.executionBlock = executionBlock + } + + /// Start the `Operation`. + override open func start() { + Task { + _executing = true + await execute() + } + } + + /// Retry function. + /// It only works if `manualRetry` property has been set to `true`. + open func retry() async { + if manualRetry, shouldRetry, let executionBlock { + await executionBlock(self) + + if !manualFinish { + finish(success: success) + } + } + } + + /// Execute the `Operation`. + /// If `executionBlock` is set, it will be executed. + open func execute() async { + if let executionBlock { + while shouldRetry, !manualRetry { + if lastExecutedAttempt != currentAttempt { + await executionBlock(self) + lastExecutedAttempt = currentAttempt + } + + if !manualFinish { + finish(success: success) + } + } + + await retry() + } + } + + /// Notify the completion of asynchronous task and hence the completion of the `Operation`. + /// Must be called when the `Operation` is finished. + /// + /// - Parameter success: Set it to `false` if the `Operation` has failed, otherwise `true`. + /// Default is `true`. + open func finish(success: Bool = true) { + if success || currentAttempt >= maximumRetries { + _executing = false + _finished = true + shouldRetry = false + } else { + currentAttempt += 1 + shouldRetry = true + } + + self.success = success + } + + /// Pause the current `Operation`, if it's supported. + /// Must be overridden by a subclass to get a custom pause action. + open func pause() {} + + /// Resume the current `Operation`, if it's supported. + /// Must be overridden by a subclass to get a custom resume action. + open func resume() {} +} + +/// `ConcurrentOperation` extension with queue handling. +@available(macOS 10.15, *) +public extension AsyncConcurrentOperation { + /// Adds the `Operation` to `shared` Queuer. + func addToSharedQueuer() { + Queuer.shared.addOperation(self) + } + + /// Adds the `Operation` to the custom queue. + /// + /// - Parameter queue: Custom queue where the `Operation` will be added. + func addToQueue(_ queue: Queuer) { + queue.addOperation(self) + } +} diff --git a/Sources/Queuer/Queuer.swift b/Sources/Queuer/Queuer.swift index aed3a96..e132d2f 100644 --- a/Sources/Queuer/Queuer.swift +++ b/Sources/Queuer/Queuer.swift @@ -161,6 +161,33 @@ public extension Queuer { addCompletionHandler(completionHandler) } + /// Add an Array of chained `Operation`s. + /// + /// Example: + /// + /// [A, B, C] = A -> B -> C -> completionHandler + /// + /// - Parameters: + /// - operations: `Operation`s Array. + /// - completionHandler: Completion block to be executed when all `Operation`s + /// are finished. + @available(macOS 10.15, *) + func addChainedAsyncOperations(_ operations: [Operation], completionHandler: (@Sendable () async -> Void)? = nil) { + for (index, operation) in operations.enumerated() { + if index > 0 { + operation.addDependency(operations[index - 1]) + } + + addOperation(operation) + } + + guard let completionHandler = completionHandler else { + return + } + + addAsyncCompletionHandler(completionHandler) + } + /// Add an Array of chained `Operation`s. /// /// Example: @@ -185,4 +212,18 @@ public extension Queuer { } addOperation(completionOperation) } + + /// Add a completion block to the queue. + /// + /// - Parameter completionHandler: Completion handler to be executed as last `Operation`. + @available(macOS 10.15, *) + func addAsyncCompletionHandler(_ completionHandler: @Sendable @escaping () async -> Void) { + let completionOperation = AsyncConcurrentOperation { operation in + await completionHandler() + } + if let lastOperation = operations.last { + completionOperation.addDependency(lastOperation) + } + addOperation(completionOperation) + } } diff --git a/Tests/QueuerTests/AsyncConcurrentOperationTests.swift b/Tests/QueuerTests/AsyncConcurrentOperationTests.swift new file mode 100644 index 0000000..c2b1fa4 --- /dev/null +++ b/Tests/QueuerTests/AsyncConcurrentOperationTests.swift @@ -0,0 +1,54 @@ +// +// AsyncConcurrentOperationTests.swift +// Queuer +// +// MIT License +// +// Copyright (c) 2017 - 2024 Fabrizio Brancati +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Queuer +import XCTest + +final class AsyncConcurrentOperationTests: XCTestCase { + func testAsyncChainedRetry() async { + let queue = Queuer(name: "ConcurrentOperationTestChainedRetry") + let testExpectation = expectation(description: "Chained Retry") + let order = Order() + + let concurrentOperation1 = AsyncConcurrentOperation { operation in + try? await Task.sleep(for: .seconds(1)) + await order.append(0) + operation.success = false + } + let concurrentOperation2 = AsyncConcurrentOperation { operation in + await order.append(1) + operation.success = false + } + queue.addChainedAsyncOperations([concurrentOperation1, concurrentOperation2]) { + await order.append(2) + testExpectation.fulfill() + } + + await fulfillment(of: [testExpectation], timeout: 10) + let finalOrder = await order.order + XCTAssertEqual(finalOrder, [0, 0, 0, 1, 1, 1, 2]) + } +}