Files
Calendarr/backend/scheduler.py
T
ScarriffleandClaude Opus 5 9bf7d3797d Take in invitations that arrive by mail
A watched mailbox is a trust boundary this app did not have: anyone who knows
the address can hand the server an iCalendar file. So a mail can do exactly one
thing — create a row in event_invites. No calendar read path touches that
table, and an invitation becomes an event only when a person accepts it.

Routing is by the ATTENDEE address, never the From header, which a sender can
claim freely. Addresses are matched against users.email plus a dedicated table,
so forwarded mail still finds its owner: the invitation names the original
address, not the mailbox it landed in.

An accepted invitation gets a fresh UID; the organiser's goes to external_uid.
LocalEvent.uid is globally unique, so reusing it would let a stranger's mail
collide with an event already there. That case has a test, because it is the
one that would be a real problem.

parse_ics now reads METHOD, SEQUENCE, the organiser address, the ATTENDEE list
and RECURRENCE-ID. All additive — existing callers read the keys they knew.
METHOD sits on the VCALENDAR, so it is read outside the component loop; that is
the only reliable way to tell an invitation from a plain calendar file. A VEVENT
carrying RECURRENCE-ID is skipped rather than applied, since treating one
occurrence as the series would move every other date.

The mailbox job registers only when IMAP_HOST is set: an unconditional job would
log every few minutes that it has nothing to do, and test_scheduler asserts the
exact job set. Credentials come from the environment, which keeps the one secret
that opens a whole mailbox out of a table an endpoint could return.

Replies to the organiser are the next step; accepting and declining work without
them.

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

171 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)
# Only when a mailbox is configured: an unconditional job would log every
# few minutes that it has nothing to do, and test_scheduler asserts the
# exact job set.
if os.environ.get("IMAP_HOST"):
poll_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5"))
register("mail.invites", poll_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