horsies 0.1.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. horsies/__init__.py +117 -0
  2. horsies/core/__init__.py +0 -0
  3. horsies/core/app.py +552 -0
  4. horsies/core/banner.py +144 -0
  5. horsies/core/brokers/__init__.py +5 -0
  6. horsies/core/brokers/listener.py +444 -0
  7. horsies/core/brokers/postgres.py +993 -0
  8. horsies/core/cli.py +624 -0
  9. horsies/core/codec/serde.py +596 -0
  10. horsies/core/errors.py +535 -0
  11. horsies/core/logging.py +90 -0
  12. horsies/core/models/__init__.py +0 -0
  13. horsies/core/models/app.py +268 -0
  14. horsies/core/models/broker.py +79 -0
  15. horsies/core/models/queues.py +23 -0
  16. horsies/core/models/recovery.py +101 -0
  17. horsies/core/models/schedule.py +229 -0
  18. horsies/core/models/task_pg.py +307 -0
  19. horsies/core/models/tasks.py +358 -0
  20. horsies/core/models/workflow.py +1990 -0
  21. horsies/core/models/workflow_pg.py +245 -0
  22. horsies/core/registry/tasks.py +101 -0
  23. horsies/core/scheduler/__init__.py +26 -0
  24. horsies/core/scheduler/calculator.py +267 -0
  25. horsies/core/scheduler/service.py +569 -0
  26. horsies/core/scheduler/state.py +260 -0
  27. horsies/core/task_decorator.py +656 -0
  28. horsies/core/types/status.py +38 -0
  29. horsies/core/utils/imports.py +203 -0
  30. horsies/core/utils/loop_runner.py +44 -0
  31. horsies/core/worker/current.py +17 -0
  32. horsies/core/worker/worker.py +1967 -0
  33. horsies/core/workflows/__init__.py +23 -0
  34. horsies/core/workflows/engine.py +2344 -0
  35. horsies/core/workflows/recovery.py +501 -0
  36. horsies/core/workflows/registry.py +97 -0
  37. horsies/py.typed +0 -0
  38. horsies-0.1.0a4.dist-info/METADATA +35 -0
  39. horsies-0.1.0a4.dist-info/RECORD +42 -0
  40. horsies-0.1.0a4.dist-info/WHEEL +5 -0
  41. horsies-0.1.0a4.dist-info/entry_points.txt +2 -0
  42. horsies-0.1.0a4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,569 @@
1
+ # horsies/core/scheduler/service.py
2
+ from __future__ import annotations
3
+ import asyncio
4
+ import hashlib
5
+ import importlib
6
+ import importlib.util
7
+ import os
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from typing import Optional
11
+ import inspect
12
+ from sqlalchemy import text
13
+ from horsies.core.app import Horsies
14
+ from horsies.core.brokers.postgres import PostgresBroker
15
+ from horsies.core.models.schedule import ScheduleConfig, TaskSchedule
16
+ from horsies.core.errors import ConfigurationError, RegistryError, ErrorCode
17
+ from horsies.core.scheduler.state import ScheduleStateManager
18
+ from horsies.core.scheduler.calculator import calculate_next_run, should_run_now
19
+ from horsies.core.logging import get_logger
20
+ from horsies.core.worker.worker import import_by_path
21
+
22
+ logger = get_logger('scheduler')
23
+
24
+
25
+ class Scheduler:
26
+ """
27
+ Main scheduler service for executing scheduled tasks.
28
+
29
+ Responsibilities:
30
+ 1. Load schedule configuration from app
31
+ 2. Track schedule state in database
32
+ 3. Calculate next run times
33
+ 4. Enqueue tasks via broker when schedules are due
34
+ 5. Handle catch-up logic for missed runs
35
+
36
+ Runs as a separate process/dyno from workers.
37
+ """
38
+
39
+ def __init__(self, app: Horsies):
40
+ self.app = app
41
+ self.broker: Optional[PostgresBroker] = None
42
+ self.state_manager: Optional[ScheduleStateManager] = None
43
+ self._stop = asyncio.Event()
44
+ self._initialized = False
45
+
46
+ # Validate that schedule config exists
47
+ if not app.config.schedule:
48
+ raise ConfigurationError(
49
+ message='app config must have schedule configuration',
50
+ code=ErrorCode.CONFIG_INVALID_SCHEDULE,
51
+ notes=['schedule config is None or not provided in AppConfig'],
52
+ help_text='add schedule=ScheduleConfig(...) to your AppConfig',
53
+ )
54
+
55
+ self.schedule_config: ScheduleConfig = app.config.schedule
56
+
57
+ logger.info(
58
+ f'Scheduler initialized with {len(self.schedule_config.schedules)} schedules, '
59
+ f'check_interval={self.schedule_config.check_interval_seconds}s'
60
+ )
61
+
62
+ async def start(self) -> None:
63
+ """Initialize broker, state manager, and database schema."""
64
+ if self._initialized:
65
+ return
66
+
67
+ # Initialize broker for task enqueuing
68
+ self.broker = self.app.get_broker()
69
+ await self.broker.ensure_schema_initialized()
70
+ logger.info('Broker initialized')
71
+
72
+ # Import task modules so task registry is populated for scheduled names
73
+ self._preload_task_modules()
74
+
75
+ # Initialize state manager with broker's session factory
76
+ self.state_manager = ScheduleStateManager(self.broker.session_factory)
77
+ logger.info('State manager initialized')
78
+
79
+ # Validate schedules eagerly (task exists, queue valid)
80
+ self._validate_schedules()
81
+
82
+ # Initialize state for all schedules
83
+ await self._initialize_schedules()
84
+
85
+ self._initialized = True
86
+ logger.info('Scheduler started successfully')
87
+
88
+ async def stop(self) -> None:
89
+ """Clean shutdown of scheduler."""
90
+ self._stop.set()
91
+
92
+ if self.broker:
93
+ await self.broker.close_async()
94
+ logger.info('Broker closed')
95
+
96
+ logger.info('Scheduler stopped')
97
+
98
+ def request_stop(self) -> None:
99
+ """Request scheduler to stop gracefully."""
100
+ self._stop.set()
101
+
102
+ async def run_forever(self) -> None:
103
+ """Main scheduler loop."""
104
+ logger.info('Starting scheduler loop')
105
+
106
+ try:
107
+ await self.start()
108
+
109
+ while not self._stop.is_set():
110
+ try:
111
+ await self._check_and_run_schedules()
112
+ except Exception as e:
113
+ logger.error(f'Error in scheduler loop: {e}', exc_info=True)
114
+
115
+ # Wait for check interval or stop signal
116
+ try:
117
+ await asyncio.wait_for(
118
+ self._stop.wait(),
119
+ timeout=self.schedule_config.check_interval_seconds,
120
+ )
121
+ break # Stop signal received
122
+ except asyncio.TimeoutError:
123
+ continue # Continue to next iteration
124
+
125
+ finally:
126
+ await self.stop()
127
+
128
+ async def _initialize_schedules(self) -> None:
129
+ """
130
+ Initialize state for all configured schedules.
131
+
132
+ Detects configuration changes and recalculates next_run_at when pattern or timezone changes.
133
+ """
134
+ if not self.state_manager:
135
+ raise RuntimeError('State manager not initialized')
136
+
137
+ now = datetime.now(timezone.utc)
138
+
139
+ for schedule in self.schedule_config.schedules:
140
+ if not schedule.enabled:
141
+ logger.debug(f'Skipping disabled schedule: {schedule.name}')
142
+ continue
143
+
144
+ # Compute current config hash
145
+ config_hash = self._compute_config_hash(schedule)
146
+
147
+ # Check if state already exists
148
+ state = await self.state_manager.get_state(schedule.name)
149
+
150
+ if state is None:
151
+ # Initialize new schedule
152
+ next_run = calculate_next_run(schedule.pattern, now, schedule.timezone)
153
+ await self.state_manager.initialize_state(
154
+ schedule.name, next_run, config_hash
155
+ )
156
+ logger.info(
157
+ f"Initialized new schedule '{schedule.name}', next_run={next_run}"
158
+ )
159
+ else:
160
+ # Check if config has changed
161
+ if state.config_hash != config_hash:
162
+ logger.warning(
163
+ f"Schedule '{schedule.name}' configuration changed "
164
+ f'(pattern or timezone), recalculating next_run_at'
165
+ )
166
+ # Recalculate next_run_at from current time
167
+ next_run = calculate_next_run(
168
+ schedule.pattern, now, schedule.timezone
169
+ )
170
+ await self.state_manager.update_next_run(
171
+ schedule.name, next_run, config_hash
172
+ )
173
+ logger.info(
174
+ f"Updated schedule '{schedule.name}' due to config change, "
175
+ f'new next_run={next_run}'
176
+ )
177
+ else:
178
+ logger.debug(
179
+ f"Schedule '{schedule.name}' already initialized, "
180
+ f'next_run={state.next_run_at}'
181
+ )
182
+
183
+ async def _check_and_run_schedules(self) -> None:
184
+ """Check all schedules and execute those that are due."""
185
+ if not self.state_manager or not self.broker:
186
+ raise RuntimeError('Scheduler not properly initialized')
187
+
188
+ now = datetime.now(timezone.utc)
189
+
190
+ enabled_schedules: dict[str, TaskSchedule] = {
191
+ schedule.name: schedule
192
+ for schedule in self.schedule_config.schedules
193
+ if schedule.enabled
194
+ }
195
+ if not enabled_schedules:
196
+ return
197
+
198
+ # Bulk fetch due states to avoid O(N) per-tick queries
199
+ due_states = await self.state_manager.get_due_states(
200
+ list(enabled_schedules.keys()),
201
+ now,
202
+ )
203
+
204
+ for state in due_states:
205
+ schedule = enabled_schedules.get(state.schedule_name)
206
+ if schedule is None:
207
+ continue
208
+
209
+ try:
210
+ await self._check_schedule(schedule, now)
211
+ except Exception as e:
212
+ logger.error(
213
+ f"Error checking schedule '{schedule.name}': {e}", exc_info=True
214
+ )
215
+
216
+ def _schedule_advisory_key(self, schedule_name: str) -> int:
217
+ """
218
+ Compute a stable 64-bit advisory lock key for a schedule.
219
+
220
+ Prevents concurrent schedulers from double-enqueuing the same schedule.
221
+ """
222
+ basis = f'horsies-schedule:{schedule_name}'.encode('utf-8')
223
+ h = hashlib.sha256(basis).digest()
224
+ return int.from_bytes(h[:8], byteorder='big', signed=True)
225
+
226
+ def _compute_config_hash(self, schedule: TaskSchedule) -> str:
227
+ """
228
+ Compute hash of schedule configuration for change detection.
229
+
230
+ Includes pattern and timezone to detect when schedule needs recalculation.
231
+ """
232
+ from horsies.core.codec.serde import dumps_json
233
+
234
+ config_str = dumps_json(
235
+ {
236
+ 'pattern': schedule.pattern.model_dump(),
237
+ 'timezone': schedule.timezone,
238
+ }
239
+ )
240
+ return hashlib.sha256(config_str.encode('utf-8')).hexdigest()
241
+
242
+ async def _check_schedule(
243
+ self, schedule: TaskSchedule, check_time: datetime
244
+ ) -> None:
245
+ """
246
+ Check a single schedule and run if due.
247
+
248
+ Uses PostgreSQL advisory lock to prevent race conditions when multiple
249
+ scheduler processes are running.
250
+ """
251
+ if not self.state_manager or not self.broker:
252
+ raise RuntimeError('Scheduler not properly initialized')
253
+
254
+ # Use advisory lock to prevent double-enqueue from concurrent schedulers
255
+ lock_key = self._schedule_advisory_key(schedule.name)
256
+
257
+ async with self.broker.session_factory() as session:
258
+ # Acquire transaction-scoped advisory lock for this specific schedule
259
+ await session.execute(
260
+ text('SELECT pg_advisory_xact_lock(CAST(:key AS BIGINT))'),
261
+ {'key': lock_key},
262
+ )
263
+
264
+ # Now we have exclusive access to this schedule's execution
265
+ # Get current state
266
+ state = await self.state_manager.get_state(schedule.name)
267
+
268
+ if state is None:
269
+ # Schedule state missing (shouldn't happen after initialization)
270
+ logger.warning(
271
+ f"Schedule state missing for '{schedule.name}', reinitializing"
272
+ )
273
+ config_hash = self._compute_config_hash(schedule)
274
+ next_run = calculate_next_run(
275
+ schedule.pattern, check_time, schedule.timezone
276
+ )
277
+ await self.state_manager.initialize_state(
278
+ schedule.name, next_run, config_hash
279
+ )
280
+ await session.commit()
281
+ return
282
+
283
+ # Check if schedule should run now
284
+ if not should_run_now(state.next_run_at, check_time):
285
+ await session.commit()
286
+ return
287
+
288
+ # Execute schedule with catch-up logic
289
+ try:
290
+ # Calculate missed runs if catch_up_missed is enabled
291
+ missed_runs: list[datetime] = []
292
+ if schedule.catch_up_missed and state.next_run_at:
293
+ missed_runs = self._calculate_missed_runs(
294
+ schedule=schedule,
295
+ last_scheduled_run=state.next_run_at,
296
+ current_time=check_time,
297
+ )
298
+
299
+ if missed_runs:
300
+ logger.warning(
301
+ f"Schedule '{schedule.name}' missed {len(missed_runs)} run(s), "
302
+ f'catching up...'
303
+ )
304
+ # Enqueue all missed runs
305
+ last_task_id = ''
306
+ for missed_time in missed_runs:
307
+ last_task_id = await self._enqueue_scheduled_task(schedule)
308
+ logger.info(
309
+ f"Schedule '{schedule.name}' catch-up: enqueued task {last_task_id} "
310
+ f'for missed run at {missed_time}'
311
+ )
312
+
313
+ # After catch-up, calculate next run from the last scheduled slot to preserve cadence
314
+ last_slot = missed_runs[-1]
315
+ next_run = calculate_next_run(
316
+ schedule.pattern, last_slot, schedule.timezone
317
+ )
318
+
319
+ # Update state with last caught-up task
320
+ await self.state_manager.update_after_run(
321
+ schedule_name=schedule.name,
322
+ task_id=last_task_id,
323
+ executed_at=check_time,
324
+ next_run_at=next_run,
325
+ )
326
+ else:
327
+ # Normal execution: single run
328
+ task_id = await self._enqueue_scheduled_task(schedule)
329
+ logger.info(
330
+ f"Schedule '{schedule.name}' executed: enqueued task {task_id} "
331
+ f"for task '{schedule.task_name}'"
332
+ )
333
+
334
+ # Calculate next run time
335
+ next_run = calculate_next_run(
336
+ schedule.pattern, check_time, schedule.timezone
337
+ )
338
+
339
+ # Update state
340
+ await self.state_manager.update_after_run(
341
+ schedule_name=schedule.name,
342
+ task_id=task_id,
343
+ executed_at=check_time,
344
+ next_run_at=next_run,
345
+ )
346
+
347
+ await session.commit()
348
+
349
+ except Exception as e:
350
+ logger.error(
351
+ f"Failed to execute schedule '{schedule.name}': {e}", exc_info=True
352
+ )
353
+ await session.rollback()
354
+ # Don't update state on failure - will retry next check
355
+
356
+ # Advisory lock automatically released at transaction end
357
+
358
+ def _calculate_missed_runs(
359
+ self,
360
+ schedule: TaskSchedule,
361
+ last_scheduled_run: datetime,
362
+ current_time: datetime,
363
+ ) -> list[datetime]:
364
+ """
365
+ Calculate all missed runs between last scheduled run and current time.
366
+
367
+ Args:
368
+ schedule: TaskSchedule configuration
369
+ last_scheduled_run: The last scheduled run time (may not have executed)
370
+ current_time: Current time
371
+
372
+ Returns:
373
+ List of missed run times, sorted chronologically
374
+ """
375
+ missed: list[datetime] = []
376
+ threshold = self.schedule_config.check_interval_seconds * 2
377
+
378
+ # Only catch up if significantly in the past
379
+ if (current_time - last_scheduled_run).total_seconds() <= threshold:
380
+ return missed
381
+
382
+ # Calculate all runs between last_scheduled_run and current_time
383
+ cursor = last_scheduled_run
384
+ while True:
385
+ # Calculate next run from cursor
386
+ next_run = calculate_next_run(schedule.pattern, cursor, schedule.timezone)
387
+
388
+ # Stop if next run is in the future
389
+ if next_run > current_time:
390
+ break
391
+
392
+ missed.append(next_run)
393
+ cursor = next_run
394
+
395
+ return missed
396
+
397
+ def _preload_task_modules(self) -> None:
398
+ """Import discovered task modules so @app.task registrations run."""
399
+ try:
400
+ modules = self.app.get_discovered_task_modules()
401
+ except Exception:
402
+ modules = []
403
+
404
+ if not modules:
405
+ logger.warning(
406
+ 'No task modules discovered; scheduler may not resolve tasks'
407
+ )
408
+ return
409
+
410
+ try:
411
+ self.app.suppress_sends(True)
412
+ except Exception:
413
+ pass
414
+
415
+ try:
416
+ for module in modules:
417
+ if module.endswith('.py') or os.path.sep in module:
418
+ try:
419
+ import_by_path(os.path.abspath(module))
420
+ except Exception as e:
421
+ logger.warning(f"Failed to import task module '{module}': {e}")
422
+ else:
423
+ # Ensure repo root is on sys.path for module imports
424
+ cwd = os.getcwd()
425
+ if cwd not in sys.path:
426
+ sys.path.insert(0, cwd)
427
+ try:
428
+ importlib.import_module(module)
429
+ except Exception as e:
430
+ logger.warning(f"Failed to import task module '{module}': {e}")
431
+ finally:
432
+ try:
433
+ self.app.suppress_sends(False)
434
+ except Exception:
435
+ pass
436
+
437
+ def _resolve_schedule_queue(self, schedule: TaskSchedule) -> str:
438
+ """
439
+ Determine the queue name for a schedule, preferring the schedule's explicit queue,
440
+ otherwise falling back to the task's declared queue (stored on the task wrapper).
441
+ """
442
+ task = self.app.tasks.get(schedule.task_name)
443
+ task_queue = getattr(task, 'task_queue_name', None) if task else None
444
+
445
+ queue_name = (
446
+ schedule.queue_name if schedule.queue_name is not None else task_queue
447
+ )
448
+ return self.app.validate_queue_name(queue_name)
449
+
450
+ def _validate_schedules(self) -> None:
451
+ """Fail fast on invalid schedules (missing tasks/queues)."""
452
+ for sched in self.schedule_config.schedules:
453
+ if not sched.enabled:
454
+ continue
455
+ if sched.task_name not in self.app.tasks:
456
+ available = list(self.app.tasks.keys())
457
+ raise RegistryError(
458
+ message=f"scheduled task '{sched.task_name}' not registered",
459
+ code=ErrorCode.TASK_NOT_REGISTERED,
460
+ notes=[
461
+ f"schedule '{sched.name}' references task '{sched.task_name}'",
462
+ f'available tasks: {available}'
463
+ if available
464
+ else 'no tasks registered',
465
+ ],
466
+ help_text='ensure the task is defined with @app.task and imported before scheduler starts',
467
+ )
468
+ try:
469
+ self._resolve_schedule_queue(sched)
470
+ except Exception as e:
471
+ raise ConfigurationError(
472
+ message=f"invalid queue configuration for schedule '{sched.name}'",
473
+ code=ErrorCode.CONFIG_INVALID_SCHEDULE,
474
+ notes=[f'underlying error: {e}'],
475
+ help_text='check queue_name in schedule matches app.config queue settings',
476
+ ) from e
477
+ self._validate_schedule_signature(sched)
478
+
479
+ def _validate_schedule_signature(self, schedule: TaskSchedule) -> None:
480
+ """Ensure required task parameters are satisfied by schedule args/kwargs."""
481
+ task = self.app.tasks.get(schedule.task_name)
482
+ original_fn = getattr(task, '_original_fn', None) if task else None
483
+ if original_fn is None:
484
+ return # Cannot validate without original function signature
485
+
486
+ sig = inspect.signature(original_fn)
487
+ args_provided = list(schedule.args)
488
+ kwargs_provided = schedule.kwargs
489
+ missing: list[str] = []
490
+
491
+ consumed_positional = 0
492
+ for param in sig.parameters.values():
493
+ if param.kind in (
494
+ inspect.Parameter.VAR_POSITIONAL,
495
+ inspect.Parameter.VAR_KEYWORD,
496
+ ):
497
+ continue # skip *args/**kwargs
498
+ if param.default is not inspect.Parameter.empty:
499
+ continue # optional
500
+
501
+ if param.kind in (
502
+ inspect.Parameter.POSITIONAL_ONLY,
503
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
504
+ ):
505
+ if consumed_positional < len(args_provided):
506
+ consumed_positional += 1
507
+ continue
508
+ if param.name in kwargs_provided:
509
+ continue
510
+ missing.append(param.name)
511
+ elif param.kind == inspect.Parameter.KEYWORD_ONLY:
512
+ if param.name in kwargs_provided:
513
+ continue
514
+ missing.append(param.name)
515
+
516
+ if missing:
517
+ raise ConfigurationError(
518
+ message=f"schedule '{schedule.name}' is missing required params for task '{schedule.task_name}'",
519
+ code=ErrorCode.CONFIG_INVALID_SCHEDULE,
520
+ notes=[f'missing required parameters: {missing}'],
521
+ help_text='add missing params to args=(...) or kwargs={{...}} in TaskSchedule',
522
+ )
523
+
524
+ async def _enqueue_scheduled_task(self, schedule: TaskSchedule) -> str:
525
+ """
526
+ Enqueue a scheduled task via the broker.
527
+
528
+ Args:
529
+ schedule: TaskSchedule configuration
530
+
531
+ Returns:
532
+ Task ID of enqueued task
533
+
534
+ Raises:
535
+ Exception: If task enqueuing fails
536
+ """
537
+ if not self.broker:
538
+ raise RuntimeError('Broker not initialized')
539
+
540
+ # Validate that task is registered
541
+ if schedule.task_name not in self.app.tasks:
542
+ raise ValueError(
543
+ f"Task '{schedule.task_name}' not registered. "
544
+ f'Available tasks: {list(self.app.tasks.keys())}'
545
+ )
546
+
547
+ # Validate queue_name against app configuration
548
+ try:
549
+ validated_queue_name = self._resolve_schedule_queue(schedule)
550
+ except ValueError as e:
551
+ raise ValueError(
552
+ f"Invalid queue configuration for schedule '{schedule.name}': {e}"
553
+ )
554
+
555
+ # Determine priority for the queue
556
+ from horsies.core.task_decorator import effective_priority
557
+
558
+ priority = effective_priority(self.app, validated_queue_name)
559
+
560
+ # Enqueue task via broker
561
+ task_id = await self.broker.enqueue_async(
562
+ task_name=schedule.task_name,
563
+ args=schedule.args,
564
+ kwargs=schedule.kwargs,
565
+ queue_name=validated_queue_name,
566
+ priority=priority,
567
+ )
568
+
569
+ return task_id