Spaces:
Sleeping
Sleeping
File size: 6,797 Bytes
b96346f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | """Standalone scheduler process — runs the cron jobs in its own OS process
so the FastAPI server can run with multiple uvicorn workers.
Responsibilities (taken over from the FastAPI lifespan):
- Resolve the monitored mail folder ID via Microsoft Graph.
- Bootstrap the Graph subscription: reuse an active one if it has more
than 60 minutes of life left; otherwise create a new one.
- Start the APScheduler-driven RenewalScheduler with three jobs:
* subscription_renewal — keeps the Graph subscription alive
* daily_brief — 18:00 America/Chicago email
* retention_cleanup — 02:00 UTC daily DB + log file purge.
- On signal, shut everything down cleanly.
Why a separate process?
The previous design ran APScheduler inside the FastAPI lifespan. That
only works with `--workers 1`; with N workers every cron job would fire
N times. Moving the scheduler out of the API process lets us scale the
webhook receiver horizontally without duplicating cron work.
Startup timing note:
Graph's subscription-creation handshake requires our /webhook/notify
endpoint to be online (Graph POSTs a validation token to it). So this
process retries subscription creation with exponential backoff for up
to ~60 seconds, giving uvicorn time to come up.
Run with: python -m app.scheduler_main
"""
from __future__ import annotations
import asyncio
import logging
import logging.handlers
import signal
import traceback
from datetime import datetime, timedelta, timezone
from app.config import settings
from app.database import get_active_subscription, get_db, init_db
from app.lib.graph.auth import GraphAuthProvider
from app.lib.graph.client import GraphClient
from app.lib.graph.folder_resolver import resolve_folder_id
from app.lib.graph.subscription import SubscriptionManager
from app.lib.utils.notifier import send_developer_alert
from app.scheduler import RenewalScheduler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [scheduler] %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
_shutdown = asyncio.Event()
def _setup_log_file() -> None:
settings.log_path.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.TimedRotatingFileHandler(
filename=settings.log_path / "scheduler.log",
when="midnight",
interval=1,
backupCount=0,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter("%(asctime)s [scheduler] %(levelname)s %(message)s")
)
logging.getLogger().addHandler(handler)
def _handle_signal(*_: object) -> None:
logger.info("Shutdown signal received")
_shutdown.set()
async def _bootstrap_subscription(
sub_manager: SubscriptionManager,
folder_id: str,
) -> str:
"""Reuse the active subscription if it has >60 minutes of life left;
otherwise create a new one. Retries creation with exponential backoff
so we don't crash if uvicorn isn't fully up yet to receive Graph's
validation POST."""
# Try to reuse first — cheap, no Graph round trip.
async with get_db(settings.database_path) as conn:
existing = await get_active_subscription(conn)
if existing:
expiry = datetime.fromisoformat(
existing["expiry_datetime"].replace("Z", "+00:00")
)
cutoff = datetime.now(timezone.utc) + timedelta(minutes=60)
if expiry > cutoff:
logger.info("Reusing existing subscription %s", existing["id"])
return existing["id"]
# Need to create a new one. Retry with backoff to give uvicorn time
# to come up so Graph's validation POST will succeed.
delays = [2, 4, 8, 16, 30]
last_exc: Exception | None = None
for i, delay in enumerate(delays):
try:
sub_id = await sub_manager.ensure_subscription(folder_id)
logger.info("Registered new subscription %s (attempt %d)", sub_id, i + 1)
return sub_id
except Exception as e:
last_exc = e
logger.warning(
"Subscription creation attempt %d failed (%s); retrying in %ds",
i + 1, type(e).__name__, delay,
)
try:
await asyncio.wait_for(_shutdown.wait(), timeout=delay)
# If we get here, shutdown was requested — abort.
raise asyncio.CancelledError("shutdown during subscription bootstrap")
except asyncio.TimeoutError:
pass
# All attempts exhausted.
tb = "".join(traceback.format_exception(last_exc)) if last_exc else "(no traceback)"
await send_developer_alert(
settings,
subject="[RCM] Scheduler: subscription bootstrap failed after retries",
body=(
"The scheduler process could not create a Graph subscription after "
f"{len(delays)} retries. Email notifications will NOT be received "
f"until this is resolved (container restart or manual intervention).\n\n"
f"{tb}"
),
)
raise RuntimeError("Subscription bootstrap failed") from last_exc
async def run() -> None:
# DB schema is normally applied by `alembic upgrade head` in entrypoint.sh
# before this process starts. init_db is idempotent (CREATE TABLE IF NOT
# EXISTS), so we run it again here for local-dev startups that skip
# alembic.
await init_db(settings.database_path)
auth = GraphAuthProvider(settings)
client = GraphClient(auth)
folder_id = await resolve_folder_id(
client, settings.mail_folder_name, settings.mailbox_user
)
logger.info(
"Monitoring folder '%s' (id=%s)", settings.mail_folder_name, folder_id
)
sub_manager = SubscriptionManager(client, settings, settings.database_path)
scheduler = RenewalScheduler(sub_manager, settings)
try:
sub_id = await _bootstrap_subscription(sub_manager, folder_id)
except Exception:
# _bootstrap_subscription already sent the developer alert.
logger.exception("Scheduler aborting due to subscription bootstrap failure")
await client.aclose()
return
scheduler.start(sub_id)
logger.info("Scheduler running. Waiting for shutdown signal...")
try:
await _shutdown.wait()
finally:
logger.info("Shutting down scheduler")
scheduler.shutdown()
await client.aclose()
logger.info("Scheduler stopped")
def main() -> None:
_setup_log_file()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _handle_signal)
try:
loop.run_until_complete(run())
finally:
loop.close()
if __name__ == "__main__":
main()
|