Ctrl K
ComunidadGuías…

backend/tests/test_automation_queue_orphan_handling.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Queue rows must not outlive their usefulness.

Two production findings from Cardinal, 2026-08-27:

1. 25 embedded_extract rows whose wanted item had been deleted retried
   daily, forever — ``extract_embedded_sub`` raised ``ValueError`` for the
   vanished item, which the runner's generic branch put back on the backoff
   ladder. A vanished item never comes back; the established contract for
   that (see ``_translate_sidecar``) is ``FileNotFoundError`` → terminal.
   Note: an orphaned *auto_sync* row is NOT a defect — those work off
   snapshotted paths precisely because the item is deleted the moment its
   subtitle lands.

2. Nothing ever deleted finished rows: 5429 ``done`` and hundreds of
   terminally-failed rows had accumulated. ``purge_finished`` gives the
   queue the same retention treatment scheduler_job_runs already has.
"""

from datetime import UTC, datetime, timedelta
from unittest.mock import patch

import pytest

from db.repositories.subtitle_automation_queue import (
    SubtitleAutomationQueueRepository,
)
from services.subtitle_automation_runner import SubtitleAutomationRunner


@pytest.fixture
def repo(app_ctx):
    return SubtitleAutomationQueueRepository()


@pytest.fixture
def runner(app_ctx):
    return SubtitleAutomationRunner()


# ── vanished wanted item is terminal for embedded_extract ────────────────────


class TestVanishedItemIsTerminal:
    def test_extract_for_vanished_item_does_not_retry(self, repo, runner):
        """The real extractor raises FileNotFoundError for a missing item,
        and the runner must treat that as terminal — not backoff."""
        repo.enqueue(wanted_item_id=99999, file_path="/m/gone.mkv", target_language="ger")
        with patch(
            "services.subtitle_automation_runner._extract_embedded_sub",
            side_effect=FileNotFoundError("Wanted item 99999 no longer exists"),
        ):
            assert runner.process_one() is True
        e = repo.get_by_wanted_item(99999)
        assert e["state"] == "failed"
        assert e["next_retry_at"] is None

    def test_extractor_raises_file_not_found_for_missing_item(self, app_ctx):
        """Pin the exception type: ValueError here meant daily retries for a
        row that could never succeed."""
        from services.embedded_extractor import extract_embedded_sub

        with pytest.raises(FileNotFoundError, match="no longer exists"):
            extract_embedded_sub(987654321, "/m/whatever.mkv")


# ── retention for finished rows ──────────────────────────────────────────────


class TestPurgeFinished:
    def _age(self, repo, wanted_item_id: int, *, days: int) -> None:
        """Backdate a row's updated_at."""
        from db.models.core import SubtitleAutomationQueueEntry

        stamp = datetime.now(UTC) - timedelta(days=days)
        repo.session.query(SubtitleAutomationQueueEntry).filter(
            SubtitleAutomationQueueEntry.wanted_item_id == wanted_item_id
        ).update({"updated_at": stamp}, synchronize_session=False)
        repo.session.commit()

    def test_old_done_rows_are_purged(self, repo):
        repo.enqueue(wanted_item_id=1, file_path="/m/a.mkv", target_language="ger")
        entry = repo.get_by_wanted_item(1)
        repo.mark_done(entry["id"])
        self._age(repo, 1, days=10)

        assert repo.purge_finished() == 1
        assert repo.get_by_wanted_item(1) is None

    def test_recent_done_rows_survive(self, repo):
        repo.enqueue(wanted_item_id=2, file_path="/m/b.mkv", target_language="ger")
        repo.mark_done(repo.get_by_wanted_item(2)["id"])

        assert repo.purge_finished() == 0
        assert repo.get_by_wanted_item(2) is not None

    def test_old_terminal_failures_are_purged(self, repo):
        repo.enqueue(wanted_item_id=3, file_path="/m/c.mkv", target_language="ger")
        repo.mark_failed(
            repo.get_by_wanted_item(3)["id"], error="gone for good", next_retry_at=None
        )
        self._age(repo, 3, days=40)

        assert repo.purge_finished() == 1
        assert repo.get_by_wanted_item(3) is None

    def test_terminal_failures_are_kept_within_window(self, repo):
        """30 days of terminal failures stay visible for diagnosis."""
        repo.enqueue(wanted_item_id=4, file_path="/m/d.mkv", target_language="ger")
        repo.mark_failed(
            repo.get_by_wanted_item(4)["id"], error="gone for good", next_retry_at=None
        )
        self._age(repo, 4, days=10)

        assert repo.purge_finished() == 0
        assert repo.get_by_wanted_item(4) is not None

    def test_retrying_failures_are_never_purged(self, repo):
        """A row still on the backoff ladder is pending work, however old."""
        repo.enqueue(wanted_item_id=5, file_path="/m/e.mkv", target_language="ger")
        repo.mark_failed(
            repo.get_by_wanted_item(5)["id"],
            error="transient",
            next_retry_at=datetime.now(UTC) + timedelta(days=1),
        )
        self._age(repo, 5, days=400)

        assert repo.purge_finished() == 0
        assert repo.get_by_wanted_item(5) is not None

    def test_pending_rows_are_never_purged(self, repo):
        repo.enqueue(wanted_item_id=6, file_path="/m/f.mkv", target_language="ger")
        self._age(repo, 6, days=400)

        assert repo.purge_finished() == 0
        assert repo.get_by_wanted_item(6) is not None


# ── wired into the scheduler history cleanup tick ────────────────────────────


def test_history_cleanup_tick_also_purges_queue(app_ctx):
    from utils import scheduler_retention

    with (
        patch.object(scheduler_retention, "delete_old_job_runs", return_value=3) as jr,
        patch.object(SubtitleAutomationQueueRepository, "purge_finished", return_value=2) as pf,
    ):
        scheduler_retention.internal_history_cleanup()
    jr.assert_called_once()
    pf.assert_called_once()