refactor(reader): the HTTP layer reads through queries and writes through the service

Feed, playlists, saved views, tags and the Home pins do no database work in their routes any more:
reads go through reader/queries (and feedquery for a feed page, its counts and facets), writes
through reader/service and discover/pins, which flush; the handlers commit. Reader is fenced below
its HTTP layer too: its owner filters go through userscope, and the catalog's jobs ask Reader
instead of joining its tables (the auto-tagger's system tags, the cleanup jobs' keep rule, the
maintenance notice). A playlist sync's catalog ingest moves to sync/videos.
This commit is contained in:
2026-10-19 01:20:14 +02:00
parent e82f87acb0
commit dbb4065e7b
24 changed files with 1489 additions and 915 deletions
+28 -10
View File
@@ -111,9 +111,6 @@ UNIT_ROUTES = {
# route's count is only the unit tables it imports; it is paid by the sprint of the unit it reaches.
ALLOWED = {
"channels.py": 83,
"discover.py": 7,
"feed.py": 36,
"playlists.py": 72,
"plex/_shared.py": 1,
"plex/admin.py": 12,
"plex/browse.py": 93,
@@ -124,11 +121,9 @@ ALLOWED = {
"plex/playback.py": 42,
"plex/playlists.py": 80,
"runs.py": 1,
"saved_views.py": 17,
"scheduler.py": 3,
"search.py": 46,
"sync.py": 38,
"tags.py": 10,
}
# The session methods that read or write data. `commit` and `rollback` stay with the handler.
@@ -149,7 +144,7 @@ SESSION_DATA = {
# What a route may name from SQLAlchemy: the session's type, for its `db: Session` parameter, and
# the errors its commit raises (the commit is the handler's, so is catching what it throws).
SQLALCHEMY_ALLOWED = {"Session"}
SQLALCHEMY_ALLOWED_MODULES = {"sqlalchemy.exc"}
SQLALCHEMY_ALLOWED_MODULES = {"sqlalchemy.exc", "sqlalchemy.orm.exc"}
# Objects a route owns and may set attributes on; anything else it writes to is a model.
OWN_OBJECTS = {"self", "cls", "request", "response"}
UNIT_MODEL_NAMES = {name: unit for unit, names in UNIT_MODELS.items() for name in names}
@@ -311,6 +306,7 @@ DETECTOR_CASES = {
"from app.capture.models import DownloadJob as J": 1,
"def f(cache): cache.get('k')": 0,
"from sqlalchemy.exc import IntegrityError\ntry:\n pass\nexcept IntegrityError:\n pass": 0,
"from sqlalchemy.orm.exc import StaleDataError\ntry:\n pass\nexcept StaleDataError:\n pass": 0,
"from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n from app.downloads.models import Job": 0,
"import typing\nif typing.TYPE_CHECKING:\n from app.models import DownloadJob": 0,
"if TYPE_CHECKING:\n pass\nfrom app.downloads.models import DownloadJob": 1,
@@ -333,11 +329,23 @@ def test_the_detector_sees_each_kind_of_database_work():
# it commits only where a job owns its session.
# Each unit's code outside the HTTP layer: its package, and the entry point that runs its jobs.
UNIT_PACKAGES = {"capture": ("downloads/", "worker.py")}
UNIT_PACKAGES = {
"capture": ("downloads/", "worker.py"),
# Home (`discover/`) is Reader's landing page; a playlist's YouTube sync and the tag suggestions
# work on Reader's tables alone.
"reader": (
"reader/",
"discover/",
"feedquery.py",
"sync/playlists.py",
"sync/playlist_merge.py",
"sync/tagsuggest.py",
),
}
# The units fenced so far; the rest join with their sprint.
FENCED = {"capture"}
FENCED = {"capture", "reader"}
# Whose tables a unit's own code may READ. Core code reads a fenced unit's tables through its queries.
READS = {"capture": {"catalog", "plex"}}
READS = {"capture": {"catalog", "plex"}, "reader": {"catalog", "plex"}}
# The columns that say whose a row is.
OWNER_COLUMNS = {"user_id", "to_user_id", "from_user_id", "created_by"}
# Where a fenced unit's code may commit: the jobs that own their session (the worker and what it
@@ -353,7 +361,17 @@ UNIT_COMMITS = {
"downloads/jobdb.py",
"downloads/pipeline.py",
"downloads/upload.py:ingest_upload",
}
},
# A playlist's YouTube sync runs over many lists and many YouTube calls: it commits what each
# step settled before the next (a list created on YouTube, one list mirrored), so a later
# failure neither forgets a write YouTube already holds nor drops the lists already done. Its
# claim is a lock held on a connection of its own.
"reader": {
"sync/playlists.py:_advisory_claim",
"sync/playlists.py:pull_user_playlists",
"sync/playlists.py:merge_playlist",
"sync/playlists.py:repull_playlist",
},
}
SQL_WRITES = {"insert", "update", "delete", "pg_insert"}