From a232d4bfde9316c48c75dda6f5e3c8c3d257e98b Mon Sep 17 00:00:00 2001 From: rasputin Date: Mon, 8 Jun 2026 15:01:06 +0200 Subject: [PATCH] feat: add database indices and testing --- .idea/deploymentTargetSelector.xml | 44 +++++ app/build.gradle.kts | 26 ++- app/src/androidTest/AndroidManifest.xml | 7 + .../benchmark/PaymentDaoBenchmark.kt | 164 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 2 + .../gudariwallet/data/db/AppDatabase.kt | 15 +- .../data/db/PaymentRecordEntity.kt | 11 +- build.gradle.kts | 2 + gradle.properties | 13 ++ gradle/libs.versions.toml | 9 + settings.gradle.kts | 3 +- 11 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/AndroidManifest.xml create mode 100644 app/src/androidTest/java/com/bitcointxoko/gudariwallet/benchmark/PaymentDaoBenchmark.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 4fbe208..3d010bb 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,6 +13,50 @@ + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dcbe9d0..5ae5967 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,9 +29,13 @@ android { versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "ACTIVITY-MISSING" } + testBuildType = "benchmark" + buildTypes { release { isMinifyEnabled = false @@ -40,6 +44,13 @@ android { "proguard-rules.pro" ) } + create("benchmark") { + initWith(buildTypes.getByName("release")) + signingConfig = signingConfigs.getByName("debug") // sign with debug key for easy deployment + installation { + enableBaselineProfile = false // ← disables profile embedding for this build type only + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -61,6 +72,16 @@ android { getByName("release") { java.srcDirs("build/generated/ksp/release/kotlin") } + getByName("benchmark") { + java.srcDirs("build/generated/ksp/benchmark/kotlin") + } + } +} + +androidComponents { + onVariants(selector().withBuildType("benchmark")) { variant -> + variant.packaging.resources.excludes.add("baseline-prof.txt") + variant.packaging.resources.excludes.add("baseline.profm") } } @@ -79,6 +100,9 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.benchmark.junit4) + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..e166224 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/androidTest/java/com/bitcointxoko/gudariwallet/benchmark/PaymentDaoBenchmark.kt b/app/src/androidTest/java/com/bitcointxoko/gudariwallet/benchmark/PaymentDaoBenchmark.kt new file mode 100644 index 0000000..222f271 --- /dev/null +++ b/app/src/androidTest/java/com/bitcointxoko/gudariwallet/benchmark/PaymentDaoBenchmark.kt @@ -0,0 +1,164 @@ +package com.bitcointxoko.gudariwallet.benchmark + +import android.content.Context +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bitcointxoko.gudariwallet.data.db.AppDatabase +import com.bitcointxoko.gudariwallet.data.db.PaymentDao +import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PaymentDaoBenchmark { + + @get:Rule + val benchmarkRule = BenchmarkRule() + + private lateinit var db: AppDatabase + private lateinit var dao: PaymentDao + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.paymentDao() + + val now = System.currentTimeMillis() + val fakePayments = (1..5_000).map { i -> + PaymentRecordEntity( + checkingId = "checking_$i", + paymentHash = "hash_$i", + amountMsat = if (i % 2 == 0) (i * 1000L) else -(i * 1000L), + feeMsat = 1000L, + memo = if (i % 3 == 0) "Coffee payment $i" else null, + time = (now - i * 60_000L).toString(), + createdAt = (now / 1000) - (i * 60L), + status = when (i % 4) { + 0 -> "complete" + 1 -> "paid" + 2 -> "pending" + else -> "failed" + }, + bolt11 = "lnbc${i}n1...", + pending = i % 4 == 2, + preimage = "preimage_$i", + extra = null, + savedAt = now - (i * 60_000L), + pubkey = if (i % 5 == 0) "pubkey_$i" else null, + alias = if (i % 5 == 0) "alias_$i" else null + ) + } + runBlocking { dao.insertAll(fakePayments) } + } + + @After + fun teardown() { + db.close() + } + + @Test + fun getAll_baseline() = benchmarkRule.measureRepeated { + runBlocking { dao.getAll() } + } + + @Test + fun getPage_30rows() = benchmarkRule.measureRepeated { + runBlocking { dao.getPage(limit = 30, offset = 0) } + } + + @Test + fun getPage_30rows_deepOffset() = benchmarkRule.measureRepeated { + runBlocking { dao.getPage(limit = 30, offset = 2_000) } + } + + @Test + fun queryAll_noFilters() = benchmarkRule.measureRepeated { + runBlocking { + dao.queryAll(null, null, null, null, null, null) + } + } + + @Test + fun queryAll_withSearchQuery() = benchmarkRule.measureRepeated { + runBlocking { + dao.queryAll("Coffee", null, null, null, null, null) + } + } + + @Test + fun queryAll_statusFilter() = benchmarkRule.measureRepeated { + runBlocking { + dao.queryAll(null, "complete,paid", null, null, null, null) + } + } + + @Test + fun queryAll_combinedFilters() = benchmarkRule.measureRepeated { + val now = System.currentTimeMillis() / 1000 + runBlocking { + dao.queryAll( + searchQuery = "Coffee", + statusPattern = "complete,paid", + minAmountMsat = 10_000L, + maxAmountMsat = 5_000_000L, + minCreatedAt = now - 86_400 * 30, + maxCreatedAt = now + ) + } + } + + @Test + fun getByCheckingId_lookup() = benchmarkRule.measureRepeated { + runBlocking { dao.getByCheckingId("checking_2500") } + } + + @Test + fun count_rows() = benchmarkRule.measureRepeated { + runBlocking { dao.count() } + } + + @Test + fun concurrentReadWrite_walBenefit() = benchmarkRule.measureRepeated { + // Simulate a write happening concurrently with a read + // (WAL allows these to proceed in parallel; default journal blocks) + val writeJob = CoroutineScope(Dispatchers.IO).launch { + dao.insert( + PaymentRecordEntity( + checkingId = "concurrent_${System.nanoTime()}", + paymentHash = "hash_concurrent", + amountMsat = 1000L, + feeMsat = 10L, + memo = "concurrent write", + time = System.currentTimeMillis().toString(), + createdAt = System.currentTimeMillis() / 1000, + status = "complete", + bolt11 = "lnbc...", + pending = false, + preimage = "preimage", + extra = null, + savedAt = System.currentTimeMillis(), + pubkey = null, + alias = null + ) + ) + } + // Read while write is in-flight + runBlocking { + dao.getPage(limit = 30, offset = 0) + writeJob.join() + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 27857b6..8eee81b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,8 @@ + + diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt index 44aac37..31e0f3b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt @@ -14,7 +14,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase LnurlCacheEntity::class, NodeAliasCacheEntity::class, ], - version = 5, // ← bumped + version = 6, exportSchema = false ) @TypeConverters(PaymentConverters::class) @@ -70,13 +70,22 @@ abstract class AppDatabase : RoomDatabase() { } } - val MIGRATION_4_5 = object : Migration(4, 5) { + private val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE payment_records ADD COLUMN pubkey TEXT") db.execSQL("ALTER TABLE payment_records ADD COLUMN alias TEXT") } } + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE INDEX IF NOT EXISTS `index_payment_record_status` ON `payment_records` (`status`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_payment_record_created_at` ON `payment_records` (`createdAt`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_payment_record_amount_msat` ON `payment_records` (`amountMsat`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_payment_record_status_created_at` ON `payment_records` (`status`, `createdAt`)") + } + } + fun getInstance(context: Context): AppDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( @@ -84,7 +93,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "gudari_wallet.db" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) // ← added + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .build() .also { INSTANCE = it } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt index 6daff05..ac0f39e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt @@ -2,10 +2,19 @@ package com.bitcointxoko.gudariwallet.data.db import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters -@Entity(tableName = "payment_records") +@Entity( + tableName = "payment_records", + indices = [ + Index(value = ["status"]), // WHERE status LIKE ? + Index(value = ["createdAt"]), // WHERE created_at BETWEEN ? AND ? + Index(value = ["amountMsat"]), // WHERE amount_msat BETWEEN ? AND ? + Index(value = ["status", "createdAt"]) // composite: common combined filter + ] +) @TypeConverters(PaymentConverters::class) data class PaymentRecordEntity( @PrimaryKey diff --git a/build.gradle.kts b/build.gradle.kts index 0b5c6ec..73bab94 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.test) apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 8e9a99a..fb54c5d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,3 +22,16 @@ android.disallowKotlinSourceSets=false ksp.useKsp2=true ksp.incremental=true ksp.incremental.log=false + +# Enable configuration cache +org.gradle.configuration-cache=true + +# Parallel project execution (useful if you add more modules later) +org.gradle.parallel=true + +# Increase heap if you see OOM during KSP/compilation +# org.gradle.jvmargs=-Xmx4g -XX:+UseG1GC + +# Enable build caching (caches task outputs across clean builds) +org.gradle.caching=true + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3670f91..85bfb4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,9 @@ secp256k1-kmp = "0.23.0" lyricist = "1.8.0" material3 = "1.4.0" work = "2.9.1" +benchmark = "1.4.1" +runner = "1.5.2" +androidx-test-ext = "1.2.1" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -75,6 +78,9 @@ lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -82,3 +88,6 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "ko kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } +androidx-benchmark = { id = "androidx.benchmark", version.ref = "benchmark" } +android-library = { id = "com.android.library", version.ref = "agp" } +android-test = { id = "com.android.test", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 62b1393..12ef809 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,5 +23,4 @@ dependencyResolutionManagement { } rootProject.name = "Gudari Wallet" -include(":app") - \ No newline at end of file +include(":app") \ No newline at end of file