From 35fca0a6a21032c64c1dd9c1169a0f4658d960ff Mon Sep 17 00:00:00 2001 From: rays Date: Sun, 11 Oct 2026 14:41:27 +0000 Subject: [PATCH] Page an item list from an index, then read only its rows (#188) All Subscriptions took 130-170 ms when used a minute before, and 0.5-1.9 s the first time after a quiet spell. Its sort needs each item's date, which was only in the item's row, so every one of a person's 13,700 rows was read, show notes inline and all, and cold that came off disk; count_in, which needs no date, took 20 ms throughout. entries_by_time holds the keys and dates, the page's keys are sorted and cut from it, and only the rows shown are read. On a copy of the shape in the test database: 619 pages read against 4,174, with no visit to the table. Neither change alone helped. Co-Authored-By: Claude Opus 5.5 --- src/db.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/db.rs b/src/db.rs index 3c5976d..ce5d480 100644 --- a/src/db.rs +++ b/src/db.rs @@ -73,6 +73,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> { // (#165). Also what the healthcheck's count and the Downloaded filter look for. "CREATE INDEX IF NOT EXISTS enclosures_on_disk ON enclosures (feed_id, guid) WHERE path IS NOT NULL", "CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))", + // Every key and date an item list sorts by, so `entries_where` pages from the index alone + // and visits the table only for the rows it shows. Sorting All Subscriptions from the + // table read all 13,700 of its rows, show notes and all, half a second to two cold (#188). + "CREATE INDEX IF NOT EXISTS entries_by_time ON entries (feed_id, guid, published, first_seen)", ] { orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; } @@ -1258,16 +1262,19 @@ impl Db { ) -> Result> { let mut a = Args::default(); let from = entries_from(&mut a, user_id, feed_id, filter, search, guid); + // The page's keys first, then its rows: the sort then needs only `entries_by_time`, not + // every item's row, and the show notes are read for the page alone (#188). + let (limit, offset) = (a.p(limit), a.p(offset)); + let user = a.p(user_id); let sql = format!( "SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description, coalesce(s.read, false) AS read, coalesce(s.flagged, false) AS flagged, e.image, coalesce(s.duration, e.duration) AS duration, e.episode, e.season, coalesce(s.position, 0) AS position - {from} - ORDER BY {order} - LIMIT {} OFFSET {}", - a.p(limit), - a.p(offset) + FROM (SELECT e.feed_id, e.guid {from} ORDER BY {order} LIMIT {limit} OFFSET {offset}) p + JOIN entries e ON e.feed_id = p.feed_id AND e.guid = p.guid + LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid + ORDER BY {order}" ); let mut rows = self .rows(&sql, a.0) @@ -2631,6 +2638,10 @@ mod tests { assert_eq!(order("type", "asc").await.first().map(String::as_str), Some("n")); db.exec_for_test("DELETE FROM entries WHERE guid = 'n';").await.unwrap(); assert_eq!(order("published", "desc").await, ["c", "b", "a"]); + // A page from the middle: the keys are paged before the rows are read (#188). + let page: Vec = db.entries_in(1, None, Filter::All, None, 1, 1, &order_sql("published", "desc")).await + .unwrap().into_iter().map(|e| e.guid).collect(); + assert_eq!(page, ["b"]); db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap(); assert_eq!(order("kept", "desc").await[0], "a"); // A pin does not lift an item above the others in any other sort (#36).