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 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 14:41:27 +00:00
parent 3d005da847
commit 35fca0a6a2

View File

@@ -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. // (#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 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))", "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())?; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
} }
@@ -1258,16 +1262,19 @@ impl Db {
) -> Result<Vec<EntryRow>> { ) -> Result<Vec<EntryRow>> {
let mut a = Args::default(); let mut a = Args::default();
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid); 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!( let sql = format!(
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description, "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.read, false) AS read, coalesce(s.flagged, false) AS flagged, e.image,
coalesce(s.duration, e.duration) AS duration, coalesce(s.duration, e.duration) AS duration,
e.episode, e.season, coalesce(s.position, 0) AS position e.episode, e.season, coalesce(s.position, 0) AS position
{from} FROM (SELECT e.feed_id, e.guid {from} ORDER BY {order} LIMIT {limit} OFFSET {offset}) p
ORDER BY {order} JOIN entries e ON e.feed_id = p.feed_id AND e.guid = p.guid
LIMIT {} OFFSET {}", LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
a.p(limit), ORDER BY {order}"
a.p(offset)
); );
let mut rows = self let mut rows = self
.rows(&sql, a.0) .rows(&sql, a.0)
@@ -2631,6 +2638,10 @@ mod tests {
assert_eq!(order("type", "asc").await.first().map(String::as_str), Some("n")); 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(); db.exec_for_test("DELETE FROM entries WHERE guid = 'n';").await.unwrap();
assert_eq!(order("published", "desc").await, ["c", "b", "a"]); 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<String> = 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(); db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap();
assert_eq!(order("kept", "desc").await[0], "a"); assert_eq!(order("kept", "desc").await[0], "a");
// A pin does not lift an item above the others in any other sort (#36). // A pin does not lift an item above the others in any other sort (#36).