CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m46s
CI & Build / Python tests (push) Successful in 2m36s
CI & Build / Build & push image (push) Successful in 31s
The 1-2 hit tail: access, background, backup, backup_job, canonical_systems, design_stylesheet, forge_connections, moment_actions, notes, notifications, placement, planning, platforms, projects, record_batch, recurrence, report_check, retrieval_review, rule_scope, rule_versions, scheduler, shape_check, supersession, system_rulings, system_usage, text, trash, verification, version_pinning. Also corrected while reading: - backup.py said restore step 16 fills the System map; step 15 does. - version_pinning.py pointed at a design doc that is not in the repo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
3.0 KiB
Python
79 lines
3.0 KiB
Python
"""One APScheduler job bridged into the asyncio loop — the shape the
|
|
*_scheduler modules (recurrence spawn, auto-pin scan, trash purge, DB
|
|
maintenance) share instead of each carrying a private copy.
|
|
|
|
APScheduler's BackgroundScheduler fires from a worker thread; the work is
|
|
async and must run on the app's loop, so the fire is bridged with
|
|
``run_coroutine_threadsafe``. Each job is a module-level singleton: start is
|
|
idempotent, stop shuts the scheduler down, and a job whose trigger the
|
|
operator can change (the maintenance hour) reschedules the live job instead
|
|
of restarting.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ScheduledJob:
|
|
"""A named APScheduler job that awaits ``work()`` on the asyncio loop.
|
|
|
|
``work`` is an async callable; exceptions it raises are logged under
|
|
``label`` and never propagate into APScheduler's thread.
|
|
"""
|
|
|
|
def __init__(self, job_id: str, work: Callable[[], Awaitable[None]], *, label: str) -> None:
|
|
self.job_id = job_id
|
|
self._work = work
|
|
self.label = label
|
|
self._scheduler: BackgroundScheduler | None = None
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return self._scheduler is not None
|
|
|
|
def _fire(self) -> None:
|
|
"""APScheduler invokes this from its worker thread; bridge into the loop."""
|
|
if self._loop is None:
|
|
logger.warning("%s scheduler: no loop registered", self.label)
|
|
return
|
|
|
|
async def _runner() -> None:
|
|
try:
|
|
await self._work()
|
|
except Exception:
|
|
logger.exception("%s run failed", self.label)
|
|
|
|
asyncio.run_coroutine_threadsafe(_runner(), self._loop)
|
|
|
|
def start(self, loop: asyncio.AbstractEventLoop, trigger, *, describe: str = "") -> None:
|
|
"""Start the job on ``trigger``. Idempotent — a second start is a no-op."""
|
|
if self._scheduler is not None:
|
|
return
|
|
self._loop = loop
|
|
self._scheduler = BackgroundScheduler()
|
|
self._scheduler.add_job(
|
|
self._fire, trigger=trigger, id=self.job_id, replace_existing=True,
|
|
)
|
|
self._scheduler.start()
|
|
logger.info("%s scheduler started%s", self.label, f" ({describe})" if describe else "")
|
|
|
|
def reschedule(self, trigger, *, describe: str = "") -> None:
|
|
"""Move the live job to a new trigger; a no-op when not running."""
|
|
if self._scheduler is None:
|
|
return
|
|
self._scheduler.reschedule_job(self.job_id, trigger=trigger)
|
|
logger.info("%s scheduler rescheduled%s", self.label, f" to {describe}" if describe else "")
|
|
|
|
def stop(self) -> None:
|
|
if self._scheduler is not None:
|
|
self._scheduler.shutdown(wait=False)
|
|
self._scheduler = None
|
|
logger.info("%s scheduler stopped", self.label)
|