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
52 lines
1.4 KiB
Python
52 lines
1.4 KiB
Python
"""The scheduler thread: one per process, one tick every 30 seconds (AD-12)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from collections.abc import Callable
|
|
|
|
from django.db import close_old_connections
|
|
|
|
from . import services
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TICK_SECONDS = 30
|
|
|
|
|
|
class Runner:
|
|
def __init__(self, tick: Callable[[], None] = services.tick, interval: float = TICK_SECONDS):
|
|
self._tick = tick
|
|
self._interval = interval
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
|
|
def start(self) -> None:
|
|
"""Start the thread; calling it again while it runs does nothing."""
|
|
if self._thread is not None and self._thread.is_alive():
|
|
return
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(target=self._loop, name="scheduler", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self, timeout: float = 5) -> None:
|
|
self._stop.set()
|
|
if self._thread is not None:
|
|
self._thread.join(timeout)
|
|
|
|
def _loop(self) -> None:
|
|
logger.info("Scheduler started")
|
|
while not self._stop.is_set():
|
|
try:
|
|
self._tick()
|
|
except Exception:
|
|
logger.exception("Scheduler tick failed")
|
|
finally:
|
|
close_old_connections()
|
|
self._stop.wait(self._interval)
|
|
logger.info("Scheduler stopped")
|
|
|
|
|
|
runner = Runner()
|