SSWG-0015: Swift Service Lifecycle

The review of SSWG-0015: Swift Service Lifecycle begins now and runs until August 22, 2020.

Reviews are an important part of the Swift Server Work Group incubation process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager (via email or direct message in the Swift forums).

What goes into a review of a proposal?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, become listed on the Swift Server Ecosystem index page .

When reviewing a proposal, here are some questions to consider:

  • What is your evaluation of the proposal and its APIs/implementation?
  • Is the problem being addressed relevant for the wider Swift on Server ecosystem?
  • Does this proposal fit well with the feel and direction of the Swift Server ecosystem?
  • If you have used other libraries in Swift or other languages, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Thanks,
Peter Adams
Review Manager

11 Likes

+1 this is great. It generalizes functionality that high-level frameworks like Vapor would otherwise have to repeat and provides the basic building blocks users rolling their own solutions would need.

One question I had when looking at the proposal was around the difference between ServiceLifecycle and ComponentLifecycle. Why do these need to be separate types?

Taking the example from sswg/0015-swift-service-lifecycle.md at master · swift-server/sswg · GitHub, why couldn't you just use ServiceLifecycle everywhere:

struct SubSystem {
    let lifecycle = ServiceLifecycle(label: "SubSystem")
    let subsystem: SubSubSystem

    init() {
        self.subsystem = SubSubSystem()
        self.lifecycle.register(self.subsystem.lifecycle)
    }

    struct SubSubSystem {
        let lifecycle = ServiceLifecycle(label: "SubSubSystem")

        init() {
            self.lifecycle.register(...)
        }
    }
}

let lifecycle = ServiceLifecycle()
let subsystem = SubSystem()
lifecycle.register(subsystem.lifecycle)

lifecycle.start { error in
    ...
}
lifecycle.wait()

I imagine the way Vapor would adopt this package is by wrapping ServiceLifecycle in its Application type. That would be the most similar to what we are currently doing today.

Now assume that someone wants to add a Vapor Application as a subcomponent in a larger system. Since Vapor's using ServiceLifecycle instead of ComponentLifecycle would this not be possible?

+1 circle

1 Like

Disclaimer: I don't really care much about the two being two types, but following the current design:

As a framework/library, shouldn't it be a component rather than a service? Only the end user's system is "the service", it often may happen to be just the vapor app, but maybe it's a number of things and the vapor app.


You could also look at Tasks with dependsOn and topological sort of task start order · Issue #49 · swift-server/swift-service-lifecycle · GitHub which IMO is a more powerful / compositional way to express these many systems / components style. It's modeled after what we successfully had in Akka and Akka HTTP etc made good use of it. So rather than users having to register lifecycles in the right order, lifecycle tasks declare if they have dependencies, and the tasks are spawned in the right order.

The current design is brittle, because it depends on users being able to register all the phases in the specific order they want to run, rather than declare them... Examples here Coordinated Shutdown • Akka Documentation

So that might be an alternate or extended consider; perhaps there would be no need for Component Lifecycle then?

It's my understanding that ComponentLifecycle doesn't have the start and wait methods. Vapor's Application type is the top-level thing that a user declares in their main.swift and tests. For example:

// main.swift
let app = try Application(.detect())
defer { app.shutdown() } 

app.get("hello") { req in
    "world"
}

try app.run()

In other words, Application wraps everything that Vapor does into a single init() and run().

I imagine app.run and app.shutdown would call ServiceLifecycle's startAndWait and shutdown respectively.

However, if you want to plug Vapor into something else, you should be able to do something like:

let lifecycle = ServiceLifecycle()

// vapor
let app = try Application(.detect())
lifecycle.register(app.lifecycle)

// other stuff

try lifecycle.startAndWait()

I guess this could be implemented by Application exposing two properties, like app.lifecycle and app.componentLifecycle or something. But I think it would be easier to use if they were just the same type. Any framework that uses ServiceLifecycle should be pluggable as a component in a larger lifecycle anyway, right?

Maybe just making this property public would be sufficient: https://github.com/swift-server/swift-service-lifecycle/blob/main/Sources/Lifecycle/Lifecycle.swift#L86. Then you could do:

let app = try Application(.detect())
lifecycle.register(app.lifecycle.component)

Still think it would be easier to understand with a single type though.


As for the dependency stuff, that looks really interesting. I'll dig into that issue more. Thanks :)

ServiceLifecycle is used when you need to set up shutdown signal listener and install the backtraces which you only want to do once per process, while ComponentLifecycle does not set those up. In most cases ComponentLifecycle is not required and its easy enough to compose by having the subsystem expose its start and shutdown APIs (normally just shutdown), but in very larger systems ComponentLifecycle proved as a useful composition block as it allows such systems to more easily reason about the start and shutdown hierarchy.

This is a fair point, but we may need to distinguish between libraries and application frameworks here.

Libraries would normally not need to use the lifecycle library and just expose public start and shutdown APIs (normally just shutdown) e.g. async-http-client, db drivers that use swift-nio EventLoopGroup, etc. If a library does decides to use the lifecycle library for composition reasons than you are 100% right that it should use ComponentLifecycle and not set up shutdown hooks on behalf of the user but I would also argue that in those cases the lifecycle should remain an implementation detail and the library should only expose start and shutdown APIs, not the underlying ComponentLifecycle.

On the other hand, application frameworks like vapor normally offer their users start and stop APIs that "just work" (including shutdown hooks and backtraces) and do not need to be composed further, which is what ServiceLifecycle is designed to solve. One could argue that the composition gives users more control, but I think most web-framework users expect the framework to be the top level entity they interact with.

Obviously this is an opinion and open to discussion.

See above - I believe this analysis is correct and that an application framework like vapor should provide it's top level start/stop functionality via ServiceLifecycle.

Yes, that is the goal. As you note, the tricky part here is that in an application framework like vapor you want to offer two kind of start API (shutdown is the same in both cases):

  1. start that set up the shutdown hook and backtrace (most common)
  2. start that defers setting up the shutdown hook and backtrace to the caller for composition (less common)

To support the second use case, vapor application can either:

  1. expose the "raw" start method that does not go through ServiceLifecycle::start, but rather is the internal method that ServiceLifecycle::start calls
  2. expose lifecycle and its "guts"

The design thinking behind ComponentLifecycle was that it would be an internal composition implementation detail in complex systems and not something that "leaks" via other libraries/frameworks APIs. As such, I tend to think option 1 would be better - just need to find a good names for the two different start APIs.

Ah okay, that's the part I was missing. This all makes a lot more sense now, thank you.

I think the most intuitive API for this would be what I posted earlier:

Using Vapor directly / top level:

let app = try Application(.detect())
// Calls ServiceLifecycle.shutdown() internally
defer { app.shutdown() } 

...

// calls ServiceLifecycle.startAndWait() internally
try app.run()

Using Vapor as a single piece in a larger application:

let lifecycle = ServiceLifecycle()

let app = try Application(.detect())
// app.lifecycle is ComponentLifecycle
lifecycle.register(app.lifecycle)

// other stuff

try lifecycle.startAndWait()

To do that, I would need either:

I much prefer option #1 since, to me at least, it makes sense that if you have something using a ServiceLifecycle, you should always be able to plug that as one piece into a larger system. It would be unfortunate if framework maintainers had to explicitly allow you both options.


Side note after digging into the code more: Is ServiceLifecycle.startAndWait() not installing signal handlers a bug?

indeed. PR coming

1 Like

Another alternative to consider:

let lifecycle = ServiceLifecycle()

let apiServer = try Application(.detect())

lifecycle.register(label: "api-server",
                   start: .sync(apiServer.start),
                   shutdown: .sync(apiServer.shutdown))
// other stuff

try lifecycle.startAndWait()

I could go either way really

I think that could work, too. Though I'm not sure if it would be valuable for Vapor to offer a way to call ServiceLifecycle.start() without waiting.

The way I imagined the API mapping would be:

// ServiceLifecycle.start()
app.start()

// ServiceLifecycle.shutdown()
app.shutdown()

// ServiceLifecycle.startAndWait()
app.run()

// ComponentLifecycle
app.lifecycle
// or
app.lifecycle.component

This seems to create a clearer distinction between Application as a top-level thing and Application as a component.

okay. lets give it a couple of days to see if there are any other ideas otherwise we can do that

1 Like

As primarily a library author instead of a framework, I'm trying to figure out what my adoption of this would be.

You mentioned that we should provide just the start/stop hooks, but should we also adopt this API library and conform our types to it, probably as a compatibility target?

Or should it be left as an exercise to higher-level libraries and frameworks, even though it breaks the Swift best practice of not conforming types you don't own to protocols you don't own?

In my opinion, most library authors do not need to depend on the lifecycle library, and should only offer start / shutdown APIs when otherwise necessary. For example, a database-driver or http-client that uses SwiftNIO and creates an instance of EventLoopGroup should expose a shutdown API anyways, and in my opinion that is enough for higher level frameworks / applications to plug that library into their lifecycle hierarchy.

4 Likes

Thank you everyone for your comments and @tomerd for the proposal.

I'm pleased to report that the SSWG voted unanimously to accept Swift Service Lifecycle at sandbox level.

This thread will now be locked and the package added to Swift.org - Swift on Server

3 Likes