Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GraalVM support #183

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
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
24 changes: 24 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Other technologies used:

- [SqlDelight](https://cashapp.github.io/sqldelight/) for the persistence layer
- [Kotest](https://kotest.io/) for testing
- [GraalVM](https://www.graalvm.org/) to build an optional native binary

## Running the project

Expand All @@ -43,3 +44,26 @@ curl -i 0.0.0.0:8080/readiness
```

Beware that `./gradlew run` doesn't properly run JVM Shutdown hooks, and the port remains bound.

### GraalVM

The project can be built into a [native image](https://www.graalvm.org/latest/reference-manual/native-image/) with GraalVM.

#### Getting started
1. Ensure a suitable [GraalVM is installed](https://www.graalvm.org/downloads/)
* Tested versions include:
* _Oracle GraalVM 21+35.1 (project default)_
* _Oracle GraalVM 17.0.8_
2. Build using `./gradlew nativeCompile`
* An image will be built at `/build/nativeCompile/ktor-arrow-sample`
3. Start `./gradlew nativeRun` (remember to `docker-compose up`)
* Verify with `curl -i 0.0.0.0:8080/readiness`

#### Known issues
* Ktor uses reflection internally when calling either `call.receive()` or `call.respond()`
* As a workaround, use `call.receiveText()`/`call.respondText()` and (de)serialize manually
* `nativeTestCompile` and `nativeTestRun` are not supported currently

#### Troubleshooting & helpful resources
* [Configuring GraalVM Gradle buildtool](https://graalvm.github.io/native-build-tools/latest/gradle-plugin.html#configuration)
* [Reachability metadata](https://www.graalvm.org/latest/reference-manual/native-image/metadata/)
43 changes: 42 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import org.graalvm.buildtools.gradle.dsl.NativeImageOptions
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

@Suppress("DSL_SCOPE_VIOLATION") plugins {
plugins {
application
alias(libs.plugins.kotest.multiplatform)
id(libs.plugins.kotlin.jvm.pluginId)
Expand All @@ -11,6 +12,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
alias(libs.plugins.sqldelight)
alias(libs.plugins.ktor)
alias(libs.plugins.spotless)
alias(libs.plugins.graalvm.buildtool)
}

application {
Expand Down Expand Up @@ -68,6 +70,45 @@ spotless {
}
}

graalvmNative {
metadataRepository {
enabled.set(true)
}
binaries {
named("main").configure {
configureNativeBuild(useQuickBuild = false)
}
named("test").configure {
configureNativeBuild(useQuickBuild = true)
}
}
}

fun NativeImageOptions.configureNativeBuild(
useQuickBuild: Boolean
) = apply {
verbose.set(true)
quickBuild.set(useQuickBuild)
javaLauncher.set(javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
})
buildArgs = listOf(
"-march=native",
"--enable-http",
"--enable-https",
"--install-exit-handlers",
"--report-unsupported-elements-at-runtime",
"--initialize-at-build-time=io.ktor,kotlin,io.github.nomisrev.routes",
"--initialize-at-build-time=org.slf4j.LoggerFactory",
"--initialize-at-build-time=ch.qos.logback",
"--initialize-at-build-time=kotlinx.serialization.modules.SerializersModuleKt",
"--initialize-at-build-time=kotlinx.serialization.json.Json\$Default",
"--initialize-at-build-time=kotlinx.serialization.internal.StringSerializer",
"--initialize-at-build-time=org.fusesource.jansi",
"-H:+ReportExceptionStackTraces",
)
}

dependencies {
implementation(libs.bundles.arrow)
implementation(libs.bundles.ktor.server)
Expand Down
4 changes: 4 additions & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ dependencies {
implementation(libs.kotlin.gradle)
implementation(libs.detekt.gradle)
}

kotlin {
jvmToolchain(19)
}
2 changes: 2 additions & 0 deletions buildSrc/src/main/kotlin/io/github/nomisrev/Detekt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import io.gitlab.arturbosch.detekt.extensions.DetektExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

fun Project.setupDetekt() {
plugins.apply("io.gitlab.arturbosch.detekt")
Expand All @@ -14,6 +15,7 @@ fun Project.setupDetekt() {
}

tasks.withType<Detekt>().configureEach {
jvmTarget = JvmTarget.JVM_19.target
exclude { "generated/sqldelight" in it.file.absolutePath }
reports {
html.required.set(true)
Expand Down
6 changes: 4 additions & 2 deletions libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ slugify="3.0.6"
suspendapp="0.4.0"
cohort="2.2.1"
spotless="6.22.0"
graalvm-buildtool="0.9.27"

[libraries]
arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" }
Expand All @@ -42,7 +43,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-defaultheaders = { module = "io.ktor:ktor-server-default-headers", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-server-tests = { module = "io.ktor:ktor-server-tests", version.ref = "ktor" }
ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" }
Expand Down Expand Up @@ -75,7 +76,7 @@ ktor-server = [
"ktor-server-cors",
"ktor-server-content-negotiation",
"ktor-server-defaultheaders",
"ktor-server-netty",
"ktor-server-cio",
"ktor-server-auth",
"ktor-serialization",
"ktor-server-resources"
Expand Down Expand Up @@ -111,3 +112,4 @@ kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", vers
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
graalvm-buildtool = { id = "org.graalvm.buildtools.native", version.ref = "graalvm-buildtool"}
16 changes: 7 additions & 9 deletions src/main/kotlin/io/github/nomisrev/env/ktor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,16 @@ val kotlinXSerializersModule = SerializersModule {
polymorphic(Any::class) { subclass(LoginUser::class, LoginUser.serializer()) }
}

val kotlinXSerializersFormat = Json {
serializersModule = kotlinXSerializersModule
isLenient = true
ignoreUnknownKeys = true
}

fun Application.configure() {
install(DefaultHeaders)
install(Resources) { serializersModule = kotlinXSerializersModule }
install(ContentNegotiation) {
json(
Json {
serializersModule = kotlinXSerializersModule
isLenient = true
ignoreUnknownKeys = true
}
)
}
install(ContentNegotiation) { json(kotlinXSerializersFormat) }
install(CORS) {
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
Expand Down
5 changes: 3 additions & 2 deletions src/main/kotlin/io/github/nomisrev/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import io.github.nomisrev.env.dependencies
import io.github.nomisrev.routes.health
import io.github.nomisrev.routes.routes
import io.ktor.server.application.Application
import io.ktor.server.netty.Netty
import io.ktor.server.cio.CIO
import io.ktor.server.routing.routing
import kotlinx.coroutines.awaitCancellation

fun main(): Unit = SuspendApp {
val env = Env()
resourceScope {
val dependencies = dependencies(env)
server(Netty, host = env.http.host, port = env.http.port) { app(dependencies) }
server(CIO, host = env.http.host, port = env.http.port) { app(dependencies) }
awaitCancellation()
}
}
Expand Down
15 changes: 13 additions & 2 deletions src/main/kotlin/io/github/nomisrev/routes/error.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ import io.github.nomisrev.JwtInvalid
import io.github.nomisrev.PasswordNotMatched
import io.github.nomisrev.UserNotFound
import io.github.nomisrev.UsernameAlreadyExists
import io.github.nomisrev.env.kotlinXSerializersFormat
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.util.pipeline.PipelineContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString

@Serializable data class GenericErrorModel(val errors: GenericErrorModelErrors)

Expand All @@ -32,7 +36,10 @@ context(PipelineContext<Unit, ApplicationCall>)
suspend inline fun <reified A : Any> Either<DomainError, A>.respond(status: HttpStatusCode): Unit =
when (this) {
is Either.Left -> respond(value)
is Either.Right -> call.respond(status, value)
is Either.Right ->
call.respondText(contentType = ContentType.Application.Json, status = status) {
kotlinXSerializersFormat.encodeToString(value)
}
}

@OptIn(ExperimentalSerializationApi::class)
Expand All @@ -57,4 +64,8 @@ suspend fun PipelineContext<Unit, ApplicationCall>.respond(error: DomainError):

private suspend inline fun PipelineContext<Unit, ApplicationCall>.unprocessable(
error: String
): Unit = call.respond(HttpStatusCode.UnprocessableEntity, GenericErrorModel(error))
): Unit =
call.respondText(
kotlinXSerializersFormat.encodeToString(GenericErrorModel(error)),
status = HttpStatusCode.UnprocessableEntity
)
8 changes: 6 additions & 2 deletions src/main/kotlin/io/github/nomisrev/routes/users.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import arrow.core.Either
import arrow.core.raise.either
import io.github.nomisrev.IncorrectJson
import io.github.nomisrev.auth.jwtAuth
import io.github.nomisrev.env.kotlinXSerializersFormat
import io.github.nomisrev.service.JwtService
import io.github.nomisrev.service.Login
import io.github.nomisrev.service.RegisterUser
Expand All @@ -13,7 +14,7 @@ import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.request.receiveText
import io.ktor.server.resources.get
import io.ktor.server.resources.post
import io.ktor.server.resources.put
Expand Down Expand Up @@ -105,4 +106,7 @@ fun Route.userRoutes(
@OptIn(ExperimentalSerializationApi::class)
private suspend inline fun <reified A : Any> PipelineContext<Unit, ApplicationCall>
.receiveCatching(): Either<IncorrectJson, A> =
Either.catchOrThrow<MissingFieldException, A> { call.receive() }.mapLeft { IncorrectJson(it) }
Either.catchOrThrow<MissingFieldException, A> {
kotlinXSerializersFormat.decodeFromString(call.receiveText())
}
.mapLeft { IncorrectJson(it) }
78 changes: 78 additions & 0 deletions src/main/resources/META-INF/native-image/jni-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
[
{
"name":"[Lcom.sun.management.internal.DiagnosticCommandArgumentInfo;"
},
{
"name":"[Lcom.sun.management.internal.DiagnosticCommandInfo;"
},
{
"name":"com.sun.management.internal.DiagnosticCommandArgumentInfo",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","java.lang.String","boolean","boolean","boolean","int"] }]
},
{
"name":"com.sun.management.internal.DiagnosticCommandInfo",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","java.lang.String","java.lang.String","java.lang.String","boolean","java.util.List"] }]
},
{
"name":"java.lang.Boolean",
"methods":[{"name":"getBoolean","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.Class"
},
{
"name":"java.lang.InternalError",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.String",
"methods":[{"name":"lastIndexOf","parameterTypes":["int"] }, {"name":"substring","parameterTypes":["int"] }]
},
{
"name":"java.lang.System",
"methods":[{"name":"getProperty","parameterTypes":["java.lang.String"] }, {"name":"setProperty","parameterTypes":["java.lang.String","java.lang.String"] }]
},
{
"name":"java.util.Arrays",
"methods":[{"name":"asList","parameterTypes":["java.lang.Object[]"] }]
},
{
"name":"net.rubygrapefruit.platform.NativeException"
},
{
"name":"net.rubygrapefruit.platform.internal.DefaultOsxMemoryInfo",
"methods":[{"name":"details","parameterTypes":["long","long","long","long","long","long","long","long","long"] }]
},
{
"name":"net.rubygrapefruit.platform.internal.FileStat",
"methods":[{"name":"details","parameterTypes":["int","int","int","int","long","long","int"] }]
},
{
"name":"net.rubygrapefruit.platform.internal.FileSystemList",
"methods":[{"name":"add","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","boolean","boolean","boolean"] }, {"name":"addForUnknownCaseSensitivity","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","boolean"] }]
},
{
"name":"net.rubygrapefruit.platform.internal.MutableSystemInfo",
"fields":[{"name":"hostname"}, {"name":"machineArchitecture"}, {"name":"osName"}, {"name":"osVersion"}]
},
{
"name":"net.rubygrapefruit.platform.internal.jni.AbstractFileEventFunctions$NativeFileWatcherCallback",
"methods":[{"name":"reportChangeEvent","parameterTypes":["int","java.lang.String"] }, {"name":"reportFailure","parameterTypes":["java.lang.Throwable"] }, {"name":"reportOverflow","parameterTypes":["java.lang.String"] }, {"name":"reportUnknownEvent","parameterTypes":["java.lang.String"] }]
},
{
"name":"net.rubygrapefruit.platform.internal.jni.NativeLogger",
"methods":[{"name":"getLogLevel","parameterTypes":[] }, {"name":"log","parameterTypes":["int","java.lang.String"] }]
},
{
"name":"org.gradle.launcher.daemon.bootstrap.GradleDaemon",
"methods":[{"name":"main","parameterTypes":["java.lang.String[]"] }]
},
{
"name":"sun.instrument.InstrumentationImpl",
"methods":[{"name":"<init>","parameterTypes":["long","boolean","boolean","boolean"] }, {"name":"loadClassAndCallAgentmain","parameterTypes":["java.lang.String","java.lang.String"] }, {"name":"loadClassAndCallPremain","parameterTypes":["java.lang.String","java.lang.String"] }, {"name":"transform","parameterTypes":["java.lang.Module","java.lang.ClassLoader","java.lang.String","java.lang.Class","java.security.ProtectionDomain","byte[]","boolean"] }]
},
{
"name":"sun.management.VMManagementImpl",
"fields":[{"name":"compTimeMonitoringSupport"}, {"name":"currentThreadCpuTimeSupport"}, {"name":"objectMonitorUsageSupport"}, {"name":"otherThreadCpuTimeSupport"}, {"name":"remoteDiagnosticCommandsSupport"}, {"name":"synchronizerUsageSupport"}, {"name":"threadAllocatedMemorySupport"}, {"name":"threadContentionMonitoringSupport"}]
}
]
12 changes: 12 additions & 0 deletions src/main/resources/META-INF/native-image/proxy-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[
{
"interfaces": [
"java.lang.reflect.TypeVariable"
]
},
{
"interfaces": [
"java.rmi.Remote"
]
}
]
Loading