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 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 16:14:36 +00:00
parent a11e3b6367
commit e1291c1627
2 changed files with 40 additions and 12 deletions

View File

@@ -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 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). - 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). - 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 ### Fixed

View File

@@ -191,6 +191,21 @@ pub fn location() -> String {
/// The Postgres connection option that keeps notices from being sent at all; see `connect`. /// The Postgres connection option that keeps notices from being sent at all; see `connect`.
const QUIET: &str = "options=-c%20client_min_messages%3Dwarning"; 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 { impl Db {
/// The connection pool as it stands, for /metrics (#194): connections open, those of them /// The connection pool as it stands, for /metrics (#194): connections open, those of them
/// idle, and the most it will open. /// idle, and the most it will open.
@@ -545,16 +560,13 @@ impl Db {
); );
let unread = counts( let unread = counts(
"unread", "unread",
"SELECT e.feed_id, count(*) AS n FROM entries e concat!(
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id "SELECT * FROM (SELECT sub.feed_id, (",
AND e.first_seen >= coalesce(sub.unread_from, 0) unread_in_sub!(),
LEFT JOIN entry_state s ") AS n FROM subscriptions sub
ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid WHERE sub.user_id = $1 AND (CAST($2 AS TEXT) IS NULL OR sub.feed_id = $2)) x
WHERE NOT coalesce(s.read, false) WHERE n > 0"
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",
vec![user_id.into(), one()], vec![user_id.into(), one()],
); );
let blocked = tracing::Instrument::instrument( let blocked = tracing::Instrument::instrument(
@@ -1561,7 +1573,23 @@ impl Db {
search: Option<&str>, search: Option<&str>,
) -> Result<i64> { ) -> Result<i64> {
let mut a = Args::default(); 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::<Vec<_>>().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?; let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?) Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
} }