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.
- horsies/__init__.py +117 -0
- horsies/core/__init__.py +0 -0
- horsies/core/app.py +552 -0
- horsies/core/banner.py +144 -0
- horsies/core/brokers/__init__.py +5 -0
- horsies/core/brokers/listener.py +444 -0
- horsies/core/brokers/postgres.py +993 -0
- horsies/core/cli.py +624 -0
- horsies/core/codec/serde.py +596 -0
- horsies/core/errors.py +535 -0
- horsies/core/logging.py +90 -0
- horsies/core/models/__init__.py +0 -0
- horsies/core/models/app.py +268 -0
- horsies/core/models/broker.py +79 -0
- horsies/core/models/queues.py +23 -0
- horsies/core/models/recovery.py +101 -0
- horsies/core/models/schedule.py +229 -0
- horsies/core/models/task_pg.py +307 -0
- horsies/core/models/tasks.py +358 -0
- horsies/core/models/workflow.py +1990 -0
- horsies/core/models/workflow_pg.py +245 -0
- horsies/core/registry/tasks.py +101 -0
- horsies/core/scheduler/__init__.py +26 -0
- horsies/core/scheduler/calculator.py +267 -0
- horsies/core/scheduler/service.py +569 -0
- horsies/core/scheduler/state.py +260 -0
- horsies/core/task_decorator.py +656 -0
- horsies/core/types/status.py +38 -0
- horsies/core/utils/imports.py +203 -0
- horsies/core/utils/loop_runner.py +44 -0
- horsies/core/worker/current.py +17 -0
- horsies/core/worker/worker.py +1967 -0
- horsies/core/workflows/__init__.py +23 -0
- horsies/core/workflows/engine.py +2344 -0
- horsies/core/workflows/recovery.py +501 -0
- horsies/core/workflows/registry.py +97 -0
- horsies/py.typed +0 -0
- horsies-0.1.0a4.dist-info/METADATA +35 -0
- horsies-0.1.0a4.dist-info/RECORD +42 -0
- horsies-0.1.0a4.dist-info/WHEEL +5 -0
- horsies-0.1.0a4.dist-info/entry_points.txt +2 -0
- 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
|