From d537e4a9656ec6e8037a8d0a9715467a44a3a6a2 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 26 Sep 2026 13:13:02 -0400 Subject: [PATCH] Serve the keyword counts from an index that carries the version `keywords::list` is the vocabulary with a per-word photograph count, and `keywords::for_images` calls it on every selection change to redraw the keyword panel. On the reference library (58 words, 10,800 assignments) it cost 3.0-3.5 ms best-of-50 by catalog_bench, `for_images` 3.1-3.6 ms (6 and 5 ms on a busy machine). Per word, the count walks `keywords_term (keyword)` and, for each assignment, reads the `keywords` row to learn its version before probing `versions` for the image: SEARCH k USING INDEX keywords_term (keyword=?) SEARCH v USING INTEGER PRIMARY KEY (rowid=?) With `keywords_term_version (keyword, version_id)` the first step is index-only: SEARCH k USING COVERING INDEX keywords_term_version (keyword=?) SEARCH v USING INTEGER PRIMARY KEY (rowid=?) After: `list` 1.3 ms, `for_images` 1.5 ms, with the same answers (digests of both outputs compared on the reference library). The index is created on first use by `list`, with CREATE INDEX IF NOT EXISTS, not by a migration: a new schema version makes every older build refuse this catalog's snapshot at sync (`sync::remote_is_mergeable` compares `user_version` and nothing else), and a build that meets an extra index ignores it. Once the index exists the statement is a schema lookup, 8 us. A failure to create it -- a read-only or busy catalog -- is logged and the list is read without it, as before. --- core/dr-catalog/src/keywords.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/core/dr-catalog/src/keywords.rs b/core/dr-catalog/src/keywords.rs index 84e1b8f..073851f 100644 --- a/core/dr-catalog/src/keywords.rs +++ b/core/dr-catalog/src/keywords.rs @@ -244,7 +244,8 @@ pub fn delete(conn: &Connection, id: KeywordId) -> Result { /// every assignment, and a query per keyword would be one statement per word /// in the library. pub fn list(conn: &Connection) -> Result, CatalogError> { - let mut stmt = conn.prepare( + ensure_term_index(conn); + let mut stmt = conn.prepare_cached( // DISTINCT image, not row: a word on two versions of one frame is one // photograph, and reporting two is the kind of small lie that makes a // user stop trusting the counts. @@ -269,6 +270,27 @@ pub fn list(conn: &Connection) -> Result, CatalogError> { Ok(rows) } +/// The index [`list`]'s per-word count is served from: `keywords_term` +/// with the version beside the word, so the count reads no `keywords` row. +/// +/// `keywords_term` alone gave the row id, and each of the 10,800 assignments +/// on the reference library cost a probe of the table for its version -- +/// 4 ms of the keyword panel's redraw, on every selection change. +/// +/// Created on first use rather than by a migration, for the reason +/// `duplicates::ensure_probe_table` gives: a new schema version makes every +/// older build refuse this catalog's snapshot at sync, and an older build +/// that meets an extra index ignores it. Once it exists, the statement is a +/// lookup in the schema (microseconds). A failure to create it is logged and +/// the list read without it: the index is a speed-up, never an answer. +fn ensure_term_index(conn: &Connection) { + if let Err(e) = conn.execute_batch( + "CREATE INDEX IF NOT EXISTS keywords_term_version ON keywords(keyword, version_id);", + ) { + log::warn!("keywords: could not create keywords_term_version: {e}"); + } +} + /// Assign a keyword to images, creating the keyword if it is new. /// /// The bulk form is the *only* form, because keywording a selection is the