Skip to content

Commit bde7c47

Browse files
authored
fix: enforce LRU only over device-specific DBs, add one-time deletion of legacy DB on switch (guarded by prefs flag) (meshtastic#3648)
1 parent 8b7d032 commit bde7c47

4 files changed

Lines changed: 223 additions & 3 deletions

File tree

core/database/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@ dependencies {
3838
implementation(libs.kotlinx.serialization.json)
3939
implementation(libs.timber)
4040

41+
androidTestImplementation(libs.androidx.test.runner)
4142
androidTestImplementation(libs.androidx.test.ext.junit)
4243
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package org.meshtastic.core.database
19+
20+
import android.app.Application
21+
import android.content.Context
22+
import androidx.test.core.app.ApplicationProvider
23+
import androidx.test.ext.junit.runners.AndroidJUnit4
24+
import kotlinx.coroutines.delay
25+
import kotlinx.coroutines.runBlocking
26+
import org.junit.Assert.assertFalse
27+
import org.junit.Assert.assertTrue
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
31+
@RunWith(AndroidJUnit4::class)
32+
class DatabaseManagerLegacyCleanupTest {
33+
@Test
34+
fun deletes_legacy_db_on_switch_when_flag_not_set() = runBlocking {
35+
val app = ApplicationProvider.getApplicationContext<Application>()
36+
val prefs = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
37+
38+
// Reset the one-time flag
39+
prefs.edit().remove(DatabaseConstants.LEGACY_DB_CLEANED_KEY).apply()
40+
41+
// Ensure legacy DB file exists
42+
val legacyName = DatabaseConstants.LEGACY_DB_NAME
43+
val legacyFile = app.getDatabasePath(legacyName)
44+
// Create or overwrite the legacy DB file by opening it once
45+
app.openOrCreateDatabase(legacyName, Context.MODE_PRIVATE, null).close()
46+
assertTrue("Precondition: legacy DB should exist before switch", legacyFile.exists())
47+
48+
val manager = DatabaseManager(app)
49+
50+
// Switch to a non-null address so active DB != legacy
51+
manager.switchActiveDatabase("01:23:45:67:89:AB")
52+
53+
// Cleanup runs asynchronously; wait briefly for deletion
54+
var attempts = 0
55+
while (legacyFile.exists() && attempts < 20) {
56+
delay(100)
57+
attempts++
58+
}
59+
60+
assertFalse("Legacy DB should be deleted after switch", legacyFile.exists())
61+
}
62+
}

core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ class DatabaseManager @Inject constructor(private val app: Application) {
7979
suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
8080
val dbName = buildDbName(address)
8181

82+
// Remember the previously active DB name (if any) so we can record its last-used time as well.
83+
val previousDbName = _currentDb.value?.openHelper?.databaseName
84+
8285
// Fast path: no-op if already on this address
8386
if (_currentAddress.value == address && _currentDb.value != null) {
8487
markLastUsed(dbName)
@@ -93,10 +96,16 @@ class DatabaseManager @Inject constructor(private val app: Application) {
9396
_currentDb.value = db
9497
_currentAddress.value = address
9598
markLastUsed(dbName)
99+
// Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp
100+
// even on first run after upgrade where no timestamp might exist yet.
101+
previousDbName?.let { markLastUsed(it) }
96102

97103
// Defer LRU eviction so switch is not blocked by filesystem work
98104
managerScope.launch(Dispatchers.IO) { enforceCacheLimit(activeDbName = dbName) }
99105

106+
// One-time cleanup: remove legacy DB if present and not active
107+
managerScope.launch(Dispatchers.IO) { cleanupLegacyDbIfNeeded(activeDbName = dbName) }
108+
100109
Timber.i("Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}")
101110
}
102111

@@ -126,8 +135,23 @@ class DatabaseManager @Inject constructor(private val app: Application) {
126135
private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock {
127136
val limit = getCacheLimit()
128137
val all = listExistingDbNames()
129-
if (all.size <= limit) return
130-
val victims = all.filter { it != activeDbName }.sortedBy { lastUsed(it) }.take(all.size - limit)
138+
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
139+
val deviceDbs =
140+
all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
141+
Timber.d(
142+
"LRU check: limit=%d, active=%s, deviceDbs=%s",
143+
limit,
144+
anonymizeDbName(activeDbName),
145+
deviceDbs.joinToString(", ") { anonymizeDbName(it) },
146+
)
147+
if (deviceDbs.size <= limit) return
148+
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
149+
Timber.d(
150+
"LRU lastUsed(ms): %s",
151+
usageSnapshot.entries.joinToString(", ") { (name, ts) -> "${anonymizeDbName(name)}=$ts" },
152+
)
153+
val victims = selectEvictionVictims(deviceDbs, activeDbName, limit, usageSnapshot)
154+
Timber.i("LRU victims: %s", victims.joinToString(", ") { anonymizeDbName(it) })
131155
victims.forEach { name ->
132156
runCatching { dbCache.remove(name)?.close() }
133157
.onFailure { Timber.w(it, "Failed to close database %s", name) }
@@ -150,6 +174,28 @@ class DatabaseManager @Inject constructor(private val app: Application) {
150174
val active = _currentDb.value?.openHelper?.databaseName ?: defaultDbName()
151175
managerScope.launch(Dispatchers.IO) { enforceCacheLimit(activeDbName = active) }
152176
}
177+
178+
private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = mutex.withLock {
179+
if (prefs.getBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, false)) return
180+
val legacy = DatabaseConstants.LEGACY_DB_NAME
181+
if (legacy == activeDbName) {
182+
// Never delete the active DB; mark as cleaned to avoid repeated checks
183+
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
184+
return
185+
}
186+
val legacyFile = getDbFile(app, legacy)
187+
if (legacyFile != null) {
188+
runCatching { dbCache.remove(legacy)?.close() }
189+
.onFailure { Timber.w(it, "Failed to close legacy database %s before deletion", legacy) }
190+
val deleted = app.deleteDatabase(legacy)
191+
if (deleted) {
192+
Timber.i("Deleted legacy DB ${anonymizeDbName(legacy)}")
193+
} else {
194+
Timber.w("Attempted to delete legacy DB %s but deleteDatabase returned false", legacy)
195+
}
196+
}
197+
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
198+
}
153199
}
154200

155201
object DatabaseConstants {
@@ -162,6 +208,8 @@ object DatabaseConstants {
162208
const val MIN_CACHE_LIMIT: Int = 1
163209
const val MAX_CACHE_LIMIT: Int = 10
164210

211+
const val LEGACY_DB_CLEANED_KEY: String = "legacy_db_cleaned"
212+
165213
// Display/truncation and hash sizing for DB names
166214
const val DB_NAME_HASH_LEN: Int = 10
167215
const val DB_NAME_SEPARATOR_LEN: Int = 1
@@ -175,7 +223,16 @@ object DatabaseConstants {
175223
// File-private helpers (kept outside the class to reduce class function count)
176224
private fun defaultDbName(): String = DatabaseConstants.DEFAULT_DB_NAME
177225

178-
private fun normalizeAddress(addr: String?): String = addr?.uppercase()?.replace(":", "") ?: "DEFAULT"
226+
private fun normalizeAddress(addr: String?): String {
227+
val u = addr?.trim()?.uppercase()
228+
val normalized =
229+
when {
230+
u.isNullOrBlank() -> "DEFAULT"
231+
u == "N" || u == "NULL" -> "DEFAULT"
232+
else -> u.replace(":", "")
233+
}
234+
return normalized
235+
}
179236

180237
private fun shortSha1(s: String): String = MessageDigest.getInstance("SHA-1")
181238
.digest(s.toByteArray())
@@ -216,3 +273,37 @@ private fun buildRoomDb(app: Application, dbName: String): MeshtasticDatabase =
216273
.build()
217274

218275
private fun getDbFile(app: Application, dbName: String): File? = app.getDatabasePath(dbName).takeIf { it.exists() }
276+
277+
/**
278+
* Compute which DBs to evict using LRU policy.
279+
*
280+
* Rules:
281+
* - Only consider device-specific DBs (exclude legacy and default)
282+
* - Never evict the active DB
283+
* - If number of device DBs is within the limit, evict none
284+
* - Otherwise evict the (size - limit) least-recently-used DBs
285+
*
286+
* Pass a precomputed [lastUsedMsByDb] snapshot to avoid redundant IO/lookups.
287+
*/
288+
internal fun selectEvictionVictims(
289+
dbNames: List<String>,
290+
activeDbName: String,
291+
limit: Int,
292+
lastUsedMsByDb: Map<String, Long>,
293+
): List<String> {
294+
val deviceDbNames =
295+
dbNames.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
296+
val victims =
297+
if (limit < 1 || deviceDbNames.size <= limit) {
298+
emptyList()
299+
} else {
300+
val candidates = deviceDbNames.filter { it != activeDbName }
301+
if (candidates.isEmpty()) {
302+
emptyList()
303+
} else {
304+
val toEvict = deviceDbNames.size - limit
305+
candidates.sortedBy { lastUsedMsByDb[it] ?: 0L }.take(toEvict)
306+
}
307+
}
308+
return victims
309+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package org.meshtastic.core.database
19+
20+
import org.junit.Assert.assertEquals
21+
import org.junit.Assert.assertTrue
22+
import org.junit.Test
23+
24+
class DatabaseManagerEvictionTest {
25+
private val a = "meshtastic_database_a111111111"
26+
private val b = "meshtastic_database_b222222222"
27+
private val c = "meshtastic_database_c333333333"
28+
private val d = "meshtastic_database_d444444444"
29+
private val legacy = DatabaseConstants.LEGACY_DB_NAME // "meshtastic_database"
30+
private val defaultDb = DatabaseConstants.DEFAULT_DB_NAME // "meshtastic_database_default"
31+
32+
@Test
33+
fun `does not evict when count equals limit`() {
34+
val names = listOf(a, b, c)
35+
val victims =
36+
selectEvictionVictims(names, activeDbName = a, limit = 3, lastUsedMsByDb = names.associateWith { 100L })
37+
assertTrue(victims.isEmpty())
38+
}
39+
40+
@Test
41+
fun `never evicts active even if oldest`() {
42+
val names = listOf(a, b, c, d)
43+
val lastUsed = mapOf(a to 1L, b to 2L, c to 3L, d to 4L)
44+
val victims = selectEvictionVictims(names, activeDbName = a, limit = 3, lastUsedMsByDb = lastUsed)
45+
// Oldest overall is a, but active must not be evicted -> next oldest is b
46+
assertEquals(listOf(b), victims)
47+
}
48+
49+
@Test
50+
fun `evicts two oldest when over limit by two`() {
51+
val names = listOf(a, b, c, d)
52+
val lastUsed = mapOf(a to 10L, b to 20L, c to 30L, d to 40L)
53+
val victims = selectEvictionVictims(names, activeDbName = d, limit = 2, lastUsedMsByDb = lastUsed)
54+
// Need to evict 2; oldest are a, then b
55+
assertEquals(listOf(a, b), victims)
56+
}
57+
58+
@Test
59+
fun `excludes legacy and default from accounting`() {
60+
val names = listOf(a, b, legacy, defaultDb)
61+
val lastUsed = mapOf(a to 10L, b to 5L)
62+
val victims = selectEvictionVictims(names, activeDbName = a, limit = 1, lastUsedMsByDb = lastUsed)
63+
// Only device DBs a & b are counted; with limit 1 and active=a, evict b
64+
assertEquals(listOf(b), victims)
65+
}
66+
}

0 commit comments

Comments
 (0)