Files
FabledCurator/tests/test_maintenance.py
T
bvandeusenandClaude Opus 5 2e0f8f8c61
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m47s
feat(cleanup): reclaim orphaned attachments — rows and store blobs (#3068)
PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or
artist left the row behind rather than taking it. Nothing ever pruned those
rows, and nothing in the repo had ever unlinked a file under the attachment
store — so both rows and bytes accumulated permanently, invisible to every
existing diagnostic.

Why a disk->DB reconciliation rather than a row sweep: the store is
sha-addressed and idempotent, so ONE blob backs MANY rows. Deleting a row
does not free its blob, and since the artist cascade (#3066) now deletes
its attachment rows outright, a freed blob has no DB pointer left to find
it by. Walking the store and asking "does any row still reference this
sha?" catches orphans from every cause, including ones no future delete
path will think to report.

Preview and apply share `_orphan_attachment_conditions` (rule 93). The
dry-run derives its surviving-sha set by NEGATING that same predicate, so
it is honest about blobs the delete would free rather than counting them as
still-referenced — the one place this was easy to get backwards, so it has
its own parity test.

Guards, each with a reason:
- A blob is written before its row commits, so a just-stored file legitimately
  has no referencing row. Files under 6h are never judged — same guard and
  reasoning as ORPHAN_TEMP_MIN_AGE_HOURS.
- `.partial` staging files belong to cleanup_orphaned_temp_files; skipped
  rather than raced.
- The sha is parsed as the first 64 chars, not via Path.stem: store() takes
  the extension from the source filename, and a URL-encoded basename yields a
  multi-dot suffix that would make stem eat part of the sha.
- A 900s walk budget reports partial=True instead of running to the task's
  hard limit (rule 89).
- TASK_STUCK_THRESHOLD_MINUTES override at 30 (= time_limit 25 + 5). Without
  it a healthy 20-minute walk is phantom-flagged 'RecoverySweep' at the bare
  5-min default — the #883 failure class; its invariant test is mirrored here.

Defaults to the safe preview at both the task and the route, unlike the other
maintenance triggers: this apply unlinks files. Operator-triggered only,
never on a beat.

Ships with its UI (rule 27): AttachmentReclaimCard in Cleanup → Duplicates &
leftovers, built on the existing useMaintenanceTask/MaintenanceTile shapes, so
a run survives navigating away. Surfaces files_failed and partial explicitly,
since both change what the numbers mean.

Also promotes humanBytes to utils/bytes.js — it was byte-identical in
VideoDedupCard and GatedPurgeCard and this card would have been the third
copy. The three divergent `formatBytes` helpers are deliberately left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:37:24 -04:00

795 lines
28 KiB
Python

"""Unit tests for the maintenance tasks. Eager-mode Celery so the task
function runs synchronously inside the same DB transaction as the test.
"""
from datetime import UTC, datetime, timedelta
import pytest
from backend.app.celery_app import celery
from backend.app.models import ImportBatch, ImportTask
pytestmark = pytest.mark.integration
@pytest.fixture(autouse=True)
def eager():
celery.conf.task_always_eager = True
yield
celery.conf.task_always_eager = False
def _make_batch(session) -> int:
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
session.add(batch)
session.flush()
return batch.id
def test_cleanup_orphaned_temp_files_removes_stale_only(tmp_path, monkeypatch):
import os
from backend.app.tasks import maintenance as m
monkeypatch.setattr(m, "IMAGES_ROOT", tmp_path)
stale = tmp_path / "artist" / "img.jpg.part" # killed download → orphan
stale.parent.mkdir(parents=True)
stale.write_bytes(b"x")
old = datetime.now(UTC).timestamp() - 8 * 3600 # older than the 6h guard
os.utime(stale, (old, old))
fresh = tmp_path / "in_progress.jpg.partial" # active download → keep
fresh.write_bytes(b"x")
keep = tmp_path / "real.jpg" # a real image → keep
keep.write_bytes(b"x")
assert m.cleanup_orphaned_temp_files() == 1
assert not stale.exists()
assert fresh.exists()
assert keep.exists()
def test_recover_interrupted_only_old(db_sync, monkeypatch):
batch_id = _make_batch(db_sync)
now = datetime.now(UTC)
# "Fresh" must sit comfortably under whatever STUCK_THRESHOLD_MINUTES
# currently is (5 min as of 2026-05-24, tightened from 30); 30
# seconds is well below any reasonable threshold. "Stale" stays at
# 2 hours so the test remains valid if the threshold ever moves
# back up.
fresh = ImportTask(
batch_id=batch_id, source_path="/import/a.jpg", task_type="media",
status="processing", started_at=now - timedelta(seconds=30),
)
stale = ImportTask(
batch_id=batch_id, source_path="/import/b.jpg", task_type="media",
status="processing", started_at=now - timedelta(hours=2),
)
db_sync.add_all([fresh, stale])
db_sync.commit()
# Isolate the recover task: under eager Celery, the real
# import_media_file.delay() would run inline against the nonexistent
# /import/b.jpg and flip the just-requeued row 'queued' -> 'skipped'.
from backend.app.tasks import import_file
dispatched: list[int] = []
monkeypatch.setattr(
import_file.import_media_file, "delay", dispatched.append
)
from backend.app.tasks.maintenance import recover_interrupted_tasks
recovered = recover_interrupted_tasks.apply().get()
assert recovered == 1
db_sync.refresh(fresh)
db_sync.refresh(stale)
assert fresh.status == "processing"
assert stale.status == "queued"
assert stale.started_at is None
assert dispatched == [stale.id]
def test_recover_interrupted_sweeps_pending_orphans_to_failed(db_sync, monkeypatch):
"""A scan that creates ImportTask rows but crashes before the second
pass (transition to 'queued' + .delay()) leaves rows orphaned at
status='pending'. The sweep flips them to 'failed' so the operator
can drain via /api/import/retry-failed without thundering-herding.
Banked 2026-05-25 after operator hit 5490 stuck pending rows.
"""
from backend.app.tasks import import_file
monkeypatch.setattr(import_file.import_media_file, "delay", lambda *_: None)
batch_id = _make_batch(db_sync)
now = datetime.now(UTC)
fresh_pending = ImportTask(
batch_id=batch_id, source_path="/import/fresh.jpg", task_type="media",
status="pending",
)
db_sync.add(fresh_pending)
db_sync.flush()
# created_at defaults to now() server-side; fresh row stays untouched.
# Two stale rows simulating the orphan pile: one 'pending', one
# 'queued' (scanner crashed AFTER transitioning some rows but
# before all). Both should sweep.
stale_pending = ImportTask(
batch_id=batch_id, source_path="/import/stale1.jpg", task_type="media",
status="pending",
)
stale_queued = ImportTask(
batch_id=batch_id, source_path="/import/stale2.jpg", task_type="media",
status="queued",
)
db_sync.add_all([stale_pending, stale_queued])
db_sync.flush()
# Backdate created_at past the orphan cutoff (30 min).
from sqlalchemy import update as _upd
db_sync.execute(
_upd(ImportTask)
.where(ImportTask.id.in_([stale_pending.id, stale_queued.id]))
.values(created_at=now - timedelta(hours=2))
)
db_sync.commit()
from backend.app.tasks.maintenance import recover_interrupted_tasks
touched = recover_interrupted_tasks.apply().get()
assert touched == 2
db_sync.refresh(fresh_pending)
db_sync.refresh(stale_pending)
db_sync.refresh(stale_queued)
assert fresh_pending.status == "pending" # fresh row untouched
assert stale_pending.status == "failed"
assert stale_queued.status == "failed"
assert "orphan" in (stale_pending.error or "")
def test_recover_interrupted_handles_both_stuck_and_orphans(db_sync, monkeypatch):
"""One sweep tick handles both 'processing' crashes AND
'pending'/'queued' orphans in a single pass."""
from backend.app.tasks import import_file
dispatched: list[int] = []
monkeypatch.setattr(
import_file.import_media_file, "delay", dispatched.append
)
batch_id = _make_batch(db_sync)
now = datetime.now(UTC)
stuck = ImportTask(
batch_id=batch_id, source_path="/import/stuck.jpg", task_type="media",
status="processing", started_at=now - timedelta(hours=2),
)
orphan = ImportTask(
batch_id=batch_id, source_path="/import/orphan.jpg", task_type="media",
status="pending",
)
db_sync.add_all([stuck, orphan])
db_sync.flush()
from sqlalchemy import update as _upd
db_sync.execute(
_upd(ImportTask).where(ImportTask.id == orphan.id)
.values(created_at=now - timedelta(hours=2))
)
db_sync.commit()
from backend.app.tasks.maintenance import recover_interrupted_tasks
touched = recover_interrupted_tasks.apply().get()
assert touched == 2
db_sync.refresh(stuck)
db_sync.refresh(orphan)
assert stuck.status == "queued"
assert orphan.status == "failed"
assert dispatched == [stuck.id] # stuck rows re-enqueue; orphans don't
def test_recover_interrupted_poison_pill_caps_at_max(db_sync, monkeypatch):
"""A stuck row that's already been recovered MAX_RECOVERY_ATTEMPTS-1
times is marked 'failed' (with a diagnostic) instead of re-queued —
the circuit breaker against an input that hard-crashes the worker
every run. Operator-flagged 2026-05-28."""
from backend.app.tasks import import_file
from backend.app.tasks.maintenance import (
MAX_RECOVERY_ATTEMPTS,
recover_interrupted_tasks,
)
dispatched: list[int] = []
monkeypatch.setattr(
import_file.import_media_file, "delay", dispatched.append
)
batch_id = _make_batch(db_sync)
now = datetime.now(UTC)
# At the cap already (recovered MAX-1 times) → fail, don't re-queue.
poison = ImportTask(
batch_id=batch_id, source_path="/import/poison.jpg", task_type="media",
status="processing", started_at=now - timedelta(hours=2),
recovery_count=MAX_RECOVERY_ATTEMPTS - 1,
)
# One recovery short of the cap → re-queue + increment.
recoverable = ImportTask(
batch_id=batch_id, source_path="/import/ok.jpg", task_type="media",
status="processing", started_at=now - timedelta(hours=2),
recovery_count=MAX_RECOVERY_ATTEMPTS - 2,
)
db_sync.add_all([poison, recoverable])
db_sync.commit()
touched = recover_interrupted_tasks.apply().get()
assert touched == 2 # one failed + one re-queued
db_sync.refresh(poison)
db_sync.refresh(recoverable)
assert poison.status == "failed"
assert "corrupt or" in (poison.error or "")
assert recoverable.status == "queued"
assert recoverable.recovery_count == MAX_RECOVERY_ATTEMPTS - 1
# Only the recoverable row re-enqueues; the poison pill does not.
assert dispatched == [recoverable.id]
def test_cleanup_old_deletes_finished_old(db_sync):
batch_id = _make_batch(db_sync)
now = datetime.now(UTC)
old_complete = ImportTask(
batch_id=batch_id, source_path="/import/a.jpg", task_type="media",
status="complete", finished_at=now - timedelta(days=10),
)
recent_complete = ImportTask(
batch_id=batch_id, source_path="/import/b.jpg", task_type="media",
status="complete", finished_at=now - timedelta(days=2),
)
old_pending = ImportTask(
batch_id=batch_id, source_path="/import/c.jpg", task_type="media",
status="pending",
)
db_sync.add_all([old_complete, recent_complete, old_pending])
db_sync.commit()
from backend.app.tasks.maintenance import cleanup_old_tasks
deleted = cleanup_old_tasks.apply().get()
assert deleted == 1
remaining = {t.source_path for t in db_sync.query(ImportTask).all()}
assert remaining == {"/import/b.jpg", "/import/c.jpg"}
# --- FC-3i: task_run sweep + retention -----------------------------
def _make_task_run(db_sync, *, status, started_at, finished_at=None,
error_type=None, queue="default",
task_name="backend.app.tasks.fake.t"):
from backend.app.models import TaskRun
row = TaskRun(
celery_task_id="x",
queue=queue,
task_name=task_name,
target_id=1,
started_at=started_at,
finished_at=finished_at,
duration_ms=1000 if finished_at else None,
status=status,
error_type=error_type,
error_message="x" if status in ("error", "timeout") else None,
)
db_sync.add(row)
db_sync.flush()
return row.id
def test_recover_stalled_task_runs_flips_old_running_to_error(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
now = datetime.now(UTC)
stale_id = _make_task_run(
db_sync, status="running", started_at=now - timedelta(minutes=10),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 1
db_sync.expire_all()
status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == stale_id)
).scalar_one()
error_type = db_sync.execute(
select(TaskRun.error_type).where(TaskRun.id == stale_id)
).scalar_one()
assert status == "error"
assert error_type == "RecoverySweep"
def test_recover_stalled_task_runs_skips_fresh_running(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
now = datetime.now(UTC)
fresh_id = _make_task_run(
db_sync, status="running", started_at=now - timedelta(seconds=30),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 0
db_sync.expire_all()
status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == fresh_id)
).scalar_one()
assert status == "running"
def test_recover_stalled_task_runs_ml_queue_uses_longer_threshold(db_sync):
"""ml-queue tasks (embed_image video branch) legitimately run
past the default 5-min threshold. The sweep must NOT flag an
ml-queue task that's only been running 10 min — the override
threshold (25 min via QUEUE_STUCK_THRESHOLD_MINUTES) protects
in-flight video tagging. Operator-flagged 2026-05-28 after
image 6288 (mp4) was marked failed at the 5-min tick mid-run."""
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
now = datetime.now(UTC)
# 10-min-old ml-queue row: stale by the default 5-min rule but
# fresh by the 25-min ml override. Must survive the sweep.
ml_fresh_id = _make_task_run(
db_sync, status="running", queue="ml",
started_at=now - timedelta(minutes=10),
)
# 30-min-old ml-queue row: past even the ml override. Must be
# flagged.
ml_stale_id = _make_task_run(
db_sync, status="running", queue="ml",
started_at=now - timedelta(minutes=30),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 1
db_sync.expire_all()
ml_fresh_status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == ml_fresh_id)
).scalar_one()
ml_stale_status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == ml_stale_id)
).scalar_one()
assert ml_fresh_status == "running"
assert ml_stale_status == "error"
def test_recover_stalled_task_runs_download_queue_uses_longer_threshold(db_sync):
"""download_source legitimately walks 5-25 min (Patreon/gallery-dl).
The 5-min default flagged healthy in-flight walks as phantom
'RecoverySweep' failures — visible in System Activity but absent from
the Subscriptions view because the download actually finished ok.
The 30-min download override (QUEUE_STUCK_THRESHOLD_MINUTES) must
protect a 10-min-old download row while still flagging a 35-min one.
Audit 2026-06-10."""
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
now = datetime.now(UTC)
# 10-min-old download row: stale by the default 5-min rule but fresh
# by the 30-min download override. Must survive the sweep.
dl_fresh_id = _make_task_run(
db_sync, status="running", queue="download",
task_name="backend.app.tasks.download.download_source",
started_at=now - timedelta(minutes=10),
)
# 35-min-old download row: past even the 30-min override (a genuine
# hard kill). Must be flagged.
dl_stale_id = _make_task_run(
db_sync, status="running", queue="download",
task_name="backend.app.tasks.download.download_source",
started_at=now - timedelta(minutes=35),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 1
db_sync.expire_all()
dl_fresh_status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == dl_fresh_id)
).scalar_one()
dl_stale_status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == dl_stale_id)
).scalar_one()
assert dl_fresh_status == "running"
assert dl_stale_status == "error"
def test_download_stuck_threshold_exceeds_hard_time_limit():
"""Invariant guard (maintenance.py:112): every queue override MUST be
≥ the relevant task's hard time_limit, else the sweep flags in-flight
work. download_source is the one that regressed — pin it so a future
DOWNLOAD_HARD_TIME_LIMIT bump can't silently re-break it."""
from backend.app.tasks.download import DOWNLOAD_HARD_TIME_LIMIT
from backend.app.tasks.maintenance import QUEUE_STUCK_THRESHOLD_MINUTES
hard_minutes = DOWNLOAD_HARD_TIME_LIMIT / 60
assert QUEUE_STUCK_THRESHOLD_MINUTES["download"] >= hard_minutes
def test_recover_stalled_task_runs_archive_task_uses_longer_threshold(db_sync):
"""import_archive_file shares the 'import' queue with fast
single-file import_media_file, so it gets a per-task-name override
(40 min) while the import queue stays at the 5-min default. A
10-min-old archive task-run must survive; a 50-min-old one is
flagged. Operator-flagged 2026-05-28."""
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
archive_name = "backend.app.tasks.import_file.import_archive_file"
now = datetime.now(UTC)
# Fast single-file import on the same queue, 10 min old → flagged
# by the default 5-min rule.
media_id = _make_task_run(
db_sync, status="running", queue="import",
task_name="backend.app.tasks.import_file.import_media_file",
started_at=now - timedelta(minutes=10),
)
# Archive on the same queue, 10 min old → survives (40-min override).
archive_fresh_id = _make_task_run(
db_sync, status="running", queue="import",
task_name=archive_name,
started_at=now - timedelta(minutes=10),
)
# Archive 50 min old → past even the 40-min override → flagged.
archive_stale_id = _make_task_run(
db_sync, status="running", queue="import",
task_name=archive_name,
started_at=now - timedelta(minutes=50),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 2 # media + stale archive
db_sync.expire_all()
def _status(_id):
return db_sync.execute(
select(TaskRun.status).where(TaskRun.id == _id)
).scalar_one()
assert _status(media_id) == "error"
assert _status(archive_fresh_id) == "running"
assert _status(archive_stale_id) == "error"
def test_recover_stalled_task_runs_external_fetch_uses_longer_threshold(db_sync):
"""fetch_external_link legitimately runs to its 60-min hard limit, but its
TaskRun records queue='default' (no queue override), so before the
task-name override (65 min) it fell to the 5-min default and healthy
in-flight fetches were phantom-flagged 'RecoverySweep' before their own
timeout/error could surface (operator-flagged 2026-06-17, target 414 swept
at 6.6min). A 10-min-old row must survive; a 70-min-old one is flagged."""
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
name = "backend.app.tasks.external.fetch_external_link"
now = datetime.now(UTC)
# 10-min-old: stale by the default 5-min rule but fresh by the 65-min
# task-name override. Must survive despite recording queue='default'.
fresh_id = _make_task_run(
db_sync, status="running", queue="default", task_name=name,
started_at=now - timedelta(minutes=10),
)
# 70-min-old: past even the 65-min override (a genuine hard kill). Flagged.
stale_id = _make_task_run(
db_sync, status="running", queue="default", task_name=name,
started_at=now - timedelta(minutes=70),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 1
db_sync.expire_all()
def _status(_id):
return db_sync.execute(
select(TaskRun.status).where(TaskRun.id == _id)
).scalar_one()
assert _status(fresh_id) == "running"
assert _status(stale_id) == "error"
def test_external_fetch_stuck_threshold_exceeds_hard_time_limit():
"""Invariant guard (maintenance.py:112): the fetch_external_link task-name
override MUST be ≥ its hard time_limit, else the sweep flags healthy long
fetches. Pins it so a future time_limit bump can't silently re-break it."""
from backend.app.tasks.external import fetch_external_link
from backend.app.tasks.maintenance import TASK_STUCK_THRESHOLD_MINUTES
hard_minutes = fetch_external_link.time_limit / 60
override = TASK_STUCK_THRESHOLD_MINUTES[
"backend.app.tasks.external.fetch_external_link"
]
assert override >= hard_minutes
def test_prune_task_runs_deletes_ok_older_than_24h(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
old_id = _make_task_run(
db_sync, status="ok",
started_at=now - timedelta(hours=30),
finished_at=now - timedelta(hours=29),
)
recent_id = _make_task_run(
db_sync, status="ok",
started_at=now - timedelta(hours=2),
finished_at=now - timedelta(hours=1),
)
db_sync.commit()
result = prune_task_runs.apply().get()
assert result["ok_deleted"] == 1
db_sync.expire_all()
surviving_ids = set(db_sync.execute(
select(TaskRun.id).where(TaskRun.id.in_([old_id, recent_id]))
).scalars().all())
assert surviving_ids == {recent_id}
def test_prune_task_runs_deletes_failures_older_than_7d(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
old_id = _make_task_run(
db_sync, status="error", error_type="OldError",
started_at=now - timedelta(days=10),
finished_at=now - timedelta(days=9),
)
db_sync.commit()
result = prune_task_runs.apply().get()
assert result["failures_deleted"] >= 1
db_sync.expire_all()
surviving = db_sync.execute(
select(TaskRun.id).where(TaskRun.id == old_id)
).scalar_one_or_none()
assert surviving is None
def test_prune_task_runs_keeps_recent_failures(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
recent_id = _make_task_run(
db_sync, status="error", error_type="RecentError",
started_at=now - timedelta(days=6),
finished_at=now - timedelta(days=5),
)
db_sync.commit()
prune_task_runs.apply().get()
db_sync.expire_all()
surviving = db_sync.execute(
select(TaskRun.id).where(TaskRun.id == recent_id)
).scalar_one_or_none()
assert surviving == recent_id
def test_prune_task_runs_never_deletes_running(db_sync):
"""Even a 30-day-old running row stays — recovery sweep is the
mechanism that flips them; prune doesn't touch in-flight."""
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
ancient_id = _make_task_run(
db_sync, status="running",
started_at=now - timedelta(days=30),
)
db_sync.commit()
prune_task_runs.apply().get()
db_sync.expire_all()
surviving = db_sync.execute(
select(TaskRun.id).where(TaskRun.id == ancient_id)
).scalar_one_or_none()
assert surviving == ancient_id
# ---- recover_stalled_download_events ----------------------------------
def _make_source(session, *, slug: str) -> int:
"""Create an Artist + Source pair for the download-recovery tests."""
from backend.app.models import Artist, Source
artist = Artist(name=f"Artist {slug}", slug=slug)
session.add(artist)
session.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url=f"https://example.com/{slug}",
)
session.add(source)
session.flush()
return source.id
def test_recover_stalled_download_skips_fresh(db_sync):
"""A pending event whose started_at is under the 30-min threshold is
left alone — the worker may still legitimately be processing it."""
from sqlalchemy import select
from backend.app.models import DownloadEvent, Source
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="fresh")
now = datetime.now(UTC)
db_sync.add(DownloadEvent(
source_id=sid, status="pending", started_at=now - timedelta(seconds=30),
))
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 0
db_sync.expire_all()
status = db_sync.execute(
select(DownloadEvent.status).where(DownloadEvent.source_id == sid)
).scalar_one()
assert status == "pending"
failures = db_sync.execute(
select(Source.consecutive_failures).where(Source.id == sid)
).scalar_one()
assert failures == 0
def test_recover_stalled_download_flips_stale_pending(db_sync):
"""A 2-hour-old pending event flips to error AND the source is bumped
(consecutive_failures, last_error, last_checked_at) so the next scan
tick can re-queue it (the in-flight guard no longer blocks)."""
from sqlalchemy import select
from backend.app.models import DownloadEvent, Source
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="stale-p")
now = datetime.now(UTC)
db_sync.add(DownloadEvent(
source_id=sid, status="pending", started_at=now - timedelta(hours=2),
))
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 1
db_sync.expire_all()
ev_row = db_sync.execute(
select(
DownloadEvent.status, DownloadEvent.finished_at, DownloadEvent.error,
).where(DownloadEvent.source_id == sid)
).one()
assert ev_row.status == "error"
assert ev_row.finished_at is not None
assert "stranded" in ev_row.error
src_row = db_sync.execute(
select(
Source.consecutive_failures, Source.last_error, Source.last_checked_at,
).where(Source.id == sid)
).one()
assert src_row.consecutive_failures == 1
assert "stranded" in src_row.last_error
assert src_row.last_checked_at is not None
def test_recover_stalled_download_flips_stale_running(db_sync):
"""'running' is the other in-flight state — recovery covers it equally."""
from sqlalchemy import select
from backend.app.models import DownloadEvent
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="stale-r")
now = datetime.now(UTC)
db_sync.add(DownloadEvent(
source_id=sid, status="running", started_at=now - timedelta(hours=2),
))
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 1
db_sync.expire_all()
status = db_sync.execute(
select(DownloadEvent.status).where(DownloadEvent.source_id == sid)
).scalar_one()
assert status == "error"
def test_recover_stalled_download_dedupes_per_source(db_sync):
"""Two stale events on one source bump consecutive_failures ONCE.
Backoff is exponential on that counter (2^failures), so per-event bumps
would inflate the next check interval by 2^N for no real reason."""
from sqlalchemy import select
from backend.app.models import DownloadEvent, Source
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="dedupe")
now = datetime.now(UTC)
db_sync.add_all([
DownloadEvent(
source_id=sid, status="pending",
started_at=now - timedelta(hours=2),
),
DownloadEvent(
source_id=sid, status="running",
started_at=now - timedelta(hours=3),
),
])
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 2
db_sync.expire_all()
failures = db_sync.execute(
select(Source.consecutive_failures).where(Source.id == sid)
).scalar_one()
assert failures == 1
def test_vacuum_analyze_runs_over_high_churn_tables():
"""VACUUM (ANALYZE) runs (on its own AUTOCOMMIT connection) and reports the
tables it touched."""
from backend.app.tasks.maintenance import VACUUM_TABLES, vacuum_analyze
result = vacuum_analyze.apply().get()
assert result["vacuumed"] == list(VACUUM_TABLES)
def test_reclaim_attachments_stuck_threshold_exceeds_hard_time_limit():
"""#883's invariant, applied to the attachment reclaim: a task whose stall
threshold is under its own hard limit gets phantom-flagged 'RecoverySweep'
while it is still healthily running."""
from backend.app.tasks.admin import reclaim_orphaned_attachments_task
from backend.app.tasks.maintenance import TASK_STUCK_THRESHOLD_MINUTES
hard_minutes = reclaim_orphaned_attachments_task.time_limit / 60
override = TASK_STUCK_THRESHOLD_MINUTES[
"backend.app.tasks.admin.reclaim_orphaned_attachments_task"
]
assert override >= hard_minutes