feat: run stale-job recovery periodically

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
Jim Lancaster
2026-08-23 18:28:21 -05:00
co-authored by Copilot App
parent 736d0c06f4
commit f193b2800b
7 changed files with 63 additions and 3 deletions
+1
View File
@@ -57,6 +57,7 @@ DATABASE_BACKUP_DIR=./data/backups
# --- worker reliability --- # --- worker reliability ---
WORKER_MAX_RETRIES=0 WORKER_MAX_RETRIES=0
WORKER_PROVIDER_TIMEOUT_SECONDS=30.0 WORKER_PROVIDER_TIMEOUT_SECONDS=30.0
WORKER_STALE_JOB_SECONDS=30.0
WORKER_MIN_TRANSCRIPTION_CHARS=0 WORKER_MIN_TRANSCRIPTION_CHARS=0
WORKER_MIN_TRANSCRIPTION_LINES=0 WORKER_MIN_TRANSCRIPTION_LINES=0
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
+3 -3
View File
@@ -71,11 +71,11 @@ async def _lifespan(app: FastAPI):
async def _recover_stale_processing_jobs(app: FastAPI) -> None: async def _recover_stale_processing_jobs(app: FastAPI) -> None:
"""Re-queue stale processing jobs at startup. """Re-queue stale processing jobs at startup.
Any job left in PROCESSING longer than the configured provider timeout is Any job left in PROCESSING longer than the stale-job threshold is assumed
assumed orphaned and moved back to QUEUED before the worker starts. orphaned and moved back to QUEUED before the worker starts.
""" """
settings = app.state.settings settings = app.state.settings
stale_before = datetime.now(UTC) - timedelta(seconds=settings.worker_provider_timeout_seconds) stale_before = datetime.now(UTC) - timedelta(seconds=settings.worker_stale_job_seconds)
recovered = await app.state.services.jobs.requeue_stale_processing_jobs(stale_before=stale_before) recovered = await app.state.services.jobs.requeue_stale_processing_jobs(stale_before=stale_before)
if recovered > 0: if recovered > 0:
logger.warning("Recovered %s stale processing job(s) at startup", recovered) logger.warning("Recovered %s stale processing job(s) at startup", recovered)
+1
View File
@@ -114,6 +114,7 @@ class Settings(BaseSettings):
# Bounded only from below. Vision transcription of a dense page routinely runs # Bounded only from below. Vision transcription of a dense page routinely runs
# well past twenty seconds, so an upper cap here would silently fail real work. # well past twenty seconds, so an upper cap here would silently fail real work.
worker_provider_timeout_seconds: float = Field(default=30.0, gt=0.0) worker_provider_timeout_seconds: float = Field(default=30.0, gt=0.0)
worker_stale_job_seconds: float = Field(default=30.0, gt=0.0)
worker_min_transcription_chars: int = Field(default=0, ge=0) worker_min_transcription_chars: int = Field(default=0, ge=0)
worker_min_transcription_lines: int = Field(default=0, ge=0) worker_min_transcription_lines: int = Field(default=0, ge=0)
worker_fail_on_finish_reason_length: bool = False worker_fail_on_finish_reason_length: bool = False
+11
View File
@@ -9,6 +9,9 @@ from contextlib import asynccontextmanager
from contextlib import contextmanager from contextlib import contextmanager
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from typing import Literal from typing import Literal
from typing import Protocol from typing import Protocol
from typing import runtime_checkable from typing import runtime_checkable
@@ -215,6 +218,14 @@ async def run_worker_loop(
await asyncio.wait_for(wake_event.wait(), timeout=poll_interval_seconds) await asyncio.wait_for(wake_event.wait(), timeout=poll_interval_seconds)
wake_event.clear() wake_event.clear()
if session_factory is not None:
with handle_worker_exceptions(operation="worker.requeue_stale_processing_jobs"):
stale_seconds = services.jobs.settings.worker_stale_job_seconds
stale_before = datetime.now(UTC) - timedelta(seconds=stale_seconds)
recovered = await services.jobs.requeue_stale_processing_jobs(stale_before=stale_before)
if recovered > 0:
logger.warning("Recovered %s stale processing job(s) in worker loop", recovered)
processed_any = False processed_any = False
while True: while True:
with handle_worker_exceptions(operation="worker.process_next_queued_job"): with handle_worker_exceptions(operation="worker.process_next_queued_job"):
+1
View File
@@ -167,6 +167,7 @@ class TestWorkerReliabilitySettings:
"""worker retry settings default to no retries.""" """worker retry settings default to no retries."""
settings = _make_settings() settings = _make_settings()
assert settings.worker_max_retries == 0 assert settings.worker_max_retries == 0
assert settings.worker_stale_job_seconds == 30.0
def test_provider_timeout_is_not_capped_at_twenty_seconds(): def test_provider_timeout_is_not_capped_at_twenty_seconds():
+1
View File
@@ -167,6 +167,7 @@ def test_env_example_default_values_match_settings_defaults():
"DATABASE_BACKUP_DIR": str(defaults.database_backup_dir), "DATABASE_BACKUP_DIR": str(defaults.database_backup_dir),
"WORKER_MAX_RETRIES": str(defaults.worker_max_retries), "WORKER_MAX_RETRIES": str(defaults.worker_max_retries),
"WORKER_PROVIDER_TIMEOUT_SECONDS": str(defaults.worker_provider_timeout_seconds), "WORKER_PROVIDER_TIMEOUT_SECONDS": str(defaults.worker_provider_timeout_seconds),
"WORKER_STALE_JOB_SECONDS": str(defaults.worker_stale_job_seconds),
"WORKER_MIN_TRANSCRIPTION_CHARS": str(defaults.worker_min_transcription_chars), "WORKER_MIN_TRANSCRIPTION_CHARS": str(defaults.worker_min_transcription_chars),
"WORKER_MIN_TRANSCRIPTION_LINES": str(defaults.worker_min_transcription_lines), "WORKER_MIN_TRANSCRIPTION_LINES": str(defaults.worker_min_transcription_lines),
"WORKER_FAIL_ON_FINISH_REASON_LENGTH": str(defaults.worker_fail_on_finish_reason_length).lower(), "WORKER_FAIL_ON_FINISH_REASON_LENGTH": str(defaults.worker_fail_on_finish_reason_length).lower(),
+45
View File
@@ -1,9 +1,15 @@
import asyncio import asyncio
import logging import logging
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from typing import cast from typing import cast
import pytest import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services import ServiceBundle from transcription.services import ServiceBundle
@@ -79,6 +85,45 @@ async def test_run_worker_loop_survives_retriable_exception(monkeypatch, caplog)
assert worker_health.snapshot().state == "stopped" assert worker_health.snapshot().state == "stopped"
@pytest.mark.asyncio
async def test_run_worker_loop_periodically_requeues_stale_processing_jobs(monkeypatch):
stop_event = asyncio.Event()
stale_sweep_calls: list[datetime] = []
class _Jobs:
settings = Settings(openrouter_api_key="test-key", worker_stale_job_seconds=120.0)
async def requeue_stale_processing_jobs(self, *, stale_before, session=None):
_ = session
stale_sweep_calls.append(stale_before)
return 1
class _Bundle:
jobs = _Jobs()
async def aclose(self):
return
monkeypatch.setattr(
"transcription.worker.ServiceBundle.from_session_factory",
classmethod(lambda _cls, _factory=None, **_kwargs: cast(ServiceBundle, _Bundle())),
)
async def _fake_process_next_queued_job(*, session=None, session_factory=None, services=None):
_ = (session, session_factory, services)
stop_event.set()
return False
monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job)
fake_session_factory = cast(async_sessionmaker[AsyncSession], object())
await run_worker_loop(stop_event=stop_event, poll_interval_seconds=0, session_factory=fake_session_factory)
assert len(stale_sweep_calls) >= 1
expected_upper_bound = datetime.now(UTC) - timedelta(seconds=120.0)
assert stale_sweep_calls[0] <= expected_upper_bound
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_worker_loop_reuses_one_bundle_across_jobs(monkeypatch): async def test_run_worker_loop_reuses_one_bundle_across_jobs(monkeypatch):
"""HIGH-02: the provider client is built once per loop, not once per job.""" """HIGH-02: the provider client is built once per loop, not once per job."""