refactor(service): harden KMP service layer — database init, connection reliability, handler decomposition (#4992)

This commit is contained in:
James Rich 2026-04-04 13:07:44 -05:00 committed by GitHub
parent e111b61e4e
commit 6af3ad6f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 3808 additions and 735 deletions

View file

@ -17,8 +17,10 @@
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room3.Room
import androidx.room3.RoomDatabase
@ -63,5 +65,7 @@ actual fun deleteDatabase(dbName: String) {
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
/** Creates an Android DataStore for database preferences. */
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> =
PreferenceDataStoreFactory.create(produceFile = { ContextServices.app.preferencesDataStoreFile(name) })
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
produceFile = { ContextServices.app.preferencesDataStoreFile(name) },
)

View file

@ -62,7 +62,6 @@ open class DatabaseManager(
private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName")
// Expose the DB cache limit as a reactive stream so UI can observe changes.
override val cacheLimit: StateFlow<Int> =
datastore.data
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
@ -81,26 +80,35 @@ open class DatabaseManager(
}
}
private val dbCache = mutableMapOf<String, MeshtasticDatabase>()
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
/**
* The currently active database, built lazily on first access. Room's `onOpen` callback is itself lazy (not invoked
* until the first query), so construction only allocates the builder and connection pool actual I/O is deferred.
*/
override val currentDb: StateFlow<MeshtasticDatabase> =
_currentDb
.filterNotNull()
.stateIn(
managerScope,
SharingStarted.Eagerly,
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build(),
)
.stateIn(managerScope, SharingStarted.Eagerly, getOrOpenDatabase(DatabaseConstants.DEFAULT_DB_NAME))
private val _currentAddress = MutableStateFlow<String?>(null)
val currentAddress: StateFlow<String?> = _currentAddress
private val dbCache = mutableMapOf<String, MeshtasticDatabase>() // key = dbName
/** Initialize the active database for [address]. */
suspend fun init(address: String?) {
switchActiveDatabase(address)
}
/**
* Returns a cached [MeshtasticDatabase] or builds a new one for [dbName]. The caller must hold [mutex] when
* modifying [dbCache] concurrently; however, this helper is also used from [currentDb]'s `initialValue` where the
* mutex is not yet relevant (single-threaded construction).
*/
private fun getOrOpenDatabase(dbName: String): MeshtasticDatabase =
dbCache.getOrPut(dbName) { getDatabaseBuilder(dbName).build() }
/** Switch active database to the one associated with [address]. Serialized via mutex. */
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
val dbName = buildDbName(address)
@ -115,9 +123,11 @@ open class DatabaseManager(
}
// Build/open Room DB off the main thread
val db =
dbCache[dbName]
?: withContext(dispatchers.io) { getDatabaseBuilder(dbName).build() }.also { dbCache[dbName] = it }
val db = withContext(dispatchers.io) { getOrOpenDatabase(dbName) }
if (previousDbName != null && previousDbName != dbName) {
closeCachedDatabase(previousDbName)
}
_currentDb.value = db
_currentAddress.value = address
@ -134,6 +144,21 @@ open class DatabaseManager(
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
/**
* Closes and removes a cached database by name. Safe to call even if the database was already closed or not in the
* cache. Does NOT delete the underlying file the database can be re-opened on next access.
*
* On JVM/Desktop, Room KMP has no auto-close timeout (Android-only API), so idle databases hold open SQLite
* connections (5 per WAL-mode DB) indefinitely until explicitly closed. This method is the primary mechanism for
* releasing those connections when a database is no longer the active target.
*/
private fun closeCachedDatabase(dbName: String) {
val removed = dbCache.remove(dbName) ?: return
runCatching { removed.close() }
.onFailure { Logger.w(it) { "Failed to close cached database ${anonymizeDbName(dbName)}" } }
Logger.d { "Closed inactive database ${anonymizeDbName(dbName)} to free connections" }
}
private val limitedIo = dispatchers.io.limitedParallelism(4)
/** Execute [block] with the current DB instance. */
@ -184,9 +209,8 @@ open class DatabaseManager(
val limit = getCurrentCacheLimit()
val all = listExistingDbNames()
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
val deviceDbs = all.filterNot {
it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME
}
val deviceDbs =
all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
if (deviceDbs.size <= limit) return@withLock
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
@ -194,12 +218,12 @@ open class DatabaseManager(
victims.forEach { name ->
runCatching {
dbCache.remove(name)?.close()
closeCachedDatabase(name)
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
}
.onFailure { Logger.w(it) { "Failed to evict database $name" } }
Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" }
.onSuccess { Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" } }
.onFailure { Logger.w(it) { "Failed to evict database ${anonymizeDbName(name)}" } }
}
}
@ -219,11 +243,11 @@ open class DatabaseManager(
if (fs.exists(legacyPath)) {
runCatching {
dbCache.remove(legacy)?.close()
closeCachedDatabase(legacy)
deleteDatabase(legacy)
}
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
.onSuccess { Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" } }
.onFailure { Logger.w(it) { "Failed to delete legacy database ${anonymizeDbName(legacy)}" } }
}
datastore.edit { it[legacyCleanedKey] = true }
}

View file

@ -17,8 +17,10 @@
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
@ -31,8 +33,10 @@ import java.io.File
/**
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
*
* Shared between `core:database` and `desktop` module to ensure all persistent data is co-located.
*/
private fun desktopDataDir(): String {
fun desktopDataDir(): String {
val override = System.getenv("MESHTASTIC_DATA_DIR")
if (!override.isNullOrBlank()) return override
return System.getProperty("user.home") + "/.meshtastic"
@ -74,5 +78,8 @@ actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
File(dir).mkdirs()
return PreferenceDataStoreFactory.create(produceFile = { File(dir, "$name.preferences_pb") })
return PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
produceFile = { File(dir, "$name.preferences_pb") },
)
}