from collections.abc import Callable from datetime import date from socket import gethostname from typing import Any from fastapi import FastAPI from app.core.config import get_settings from app.core.constants import ActorValue from app.modules.feishu.constants import FeishuReceiveIdType from app.application.delivery import ReportDeliveryService from app.modules.observability.constants import HeartbeatComponent def attach_scheduler(app: FastAPI) -> None: """Attach optional APScheduler jobs to the FastAPI application.""" settings = get_settings() if not settings.scheduler_enabled: return scheduler = create_scheduler(app) @app.on_event("startup") def start_scheduler() -> None: scheduler.start() @app.on_event("shutdown") def stop_scheduler() -> None: scheduler.shutdown(wait=False) def create_scheduler(app: FastAPI | None = None) -> Any: """Create the V2/V3 scheduler without requiring a FastAPI process.""" from apscheduler.schedulers.background import BackgroundScheduler from app.core.database import SessionLocal from app.core.background.task_queue import ( enqueue_attendance_summary_push, enqueue_daily_brief_push, enqueue_event_dispatch, enqueue_legacy_project_sync, enqueue_legacy_task_sync, enqueue_lifecycle_report, enqueue_market_report, enqueue_project_weekly_push, enqueue_risk_progress_push, enqueue_subscription_cycle, enqueue_work_daily_push, enqueue_work_weekly_push, ) from app.modules.observability.service import ObservabilityService from app.modules.personalization.services import ConversationService from app.modules.reports.services import ReportService settings = get_settings() scheduler = BackgroundScheduler( timezone="Asia/Shanghai", job_defaults={ "coalesce": True, "max_instances": 1, "misfire_grace_time": 300, }, ) def deliver_report( state_key: str, build_report: Callable[[Any], dict[str, Any]], enqueue_push: Callable[..., dict[str, Any]], ) -> None: db = SessionLocal() try: service = ReportService(db) report = build_report(service) _set_state(app, state_key, report) if not _feishu_delivery_configured(settings): return if settings.task_queue_enabled: dispatch = enqueue_push( receive_id=settings.feishu_default_chat_id, receive_id_type=FeishuReceiveIdType.CHAT_ID, actor=ActorValue.SCHEDULER, ) _set_state(app, f"{state_key}_dispatch", dispatch) return ReportDeliveryService(db).push_report( report, receive_id=settings.feishu_default_chat_id, receive_id_type=FeishuReceiveIdType.CHAT_ID, actor=ActorValue.SCHEDULER, ) finally: db.close() def run_daily_brief() -> None: deliver_report( "last_daily_brief", lambda service: service.daily_brief(), enqueue_daily_brief_push, ) def run_project_weekly() -> None: deliver_report( "last_project_weekly", lambda service: service.project_weekly(), enqueue_project_weekly_push, ) def run_attendance_summary() -> None: deliver_report( "last_attendance_summary", lambda service: service.attendance_summary(), enqueue_attendance_summary_push, ) def run_risk_progress() -> None: deliver_report( "last_risk_progress", lambda service: service.risk_progress(), enqueue_risk_progress_push, ) def run_work_daily() -> None: deliver_report( "last_work_daily", lambda service: service.work_daily_report( reporter=ActorValue.SCHEDULER, actor=ActorValue.SCHEDULER, ), enqueue_work_daily_push, ) def run_work_weekly() -> None: deliver_report( "last_work_weekly", lambda service: service.work_weekly_report( reporter=ActorValue.SCHEDULER, actor=ActorValue.SCHEDULER, ), enqueue_work_weekly_push, ) def run_legacy_project_sync() -> None: dispatch = enqueue_legacy_project_sync(actor=ActorValue.SCHEDULER) _set_state(app, "last_legacy_project_sync_dispatch", dispatch) def run_legacy_task_sync() -> None: dispatch = enqueue_legacy_task_sync(actor=ActorValue.SCHEDULER) _set_state(app, "last_legacy_task_sync_dispatch", dispatch) def run_daily_lifecycle() -> None: dispatch = enqueue_lifecycle_report( report_type="daily", receive_id=settings.feishu_default_chat_id, receive_id_type=FeishuReceiveIdType.CHAT_ID, actor=ActorValue.SCHEDULER, ) _set_state(app, "last_daily_lifecycle_dispatch", dispatch) def run_weekly_lifecycle() -> None: dispatch = enqueue_lifecycle_report( report_type="weekly", receive_id=settings.feishu_default_chat_id, receive_id_type=FeishuReceiveIdType.CHAT_ID, actor=ActorValue.SCHEDULER, ) _set_state(app, "last_weekly_lifecycle_dispatch", dispatch) def run_market_premarket() -> None: dispatch = enqueue_market_report("premarket") _set_state(app, "last_market_premarket_dispatch", dispatch) def run_market_close() -> None: dispatch = enqueue_market_report("close") _set_state(app, "last_market_close_dispatch", dispatch) def run_market_weekly() -> None: dispatch = enqueue_market_report("weekly", date.today()) _set_state(app, "last_market_weekly_dispatch", dispatch) def run_event_dispatch() -> None: dispatch = enqueue_event_dispatch( limit=settings.event_dispatch_batch_size, actor=ActorValue.SCHEDULER, ) _set_state(app, "last_event_dispatch", dispatch) def record_scheduler_heartbeat() -> None: db = SessionLocal() try: heartbeat = ObservabilityService(db).record_heartbeat( component=HeartbeatComponent.SCHEDULER, instance_id=gethostname(), actor=ActorValue.SCHEDULER, ) _set_state(app, "last_scheduler_heartbeat", heartbeat) finally: db.close() def run_subscription_cycle() -> None: dispatch = enqueue_subscription_cycle(actor=ActorValue.SCHEDULER) _set_state(app, "last_subscription_cycle", dispatch) def run_personalization_retention_cleanup() -> None: db = SessionLocal() try: deleted = ConversationService(db).cleanup_expired_globally() _set_state(app, "last_personalization_retention_cleanup", deleted) finally: db.close() if settings.lifecycle_pipeline_enabled: scheduler.add_job( run_daily_lifecycle, trigger="cron", hour=settings.daily_brief_cron_hour, minute=settings.daily_brief_cron_minute, id="daily_lifecycle_pipeline", replace_existing=True, ) scheduler.add_job( run_weekly_lifecycle, trigger="cron", day_of_week=settings.weekly_project_report_day_of_week, hour=settings.weekly_project_report_cron_hour, minute=settings.weekly_project_report_cron_minute, id="weekly_lifecycle_pipeline", replace_existing=True, ) else: scheduler.add_job( run_daily_brief, trigger="cron", hour=settings.daily_brief_cron_hour, minute=settings.daily_brief_cron_minute, id="daily_brief_push", replace_existing=True, ) scheduler.add_job( run_project_weekly, trigger="cron", day_of_week=settings.weekly_project_report_day_of_week, hour=settings.weekly_project_report_cron_hour, minute=settings.weekly_project_report_cron_minute, id="project_weekly_push", replace_existing=True, ) scheduler.add_job( run_attendance_summary, trigger="cron", hour=settings.attendance_summary_cron_hour, minute=settings.attendance_summary_cron_minute, id="attendance_summary_push", replace_existing=True, ) scheduler.add_job( run_risk_progress, trigger="cron", hour=settings.risk_progress_cron_hour, minute=settings.risk_progress_cron_minute, id="risk_progress_push", replace_existing=True, ) scheduler.add_job( run_work_daily, trigger="cron", hour=settings.work_daily_cron_hour, minute=settings.work_daily_cron_minute, id="work_daily_push", replace_existing=True, ) scheduler.add_job( run_work_weekly, trigger="cron", day_of_week=settings.work_weekly_report_day_of_week, hour=settings.work_weekly_cron_hour, minute=settings.work_weekly_cron_minute, id="work_weekly_push", replace_existing=True, ) if settings.event_dispatch_enabled: scheduler.add_job( run_event_dispatch, trigger="cron", minute=settings.event_dispatch_cron_minute, id="event_dispatch", replace_existing=True, ) if settings.market_analysis_enabled: scheduler.add_job( run_market_premarket, trigger="cron", day_of_week="mon-fri", hour=settings.market_premarket_cron_hour, minute=settings.market_premarket_cron_minute, id="market_premarket_analysis", replace_existing=True, ) scheduler.add_job( run_market_close, trigger="cron", day_of_week="mon-fri", hour=settings.market_close_cron_hour, minute=settings.market_close_cron_minute, id="market_close_analysis", replace_existing=True, ) scheduler.add_job( run_market_weekly, trigger="cron", day_of_week=settings.market_weekly_day_of_week, hour=settings.market_weekly_cron_hour, minute=settings.market_weekly_cron_minute, id="market_weekly_analysis", replace_existing=True, ) scheduler.add_job( record_scheduler_heartbeat, trigger="interval", seconds=settings.heartbeat_interval_seconds, id="scheduler_heartbeat", replace_existing=True, ) if settings.feishu_user_features_enabled: scheduler.add_job( run_subscription_cycle, trigger="interval", minutes=1, id="subscription_delivery_cycle", replace_existing=True, ) scheduler.add_job( run_personalization_retention_cleanup, trigger="interval", minutes=1, id="personalization_retention_cleanup", replace_existing=True, ) if ( not settings.lifecycle_pipeline_enabled and settings.legacy_sync_enabled and settings.legacy_project_query ): scheduler.add_job( run_legacy_project_sync, trigger="cron", hour=settings.legacy_project_sync_cron_hour, minute=settings.legacy_project_sync_cron_minute, id="legacy_project_sync", replace_existing=True, ) if ( not settings.lifecycle_pipeline_enabled and settings.legacy_sync_enabled and settings.legacy_task_query ): scheduler.add_job( run_legacy_task_sync, trigger="cron", hour=settings.legacy_task_sync_cron_hour, minute=settings.legacy_task_sync_cron_minute, id="legacy_task_sync", replace_existing=True, ) return scheduler def _set_state(app: FastAPI | None, key: str, value: Any) -> None: if app is not None: setattr(app.state, key, value) def _feishu_delivery_configured(settings: Any) -> bool: return bool( settings.feishu_app_id and settings.feishu_app_secret and settings.feishu_default_chat_id )