scheduler.py 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019
  1. """
  2. Main Scheduler System for Trixy Application
  3. This module provides the main Scheduler class that manages schedule entries,
  4. handles execution timing, integrates with the event system, and provides
  5. a comprehensive scheduling solution for the Trixy application.
  6. Key Features:
  7. - Multiple schedule entry management with unique name validation
  8. - Thread-safe operations for multi-satellite environments
  9. - Integration with Trixy event system and application container
  10. - Automatic schedule persistence and backup
  11. - Comprehensive monitoring and execution history
  12. - Advanced querying and filtering capabilities
  13. - Graceful startup, shutdown, and error handling
  14. - Cron expression support with advanced scheduling
  15. - Multiple trigger and action types
  16. Components:
  17. - Scheduler: Main scheduler class managing all schedules
  18. - ScheduleManager: Advanced schedule management with querying
  19. - ScheduleQuery: Query builder for finding schedules
  20. - SchedulerConfig: Configuration for scheduler behavior
  21. Usage:
  22. from trixy_core.scheduler import Scheduler, CronTrigger, EventAction
  23. # Create and configure scheduler
  24. scheduler = Scheduler(
  25. storage_path="/config/schedules.json",
  26. auto_start=True,
  27. check_interval=60
  28. )
  29. # Add schedule with cron trigger
  30. scheduler.add_schedule(
  31. name="daily_report",
  32. triggers=[CronTrigger("0 9 * * 1-5")], # 9 AM weekdays
  33. actions=[EventAction("generate_report", {"type": "daily"})],
  34. description="Generate daily reports on weekdays"
  35. )
  36. # Start scheduler
  37. scheduler.start()
  38. # Add more schedules dynamically
  39. scheduler.add_cron_schedule(
  40. name="backup",
  41. cron_expression="0 2 * * *", # Daily at 2 AM
  42. actions=[EventAction("system_backup", {"type": "full"})]
  43. )
  44. """
  45. import time
  46. import threading
  47. import traceback
  48. from datetime import datetime, timedelta
  49. from typing import List, Dict, Any, Optional, Union, Set, Callable
  50. from dataclasses import dataclass, field
  51. from enum import Enum
  52. from concurrent.futures import ThreadPoolExecutor, Future
  53. import uuid
  54. import re
  55. from .schedule_entry import (
  56. ScheduleEntry, ScheduleStatus, ExecutionResult,
  57. ScheduleEntryError, NameConflictError, ValidationError,
  58. get_registered_names, is_name_available
  59. )
  60. from .triggers import (
  61. BaseTrigger, CronTrigger, DateTrigger, TimeTrigger,
  62. EventTrigger, WeekdayTrigger, IntervalTrigger, ManualTrigger,
  63. TriggerFactory
  64. )
  65. from .actions import (
  66. BaseAction, EventAction, MLTrainingAction, FunctionAction,
  67. MultiAction, ConditionalAction, ActionFactory
  68. )
  69. from .persistence import SchedulePersistence, PersistenceConfig, BackupPolicy
  70. from .cron_parser import parse_cron_expression, validate_cron_expression
  71. def pprint(message: str) -> None:
  72. """
  73. Scheduler logging function that adapts based on mode.
  74. Uses the same pattern as specified in CLAUDE.md.
  75. """
  76. print(f"[SCHEDULER] {message}")
  77. class SchedulerError(Exception):
  78. """Base exception for scheduler-related errors."""
  79. pass
  80. class SchedulerStatus(Enum):
  81. """Status of the scheduler."""
  82. STOPPED = "stopped"
  83. STARTING = "starting"
  84. RUNNING = "running"
  85. STOPPING = "stopping"
  86. ERROR = "error"
  87. @dataclass
  88. class SchedulerConfig:
  89. """Configuration for the scheduler."""
  90. check_interval_seconds: float = 60.0
  91. max_concurrent_executions: int = 10
  92. enable_persistence: bool = True
  93. auto_save_interval_seconds: float = 300.0 # 5 minutes
  94. storage_path: str = "config/schedules.json"
  95. backup_policy: BackupPolicy = BackupPolicy.DAILY
  96. max_backups: int = 30
  97. startup_delay_seconds: float = 5.0
  98. shutdown_timeout_seconds: float = 30.0
  99. enable_event_integration: bool = True
  100. validate_on_load: bool = True
  101. def __post_init__(self):
  102. """Post-initialization validation."""
  103. if self.check_interval_seconds <= 0:
  104. raise ValueError("Check interval must be positive")
  105. if self.max_concurrent_executions <= 0:
  106. raise ValueError("Max concurrent executions must be positive")
  107. if self.auto_save_interval_seconds <= 0:
  108. raise ValueError("Auto save interval must be positive")
  109. class ScheduleQuery:
  110. """
  111. Query builder for finding schedules based on various criteria.
  112. Provides a fluent interface for building complex schedule queries.
  113. """
  114. def __init__(self, schedules: List[ScheduleEntry]):
  115. """
  116. Initialize query with schedule list.
  117. Args:
  118. schedules: List of schedules to query
  119. """
  120. self._schedules = schedules
  121. self._filters: List[Callable[[ScheduleEntry], bool]] = []
  122. def by_name(self, name: str) -> 'ScheduleQuery':
  123. """Filter by exact name match."""
  124. self._filters.append(lambda s: s.name == name)
  125. return self
  126. def by_name_pattern(self, pattern: str) -> 'ScheduleQuery':
  127. """Filter by name pattern (regex)."""
  128. regex = re.compile(pattern)
  129. self._filters.append(lambda s: bool(regex.search(s.name)))
  130. return self
  131. def by_status(self, status: ScheduleStatus) -> 'ScheduleQuery':
  132. """Filter by schedule status."""
  133. self._filters.append(lambda s: s.status == status)
  134. return self
  135. def by_enabled(self, enabled: bool = True) -> 'ScheduleQuery':
  136. """Filter by enabled status."""
  137. self._filters.append(lambda s: s.enabled == enabled)
  138. return self
  139. def by_tag(self, tag: str) -> 'ScheduleQuery':
  140. """Filter by tag."""
  141. self._filters.append(lambda s: tag in s.tags)
  142. return self
  143. def by_trigger_type(self, trigger_type: type) -> 'ScheduleQuery':
  144. """Filter by trigger type."""
  145. self._filters.append(
  146. lambda s: any(isinstance(t, trigger_type) for t in s.get_triggers())
  147. )
  148. return self
  149. def by_action_type(self, action_type: type) -> 'ScheduleQuery':
  150. """Filter by action type."""
  151. self._filters.append(
  152. lambda s: any(isinstance(a, action_type) for a in s.get_actions())
  153. )
  154. return self
  155. def by_execution_count(self, min_count: int = 0, max_count: Optional[int] = None) -> 'ScheduleQuery':
  156. """Filter by execution count range."""
  157. def filter_func(s):
  158. count = s.execution_count
  159. if count < min_count:
  160. return False
  161. if max_count is not None and count > max_count:
  162. return False
  163. return True
  164. self._filters.append(filter_func)
  165. return self
  166. def by_last_execution(self, hours_ago: float) -> 'ScheduleQuery':
  167. """Filter by last execution time (within N hours ago)."""
  168. cutoff_time = time.time() - (hours_ago * 3600)
  169. self._filters.append(
  170. lambda s: s.last_executed is not None and s.last_executed >= cutoff_time
  171. )
  172. return self
  173. def by_next_execution(self, within_hours: float) -> 'ScheduleQuery':
  174. """Filter by next execution time (within N hours)."""
  175. future_time = time.time() + (within_hours * 3600)
  176. def filter_func(s):
  177. next_exec = s.get_next_execution_time()
  178. return next_exec is not None and next_exec <= future_time
  179. self._filters.append(filter_func)
  180. return self
  181. def custom_filter(self, filter_func: Callable[[ScheduleEntry], bool]) -> 'ScheduleQuery':
  182. """Add custom filter function."""
  183. self._filters.append(filter_func)
  184. return self
  185. def execute(self) -> List[ScheduleEntry]:
  186. """Execute the query and return matching schedules."""
  187. result = self._schedules
  188. for filter_func in self._filters:
  189. result = [s for s in result if filter_func(s)]
  190. return result
  191. def first(self) -> Optional[ScheduleEntry]:
  192. """Get first matching schedule."""
  193. result = self.execute()
  194. return result[0] if result else None
  195. def count(self) -> int:
  196. """Get count of matching schedules."""
  197. return len(self.execute())
  198. def exists(self) -> bool:
  199. """Check if any schedules match."""
  200. return self.count() > 0
  201. class ScheduleManager:
  202. """
  203. Advanced schedule management with querying capabilities.
  204. Provides high-level operations for managing collections of schedules.
  205. """
  206. def __init__(self, schedules: Optional[List[ScheduleEntry]] = None):
  207. """
  208. Initialize schedule manager.
  209. Args:
  210. schedules: Initial list of schedules (optional)
  211. """
  212. self._schedules: List[ScheduleEntry] = schedules or []
  213. self._lock = threading.RLock()
  214. def add_schedule(self, schedule: ScheduleEntry) -> None:
  215. """Add a schedule to the manager."""
  216. with self._lock:
  217. self._schedules.append(schedule)
  218. def remove_schedule(self, name: str) -> bool:
  219. """Remove a schedule by name."""
  220. with self._lock:
  221. for i, schedule in enumerate(self._schedules):
  222. if schedule.name == name:
  223. del self._schedules[i]
  224. return True
  225. return False
  226. def get_schedule(self, name: str) -> Optional[ScheduleEntry]:
  227. """Get a schedule by name."""
  228. with self._lock:
  229. for schedule in self._schedules:
  230. if schedule.name == name:
  231. return schedule
  232. return None
  233. def get_all_schedules(self) -> List[ScheduleEntry]:
  234. """Get all schedules."""
  235. with self._lock:
  236. return self._schedules.copy()
  237. def query(self) -> ScheduleQuery:
  238. """Create a new query builder."""
  239. with self._lock:
  240. return ScheduleQuery(self._schedules.copy())
  241. def get_schedules_due(self, current_time: Optional[float] = None) -> List[ScheduleEntry]:
  242. """Get schedules that should execute now."""
  243. current_time = current_time or time.time()
  244. with self._lock:
  245. return [s for s in self._schedules if s.should_execute(current_time)]
  246. def get_next_execution_time(self) -> Optional[float]:
  247. """Get the next execution time across all schedules."""
  248. current_time = time.time()
  249. next_times = []
  250. with self._lock:
  251. for schedule in self._schedules:
  252. next_time = schedule.get_next_execution_time(current_time)
  253. if next_time:
  254. next_times.append(next_time)
  255. return min(next_times) if next_times else None
  256. def get_statistics(self) -> Dict[str, Any]:
  257. """Get statistics about managed schedules."""
  258. with self._lock:
  259. total_schedules = len(self._schedules)
  260. enabled_schedules = sum(1 for s in self._schedules if s.enabled)
  261. disabled_schedules = total_schedules - enabled_schedules
  262. total_executions = sum(s.execution_count for s in self._schedules)
  263. total_errors = sum(s.error_count for s in self._schedules)
  264. # Status breakdown
  265. status_counts = {}
  266. for status in ScheduleStatus:
  267. count = sum(1 for s in self._schedules if s.status == status)
  268. status_counts[status.value] = count
  269. # Trigger type breakdown
  270. trigger_counts = {}
  271. for schedule in self._schedules:
  272. for trigger in schedule.get_triggers():
  273. trigger_type = type(trigger).__name__
  274. trigger_counts[trigger_type] = trigger_counts.get(trigger_type, 0) + 1
  275. # Action type breakdown
  276. action_counts = {}
  277. for schedule in self._schedules:
  278. for action in schedule.get_actions():
  279. action_type = type(action).__name__
  280. action_counts[action_type] = action_counts.get(action_type, 0) + 1
  281. return {
  282. 'total_schedules': total_schedules,
  283. 'enabled_schedules': enabled_schedules,
  284. 'disabled_schedules': disabled_schedules,
  285. 'total_executions': total_executions,
  286. 'total_errors': total_errors,
  287. 'success_rate': (total_executions - total_errors) / total_executions * 100 if total_executions > 0 else 0,
  288. 'status_breakdown': status_counts,
  289. 'trigger_type_breakdown': trigger_counts,
  290. 'action_type_breakdown': action_counts,
  291. 'next_execution': self.get_next_execution_time()
  292. }
  293. def validate_all(self) -> Dict[str, List[str]]:
  294. """Validate all schedules and return errors."""
  295. validation_results = {}
  296. with self._lock:
  297. for schedule in self._schedules:
  298. errors = schedule.validate()
  299. if errors:
  300. validation_results[schedule.name] = errors
  301. return validation_results
  302. def clear_all(self) -> None:
  303. """Remove all schedules."""
  304. with self._lock:
  305. self._schedules.clear()
  306. class Scheduler:
  307. """
  308. Main scheduler class for the Trixy application.
  309. Manages schedule entries, handles execution timing, integrates with
  310. the event system, and provides comprehensive scheduling capabilities.
  311. """
  312. def __init__(
  313. self,
  314. config: Optional[SchedulerConfig] = None,
  315. storage_path: Optional[str] = None,
  316. event_handler=None,
  317. application=None
  318. ):
  319. """
  320. Initialize the scheduler.
  321. Args:
  322. config: Scheduler configuration (optional)
  323. storage_path: Path for schedule storage (optional)
  324. event_handler: Event handler for integration (optional)
  325. application: Application container for integration (optional)
  326. """
  327. # Configuration
  328. if config is None:
  329. config = SchedulerConfig()
  330. if storage_path:
  331. config.storage_path = storage_path
  332. self.config = config
  333. # Core components
  334. self.schedule_manager = ScheduleManager()
  335. self._status = SchedulerStatus.STOPPED
  336. self._lock = threading.RLock()
  337. # Threading
  338. self._scheduler_thread: Optional[threading.Thread] = None
  339. self._auto_save_thread: Optional[threading.Thread] = None
  340. self._stop_event = threading.Event()
  341. self._executor = ThreadPoolExecutor(max_workers=config.max_concurrent_executions)
  342. # Persistence
  343. if config.enable_persistence:
  344. persistence_config = PersistenceConfig(
  345. storage_path=config.storage_path,
  346. backup_policy=config.backup_policy,
  347. max_backups=config.max_backups,
  348. validate_on_load=config.validate_on_load
  349. )
  350. self.persistence = SchedulePersistence(config.storage_path, config=persistence_config)
  351. else:
  352. self.persistence = None
  353. # Integration
  354. self._event_handler = event_handler
  355. self._application = application
  356. # Statistics
  357. self._start_time: Optional[float] = None
  358. self._execution_count = 0
  359. self._error_count = 0
  360. self._last_check_time: Optional[float] = None
  361. # Running futures for tracking executions
  362. self._running_executions: Dict[str, Future] = {}
  363. pprint(f"Scheduler initialized with {len(self.schedule_manager.get_all_schedules())} schedules")
  364. def set_event_handler(self, event_handler) -> None:
  365. """Set the event handler for integration."""
  366. with self._lock:
  367. self._event_handler = event_handler
  368. # Update event handlers for existing schedules
  369. for schedule in self.schedule_manager.get_all_schedules():
  370. for action in schedule.get_actions():
  371. if hasattr(action, 'set_event_handler'):
  372. action.set_event_handler(event_handler)
  373. for trigger in schedule.get_triggers():
  374. if hasattr(trigger, 'set_event_handler'):
  375. trigger.set_event_handler(event_handler)
  376. pprint("Event handler connected to scheduler")
  377. def set_application(self, application) -> None:
  378. """Set the application container for integration."""
  379. with self._lock:
  380. self._application = application
  381. pprint("Application container connected to scheduler")
  382. def start(self) -> None:
  383. """Start the scheduler."""
  384. with self._lock:
  385. if self._status != SchedulerStatus.STOPPED:
  386. raise SchedulerError(f"Cannot start scheduler in status: {self._status.value}")
  387. self._status = SchedulerStatus.STARTING
  388. pprint("Starting scheduler...")
  389. try:
  390. # Load schedules from persistence
  391. if self.persistence:
  392. self._load_schedules()
  393. # Start scheduler thread
  394. self._stop_event.clear()
  395. self._scheduler_thread = threading.Thread(target=self._scheduler_loop, daemon=True)
  396. self._scheduler_thread.start()
  397. # Start auto-save thread if enabled
  398. if self.persistence and self.config.auto_save_interval_seconds > 0:
  399. self._auto_save_thread = threading.Thread(target=self._auto_save_loop, daemon=True)
  400. self._auto_save_thread.start()
  401. self._start_time = time.time()
  402. self._status = SchedulerStatus.RUNNING
  403. # Trigger startup event
  404. if self._event_handler and self.config.enable_event_integration:
  405. self._event_handler.trigger_event(
  406. "scheduler_started",
  407. scheduler_id=id(self),
  408. schedule_count=len(self.schedule_manager.get_all_schedules()),
  409. config=self.config.__dict__
  410. )
  411. pprint(f"Scheduler started successfully with {len(self.schedule_manager.get_all_schedules())} schedules")
  412. except Exception as e:
  413. self._status = SchedulerStatus.ERROR
  414. pprint(f"Failed to start scheduler: {e}")
  415. raise SchedulerError(f"Failed to start scheduler: {e}")
  416. def stop(self, timeout: Optional[float] = None) -> None:
  417. """Stop the scheduler."""
  418. with self._lock:
  419. if self._status not in [SchedulerStatus.RUNNING, SchedulerStatus.ERROR]:
  420. pprint(f"Scheduler not running (status: {self._status.value})")
  421. return
  422. self._status = SchedulerStatus.STOPPING
  423. pprint("Stopping scheduler...")
  424. timeout = timeout or self.config.shutdown_timeout_seconds
  425. try:
  426. # Signal threads to stop
  427. self._stop_event.set()
  428. # Wait for scheduler thread
  429. if self._scheduler_thread and self._scheduler_thread.is_alive():
  430. self._scheduler_thread.join(timeout=timeout / 2)
  431. if self._scheduler_thread.is_alive():
  432. pprint("Warning: Scheduler thread did not stop gracefully")
  433. # Wait for auto-save thread
  434. if self._auto_save_thread and self._auto_save_thread.is_alive():
  435. self._auto_save_thread.join(timeout=timeout / 2)
  436. if self._auto_save_thread.is_alive():
  437. pprint("Warning: Auto-save thread did not stop gracefully")
  438. # Wait for running executions
  439. if self._running_executions:
  440. pprint(f"Waiting for {len(self._running_executions)} running executions to complete...")
  441. for execution_id, future in list(self._running_executions.items()):
  442. try:
  443. future.result(timeout=min(5.0, timeout / len(self._running_executions)))
  444. except Exception:
  445. future.cancel()
  446. finally:
  447. self._running_executions.pop(execution_id, None)
  448. # Shutdown executor
  449. self._executor.shutdown(wait=True)
  450. # Save schedules one final time
  451. if self.persistence:
  452. try:
  453. self._save_schedules()
  454. except Exception as e:
  455. pprint(f"Warning: Failed to save schedules during shutdown: {e}")
  456. self._status = SchedulerStatus.STOPPED
  457. # Trigger shutdown event
  458. if self._event_handler and self.config.enable_event_integration:
  459. self._event_handler.trigger_event(
  460. "scheduler_stopped",
  461. scheduler_id=id(self),
  462. uptime_seconds=time.time() - self._start_time if self._start_time else 0,
  463. execution_count=self._execution_count
  464. )
  465. pprint("Scheduler stopped successfully")
  466. except Exception as e:
  467. self._status = SchedulerStatus.ERROR
  468. pprint(f"Error during scheduler shutdown: {e}")
  469. raise SchedulerError(f"Failed to stop scheduler: {e}")
  470. def _scheduler_loop(self) -> None:
  471. """Main scheduler loop that checks and executes schedules."""
  472. pprint("Scheduler loop started")
  473. # Initial startup delay
  474. if self.config.startup_delay_seconds > 0:
  475. pprint(f"Startup delay: {self.config.startup_delay_seconds} seconds")
  476. if self._stop_event.wait(self.config.startup_delay_seconds):
  477. return
  478. while not self._stop_event.is_set():
  479. try:
  480. self._check_and_execute_schedules()
  481. self._cleanup_completed_executions()
  482. # Wait for next check or stop signal
  483. self._stop_event.wait(self.config.check_interval_seconds)
  484. except Exception as e:
  485. pprint(f"Error in scheduler loop: {e}")
  486. pprint(f"Traceback: {traceback.format_exc()}")
  487. # Brief pause before continuing to avoid rapid error loops
  488. self._stop_event.wait(1.0)
  489. pprint("Scheduler loop stopped")
  490. def _check_and_execute_schedules(self) -> None:
  491. """Check schedules and execute those that are due."""
  492. current_time = time.time()
  493. self._last_check_time = current_time
  494. # Get schedules that should execute
  495. due_schedules = self.schedule_manager.get_schedules_due(current_time)
  496. if due_schedules:
  497. pprint(f"Found {len(due_schedules)} schedules due for execution")
  498. for schedule in due_schedules:
  499. try:
  500. self._execute_schedule(schedule)
  501. except Exception as e:
  502. pprint(f"Error executing schedule '{schedule.name}': {e}")
  503. self._error_count += 1
  504. def _execute_schedule(self, schedule: ScheduleEntry) -> None:
  505. """Execute a schedule asynchronously."""
  506. execution_id = str(uuid.uuid4())
  507. pprint(f"Scheduling execution of '{schedule.name}' (execution_id: {execution_id})")
  508. # Create execution context
  509. context = {
  510. 'execution_id': execution_id,
  511. 'scheduler_id': id(self),
  512. 'schedule_name': schedule.name,
  513. 'execution_time': time.time(),
  514. 'check_interval': self.config.check_interval_seconds
  515. }
  516. # Submit execution to thread pool
  517. future = self._executor.submit(self._execute_schedule_sync, schedule, context)
  518. self._running_executions[execution_id] = future
  519. # Add completion callback
  520. future.add_done_callback(lambda f: self._on_execution_complete(execution_id, f))
  521. self._execution_count += 1
  522. def _execute_schedule_sync(self, schedule: ScheduleEntry, context: Dict[str, Any]) -> None:
  523. """Execute a schedule synchronously."""
  524. try:
  525. # Trigger schedule_triggered event
  526. if self._event_handler and self.config.enable_event_integration:
  527. self._event_handler.trigger_event(
  528. "schedule_triggered",
  529. schedule_name=schedule.name,
  530. execution_id=context['execution_id'],
  531. schedule_info=schedule.get_info()
  532. )
  533. # Execute the schedule
  534. result = schedule.execute(context)
  535. pprint(f"Schedule '{schedule.name}' executed with result: {result.value}")
  536. except Exception as e:
  537. pprint(f"Schedule '{schedule.name}' execution failed: {e}")
  538. self._error_count += 1
  539. raise
  540. def _on_execution_complete(self, execution_id: str, future: Future) -> None:
  541. """Handle completion of schedule execution."""
  542. try:
  543. future.result() # This will raise any exception that occurred
  544. except Exception as e:
  545. pprint(f"Execution {execution_id} failed: {e}")
  546. finally:
  547. self._running_executions.pop(execution_id, None)
  548. def _cleanup_completed_executions(self) -> None:
  549. """Clean up completed executions from tracking."""
  550. completed_ids = []
  551. for execution_id, future in self._running_executions.items():
  552. if future.done():
  553. completed_ids.append(execution_id)
  554. for execution_id in completed_ids:
  555. self._running_executions.pop(execution_id, None)
  556. def _auto_save_loop(self) -> None:
  557. """Auto-save loop for schedule persistence."""
  558. pprint("Auto-save loop started")
  559. while not self._stop_event.is_set():
  560. if self._stop_event.wait(self.config.auto_save_interval_seconds):
  561. break
  562. try:
  563. self._save_schedules()
  564. except Exception as e:
  565. pprint(f"Auto-save failed: {e}")
  566. pprint("Auto-save loop stopped")
  567. def _load_schedules(self) -> None:
  568. """Load schedules from persistence."""
  569. if not self.persistence:
  570. return
  571. try:
  572. schedules = self.persistence.load_schedules()
  573. # Clear current schedules and add loaded ones
  574. self.schedule_manager.clear_all()
  575. for schedule in schedules:
  576. # Set up event handlers for actions and triggers
  577. if self._event_handler:
  578. for action in schedule.get_actions():
  579. if hasattr(action, 'set_event_handler'):
  580. action.set_event_handler(self._event_handler)
  581. for trigger in schedule.get_triggers():
  582. if hasattr(trigger, 'set_event_handler'):
  583. trigger.set_event_handler(self._event_handler)
  584. self.schedule_manager.add_schedule(schedule)
  585. pprint(f"Loaded {len(schedules)} schedules from storage")
  586. except Exception as e:
  587. pprint(f"Failed to load schedules: {e}")
  588. def _save_schedules(self) -> None:
  589. """Save schedules to persistence."""
  590. if not self.persistence:
  591. return
  592. try:
  593. schedules = self.schedule_manager.get_all_schedules()
  594. self.persistence.save_schedules(schedules)
  595. pprint(f"Saved {len(schedules)} schedules to storage")
  596. except Exception as e:
  597. pprint(f"Failed to save schedules: {e}")
  598. raise
  599. # Public API methods
  600. def add_schedule(
  601. self,
  602. name: str,
  603. triggers: List[BaseTrigger],
  604. actions: List[BaseAction],
  605. description: str = "",
  606. enabled: bool = True,
  607. **kwargs
  608. ) -> ScheduleEntry:
  609. """
  610. Add a new schedule.
  611. Args:
  612. name: Unique name for the schedule
  613. triggers: List of triggers for the schedule
  614. actions: List of actions for the schedule
  615. description: Schedule description
  616. enabled: Whether schedule is enabled
  617. **kwargs: Additional ScheduleEntry parameters
  618. Returns:
  619. ScheduleEntry: Created schedule entry
  620. Raises:
  621. SchedulerError: If schedule cannot be added
  622. """
  623. try:
  624. schedule = ScheduleEntry(
  625. name=name,
  626. description=description,
  627. enabled=enabled,
  628. **kwargs
  629. )
  630. # Add triggers
  631. for trigger in triggers:
  632. schedule.add_trigger(trigger)
  633. # Add actions
  634. for action in actions:
  635. # Set up event handler if available
  636. if self._event_handler and hasattr(action, 'set_event_handler'):
  637. action.set_event_handler(self._event_handler)
  638. schedule.add_action(action)
  639. # Add to manager
  640. self.schedule_manager.add_schedule(schedule)
  641. pprint(f"Added schedule '{name}' with {len(triggers)} triggers and {len(actions)} actions")
  642. # Auto-save if scheduler is running
  643. if self._status == SchedulerStatus.RUNNING and self.persistence:
  644. try:
  645. self._save_schedules()
  646. except Exception as e:
  647. pprint(f"Failed to auto-save after adding schedule: {e}")
  648. return schedule
  649. except Exception as e:
  650. raise SchedulerError(f"Failed to add schedule '{name}': {e}")
  651. def add_cron_schedule(
  652. self,
  653. name: str,
  654. cron_expression: str,
  655. actions: List[BaseAction],
  656. description: str = "",
  657. enabled: bool = True,
  658. **kwargs
  659. ) -> ScheduleEntry:
  660. """
  661. Add a schedule with cron trigger.
  662. Args:
  663. name: Unique name for the schedule
  664. cron_expression: Standard cron expression
  665. actions: List of actions for the schedule
  666. description: Schedule description
  667. enabled: Whether schedule is enabled
  668. **kwargs: Additional parameters
  669. Returns:
  670. ScheduleEntry: Created schedule entry
  671. """
  672. # Validate cron expression
  673. if not validate_cron_expression(cron_expression):
  674. raise SchedulerError(f"Invalid cron expression: {cron_expression}")
  675. # Create cron trigger
  676. cron_trigger = CronTrigger(cron_expression)
  677. return self.add_schedule(
  678. name=name,
  679. triggers=[cron_trigger],
  680. actions=actions,
  681. description=description,
  682. enabled=enabled,
  683. **kwargs
  684. )
  685. def remove_schedule(self, name: str) -> bool:
  686. """
  687. Remove a schedule by name.
  688. Args:
  689. name: Name of schedule to remove
  690. Returns:
  691. bool: True if schedule was removed
  692. """
  693. removed = self.schedule_manager.remove_schedule(name)
  694. if removed:
  695. pprint(f"Removed schedule '{name}'")
  696. # Auto-save if scheduler is running
  697. if self._status == SchedulerStatus.RUNNING and self.persistence:
  698. try:
  699. self._save_schedules()
  700. except Exception as e:
  701. pprint(f"Failed to auto-save after removing schedule: {e}")
  702. return removed
  703. def get_schedule(self, name: str) -> Optional[ScheduleEntry]:
  704. """Get a schedule by name."""
  705. return self.schedule_manager.get_schedule(name)
  706. def get_all_schedules(self) -> List[ScheduleEntry]:
  707. """Get all schedules."""
  708. return self.schedule_manager.get_all_schedules()
  709. def query_schedules(self) -> ScheduleQuery:
  710. """Create a schedule query builder."""
  711. return self.schedule_manager.query()
  712. def enable_schedule(self, name: str) -> bool:
  713. """Enable a schedule by name."""
  714. schedule = self.get_schedule(name)
  715. if schedule:
  716. schedule.enabled = True
  717. pprint(f"Enabled schedule '{name}'")
  718. return True
  719. return False
  720. def disable_schedule(self, name: str) -> bool:
  721. """Disable a schedule by name."""
  722. schedule = self.get_schedule(name)
  723. if schedule:
  724. schedule.enabled = False
  725. pprint(f"Disabled schedule '{name}'")
  726. return True
  727. return False
  728. def trigger_schedule_manually(self, name: str) -> bool:
  729. """Manually trigger a schedule execution."""
  730. schedule = self.get_schedule(name)
  731. if not schedule:
  732. return False
  733. try:
  734. self._execute_schedule(schedule)
  735. pprint(f"Manually triggered schedule '{name}'")
  736. return True
  737. except Exception as e:
  738. pprint(f"Failed to manually trigger schedule '{name}': {e}")
  739. return False
  740. def get_status(self) -> SchedulerStatus:
  741. """Get current scheduler status."""
  742. return self._status
  743. def get_statistics(self) -> Dict[str, Any]:
  744. """Get comprehensive scheduler statistics."""
  745. with self._lock:
  746. schedule_stats = self.schedule_manager.get_statistics()
  747. uptime = 0.0
  748. if self._start_time and self._status == SchedulerStatus.RUNNING:
  749. uptime = time.time() - self._start_time
  750. return {
  751. 'status': self._status.value,
  752. 'uptime_seconds': uptime,
  753. 'uptime_hours': uptime / 3600,
  754. 'total_executions': self._execution_count,
  755. 'total_errors': self._error_count,
  756. 'success_rate': (self._execution_count - self._error_count) / self._execution_count * 100 if self._execution_count > 0 else 0,
  757. 'running_executions': len(self._running_executions),
  758. 'last_check_time': self._last_check_time,
  759. 'next_check_in': max(0, self.config.check_interval_seconds - (time.time() - self._last_check_time)) if self._last_check_time else 0,
  760. 'config': {
  761. 'check_interval_seconds': self.config.check_interval_seconds,
  762. 'max_concurrent_executions': self.config.max_concurrent_executions,
  763. 'storage_path': self.config.storage_path if self.persistence else None,
  764. 'persistence_enabled': self.config.enable_persistence,
  765. 'event_integration_enabled': self.config.enable_event_integration
  766. },
  767. 'schedules': schedule_stats
  768. }
  769. def validate_all_schedules(self) -> Dict[str, List[str]]:
  770. """Validate all schedules and return validation errors."""
  771. return self.schedule_manager.validate_all()
  772. def create_backup(self, backup_name: Optional[str] = None) -> Optional[str]:
  773. """Create a backup of current schedules."""
  774. if not self.persistence:
  775. return None
  776. try:
  777. backup_path = self.persistence.create_backup(backup_name)
  778. pprint(f"Created schedule backup: {backup_path}")
  779. return backup_path
  780. except Exception as e:
  781. pprint(f"Failed to create backup: {e}")
  782. return None
  783. def restore_from_backup(self, backup_path: str) -> bool:
  784. """Restore schedules from backup."""
  785. if not self.persistence:
  786. return False
  787. try:
  788. self.persistence.restore_from_backup(backup_path)
  789. # Reload schedules if scheduler is running
  790. if self._status == SchedulerStatus.RUNNING:
  791. self._load_schedules()
  792. pprint(f"Restored schedules from backup: {backup_path}")
  793. return True
  794. except Exception as e:
  795. pprint(f"Failed to restore from backup: {e}")
  796. return False
  797. def list_backups(self):
  798. """List available backups."""
  799. if not self.persistence:
  800. return []
  801. return self.persistence.list_backups()
  802. def __enter__(self):
  803. """Context manager entry."""
  804. self.start()
  805. return self
  806. def __exit__(self, exc_type, exc_val, exc_tb):
  807. """Context manager exit."""
  808. self.stop()
  809. # Module exports
  810. __all__ = [
  811. 'Scheduler',
  812. 'SchedulerError',
  813. 'SchedulerStatus',
  814. 'SchedulerConfig',
  815. 'ScheduleManager',
  816. 'ScheduleQuery',
  817. 'pprint'
  818. ]