SMTP adapter with TLS and a fake, outbox with retries, backoff, rate limit and restart safety, scheduler tick with job-run records started by gunicorn, and a send_test_mail command. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
105 lines
3.2 KiB
Python
105 lines
3.2 KiB
Python
"""The scheduler: one tick runs the jobs and records each run (AD-12)."""
|
|
|
|
import threading
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
from django.utils import timezone
|
|
|
|
from portal.mailer import services as mailer
|
|
from portal.scheduler import services
|
|
from portal.scheduler.models import JobRun
|
|
from portal.scheduler.runner import Runner
|
|
|
|
pytestmark = pytest.mark.django_db(transaction=True)
|
|
|
|
|
|
def test_a_tick_runs_the_mail_job_and_records_the_run(adapters):
|
|
mailer.enqueue("test", "lena@example.test")
|
|
services.tick()
|
|
assert len(adapters.mailer.sent) == 1
|
|
run = JobRun.objects.get(job="mail.flush")
|
|
assert run.status == JobRun.Status.OK and run.finished_at
|
|
assert run.detail == "sent=1 retried=0 failed=0"
|
|
|
|
|
|
def test_an_idle_tick_is_recorded_too_and_repeating_it_is_harmless(adapters):
|
|
services.tick()
|
|
services.tick()
|
|
assert JobRun.objects.filter(job="mail.flush").count() == 2
|
|
assert adapters.mailer.sent == []
|
|
|
|
|
|
def test_a_restart_sends_nothing_twice(adapters):
|
|
mailer.enqueue("test", "lena@example.test")
|
|
services.tick()
|
|
services.tick(timezone.now() + timedelta(minutes=5)) # "after a restart"
|
|
assert len(adapters.mailer.sent) == 1
|
|
|
|
|
|
def test_a_failing_job_is_recorded_and_the_others_still_run():
|
|
ran = []
|
|
|
|
def broken(now):
|
|
raise RuntimeError("secret detail that must not be stored")
|
|
|
|
jobs = (
|
|
services.Job("first", lambda now: ran.append("first") or "fine"),
|
|
services.Job("broken", broken),
|
|
services.Job("last", lambda now: ran.append("last") or "also fine"),
|
|
)
|
|
services.tick(jobs=jobs)
|
|
assert ran == ["first", "last"]
|
|
broken_run = JobRun.objects.get(job="broken")
|
|
assert broken_run.status == JobRun.Status.ERROR and broken_run.detail == "RuntimeError"
|
|
assert JobRun.objects.get(job="last").status == JobRun.Status.OK
|
|
|
|
|
|
def test_old_run_records_are_removed():
|
|
now = timezone.now()
|
|
JobRun.objects.create(job="x", started_at=now - timedelta(days=services.KEEP_RUNS_DAYS + 1))
|
|
JobRun.objects.create(job="x", started_at=now - timedelta(days=1))
|
|
services.tick(now, jobs=())
|
|
assert JobRun.objects.filter(job="x").count() == 1
|
|
|
|
|
|
# -- the thread -----------------------------------------
|
|
|
|
|
|
def test_the_thread_ticks_repeatedly_and_stops():
|
|
ticked = threading.Semaphore(0)
|
|
runner = Runner(tick=ticked.release, interval=0.01)
|
|
runner.start()
|
|
assert ticked.acquire(timeout=2) and ticked.acquire(timeout=2)
|
|
runner.stop()
|
|
assert not runner._thread.is_alive()
|
|
|
|
|
|
def test_a_failing_tick_does_not_stop_the_thread():
|
|
calls = threading.Semaphore(0)
|
|
|
|
def tick():
|
|
calls.release()
|
|
raise RuntimeError("boom")
|
|
|
|
runner = Runner(tick=tick, interval=0.01)
|
|
runner.start()
|
|
assert calls.acquire(timeout=2) and calls.acquire(timeout=2)
|
|
runner.stop()
|
|
|
|
|
|
def test_starting_twice_runs_only_one_thread():
|
|
runner = Runner(tick=lambda: None, interval=0.05)
|
|
runner.start()
|
|
first = runner._thread
|
|
runner.start()
|
|
assert runner._thread is first
|
|
runner.stop()
|
|
assert sum(1 for t in threading.enumerate() if t.name == "scheduler") == 0
|
|
|
|
|
|
def test_the_default_interval_is_thirty_seconds():
|
|
from portal.scheduler.runner import TICK_SECONDS, runner
|
|
|
|
assert TICK_SECONDS == 30 and runner._interval == 30
|