""" Background Task Scheduler for POS Service Sets up periodic background jobs for: - Syncing POS transactions to sales service - Other maintenance tasks as needed Uses Redis-based leader election to ensure only one pod runs scheduled tasks when running with multiple replicas. Usage in main.py: ```python from app.scheduler import POSScheduler # On startup scheduler = POSScheduler(redis_url=settings.REDIS_URL) await scheduler.start() # On shutdown await scheduler.stop() ``` """ import structlog from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from datetime import datetime from typing import Optional logger = structlog.get_logger() class POSScheduler: """ POS Scheduler service that manages background sync jobs. Uses Redis-based leader election to ensure only one pod runs scheduled jobs in a multi-replica deployment. """ def __init__(self, redis_url: str = None, sync_interval_minutes: int = 5): """ Initialize POS scheduler. Args: redis_url: Redis connection URL for leader election sync_interval_minutes: Interval for POS-to-sales sync job """ self.scheduler = None self.sync_interval_minutes = sync_interval_minutes # Leader election self._redis_url = redis_url self._leader_election = None self._redis_client = None self._scheduler_started = False async def start(self): """Start the POS scheduler with leader election""" if self._redis_url: await self._start_with_leader_election() else: # Fallback to standalone mode (for local development or single-pod deployments) logger.warning("Redis URL not provided, starting POS scheduler in standalone mode") await self._start_standalone() async def _start_with_leader_election(self): """Start with Redis-based leader election for horizontal scaling""" import redis.asyncio as redis from shared.leader_election import LeaderElectionService try: # Create Redis connection self._redis_client = redis.from_url(self._redis_url, decode_responses=False) await self._redis_client.ping() # Create scheduler (but don't start it yet) self.scheduler = AsyncIOScheduler() # Create leader election self._leader_election = LeaderElectionService( self._redis_client, service_name="pos-scheduler" ) # Start leader election with callbacks await self._leader_election.start( on_become_leader=self._on_become_leader, on_lose_leader=self._on_lose_leader ) logger.info("POS scheduler started with leader election", is_leader=self._leader_election.is_leader, instance_id=self._leader_election.instance_id) except Exception as e: logger.error("Failed to start with leader election, falling back to standalone", error=str(e)) await self._start_standalone() async def _on_become_leader(self): """Called when this instance becomes the leader""" logger.info("POS scheduler became leader, starting scheduled jobs") await self._start_scheduler() async def _on_lose_leader(self): """Called when this instance loses leadership""" logger.warning("POS scheduler lost leadership, stopping scheduled jobs") await self._stop_scheduler() async def _start_scheduler(self): """Start the APScheduler with POS jobs""" if self._scheduler_started: logger.warning("POS scheduler already started") return try: # Import sync job from app.jobs.sync_pos_to_sales import run_pos_to_sales_sync # Job 1: Sync POS transactions to sales service self.scheduler.add_job( run_pos_to_sales_sync, trigger=IntervalTrigger(minutes=self.sync_interval_minutes), id='pos_to_sales_sync', name='Sync POS Transactions to Sales', replace_existing=True, max_instances=1, # Prevent concurrent runs coalesce=True, # Combine multiple missed runs into one misfire_grace_time=60 # Allow 60 seconds grace for missed runs ) # Start scheduler if not self.scheduler.running: self.scheduler.start() self._scheduler_started = True logger.info("POS scheduler jobs started", sync_interval_minutes=self.sync_interval_minutes, job_count=len(self.scheduler.get_jobs()), next_run=self.scheduler.get_jobs()[0].next_run_time if self.scheduler.get_jobs() else None) except Exception as e: logger.error("Failed to start POS scheduler", error=str(e)) async def _stop_scheduler(self): """Stop the APScheduler""" if not self._scheduler_started: return try: if self.scheduler and self.scheduler.running: self.scheduler.shutdown(wait=False) self._scheduler_started = False logger.info("POS scheduler jobs stopped") except Exception as e: logger.error("Failed to stop POS scheduler", error=str(e)) async def _start_standalone(self): """Start scheduler without leader election (fallback mode)""" logger.warning("Starting POS scheduler in standalone mode (no leader election)") self.scheduler = AsyncIOScheduler() try: # Import sync job from app.jobs.sync_pos_to_sales import run_pos_to_sales_sync self.scheduler.add_job( run_pos_to_sales_sync, trigger=IntervalTrigger(minutes=self.sync_interval_minutes), id='pos_to_sales_sync', name='Sync POS Transactions to Sales', replace_existing=True, max_instances=1, coalesce=True, misfire_grace_time=60 ) if not self.scheduler.running: self.scheduler.start() self._scheduler_started = True logger.info("POS scheduler started (standalone mode)", sync_interval_minutes=self.sync_interval_minutes, next_run=self.scheduler.get_jobs()[0].next_run_time if self.scheduler.get_jobs() else None) except Exception as e: logger.error("Failed to start POS scheduler in standalone mode", error=str(e)) async def stop(self): """Stop the POS scheduler and leader election""" # Stop leader election if self._leader_election: await self._leader_election.stop() # Stop scheduler await self._stop_scheduler() # Close Redis if self._redis_client: await self._redis_client.close() logger.info("POS scheduler stopped") @property def is_leader(self) -> bool: """Check if this instance is the leader""" return self._leader_election.is_leader if self._leader_election else True def get_leader_status(self) -> dict: """Get leader election status""" if self._leader_election: return self._leader_election.get_status() return {"is_leader": True, "mode": "standalone"} def get_scheduler_status(self) -> dict: """ Get current scheduler status Returns: Dict with scheduler info and job statuses """ if self.scheduler is None or not self._scheduler_started: return { "running": False, "is_leader": self.is_leader, "jobs": [] } jobs = [] for job in self.scheduler.get_jobs(): jobs.append({ "id": job.id, "name": job.name, "next_run": job.next_run_time.isoformat() if job.next_run_time else None, "trigger": str(job.trigger) }) return { "running": True, "is_leader": self.is_leader, "jobs": jobs, "state": self.scheduler.state } def trigger_job_now(self, job_id: str) -> bool: """ Manually trigger a scheduled job immediately Args: job_id: Job identifier (e.g., 'pos_to_sales_sync') Returns: True if job was triggered, False otherwise """ if self.scheduler is None or not self._scheduler_started: logger.error("Cannot trigger job, scheduler not running") return False if not self.is_leader: logger.warning("Cannot trigger job, this instance is not the leader") return False try: job = self.scheduler.get_job(job_id) if job: self.scheduler.modify_job(job_id, next_run_time=datetime.now()) logger.info("Job triggered manually", job_id=job_id) return True else: logger.warning("Job not found", job_id=job_id) return False except Exception as e: logger.error("Failed to trigger job", job_id=job_id, error=str(e)) return False # ================================================================ # Legacy compatibility functions (deprecated - use POSScheduler class) # ================================================================ # Global scheduler instance for backward compatibility _scheduler_instance: Optional[POSScheduler] = None def start_scheduler(): """ DEPRECATED: Use POSScheduler class directly for better leader election support. Initialize and start the background scheduler (legacy function). """ global _scheduler_instance if _scheduler_instance is not None: logger.warning("Scheduler already running") return logger.warning("Using deprecated start_scheduler function. " "Consider migrating to POSScheduler class for leader election support.") try: _scheduler_instance = POSScheduler() # Note: This is synchronous fallback, no leader election import asyncio asyncio.create_task(_scheduler_instance._start_standalone()) except Exception as e: logger.error("Failed to start scheduler", error=str(e), exc_info=True) _scheduler_instance = None def shutdown_scheduler(): """ DEPRECATED: Use POSScheduler class directly. Gracefully shutdown the scheduler (legacy function). """ global _scheduler_instance if _scheduler_instance is None: logger.warning("Scheduler not running") return try: import asyncio asyncio.create_task(_scheduler_instance.stop()) _scheduler_instance = None except Exception as e: logger.error("Failed to shutdown scheduler", error=str(e), exc_info=True) def get_scheduler_status(): """ DEPRECATED: Use POSScheduler class directly. Get current scheduler status (legacy function). """ if _scheduler_instance is None: return { "running": False, "jobs": [] } return _scheduler_instance.get_scheduler_status() def trigger_job_now(job_id: str): """ DEPRECATED: Use POSScheduler class directly. Manually trigger a scheduled job immediately (legacy function). """ if _scheduler_instance is None: logger.error("Cannot trigger job, scheduler not running") return False return _scheduler_instance.trigger_job_now(job_id)