Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ extension AsyncSequence where Self: Sendable, Element: Sendable {
}
}

/// An asynchronous sequence that is cancelled once graceful shutdown has triggered.
/// An asynchronous sequence that is cancelled after graceful shutdown has triggered.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public struct AsyncCancelOnGracefulShutdownSequence<Base: AsyncSequence & Sendable>: AsyncSequence, Sendable
where Base.Element: Sendable {
Expand All @@ -40,24 +40,29 @@ where Base.Element: Sendable {
AsyncMapSequence<AsyncMapNilSequence<AsyncGracefulShutdownSequence>, _ElementOrGracefulShutdown>
>

/// The type that the sequence produces.
public typealias Element = Base.Element

@usableFromInline
let _merge: Merged

/// Creates a new asynchronous sequence that cancels after graceful shutdown is triggered.
/// - Parameter base: The asynchronous sequence to wrap.
@inlinable
public init(base: Base) {
self._merge = merge(
base.mapNil().map { .base($0) },
AsyncGracefulShutdownSequence().mapNil().map { _ in .gracefulShutdown }
)
}


/// Creates an iterator for the sequence.
@inlinable
public func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(iterator: self._merge.makeAsyncIterator())
}


/// An iterator for an asynchronous sequence that cancels after graceful shutdown is triggered.
public struct AsyncIterator: AsyncIteratorProtocol {
@usableFromInline
var _iterator: Merged.AsyncIterator
Expand All @@ -69,7 +74,8 @@ where Base.Element: Sendable {
init(iterator: Merged.AsyncIterator) {
self._iterator = iterator
}


/// Returns the next item in the sequence, or `nil` if the sequence is finished.
@inlinable
public mutating func next() async rethrows -> Element? {
guard !self._isFinished else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

/// An async sequence that emits an element once graceful shutdown has been triggered.
///
/// This sequence is a broadcast async sequence and will only produce one value and then finish.
/// This sequence is a broadcast async sequence and only produces one value and then finishes.
///
/// - Note: This sequence respects cancellation and thus is `throwing`.
@usableFromInline
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# How to adopt ServiceLifecycle in applications
# Adopting ServiceLifecycle in applications

``ServiceLifecycle`` provides a unified API for services to streamline their
orchestration in applications: the ``ServiceGroup`` actor.
Service Lifecycle provides a unified API for services to streamline their
orchestration in applications: the Service Group actor.

## Why do we need this?
## Overview

Applications often rely on fundamental observability services like logging and
metrics, while long-running actors bundle the application's business logic in
Expand All @@ -13,29 +13,29 @@ orchestrate the various services during startup and shutdown.

With the introduction of Structured Concurrency in Swift, multiple asynchronous
services can be run concurrently with task groups. However, Structured
Concurrency doesn't enforce consistent interfaces between the services, and it
becomes hard to orchestrate them. To solve this issue, ``ServiceLifecycle``
Concurrency doesn't enforce consistent interfaces between services, and it can
become hard to orchestrate them. To solve this issue, ``ServiceLifecycle``
provides the ``Service`` protocol to enforce a common API, as well as the
``ServiceGroup`` actor to orchestrate all services in an application.

## Adopting the ServiceGroup actor in your application

This article focuses on how ``ServiceGroup`` works, and how you can adopt it in
your application. If you are interested in how to properly implement a service,
go check out the article: <doc:How-to-adopt-ServiceLifecycle-in-libraries>.
go check out the article: <doc:Adopting-ServiceLifecycle-in-libraries>.

### How does the ServiceGroup actor work?

Under the hood, the ``ServiceGroup`` actor is just a complicated task group that
runs each service in a separate child task, and handles individual services
exiting or throwing. It also introduces the concept of graceful shutdown, which
allows the safe teardown of all services in reverse order. Graceful shutdown is
often used in server scenarios, i.e., when rolling out a new version and
often used in server scenarios, for example when rolling out a new version and
draining traffic from the old version (commonly referred to as quiescing).

### How to use ServiceGroup?

Let's take a look how ``ServiceGroup`` can be used in an application. First, we
Let's take a look at how ``ServiceGroup`` can be used in an application. First, we
define some fictional services.

```swift
Expand Down Expand Up @@ -87,13 +87,13 @@ struct Application {

Graceful shutdown is a concept introduced in ServiceLifecycle, with the aim to
be a less forceful alternative to task cancellation. Graceful shutdown allows
each services to opt-in support. For example, you might want to use graceful
each service to opt-in support. For example, you might want to use graceful
shutdown in containerized environments such as Docker or Kubernetes. In those
environments, `SIGTERM` is commonly used to indicate that the application should
shutdown. If it does not, then a `SIGKILL` is sent to force a non-graceful
shutdown.

The ``ServiceGroup`` can be setup to listen to `SIGTERM` and trigger a graceful
The ``ServiceGroup`` can be set up to listen to `SIGTERM` and trigger a graceful
shutdown on all its orchestrated services. Once the signal is received, it will
gracefully shut down each service one by one in reverse startup order.
Importantly, the ``ServiceGroup`` is going to wait for the ``Service/run()``
Expand Down Expand Up @@ -227,10 +227,10 @@ shutting down, and await an acknowledgment of that message.

### Customizing the behavior when a service returns or throws

By default the ``ServiceGroup`` cancels the whole group, if one service returns
or throws. However, in some scenarios this is unexpected, e.g., when the
By default, the ``ServiceGroup`` cancels the whole group if one service returns
or throws. However, in some scenarios, this is unexpected, e.g., when the
``ServiceGroup`` is used in a CLI to orchestrate some services while a command
is handled. To customize the behavior you set the
is handled. To customize the behavior, you set the
``ServiceGroupConfiguration/ServiceConfiguration/successTerminationBehavior``
and
``ServiceGroupConfiguration/ServiceConfiguration/failureTerminationBehavior``.
Expand All @@ -242,9 +242,9 @@ it to
trigger a graceful shutdown by setting it to
``ServiceGroupConfiguration/ServiceConfiguration/TerminationBehavior/gracefullyShutdownGroup``.

Another example where you might want to use customize the behavior is when you
have a service that should be gracefully shutdown when another service exits.
For example, you want to make sure your telemetry service is gracefully shutdown
Another example where you might want to customize the behavior is when you
have a service that should be gracefully shut down when another service exits.
For example, you want to make sure your telemetry service is gracefully shut down
after your HTTP server unexpectedly throws from its `run()` method. This setup
could look like this:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Adopting ServiceLifecycle in libraries

Adopt the Service protocol for your service to allow a Service Group to coordinate it's operation with other services.

## Overview

Service Lifecycle provides a unified API to represent a long running task: the ``Service`` protocol.

Before diving into how to adopt this protocol in your library, let's take a step back and talk about why we need to have this unified API.
Services often need to schedule long-running tasks, such as sending keep-alive pings in the background, or the handling of incoming work like new TCP connections.
Before Swift Concurrency was introduced, services put their work into separate threads using a `DispatchQueue` or an NIO `EventLoop`.
Services often required explicit lifetime management to make sure their resources, such as threads, were shut down correctly.

With the introduction of Swift Concurrency, specifically by using Structured Concurrency, we have better tools to structure our programs and model our work as a tree of tasks.
The `Service` protocol provides a common interface, a single `run()` method, for services to use when they run their long-running work.
If all services in an application conform to this protocol, then orchestrating them becomes trivial.

## Adopting the Service protocol in your service

Adopting the `Service` protocol is quite easy.
The protocol's single requirement is the ``Service/run()`` method.
Make sure that your service adheres to the important caveats addressed in the following sections.

### Use Structured Concurrency

Swift offers multiple ways to use Concurrency.
The primary primitives are the `async` and `await` keywords which enable straight-line code to make asynchronous calls.
The language also provides the concept of task groups; they allow the creation of concurrent work, while staying tied to the parent task.
At the same time, Swift also provides `Task(priority:operation:)` and `Task.detached(priority:operation:)` which create new unstructured Tasks.

Imagine our library wants to offer a simple `TCPEchoClient`.
To make it Interesting, let's assume we need to send keep-alive pings on every open connection every second.
Below you can see how we could implement this using unstructured concurrency.

```swift
public actor TCPEchoClient {
public init() {
Task {
for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
self.sendKeepAlivePings()
}
}
}

private func sendKeepAlivePings() async { ... }
}
```

The above code has a few problems.
First, the code never cancels the `Task` that runs the keep-alive pings.
To do this, it would need to store the `Task` in the actor and cancel it at the appropriate time.
Second, it would also need to expose a `cancel()` method on the actor to cancel the `Task`.
If it were to do all of this, it would have reinvented Structured Concurrency.

To avoid all of these problems, the code can conform to the ``Service`` protocol.
Its requirement guides us to implement the long-running work inside the `run()` method.
It allows the user of the client to decide in which task to schedule the keep-alive pings — using an unstructured `Task` is an option as well.
Furthermore, we now benefit from the automatic cancellation propagation by the task that called our `run()` method.
The code below illustrates an overhauled implementation that exposes such a `run()` method:

```swift
public actor TCPEchoClient: Service {
public init() { }

public func run() async throws {
for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
self.sendKeepAlivePings()
}
}

private func sendKeepAlivePings() async { ... }
}
```

### Returning from your `run()` method

Since the `run()` method contains long-running work, returning from it is interpreted as a failure and will lead to the ``ServiceGroup`` canceling all other services.
Unless specified otherwise in
``ServiceGroupConfiguration/ServiceConfiguration/successTerminationBehavior``
and
``ServiceGroupConfiguration/ServiceConfiguration/failureTerminationBehavior``,
each task started in its respective `run()` method will be canceled.

### Cancellation

Structured Concurrency propagates task cancellation down the task tree.
Every task in the tree can check for cancellation or react to it with cancellation handlers.
``ServiceGroup`` uses task cancellation to tear down everything when a service’s `run()` method returns early or throws an error.
Hence, it is important that each service properly implements task cancellation in their `run()` methods.

Note: If your `run()` method calls other async methods that support cancellation, or consumes an `AsyncSequence`, then you don't have to do anything explicitly.
The latter is shown in the `TCPEchoClient` example above.

### Graceful shutdown

Applications are often required to be shut down gracefully when run in a real production environment.

For example, the application might be deployed on Kubernetes and a new version got released.
During a rollout of that new version, Kubernetes sends a `SIGTERM` signal to the application, expecting it to terminate within a grace period.
If the application does not stop in time, then Kubernetes sends the `SIGKILL` signal and forcefully terminates the process.
For this reason, ``ServiceLifecycle`` introduces the _shutdown gracefully_ concept that allows terminating an application’s work in a structured and graceful manner.
This behavior is similar to task cancellation, but due to its opt-in nature, it is up to the business logic of the application to decide what to do.

``ServiceLifecycle`` exposes one free function called ``withGracefulShutdownHandler(operation:onGracefulShutdown:)`` that works similarly to the `withTaskCancellationHandler` function from the Concurrency library.
Library authors are expected to make sure that any work they spawn from the `run()` method properly supports graceful shutdown.
For example, a server might close its listening socket to stop accepting new connections.
It is of upmost importance that the server does not force the closure of any currently open connections.
It is expected that the business logic behind these connections handles the graceful shutdown.

An example high-level implementation of a `TCPEchoServer` with graceful shutdown
support might look like this:

```swift
public actor TCPEchoServer: Service {
public init() { }

public func run() async throws {
await withGracefulShutdownHandler {
for connection in self.listeningSocket.connections {
// Handle incoming connections
}
} onGracefulShutdown: {
self.listeningSocket.close()
}
}
}
```

When the above code receives the graceful shutdown sequence, the only reasonable thing for `TCPEchoClient` to do is cancel the iteration of the timer sequence.
``ServiceLifecycle`` provides a convenience on `AsyncSequence` to cancel on graceful shutdown.
Let's take a look at how this works.

```swift
public actor TCPEchoClient: Service {
public init() { }

public func run() async throws {
for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous).cancelOnGracefulShutdown() {
self.sendKeepAlivePings()
}
}

private func sendKeepAlivePings() async { ... }
}
```

As you can see in the code above, the additional `cancelOnGracefulShutdown()` call takes care of any downstream cancellation.
Loading