Skip to content

Commit 2b64076

Browse files
committed
✨ feat(database): add health check support for DatabaseApi and registry management
- introduce DatabaseHealthRegistry for managing health checks of DatabaseApi instances - enhance DatabaseApi constructor to register health check name - implement DatabaseMicroserviceHealthContributor for microservice health checks - update shutdown method to unregister from health registry
1 parent 89556cf commit 2b64076

9 files changed

Lines changed: 183 additions & 19 deletions

File tree

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ dependencies {
1616
implementation(libs.bundles.exposed)
1717
implementation(libs.r2dbc.pool)
1818
implementation(libs.bundles.databaseDriver)
19+
20+
compileOnly(libs.surf.microservice)
1921
}
2022

2123
configurations.runtimeClasspath {

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ kotlin.stdlib.default.dependency=false
33
org.gradle.parallel=true
44
#org.gradle.caching=true
55
#org.gradle.configureondemand=true
6-
version=2.3.1
6+
version=2.3.2

gradle/libs.versions.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ exposed = "1.3.0"
33
r2dbc-mariadb = "1.4.1"
44
r2dbc-postgresql = "1.1.1.RELEASE"
55
r2dbc-pool = "1.0.2.RELEASE"
6+
surf-microservice = "2.+"
67

78
[libraries]
89
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
@@ -13,7 +14,7 @@ exposed-migration-r2dbc = { module = "org.jetbrains.exposed:exposed-migration-r2
1314
r2dbc-mariadb = { module = "org.mariadb:r2dbc-mariadb", version.ref = "r2dbc-mariadb" }
1415
r2dbc-postgresql = { module = "org.postgresql:r2dbc-postgresql", version.ref = "r2dbc-postgresql" }
1516
r2dbc-pool = { module = "io.r2dbc:r2dbc-pool", version.ref = "r2dbc-pool" }
16-
17+
surf-microservice = { module = "dev.slne.surf.microservice:surf-microservice-api-microservice", version.ref = "surf-microservice" }
1718

1819
[bundles]
1920
exposed = ["exposed-core", "exposed-r2dbc", "exposed-java-time", "exposed-json", "exposed-migration-r2dbc"]

gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
44
networkTimeout=10000
55
retries=0
66
retryBackOffMs=500

gradlew

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gradlew.bat

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/kotlin/dev/slne/surf/database/DatabaseApi.kt

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dev.slne.surf.database
33
import dev.slne.surf.api.core.util.getCallerClass
44
import dev.slne.surf.database.config.DatabaseConfig
55
import dev.slne.surf.database.config.DatabaseType
6+
import dev.slne.surf.database.health.DatabaseHealthRegistry
67
import dev.slne.surf.database.logger.ComponentSqlLogger
78
import io.r2dbc.pool.ConnectionPool
89
import io.r2dbc.pool.ConnectionPoolConfiguration
@@ -23,15 +24,28 @@ import org.slf4j.event.Level
2324
import reactor.netty.resources.LoopResources
2425
import java.nio.file.Path
2526
import java.time.Duration.ofMillis
27+
import java.util.concurrent.atomic.AtomicLong
2628

2729
/**
2830
* Small wrapper around an [R2dbcDatabase] instance.
2931
*
3032
* Use [create(Path, String, R2dbcDatabaseConfig.Builder.() -> Unit)] in production to build the connection pool from the
3133
* on-disk [DatabaseConfig]. A lower-level overload exists mainly for tests.
3234
*/
33-
class DatabaseApi internal constructor(val database: R2dbcDatabase) {
35+
class DatabaseApi internal constructor(
36+
val database: R2dbcDatabase,
37+
healthCheckName: String
38+
) {
39+
init {
40+
DatabaseHealthRegistry.register(
41+
databaseApi = this,
42+
name = healthCheckName
43+
)
44+
}
45+
3446
companion object {
47+
private val manualDatabaseSequence = AtomicLong()
48+
3549
/**
3650
* Creates a [DatabaseApi] using the [DatabaseConfig] located in/relative to [pluginPath].
3751
*
@@ -42,7 +56,6 @@ class DatabaseApi internal constructor(val database: R2dbcDatabase) {
4256
* @param poolName Optional pool name (defaults to a generated name based on the caller).
4357
* @param configCustomizer Optional customization hook for Exposed's [R2dbcDatabaseConfig].
4458
*/
45-
@OptIn(TestOnlyDatabaseApi::class)
4659
fun create(
4760
pluginPath: Path,
4861
fileName: String = "database.yml",
@@ -121,11 +134,12 @@ class DatabaseApi internal constructor(val database: R2dbcDatabase) {
121134
DatabaseType.POSTGRESQL -> PostgreSQLDialect()
122135
}
123136

124-
return create(
137+
return createDatabase(
125138
connectionFactory = pool,
126139
dialect = dialect,
127140
logger = logger,
128141
logLevel = logLevel,
142+
healthCheckName = poolName,
129143
configCustomizer = configCustomizer
130144
)
131145
}
@@ -151,21 +165,50 @@ class DatabaseApi internal constructor(val database: R2dbcDatabase) {
151165
logLevel: Level = Level.DEBUG,
152166
configCustomizer: R2dbcDatabaseConfig.Builder.() -> Unit = {}
153167
): DatabaseApi {
154-
val database = R2dbcDatabase.connect(connectionFactory, R2dbcDatabaseConfig {
155-
explicitDialect = dialect
156-
sqlLogger = ComponentSqlLogger(logger, logLevel)
157-
defaultR2dbcIsolationLevel = IsolationLevel.READ_UNCOMMITTED
158-
configCustomizer()
159-
})
160-
161-
return DatabaseApi(database)
168+
return createDatabase(
169+
connectionFactory = connectionFactory,
170+
dialect = dialect,
171+
logger = logger,
172+
logLevel = logLevel,
173+
healthCheckName = nextManualHealthCheckName(),
174+
configCustomizer = configCustomizer
175+
)
176+
}
177+
178+
private fun createDatabase(
179+
connectionFactory: ConnectionFactory,
180+
dialect: DatabaseDialect,
181+
logger: ComponentLogger,
182+
logLevel: Level,
183+
healthCheckName: String,
184+
configCustomizer: R2dbcDatabaseConfig.Builder.() -> Unit
185+
): DatabaseApi {
186+
val database = R2dbcDatabase.connect(
187+
connectionFactory,
188+
R2dbcDatabaseConfig {
189+
explicitDialect = dialect
190+
sqlLogger = ComponentSqlLogger(logger, logLevel)
191+
defaultR2dbcIsolationLevel = IsolationLevel.READ_UNCOMMITTED
192+
193+
configCustomizer()
194+
}
195+
)
196+
197+
return DatabaseApi(
198+
database = database,
199+
healthCheckName = healthCheckName
200+
)
162201
}
163202

164203
private fun generatePoolName(): String {
165204
val caller = getCallerClass(1)
166205
val callerName = caller?.simpleName ?: "unknown"
167206
return "j2bdc-pool-$callerName"
168207
}
208+
209+
private fun nextManualHealthCheckName(): String {
210+
return "database-${manualDatabaseSequence.incrementAndGet()}"
211+
}
169212
}
170213

171214
/**
@@ -174,7 +217,11 @@ class DatabaseApi internal constructor(val database: R2dbcDatabase) {
174217
* Call this during plugin/application shutdown to ensure connections are released.
175218
*/
176219
fun shutdown() {
177-
TransactionManager.closeAndUnregister(database)
220+
try {
221+
TransactionManager.closeAndUnregister(database)
222+
} finally {
223+
DatabaseHealthRegistry.unregister(this)
224+
}
178225
}
179226
}
180227

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package dev.slne.surf.database.health
2+
3+
import dev.slne.surf.database.DatabaseApi
4+
import java.util.concurrent.ConcurrentHashMap
5+
6+
internal data class RegisteredDatabase(
7+
val name: String,
8+
val api: DatabaseApi
9+
)
10+
11+
internal object DatabaseHealthRegistry {
12+
private val databases = ConcurrentHashMap<DatabaseApi, String>()
13+
14+
fun register(databaseApi: DatabaseApi, name: String) {
15+
databases[databaseApi] = name
16+
}
17+
18+
fun unregister(databaseApi: DatabaseApi) {
19+
databases.remove(databaseApi)
20+
}
21+
22+
fun snapshot(): List<RegisteredDatabase> {
23+
return databases.entries
24+
.map { (api, name) ->
25+
RegisteredDatabase(
26+
name = name,
27+
api = api
28+
)
29+
}
30+
.sortedBy(RegisteredDatabase::name)
31+
}
32+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package dev.slne.surf.database.health
2+
3+
import com.google.auto.service.AutoService
4+
import dev.slne.surf.microservice.api.microservice.health.MicroserviceHealthCheckResult
5+
import dev.slne.surf.microservice.api.microservice.health.MicroserviceHealthContributor
6+
import dev.slne.surf.microservice.api.microservice.health.MicroserviceHealthStatus
7+
import kotlinx.coroutines.async
8+
import kotlinx.coroutines.awaitAll
9+
import kotlinx.coroutines.supervisorScope
10+
import kotlinx.coroutines.withTimeoutOrNull
11+
import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction
12+
import kotlin.coroutines.cancellation.CancellationException
13+
import kotlin.time.Duration.Companion.seconds
14+
15+
private val DATABASE_HEALTH_TIMEOUT = 2.5.seconds
16+
17+
@AutoService(MicroserviceHealthContributor::class)
18+
internal class DatabaseMicroserviceHealthContributor : MicroserviceHealthContributor {
19+
override val name: String = "database"
20+
21+
override suspend fun check(): List<MicroserviceHealthCheckResult> {
22+
val databases = DatabaseHealthRegistry.snapshot()
23+
24+
return supervisorScope {
25+
databases
26+
.map { database ->
27+
async {
28+
checkDatabase(database)
29+
}
30+
}
31+
.awaitAll()
32+
}
33+
}
34+
35+
private suspend fun checkDatabase(
36+
database: RegisteredDatabase
37+
): MicroserviceHealthCheckResult = try {
38+
val completed = withTimeoutOrNull(DATABASE_HEALTH_TIMEOUT) {
39+
suspendTransaction(db = database.api.database) {
40+
exec("SELECT 1")
41+
}
42+
43+
true
44+
} ?: false
45+
46+
if (completed) {
47+
healthyResult(database.name)
48+
} else {
49+
unhealthyResult(
50+
instance = database.name,
51+
message = "Database health check timed out after $DATABASE_HEALTH_TIMEOUT."
52+
)
53+
}
54+
} catch (exception: CancellationException) {
55+
throw exception
56+
} catch (exception: Throwable) {
57+
unhealthyResult(
58+
instance = database.name,
59+
message = exception.message
60+
?.takeIf { it.isNotBlank() }
61+
?: exception.javaClass.name
62+
)
63+
}
64+
65+
private fun healthyResult(
66+
instance: String
67+
) = MicroserviceHealthCheckResult(
68+
component = name,
69+
instance = instance,
70+
status = MicroserviceHealthStatus.HEALTHY
71+
)
72+
73+
private fun unhealthyResult(
74+
instance: String,
75+
message: String
76+
) = MicroserviceHealthCheckResult(
77+
component = name,
78+
instance = instance,
79+
status = MicroserviceHealthStatus.UNHEALTHY,
80+
message = message
81+
)
82+
}

0 commit comments

Comments
 (0)