perf(db): the channel manager and the feed sidebar read an index, not the video heap

GET /api/channels summed every subscribed channel's videos by reading each video's heap page
(681 MB on prod): 51-61 ms warm, but 2.25 s on a first open and 6.4 s under EXPLAIN once the
table had left memory. /feed/summary and /feed/years did the same (~140 ms warm, ~800 ms cold).
Migration 0095 replaces ix_videos_channel_pub_desc with the same key INCLUDING the columns
those queries read, so they are Index Only Scans over a 25 MB index. A test holds the queries
and the index's column list together.

The admin Configuration page did a db.get per setting (two for an unset one): it now reads
every override in one query (~40 ms of ~55 on prod).
This commit is contained in:
2026-10-02 03:16:37 +02:00
parent 20010178b0
commit e995c664c6
7 changed files with 220 additions and 26 deletions
@@ -0,0 +1,83 @@
"""The channel manager's sums and the feed sidebar's counts read the index alone (E31 S4d).
`GET /api/channels` counts each subscribed channel's videos and sums their durations; `/feed/summary`
and `/feed/years` count the reader's videos per channel and per year under the default filters. They
used to read every video's heap page for that (681 MB on prod), a 2–6 s first open once something else
had pushed the table out of memory. Migration 0095 makes `ix_videos_channel_pub_cover` carry every
column those queries read, so Postgres answers them with Index Only Scans. This test holds the queries
and the index together: a column added to one and not the other fails it. DB lane.
"""
import re
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
from app.feedquery import filtered_query
from app.models import Video
from app.routes.channels import channel_aggregates
INDEX = "ix_videos_channel_pub_cover"
# The feed's own defaults (routes/feed.py _feed_params): what the sidebar asks on a plain open.
FEED_DEFAULTS = {
"tags": [],
"tag_modes": {"language": "or", "topic": "or", "other": "or"},
"untagged": False,
"channel_ids": [],
"q": None,
"min_duration": None,
"max_duration": None,
"max_age_days": None,
"published_after": None,
"published_before": None,
"show_normal": True,
"include_shorts": False,
"include_live": False,
"show": "unwatched",
"scope": "my",
"library_source": "organic",
}
def _index_columns(db) -> list[str]:
# Key columns first, then the INCLUDE ones, in the index's own order.
return list(
db.execute(
text(
"SELECT a.attname FROM pg_index i "
"JOIN pg_class c ON c.oid = i.indexrelid "
"CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, n) "
"JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum "
"WHERE c.relname = :name ORDER BY k.n"
),
{"name": INDEX},
).scalars()
)
def _video_columns(stmt) -> set[str]:
# The columns of `videos` the statement as SENT reads: the compiled SQL, not the expression tree,
# which still carries the projection a with_only_columns replaced.
sql = str(stmt.compile(dialect=postgresql.dialect()))
return set(re.findall(r"\bvideos\.(\w+)", sql))
def _assert_covered(db, read: set[str]) -> None:
held = _index_columns(db)
assert held, f"{INDEX} is missing"
# It leads with channel_id, so it also serves every per-channel lookup the old index did.
assert held[0] == "channel_id"
assert read <= set(held), f"read but not in {INDEX}: {sorted(read - set(held))}"
def test_the_channel_sums_read_only_columns_the_index_holds(db):
_assert_covered(db, _video_columns(channel_aggregates(["UC_x"])))
def test_the_feed_sidebar_counts_read_only_columns_the_index_holds(db, make_user):
# feed_summary groups the default filter by channel, feed_years by publish date.
base, _rank = filtered_query(db, make_user(), **FEED_DEFAULTS)
_assert_covered(
db, _video_columns(base.with_only_columns(Video.channel_id, Video.published_at))
)
@@ -0,0 +1,39 @@
"""The admin Configuration page reads every override in one query (E31 S4d).
`sysconfig.describe()` did a `db.get` per key to tell whether an override was set, and `get()` did one
more for its value: ~55 keys, and two round trips for a key with no row, since the session cannot cache
an absence. That was ~40 ms of the page's ~55 on prod. It now reads the table once. DB lane.
"""
from sqlalchemy import event
from app import sysconfig
def _statements(db, fn) -> int:
seen: list[str] = []
engine = db.get_bind()
listener = lambda *a, **k: seen.append(a[2]) # noqa: E731 — (conn, cursor, statement, …)
event.listen(engine, "before_cursor_execute", listener)
try:
fn()
finally:
event.remove(engine, "before_cursor_execute", listener)
return len(seen)
def test_describe_reads_the_overrides_once(db):
sysconfig.set_value(db, "quota_daily_budget", 5000)
db.commit()
assert _statements(db, lambda: sysconfig.describe(db)) == 1
def test_describe_shows_the_stored_value_and_the_default(db):
sysconfig.set_value(db, "quota_daily_budget", 5000)
db.commit()
items = {i["key"]: i for g in sysconfig.describe(db)["groups"].values() for i in g}
assert items["quota_daily_budget"]["is_set"] is True
assert items["quota_daily_budget"]["value"] == 5000
other = next(i for k, i in items.items() if k != "quota_daily_budget" and not i["secret"])
assert other["is_set"] is False
assert other["value"] == other["default"]
+7 -3
View File
@@ -67,19 +67,23 @@ def test_the_env_accepts_any_letter_case_and_rejects_an_unknown_value():
class _Row:
def __init__(self, value):
self.value, self.encrypted = value, False
def __init__(self, key, value):
self.key, self.value, self.encrypted = key, value, False
class _FakeDb:
"""Just enough of a Session for sysconfig's reads: one stored override row per key."""
def __init__(self, **rows):
self.rows = {k: _Row(v) for k, v in rows.items()}
self.rows = {k: _Row(k, v) for k, v in rows.items()}
def get(self, _model, key):
return self.rows.get(key)
def scalars(self, _stmt):
# describe() reads every override at once (E31 S4d).
return list(self.rows.values())
def test_a_stored_value_reads_back_canonical_and_a_stale_one_falls_back_to_the_default():
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="OFF"), "plex_transcode_hw") == "off"