fix(capture): every process registers every table before its first query

The models now live in their units' packages, and a core model names a unit's by
string (User.subscriptions -> Subscription). The download child imports neither
catalog nor app.tables before db.get, so mapper configuration failed and every
download, clip, edit and upload job died with child_exit. Each entry point now
imports app.tables, and a test imports each one in a clean interpreter.
This commit is contained in:
2026-10-20 00:45:20 +02:00
parent 5ce292cd28
commit d73cdcee97
5 changed files with 67 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import app.tables # noqa: F401 — a fresh interpreter: every table, before the first query
from app import logs from app import logs
from app import logsink from app import logsink
from app.config import settings from app.config import settings
+1
View File
@@ -28,6 +28,7 @@ log = logs.get_logger(__name__)
from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first
from app.config import settings # noqa: E402 — logging is configured first from app.config import settings # noqa: E402 — logging is configured first
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
import app.tables # noqa: E402 — every table, before the first query
from app.downloads import service as download_service # noqa: E402 — logging is configured first from app.downloads import service as download_service # noqa: E402 — logging is configured first
from app.downloads import storage # noqa: E402 — logging is configured first from app.downloads import storage # noqa: E402 — logging is configured first
from app.plex import stream as plex_stream # noqa: E402 — logging is configured first from app.plex import stream as plex_stream # noqa: E402 — logging is configured first
+1
View File
@@ -31,6 +31,7 @@ from functools import partial
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import app.tables # noqa: F401 — every table, before the first query
from app import logs from app import logs
from app import logsink from app import logsink
from app import sysconfig from app import sysconfig
+63
View File
@@ -0,0 +1,63 @@
"""Every process the app runs as sees every table before its first query.
The units declare their tables in their own `models` modules, and a core model names a unit's by
string (`User.subscriptions` → `Subscription`). A fresh interpreter that never imported the unit's
module fails to configure its mappers on its first query: the download child once did exactly that,
and every job failed, while this suite stayed green — it imports `app.tables` in-process, which hides
the gap. So each entry point is imported here in a clean interpreter, and must register the whole
schema on its own.
"""
import ast
import subprocess
import sys
from pathlib import Path
import app.tables # noqa: F401
from app.db import Base
BACKEND = Path(__file__).parent.parent
APP = BACKEND / "app"
# Each `python -m` module the deployment starts, and the API's app module.
ENTRY_POINTS = ("app.main", "app.worker", "app.downloads.runner")
_PROBE = (
"import importlib, sys\n"
"importlib.import_module(sys.argv[1])\n"
"from sqlalchemy.orm import configure_mappers\n"
"from app.db import Base\n"
"configure_mappers()\n"
"print('\\n'.join(sorted(Base.metadata.tables)))\n"
)
def test_each_entry_point_registers_the_whole_schema_on_its_own():
whole = sorted(Base.metadata.tables)
for module in ENTRY_POINTS:
r = subprocess.run(
[sys.executable, "-c", _PROBE, module],
cwd=BACKEND,
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert r.returncode == 0, (module, r.stderr[-2000:])
assert r.stdout.split() == whole, module
def test_every_module_that_runs_as_a_process_is_listed():
mains = set()
for path in APP.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in tree.body:
if (
isinstance(node, ast.If)
and isinstance(node.test, ast.Compare)
and isinstance(node.test.left, ast.Name)
and node.test.left.id == "__name__"
):
rel = path.relative_to(BACKEND).with_suffix("").as_posix()
mains.add(rel.replace("/", "."))
assert mains <= set(ENTRY_POINTS), mains - set(ENTRY_POINTS)
+1
View File
@@ -24,6 +24,7 @@ sys.path.insert(0, "/app")
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from app.db import SessionLocal from app.db import SessionLocal
import app.tables # noqa: F401 — every table, before the first query
from app.catalog.models import Channel, Subscription, Video from app.catalog.models import Channel, Subscription, Video
from app.models import AppState, User from app.models import AppState, User
from app.reader.models import Playlist, PlaylistItem, VideoState from app.reader.models import Playlist, PlaylistItem, VideoState