diff --git a/README.md b/README.md index 09206d0..f62c3a1 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,32 @@ In more complex cases, when `Signal`-trapping-based shutdown is not appropriate, `shutdown` is an asynchronous operation. Errors will be logged and bubbled up to the provided completion handler. +### Stateful handlers + +In some cases it is useful to have the Start handlers return a state that can be passed on to the Shutdown handlers for shutdown. +For example, when establishing some sort of a connection that needs to be closed at shutdown. + +```swift +struct Foo { + func start() throws -> Connection { + return ... + } + + func shutdown(state: Connection) throws { + ... + } +} +``` + +```swift +let foo = ... +lifecycle.registerStateful( + label: "foo", + start: .sync(foo.start), + shutdown: .sync(foo.shutdown) +) +``` + ### Complex Systems and Nesting of Subsystems In larger Applications (Services) `ComponentLifecycle` can be used to manage the lifecycle of subsystems, such that `ServiceLifecycle` can start and shutdown `ComponentLifecycle`s. diff --git a/Sources/Lifecycle/Lifecycle.swift b/Sources/Lifecycle/Lifecycle.swift index 0cdf9b7..5fd973d 100644 --- a/Sources/Lifecycle/Lifecycle.swift +++ b/Sources/Lifecycle/Lifecycle.swift @@ -42,25 +42,25 @@ extension LifecycleTask { /// Supported startup and shutdown method styles public struct LifecycleHandler { + @available(*, deprecated) public typealias Callback = (@escaping (Error?) -> Void) -> Void - private let body: Callback? + private let underlying: ((@escaping (Error?) -> Void) -> Void)? /// Initialize a `LifecycleHandler` based on a completion handler. /// /// - parameters: - /// - callback: the underlying completion handler - /// - noop: the underlying completion handler is a no-op - public init(_ callback: Callback?) { - self.body = callback + /// - handler: the underlying completion handler + public init(_ handler: ((@escaping (Error?) -> Void) -> Void)?) { + self.underlying = handler } /// Asynchronous `LifecycleHandler` based on a completion handler. /// /// - parameters: - /// - callback: the underlying completion handler - public static func async(_ callback: @escaping Callback) -> LifecycleHandler { - return LifecycleHandler(callback) + /// - handler: the underlying async handler + public static func async(_ handler: @escaping (@escaping (Error?) -> Void) -> Void) -> LifecycleHandler { + return LifecycleHandler(handler) } /// Asynchronous `LifecycleHandler` based on a blocking, throwing function. @@ -83,15 +83,97 @@ public struct LifecycleHandler { return LifecycleHandler(nil) } - internal func run(_ callback: @escaping (Error?) -> Void) { - let body = self.body ?? { callback in + internal func run(_ completionHandler: @escaping (Error?) -> Void) { + let body = self.underlying ?? { callback in callback(nil) } - body(callback) + body(completionHandler) } internal var noop: Bool { - return self.body == nil + return self.underlying == nil + } +} + +// MARK: - Stateful Lifecycle Handlers + +/// LifecycleHandler for starting stateful tasks. The state can then be fed into a LifecycleShutdownHandler +public struct LifecycleStartHandler { + private let underlying: (@escaping (Result) -> Void) -> Void + + /// Initialize a `LifecycleHandler` based on a completion handler. + /// + /// - parameters: + /// - callback: the underlying completion handler + public init(_ handler: @escaping (@escaping (Result) -> Void) -> Void) { + self.underlying = handler + } + + /// Asynchronous `LifecycleStartHandler` based on a completion handler. + /// + /// - parameters: + /// - handler: the underlying async handler + public static func async(_ handler: @escaping (@escaping (Result) -> Void) -> Void) -> LifecycleStartHandler { + return LifecycleStartHandler(handler) + } + + /// Synchronous `LifecycleStartHandler` based on a blocking, throwing function. + /// + /// - parameters: + /// - body: the underlying function + public static func sync(_ body: @escaping () throws -> State) -> LifecycleStartHandler { + return LifecycleStartHandler { completionHandler in + do { + let state = try body() + completionHandler(.success(state)) + } catch { + completionHandler(.failure(error)) + } + } + } + + internal func run(_ completionHandler: @escaping (Result) -> Void) { + self.underlying(completionHandler) + } +} + +/// LifecycleHandler for shutting down stateful tasks. The state comes from a LifecycleStartHandler +public struct LifecycleShutdownHandler { + private let underlying: (State, @escaping (Error?) -> Void) -> Void + + /// Initialize a `LifecycleShutdownHandler` based on a completion handler. + /// + /// - parameters: + /// - handler: the underlying completion handler + public init(_ handler: @escaping (State, @escaping (Error?) -> Void) -> Void) { + self.underlying = handler + } + + /// Asynchronous `LifecycleShutdownHandler` based on a completion handler. + /// + /// - parameters: + /// - handler: the underlying async handler + public static func async(_ handler: @escaping (State, @escaping (Error?) -> Void) -> Void) -> LifecycleShutdownHandler { + return LifecycleShutdownHandler(handler) + } + + /// Asynchronous `LifecycleShutdownHandler` based on a blocking, throwing function. + /// + /// - parameters: + /// - body: the underlying function + public static func sync(_ body: @escaping (State) throws -> Void) -> LifecycleShutdownHandler { + return LifecycleShutdownHandler { state, completionHandler in + do { + try body(state) + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } + + internal func run(state: State, _ completionHandler: @escaping (Error?) -> Void) { + self.underlying(state, completionHandler) } } @@ -550,8 +632,19 @@ extension LifecycleTasksContainer { public func registerShutdown(label: String, _ handler: LifecycleHandler) { self.register(label: label, start: .none, shutdown: handler) } + + /// Add a stateful `LifecycleTask` to a `LifecycleTasks` collection. + /// + /// - parameters: + /// - label: label of the item, useful for debugging. + /// - start: `LifecycleStartHandler` to perform the startup and return the state. + /// - shutdown: `LifecycleShutdownHandler` to perform the shutdown given the state. + public func registerStateful(label: String, start: LifecycleStartHandler, shutdown: LifecycleShutdownHandler) { + self.register(StatefulLifecycleTask(label: label, start: start, shutdown: shutdown)) + } } +// internal for testing internal struct _LifecycleTask: LifecycleTask { let label: String let shutdownIfNotStarted: Bool @@ -573,3 +666,43 @@ internal struct _LifecycleTask: LifecycleTask { self.shutdown.run(callback) } } + +// internal for testing +internal class StatefulLifecycleTask: LifecycleTask { + let label: String + let shutdownIfNotStarted: Bool = false + let start: LifecycleStartHandler + let shutdown: LifecycleShutdownHandler + + let stateLock = Lock() + var state: State? + + init(label: String, start: LifecycleStartHandler, shutdown: LifecycleShutdownHandler) { + self.label = label + self.start = start + self.shutdown = shutdown + } + + func start(_ callback: @escaping (Error?) -> Void) { + self.start.run { result in + switch result { + case .failure(let error): + callback(error) + case .success(let state): + self.stateLock.withLock { + self.state = state + } + callback(nil) + } + } + } + + func shutdown(_ callback: @escaping (Error?) -> Void) { + guard let state = (self.stateLock.withLock { self.state }) else { + return callback(UnknownState()) + } + self.shutdown.run(state: state, callback) + } + + struct UnknownState: Error {} +} diff --git a/Sources/LifecycleNIOCompat/Bridge.swift b/Sources/LifecycleNIOCompat/Bridge.swift index 57001a7..2c66fcf 100644 --- a/Sources/LifecycleNIOCompat/Bridge.swift +++ b/Sources/LifecycleNIOCompat/Bridge.swift @@ -16,7 +16,7 @@ import Lifecycle import NIO extension LifecycleHandler { - /// Asynchronous `Lifecycle.Handler` based on an `EventLoopFuture`. + /// Asynchronous `LifecycleHandler` based on an `EventLoopFuture`. /// /// - parameters: /// - future: function returning the underlying `EventLoopFuture` @@ -32,8 +32,10 @@ extension LifecycleHandler { } } } +} - /// `Lifecycle.Handler` that cancels a `RepeatedTask`. +extension LifecycleHandler { + /// `LifecycleHandler` that cancels a `RepeatedTask`. /// /// - parameters: /// - task: `RepeatedTask` to be cancelled @@ -47,6 +49,39 @@ extension LifecycleHandler { } } +extension LifecycleStartHandler { + /// Asynchronous `LifecycleStartHandler` based on an `EventLoopFuture`. + /// + /// - parameters: + /// - future: function returning the underlying `EventLoopFuture` + public static func eventLoopFuture(_ future: @escaping () -> EventLoopFuture) -> LifecycleStartHandler { + return LifecycleStartHandler { callback in + future().whenComplete { result in + callback(result) + } + } + } +} + +extension LifecycleShutdownHandler { + /// Asynchronous `LifecycleShutdownHandler` based on an `EventLoopFuture`. + /// + /// - parameters: + /// - future: function returning the underlying `EventLoopFuture` + public static func eventLoopFuture(_ future: @escaping (State) -> EventLoopFuture) -> LifecycleShutdownHandler { + return LifecycleShutdownHandler { state, callback in + future(state).whenComplete { result in + switch result { + case .success: + callback(nil) + case .failure(let error): + callback(error) + } + } + } + } +} + extension ComponentLifecycle { /// Starts the provided `LifecycleItem` array. /// Startup is performed in the order of items provided. diff --git a/Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift b/Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift index 383d95c..eb95048 100644 --- a/Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift +++ b/Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift @@ -57,6 +57,15 @@ extension ComponentLifecycleTests { ("testNOOPHandlers", testNOOPHandlers), ("testShutdownOnlyStarted", testShutdownOnlyStarted), ("testShutdownWhenStartFailedIfAsked", testShutdownWhenStartFailedIfAsked), + ("testStatefulSync", testStatefulSync), + ("testStatefulSyncStartError", testStatefulSyncStartError), + ("testStatefulSyncShutdownError", testStatefulSyncShutdownError), + ("testStatefulAsync", testStatefulAsync), + ("testStatefulAsyncStartError", testStatefulAsyncStartError), + ("testStatefulAsyncShutdownError", testStatefulAsyncShutdownError), + ("testStatefulNIO", testStatefulNIO), + ("testStatefulNIOStartFailure", testStatefulNIOStartFailure), + ("testStatefulNIOShutdownFailure", testStatefulNIOShutdownFailure), ] } } diff --git a/Tests/LifecycleTests/ComponentLifecycleTests.swift b/Tests/LifecycleTests/ComponentLifecycleTests.swift index 9dafbf7..c4fb3fe 100644 --- a/Tests/LifecycleTests/ComponentLifecycleTests.swift +++ b/Tests/LifecycleTests/ComponentLifecycleTests.swift @@ -984,4 +984,267 @@ final class ComponentLifecycleTests: XCTestCase { XCTAssertEqual(.success, sempahore.wait(timeout: .now() + 1)) } + + func testStatefulSync() { + class Item { + let id: String = UUID().uuidString + var shutdown: Bool = false + + func start() throws -> String { + return self.id + } + + func shutdown(state: String) throws { + XCTAssertEqual(self.id, state) + self.shutdown = true // not thread safe but okay for this purpose + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .sync(item.start), shutdown: .sync(item.shutdown)) + + lifecycle.start { error in + XCTAssertNil(error, "not expecting error") + lifecycle.shutdown() + } + lifecycle.wait() + XCTAssertTrue(item.shutdown, "expected item to be shutdown") + } + + func testStatefulSyncStartError() { + class Item { + let id: String = UUID().uuidString + + func start() throws -> String { + throw TestError() + } + + func shutdown(state: String) throws { + XCTFail("should not be shutdown") + throw TestError() + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .sync(item.start), shutdown: .sync(item.shutdown)) + + XCTAssertThrowsError(try lifecycle.startAndWait()) { error in + XCTAssert(error is TestError, "expected error to match") + } + } + + func testStatefulSyncShutdownError() { + class Item { + let id: String = UUID().uuidString + var shutdown: Bool = false + + func start() throws -> String { + return self.id + } + + func shutdown(state: String) throws { + XCTAssertEqual(self.id, state) + throw TestError() + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .sync(item.start), shutdown: .sync(item.shutdown)) + + lifecycle.start { error in + XCTAssertNil(error, "not expecting error") + lifecycle.shutdown { error in + guard let shutdownError = error as? ShutdownError else { + return XCTFail("expected error to match") + } + XCTAssertEqual(shutdownError.errors.count, 1) + XCTAssert(shutdownError.errors.values.first! is TestError, "expected error to match") + } + } + + XCTAssertFalse(item.shutdown, "expected item to be shutdown") + } + + func testStatefulAsync() { + class Item { + let id: String = UUID().uuidString + var shutdown: Bool = false + + func start(_ callback: @escaping (Result) -> Void) { + callback(.success(self.id)) + } + + func shutdown(state: String, _ callback: @escaping (Error?) -> Void) { + XCTAssertEqual(self.id, state) + self.shutdown = true // not thread safe but okay for this purpose + callback(nil) + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) + + lifecycle.start { error in + XCTAssertNil(error, "not expecting error") + lifecycle.shutdown() + } + lifecycle.wait() + XCTAssertTrue(item.shutdown, "expected item to be shutdown") + } + + func testStatefulAsyncStartError() { + class Item { + let id: String = UUID().uuidString + + func start(_ callback: @escaping (Result) -> Void) { + callback(.failure(TestError())) + } + + func shutdown(state: String, _ callback: @escaping (Error?) -> Void) { + XCTFail("should not be shutdown") + callback(TestError()) + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) + + XCTAssertThrowsError(try lifecycle.startAndWait()) { error in + XCTAssert(error is TestError, "expected error to match") + } + } + + func testStatefulAsyncShutdownError() { + class Item { + let id: String = UUID().uuidString + var shutdown: Bool = false + + func start(_ callback: @escaping (Result) -> Void) { + callback(.success(self.id)) + } + + func shutdown(state: String, _ callback: @escaping (Error?) -> Void) { + XCTAssertEqual(self.id, state) + callback(TestError()) + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) + + lifecycle.start { error in + XCTAssertNil(error, "not expecting error") + lifecycle.shutdown { error in + guard let shutdownError = error as? ShutdownError else { + return XCTFail("expected error to match") + } + XCTAssertEqual(shutdownError.errors.count, 1) + XCTAssert(shutdownError.errors.values.first! is TestError, "expected error to match") + } + } + + XCTAssertFalse(item.shutdown, "expected item to be shutdown") + } + + func testStatefulNIO() { + class Item { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let id: String = UUID().uuidString + var shutdown: Bool = false + + func start() -> EventLoopFuture { + return self.eventLoopGroup.next().makeSucceededFuture(self.id) + } + + func shutdown(state: String) -> EventLoopFuture { + XCTAssertEqual(self.id, state) + self.shutdown = true // not thread safe but okay for this purpose + return self.eventLoopGroup.next().makeSucceededFuture(()) + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .eventLoopFuture(item.start), shutdown: .eventLoopFuture(item.shutdown)) + + lifecycle.start { error in + XCTAssertNil(error, "not expecting error") + lifecycle.shutdown() + } + lifecycle.wait() + XCTAssertTrue(item.shutdown, "expected item to be shutdown") + } + + func testStatefulNIOStartFailure() { + class Item { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let id: String = UUID().uuidString + + func start() -> EventLoopFuture { + return self.eventLoopGroup.next().makeFailedFuture(TestError()) + } + + func shutdown(state: String) -> EventLoopFuture { + XCTFail("should not be shutdown") + return self.eventLoopGroup.next().makeFailedFuture(TestError()) + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .eventLoopFuture(item.start), shutdown: .eventLoopFuture(item.shutdown)) + + XCTAssertThrowsError(try lifecycle.startAndWait()) { error in + XCTAssert(error is TestError, "expected error to match") + } + } + + func testStatefulNIOShutdownFailure() { + class Item { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let id: String = UUID().uuidString + var shutdown: Bool = false + + func start() -> EventLoopFuture { + return self.eventLoopGroup.next().makeSucceededFuture(self.id) + } + + func shutdown(state: String) -> EventLoopFuture { + XCTAssertEqual(self.id, state) + return self.eventLoopGroup.next().makeFailedFuture(TestError()) + } + } + + let lifecycle = ComponentLifecycle(label: "test") + + let item = Item() + lifecycle.registerStateful(label: "test", start: .eventLoopFuture(item.start), shutdown: .eventLoopFuture(item.shutdown)) + + lifecycle.start { error in + XCTAssertNil(error, "not expecting error") + lifecycle.shutdown { error in + guard let shutdownError = error as? ShutdownError else { + return XCTFail("expected error to match") + } + XCTAssertEqual(shutdownError.errors.count, 1) + XCTAssert(shutdownError.errors.values.first! is TestError, "expected error to match") + } + } + + XCTAssertFalse(item.shutdown, "expected item to be shutdown") + } }