Refactor database access through managed connections

This commit is contained in:
Abel Luck 2026-03-31 17:30:07 +02:00
parent f19bab6fa2
commit 3f28e46ff6
10 changed files with 1327 additions and 716 deletions

View file

@ -21,6 +21,7 @@ from repub.model import (
SourceFeed,
SourcePangea,
create_source,
database,
load_max_concurrent_jobs,
load_settings_form,
save_setting,
@ -43,6 +44,35 @@ from repub.web import (
)
def _db_reader(fn):
with database.reader():
return fn()
def _db_writer(fn):
with database.writer():
return fn()
def test_web_routes_do_not_access_peewee_models_directly() -> None:
web_source = Path("repub/web.py").read_text(encoding="utf-8")
assert (
re.search(
r"\b(Job|Source|JobExecution|SourceFeed|SourcePangea)\.get",
web_source,
)
is None
)
assert (
re.search(
r"\b(Job|Source|JobExecution|SourceFeed|SourcePangea)\.select",
web_source,
)
is None
)
def test_status_badge_uses_green_done_tone() -> None:
badge = str(status_badge(label="Succeeded", tone="done"))
@ -790,8 +820,12 @@ def test_load_dashboard_view_lists_source_feed_artifacts(
updated_at = reference_time - timedelta(minutes=32)
updated_at_epoch = updated_at.timestamp()
os.utime(feed_path, (updated_at_epoch, updated_at_epoch))
available_job = Job.get(Job.source == available_source)
missing_job = Job.get(Job.source == missing_source)
available_job, missing_job = _db_reader(
lambda: (
Job.get(Job.source == available_source),
Job.get(Job.source == missing_source),
)
)
source_feeds = cast(
tuple[dict[str, object], ...],
@ -871,16 +905,18 @@ def test_load_dashboard_view_projects_feed_status_from_job_runtime(
feed_url="https://example.com/queued.xml",
)
running_job = Job.get(Job.source == running_source)
queued_job = Job.get(Job.source == queued_source)
JobExecution.create(
job=running_job,
running_status=JobExecutionStatus.RUNNING,
started_at=reference_time - timedelta(minutes=2),
)
JobExecution.create(
job=queued_job,
running_status=JobExecutionStatus.PENDING,
_db_writer(
lambda: (
JobExecution.create(
job=Job.get(Job.source == running_source),
running_status=JobExecutionStatus.RUNNING,
started_at=reference_time - timedelta(minutes=2),
),
JobExecution.create(
job=Job.get(Job.source == queued_source),
running_status=JobExecutionStatus.PENDING,
),
)
)
source_feeds = cast(
@ -938,8 +974,12 @@ def test_render_dashboard_shows_source_feed_links_and_statuses(
published_feed = tmp_path / "out" / "feeds" / "published-source" / "feed.rss"
published_feed.parent.mkdir(parents=True)
published_feed.write_text("<rss/>\n", encoding="utf-8")
published_job = Job.get(Job.source == published_source)
missing_job = Job.get(Job.source == missing_source)
published_job, missing_job = _db_reader(
lambda: (
Job.get(Job.source == published_source),
Job.get(Job.source == missing_source),
)
)
body = str(await render_dashboard(app))
@ -1253,9 +1293,15 @@ def test_create_source_action_creates_pangea_source_and_job_in_database(
assert response.status_code == 200
assert "window.location = '/sources'" in body
source = Source.get(Source.slug == "kenya-health")
pangea = SourcePangea.get(SourcePangea.source == source)
job = Job.get(Job.source == source)
source, pangea, job = _db_reader(
lambda: (
Source.get(Source.slug == "kenya-health"),
SourcePangea.get(
SourcePangea.source == Source.get(Source.slug == "kenya-health")
),
Job.get(Job.source == Source.get(Source.slug == "kenya-health")),
)
)
rendered_sources = str(await render_sources(app))
assert source.name == "Kenya health desk"
@ -1307,9 +1353,15 @@ def test_create_source_action_creates_feed_source_and_job_in_database(
assert response.status_code == 200
assert "window.location = '/sources'" in body
source = Source.get(Source.slug == "nasa-feed")
feed = SourceFeed.get(SourceFeed.source == source)
job = Job.get(Job.source == source)
source, feed, job = _db_reader(
lambda: (
Source.get(Source.slug == "nasa-feed"),
SourceFeed.get(
SourceFeed.source == Source.get(Source.slug == "nasa-feed")
),
Job.get(Job.source == Source.get(Source.slug == "nasa-feed")),
)
)
rendered_sources = str(await render_sources(app))
assert source.source_type == "feed"
@ -1390,9 +1442,15 @@ def test_edit_source_action_updates_existing_source_and_job_in_database(
assert response.status_code == 200
assert "window.location = '/sources'" in body
source = Source.get(Source.slug == "kenya-health")
pangea = SourcePangea.get(SourcePangea.source == source)
job = Job.get(Job.source == source)
source, pangea, job = _db_reader(
lambda: (
Source.get(Source.slug == "kenya-health"),
SourcePangea.get(
SourcePangea.source == Source.get(Source.slug == "kenya-health")
),
Job.get(Job.source == Source.get(Source.slug == "kenya-health")),
)
)
rendered_sources = str(await render_sources(app))
assert source.name == "Kenya health desk nightly"
@ -1477,8 +1535,18 @@ def test_edit_source_action_rejects_slug_changes(monkeypatch, tmp_path: Path) ->
assert response.status_code == 200
assert "Slug is immutable." in body
assert Source.get(Source.slug == "kenya-health").name == "Kenya health desk"
assert Source.select().where(Source.slug == "kenya-health-renamed").count() == 0
assert (
_db_reader(lambda: Source.get(Source.slug == "kenya-health").name)
== "Kenya health desk"
)
assert (
_db_reader(
lambda: Source.select()
.where(Source.slug == "kenya-health-renamed")
.count()
)
== 0
)
asyncio.run(run())
@ -1491,10 +1559,12 @@ def test_create_source_action_validates_duplicate_slug_and_pangea_type(
async def run() -> None:
app = create_app()
Source.create(
name="Guardian feed mirror",
slug="guardian-feed",
source_type="feed",
_db_writer(
lambda: Source.create(
name="Guardian feed mirror",
slug="guardian-feed",
source_type="feed",
)
)
client = app.test_client()
@ -1526,7 +1596,14 @@ def test_create_source_action_validates_duplicate_slug_and_pangea_type(
assert "Content format is invalid." in body
assert "Content type is invalid." in body
assert "Max articles must be an integer." in body
assert Source.select().where(Source.name == "Duplicate guardian").count() == 0
assert (
_db_reader(
lambda: Source.select()
.where(Source.name == "Duplicate guardian")
.count()
)
== 0
)
asyncio.run(run())
@ -1629,10 +1706,14 @@ def test_render_runs_shows_running_scheduled_and_completed_tables(
cron_month="*",
feed_url="https://example.com/runs.xml",
)
job = Job.get(Job.source == source)
execution = JobExecution.create(
job=job,
running_status=JobExecutionStatus.SUCCEEDED,
job, execution = _db_writer(
lambda: (
Job.get(Job.source == source),
JobExecution.create(
job=Job.get(Job.source == source),
running_status=JobExecutionStatus.SUCCEEDED,
),
)
)
body = str(await render_runs(app))
@ -1704,14 +1785,16 @@ def test_runs_pagination_action_updates_only_the_current_tab(
cron_month="*",
feed_url="https://example.com/paged-runs.xml",
)
job = Job.get(Job.source == source)
for minute in range(21):
JobExecution.create(
job=job,
ended_at=datetime(2026, 3, 30, 12, minute, tzinfo=UTC),
running_status=JobExecutionStatus.SUCCEEDED,
_db_writer(
lambda: tuple(
JobExecution.create(
job=Job.get(Job.source == source),
ended_at=datetime(2026, 3, 30, 12, minute, tzinfo=UTC),
running_status=JobExecutionStatus.SUCCEEDED,
)
for minute in range(21)
)
)
async with client.request(
"/runs?u=shim",
@ -1853,10 +1936,14 @@ def test_render_runs_keeps_queued_execution_in_scheduled_jobs_table(
cron_month="*",
feed_url="https://example.com/scheduled.xml",
)
queued_job = Job.get(Job.source == queued_source)
queued_execution = JobExecution.create(
job=queued_job,
running_status=JobExecutionStatus.PENDING,
queued_job, queued_execution = _db_writer(
lambda: (
Job.get(Job.source == queued_source),
JobExecution.create(
job=Job.get(Job.source == queued_source),
running_status=JobExecutionStatus.PENDING,
),
)
)
async def run() -> None:
@ -1899,15 +1986,19 @@ def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up(
cron_month="*",
feed_url="https://example.com/busy.xml",
)
job = Job.get(Job.source == source)
running_execution = JobExecution.create(
job=job,
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
running_status=JobExecutionStatus.RUNNING,
)
pending_execution = JobExecution.create(
job=job,
running_status=JobExecutionStatus.PENDING,
job, running_execution, pending_execution = _db_writer(
lambda: (
Job.get(Job.source == source),
JobExecution.create(
job=Job.get(Job.source == source),
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
running_status=JobExecutionStatus.RUNNING,
),
JobExecution.create(
job=Job.get(Job.source == source),
running_status=JobExecutionStatus.PENDING,
),
)
)
async def run() -> None:
@ -2036,15 +2127,19 @@ def test_cancel_queued_execution_action_deletes_pending_row_without_touching_run
cron_month="*",
feed_url="https://example.com/busy.xml",
)
job = Job.get(Job.source == source)
running_execution = JobExecution.create(
job=job,
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
running_status=JobExecutionStatus.RUNNING,
)
pending_execution = JobExecution.create(
job=job,
running_status=JobExecutionStatus.PENDING,
job, running_execution, pending_execution = _db_writer(
lambda: (
Job.get(Job.source == source),
JobExecution.create(
job=Job.get(Job.source == source),
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
running_status=JobExecutionStatus.RUNNING,
),
JobExecution.create(
job=Job.get(Job.source == source),
running_status=JobExecutionStatus.PENDING,
),
)
)
response = await client.post(
@ -2052,9 +2147,18 @@ def test_cancel_queued_execution_action_deletes_pending_row_without_touching_run
)
assert response.status_code == 204
assert JobExecution.get_or_none(id=int(pending_execution.get_id())) is None
assert (
JobExecution.get_by_id(int(running_execution.get_id())).running_status
_db_reader(
lambda: JobExecution.get_or_none(id=int(pending_execution.get_id()))
)
is None
)
assert (
_db_reader(
lambda: JobExecution.get_by_id(
int(running_execution.get_id())
).running_status
)
== JobExecutionStatus.RUNNING
)
@ -2087,16 +2191,20 @@ def test_clear_completed_executions_action_removes_history_and_log_artifacts(
cron_month="*",
feed_url="https://example.com/history.xml",
)
job = Job.get(Job.source == source)
completed_execution = JobExecution.create(
job=job,
running_status=JobExecutionStatus.SUCCEEDED,
ended_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
)
running_execution = JobExecution.create(
job=job,
running_status=JobExecutionStatus.RUNNING,
started_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC),
job, completed_execution, running_execution = _db_writer(
lambda: (
Job.get(Job.source == source),
JobExecution.create(
job=Job.get(Job.source == source),
running_status=JobExecutionStatus.SUCCEEDED,
ended_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
),
JobExecution.create(
job=Job.get(Job.source == source),
running_status=JobExecutionStatus.RUNNING,
started_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC),
),
)
)
log_dir.mkdir(parents=True, exist_ok=True)
completed_prefix = (
@ -2112,8 +2220,18 @@ def test_clear_completed_executions_action_removes_history_and_log_artifacts(
response = await client.post("/actions/completed-executions/clear")
assert response.status_code == 204
assert JobExecution.get_or_none(id=int(completed_execution.get_id())) is None
assert JobExecution.get_or_none(id=int(running_execution.get_id())) is not None
assert (
_db_reader(
lambda: JobExecution.get_or_none(id=int(completed_execution.get_id()))
)
is None
)
assert (
_db_reader(
lambda: JobExecution.get_or_none(id=int(running_execution.get_id()))
)
is not None
)
for suffix in (".log", ".jsonl", ".pygea.log"):
assert not completed_prefix.with_suffix(suffix).exists()
assert running_log_path.exists()
@ -2161,17 +2279,21 @@ def test_move_queued_execution_action_reorders_queue(
cron_month="*",
feed_url="https://example.com/second.xml",
)
first_job = Job.get(Job.source == first_source)
second_job = Job.get(Job.source == second_source)
first_execution = JobExecution.create(
job=first_job,
created_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
running_status=JobExecutionStatus.PENDING,
)
second_execution = JobExecution.create(
job=second_job,
created_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC),
running_status=JobExecutionStatus.PENDING,
first_job, second_job, first_execution, second_execution = _db_writer(
lambda: (
Job.get(Job.source == first_source),
Job.get(Job.source == second_source),
JobExecution.create(
job=Job.get(Job.source == first_source),
created_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
running_status=JobExecutionStatus.PENDING,
),
JobExecution.create(
job=Job.get(Job.source == second_source),
created_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC),
running_status=JobExecutionStatus.PENDING,
),
)
)
response = await client.post(
@ -2217,17 +2339,26 @@ def test_toggle_job_enabled_action_removes_queued_execution(
cron_month="*",
feed_url="https://example.com/queued.xml",
)
job = Job.get(Job.source == source)
queued_execution = JobExecution.create(
job=job,
running_status=JobExecutionStatus.PENDING,
job, queued_execution = _db_writer(
lambda: (
Job.get(Job.source == source),
JobExecution.create(
job=Job.get(Job.source == source),
running_status=JobExecutionStatus.PENDING,
),
)
)
response = await client.post(f"/actions/jobs/{job.id}/toggle-enabled")
assert response.status_code == 204
assert Job.get_by_id(job.id).enabled is False
assert JobExecution.get_or_none(id=int(queued_execution.get_id())) is None
assert _db_reader(lambda: Job.get_by_id(job.id).enabled) is False
assert (
_db_reader(
lambda: JobExecution.get_or_none(id=int(queued_execution.get_id()))
)
is None
)
body = str(await render_runs(app))
assert (
f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
@ -2279,10 +2410,14 @@ def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> No
cron_month="*",
feed_url="https://example.com/logs.xml",
)
job = Job.get(Job.source == source)
execution = JobExecution.create(
job=job,
running_status=JobExecutionStatus.RUNNING,
job, execution = _db_writer(
lambda: (
Job.get(Job.source == source),
JobExecution.create(
job=Job.get(Job.source == source),
running_status=JobExecutionStatus.RUNNING,
),
)
)
log_path = log_dir / f"job-{job.id}-execution-{execution.get_id()}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)