Run the item page's count and the feed list's queries together (#346)

entry_page awaited the page's query and then its count, about 40 and
24 ms on /api/entries, the busiest route; feed_list awaited five
independent queries in turn (entries, downloaded, unread, blocked,
feeds), 59 ms of a 67 ms /api/feeds, with the pool's connections idle.
Both now use tokio::try_join!. On SQLite they queue on its connection
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 15:28:09 +00:00
parent aa430f6d50
commit 21fa0ae476
3 changed files with 34 additions and 22 deletions

View File

@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Traces show more of where a request's time goes: a page of items' query and its files apart, artwork's cache, check and fetch, the status counts, and each part of changing or leaving a feed (#350).
- A page of items finds its files by feed as well as item: it read every file's row in the database, about a quarter of a second a page (#344).
- 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).
### Fixed

View File

@@ -521,22 +521,22 @@ impl Db {
// Of this person's feeds only, as the unread count is: counting every item of every feed on
// the server, 1534 feeds for someone reading 146, was 184-316 ms, 70% of the feed list's
// time (#165). The Directory reads this for every feed's title and artwork, not a count.
// Five queries that need nothing of each other, run at once: one after another they were
// 59 ms of a 67 ms feed list, on a pool of idle connections (#346).
let entries = counts(
"entries",
"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
WHERE (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) GROUP BY e.feed_id",
vec![user_id.into(), one()],
)
.await?;
);
let downloaded = counts(
"downloaded",
"SELECT x.feed_id, count(*) AS n FROM enclosures x
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = x.feed_id
WHERE x.path IS NOT NULL AND (CAST($2 AS TEXT) IS NULL OR x.feed_id = $2) GROUP BY x.feed_id",
vec![user_id.into(), one()],
)
.await?;
);
let unread = counts(
"unread",
"SELECT e.feed_id, count(*) AS n FROM entries e
@@ -549,22 +549,26 @@ impl Db {
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()],
)
.await?;
let mut blocked: HashMap<String, Vec<String>> = tracing::Instrument::instrument(
);
let blocked = tracing::Instrument::instrument(
blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm),
step("blocked"),
)
.await?
.into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect();
);
let mut rows = feeds::Entity::find();
if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id));
}
Ok(tracing::Instrument::instrument(rows.all(&self.orm), step("feeds"))
.await?
let rows = tracing::Instrument::instrument(rows.all(&self.orm), step("feeds"));
let (entries, downloaded, unread, blocked, rows) = tokio::try_join!(
entries,
downloaded,
unread,
async { blocked.await.map_err(anyhow::Error::from) },
async { rows.await.map_err(anyhow::Error::from) },
)?;
let mut blocked: HashMap<String, Vec<String>> =
blocked.into_iter().map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default())).collect();
Ok(rows
.into_iter()
.map(|f| {
let id = f.id.clone();

View File

@@ -1596,12 +1596,23 @@ async fn entry_page(
limit,
search = search.is_some(),
);
let mut rows = match signal {
let list = async {
match signal {
Some(id) => {
db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(in_list).await?
db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(in_list).await
}
None => db.entries_of(user_id, feeds, filter, search, page.offset, limit, &order).instrument(in_list).await,
}
None => db.entries_of(user_id, feeds, filter, search, page.offset, limit, &order).instrument(in_list).await?,
};
let count = async {
match signal {
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await,
None => db.count_of(user_id, feeds, filter, search).instrument(step("count_in")).await,
}
};
// The count beside the list, not after it: each waited on the other, about 40 and 24 ms on the
// busiest route (#346).
let (mut rows, total) = tokio::try_join!(list, count)?;
step("sanitize").in_scope(|| {
let mut sanitizer = feed_sanitizer();
for row in &mut rows {
@@ -1609,10 +1620,6 @@ async fn entry_page(
}
});
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
let total = match signal {
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await?,
None => db.count_of(user_id, feeds, filter, search).instrument(step("count_in")).await?,
};
Ok(Json(EntryPage { total, entries: rows }))
}