mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
library: stop artist art updates from reindexing artist search
The materialized art update rewrote every artist row on every library-changing sync. That fired the FTS5 content-sync triggers for each artist, which was the real cost: the same SQL is ~10ms with the triggers removed and ~4.7s for 100 changed artists with them in place. Only update rows whose cover lists actually changed, and replace the generated artist search triggers with UPDATE OF name, sortName so art-only writes never touch the FTS index. Existing databases get this through migration 36->37; fresh databases get it from the onCreate callback.
This commit is contained in:
+1962
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(driver)
|
||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.addCallback(SqliteLifecycleLogging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@ class LoggedMigration(
|
||||
}
|
||||
}
|
||||
|
||||
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
|
||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||
optimizeArtistSearchTriggers(connection)
|
||||
}
|
||||
}
|
||||
|
||||
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||
Log.i("sql", "created fresh database")
|
||||
|
||||
@@ -230,7 +230,10 @@ internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"coverArts = COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), ''), " +
|
||||
"downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), '') " +
|
||||
"WHERE sourceId = :sourceId"
|
||||
"WHERE sourceId = :sourceId AND (" +
|
||||
"coverArts != COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') " +
|
||||
"OR downloadedCoverArts != COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), ''))"
|
||||
|
||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||
@@ -248,7 +251,9 @@ internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
") GROUP BY artistId) " +
|
||||
"UPDATE artists SET downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), '') " +
|
||||
"WHERE sourceId = :sourceId AND id IN (:artistIds)"
|
||||
"WHERE sourceId = :sourceId AND id IN (:artistIds) AND " +
|
||||
"downloadedCoverArts != COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), '')"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
|
||||
@@ -705,6 +705,29 @@ val MIGRATION_35_36 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_36_37 =
|
||||
object : Migration(36, 37) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
optimizeArtistSearchTriggers(connection)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
|
||||
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
connection.execSQL(
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
|
||||
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
|
||||
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
|
||||
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||
val sourceIds = mutableListOf<Long>()
|
||||
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||
@@ -755,4 +778,5 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_33_34,
|
||||
MIGRATION_34_35,
|
||||
MIGRATION_35_36,
|
||||
MIGRATION_36_37,
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 36,
|
||||
version = 37,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
@@ -217,6 +218,49 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistSearchTriggersIgnoreArtOnlyUpdates() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 36)
|
||||
MIGRATION_36_37.migrate(migrated)
|
||||
|
||||
assertTrue(
|
||||
triggerSql(migrated, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
assertTrue(
|
||||
triggerSql(migrated, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theFreshDatabaseCallbackOptimizesTheArtistSearchTriggers() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { connection ->
|
||||
applySchema(connection, 37)
|
||||
connection.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
|
||||
)
|
||||
|
||||
ArtistSearchTriggerOptimization.onCreate(connection)
|
||||
val before = long(connection, "SELECT total_changes()")
|
||||
connection.execSQL("UPDATE artists SET coverArts = 'a' WHERE id = 'ar1'")
|
||||
|
||||
assertEquals(before + 1, long(connection, "SELECT total_changes()"))
|
||||
assertTrue(
|
||||
triggerSql(connection, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
||||
runTest {
|
||||
@@ -459,6 +503,16 @@ class MigrationsTest {
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
private fun triggerSql(
|
||||
connection: SQLiteConnection,
|
||||
trigger: String,
|
||||
): String =
|
||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").use { statement ->
|
||||
statement.bindText(1, trigger)
|
||||
check(statement.step()) { "No trigger $trigger" }
|
||||
statement.getText(0)
|
||||
}
|
||||
|
||||
private fun indexColumn(
|
||||
connection: SQLiteConnection,
|
||||
index: String,
|
||||
|
||||
Reference in New Issue
Block a user