Skip to content

Commit 7cab86b

Browse files
committed
Add public shutdownGracefully method to the service runner
1 parent d7a56c3 commit 7cab86b

File tree

3 files changed

+177
-17
lines changed

3 files changed

+177
-17
lines changed

Sources/ServiceLifecycle/Docs.docc/How to adopt ServiceLifecycle in applications.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ startup and shutdown. Furthermore, the application also needs to handle a single
1414

1515
Swift introduced Structured Concurrency which already helps tremendously with running multiple
1616
async services concurrently. This can be achieved with the use of task groups. However, Structured
17-
Concurrency doesn't enforce consistent interfaces between so services, so it becomes hard to orchestrate them.
17+
Concurrency doesn't enforce consistent interfaces between the services, so it becomes hard to orchestrate them.
1818
This is where ``ServiceLifecycle`` comes in. It provides the ``Service`` protocol which enforces
1919
a common API. Additionally, it provides the ``ServiceRunner`` which is responsible for orchestrating
2020
all services in an application.
@@ -53,7 +53,7 @@ public struct BarService: Service {
5353
}
5454
```
5555

56-
The `BarService` is depending in our example on the `FofService`. A dependency between services
56+
The `BarService` is depending in our example on the `FooService`. A dependency between services
5757
is quite common and the ``ServiceRunner`` is inferring the dependencies from the order of the
5858
services passed to the ``ServiceRunner/init(services:configuration:logger:)``. Services with a higher
5959
index can depend on services with a lower index. The following example shows how this can be applied

Sources/ServiceLifecycle/ServiceRunner.swift

Lines changed: 118 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ public actor ServiceRunner: Sendable {
2222
/// The initial state of the runner.
2323
case initial
2424
/// The state once ``ServiceRunner/run()`` has been called.
25-
case running
25+
case running(
26+
gracefulShutdownStreamContinuation: AsyncStream<Void>.Continuation
27+
)
2628
/// The state once ``ServiceRunner/run()`` has finished.
2729
case finished
2830
}
@@ -59,15 +61,34 @@ public actor ServiceRunner: Sendable {
5961
public func run(file: String = #file, line: Int = #line) async throws {
6062
switch self.state {
6163
case .initial:
62-
self.state = .running
63-
try await self._run()
64+
guard !self.services.isEmpty else {
65+
self.state = .finished
66+
return
67+
}
68+
69+
let (gracefulShutdownStream, gracefulShutdownContinuation) = AsyncStream.makeStream(of: Void.self)
70+
71+
self.state = .running(
72+
gracefulShutdownStreamContinuation: gracefulShutdownContinuation
73+
)
74+
75+
var potentialError: Error?
76+
do {
77+
try await self._run(gracefulShutdownStream: gracefulShutdownStream)
78+
} catch {
79+
potentialError = error
80+
}
6481

6582
switch self.state {
6683
case .initial, .finished:
6784
fatalError("ServiceRunner is in an invalid state \(self.state)")
6885

6986
case .running:
7087
self.state = .finished
88+
89+
if let potentialError {
90+
throw potentialError
91+
}
7192
}
7293

7394
case .running:
@@ -78,15 +99,43 @@ public actor ServiceRunner: Sendable {
7899
}
79100
}
80101

102+
/// Triggers the graceful shutdown of all services.
103+
///
104+
/// This method returns immediately after triggering the graceful shutdown and doesn't wait until the service have shutdown.
105+
public func shutdownGracefully() async {
106+
switch self.state {
107+
case .initial:
108+
// We aren't even running so we can stop right away.
109+
self.state = .finished
110+
return
111+
112+
case .running(let gracefulShutdownStreamContinuation):
113+
// We cannot transition to shuttingDown here since we are signalling over to the task
114+
// that runs `run`. This task is responsible for transitioning to shuttingDown since
115+
// there might be multiple signals racing to trigger it
116+
117+
// We are going to signal the run method that graceful shutdown
118+
// should be triggered
119+
gracefulShutdownStreamContinuation.yield()
120+
gracefulShutdownStreamContinuation.finish()
121+
122+
case .finished:
123+
// Already finished running so nothing to do here
124+
return
125+
}
126+
}
127+
81128
private enum ChildTaskResult {
82129
case serviceFinished(service: any Service, index: Int)
83130
case serviceThrew(service: any Service, index: Int, error: any Error)
84131
case signalCaught(UnixSignal)
85132
case signalSequenceFinished
133+
case gracefulShutdownCaught
134+
case gracefulShutdownFinished
86135
}
87136

88-
private func _run() async throws {
89-
self.logger.info(
137+
private func _run(gracefulShutdownStream: AsyncStream<Void>) async throws {
138+
self.logger.debug(
90139
"Starting service lifecycle",
91140
metadata: [
92141
self.configuration.logging.signalsKey: "\(self.configuration.gracefulShutdownSignals)",
@@ -100,6 +149,7 @@ public actor ServiceRunner: Sendable {
100149
// First we have to register our signals.
101150
let unixSignals = await UnixSignalsSequence(trapping: self.configuration.gracefulShutdownSignals)
102151

152+
// This is the task that listens to signals
103153
group.addTask {
104154
for await signal in unixSignals {
105155
return .signalCaught(signal)
@@ -108,6 +158,26 @@ public actor ServiceRunner: Sendable {
108158
return .signalSequenceFinished
109159
}
110160

161+
// This is the task that listens to manual graceful shutdown
162+
group.addTask {
163+
for await _ in gracefulShutdownStream {
164+
return .gracefulShutdownCaught
165+
}
166+
167+
return .gracefulShutdownFinished
168+
}
169+
170+
// This is an optional task that listens to graceful shutdowns from the parent task
171+
if let _ = TaskLocals.gracefulShutdownManager {
172+
group.addTask {
173+
for await _ in AsyncGracefulShutdownSequence() {
174+
return .gracefulShutdownCaught
175+
}
176+
177+
return .gracefulShutdownFinished
178+
}
179+
}
180+
111181
// We have to create a graceful shutdown manager per service
112182
// since we want to signal them individually and wait for a single service
113183
// to finish before moving to the next one
@@ -174,10 +244,8 @@ public actor ServiceRunner: Sendable {
174244
return .failure(error)
175245

176246
case .signalCaught(let unixSignal):
177-
// We got a signal. Let's initiate graceful shutdown in reverse order than we started the
178-
// services. This allows the users to declare a hierarchy with the order they passed
179-
// the services.
180-
self.logger.info(
247+
// We got a signal. Let's initiate graceful shutdown.
248+
self.logger.debug(
181249
"Signal caught. Shutting down services",
182250
metadata: [
183251
self.configuration.logging.signalKey: "\(unixSignal)",
@@ -193,7 +261,20 @@ public actor ServiceRunner: Sendable {
193261
return .failure(error)
194262
}
195263

196-
case .signalSequenceFinished:
264+
case .gracefulShutdownCaught:
265+
// We got a manual or inherited graceful shutdown. Let's initiate graceful shutdown.
266+
self.logger.debug("Graceful shutdown caught. Cascading shutdown to services")
267+
268+
do {
269+
try await self.shutdownGracefully(
270+
group: &group,
271+
gracefulShutdownManagers: gracefulShutdownManagers
272+
)
273+
} catch {
274+
return .failure(error)
275+
}
276+
277+
case .signalSequenceFinished, .gracefulShutdownFinished:
197278
// This can happen when we are either cancelling everything or
198279
// when the user did not specify any shutdown signals. We just have to tolerate
199280
// this.
@@ -207,7 +288,7 @@ public actor ServiceRunner: Sendable {
207288
return .success(())
208289
}
209290

210-
self.logger.info(
291+
self.logger.debug(
211292
"Service lifecycle ended"
212293
)
213294
try result.get()
@@ -217,6 +298,10 @@ public actor ServiceRunner: Sendable {
217298
group: inout TaskGroup<ChildTaskResult>,
218299
gracefulShutdownManagers: [GracefulShutdownManager]
219300
) async throws {
301+
guard case .running = self.state else {
302+
fatalError("Unexpected state")
303+
}
304+
220305
// We have to shutdown the services in reverse. To do this
221306
// we are going to signal each child task the graceful shutdown and then wait for
222307
// its exit.
@@ -246,7 +331,7 @@ public actor ServiceRunner: Sendable {
246331
continue
247332
} else {
248333
// Another service exited unexpectedly
249-
self.logger.error(
334+
self.logger.debug(
250335
"Service finished unexpectedly during graceful shutdown. Cancelling all other services now",
251336
metadata: [
252337
self.configuration.logging.serviceKey: "\(service)",
@@ -258,7 +343,7 @@ public actor ServiceRunner: Sendable {
258343
}
259344

260345
case .serviceThrew(let service, _, let error):
261-
self.logger.error(
346+
self.logger.debug(
262347
"Service threw error during graceful shutdown. Cancelling all other services now",
263348
metadata: [
264349
self.configuration.logging.serviceKey: "\(service)",
@@ -269,12 +354,30 @@ public actor ServiceRunner: Sendable {
269354

270355
throw error
271356

272-
case .signalCaught, .signalSequenceFinished:
273-
fatalError("Signal sequence already returned a signal.")
357+
case .signalCaught, .signalSequenceFinished, .gracefulShutdownCaught, .gracefulShutdownFinished:
358+
// We just have to tolerate this since signals and parent graceful shutdowns downs can race.
359+
continue
274360

275361
case nil:
276362
fatalError("Invalid result from group.next().")
277363
}
278364
}
365+
366+
// If we hit this then all services are shutdown. The only thing remaining
367+
// are the tasks that listen to the various graceful shutdown signals. We
368+
// just have to cancel those
369+
group.cancelAll()
370+
}
371+
}
372+
373+
// This should be removed once we support Swift 5.9+
374+
extension AsyncStream {
375+
fileprivate static func makeStream(
376+
of elementType: Element.Type = Element.self,
377+
bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
378+
) -> (stream: AsyncStream<Element>, continuation: AsyncStream<Element>.Continuation) {
379+
var continuation: AsyncStream<Element>.Continuation!
380+
let stream = AsyncStream<Element>(bufferingPolicy: limit) { continuation = $0 }
381+
return (stream: stream, continuation: continuation!)
279382
}
280383
}

Tests/ServiceLifecycleTests/ServiceRunnerTests.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,63 @@ final class ServiceRunnerTests: XCTestCase {
487487
}
488488
}
489489

490+
func testShutdownGracefully() async throws {
491+
let configuration = ServiceRunnerConfiguration(gracefulShutdownSignals: [])
492+
let service1 = MockService(description: "Service1")
493+
let service2 = MockService(description: "Service2")
494+
let service3 = MockService(description: "Service3")
495+
let runner = self.makeServiceRunner(services: [service1, service2, service3], configuration: configuration)
496+
497+
await withThrowingTaskGroup(of: Void.self) { group in
498+
group.addTask {
499+
try await runner.run()
500+
}
501+
502+
var eventIterator1 = service1.events.makeAsyncIterator()
503+
await XCTAsyncAssertEqual(await eventIterator1.next(), .run)
504+
505+
var eventIterator2 = service2.events.makeAsyncIterator()
506+
await XCTAsyncAssertEqual(await eventIterator2.next(), .run)
507+
508+
var eventIterator3 = service3.events.makeAsyncIterator()
509+
await XCTAsyncAssertEqual(await eventIterator3.next(), .run)
510+
511+
await runner.shutdownGracefully()
512+
513+
// The last service should receive the shutdown signal first
514+
await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully)
515+
516+
// Waiting to see that all three are still running
517+
service1.sendPing()
518+
service2.sendPing()
519+
service3.sendPing()
520+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
521+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
522+
await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing)
523+
524+
// Let's exit from the last service
525+
await service3.resumeRunContinuation(with: .success(()))
526+
527+
// The middle service should now receive the signal
528+
await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully)
529+
530+
// Waiting to see that the two remaining are still running
531+
service1.sendPing()
532+
service2.sendPing()
533+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
534+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
535+
536+
// Let's exit from the first service
537+
await service1.resumeRunContinuation(with: .success(()))
538+
539+
// The middle service should now receive a cancellation
540+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runCancelled)
541+
542+
// Let's exit from the first service
543+
await service2.resumeRunContinuation(with: .success(()))
544+
}
545+
}
546+
490547
// MARK: - Helpers
491548

492549
private func makeServiceRunner(

0 commit comments

Comments
 (0)