From e1291c16279ed54043a097b5fedddd92ed047d0f Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 23 Oct 2026 16:14:36 +0000 Subject: [PATCH] Count unread feed by feed (#352) After #347 an unread count read only the items since each feed's unread_from, but Postgres guessed a range join across every feed keeps a third of its rows (17,000 where there are 200), so it hashed all 22,949 of the person's entry_state rows. Counted in a subquery per subscription, the feed and its unread_from are constants there, and it looks up the few items since and their read state by key: on production the feed list's unread counts went from 19.7 to 14.8 ms and All Subscriptions' unread count from 19 to 13. The feed list's unread counts and count_of for Unread without a search (All Subscriptions, a feed, an OPML's shows) use it; a page of unread items still joins, since it needs rows in order, not a count. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/CHANGELOG.md | 2 +- crates/ipx/src/db.rs | 50 ++++++++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 5b658d7..e2f1bb1 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A page of items asks for its count beside the items, and the feed list asks its five questions at once, where each waited for the one before (#346). - A Signal's count of items and of unread ones are worked out in one pass, not two (#348). - Artwork kept on disk is read and written off the threads that answer requests, and two requests for the same missing artwork fetch it once (#345, #349). -- Unread lists and counts read a feed's items from each person's oldest unread one on, which the daily cleanup works out, not the whole back catalogue (#347). +- Unread lists and counts read a feed's items from each person's oldest unread one on, which the daily cleanup works out, not the whole back catalogue, and unread counts are worked out feed by feed, without reading everything you have read (#347, #352). ### Fixed diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index ee4df67..fbe2178 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -191,6 +191,21 @@ pub fn location() -> String { /// The Postgres connection option that keeps notices from being sent at all; see `connect`. const QUIET: &str = "options=-c%20client_min_messages%3Dwarning"; +/// How many of one subscription's items, `sub`, are unread and not hidden: a query of its own +/// for each, so Postgres sees the feed and where its unread begins as constants and looks up the +/// few items since. Joined across every feed, it guessed a third of them and hashed all of the +/// person's read state, 22,949 rows for 44 unread (#352). +macro_rules! unread_in_sub { + () => { + "SELECT count(*) FROM entries e + LEFT JOIN entry_state s ON s.user_id = sub.user_id AND s.feed_id = e.feed_id AND s.guid = e.guid + WHERE e.feed_id = sub.feed_id AND e.first_seen >= coalesce(sub.unread_from, 0) + AND NOT coalesce(s.read, false) + AND NOT EXISTS (SELECT 1 FROM hidden h + WHERE h.user_id = sub.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid)" + }; +} + impl Db { /// The connection pool as it stands, for /metrics (#194): connections open, those of them /// idle, and the most it will open. @@ -545,16 +560,13 @@ impl Db { ); let unread = counts( "unread", - "SELECT e.feed_id, count(*) AS n FROM entries e - JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id - AND e.first_seen >= coalesce(sub.unread_from, 0) - LEFT JOIN entry_state s - ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid - WHERE NOT coalesce(s.read, false) - AND (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) - AND NOT EXISTS (SELECT 1 FROM hidden h - WHERE h.user_id = $1 AND h.feed_id = e.feed_id AND h.guid = e.guid) - GROUP BY e.feed_id", + concat!( + "SELECT * FROM (SELECT sub.feed_id, (", + unread_in_sub!(), + ") AS n FROM subscriptions sub + WHERE sub.user_id = $1 AND (CAST($2 AS TEXT) IS NULL OR sub.feed_id = $2)) x + WHERE n > 0" + ), vec![user_id.into(), one()], ); let blocked = tracing::Instrument::instrument( @@ -1561,7 +1573,23 @@ impl Db { search: Option<&str>, ) -> Result { let mut a = Args::default(); - let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feeds, filter, search, None, None)); + let sql = if filter == Filter::Unread && search.is_none() { + // Feed by feed, as the feed list counts (#352); the same feeds as `entries_from`. + let user = a.p(user_id); + let scope = match feeds { + Some([f]) => format!("sub.feed_id = {}", a.p(*f)), + Some([]) => "false".into(), + Some(fs) => format!("sub.feed_id IN ({})", fs.iter().map(|f| a.p(*f)).collect::>().join(", ")), + None => shown_in("hide_from_all", "sub"), + }; + format!( + "SELECT CAST(coalesce(sum(n), 0) AS BIGINT) AS n + FROM (SELECT ({}) AS n FROM subscriptions sub WHERE sub.user_id = {user} AND {scope}) x", + unread_in_sub!() + ) + } else { + format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feeds, filter, search, None, None)) + }; let r = self.rows(&sql, a.0).await?; Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?) }