Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -44,6 +46,7 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import kotlin.concurrent.Volatile
import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager

/** Manages per-device Room database instances for node data, with LRU eviction. */
Expand All @@ -64,6 +67,10 @@ open class DatabaseManager(

private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName")

private var backfillJob: Job? = null

@Volatile private var hasDelayedFirstDeviceBackfill = false

override val cacheLimit: StateFlow<Int> =
datastore.data
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
Expand Down Expand Up @@ -150,8 +157,12 @@ open class DatabaseManager(
// One-time cleanup: remove legacy DB if present and not active
managerScope.launch(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) }

// Backfill FTS search index for any text messages missing messageText
managerScope.launch(dispatchers.io) { backfillSearchIndexIfNeeded(db) }
// Backfill FTS search index for any text messages missing messageText.
// On the first real device DB, defer this so it does not starve the single DB connection while
// the UI is collecting startup flows. The default DB should not consume the cold-start delay.
val shouldDelayBackfill = dbName != DatabaseConstants.DEFAULT_DB_NAME && !hasDelayedFirstDeviceBackfill
if (shouldDelayBackfill) hasDelayedFirstDeviceBackfill = true
scheduleSearchIndexBackfill(dbName = dbName, db = db, shouldDelayBackfill = shouldDelayBackfill)

Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
Expand Down Expand Up @@ -208,6 +219,7 @@ open class DatabaseManager(
}

private companion object {
private const val BACKFILL_COLD_START_DELAY_MS = 2_000L
val DB_TERMS = listOf("pool", "database", "connection", "sqlite")
}

Expand Down Expand Up @@ -309,6 +321,23 @@ open class DatabaseManager(
datastore.edit { it[legacyCleanedKey] = true }
}

@Suppress("TooGenericExceptionCaught")
private fun scheduleSearchIndexBackfill(dbName: String, db: MeshtasticDatabase, shouldDelayBackfill: Boolean) {
backfillJob?.cancel()
backfillJob =
managerScope.launch(dispatchers.io) {
try {
if (shouldDelayBackfill) delay(BACKFILL_COLD_START_DELAY_MS)
if (_currentDb.value !== db) return@launch
backfillSearchIndexIfNeeded(db)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.w(e) { "Failed to backfill search index for ${anonymizeDbName(dbName)}" }
}
}
}

/**
* Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema, then rebuilds the
* FTS index so search covers historical messages. The text is decoded in Kotlin from each packet's payload (see
Expand All @@ -333,6 +362,8 @@ open class DatabaseManager(

/** Closes all open databases and cancels background work. */
fun close() {
backfillJob?.cancel()
backfillJob = null
managerScope.cancel()
dbCache.values.forEach { it.close() }
dbCache.clear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.room3.DeleteColumn
import androidx.room3.DeleteTable
import androidx.room3.RoomDatabase
import androidx.room3.migration.AutoMigrationSpec
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.DeviceLinkDao
Expand Down Expand Up @@ -143,13 +144,14 @@ 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 serialize all reads and writes
* through a single connection (no separate reader pool).
* `maxNumOfWriters = 1`) so reads can run concurrently. Pass `false` to explicitly force
* [setSingleConnectionPool], serializing all reads and writes through one connection.
*
* **Android production passes `false`.** Under coroutine cancellation churn (e.g. DB switches via
* `flatMapLatest`), the Room KMP reader-pool permit semaphore can wedge: all reader connections report `Free`
* but `permits=0`, so every read acquisition times out indefinitely. Single-connection mode eliminates the
* separate reader permit pool. See `DatabaseBuilder.kt` (androidMain).
* **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 MUST pass `false`** for deterministic read-after-write: a pooled reader connection can
* serve a snapshot older than the latest write on the writer connection, so a read immediately after a write
Expand All @@ -159,11 +161,22 @@ abstract class MeshtasticDatabase : RoomDatabase() {
* **JVM/iOS production uses `true`** (the default). Revisit if desktop/iOS field logs show similar
* pool-exhaustion patterns under cancellation churn.
*/
@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) }
.setQueryCoroutineContext(ioDispatcher)
.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),
)
}
}

Expand Down