Files
Calendarr/backend/scheduler.py
T
ScarriffleandClaude Opus 5 a67602dbc2 Configure the invitation mailbox in the UI, not the environment
Putting IMAP settings in environment variables was my call to make and it was
the wrong one: it meant editing a file on the server and restarting to change a
folder name, and it was never offered as a choice.

The mailbox is now a row in mailbox_accounts, edited under Settings → Admin:
host, user, password, port, folder, interval, on/off. Administrators only — it
is a shared forwarding target, not a per-user account. The password is never
returned by any endpoint, the same as the CalDAV account routes, and leaving
the field empty on edit keeps the stored one so changing a port does not mean
retyping it. The panel shows when the mailbox was last read and what went wrong
if it did, because a mailbox that quietly stopped working looks exactly like
one that has no mail.

Worth stating plainly: the password now lives in the database, in clear text,
like the CalDAV, Google and Home Assistant credentials beside it. Unlike those
three, an IMAP password opens an entire mailbox.

The scheduler job is registered unconditionally now — whether a mailbox exists
can change without a restart, and fetch_invites returns immediately when none
does. test_scheduler knows about it.

Also adds the requests list to the sidebar with accept and decline, 26
translation keys across all ten languages, and tests covering that the password
is never returned, that an edit without one keeps it, and that a disabled
mailbox counts as unconfigured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-29 17:31:56 +01:00

170 lines
5.8 KiB
Python

"""A small in-process scheduler for periodic maintenance jobs.
The server runs as a single uvicorn process (``main.py`` calls ``uvicorn.run``
with no ``workers=``), so an in-process loop runs exactly once — no locking or
leader election needed. If the deployment ever grows to multiple workers this
has to be revisited, because every worker would run every job.
Jobs are plain synchronous callables taking a ``Session``. They run in a worker
thread with their own session: SQLAlchemy is synchronous here, and a job doing
file I/O or an HTTP fetch on the event loop would stall every request.
Failures are caught per job, so a broken iCal feed cannot stop the attachment
sweep and neither can stop the loop.
Environment:
SCHEDULER_ENABLED "0" disables the loop entirely (default on)
ATTACHMENT_SWEEP_HOURS how often orphaned attachments are cleaned (24)
ICAL_REFRESH_MINUTES how often subscriptions are checked (15)
"""
import asyncio
import contextlib
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Callable
from sqlalchemy.orm import Session
from database import SessionLocal
logger = logging.getLogger(__name__)
ENABLED = os.environ.get("SCHEDULER_ENABLED", "1") != "0"
# How often the loop wakes to see whether anything is due. Jobs have their own,
# much longer intervals; this only bounds how late a job can start.
TICK_SECONDS = 30
# Delay before the first run so a restart is not slowed by maintenance work.
FIRST_RUN_DELAY_SECONDS = 20
class _Job:
def __init__(self, name: str, interval: float, fn: Callable[[Session], None]):
self.name = name
self.interval = interval
self.fn = fn
self.next_run = time.monotonic() + FIRST_RUN_DELAY_SECONDS
_jobs: list[_Job] = []
def register(name: str, interval_seconds: float, fn: Callable[[Session], None]) -> None:
"""Add a job. Registering the same name twice replaces the first."""
_jobs[:] = [j for j in _jobs if j.name != name]
_jobs.append(_Job(name, interval_seconds, fn))
def _run_job(job: _Job) -> None:
"""Run one job in a worker thread, with its own session."""
db = SessionLocal()
try:
job.fn(db)
except Exception:
# Never let one job's failure escape: the loop must survive it, and so
# must the other jobs.
logger.exception("Scheduled job %s failed", job.name)
with contextlib.suppress(Exception):
db.rollback()
finally:
db.close()
async def _runner() -> None:
logger.info(
"Scheduler started with %d job(s): %s",
len(_jobs),
", ".join(j.name for j in _jobs),
)
while True:
await asyncio.sleep(TICK_SECONDS)
now = time.monotonic()
for job in list(_jobs):
if now < job.next_run:
continue
job.next_run = now + job.interval
await asyncio.to_thread(_run_job, job)
# ── The jobs ──────────────────────────────────────────────
def sweep_attachments(db: Session) -> None:
"""Remove attachment rows whose event is gone, and files with no row.
The explicit purge at each delete path is the normal mechanism; this is the
safety net for what slips past it — an external CalDAV client deleting an
event, a request that died between writing the file and committing the row,
or a delete path added later that forgets the helper.
"""
import attachments_store
attachments_store.sweep(db)
def refresh_ical_subscriptions(db: Session) -> None:
"""Re-fetch iCal subscriptions that are due.
``_refresh_if_needed`` already honours each subscription's own
``refresh_minutes`` and swallows network errors — except when a
subscription has no cached data at all, where it re-raises. So every
subscription is wrapped separately: one unreachable URL must not stop the
rest.
The lazy refresh in the merged read stays as it is. This only adds the case
nobody was covering: keeping subscriptions current while no one has the app
open.
"""
import models
from routers.ical_router import _refresh_if_needed
subs = (
db.query(models.ICalSubscription)
.filter(models.ICalSubscription.enabled == True) # noqa: E712
.all()
)
for sub in subs:
try:
_refresh_if_needed(sub, db)
except Exception:
logger.warning("Scheduled refresh of iCal subscription %s failed", sub.id)
with contextlib.suppress(Exception):
db.rollback()
def fetch_mail_invites(db: Session) -> None:
"""Read the watched mailbox for new invitations."""
import mail_invites
mail_invites.fetch_invites(db)
def install_default_jobs() -> None:
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions)
# Always registered: the mailbox is configured in the UI, so whether there
# is one to read can change without a restart. fetch_invites returns at once
# when none is set up.
mail_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5"))
register("mail.invites", mail_minutes * 60, fetch_mail_invites)
@asynccontextmanager
async def lifespan(app):
task = None
if ENABLED:
install_default_jobs()
task = asyncio.create_task(_runner())
else:
logger.info("Scheduler disabled via SCHEDULER_ENABLED=0")
try:
yield
finally:
if task is not None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task