perf(database): rebuild the title index once per profile, not again on the second launch (#1900)

upgradeContentTitleFtsTokenizer recreates and rebuilds content_title_fts and
records migration:content-title-fts-remove-diacritics:v1, but not
migration:content-title-fts-trigram:v1. On a new profile the tokenizer
upgrade therefore did the first rebuild, and on the second launch
ensureContentTitleFts found its own marker missing and rebuilt the whole
index again, synchronously inside initDatabase on the main thread, right
after the first catalog import. A trigram rebuild costs about 4.5 s per
million titles (sqlite3 3.51, synthetic titles). Every J4 iteration paid it
too, since each launches a copy of a freshly seeded profile.

The upgrade's transaction now records both markers. The skipped-release
upgrade harness counts rebuilds across every initialization of a profile
and requires exactly one; it fails for all six cases without the fix.

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 07:37:27 +02:00
1 parent 5f55553f41
commit a9f4ae38ea
6 files changed
+62 -12

No files matched your search

@@ -358,7 +358,12 @@ describe('content title FTS tokenizer upgrade', () => {
expect.stringContaining("tokenize='trigram remove_diacritics 1'")
);
expect(rebuild.run).toHaveBeenCalled();
expect(marker.run).toHaveBeenCalled();
// The rebuild also covers the plain index migration; without its
// record the next launch rebuilds the whole index again.
expect(marker.run.mock.calls).toEqual([
['migration:content-title-fts-remove-diacritics:v1'],
['migration:content-title-fts-trigram:v1'],
]);
// One transaction, so a rejected CREATE rolls the drop back and the
// working index survives.
expect(transaction).toHaveBeenCalled();
+13 -9
View File
@@ -755,15 +755,19 @@ function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean {
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY);
// The rebuild is also all that `ensureContentTitleFts` does, so
// its record is written too. Without it the next launch rebuilds
// the whole index again, on the main thread, on the first launch
// after a fresh install has imported its catalog.
const markDone = sqliteDb.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
);
markDone.run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY);
markDone.run(CONTENT_TITLE_FTS_MIGRATION_KEY);
});
executeMigration();
@@ -71,6 +71,30 @@ function seed(sqlite: Database.Database) {
}
}
const titleIndexRebuilds: string[] = [];
/**
* Every full rebuild of the title FTS index goes through `prepare`. A profile
* needs one; another on a later launch re-indexes the whole catalog on the
* main thread before the app can read anything.
*/
function countTitleIndexRebuilds(connection: Database.Database): void {
const prepare = connection.prepare;
connection.prepare = function countingPrepare(
this: Database.Database,
sql: string
) {
if (
/content_title_fts\(content_title_fts\)\s+VALUES\s+\('rebuild'\)/.test(
sql
)
) {
titleIndexRebuilds.push(sql);
}
return prepare.call(this, sql);
} as typeof connection.prepare;
}
/**
* The performance capture counts one `exec` call as one SQL statement
* (apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts),
@@ -81,6 +105,7 @@ function seed(sqlite: Database.Database) {
function requireSingleStatementExec(): string[] {
const batches: string[] = [];
setDatabaseConnectionObserver((connection) => {
countTitleIndexRebuilds(connection);
const exec = connection.exec;
connection.exec = function singleStatementExec(sql: string) {
try {
@@ -230,6 +255,11 @@ async function main() {
[],
'Initialization must pass one SQL statement per exec call'
);
assert.equal(
titleIndexRebuilds.length,
1,
'The title index must be rebuilt once per profile, not on every launch'
);
process.stdout.write('upgrade verified');
}