The problem we have with the new API is that it assumes that the work takes the structure of start, blocked run and shutdown.
Most of our services do not necessarily block, but can in turn start other asynchronous work (which would be shut down by the shutdown handles).
This worked just fine with the old API, as there were no such assumption.
What would be the recommended way to migrate? It doesn't seem that nice to just run an infinite loop and check for task cancellation / sleep between checks. I guess we could create an async stream where we could yield the 'shutdown' when it's time, but it seems perhaps that would better be part of the API then, or are we missing something?
This question of how to bridge an existing service that exposes start/stop APIs came up a few times. The gist of the problem is what you call out here:
This is a totally fine pattern and we want services to do other asynchronous work. The problem that you are encountering here is that your service is doing the asynchronous work in an unstructured way. Presumably it runs this work on some NIO event loop. This is totally fine and the way we have done this in the past; however, the new ServiceLifecycle is fully embracing Structured Concurrency and wants to nudge you into making your asynchronous work part of the task tree.
Though we can't change everything to be fully structured right away; therefore, it is totally fine to create a bridge that wraps your start/stop service into a "structured" service. Wrapping it is quite simple:
actor StartStopService: Service {
private var continuation: CheckedContinuation<Void, Error>?
func run() async throws {
self.underlyingService.start()
try await withTaskCancellationHandler {
try await withCheckedContinuation { continuation in
self.continuation = continuation
}
} onCancel: {
Task { self.cancel() } // We have to spawn an unstructured task here since we don't have actor send yet
}
}
private func cancel() {
self.continuation?.resume(throwing: CancellationError())
}
}
We don't provide such a bridging construct out of the box because in the fullness of time we expect that start/stop services to face out and be replaced with run() based services.
On a related note:
I am unsure if you want to model a database migrator as a service. Personally, I think that database migrations have to run before any other part of the application is started. Meaning your main method should look more like this
The Database migrator isn't ours, it was the original sample code from the Service Lifecycle blog entry so I just used that for contrast of discussion ;-)
Hello @FranzBusch! Thanks for your example, that's definitely makes sense.
I managed this to work, with small change of try await withTaskCancellationHandler to be await withGracefulShutdownHandler and using none error version of continuation.
One more thing that we are using in previous version is order of start operations. Basically if I register lifecycle tasks A, B, C, then when C starts I know that A and B have started. This allows us to avoid logic of checking and awaiting of dependencies (like C do not check and wait for A/B to be started).
I guess it can be solved by logic like: C is awaiting on some continuation which is resumed when both A and B are done, but this make the adaptation even more complex and not straightforward.
So is there any plans to support ordering of service startup and if yes, how it will correlate with concept that run basically does the job until is not cancelled?
To give more context I can provide an example: I have service (say A) which connects to external service and register the running process. Then I start B/C/etc which rely that process is already registered and do not wait for it. Seems in the new version of lifecycle I need to start service A (and similar ones) beyond lifecycle service group.
Great question! During the development of the new lib we also took a look at exactly that use-case.
From my experience with using the new service lifecycle and structured concurrency, I saw that this dependency concept is something that can be achieved in different ways. Importantly here is how are the dependent services (B/C) relying on the other service (A) running, i.e. are B/C using A directly by having it injected in their inits or are they relying on some "output" that A produces.
If B/C are getting A injected and using it to make requests then this is something that A should be able to handle on its own with async methods, e.g. A can suspend all requests until its run() method is called or it could throw an error to make sure that nothing is using it before its running. Another thing is that service A can expose its state which the other services can observe to know when they are ready to start, e.g.:
actor ServiceA: Service {
enum State {
case initial
case running
case shutdown
}
var state: AsyncStream<State>
}
On the other hand, if service A is producing an output that the other services want to consume to operate then you could model this by vending an AsyncSequence of the output that is injected into the dependent services which consume the sequence. An example of this is service A is producing a certificate that the other services need to use to rotate the certificate used for communicating to some external application.
Overall, we saw that we were able to solve all of these inter-service dependencies by employing one of the above approaches. If none of those work, then it would be great if you could share a more detailed example that we could discuss.