feat: add database indices and testing
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
</manifest>
|
||||
+164
@@ -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<Context>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user