fix(database): force Room single-connection pool on all platforms (#6202)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-10 13:43:02 -05:00
committed by GitHub
parent 441227914e
commit 97935eb30d
4 changed files with 22 additions and 43 deletions

View File

@@ -32,13 +32,8 @@ import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
/**
* Returns a [RoomDatabase.Builder] configured for Android with the given [dbName].
*
* Android production deliberately opts out of Room KMP's multi-reader connection pool (`multiConnection = false`).
* Under coroutine cancellation churn (e.g. DB switches via `flatMapLatest`), the reader-pool permit semaphore can
* wedge: all reader connections report `Free` but `permits=0`, so every new read acquisition times out indefinitely.
* Single-connection mode serializes reads and writes through one connection, eliminating the separate reader permit
* pool entirely. JVM/iOS may still use the pool; see [MeshtasticDatabase.configureCommon].
* Returns a [RoomDatabase.Builder] configured for Android with the given [dbName]. All platforms use Room KMP's
* single-connection pool to avoid the reader-pool permit wedge; see [MeshtasticDatabase.configureCommon].
*/
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
val dbFile = ContextServices.app.getDatabasePath(dbName)
@@ -46,14 +41,14 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDa
name = dbFile.absolutePath,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.configureCommon(multiConnection = false)
.configureCommon()
.setDriver(BundledSQLiteDriver())
}
/** Returns a [RoomDatabase.Builder] configured for an in-memory Android database. */
actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase> =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(factory = { MeshtasticDatabaseConstructor.initialize() })
.configureCommon(multiConnection = false)
.configureCommon()
.setDriver(BundledSQLiteDriver())
/** Returns the Android directory where database files are stored. */

View File

@@ -153,41 +153,25 @@ abstract class MeshtasticDatabase : RoomDatabase() {
/**
* Configures a [RoomDatabase.Builder] with standard settings for this project.
*
* @param multiConnection when `true` (default), opens a multi-reader connection pool (`maxNumOfReaders = 4`,
* `maxNumOfWriters = 1`) so reads can run concurrently. Pass `false` to explicitly force
* [setSingleConnectionPool], serializing all reads and writes through one connection.
* All platforms force [setSingleConnectionPool]. Without it, Room defaults to a 4-reader pool for named
* databases, and under coroutine cancellation churn (e.g. DB switches via `flatMapLatest`) the reader-pool
* permit semaphore can wedge: all reader connections report `Free` but `permits=0`, so every read acquisition
* times out indefinitely ("Error code: 5, Timed out attempting to acquire a reader connection"). Android hit
* this first; desktop (Flathub, 2026-07-10) reproduced the identical wedge in field logs, so JVM/iOS were moved
* off the multi-reader pool too. Single-connection eliminates the separate reader permit pool entirely.
*
* **Android production passes `false`.** Without the explicit `setSingleConnectionPool()` call, Room defaults
* to a 4-reader pool for named databases regardless of whether `setMultipleConnectionPool` was called. Under
* coroutine cancellation churn (e.g. DB switches via `flatMapLatest`), the reader-pool permit semaphore can
* wedge: all reader connections report `Free` but `permits=0`, so every read acquisition times out
* indefinitely. Forcing single-connection eliminates the separate reader permit pool entirely.
*
* **In-memory databases (tests) pass `false`.** Room already serves an in-memory database (`name == null`) from
* a single connection regardless of the pool configuration, so this only makes the single-connection intent
* explicit and serializes the query dispatcher; it is not load-bearing for read-after-write.
* (`DeviceLinkRepositoryImplTest` is deterministic because it runs on the wall clock, not because of this flag;
* see that test's comments.)
*
* **JVM/iOS production uses `true`** (the default). Revisit if desktop/iOS field logs show similar
* pool-exhaustion patterns under cancellation churn.
* For in-memory databases (tests) this is a no-op for pooling — Room already serves `name == null` databases
* from a single connection — it just serializes the query dispatcher.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(
multiConnection: Boolean = true,
): RoomDatabase.Builder<T> = this.fallbackToDestructiveMigration(dropAllTables = false)
.apply {
if (multiConnection) {
setMultipleConnectionPool(maxNumOfReaders = 4, maxNumOfWriters = 1)
} else {
setSingleConnectionPool()
}
}
.setQueryCoroutineContext(
// limitedParallelism(1) has the same throughput ceiling as the single-connection pool
// (already serialized), so this only blocks the cancellation pileup — not real I/O concurrency.
if (multiConnection) ioDispatcher else ioDispatcher.limitedParallelism(1),
)
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
this.fallbackToDestructiveMigration(dropAllTables = false)
.setSingleConnectionPool()
.setQueryCoroutineContext(
// limitedParallelism(1) has the same throughput ceiling as the single-connection pool
// (already serialized), so this only blocks the cancellation pileup — not real I/O concurrency.
ioDispatcher.limitedParallelism(1),
)
}
}

View File

@@ -51,7 +51,7 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDa
/** Returns a [RoomDatabase.Builder] configured for an in-memory iOS database. */
actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase> =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(factory = { MeshtasticDatabaseConstructor.initialize() })
.configureCommon(multiConnection = false)
.configureCommon()
.setDriver(BundledSQLiteDriver())
/** Returns the iOS directory where database files are stored. */

View File

@@ -57,7 +57,7 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDa
/** Returns a [RoomDatabase.Builder] configured for an in-memory JVM database. */
actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase> =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(factory = { MeshtasticDatabaseConstructor.initialize() })
.configureCommon(multiConnection = false)
.configureCommon()
.setDriver(BundledSQLiteDriver())
/** Returns the JVM/Desktop directory where database files are stored. */