feat: add scheduler and mail outbox
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
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user