libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit c453b695724f3a066690b2cc94f9d590f29110e9
parent 2f0903fb0c92a316e625f035dca2e3ff9c3408df
Author: Florian Dold <dold@taler.net>
Date:   Fri, 11 Sep 2026 09:47:00 +0200

database: close notification connections on retry and shutdown

Diffstat:
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 12+++++++++---
Mlibeufin-bank/src/test/kotlin/DatabaseTest.kt | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-common/src/main/kotlin/db/notifications.kt | 34++++++++++++++++++----------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt | 12+++++++++---
4 files changed, 94 insertions(+), 22 deletions(-)

diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -56,7 +56,7 @@ class Database( // Withdrawal confirmation flow, the key is the public withdrawal UUID private val withdrawalFlow = ConcurrentHashMap<UUID, CountedSharedFlow<WithdrawalStatus>>() - init { + private val notificationWatcher = watchNotifications(pgSource, "libeufin_bank", LoggerFactory.getLogger("libeufin-bank-db-watcher"), mapOf( "bank_tx" to { val (debtor, creditor, debitRow, creditRow) = it.split(' ', limit = 4).map { it.toLong() } @@ -91,6 +91,13 @@ class Database( } } )) + + override fun close() { + try { + notificationWatcher.close() + } finally { + super.close() + } } /** Listen for new bank transactions for [account] */ @@ -164,4 +171,4 @@ enum class AbortResult { Success, UnknownOperation, AlreadyConfirmed -} -\ No newline at end of file +} diff --git a/libeufin-bank/src/test/kotlin/DatabaseTest.kt b/libeufin-bank/src/test/kotlin/DatabaseTest.kt @@ -20,6 +20,7 @@ import io.ktor.http.* import kotlinx.coroutines.* import org.junit.Test +import org.slf4j.LoggerFactory import tech.libeufin.bank.* import tech.libeufin.bank.db.AccountDAO.AccountCreationResult import tech.libeufin.bank.db.TanDAO.* @@ -36,8 +37,65 @@ import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertNotEquals class DatabaseTest { + + @Test + fun notificationConnectionsClose() = setup { db, cfg -> + val source = pgDataSource(cfg.dbCfg.dbConnStr) + val applicationName = "notification-test-${UUID.randomUUID()}" + source.applicationName = applicationName + val firstNotification = CompletableDeferred<Unit>() + val secondNotification = CompletableDeferred<Unit>() + var reconnect = true + + db.conn { observer -> + fun watcherPids(): List<Int> = observer.prepareStatement( + "SELECT pid FROM pg_stat_activity WHERE application_name = ? AND query = 'LISTEN watcher_test' AND state = 'idle'" + ).use { stmt -> + stmt.setString(1, applicationName) + stmt.executeQuery().use { rows -> + buildList { while (rows.next()) add(rows.getInt(1)) } + } + } + + val watcher = watchNotifications( + source, "libeufin_bank", LoggerFactory.getLogger("notification-test"), + mapOf("watcher_test" to { + if (reconnect) { + reconnect = false + firstNotification.complete(Unit) + // Exercise cleanup on the retry path as well as on close(). + throw IllegalStateException("test notification reconnect") + } + secondNotification.complete(Unit) + }) + ) + watcher.use { + val firstPid = withTimeout(5000) { + while (watcherPids().isEmpty()) delay(10) + watcherPids().single() + } + observer.execSQLUpdate("NOTIFY watcher_test, 'first'") + withTimeout(5000) { firstNotification.await() } + val secondPid = withTimeout(5000) { + while (true) { + val pids = watcherPids() + if (pids.size == 1 && pids.single() != firstPid) break + delay(10) + } + watcherPids().single() + } + assertNotEquals(firstPid, secondPid) + observer.execSQLUpdate("NOTIFY watcher_test, 'second'") + withTimeout(5000) { secondNotification.await() } + } + withTimeout(5000) { + while (watcherPids().isNotEmpty()) delay(10) + } + } + } // Testing the helper that creates the admin account. @Test diff --git a/libeufin-common/src/main/kotlin/db/notifications.kt b/libeufin-common/src/main/kotlin/db/notifications.kt @@ -19,14 +19,14 @@ package tech.libeufin.common.db -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.runBlocking import org.postgresql.ds.PGSimpleDataSource import org.slf4j.Logger import tech.libeufin.common.ExpoBackoffDecorr import tech.libeufin.common.fmtLog +import java.io.Closeable import java.util.concurrent.ConcurrentHashMap // SharedFlow that are manually counted for manual garbage collection @@ -40,14 +40,13 @@ fun watchNotifications( schema: String, logger: Logger, listeners: Map<String, (suspend (String) -> Unit)> -) { +): Closeable { val backoff = ExpoBackoffDecorr() - // Run notification logic in a separated thread - kotlin.concurrent.thread(isDaemon = true) { - runBlocking { - while (true) { - try { - val conn = pgSource.pgConnection(schema) + // JDBC notification reads block, so keep them off the caller's dispatcher. + val job = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + try { + pgSource.pgConnection(schema).use { conn -> // Listen to all notifications channels for (channel in listeners.keys) { @@ -56,24 +55,28 @@ fun watchNotifications( backoff.reset() - while (true) { - conn.getNotifications(0) // Block until we receive at least one notification + while (isActive) { + // Bound the blocking read so close() can cancel an idle watcher. + conn.getNotifications(1000) .forEach { // Dispatch try { listeners[it.name]!!(it.parameter) } catch (e: Exception) { + ensureActive() throw Exception("channel ${it.name} with input '${it.parameter}'", e) } } } - } catch (e: Exception) { - e.fmtLog(logger) - delay(backoff.next()) } + } catch (e: Exception) { + ensureActive() + e.fmtLog(logger) + delay(backoff.next()) } } } + return Closeable { runBlocking { job.cancelAndJoin() } } } /** Listen to flow from [map] for [key] using [lambda]*/ @@ -95,4 +98,4 @@ suspend fun <R, K, V> listen(map: ConcurrentHashMap<K, CountedSharedFlow<V>>, ke if (v.count > 0) v else null } } -} -\ No newline at end of file +} diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt @@ -100,7 +100,7 @@ class Database(dbConfig: DatabaseConfig, val currency: String): DbPool(dbConfig, private val incomingTxFlows: MutableSharedFlow<Long> = MutableSharedFlow() private val revenueTxFlows: MutableSharedFlow<Long> = MutableSharedFlow() - init { + private val notificationWatcher = watchNotifications(pgSource, "libeufin_nexus", LoggerFactory.getLogger("libeufin-nexus-db-watcher"), mapOf( "nexus_revenue_tx" to { val id = it.toLong() @@ -115,6 +115,13 @@ class Database(dbConfig: DatabaseConfig, val currency: String): DbPool(dbConfig, incomingTxFlows.emit(id) } )) + + override fun close() { + try { + notificationWatcher.close() + } finally { + super.close() + } } /** Listen for new taler outgoing transactions */ @@ -126,4 +133,4 @@ class Database(dbConfig: DatabaseConfig, val currency: String): DbPool(dbConfig, /** Listen for new incoming transactions */ suspend fun <R> listenRevenue(lambda: suspend (Flow<Long>) -> R): R = lambda(revenueTxFlows) -} -\ No newline at end of file +}