test(store): migration harness, restore path and a 5k-task check
Phase 6 of docs/OWN-STORE.md. MigrationTestHelper is wired against the committed v1 schema, so the first real migration only has to add its own case; the class KDoc says where it goes. app/schemas/ is added to the androidTest assets — the schema location comes from the KSP arg, not the Room Gradle plugin, so nothing wired the test assets automatically. The restore tests state the WAL premise directly rather than around it: a backup of the .db alone must lose whatever is still in the -wal, carrying the sidecars must keep it, and checkpointing first must make the .db alone sufficient. If the premise is wrong the first test fails instead of passing vacuously. Performance: 5,000 tasks and 20 FREQ=DAILY series — daily on purpose, so the per-series occurrence cap is the case being measured — through one full smart-list read. The ceiling is loose and the numbers are printed, because nobody has run this on hardware yet. Also fixes a lint error I introduced in the backup rules two commits ago. Naming any <include> makes everything else excluded by default, so the <exclude> for tasks.db.imported sat under no included path and FullBackupContent rejected it — lintDebug has been failing at HEAD since, and CI runs it. The same defect had a second, quieter half: those explicit includes had silently stopped DataStore being backed up at all, since it was only ever covered by the old file's "everything by default". Settings are listed back in explicitly.
This commit is contained in:
@@ -129,6 +129,10 @@ android {
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
// MigrationTestHelper reads the exported schemas out of the test APK's
|
||||
// assets, so app/schemas/ has to ship with the instrumented tests.
|
||||
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -203,6 +207,7 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(libs.truth)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The migration harness, proven against the committed schema in `app/schemas/`.
|
||||
*
|
||||
* There is one schema version today, so all there is to assert is that the helper
|
||||
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
||||
* export, the assets wiring and the identity hash all line up. That is the point:
|
||||
* the first real migration only has to add its own case.
|
||||
*
|
||||
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
||||
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
||||
* database, and add a test here shaped like this:
|
||||
*
|
||||
* ```
|
||||
* helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
||||
* }
|
||||
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
||||
* // read the seeded rows back — validation proves the shape, not the data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseMigrationTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
TasksDatabase::class.java,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun buildsV1FromTheExportedSchema() {
|
||||
helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)")
|
||||
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')")
|
||||
|
||||
db.query("SELECT title FROM tasks").use { cursor ->
|
||||
assertThat(cursor.moveToFirst()).isTrue()
|
||||
assertThat(cursor.getString(0)).isEqualTo("Buy milk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatesV1AgainstTheExportedSchema() {
|
||||
helper.createDatabase(TEST_DB, 1).close()
|
||||
|
||||
// No migrations to run: v1 is opened and checked against 1.json, which is
|
||||
// what proves the harness rather than the schema.
|
||||
helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db ->
|
||||
assertThat(db.version).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.measureTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
/**
|
||||
* The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a
|
||||
* smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which
|
||||
* includes expanding every series in memory.
|
||||
*
|
||||
* The assertion is a deliberately loose ceiling, so it catches a real regression
|
||||
* rather than CI jitter; the printed numbers are what the check is actually for.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabasePerformanceTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
delete()
|
||||
db = Room.databaseBuilder(context, TasksDatabase::class.java, DB)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Everything", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() {
|
||||
val seeded = measureTime { seed() }
|
||||
|
||||
// Discard the first read: it pays for statement compilation and page cache
|
||||
// warming, which a running app has already paid.
|
||||
source.tasks(TaskQuery(includeCompleted = true))
|
||||
val (tasks, elapsed) = measureTimedValue {
|
||||
source.tasks(TaskQuery(includeCompleted = true))
|
||||
}
|
||||
|
||||
println(
|
||||
"[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " +
|
||||
"in $elapsed (seed $seeded)",
|
||||
)
|
||||
// Expansion is bounded twice over: the read window is 1 year back and 2
|
||||
// forward, and each series stops at ExpansionWindow.maxOccurrences (500),
|
||||
// so the occurrence count cannot grow with the age of the series.
|
||||
assertThat(tasks.size).isAtLeast(TASK_COUNT)
|
||||
assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS)
|
||||
}
|
||||
|
||||
private fun seed() {
|
||||
val anchor = Clock.System.now() - 30.days
|
||||
val ids = ArrayList<Long>(TASK_COUNT)
|
||||
db.runInTransaction {
|
||||
repeat(TASK_COUNT) { index ->
|
||||
ids += source.insertTask(
|
||||
TaskForm(title = "Task $index", listId = listId, due = anchor + index.days),
|
||||
)
|
||||
}
|
||||
}
|
||||
db.runInTransaction {
|
||||
ids.take(SERIES_COUNT).forEach { id ->
|
||||
val entity = db.tasks().entity(id)!!
|
||||
db.tasks().update(
|
||||
entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
val base = context.getDatabasePath(DB)
|
||||
base.delete()
|
||||
listOf("-wal", "-shm").forEach { File(base.path + it).delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DB = "performance-test.db"
|
||||
const val TASK_COUNT = 5_000
|
||||
const val SERIES_COUNT = 20
|
||||
const val CEILING_MILLIS = 8_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The Auto Backup restore path, on disk.
|
||||
*
|
||||
* Auto Backup copies database files without checkpointing, and Room runs in WAL
|
||||
* mode — so `.db` alone can be a *stale* copy of a database whose recent writes
|
||||
* are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three
|
||||
* files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts
|
||||
* that both of those actually do what they claim, and that neither alone is an
|
||||
* assumption.
|
||||
*
|
||||
* A file copy of a live database stands in for the backup transport — the
|
||||
* transport is what Auto Backup does to these files, and it is not what is under
|
||||
* test here.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseRestoreTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
private var restored: TasksDatabase? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
delete(LIVE)
|
||||
delete(BACKUP)
|
||||
db = open(LIVE)
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
restored?.close()
|
||||
db.close()
|
||||
delete(LIVE)
|
||||
delete(BACKUP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roomRunsInWalMode() {
|
||||
// Everything below is only interesting because of this.
|
||||
assertThat(journalMode()).isEqualTo("wal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("only in the wal")
|
||||
|
||||
backUp(withSidecars = false)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("only in the wal")
|
||||
|
||||
backUp(withSidecars = true)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed", "only in the wal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkpointingFirstMakesTheDbFileAloneEnough() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("last write")
|
||||
|
||||
// What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore
|
||||
// that arrives without the sidecars.
|
||||
checkpoint()
|
||||
backUp(withSidecars = false)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed", "last write")
|
||||
}
|
||||
|
||||
// --- the moving parts -----------------------------------------------------
|
||||
|
||||
private fun open(name: String): TasksDatabase =
|
||||
Room.databaseBuilder(context, TasksDatabase::class.java, name)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
|
||||
private fun write(title: String) {
|
||||
source.insertTask(TaskForm(title = title, listId = listId))
|
||||
}
|
||||
|
||||
private fun journalMode(): String =
|
||||
db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0).lowercase()
|
||||
}
|
||||
|
||||
/** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */
|
||||
private fun checkpoint() {
|
||||
db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertThat(cursor.getInt(0)).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */
|
||||
private fun backUp(withSidecars: Boolean) {
|
||||
delete(BACKUP)
|
||||
val live = context.getDatabasePath(LIVE)
|
||||
val backup = context.getDatabasePath(BACKUP)
|
||||
live.copyTo(backup, overwrite = true)
|
||||
if (!withSidecars) return
|
||||
SIDECARS.forEach { suffix ->
|
||||
val from = File(live.path + suffix)
|
||||
if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the copy as a fresh install would and reports the task titles that survived. */
|
||||
private fun restore(): List<String> {
|
||||
restored?.close()
|
||||
val database = open(BACKUP).also { restored = it }
|
||||
return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title }
|
||||
}
|
||||
|
||||
private fun delete(name: String) {
|
||||
val base = context.getDatabasePath(name)
|
||||
base.delete()
|
||||
SIDECARS.forEach { File(base.path + it).delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIVE = "restore-live.db"
|
||||
const val BACKUP = "restore-backup.db"
|
||||
val SIDECARS = listOf("-wal", "-shm")
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,18 @@
|
||||
files without checkpointing, so the `-wal` sidecar can hold writes the
|
||||
`.db` alone does not — all three go in together, and the app checkpoints
|
||||
on ON_STOP so a restore is consistent either way.
|
||||
|
||||
Naming any <include> makes everything else excluded by default, so the
|
||||
archived dmfs database (`tasks.db.imported`, kept one release as the
|
||||
import's rollback path) is already left out. An explicit <exclude> for it
|
||||
would be redundant *and* rejected — lint's FullBackupContent check errors
|
||||
on an exclude that sits under no included path.
|
||||
|
||||
Settings live in DataStore, which this exclusion now also covers, so its
|
||||
sharedpref file is listed back in.
|
||||
-->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
|
||||
<!--
|
||||
The archived dmfs database. Kept on the device for one release as the
|
||||
import's rollback path, but it is a copy of data that has already been
|
||||
imported, so backing it up would double every task in the archive.
|
||||
-->
|
||||
<exclude domain="database" path="tasks.db.imported" />
|
||||
|
||||
<!-- Settings live in DataStore, which is backed up by default. -->
|
||||
<include domain="file" path="datastore/" />
|
||||
</full-backup-content>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!--
|
||||
See backup_rules.xml: the WAL sidecars travel with the database, and
|
||||
naming any <include> makes everything else excluded by default — which is
|
||||
what keeps the archived `tasks.db.imported` out without an <exclude> that
|
||||
lint would reject.
|
||||
-->
|
||||
<cloud-backup>
|
||||
<!-- See backup_rules.xml: the WAL sidecars travel with the database. -->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<exclude domain="database" path="tasks.db.imported" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<exclude domain="database" path="tasks.db.imported" />
|
||||
<include domain="file" path="datastore/" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
||||
Reference in New Issue
Block a user