horsies 0.1.0a1__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 +115 -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 +864 -0
- horsies/core/cli.py +624 -0
- horsies/core/codec/serde.py +575 -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 +332 -0
- horsies/core/models/workflow.py +1988 -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 +615 -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.0a1.dist-info/METADATA +31 -0
- horsies-0.1.0a1.dist-info/RECORD +42 -0
- horsies-0.1.0a1.dist-info/WHEEL +5 -0
- horsies-0.1.0a1.dist-info/entry_points.txt +2 -0
- horsies-0.1.0a1.dist-info/top_level.txt +1 -0
horsies/core/cli.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
# app/core/cli.py
|
|
2
|
+
"""
|
|
3
|
+
CLI for horsies worker, scheduler, and check commands.
|
|
4
|
+
|
|
5
|
+
Module path resolution follows Celery's approach:
|
|
6
|
+
1. User provides dotted module path: `horsies worker app.configs.horsies:app`
|
|
7
|
+
2. User is responsible for PYTHONPATH / running from correct directory
|
|
8
|
+
3. Convenience: if cwd has pyproject.toml, we add cwd to sys.path
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import importlib
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from horsies.core.app import Horsies
|
|
20
|
+
from horsies.core.banner import print_banner
|
|
21
|
+
from horsies.core.errors import ConfigurationError, ErrorCode, HorsiesError, ValidationReport
|
|
22
|
+
from horsies.core.logging import get_logger
|
|
23
|
+
from horsies.core.scheduler import Scheduler
|
|
24
|
+
from horsies.core.worker.worker import Worker, WorkerConfig
|
|
25
|
+
from horsies.core.utils.imports import (
|
|
26
|
+
import_file_path,
|
|
27
|
+
setup_sys_path_from_cwd,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_module_argument(args: argparse.Namespace) -> str:
|
|
32
|
+
"""Return module path from --module or positional, error if missing."""
|
|
33
|
+
module_path = getattr(args, 'module', None) or getattr(args, 'module_pos', None)
|
|
34
|
+
if not module_path:
|
|
35
|
+
raise ConfigurationError(
|
|
36
|
+
message='module path is required',
|
|
37
|
+
code=ErrorCode.CLI_INVALID_ARGS,
|
|
38
|
+
notes=['no --module flag or positional module argument provided'],
|
|
39
|
+
help_text=(
|
|
40
|
+
'provide module path in one of these formats:\n'
|
|
41
|
+
' horsies worker app.configs.horsies:app (recommended)\n'
|
|
42
|
+
' horsies worker app/configs/horsies.py:app (file path)\n'
|
|
43
|
+
' horsies worker app.configs.horsies (auto-discover app variable)'
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
return module_path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_locator(locator: str) -> tuple[str, str | None]:
|
|
50
|
+
"""
|
|
51
|
+
Parse a module locator into (module_path, attribute_name).
|
|
52
|
+
|
|
53
|
+
Formats:
|
|
54
|
+
- "app.configs.horsies:app" -> ("app.configs.horsies", "app")
|
|
55
|
+
- "app.configs.horsies" -> ("app.configs.horsies", None)
|
|
56
|
+
- "/path/to/file.py:app" -> ("/path/to/file.py", "app")
|
|
57
|
+
- "app/configs/horsies.py" -> ("app/configs/horsies.py", None)
|
|
58
|
+
"""
|
|
59
|
+
if ':' in locator:
|
|
60
|
+
module_part, attr = locator.rsplit(':', 1)
|
|
61
|
+
return (module_part, attr)
|
|
62
|
+
return (locator, None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_file_path(path: str) -> bool:
|
|
66
|
+
"""Check if path looks like a file path (vs dotted module path)."""
|
|
67
|
+
return path.endswith('.py') or os.path.sep in path or '/' in path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def discover_app(module_locator: str) -> tuple[Horsies, str, str, str | None]:
|
|
71
|
+
"""
|
|
72
|
+
Import module and discover horsies instance.
|
|
73
|
+
|
|
74
|
+
Supports two formats (like Celery):
|
|
75
|
+
1. Dotted module path: "app.configs.horsies:app" or "app.configs.horsies"
|
|
76
|
+
2. File path: "app/configs/horsies.py:app" or "app/configs/horsies.py"
|
|
77
|
+
|
|
78
|
+
The pyproject.toml convenience is applied before import:
|
|
79
|
+
if cwd contains pyproject.toml, cwd is added to sys.path.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
(app_instance, variable_name, module_name, sys_path_root)
|
|
83
|
+
"""
|
|
84
|
+
logger = get_logger('cli')
|
|
85
|
+
|
|
86
|
+
# Convenience: add project root to sys.path if pyproject.toml found
|
|
87
|
+
project_root = setup_sys_path_from_cwd()
|
|
88
|
+
if project_root:
|
|
89
|
+
logger.info(f'Added project root to sys.path: {project_root}')
|
|
90
|
+
|
|
91
|
+
# Parse the locator
|
|
92
|
+
module_path, attr_name = _parse_locator(module_locator)
|
|
93
|
+
|
|
94
|
+
# Import the module
|
|
95
|
+
if _is_file_path(module_path):
|
|
96
|
+
# File path - normalize and import
|
|
97
|
+
if not module_path.endswith('.py'):
|
|
98
|
+
module_path += '.py'
|
|
99
|
+
file_path = os.path.realpath(module_path)
|
|
100
|
+
|
|
101
|
+
if not os.path.exists(file_path):
|
|
102
|
+
# Detect ambiguous "dotted.module.py" pattern
|
|
103
|
+
stem = module_path.removesuffix('.py')
|
|
104
|
+
if '.' in stem and '/' not in stem and os.path.sep not in stem:
|
|
105
|
+
attr_hint = attr_name if attr_name else '<app_name>'
|
|
106
|
+
dotted_form = f'{stem}:{attr_hint}'
|
|
107
|
+
file_form = f'{stem.replace(".", "/")}.py:{attr_hint}'
|
|
108
|
+
help_lines = (
|
|
109
|
+
f'use dotted module path: {dotted_form}\n'
|
|
110
|
+
f'or use file path: {file_form}'
|
|
111
|
+
)
|
|
112
|
+
if not attr_name:
|
|
113
|
+
help_lines += '\nwhere <app_name> is the variable name of your Horsies instance'
|
|
114
|
+
raise ConfigurationError(
|
|
115
|
+
message=f"ambiguous module locator: '{module_locator}'",
|
|
116
|
+
code=ErrorCode.CLI_INVALID_ARGS,
|
|
117
|
+
notes=[
|
|
118
|
+
f"'{module_path}' mixes dotted module notation with a .py file extension",
|
|
119
|
+
],
|
|
120
|
+
help_text=help_lines,
|
|
121
|
+
)
|
|
122
|
+
raise FileNotFoundError(f'Module file not found: {file_path}')
|
|
123
|
+
|
|
124
|
+
# import_file_path adds parent directory to sys.path
|
|
125
|
+
module = import_file_path(file_path)
|
|
126
|
+
module_name = module.__name__
|
|
127
|
+
sys_path_root = os.path.dirname(file_path)
|
|
128
|
+
else:
|
|
129
|
+
# Dotted module path - use standard import
|
|
130
|
+
try:
|
|
131
|
+
module = importlib.import_module(module_path)
|
|
132
|
+
except ModuleNotFoundError as e:
|
|
133
|
+
raise ConfigurationError(
|
|
134
|
+
message=f'module not found: {module_path}',
|
|
135
|
+
code=ErrorCode.CLI_INVALID_ARGS,
|
|
136
|
+
notes=[
|
|
137
|
+
str(e),
|
|
138
|
+
f'sys.path: {sys.path[:5]}...',
|
|
139
|
+
],
|
|
140
|
+
help_text=(
|
|
141
|
+
'ensure you are running from the correct directory\n'
|
|
142
|
+
'or set PYTHONPATH to include your project root'
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
module_name = module_path
|
|
146
|
+
sys_path_root = project_root
|
|
147
|
+
|
|
148
|
+
# Find horsies instance
|
|
149
|
+
if attr_name:
|
|
150
|
+
# Explicit attribute name
|
|
151
|
+
if not hasattr(module, attr_name):
|
|
152
|
+
raise AttributeError(
|
|
153
|
+
f"Module '{module_name}' has no attribute '{attr_name}'"
|
|
154
|
+
)
|
|
155
|
+
obj = getattr(module, attr_name)
|
|
156
|
+
if not isinstance(obj, Horsies):
|
|
157
|
+
raise TypeError(
|
|
158
|
+
f"'{attr_name}' in module '{module_name}' is not a Horsies instance "
|
|
159
|
+
f"(got {type(obj).__name__})"
|
|
160
|
+
)
|
|
161
|
+
app = obj
|
|
162
|
+
var_name = attr_name
|
|
163
|
+
else:
|
|
164
|
+
# Auto-discover horsies instance
|
|
165
|
+
app_instances: list[tuple[Horsies, str]] = []
|
|
166
|
+
for name in dir(module):
|
|
167
|
+
if not name.startswith('_'):
|
|
168
|
+
obj = getattr(module, name)
|
|
169
|
+
if isinstance(obj, Horsies):
|
|
170
|
+
app_instances.append((obj, name))
|
|
171
|
+
|
|
172
|
+
if not app_instances:
|
|
173
|
+
raise AttributeError(
|
|
174
|
+
f'No Horsies instance found in {module_name}. '
|
|
175
|
+
'Specify the variable name: module.path:variable'
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if len(app_instances) > 1:
|
|
179
|
+
var_names = [name for _, name in app_instances]
|
|
180
|
+
raise AttributeError(
|
|
181
|
+
f'Multiple Horsies instances found in {module_name}: {var_names}. '
|
|
182
|
+
'Specify which one: module.path:variable'
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
app, var_name = app_instances[0]
|
|
186
|
+
|
|
187
|
+
logger.info(f"Discovered horsies '{var_name}' from {module_name}")
|
|
188
|
+
return app, var_name, module_name, sys_path_root
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def setup_logging(loglevel: str) -> None:
|
|
192
|
+
"""Configure logging level globally."""
|
|
193
|
+
from horsies.core.logging import set_default_level
|
|
194
|
+
|
|
195
|
+
level = getattr(logging, loglevel.upper(), logging.INFO)
|
|
196
|
+
set_default_level(level)
|
|
197
|
+
|
|
198
|
+
root_logger = logging.getLogger('horsies')
|
|
199
|
+
root_logger.setLevel(level)
|
|
200
|
+
|
|
201
|
+
for handler in root_logger.handlers:
|
|
202
|
+
handler.setLevel(level)
|
|
203
|
+
|
|
204
|
+
for name in logging.Logger.manager.loggerDict:
|
|
205
|
+
if isinstance(name, str) and name.startswith('horsies.'):
|
|
206
|
+
lgr = logging.getLogger(name)
|
|
207
|
+
lgr.setLevel(level)
|
|
208
|
+
for handler in lgr.handlers:
|
|
209
|
+
handler.setLevel(level)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def worker_command(args: argparse.Namespace) -> None:
|
|
213
|
+
"""Handle worker command."""
|
|
214
|
+
logger = get_logger('cli')
|
|
215
|
+
|
|
216
|
+
# Setup logging first
|
|
217
|
+
loglevel: str = args.loglevel
|
|
218
|
+
setup_logging(loglevel)
|
|
219
|
+
logger.info(f'Starting horsies worker with loglevel={loglevel}')
|
|
220
|
+
|
|
221
|
+
# Discover app
|
|
222
|
+
try:
|
|
223
|
+
module_locator: str = _resolve_module_argument(args)
|
|
224
|
+
app, var_name, module_name, sys_path_root = discover_app(module_locator)
|
|
225
|
+
app.set_role('worker')
|
|
226
|
+
except HorsiesError as e:
|
|
227
|
+
logger.error(str(e))
|
|
228
|
+
sys.exit(1)
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
logger.error(str(e))
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.error(f'Failed to discover app: {e}')
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
# Get broker config from app
|
|
237
|
+
try:
|
|
238
|
+
broker = app.get_broker()
|
|
239
|
+
postgres_config = broker.config
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f'Failed to get broker config: {e}')
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
|
|
244
|
+
# Get queues from app config
|
|
245
|
+
queues: list[str] = app.get_valid_queue_names()
|
|
246
|
+
logger.info(f'Worker will process queues: {queues}')
|
|
247
|
+
|
|
248
|
+
# Build per-queue settings for CUSTOM mode
|
|
249
|
+
queue_priorities: dict[str, int] = {}
|
|
250
|
+
queue_max_concurrency: dict[str, int] = {}
|
|
251
|
+
try:
|
|
252
|
+
if app.config.queue_mode.name == 'CUSTOM' and app.config.custom_queues:
|
|
253
|
+
for q in app.config.custom_queues:
|
|
254
|
+
queue_priorities[q.name] = q.priority
|
|
255
|
+
queue_max_concurrency[q.name] = q.max_concurrency
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
# Get discovered task modules
|
|
260
|
+
discovered_modules = app.get_discovered_task_modules()
|
|
261
|
+
if discovered_modules:
|
|
262
|
+
logger.info(f'Using discovered task modules')
|
|
263
|
+
else:
|
|
264
|
+
logger.warning('No task modules discovered.')
|
|
265
|
+
|
|
266
|
+
# Create worker config
|
|
267
|
+
processes: int = args.processes
|
|
268
|
+
log_level_int = getattr(logging, loglevel.upper(), logging.INFO)
|
|
269
|
+
|
|
270
|
+
# Build sys_path_roots
|
|
271
|
+
sys_path_roots: list[str] = []
|
|
272
|
+
if sys_path_root:
|
|
273
|
+
sys_path_roots.append(sys_path_root)
|
|
274
|
+
|
|
275
|
+
# Build app_locator - prefer module path format
|
|
276
|
+
if _is_file_path(module_locator.split(':')[0]):
|
|
277
|
+
# File path - use absolute path
|
|
278
|
+
file_path = module_locator.split(':')[0]
|
|
279
|
+
if not file_path.endswith('.py'):
|
|
280
|
+
file_path += '.py'
|
|
281
|
+
app_locator = f'{os.path.realpath(file_path)}:{var_name}'
|
|
282
|
+
else:
|
|
283
|
+
# Module path - use as-is
|
|
284
|
+
app_locator = f'{module_name}:{var_name}'
|
|
285
|
+
|
|
286
|
+
worker_config = WorkerConfig(
|
|
287
|
+
dsn=postgres_config.database_url,
|
|
288
|
+
psycopg_dsn=postgres_config.database_url,
|
|
289
|
+
queues=queues,
|
|
290
|
+
processes=processes,
|
|
291
|
+
app_locator=app_locator,
|
|
292
|
+
sys_path_roots=sys_path_roots,
|
|
293
|
+
imports=discovered_modules,
|
|
294
|
+
queue_priorities=queue_priorities,
|
|
295
|
+
queue_max_concurrency=queue_max_concurrency,
|
|
296
|
+
cluster_wide_cap=app.config.cluster_wide_cap,
|
|
297
|
+
prefetch_buffer=app.config.prefetch_buffer,
|
|
298
|
+
claim_lease_ms=app.config.claim_lease_ms,
|
|
299
|
+
max_claim_batch=args.max_claim_batch,
|
|
300
|
+
max_claim_per_worker=args.max_claim_per_worker,
|
|
301
|
+
recovery_config=app.config.recovery,
|
|
302
|
+
loglevel=log_level_int,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Print startup banner
|
|
306
|
+
app.import_task_modules() # Import tasks so they show in banner
|
|
307
|
+
print_banner(app, role='worker', show_tasks=True)
|
|
308
|
+
|
|
309
|
+
# Start worker
|
|
310
|
+
logger.info('Starting worker...')
|
|
311
|
+
try:
|
|
312
|
+
|
|
313
|
+
async def run_worker() -> None:
|
|
314
|
+
try:
|
|
315
|
+
logger.info('Ensuring Postgres schema and triggers are initialized...')
|
|
316
|
+
await broker.ensure_schema_initialized()
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(f'Failed to initialize database schema: {e}')
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
worker = Worker(broker.session_factory, broker.listener, worker_config)
|
|
322
|
+
|
|
323
|
+
loop = asyncio.get_running_loop()
|
|
324
|
+
|
|
325
|
+
def signal_handler() -> None:
|
|
326
|
+
logger.info('Received interrupt signal, stopping worker...')
|
|
327
|
+
worker.request_stop()
|
|
328
|
+
|
|
329
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
330
|
+
try:
|
|
331
|
+
loop.add_signal_handler(sig, signal_handler)
|
|
332
|
+
except NotImplementedError:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
await worker.run_forever()
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
asyncio.run(run_worker())
|
|
339
|
+
except KeyboardInterrupt:
|
|
340
|
+
logger.info('Worker interrupted by user')
|
|
341
|
+
return
|
|
342
|
+
except asyncio.TimeoutError:
|
|
343
|
+
logger.error('Worker startup timed out')
|
|
344
|
+
return
|
|
345
|
+
except KeyboardInterrupt:
|
|
346
|
+
logger.info('Worker interrupted by user')
|
|
347
|
+
return
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error(f'Worker failed: {e}')
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def scheduler_command(args: argparse.Namespace) -> None:
|
|
354
|
+
"""Handle scheduler command."""
|
|
355
|
+
logger = get_logger('cli')
|
|
356
|
+
|
|
357
|
+
# Setup logging first
|
|
358
|
+
loglevel: str = args.loglevel
|
|
359
|
+
setup_logging(loglevel)
|
|
360
|
+
logger.info(f'Starting scheduler with loglevel={loglevel}')
|
|
361
|
+
|
|
362
|
+
# Discover app
|
|
363
|
+
try:
|
|
364
|
+
module_locator: str = _resolve_module_argument(args)
|
|
365
|
+
app, _var_name, _module_name, _sys_path_root = discover_app(module_locator)
|
|
366
|
+
app.set_role('scheduler')
|
|
367
|
+
except HorsiesError as e:
|
|
368
|
+
logger.error(str(e))
|
|
369
|
+
sys.exit(1)
|
|
370
|
+
except ValueError as e:
|
|
371
|
+
logger.error(str(e))
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.error(f'Failed to discover app: {e}')
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
|
|
377
|
+
# Validate schedule config
|
|
378
|
+
if not app.config.schedule:
|
|
379
|
+
logger.error('Schedule configuration not found in app config')
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
|
|
382
|
+
if not app.config.schedule.enabled:
|
|
383
|
+
logger.warning('Scheduler is disabled in config')
|
|
384
|
+
sys.exit(1)
|
|
385
|
+
|
|
386
|
+
# Import task modules for validation
|
|
387
|
+
discovered_modules = app.get_discovered_task_modules()
|
|
388
|
+
if discovered_modules:
|
|
389
|
+
logger.info(f'Task modules available: {discovered_modules}')
|
|
390
|
+
for module in discovered_modules:
|
|
391
|
+
try:
|
|
392
|
+
if _is_file_path(module):
|
|
393
|
+
import_file_path(os.path.abspath(module))
|
|
394
|
+
else:
|
|
395
|
+
importlib.import_module(module)
|
|
396
|
+
except Exception as e:
|
|
397
|
+
logger.warning(f"Failed to import task module '{module}': {e}")
|
|
398
|
+
|
|
399
|
+
schedule_count = len(app.config.schedule.schedules)
|
|
400
|
+
enabled_count = sum(1 for s in app.config.schedule.schedules if s.enabled)
|
|
401
|
+
|
|
402
|
+
# Print startup banner
|
|
403
|
+
print_banner(app, role='scheduler', show_tasks=True)
|
|
404
|
+
|
|
405
|
+
# Start scheduler
|
|
406
|
+
logger.info(f'Scheduler: {enabled_count}/{schedule_count} schedules enabled')
|
|
407
|
+
try:
|
|
408
|
+
|
|
409
|
+
async def run_scheduler() -> None:
|
|
410
|
+
try:
|
|
411
|
+
scheduler = Scheduler(app)
|
|
412
|
+
|
|
413
|
+
loop = asyncio.get_running_loop()
|
|
414
|
+
|
|
415
|
+
def signal_handler() -> None:
|
|
416
|
+
logger.info('Received interrupt signal, stopping scheduler...')
|
|
417
|
+
scheduler.request_stop()
|
|
418
|
+
|
|
419
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
420
|
+
try:
|
|
421
|
+
loop.add_signal_handler(sig, signal_handler)
|
|
422
|
+
except NotImplementedError:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
await scheduler.run_forever()
|
|
426
|
+
|
|
427
|
+
except Exception as e:
|
|
428
|
+
logger.error(f'Scheduler error: {e}', exc_info=True)
|
|
429
|
+
raise
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
asyncio.run(run_scheduler())
|
|
433
|
+
except KeyboardInterrupt:
|
|
434
|
+
logger.info('Scheduler interrupted by user')
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
except KeyboardInterrupt:
|
|
438
|
+
logger.info('Scheduler interrupted by user')
|
|
439
|
+
return
|
|
440
|
+
except Exception as e:
|
|
441
|
+
logger.error(f'Scheduler failed: {e}')
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def check_command(args: argparse.Namespace) -> None:
|
|
446
|
+
"""Handle check command — validate app configuration without starting services."""
|
|
447
|
+
logger = get_logger('cli')
|
|
448
|
+
|
|
449
|
+
# Setup logging
|
|
450
|
+
loglevel: str = args.loglevel
|
|
451
|
+
setup_logging(loglevel)
|
|
452
|
+
|
|
453
|
+
# Discover app
|
|
454
|
+
try:
|
|
455
|
+
module_locator: str = _resolve_module_argument(args)
|
|
456
|
+
app, _var_name, _module_name, _sys_path_root = discover_app(module_locator)
|
|
457
|
+
except HorsiesError as e:
|
|
458
|
+
logger.error(str(e))
|
|
459
|
+
sys.exit(1)
|
|
460
|
+
except ValueError as e:
|
|
461
|
+
logger.error(str(e))
|
|
462
|
+
sys.exit(1)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.error(f'Failed to discover app: {e}')
|
|
465
|
+
sys.exit(1)
|
|
466
|
+
|
|
467
|
+
# Run phased validation
|
|
468
|
+
live: bool = args.live
|
|
469
|
+
errors = app.check(live=live)
|
|
470
|
+
|
|
471
|
+
if errors:
|
|
472
|
+
report = ValidationReport('check')
|
|
473
|
+
for error in errors:
|
|
474
|
+
report.add(error)
|
|
475
|
+
print(report.format_rust_style(), file=sys.stderr)
|
|
476
|
+
sys.exit(1)
|
|
477
|
+
else:
|
|
478
|
+
task_count = len(app.list_tasks())
|
|
479
|
+
print(f'ok: all validations passed\n {task_count} task(s) registered')
|
|
480
|
+
sys.exit(0)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def main() -> None:
|
|
484
|
+
"""Main CLI entry point."""
|
|
485
|
+
try:
|
|
486
|
+
parser = argparse.ArgumentParser(
|
|
487
|
+
prog='horsies',
|
|
488
|
+
description='Horsies task queue - worker and scheduler management',
|
|
489
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
490
|
+
epilog="""
|
|
491
|
+
Examples:
|
|
492
|
+
# Using dotted module path (recommended)
|
|
493
|
+
horsies worker app.configs.horsies:app
|
|
494
|
+
|
|
495
|
+
# Using file path
|
|
496
|
+
horsies worker app/configs/horsies.py:app
|
|
497
|
+
|
|
498
|
+
# Auto-discover app variable
|
|
499
|
+
horsies worker app.configs.horsies
|
|
500
|
+
|
|
501
|
+
# Validate configuration without starting services
|
|
502
|
+
horsies check app.configs.horsies:app
|
|
503
|
+
horsies check app.configs.horsies:app --live
|
|
504
|
+
""",
|
|
505
|
+
)
|
|
506
|
+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
|
507
|
+
|
|
508
|
+
# Worker command
|
|
509
|
+
worker_parser = subparsers.add_parser(
|
|
510
|
+
'worker',
|
|
511
|
+
help='Start a horsies worker',
|
|
512
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
513
|
+
)
|
|
514
|
+
worker_parser.add_argument(
|
|
515
|
+
'-m',
|
|
516
|
+
'--module',
|
|
517
|
+
dest='module',
|
|
518
|
+
help='Module path (e.g., app.configs.horsies:app)',
|
|
519
|
+
)
|
|
520
|
+
worker_parser.add_argument(
|
|
521
|
+
'module_pos',
|
|
522
|
+
nargs='?',
|
|
523
|
+
help='Module path (e.g., app.configs.horsies:app)',
|
|
524
|
+
)
|
|
525
|
+
worker_parser.add_argument(
|
|
526
|
+
'--loglevel',
|
|
527
|
+
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
|
528
|
+
default='INFO',
|
|
529
|
+
type=str.upper,
|
|
530
|
+
help='Logging level (default: INFO)',
|
|
531
|
+
)
|
|
532
|
+
worker_parser.add_argument(
|
|
533
|
+
'--processes',
|
|
534
|
+
type=int,
|
|
535
|
+
default=1,
|
|
536
|
+
help='Number of worker processes (default: 1)',
|
|
537
|
+
)
|
|
538
|
+
worker_parser.add_argument(
|
|
539
|
+
'--max-claim-batch',
|
|
540
|
+
type=int,
|
|
541
|
+
default=2,
|
|
542
|
+
help='Max tasks per queue per pass (default: 2)',
|
|
543
|
+
)
|
|
544
|
+
worker_parser.add_argument(
|
|
545
|
+
'--max-claim-per-worker',
|
|
546
|
+
type=int,
|
|
547
|
+
default=0,
|
|
548
|
+
help='Max claimed tasks per worker, 0=auto (default: 0)',
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Scheduler command
|
|
552
|
+
scheduler_parser = subparsers.add_parser(
|
|
553
|
+
'scheduler',
|
|
554
|
+
help='Start the scheduler service',
|
|
555
|
+
)
|
|
556
|
+
scheduler_parser.add_argument(
|
|
557
|
+
'-m',
|
|
558
|
+
'--module',
|
|
559
|
+
dest='module',
|
|
560
|
+
help='Module path (e.g., app.configs.horsies:app)',
|
|
561
|
+
)
|
|
562
|
+
scheduler_parser.add_argument(
|
|
563
|
+
'module_pos',
|
|
564
|
+
nargs='?',
|
|
565
|
+
help='Module path (e.g., app.configs.horsies:app)',
|
|
566
|
+
)
|
|
567
|
+
scheduler_parser.add_argument(
|
|
568
|
+
'--loglevel',
|
|
569
|
+
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
|
570
|
+
default='INFO',
|
|
571
|
+
type=str.upper,
|
|
572
|
+
help='Logging level (default: INFO)',
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Check command
|
|
576
|
+
check_parser = subparsers.add_parser(
|
|
577
|
+
'check',
|
|
578
|
+
help='Validate app configuration without starting services',
|
|
579
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
580
|
+
)
|
|
581
|
+
check_parser.add_argument(
|
|
582
|
+
'-m',
|
|
583
|
+
'--module',
|
|
584
|
+
dest='module',
|
|
585
|
+
help='Module path (e.g., app.configs.horsies:app)',
|
|
586
|
+
)
|
|
587
|
+
check_parser.add_argument(
|
|
588
|
+
'module_pos',
|
|
589
|
+
nargs='?',
|
|
590
|
+
help='Module path (e.g., app.configs.horsies:app)',
|
|
591
|
+
)
|
|
592
|
+
check_parser.add_argument(
|
|
593
|
+
'--loglevel',
|
|
594
|
+
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
|
595
|
+
default='WARNING',
|
|
596
|
+
type=str.upper,
|
|
597
|
+
help='Logging level (default: WARNING)',
|
|
598
|
+
)
|
|
599
|
+
check_parser.add_argument(
|
|
600
|
+
'--live',
|
|
601
|
+
action='store_true',
|
|
602
|
+
default=False,
|
|
603
|
+
help='Also check broker connectivity (SELECT 1)',
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
args = parser.parse_args()
|
|
607
|
+
|
|
608
|
+
match args.command:
|
|
609
|
+
case 'worker':
|
|
610
|
+
worker_command(args)
|
|
611
|
+
case 'scheduler':
|
|
612
|
+
scheduler_command(args)
|
|
613
|
+
case 'check':
|
|
614
|
+
check_command(args)
|
|
615
|
+
case _:
|
|
616
|
+
parser.print_help()
|
|
617
|
+
sys.exit(1)
|
|
618
|
+
except KeyboardInterrupt:
|
|
619
|
+
print('\nInterrupted by user')
|
|
620
|
+
sys.exit(0)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
if __name__ == '__main__':
|
|
624
|
+
main()
|