dependencies {
implementation("io.arrow-kt:suspendapp:_")
}
When writing software we need to deal with the lifecycle of the application such as termination signals, and sending correct exit codes. This is important, so we correctly interact with the OS our application runs on. This requires a lot of platform-specific code, SuspendApp solves that problem by leveraging Kotlin MPP using KotlinX Coroutines, and Structured Concurrency. See Simple Example below.
Currently supported targets:
- JVM
- MacOsX64 & MacosArm64
- NodeJS
- Windows (MingwX64)
- Linux
SuspendApp currently does not support any mobile or browser targets because it does not make sense to have such application behavior on such platforms. If you have a use-case for this please open a ticket!
Let's see some simple examples that more clearly demonstrate the rationale for SuspendApp.
If you see App Started! Waiting until asked to shutdown.
try pressing ctrl+C
to signal interruption (SIGINT
) to
the process.
You can also use ps -ax
to find the PID
and call kill PID
to send a SIGTERM
event to the process.
import arrow.continuations.SuspendApp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
fun main() = SuspendApp {
try {
println("App Started! Waiting until asked to shutdown.")
while (true) {
delay(2_500)
println("Ping")
}
} catch (e: CancellationException) {
println("Cleaning up App... will take 10 seconds...")
withContext(NonCancellable) { delay(10_000) }
println("Done cleaning up. Will release app to exit")
}
}
Note: since our CoroutineScope
is cancelled we need to run our delay
in NonCancelable
.
Arrow Fx Coroutines Resource
allows for modeling resources within the suspend
world,
and properly takes into account structured concurrency and cancellation.
This means that when a CoroutineScope
gets cancelled, then any suspend finalizer
will back pressure Job#join
.
And thus when you call cancelAndJoin
on a CoroutineScope
it will properly await the finalizers
to have finished
running.
With SuspendApp
this means that if someone sends a terminal signal such as SIGINT
or SIGTERM
to the App
then it will run all the suspend finalizers
before closing the App
.
fun main() = SuspendApp {
resourceScope {
install({ println("Creating some resource") }) { _, exitCase ->
println("ExitCase: $exitCase")
println("Shutting down will take 10 seconds")
delay(10_000)
println("Shutdown finished")
}
println("Application running with acquired resources.")
awaitCancellation()
}
}
In the example above we have a Resource
that during acquisition will print Creating some resource
,
when the Resource
needs to be closed, release, we print the ExitCase
with which the Resource
was closed, and
then
we wait for 10 seconds. The Resource
already takes care of calling release
on a NonCancelable
context.
We consume the Resource
until our App
is cancelled by calling awaitCancellation
from KotlinX Coroutines.
That gives us the following output, if you press ctrl+c
in the terminal.
Creating some resource
Application running with acquired resources.
^CExitCase: Cancelled(exception=kotlinx.coroutines.JobCancellationException: LazyStandaloneCoroutine was cancelled; job=LazyStandaloneCoroutine{Cancelling}@f7470010)
Shutting down will take 10 seconds
Shutdown finished
You can find this example in the example
module, currently setup for NodeJS and native targets.
There are some cases where it is convenient to gracefully shutdown a Ktor
server. Basically, it is about giving some
time to the server to finish some pending processing before turning it off.
Kubernetes is a good example of this need. When we're working with Kubernetes we often need to
support Graceful Shutdown
.
Kubernetes sends SIGTERM
to our Pod to signal it needs to gracefully shutdown.
However, there is an issue which doesn't allow us to immediately shutdown when we receive SIGTERM
from Kubernetes.
Our pod can still receive traffic after SIGTERM
, so we need to apply additional back-pressure to delay graceful
shutdown.
More information on this can be found in this blog by Phil Pearl,
and on learnk8s.io.
The module suspendapp-ktor
provides a server
constructor that lifts the Ktor ApplicationEngine
in to a Resource
,
representing the Engine running an Application
(i.e Netty
) while supporting auto-reload.
Check the official Ktor documentation to learn more about watchPaths.
When our release
function of our ApplicationEngine
is called, there is a wait
period before the beginning of the
stop
process (defaulted to 30.seconds
), this gives Kubernetes enough time to do all its network management before we shut
down.
Two more parameters are available: grace
which set the number of seconds during which already inflight requests are
allowed to continue before the shutdown process begins, and timeout
which set the number of seconds after which the
server will be forceably shutdown. In the case that ktor
server is set in
development mode, the wait
period is ignored.
Given this Resource
definition of a Ktor server, with support for gracefully shutting down for K8S we can define
a SuspendApp
.
fun main() = SuspendApp {
resourceScope {
server(Netty) {
routing {
get("/ping") {
call.respond("pong")
}
}
}
awaitCancellation()
}
}
We also use awaitCancellation
here to await SIGTERM
, SIGINT
or other shutdown hooks,
and we let the server
Resource
back-pressure closing the application for K8s.
Gracefully shutting down is also often needed with other applications, beside K8S. It can be useful in all kinds of applications that need to execute some code before getting shutdown.
Kafka for example, when streaming records from Kafka we need to commit (acknowledge) the offset of the records we've processed. The official recommendation for doing this is committing offsets in batches, so we typically don't send the commit event to Kafka for every processed record. Instead, we commit the offset every 5 seconds (or every x records, 5s is default).
Imagine the application getting stopped after 4,5 seconds, either by ctrl+c
or K8S
or another type of
containerization.
We could've processed thousands, or tens of thousands of events.
If we don't commit these offsets before shutting down we'd have to re-process all the events.
We can easily prevent this with SuspendApp
, and kotlin-kafka
or reactor-kafka.
Both these high-level Kafka libraries guarantee committing offsets upon termination of the stream, this includes
cancellation!
In the example below, all calls to acknowledge
will be committed to Kafka before the SuspendApp
terminates when
receiving SIGTERM
or SIGINT
.
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import org.apache.kafka.common.serialization.StringDeserializer
import io.github.nomisRev.kafka.receiver.KafkaReceiver
import io.github.nomisRev.kafka.receiver.ReceiverSettings
import arrow.continuations.SuspendApp
fun main() = SuspendApp {
val settings: ReceiverSettings<Nothing, String> = ReceiverSettings(
bootstrapServers = kafka.bootstrapServers,
groupId = "group-id",
valueDeserializer = StringDeserializer()
)
KafkaReceiver(settings)
.receive(topicName)
.map { record ->
println("${record.key()} -> ${record.value()}")
record.offset.acknowledge()
}.collect()
}
A small tutorial on how you can configure and run SuspendApp on the different platforms.
For more details on Kotlin Multiplatform configuration consult the official documentation here.
Just ./gradlew build
the project, and launch the created binaries as shown in the sections belows.
Make sure you configure your NodeJS app to be executable.
js(IR) {
nodejs {
binaries.executable()
}
}
You can run your NodeJS app with the following node
command,
and if you press ctrl+c
within the first 2500ms you will see the following output.
./gradlew compileProductionExecutableKotlinJs
node example/build/compileSync/js/main/productionExecutable/kotlin/suspendapp-example.js
App Started! Waiting until asked to shutdown.
^CCleaning up App... will take 10 seconds...
Done cleaning up. Will release app to exit
Make sure you configure your Native app(s) to be executable.
linuxX64 {
binaries.executable()
}
mingwX64 {
binaries.executable()
}
macosArm64 {
binaries.executable()
}
macosX64 {
binaries.executable()
}
You can run your Native app with the following command,
and if you press ctrl+c
within the first 2500ms you will see the following output.
./gradlew linkReleaseExecutableMacosArm64
build/bin/native/releaseExecutable/YourAppName.kexe
App Started! Waiting until asked to shutdown.
^CCleaning up App... will take 10 seconds...
Done cleaning up. Will release app to exit