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

@@ -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<i64> {
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?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
}