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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user