quantum-framework 0.9.0__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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +417 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +527 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +547 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2611 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +148 -0
- quantum/core/expressions.py +591 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_fetching/src/__init__.py +21 -0
- quantum/core/features/data_fetching/src/ast_node.py +312 -0
- quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
- quantum/core/features/data_fetching/src/html_adapter.py +474 -0
- quantum/core/features/data_fetching/src/parser.py +225 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +291 -0
- quantum/core/features/data_import/src/runtime.py +538 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +146 -0
- quantum/core/features/invocation/src/runtime.py +327 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/database_service.py +322 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +228 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
- quantum/core/features/ui_engine/src/parser.py +1106 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1235 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +68 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +89 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +217 -0
- quantum/core/parsers/data/invoke_parser.py +116 -0
- quantum/core/parsers/data/query_parser.py +188 -0
- quantum/core/parsers/data/transaction_parser.py +77 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +54 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +137 -0
- quantum/core/parsers/functions/param_parser.py +66 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/persistence/__init__.py +9 -0
- quantum/core/parsers/persistence/persist_parser.py +64 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +82 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +28 -0
- quantum/runtime/action_handler.py +443 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/api_server.py +86 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +990 -0
- quantum/runtime/component_composer.py +319 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/database_service.py +598 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +286 -0
- quantum/runtime/executor_registry.py +171 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +262 -0
- quantum/runtime/executors/control_flow/__init__.py +11 -0
- quantum/runtime/executors/control_flow/if_executor.py +93 -0
- quantum/runtime/executors/control_flow/loop_executor.py +307 -0
- quantum/runtime/executors/control_flow/set_executor.py +412 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +145 -0
- quantum/runtime/executors/data/invoke_executor.py +176 -0
- quantum/runtime/executors/data/query_executor.py +256 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +500 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/logging_setup.py +140 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +184 -0
- quantum/runtime/pypy_compat.py +315 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +733 -0
- quantum/runtime/service_container.py +444 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +188 -0
- quantum/runtime/ui_desktop_adapter.py +1730 -0
- quantum/runtime/ui_desktop_templates.py +307 -0
- quantum/runtime/ui_html_adapter.py +2691 -0
- quantum/runtime/ui_html_templates.py +2297 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +1766 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/utils/__init__.py +1 -0
- quantum_framework-0.9.0.dist-info/METADATA +244 -0
- quantum_framework-0.9.0.dist-info/RECORD +262 -0
- quantum_framework-0.9.0.dist-info/WHEEL +5 -0
- quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1599 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Job Execution System - Scheduled Tasks, Threads, and Job Queues
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- ScheduleService: APScheduler-based scheduled task execution (like cfschedule)
|
|
6
|
+
- ThreadService: ThreadPoolExecutor-based async thread management (like cfthread)
|
|
7
|
+
- JobQueueService: SQLite-based job queue for batch processing
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
# Schedule a task to run every 5 minutes
|
|
11
|
+
<q:schedule name="cleanup" interval="5m">
|
|
12
|
+
<q:query datasource="db">DELETE FROM temp WHERE created_at < NOW() - INTERVAL 1 HOUR</q:query>
|
|
13
|
+
</q:schedule>
|
|
14
|
+
|
|
15
|
+
# Run async thread
|
|
16
|
+
<q:thread name="sendEmails" priority="high">
|
|
17
|
+
<q:loop query="pendingEmails">
|
|
18
|
+
<q:mail to="{email}" subject="Notification">...</q:mail>
|
|
19
|
+
</q:loop>
|
|
20
|
+
</q:thread>
|
|
21
|
+
|
|
22
|
+
# Dispatch job to queue
|
|
23
|
+
<q:job name="processOrder" action="dispatch" queue="orders" delay="30s" />
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
import json
|
|
28
|
+
import sqlite3
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import logging
|
|
32
|
+
from concurrent.futures import ThreadPoolExecutor, Future
|
|
33
|
+
from datetime import datetime, timedelta
|
|
34
|
+
from typing import Any, Dict, List, Optional, Callable, Tuple
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
|
|
38
|
+
# Try to import APScheduler (optional dependency)
|
|
39
|
+
try:
|
|
40
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
41
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
42
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
43
|
+
from apscheduler.triggers.date import DateTrigger
|
|
44
|
+
from apscheduler.jobstores.memory import MemoryJobStore
|
|
45
|
+
HAS_APSCHEDULER = True
|
|
46
|
+
except ImportError:
|
|
47
|
+
HAS_APSCHEDULER = False
|
|
48
|
+
BackgroundScheduler = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Register datetime adapters/converters for sqlite3 (Python 3.12+ compatibility)
|
|
52
|
+
def _adapt_datetime(val: datetime) -> str:
|
|
53
|
+
"""Convert datetime to ISO format string for SQLite storage."""
|
|
54
|
+
return val.isoformat()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _convert_datetime(val: bytes) -> datetime:
|
|
58
|
+
"""Convert ISO format string from SQLite to datetime."""
|
|
59
|
+
return datetime.fromisoformat(val.decode())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Register the adapter and converter
|
|
63
|
+
sqlite3.register_adapter(datetime, _adapt_datetime)
|
|
64
|
+
sqlite3.register_converter("timestamp", _convert_datetime)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Configure logging
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class JobExecutorError(Exception):
|
|
72
|
+
"""Base exception for job execution errors"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ScheduleError(JobExecutorError):
|
|
77
|
+
"""Raised when schedule execution fails"""
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ThreadError(JobExecutorError):
|
|
82
|
+
"""Raised when thread execution fails"""
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class JobQueueError(JobExecutorError):
|
|
87
|
+
"""Raised when job queue operation fails"""
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_duration(duration_str: str) -> int:
|
|
92
|
+
"""
|
|
93
|
+
Parse duration string to seconds.
|
|
94
|
+
|
|
95
|
+
Supported formats:
|
|
96
|
+
- "30s" -> 30 seconds
|
|
97
|
+
- "5m" -> 5 minutes (300 seconds)
|
|
98
|
+
- "1h" -> 1 hour (3600 seconds)
|
|
99
|
+
- "1d" -> 1 day (86400 seconds)
|
|
100
|
+
- "1w" -> 1 week (604800 seconds)
|
|
101
|
+
- Plain number -> seconds
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
duration_str: Duration string (e.g., "30s", "5m", "1h")
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Duration in seconds
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ValueError: If format is invalid
|
|
111
|
+
"""
|
|
112
|
+
if not duration_str:
|
|
113
|
+
raise ValueError("Duration string is required")
|
|
114
|
+
|
|
115
|
+
duration_str = duration_str.strip().lower()
|
|
116
|
+
|
|
117
|
+
# Try plain number first
|
|
118
|
+
try:
|
|
119
|
+
return int(duration_str)
|
|
120
|
+
except ValueError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
# Parse with unit suffix
|
|
124
|
+
match = re.match(r'^(\d+(?:\.\d+)?)\s*([smhdw])$', duration_str)
|
|
125
|
+
if not match:
|
|
126
|
+
raise ValueError(f"Invalid duration format: {duration_str}")
|
|
127
|
+
|
|
128
|
+
value = float(match.group(1))
|
|
129
|
+
unit = match.group(2)
|
|
130
|
+
|
|
131
|
+
multipliers = {
|
|
132
|
+
's': 1,
|
|
133
|
+
'm': 60,
|
|
134
|
+
'h': 3600,
|
|
135
|
+
'd': 86400,
|
|
136
|
+
'w': 604800
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return int(value * multipliers[unit])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_duration(seconds: int) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Format seconds as human-readable duration.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
seconds: Duration in seconds
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Formatted string (e.g., "5m", "1h 30m")
|
|
151
|
+
"""
|
|
152
|
+
if seconds < 60:
|
|
153
|
+
return f"{seconds}s"
|
|
154
|
+
elif seconds < 3600:
|
|
155
|
+
minutes = seconds // 60
|
|
156
|
+
secs = seconds % 60
|
|
157
|
+
if secs:
|
|
158
|
+
return f"{minutes}m {secs}s"
|
|
159
|
+
return f"{minutes}m"
|
|
160
|
+
elif seconds < 86400:
|
|
161
|
+
hours = seconds // 3600
|
|
162
|
+
minutes = (seconds % 3600) // 60
|
|
163
|
+
if minutes:
|
|
164
|
+
return f"{hours}h {minutes}m"
|
|
165
|
+
return f"{hours}h"
|
|
166
|
+
else:
|
|
167
|
+
days = seconds // 86400
|
|
168
|
+
hours = (seconds % 86400) // 3600
|
|
169
|
+
if hours:
|
|
170
|
+
return f"{days}d {hours}h"
|
|
171
|
+
return f"{days}d"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ============================================
|
|
175
|
+
# SCHEDULE SERVICE (APScheduler-based)
|
|
176
|
+
# ============================================
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class ScheduleInfo:
|
|
180
|
+
"""Information about a scheduled task"""
|
|
181
|
+
name: str
|
|
182
|
+
trigger_type: str # interval, cron, date
|
|
183
|
+
trigger_info: str # Human-readable trigger description
|
|
184
|
+
next_run: Optional[datetime]
|
|
185
|
+
enabled: bool
|
|
186
|
+
run_count: int = 0
|
|
187
|
+
last_run: Optional[datetime] = None
|
|
188
|
+
last_error: Optional[str] = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ScheduleService:
|
|
192
|
+
"""
|
|
193
|
+
Service for managing scheduled task execution.
|
|
194
|
+
|
|
195
|
+
Uses APScheduler for reliable scheduling with support for:
|
|
196
|
+
- Interval triggers (every X seconds/minutes/hours/days)
|
|
197
|
+
- Cron triggers (cron expression)
|
|
198
|
+
- One-time triggers (specific datetime)
|
|
199
|
+
|
|
200
|
+
Thread-safe and handles overlapping executions.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, max_instances: int = 3):
|
|
204
|
+
"""
|
|
205
|
+
Initialize schedule service.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
max_instances: Maximum concurrent instances of same job
|
|
209
|
+
"""
|
|
210
|
+
self._scheduler: Optional[BackgroundScheduler] = None
|
|
211
|
+
self._schedules: Dict[str, ScheduleInfo] = {}
|
|
212
|
+
self._callbacks: Dict[str, Callable] = {}
|
|
213
|
+
self._max_instances = max_instances
|
|
214
|
+
self._lock = threading.Lock()
|
|
215
|
+
self._started = False
|
|
216
|
+
|
|
217
|
+
def _ensure_scheduler(self):
|
|
218
|
+
"""Ensure scheduler is initialized and running"""
|
|
219
|
+
if not HAS_APSCHEDULER:
|
|
220
|
+
raise ScheduleError(
|
|
221
|
+
"APScheduler is not installed. Install with: pip install apscheduler"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if self._scheduler is None:
|
|
225
|
+
self._scheduler = BackgroundScheduler(
|
|
226
|
+
jobstores={'default': MemoryJobStore()},
|
|
227
|
+
job_defaults={
|
|
228
|
+
'coalesce': True,
|
|
229
|
+
'max_instances': self._max_instances,
|
|
230
|
+
'misfire_grace_time': 60
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if not self._started:
|
|
235
|
+
self._scheduler.start()
|
|
236
|
+
self._started = True
|
|
237
|
+
|
|
238
|
+
def add_schedule(
|
|
239
|
+
self,
|
|
240
|
+
name: str,
|
|
241
|
+
callback: Callable,
|
|
242
|
+
interval: Optional[str] = None,
|
|
243
|
+
cron: Optional[str] = None,
|
|
244
|
+
at: Optional[str] = None,
|
|
245
|
+
timezone: str = 'UTC',
|
|
246
|
+
enabled: bool = True,
|
|
247
|
+
overlap: bool = False
|
|
248
|
+
) -> ScheduleInfo:
|
|
249
|
+
"""
|
|
250
|
+
Add a scheduled task.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
name: Unique schedule name
|
|
254
|
+
callback: Function to execute
|
|
255
|
+
interval: Interval trigger (e.g., "30s", "5m", "1h", "1d")
|
|
256
|
+
cron: Cron expression (e.g., "0 2 * * *")
|
|
257
|
+
at: Specific datetime (ISO 8601 format)
|
|
258
|
+
timezone: Timezone for cron schedules
|
|
259
|
+
enabled: Whether schedule is enabled
|
|
260
|
+
overlap: Allow overlapping executions
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
ScheduleInfo object
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
ScheduleError: If schedule configuration is invalid
|
|
267
|
+
"""
|
|
268
|
+
self._ensure_scheduler()
|
|
269
|
+
|
|
270
|
+
if not name:
|
|
271
|
+
raise ScheduleError("Schedule name is required")
|
|
272
|
+
|
|
273
|
+
if not any([interval, cron, at]):
|
|
274
|
+
raise ScheduleError("Schedule requires 'interval', 'cron', or 'at'")
|
|
275
|
+
|
|
276
|
+
# Build trigger
|
|
277
|
+
trigger = None
|
|
278
|
+
trigger_type = None
|
|
279
|
+
trigger_info = None
|
|
280
|
+
|
|
281
|
+
if interval:
|
|
282
|
+
seconds = parse_duration(interval)
|
|
283
|
+
trigger = IntervalTrigger(seconds=seconds, timezone=timezone)
|
|
284
|
+
trigger_type = 'interval'
|
|
285
|
+
trigger_info = f"every {format_duration(seconds)}"
|
|
286
|
+
|
|
287
|
+
elif cron:
|
|
288
|
+
# Parse cron expression
|
|
289
|
+
parts = cron.split()
|
|
290
|
+
if len(parts) == 5:
|
|
291
|
+
minute, hour, day, month, day_of_week = parts
|
|
292
|
+
trigger = CronTrigger(
|
|
293
|
+
minute=minute,
|
|
294
|
+
hour=hour,
|
|
295
|
+
day=day,
|
|
296
|
+
month=month,
|
|
297
|
+
day_of_week=day_of_week,
|
|
298
|
+
timezone=timezone
|
|
299
|
+
)
|
|
300
|
+
elif len(parts) == 6:
|
|
301
|
+
second, minute, hour, day, month, day_of_week = parts
|
|
302
|
+
trigger = CronTrigger(
|
|
303
|
+
second=second,
|
|
304
|
+
minute=minute,
|
|
305
|
+
hour=hour,
|
|
306
|
+
day=day,
|
|
307
|
+
month=month,
|
|
308
|
+
day_of_week=day_of_week,
|
|
309
|
+
timezone=timezone
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
raise ScheduleError(f"Invalid cron expression: {cron}")
|
|
313
|
+
trigger_type = 'cron'
|
|
314
|
+
trigger_info = f"cron: {cron}"
|
|
315
|
+
|
|
316
|
+
elif at:
|
|
317
|
+
# Parse ISO 8601 datetime
|
|
318
|
+
try:
|
|
319
|
+
run_date = datetime.fromisoformat(at)
|
|
320
|
+
except ValueError:
|
|
321
|
+
raise ScheduleError(f"Invalid datetime format: {at}. Use ISO 8601")
|
|
322
|
+
trigger = DateTrigger(run_date=run_date, timezone=timezone)
|
|
323
|
+
trigger_type = 'date'
|
|
324
|
+
trigger_info = f"at {at}"
|
|
325
|
+
|
|
326
|
+
# Wrap callback to track execution
|
|
327
|
+
def wrapped_callback():
|
|
328
|
+
info = self._schedules.get(name)
|
|
329
|
+
if info:
|
|
330
|
+
info.run_count += 1
|
|
331
|
+
info.last_run = datetime.now()
|
|
332
|
+
try:
|
|
333
|
+
callback()
|
|
334
|
+
info.last_error = None
|
|
335
|
+
except Exception as e:
|
|
336
|
+
info.last_error = str(e)
|
|
337
|
+
logger.error(f"Schedule '{name}' failed: {e}")
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
# Store callback
|
|
341
|
+
self._callbacks[name] = wrapped_callback
|
|
342
|
+
|
|
343
|
+
with self._lock:
|
|
344
|
+
# Remove existing schedule if present
|
|
345
|
+
if name in self._schedules:
|
|
346
|
+
self.remove_schedule(name)
|
|
347
|
+
|
|
348
|
+
# Add job to scheduler
|
|
349
|
+
max_inst = self._max_instances if overlap else 1
|
|
350
|
+
job = self._scheduler.add_job(
|
|
351
|
+
wrapped_callback,
|
|
352
|
+
trigger=trigger,
|
|
353
|
+
id=name,
|
|
354
|
+
name=name,
|
|
355
|
+
max_instances=max_inst,
|
|
356
|
+
replace_existing=True
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Pause if not enabled
|
|
360
|
+
if not enabled:
|
|
361
|
+
self._scheduler.pause_job(name)
|
|
362
|
+
|
|
363
|
+
# Create schedule info
|
|
364
|
+
info = ScheduleInfo(
|
|
365
|
+
name=name,
|
|
366
|
+
trigger_type=trigger_type,
|
|
367
|
+
trigger_info=trigger_info,
|
|
368
|
+
next_run=job.next_run_time,
|
|
369
|
+
enabled=enabled
|
|
370
|
+
)
|
|
371
|
+
self._schedules[name] = info
|
|
372
|
+
|
|
373
|
+
logger.info(f"Added schedule '{name}': {trigger_info}")
|
|
374
|
+
return info
|
|
375
|
+
|
|
376
|
+
def remove_schedule(self, name: str) -> bool:
|
|
377
|
+
"""
|
|
378
|
+
Remove a scheduled task.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
name: Schedule name
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
True if removed, False if not found
|
|
385
|
+
"""
|
|
386
|
+
if self._scheduler is None:
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
with self._lock:
|
|
390
|
+
if name in self._schedules:
|
|
391
|
+
try:
|
|
392
|
+
self._scheduler.remove_job(name)
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
del self._schedules[name]
|
|
396
|
+
if name in self._callbacks:
|
|
397
|
+
del self._callbacks[name]
|
|
398
|
+
logger.info(f"Removed schedule '{name}'")
|
|
399
|
+
return True
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
def pause_schedule(self, name: str) -> bool:
|
|
403
|
+
"""Pause a scheduled task"""
|
|
404
|
+
if self._scheduler is None:
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
with self._lock:
|
|
408
|
+
if name in self._schedules:
|
|
409
|
+
self._scheduler.pause_job(name)
|
|
410
|
+
self._schedules[name].enabled = False
|
|
411
|
+
logger.info(f"Paused schedule '{name}'")
|
|
412
|
+
return True
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
def resume_schedule(self, name: str) -> bool:
|
|
416
|
+
"""Resume a paused scheduled task"""
|
|
417
|
+
if self._scheduler is None:
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
with self._lock:
|
|
421
|
+
if name in self._schedules:
|
|
422
|
+
self._scheduler.resume_job(name)
|
|
423
|
+
self._schedules[name].enabled = True
|
|
424
|
+
logger.info(f"Resumed schedule '{name}'")
|
|
425
|
+
return True
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
def get_schedule(self, name: str) -> Optional[ScheduleInfo]:
|
|
429
|
+
"""Get schedule info by name"""
|
|
430
|
+
return self._schedules.get(name)
|
|
431
|
+
|
|
432
|
+
def list_schedules(self) -> List[ScheduleInfo]:
|
|
433
|
+
"""List all schedules"""
|
|
434
|
+
return list(self._schedules.values())
|
|
435
|
+
|
|
436
|
+
def run_now(self, name: str) -> bool:
|
|
437
|
+
"""Trigger immediate execution of a schedule"""
|
|
438
|
+
if name in self._callbacks:
|
|
439
|
+
try:
|
|
440
|
+
self._callbacks[name]()
|
|
441
|
+
return True
|
|
442
|
+
except Exception as e:
|
|
443
|
+
logger.error(f"Immediate run of '{name}' failed: {e}")
|
|
444
|
+
return False
|
|
445
|
+
return False
|
|
446
|
+
|
|
447
|
+
def shutdown(self):
|
|
448
|
+
"""Shutdown the scheduler"""
|
|
449
|
+
if self._scheduler and self._started:
|
|
450
|
+
self._scheduler.shutdown(wait=True)
|
|
451
|
+
self._started = False
|
|
452
|
+
logger.info("Schedule service shutdown complete")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ============================================
|
|
456
|
+
# THREAD SERVICE (ThreadPoolExecutor-based)
|
|
457
|
+
# ============================================
|
|
458
|
+
|
|
459
|
+
@dataclass
|
|
460
|
+
class ThreadInfo:
|
|
461
|
+
"""Information about a running thread"""
|
|
462
|
+
name: str
|
|
463
|
+
priority: str
|
|
464
|
+
started_at: datetime
|
|
465
|
+
status: str # running, completed, failed, terminated
|
|
466
|
+
timeout: Optional[int] = None
|
|
467
|
+
result: Any = None
|
|
468
|
+
error: Optional[str] = None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class ThreadService:
|
|
472
|
+
"""
|
|
473
|
+
Service for managing async thread execution.
|
|
474
|
+
|
|
475
|
+
Uses ThreadPoolExecutor for managed thread pools with:
|
|
476
|
+
- Priority-based execution (low, normal, high)
|
|
477
|
+
- Timeout support
|
|
478
|
+
- Callbacks for completion/error
|
|
479
|
+
- Thread joining and termination
|
|
480
|
+
|
|
481
|
+
Thread-safe operations.
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
PRIORITY_WEIGHTS = {
|
|
485
|
+
'low': 1,
|
|
486
|
+
'normal': 5,
|
|
487
|
+
'high': 10
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
def __init__(self, max_workers: int = 10):
|
|
491
|
+
"""
|
|
492
|
+
Initialize thread service.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
max_workers: Maximum concurrent threads
|
|
496
|
+
"""
|
|
497
|
+
self._executor = ThreadPoolExecutor(
|
|
498
|
+
max_workers=max_workers,
|
|
499
|
+
thread_name_prefix="quantum_thread_"
|
|
500
|
+
)
|
|
501
|
+
self._threads: Dict[str, ThreadInfo] = {}
|
|
502
|
+
self._futures: Dict[str, Future] = {}
|
|
503
|
+
self._lock = threading.Lock()
|
|
504
|
+
|
|
505
|
+
def run_thread(
|
|
506
|
+
self,
|
|
507
|
+
name: str,
|
|
508
|
+
callback: Callable,
|
|
509
|
+
priority: str = 'normal',
|
|
510
|
+
timeout: Optional[str] = None,
|
|
511
|
+
on_complete: Optional[Callable[[Any], None]] = None,
|
|
512
|
+
on_error: Optional[Callable[[Exception], None]] = None
|
|
513
|
+
) -> ThreadInfo:
|
|
514
|
+
"""
|
|
515
|
+
Run a task in a background thread.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
name: Thread name (must be unique)
|
|
519
|
+
callback: Function to execute
|
|
520
|
+
priority: Thread priority (low, normal, high)
|
|
521
|
+
timeout: Optional timeout (e.g., "30s", "5m")
|
|
522
|
+
on_complete: Callback on successful completion
|
|
523
|
+
on_error: Callback on error
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
ThreadInfo object
|
|
527
|
+
|
|
528
|
+
Raises:
|
|
529
|
+
ThreadError: If thread already exists or parameters invalid
|
|
530
|
+
"""
|
|
531
|
+
if not name:
|
|
532
|
+
raise ThreadError("Thread name is required")
|
|
533
|
+
|
|
534
|
+
if priority not in self.PRIORITY_WEIGHTS:
|
|
535
|
+
raise ThreadError(f"Invalid priority: {priority}")
|
|
536
|
+
|
|
537
|
+
timeout_seconds = None
|
|
538
|
+
if timeout:
|
|
539
|
+
timeout_seconds = parse_duration(timeout)
|
|
540
|
+
|
|
541
|
+
with self._lock:
|
|
542
|
+
if name in self._threads:
|
|
543
|
+
existing = self._threads[name]
|
|
544
|
+
if existing.status == 'running':
|
|
545
|
+
raise ThreadError(f"Thread '{name}' is already running")
|
|
546
|
+
|
|
547
|
+
# Wrapper function for execution tracking
|
|
548
|
+
def execute_thread():
|
|
549
|
+
info = self._threads.get(name)
|
|
550
|
+
if not info:
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
result = callback()
|
|
555
|
+
info.status = 'completed'
|
|
556
|
+
info.result = result
|
|
557
|
+
if on_complete:
|
|
558
|
+
try:
|
|
559
|
+
on_complete(result)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
logger.error(f"Thread '{name}' on_complete callback failed: {e}")
|
|
562
|
+
return result
|
|
563
|
+
except Exception as e:
|
|
564
|
+
info.status = 'failed'
|
|
565
|
+
info.error = str(e)
|
|
566
|
+
logger.error(f"Thread '{name}' failed: {e}")
|
|
567
|
+
if on_error:
|
|
568
|
+
try:
|
|
569
|
+
on_error(e)
|
|
570
|
+
except Exception as cb_err:
|
|
571
|
+
logger.error(f"Thread '{name}' on_error callback failed: {cb_err}")
|
|
572
|
+
raise
|
|
573
|
+
|
|
574
|
+
# Create thread info
|
|
575
|
+
info = ThreadInfo(
|
|
576
|
+
name=name,
|
|
577
|
+
priority=priority,
|
|
578
|
+
started_at=datetime.now(),
|
|
579
|
+
status='running',
|
|
580
|
+
timeout=timeout_seconds
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
with self._lock:
|
|
584
|
+
self._threads[name] = info
|
|
585
|
+
|
|
586
|
+
# Submit to executor
|
|
587
|
+
future = self._executor.submit(execute_thread)
|
|
588
|
+
self._futures[name] = future
|
|
589
|
+
|
|
590
|
+
# Handle timeout if specified
|
|
591
|
+
if timeout_seconds:
|
|
592
|
+
def check_timeout():
|
|
593
|
+
time.sleep(timeout_seconds)
|
|
594
|
+
if name in self._threads and self._threads[name].status == 'running':
|
|
595
|
+
self.terminate_thread(name)
|
|
596
|
+
logger.warning(f"Thread '{name}' terminated due to timeout")
|
|
597
|
+
|
|
598
|
+
timeout_thread = threading.Thread(target=check_timeout, daemon=True)
|
|
599
|
+
timeout_thread.start()
|
|
600
|
+
|
|
601
|
+
logger.info(f"Started thread '{name}' with priority '{priority}'")
|
|
602
|
+
return info
|
|
603
|
+
|
|
604
|
+
def join_thread(self, name: str, timeout: Optional[float] = None) -> Optional[Any]:
|
|
605
|
+
"""
|
|
606
|
+
Wait for a thread to complete.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
name: Thread name
|
|
610
|
+
timeout: Optional timeout in seconds
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Thread result or None if not found
|
|
614
|
+
|
|
615
|
+
Raises:
|
|
616
|
+
ThreadError: If thread failed or timed out
|
|
617
|
+
"""
|
|
618
|
+
if name not in self._futures:
|
|
619
|
+
raise ThreadError(f"Thread '{name}' not found")
|
|
620
|
+
|
|
621
|
+
future = self._futures[name]
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
result = future.result(timeout=timeout)
|
|
625
|
+
logger.info(f"Thread '{name}' completed successfully")
|
|
626
|
+
return result
|
|
627
|
+
except Exception as e:
|
|
628
|
+
raise ThreadError(f"Thread '{name}' failed: {e}")
|
|
629
|
+
|
|
630
|
+
def terminate_thread(self, name: str) -> bool:
|
|
631
|
+
"""
|
|
632
|
+
Request termination of a thread.
|
|
633
|
+
|
|
634
|
+
Note: Python threads cannot be forcibly killed, but this marks
|
|
635
|
+
the thread as terminated and cancels the future if possible.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
name: Thread name
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
True if termination requested, False if not found
|
|
642
|
+
"""
|
|
643
|
+
with self._lock:
|
|
644
|
+
if name in self._threads:
|
|
645
|
+
info = self._threads[name]
|
|
646
|
+
if info.status == 'running':
|
|
647
|
+
info.status = 'terminated'
|
|
648
|
+
|
|
649
|
+
# Try to cancel the future (only works if not started)
|
|
650
|
+
if name in self._futures:
|
|
651
|
+
self._futures[name].cancel()
|
|
652
|
+
|
|
653
|
+
logger.info(f"Requested termination of thread '{name}'")
|
|
654
|
+
return True
|
|
655
|
+
return False
|
|
656
|
+
|
|
657
|
+
def get_thread(self, name: str) -> Optional[ThreadInfo]:
|
|
658
|
+
"""Get thread info by name"""
|
|
659
|
+
return self._threads.get(name)
|
|
660
|
+
|
|
661
|
+
def list_threads(self, status: Optional[str] = None) -> List[ThreadInfo]:
|
|
662
|
+
"""
|
|
663
|
+
List threads, optionally filtered by status.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
status: Filter by status (running, completed, failed, terminated)
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
List of ThreadInfo objects
|
|
670
|
+
"""
|
|
671
|
+
threads = list(self._threads.values())
|
|
672
|
+
if status:
|
|
673
|
+
threads = [t for t in threads if t.status == status]
|
|
674
|
+
return threads
|
|
675
|
+
|
|
676
|
+
def cleanup(self, max_age_seconds: int = 3600):
|
|
677
|
+
"""
|
|
678
|
+
Remove completed/failed threads older than max_age.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
max_age_seconds: Maximum age in seconds (default 1 hour)
|
|
682
|
+
"""
|
|
683
|
+
cutoff = datetime.now() - timedelta(seconds=max_age_seconds)
|
|
684
|
+
with self._lock:
|
|
685
|
+
to_remove = []
|
|
686
|
+
for name, info in self._threads.items():
|
|
687
|
+
if info.status != 'running' and info.started_at < cutoff:
|
|
688
|
+
to_remove.append(name)
|
|
689
|
+
|
|
690
|
+
for name in to_remove:
|
|
691
|
+
del self._threads[name]
|
|
692
|
+
if name in self._futures:
|
|
693
|
+
del self._futures[name]
|
|
694
|
+
|
|
695
|
+
if to_remove:
|
|
696
|
+
logger.info(f"Cleaned up {len(to_remove)} old threads")
|
|
697
|
+
|
|
698
|
+
def shutdown(self, wait: bool = True):
|
|
699
|
+
"""
|
|
700
|
+
Shutdown the thread pool.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
wait: Wait for running threads to complete
|
|
704
|
+
"""
|
|
705
|
+
self._executor.shutdown(wait=wait)
|
|
706
|
+
logger.info("Thread service shutdown complete")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ============================================
|
|
710
|
+
# JOB QUEUE SERVICE (SQLite-based)
|
|
711
|
+
# ============================================
|
|
712
|
+
|
|
713
|
+
@dataclass
|
|
714
|
+
class JobInfo:
|
|
715
|
+
"""Information about a queued job"""
|
|
716
|
+
id: int
|
|
717
|
+
name: str
|
|
718
|
+
queue: str
|
|
719
|
+
params: Dict[str, Any]
|
|
720
|
+
status: str # pending, running, completed, failed
|
|
721
|
+
attempts: int
|
|
722
|
+
max_attempts: int
|
|
723
|
+
scheduled_at: Optional[datetime]
|
|
724
|
+
started_at: Optional[datetime]
|
|
725
|
+
completed_at: Optional[datetime]
|
|
726
|
+
error: Optional[str]
|
|
727
|
+
created_at: datetime
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class JobQueueService:
|
|
731
|
+
"""
|
|
732
|
+
Service for managing job queues with SQLite persistence.
|
|
733
|
+
|
|
734
|
+
Features:
|
|
735
|
+
- Multiple named queues
|
|
736
|
+
- Job priorities
|
|
737
|
+
- Delayed execution
|
|
738
|
+
- Automatic retries with backoff
|
|
739
|
+
- Dead letter queue support
|
|
740
|
+
|
|
741
|
+
Thread-safe with connection pooling.
|
|
742
|
+
"""
|
|
743
|
+
|
|
744
|
+
CREATE_TABLE_SQL = """
|
|
745
|
+
CREATE TABLE IF NOT EXISTS quantum_jobs (
|
|
746
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
747
|
+
name TEXT NOT NULL,
|
|
748
|
+
queue TEXT DEFAULT 'default',
|
|
749
|
+
params TEXT,
|
|
750
|
+
status TEXT DEFAULT 'pending',
|
|
751
|
+
priority INTEGER DEFAULT 0,
|
|
752
|
+
attempts INTEGER DEFAULT 0,
|
|
753
|
+
max_attempts INTEGER DEFAULT 3,
|
|
754
|
+
backoff_seconds INTEGER DEFAULT 30,
|
|
755
|
+
scheduled_at DATETIME,
|
|
756
|
+
started_at DATETIME,
|
|
757
|
+
completed_at DATETIME,
|
|
758
|
+
error TEXT,
|
|
759
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
760
|
+
);
|
|
761
|
+
CREATE INDEX IF NOT EXISTS idx_quantum_jobs_status ON quantum_jobs(status);
|
|
762
|
+
CREATE INDEX IF NOT EXISTS idx_quantum_jobs_queue ON quantum_jobs(queue);
|
|
763
|
+
CREATE INDEX IF NOT EXISTS idx_quantum_jobs_scheduled ON quantum_jobs(scheduled_at);
|
|
764
|
+
"""
|
|
765
|
+
|
|
766
|
+
def __init__(self, db_path: str = "quantum_jobs.db"):
|
|
767
|
+
"""
|
|
768
|
+
Initialize job queue service.
|
|
769
|
+
|
|
770
|
+
Args:
|
|
771
|
+
db_path: Path to SQLite database file
|
|
772
|
+
"""
|
|
773
|
+
self._db_path = db_path
|
|
774
|
+
self._handlers: Dict[str, Callable] = {}
|
|
775
|
+
self._workers: Dict[str, threading.Thread] = {}
|
|
776
|
+
self._running = False
|
|
777
|
+
self._lock = threading.Lock()
|
|
778
|
+
|
|
779
|
+
# Initialize database
|
|
780
|
+
self._init_db()
|
|
781
|
+
|
|
782
|
+
def _init_db(self):
|
|
783
|
+
"""Initialize the database schema."""
|
|
784
|
+
conn = sqlite3.connect(
|
|
785
|
+
self._db_path,
|
|
786
|
+
detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
|
|
787
|
+
)
|
|
788
|
+
try:
|
|
789
|
+
conn.executescript(self.CREATE_TABLE_SQL)
|
|
790
|
+
conn.commit()
|
|
791
|
+
finally:
|
|
792
|
+
conn.close()
|
|
793
|
+
|
|
794
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
795
|
+
"""Get a database connection with datetime support."""
|
|
796
|
+
conn = sqlite3.connect(
|
|
797
|
+
self._db_path,
|
|
798
|
+
detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
|
|
799
|
+
)
|
|
800
|
+
conn.row_factory = sqlite3.Row
|
|
801
|
+
return conn
|
|
802
|
+
|
|
803
|
+
def register_handler(self, job_name: str, handler: Callable):
|
|
804
|
+
"""
|
|
805
|
+
Register a handler for a job type.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
job_name: Job name to handle
|
|
809
|
+
handler: Function to execute (receives params dict)
|
|
810
|
+
"""
|
|
811
|
+
self._handlers[job_name] = handler
|
|
812
|
+
logger.info(f"Registered handler for job '{job_name}'")
|
|
813
|
+
|
|
814
|
+
def dispatch(
|
|
815
|
+
self,
|
|
816
|
+
name: str,
|
|
817
|
+
queue: str = 'default',
|
|
818
|
+
params: Optional[Dict[str, Any]] = None,
|
|
819
|
+
delay: Optional[str] = None,
|
|
820
|
+
priority: int = 0,
|
|
821
|
+
attempts: int = 3,
|
|
822
|
+
backoff: str = '30s'
|
|
823
|
+
) -> int:
|
|
824
|
+
"""
|
|
825
|
+
Dispatch a job to the queue.
|
|
826
|
+
|
|
827
|
+
Args:
|
|
828
|
+
name: Job name (must have registered handler)
|
|
829
|
+
queue: Queue name
|
|
830
|
+
params: Job parameters
|
|
831
|
+
delay: Delay before execution (e.g., "30s", "5m")
|
|
832
|
+
priority: Job priority (0-10, higher = more important)
|
|
833
|
+
attempts: Maximum retry attempts
|
|
834
|
+
backoff: Backoff time between retries
|
|
835
|
+
|
|
836
|
+
Returns:
|
|
837
|
+
Job ID
|
|
838
|
+
|
|
839
|
+
Raises:
|
|
840
|
+
JobQueueError: If dispatch fails
|
|
841
|
+
"""
|
|
842
|
+
if not name:
|
|
843
|
+
raise JobQueueError("Job name is required")
|
|
844
|
+
|
|
845
|
+
# Calculate scheduled_at
|
|
846
|
+
scheduled_at = None
|
|
847
|
+
if delay:
|
|
848
|
+
delay_seconds = parse_duration(delay)
|
|
849
|
+
scheduled_at = datetime.now() + timedelta(seconds=delay_seconds)
|
|
850
|
+
|
|
851
|
+
backoff_seconds = parse_duration(backoff) if backoff else 30
|
|
852
|
+
|
|
853
|
+
# Serialize params
|
|
854
|
+
params_json = json.dumps(params or {})
|
|
855
|
+
|
|
856
|
+
conn = self._get_connection()
|
|
857
|
+
try:
|
|
858
|
+
cursor = conn.execute(
|
|
859
|
+
"""
|
|
860
|
+
INSERT INTO quantum_jobs
|
|
861
|
+
(name, queue, params, priority, max_attempts, backoff_seconds, scheduled_at)
|
|
862
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
863
|
+
""",
|
|
864
|
+
(name, queue, params_json, priority, attempts, backoff_seconds, scheduled_at)
|
|
865
|
+
)
|
|
866
|
+
conn.commit()
|
|
867
|
+
job_id = cursor.lastrowid
|
|
868
|
+
logger.info(f"Dispatched job '{name}' (id={job_id}) to queue '{queue}'")
|
|
869
|
+
return job_id
|
|
870
|
+
finally:
|
|
871
|
+
conn.close()
|
|
872
|
+
|
|
873
|
+
def dispatch_batch(
|
|
874
|
+
self,
|
|
875
|
+
jobs: List[Dict[str, Any]],
|
|
876
|
+
queue: str = 'default'
|
|
877
|
+
) -> List[int]:
|
|
878
|
+
"""
|
|
879
|
+
Dispatch multiple jobs at once.
|
|
880
|
+
|
|
881
|
+
Args:
|
|
882
|
+
jobs: List of job dicts with 'name' and optional 'params', 'delay', etc.
|
|
883
|
+
queue: Default queue name
|
|
884
|
+
|
|
885
|
+
Returns:
|
|
886
|
+
List of job IDs
|
|
887
|
+
"""
|
|
888
|
+
job_ids = []
|
|
889
|
+
for job in jobs:
|
|
890
|
+
job_id = self.dispatch(
|
|
891
|
+
name=job.get('name', ''),
|
|
892
|
+
queue=job.get('queue', queue),
|
|
893
|
+
params=job.get('params'),
|
|
894
|
+
delay=job.get('delay'),
|
|
895
|
+
priority=job.get('priority', 0),
|
|
896
|
+
attempts=job.get('attempts', 3),
|
|
897
|
+
backoff=job.get('backoff', '30s')
|
|
898
|
+
)
|
|
899
|
+
job_ids.append(job_id)
|
|
900
|
+
return job_ids
|
|
901
|
+
|
|
902
|
+
def get_job(self, job_id: int) -> Optional[JobInfo]:
|
|
903
|
+
"""Get job info by ID"""
|
|
904
|
+
conn = self._get_connection()
|
|
905
|
+
try:
|
|
906
|
+
row = conn.execute(
|
|
907
|
+
"SELECT * FROM quantum_jobs WHERE id = ?",
|
|
908
|
+
(job_id,)
|
|
909
|
+
).fetchone()
|
|
910
|
+
|
|
911
|
+
if row:
|
|
912
|
+
return self._row_to_job_info(row)
|
|
913
|
+
return None
|
|
914
|
+
finally:
|
|
915
|
+
conn.close()
|
|
916
|
+
|
|
917
|
+
def list_jobs(
|
|
918
|
+
self,
|
|
919
|
+
queue: Optional[str] = None,
|
|
920
|
+
status: Optional[str] = None,
|
|
921
|
+
limit: int = 100
|
|
922
|
+
) -> List[JobInfo]:
|
|
923
|
+
"""
|
|
924
|
+
List jobs with optional filters.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
queue: Filter by queue name
|
|
928
|
+
status: Filter by status
|
|
929
|
+
limit: Maximum results
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
List of JobInfo objects
|
|
933
|
+
"""
|
|
934
|
+
conn = self._get_connection()
|
|
935
|
+
try:
|
|
936
|
+
sql = "SELECT * FROM quantum_jobs WHERE 1=1"
|
|
937
|
+
params = []
|
|
938
|
+
|
|
939
|
+
if queue:
|
|
940
|
+
sql += " AND queue = ?"
|
|
941
|
+
params.append(queue)
|
|
942
|
+
|
|
943
|
+
if status:
|
|
944
|
+
sql += " AND status = ?"
|
|
945
|
+
params.append(status)
|
|
946
|
+
|
|
947
|
+
sql += " ORDER BY priority DESC, created_at ASC LIMIT ?"
|
|
948
|
+
params.append(limit)
|
|
949
|
+
|
|
950
|
+
rows = conn.execute(sql, params).fetchall()
|
|
951
|
+
return [self._row_to_job_info(row) for row in rows]
|
|
952
|
+
finally:
|
|
953
|
+
conn.close()
|
|
954
|
+
|
|
955
|
+
def cancel_job(self, job_id: int) -> bool:
|
|
956
|
+
"""
|
|
957
|
+
Cancel a pending job.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
job_id: Job ID
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
True if cancelled, False if not found or already running
|
|
964
|
+
"""
|
|
965
|
+
conn = self._get_connection()
|
|
966
|
+
try:
|
|
967
|
+
cursor = conn.execute(
|
|
968
|
+
"""
|
|
969
|
+
UPDATE quantum_jobs
|
|
970
|
+
SET status = 'cancelled', completed_at = ?
|
|
971
|
+
WHERE id = ? AND status = 'pending'
|
|
972
|
+
""",
|
|
973
|
+
(datetime.now(), job_id)
|
|
974
|
+
)
|
|
975
|
+
conn.commit()
|
|
976
|
+
if cursor.rowcount > 0:
|
|
977
|
+
logger.info(f"Cancelled job {job_id}")
|
|
978
|
+
return True
|
|
979
|
+
return False
|
|
980
|
+
finally:
|
|
981
|
+
conn.close()
|
|
982
|
+
|
|
983
|
+
def retry_job(self, job_id: int) -> bool:
|
|
984
|
+
"""
|
|
985
|
+
Retry a failed job.
|
|
986
|
+
|
|
987
|
+
Args:
|
|
988
|
+
job_id: Job ID
|
|
989
|
+
|
|
990
|
+
Returns:
|
|
991
|
+
True if retried, False if not found or not failed
|
|
992
|
+
"""
|
|
993
|
+
conn = self._get_connection()
|
|
994
|
+
try:
|
|
995
|
+
cursor = conn.execute(
|
|
996
|
+
"""
|
|
997
|
+
UPDATE quantum_jobs
|
|
998
|
+
SET status = 'pending', attempts = 0, error = NULL,
|
|
999
|
+
started_at = NULL, completed_at = NULL
|
|
1000
|
+
WHERE id = ? AND status = 'failed'
|
|
1001
|
+
""",
|
|
1002
|
+
(job_id,)
|
|
1003
|
+
)
|
|
1004
|
+
conn.commit()
|
|
1005
|
+
if cursor.rowcount > 0:
|
|
1006
|
+
logger.info(f"Retrying job {job_id}")
|
|
1007
|
+
return True
|
|
1008
|
+
return False
|
|
1009
|
+
finally:
|
|
1010
|
+
conn.close()
|
|
1011
|
+
|
|
1012
|
+
def purge_queue(self, queue: str, status: Optional[str] = None) -> int:
|
|
1013
|
+
"""
|
|
1014
|
+
Purge jobs from a queue.
|
|
1015
|
+
|
|
1016
|
+
Args:
|
|
1017
|
+
queue: Queue name
|
|
1018
|
+
status: Only purge jobs with this status (default: all non-running)
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
Number of jobs purged
|
|
1022
|
+
"""
|
|
1023
|
+
conn = self._get_connection()
|
|
1024
|
+
try:
|
|
1025
|
+
if status:
|
|
1026
|
+
cursor = conn.execute(
|
|
1027
|
+
"DELETE FROM quantum_jobs WHERE queue = ? AND status = ?",
|
|
1028
|
+
(queue, status)
|
|
1029
|
+
)
|
|
1030
|
+
else:
|
|
1031
|
+
cursor = conn.execute(
|
|
1032
|
+
"DELETE FROM quantum_jobs WHERE queue = ? AND status != 'running'",
|
|
1033
|
+
(queue,)
|
|
1034
|
+
)
|
|
1035
|
+
conn.commit()
|
|
1036
|
+
count = cursor.rowcount
|
|
1037
|
+
logger.info(f"Purged {count} jobs from queue '{queue}'")
|
|
1038
|
+
return count
|
|
1039
|
+
finally:
|
|
1040
|
+
conn.close()
|
|
1041
|
+
|
|
1042
|
+
def start_worker(self, queue: str = 'default', poll_interval: float = 1.0):
|
|
1043
|
+
"""
|
|
1044
|
+
Start a background worker for a queue.
|
|
1045
|
+
|
|
1046
|
+
Args:
|
|
1047
|
+
queue: Queue to process
|
|
1048
|
+
poll_interval: Seconds between polls
|
|
1049
|
+
"""
|
|
1050
|
+
if queue in self._workers:
|
|
1051
|
+
logger.warning(f"Worker for queue '{queue}' already running")
|
|
1052
|
+
return
|
|
1053
|
+
|
|
1054
|
+
self._running = True
|
|
1055
|
+
|
|
1056
|
+
def worker_loop():
|
|
1057
|
+
logger.info(f"Started worker for queue '{queue}'")
|
|
1058
|
+
while self._running:
|
|
1059
|
+
try:
|
|
1060
|
+
job = self._fetch_next_job(queue)
|
|
1061
|
+
if job:
|
|
1062
|
+
# False means the job went back to the queue (no
|
|
1063
|
+
# handler here). Sleep anyway, or the loop spins on
|
|
1064
|
+
# the same row at full speed.
|
|
1065
|
+
if self._process_job(job) is False:
|
|
1066
|
+
time.sleep(poll_interval)
|
|
1067
|
+
else:
|
|
1068
|
+
time.sleep(poll_interval)
|
|
1069
|
+
except Exception as e:
|
|
1070
|
+
logger.error(f"Worker error: {e}")
|
|
1071
|
+
time.sleep(poll_interval)
|
|
1072
|
+
|
|
1073
|
+
worker = threading.Thread(target=worker_loop, daemon=True)
|
|
1074
|
+
worker.start()
|
|
1075
|
+
self._workers[queue] = worker
|
|
1076
|
+
logger.info(f"Started worker thread for queue '{queue}'")
|
|
1077
|
+
|
|
1078
|
+
def stop_workers(self):
|
|
1079
|
+
"""Stop all worker threads"""
|
|
1080
|
+
self._running = False
|
|
1081
|
+
self._workers.clear()
|
|
1082
|
+
logger.info("Stopped all job queue workers")
|
|
1083
|
+
|
|
1084
|
+
def _fetch_next_job(self, queue: str) -> Optional[JobInfo]:
|
|
1085
|
+
"""Fetch and lock the next pending job"""
|
|
1086
|
+
conn = self._get_connection()
|
|
1087
|
+
try:
|
|
1088
|
+
# Find next pending job (respecting scheduled_at and priority)
|
|
1089
|
+
row = conn.execute(
|
|
1090
|
+
"""
|
|
1091
|
+
SELECT * FROM quantum_jobs
|
|
1092
|
+
WHERE queue = ? AND status = 'pending'
|
|
1093
|
+
AND (scheduled_at IS NULL OR scheduled_at <= ?)
|
|
1094
|
+
ORDER BY priority DESC, created_at ASC
|
|
1095
|
+
LIMIT 1
|
|
1096
|
+
""",
|
|
1097
|
+
(queue, datetime.now())
|
|
1098
|
+
).fetchone()
|
|
1099
|
+
|
|
1100
|
+
if not row:
|
|
1101
|
+
return None
|
|
1102
|
+
|
|
1103
|
+
# Mark as running
|
|
1104
|
+
conn.execute(
|
|
1105
|
+
"UPDATE quantum_jobs SET status = 'running', started_at = ? WHERE id = ?",
|
|
1106
|
+
(datetime.now(), row['id'])
|
|
1107
|
+
)
|
|
1108
|
+
conn.commit()
|
|
1109
|
+
|
|
1110
|
+
return self._row_to_job_info(row)
|
|
1111
|
+
finally:
|
|
1112
|
+
conn.close()
|
|
1113
|
+
|
|
1114
|
+
def _process_job(self, job: JobInfo):
|
|
1115
|
+
"""Process a job"""
|
|
1116
|
+
handler = self._handlers.get(job.name)
|
|
1117
|
+
if not handler:
|
|
1118
|
+
# Return it to the queue rather than failing it.
|
|
1119
|
+
#
|
|
1120
|
+
# Handlers are registered by EXECUTING the .q file that declares
|
|
1121
|
+
# <q:job>. A standalone `quantum jobs worker start` process has an
|
|
1122
|
+
# empty handler table, so this used to mark every pending job
|
|
1123
|
+
# 'failed' on the first poll — permanently destroying queued work
|
|
1124
|
+
# that another worker (or the same one, after loading the app)
|
|
1125
|
+
# could have run. A worker that cannot do a job has not learned
|
|
1126
|
+
# that the job is bad.
|
|
1127
|
+
logger.warning(
|
|
1128
|
+
"No handler registered for job '%s' (id=%s) in this process; "
|
|
1129
|
+
"leaving it pending. Run the .q that declares <q:job name=\"%s\">, "
|
|
1130
|
+
"or start the worker from that application.",
|
|
1131
|
+
job.name, job.id, job.name
|
|
1132
|
+
)
|
|
1133
|
+
self._release_job(job.id)
|
|
1134
|
+
return False
|
|
1135
|
+
|
|
1136
|
+
try:
|
|
1137
|
+
# Execute handler
|
|
1138
|
+
handler(job.params)
|
|
1139
|
+
|
|
1140
|
+
# Mark as completed
|
|
1141
|
+
conn = self._get_connection()
|
|
1142
|
+
try:
|
|
1143
|
+
conn.execute(
|
|
1144
|
+
"UPDATE quantum_jobs SET status = 'completed', completed_at = ? WHERE id = ?",
|
|
1145
|
+
(datetime.now(), job.id)
|
|
1146
|
+
)
|
|
1147
|
+
conn.commit()
|
|
1148
|
+
finally:
|
|
1149
|
+
conn.close()
|
|
1150
|
+
|
|
1151
|
+
logger.info(f"Completed job '{job.name}' (id={job.id})")
|
|
1152
|
+
return True
|
|
1153
|
+
|
|
1154
|
+
except Exception as e:
|
|
1155
|
+
logger.error(f"Job '{job.name}' (id={job.id}) failed: {e}")
|
|
1156
|
+
self._handle_job_failure(job, str(e))
|
|
1157
|
+
return True
|
|
1158
|
+
|
|
1159
|
+
def _release_job(self, job_id: int):
|
|
1160
|
+
"""Put a job the worker locked back into 'pending'.
|
|
1161
|
+
|
|
1162
|
+
_fetch_next_job marks a job 'running' before handing it over. If this
|
|
1163
|
+
process cannot run it, the row must go back to 'pending' or it is
|
|
1164
|
+
stranded in 'running' forever.
|
|
1165
|
+
"""
|
|
1166
|
+
conn = self._get_connection()
|
|
1167
|
+
try:
|
|
1168
|
+
conn.execute(
|
|
1169
|
+
"UPDATE quantum_jobs SET status = 'pending', started_at = NULL "
|
|
1170
|
+
"WHERE id = ? AND status = 'running'",
|
|
1171
|
+
(job_id,)
|
|
1172
|
+
)
|
|
1173
|
+
conn.commit()
|
|
1174
|
+
finally:
|
|
1175
|
+
conn.close()
|
|
1176
|
+
|
|
1177
|
+
def _handle_job_failure(self, job: JobInfo, error: str):
|
|
1178
|
+
"""Handle job failure with retry logic"""
|
|
1179
|
+
conn = self._get_connection()
|
|
1180
|
+
try:
|
|
1181
|
+
# Get current job state
|
|
1182
|
+
row = conn.execute(
|
|
1183
|
+
"SELECT attempts, max_attempts, backoff_seconds FROM quantum_jobs WHERE id = ?",
|
|
1184
|
+
(job.id,)
|
|
1185
|
+
).fetchone()
|
|
1186
|
+
|
|
1187
|
+
if not row:
|
|
1188
|
+
return
|
|
1189
|
+
|
|
1190
|
+
attempts = row['attempts'] + 1
|
|
1191
|
+
max_attempts = row['max_attempts']
|
|
1192
|
+
backoff_seconds = row['backoff_seconds']
|
|
1193
|
+
|
|
1194
|
+
if attempts < max_attempts:
|
|
1195
|
+
# Schedule retry with exponential backoff
|
|
1196
|
+
delay = backoff_seconds * (2 ** (attempts - 1))
|
|
1197
|
+
scheduled_at = datetime.now() + timedelta(seconds=delay)
|
|
1198
|
+
|
|
1199
|
+
conn.execute(
|
|
1200
|
+
"""
|
|
1201
|
+
UPDATE quantum_jobs
|
|
1202
|
+
SET status = 'pending', attempts = ?, scheduled_at = ?, error = ?
|
|
1203
|
+
WHERE id = ?
|
|
1204
|
+
""",
|
|
1205
|
+
(attempts, scheduled_at, error, job.id)
|
|
1206
|
+
)
|
|
1207
|
+
logger.info(f"Job {job.id} will retry in {format_duration(delay)}")
|
|
1208
|
+
else:
|
|
1209
|
+
# Max retries exceeded
|
|
1210
|
+
conn.execute(
|
|
1211
|
+
"""
|
|
1212
|
+
UPDATE quantum_jobs
|
|
1213
|
+
SET status = 'failed', attempts = ?, completed_at = ?, error = ?
|
|
1214
|
+
WHERE id = ?
|
|
1215
|
+
""",
|
|
1216
|
+
(attempts, datetime.now(), error, job.id)
|
|
1217
|
+
)
|
|
1218
|
+
logger.error(f"Job {job.id} failed after {attempts} attempts")
|
|
1219
|
+
|
|
1220
|
+
conn.commit()
|
|
1221
|
+
finally:
|
|
1222
|
+
conn.close()
|
|
1223
|
+
|
|
1224
|
+
def _fail_job(self, job_id: int, error: str):
|
|
1225
|
+
"""Mark a job as failed"""
|
|
1226
|
+
conn = self._get_connection()
|
|
1227
|
+
try:
|
|
1228
|
+
conn.execute(
|
|
1229
|
+
"UPDATE quantum_jobs SET status = 'failed', completed_at = ?, error = ? WHERE id = ?",
|
|
1230
|
+
(datetime.now(), error, job_id)
|
|
1231
|
+
)
|
|
1232
|
+
conn.commit()
|
|
1233
|
+
finally:
|
|
1234
|
+
conn.close()
|
|
1235
|
+
|
|
1236
|
+
def _row_to_job_info(self, row: sqlite3.Row) -> JobInfo:
|
|
1237
|
+
"""Convert database row to JobInfo"""
|
|
1238
|
+
params = {}
|
|
1239
|
+
if row['params']:
|
|
1240
|
+
try:
|
|
1241
|
+
params = json.loads(row['params'])
|
|
1242
|
+
except json.JSONDecodeError:
|
|
1243
|
+
pass
|
|
1244
|
+
|
|
1245
|
+
def parse_datetime(val):
|
|
1246
|
+
if val:
|
|
1247
|
+
if isinstance(val, str):
|
|
1248
|
+
return datetime.fromisoformat(val)
|
|
1249
|
+
return val
|
|
1250
|
+
return None
|
|
1251
|
+
|
|
1252
|
+
return JobInfo(
|
|
1253
|
+
id=row['id'],
|
|
1254
|
+
name=row['name'],
|
|
1255
|
+
queue=row['queue'],
|
|
1256
|
+
params=params,
|
|
1257
|
+
status=row['status'],
|
|
1258
|
+
attempts=row['attempts'],
|
|
1259
|
+
max_attempts=row['max_attempts'],
|
|
1260
|
+
scheduled_at=parse_datetime(row['scheduled_at']),
|
|
1261
|
+
started_at=parse_datetime(row['started_at']),
|
|
1262
|
+
completed_at=parse_datetime(row['completed_at']),
|
|
1263
|
+
error=row['error'],
|
|
1264
|
+
created_at=parse_datetime(row['created_at'])
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
def get_queue_stats(self, queue: str = 'default') -> Dict[str, int]:
|
|
1268
|
+
"""
|
|
1269
|
+
Get queue statistics.
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
queue: Queue name
|
|
1273
|
+
|
|
1274
|
+
Returns:
|
|
1275
|
+
Dict with status counts
|
|
1276
|
+
"""
|
|
1277
|
+
conn = self._get_connection()
|
|
1278
|
+
try:
|
|
1279
|
+
rows = conn.execute(
|
|
1280
|
+
"""
|
|
1281
|
+
SELECT status, COUNT(*) as count
|
|
1282
|
+
FROM quantum_jobs
|
|
1283
|
+
WHERE queue = ?
|
|
1284
|
+
GROUP BY status
|
|
1285
|
+
""",
|
|
1286
|
+
(queue,)
|
|
1287
|
+
).fetchall()
|
|
1288
|
+
|
|
1289
|
+
stats = {'pending': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
|
1290
|
+
for row in rows:
|
|
1291
|
+
stats[row['status']] = row['count']
|
|
1292
|
+
return stats
|
|
1293
|
+
finally:
|
|
1294
|
+
conn.close()
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
# ============================================
|
|
1298
|
+
# JOB EXECUTOR (Unified Interface)
|
|
1299
|
+
# ============================================
|
|
1300
|
+
|
|
1301
|
+
class JobExecutor:
|
|
1302
|
+
"""
|
|
1303
|
+
Unified interface for job execution services.
|
|
1304
|
+
|
|
1305
|
+
Provides access to:
|
|
1306
|
+
- ScheduleService for scheduled tasks
|
|
1307
|
+
- ThreadService for async threads
|
|
1308
|
+
- JobQueueService for job queues
|
|
1309
|
+
"""
|
|
1310
|
+
|
|
1311
|
+
def __init__(
|
|
1312
|
+
self,
|
|
1313
|
+
max_thread_workers: int = 10,
|
|
1314
|
+
job_db_path: str = "quantum_jobs.db"
|
|
1315
|
+
):
|
|
1316
|
+
"""
|
|
1317
|
+
Initialize job executor.
|
|
1318
|
+
|
|
1319
|
+
Args:
|
|
1320
|
+
max_thread_workers: Maximum concurrent threads
|
|
1321
|
+
job_db_path: Path to job queue database
|
|
1322
|
+
"""
|
|
1323
|
+
self.schedule = ScheduleService()
|
|
1324
|
+
self.thread = ThreadService(max_workers=max_thread_workers)
|
|
1325
|
+
self.job_queue = JobQueueService(db_path=job_db_path)
|
|
1326
|
+
self._worker_running = False
|
|
1327
|
+
|
|
1328
|
+
def shutdown(self, wait: bool = True):
|
|
1329
|
+
"""Shutdown all services"""
|
|
1330
|
+
self.schedule.shutdown()
|
|
1331
|
+
self.thread.shutdown(wait=wait)
|
|
1332
|
+
self.job_queue.stop_workers()
|
|
1333
|
+
logger.info("Job executor shutdown complete")
|
|
1334
|
+
|
|
1335
|
+
# ========================================
|
|
1336
|
+
# CLI Interface Methods
|
|
1337
|
+
# ========================================
|
|
1338
|
+
|
|
1339
|
+
def list_jobs(self, status: Optional[str] = None, queue: Optional[str] = None) -> List[Dict]:
|
|
1340
|
+
"""
|
|
1341
|
+
List all jobs (schedules and queued jobs).
|
|
1342
|
+
|
|
1343
|
+
Args:
|
|
1344
|
+
status: Filter by status
|
|
1345
|
+
queue: Filter by queue
|
|
1346
|
+
|
|
1347
|
+
Returns:
|
|
1348
|
+
List of job dicts
|
|
1349
|
+
"""
|
|
1350
|
+
jobs = []
|
|
1351
|
+
|
|
1352
|
+
# Add scheduled jobs
|
|
1353
|
+
for sched in self.schedule.list_schedules():
|
|
1354
|
+
job_status = 'active' if sched.enabled else 'paused'
|
|
1355
|
+
if status and status != job_status:
|
|
1356
|
+
continue
|
|
1357
|
+
jobs.append({
|
|
1358
|
+
'name': sched.name,
|
|
1359
|
+
'type': 'schedule',
|
|
1360
|
+
'queue': 'scheduler',
|
|
1361
|
+
'status': job_status,
|
|
1362
|
+
'interval': sched.trigger_info,
|
|
1363
|
+
'next_run': sched.next_run,
|
|
1364
|
+
'last_run': sched.last_run,
|
|
1365
|
+
'run_count': sched.run_count,
|
|
1366
|
+
'last_error': sched.last_error,
|
|
1367
|
+
})
|
|
1368
|
+
|
|
1369
|
+
# Add queued jobs
|
|
1370
|
+
queued_jobs = self.job_queue.list_jobs(queue=queue, status=status)
|
|
1371
|
+
for job in queued_jobs:
|
|
1372
|
+
jobs.append({
|
|
1373
|
+
'name': job.name,
|
|
1374
|
+
'type': 'job',
|
|
1375
|
+
'queue': job.queue,
|
|
1376
|
+
'status': job.status,
|
|
1377
|
+
'next_run': job.scheduled_at,
|
|
1378
|
+
'last_run': job.completed_at,
|
|
1379
|
+
'attempts': job.attempts,
|
|
1380
|
+
'error': job.error,
|
|
1381
|
+
})
|
|
1382
|
+
|
|
1383
|
+
return jobs
|
|
1384
|
+
|
|
1385
|
+
def get_job_status(self, name: str) -> Optional[Dict]:
|
|
1386
|
+
"""
|
|
1387
|
+
Get detailed status of a job.
|
|
1388
|
+
|
|
1389
|
+
Args:
|
|
1390
|
+
name: Job name
|
|
1391
|
+
|
|
1392
|
+
Returns:
|
|
1393
|
+
Job details dict or None
|
|
1394
|
+
"""
|
|
1395
|
+
# Check schedules first
|
|
1396
|
+
sched = self.schedule.get_schedule(name)
|
|
1397
|
+
if sched:
|
|
1398
|
+
return {
|
|
1399
|
+
'name': sched.name,
|
|
1400
|
+
'type': 'schedule',
|
|
1401
|
+
'status': 'active' if sched.enabled else 'paused',
|
|
1402
|
+
'trigger_type': sched.trigger_type,
|
|
1403
|
+
'interval': sched.trigger_info,
|
|
1404
|
+
'next_run': sched.next_run,
|
|
1405
|
+
'last_run': sched.last_run,
|
|
1406
|
+
'run_count': sched.run_count,
|
|
1407
|
+
'last_error': sched.last_error,
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
# Check queued jobs
|
|
1411
|
+
jobs = self.job_queue.list_jobs()
|
|
1412
|
+
for job in jobs:
|
|
1413
|
+
if job.name == name:
|
|
1414
|
+
return {
|
|
1415
|
+
'name': job.name,
|
|
1416
|
+
'type': 'job',
|
|
1417
|
+
'queue': job.queue,
|
|
1418
|
+
'status': job.status,
|
|
1419
|
+
'params': job.params,
|
|
1420
|
+
'attempts': job.attempts,
|
|
1421
|
+
'max_attempts': job.max_attempts,
|
|
1422
|
+
'scheduled_at': job.scheduled_at,
|
|
1423
|
+
'started_at': job.started_at,
|
|
1424
|
+
'completed_at': job.completed_at,
|
|
1425
|
+
'error': job.error,
|
|
1426
|
+
'created_at': job.created_at,
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
return None
|
|
1430
|
+
|
|
1431
|
+
def run_job_now(self, name: str, params: Optional[Dict] = None, wait: bool = False) -> Dict:
|
|
1432
|
+
"""
|
|
1433
|
+
Run a job immediately.
|
|
1434
|
+
|
|
1435
|
+
Args:
|
|
1436
|
+
name: Job name
|
|
1437
|
+
params: Optional parameters
|
|
1438
|
+
wait: Wait for completion
|
|
1439
|
+
|
|
1440
|
+
Returns:
|
|
1441
|
+
Result dict with success/error
|
|
1442
|
+
"""
|
|
1443
|
+
# Check if it's a schedule
|
|
1444
|
+
if self.schedule.run_now(name):
|
|
1445
|
+
return {'success': True, 'type': 'schedule'}
|
|
1446
|
+
|
|
1447
|
+
# Otherwise dispatch to queue
|
|
1448
|
+
try:
|
|
1449
|
+
job_id = self.job_queue.dispatch(name, params=params or {})
|
|
1450
|
+
result = {'success': True, 'type': 'job', 'job_id': job_id}
|
|
1451
|
+
|
|
1452
|
+
if wait:
|
|
1453
|
+
# Poll for completion
|
|
1454
|
+
import time
|
|
1455
|
+
for _ in range(60): # Max 60 seconds
|
|
1456
|
+
job = self.job_queue.get_job(job_id)
|
|
1457
|
+
if job and job.status in ('completed', 'failed'):
|
|
1458
|
+
result['success'] = job.status == 'completed'
|
|
1459
|
+
result['result'] = job.params
|
|
1460
|
+
if job.error:
|
|
1461
|
+
result['error'] = job.error
|
|
1462
|
+
break
|
|
1463
|
+
time.sleep(1)
|
|
1464
|
+
|
|
1465
|
+
return result
|
|
1466
|
+
except Exception as e:
|
|
1467
|
+
return {'success': False, 'error': str(e)}
|
|
1468
|
+
|
|
1469
|
+
def pause_job(self, name: str) -> bool:
|
|
1470
|
+
"""Pause a scheduled job."""
|
|
1471
|
+
return self.schedule.pause_schedule(name)
|
|
1472
|
+
|
|
1473
|
+
def resume_job(self, name: str) -> bool:
|
|
1474
|
+
"""Resume a paused job."""
|
|
1475
|
+
return self.schedule.resume_schedule(name)
|
|
1476
|
+
|
|
1477
|
+
def cancel_job(self, name: str) -> bool:
|
|
1478
|
+
"""Cancel a job (schedule or queued)."""
|
|
1479
|
+
# Try schedule first
|
|
1480
|
+
if self.schedule.remove_schedule(name):
|
|
1481
|
+
return True
|
|
1482
|
+
|
|
1483
|
+
# Try queued jobs
|
|
1484
|
+
jobs = self.job_queue.list_jobs(status='pending')
|
|
1485
|
+
for job in jobs:
|
|
1486
|
+
if job.name == name:
|
|
1487
|
+
return self.job_queue.cancel_job(job.id)
|
|
1488
|
+
|
|
1489
|
+
return False
|
|
1490
|
+
|
|
1491
|
+
def get_job_history(
|
|
1492
|
+
self,
|
|
1493
|
+
name: Optional[str] = None,
|
|
1494
|
+
limit: int = 10,
|
|
1495
|
+
status: Optional[str] = None
|
|
1496
|
+
) -> List[Dict]:
|
|
1497
|
+
"""
|
|
1498
|
+
Get job execution history.
|
|
1499
|
+
|
|
1500
|
+
Args:
|
|
1501
|
+
name: Filter by job name
|
|
1502
|
+
limit: Maximum entries
|
|
1503
|
+
status: Filter by status
|
|
1504
|
+
|
|
1505
|
+
Returns:
|
|
1506
|
+
List of history entries
|
|
1507
|
+
"""
|
|
1508
|
+
# Get completed/failed jobs from queue
|
|
1509
|
+
jobs = self.job_queue.list_jobs(status=status, limit=limit)
|
|
1510
|
+
|
|
1511
|
+
history = []
|
|
1512
|
+
for job in jobs:
|
|
1513
|
+
if name and job.name != name:
|
|
1514
|
+
continue
|
|
1515
|
+
|
|
1516
|
+
duration = None
|
|
1517
|
+
if job.started_at and job.completed_at:
|
|
1518
|
+
duration = (job.completed_at - job.started_at).total_seconds()
|
|
1519
|
+
|
|
1520
|
+
history.append({
|
|
1521
|
+
'name': job.name,
|
|
1522
|
+
'queue': job.queue,
|
|
1523
|
+
'status': job.status,
|
|
1524
|
+
'started_at': job.started_at,
|
|
1525
|
+
'completed_at': job.completed_at,
|
|
1526
|
+
'duration': duration,
|
|
1527
|
+
'attempts': job.attempts,
|
|
1528
|
+
'error': job.error,
|
|
1529
|
+
})
|
|
1530
|
+
|
|
1531
|
+
return history[:limit]
|
|
1532
|
+
|
|
1533
|
+
def list_queues(self) -> List[Dict]:
|
|
1534
|
+
"""List all known queues with stats."""
|
|
1535
|
+
# Get unique queues from jobs
|
|
1536
|
+
all_jobs = self.job_queue.list_jobs(limit=1000)
|
|
1537
|
+
queues = {}
|
|
1538
|
+
|
|
1539
|
+
for job in all_jobs:
|
|
1540
|
+
if job.queue not in queues:
|
|
1541
|
+
queues[job.queue] = {
|
|
1542
|
+
'name': job.queue,
|
|
1543
|
+
'pending': 0,
|
|
1544
|
+
'running': 0,
|
|
1545
|
+
'completed': 0,
|
|
1546
|
+
'failed': 0
|
|
1547
|
+
}
|
|
1548
|
+
queues[job.queue][job.status] = queues[job.queue].get(job.status, 0) + 1
|
|
1549
|
+
|
|
1550
|
+
return list(queues.values()) if queues else [{'name': 'default', 'pending': 0, 'running': 0, 'completed': 0, 'failed': 0}]
|
|
1551
|
+
|
|
1552
|
+
def purge_queue(self, queue: str, status: Optional[str] = None) -> int:
|
|
1553
|
+
"""Purge jobs from a queue."""
|
|
1554
|
+
return self.job_queue.purge_queue(queue, status=status)
|
|
1555
|
+
|
|
1556
|
+
def get_queue_stats(self, queue: Optional[str] = None) -> Dict:
|
|
1557
|
+
"""Get queue statistics."""
|
|
1558
|
+
if queue:
|
|
1559
|
+
return self.job_queue.get_queue_stats(queue)
|
|
1560
|
+
|
|
1561
|
+
# Aggregate all queues
|
|
1562
|
+
all_stats = {'total_jobs': 0, 'pending': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
|
1563
|
+
for q in self.list_queues():
|
|
1564
|
+
for key in ['pending', 'running', 'completed', 'failed']:
|
|
1565
|
+
all_stats[key] += q.get(key, 0)
|
|
1566
|
+
all_stats['total_jobs'] += q.get(key, 0)
|
|
1567
|
+
return all_stats
|
|
1568
|
+
|
|
1569
|
+
def start_worker(
|
|
1570
|
+
self,
|
|
1571
|
+
queues: List[str],
|
|
1572
|
+
concurrency: int = 4,
|
|
1573
|
+
poll_interval: float = 1.0
|
|
1574
|
+
):
|
|
1575
|
+
"""
|
|
1576
|
+
Start job workers.
|
|
1577
|
+
|
|
1578
|
+
Args:
|
|
1579
|
+
queues: List of queues to process
|
|
1580
|
+
concurrency: Workers per queue
|
|
1581
|
+
poll_interval: Poll interval in seconds
|
|
1582
|
+
"""
|
|
1583
|
+
self._worker_running = True
|
|
1584
|
+
for queue in queues:
|
|
1585
|
+
for _ in range(concurrency):
|
|
1586
|
+
self.job_queue.start_worker(queue, poll_interval)
|
|
1587
|
+
|
|
1588
|
+
# Keep main thread alive
|
|
1589
|
+
import time
|
|
1590
|
+
try:
|
|
1591
|
+
while self._worker_running:
|
|
1592
|
+
time.sleep(1)
|
|
1593
|
+
except KeyboardInterrupt:
|
|
1594
|
+
pass
|
|
1595
|
+
|
|
1596
|
+
def stop_worker(self, graceful: bool = True):
|
|
1597
|
+
"""Stop all workers."""
|
|
1598
|
+
self._worker_running = False
|
|
1599
|
+
self.job_queue.stop_workers()
|