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). - 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 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 ### 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 // 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 // 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. // 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( let entries = counts(
"entries", "entries",
"SELECT e.feed_id, count(*) AS n FROM entries e "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 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", WHERE (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) GROUP BY e.feed_id",
vec![user_id.into(), one()], vec![user_id.into(), one()],
) );
.await?;
let downloaded = counts( let downloaded = counts(
"downloaded", "downloaded",
"SELECT x.feed_id, count(*) AS n FROM enclosures x "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 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", 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()], vec![user_id.into(), one()],
) );
.await?;
let unread = counts( let unread = counts(
"unread", "unread",
"SELECT e.feed_id, count(*) AS n FROM entries e "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) WHERE h.user_id = $1 AND h.feed_id = e.feed_id AND h.guid = e.guid)
GROUP BY e.feed_id", GROUP BY e.feed_id",
vec![user_id.into(), one()], vec![user_id.into(), one()],
) );
.await?; let blocked = tracing::Instrument::instrument(
let mut blocked: HashMap<String, Vec<String>> = tracing::Instrument::instrument(
blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm), blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm),
step("blocked"), step("blocked"),
) );
.await?
.into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect();
let mut rows = feeds::Entity::find(); let mut rows = feeds::Entity::find();
if let Some(id) = only { if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id)); rows = rows.filter(feeds::Column::Id.eq(id));
} }
Ok(tracing::Instrument::instrument(rows.all(&self.orm), step("feeds")) let rows = tracing::Instrument::instrument(rows.all(&self.orm), step("feeds"));
.await? 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() .into_iter()
.map(|f| { .map(|f| {
let id = f.id.clone(); let id = f.id.clone();

View File

@@ -1596,12 +1596,23 @@ async fn entry_page(
limit, limit,
search = search.is_some(), search = search.is_some(),
); );
let mut rows = match signal { let list = async {
Some(id) => { match signal {
db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(in_list).await? Some(id) => {
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(|| { step("sanitize").in_scope(|| {
let mut sanitizer = feed_sanitizer(); let mut sanitizer = feed_sanitizer();
for row in &mut rows { 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?; 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 })) Ok(Json(EntryPage { total, entries: rows }))
} }