commit f2084ad859803b32a8c12da0e5255eb9fa77c9ed equwal <truex@equwal.com> 2026-09-26 21:58:49 -0700 Copy the tables of every model module in the burst database copy The burst runbook runs python -m tools.copy_database in a new interpreter, once when burst mode goes on and once when it goes off. The tool imported only backend.db, so Base.metadata held only the tables of backend/db.py. The copy skipped caption_balances, subrep_licences and waitlist. After the switch, init_db made these tables again, empty, and each user lost the cloud caption hours that they paid for. A model table of a later module, for example play_purchases, had the same problem. The tool now imports backend.main. backend.main imports every model module, as the running app does, so the copy gets the same tables as init_db. The new test runs the command in a subprocess, because the test process imported backend.main already through tests/conftest.py. Without the fix, the test fails: the target has no caption_balances, subrep_licences or waitlist table.
tests/test_copy_database.py | 35 ++++++++++++++++++++++++++++++++++- tools/copy_database.py | 4 ++++ 2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/tests/test_copy_database.py b/tests/test_copy_database.py index bb70a67..2f39fc9 100644 --- a/tests/test_copy_database.py +++ b/tests/test_copy_database.py @@ -2,15 +2,21 @@ from __future__ import annotations +import os +import subprocess +import sys from datetime import date, datetime, timedelta, timezone +from pathlib import Path import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from sqlalchemy import create_engine, select +from sqlalchemy import create_engine, inspect, select from sqlalchemy.orm import Session +from backend.captions import CaptionBalance from backend.db import Account, Artifact, Base, Job, JobStatus, Purchase +from backend.subrep import SubrepLicence, WaitlistEntry from tools.copy_database import copy_database T0 = datetime(2026, 9, 22, tzinfo=timezone.utc) @@ -81,3 +87,30 @@ def test_the_copy_refuses_a_target_with_rows(tmp_path): s.commit() with pytest.raises(RuntimeError, match="accounts"): copy_database(source, target) + + +def test_the_command_copies_the_tables_of_every_model_module(tmp_path): + """The burst runbook runs the tool in a new interpreter. There, only the + modules that the tool imports put their tables in Base.metadata. This test + process imported backend.main already (conftest), so only a subprocess + shows a model module that the tool does not import.""" + source, target = f"sqlite:///{tmp_path / 'source.db'}", f"sqlite:///{tmp_path / 'target.db'}" + engine = create_engine(source) + Base.metadata.create_all(engine) + with Session(engine) as s: + s.add(Account(id="acct_1", device_token="dev_1", email="reader@example.com")) + s.add(CaptionBalance(account_id="acct_1", seconds_left=20 * 3600, seconds_used=60)) + s.add(SubrepLicence(account_id="acct_1", refresh_key="key_1", subscription_id="sub_1", + status="active", plan_id="subrep_year", period_end=T0)) + s.add(WaitlistEntry(kind="ios", email="reader@example.com", account_id="acct_1")) + s.commit() + + env = {**os.environ, "SUBPLZ_WEB_DATA_DIR": str(tmp_path)} + root = Path(__file__).resolve().parent.parent + out = subprocess.run([sys.executable, "-m", "tools.copy_database", source, target], + cwd=root, env=env, capture_output=True, text=True, timeout=120, + check=False) + assert out.returncode == 0, out.stderr + + assert set(Base.metadata.tables) <= set(inspect(create_engine(target)).get_table_names()) + assert rows(target) == rows(source) diff --git a/tools/copy_database.py b/tools/copy_database.py index b59be97..2ab5c3d 100644 --- a/tools/copy_database.py +++ b/tools/copy_database.py @@ -18,6 +18,10 @@ from datetime import datetime, timezone from sqlalchemy import DateTime, create_engine, func, insert, select +# The runbook runs this tool in a new interpreter. There, Base.metadata holds +# only the tables of the modules that Python imported. backend.main imports +# every model module, as the app does, so the copy gets every table. +import backend.main # noqa: F401 from backend.db import Base BATCH = 1000