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.
Files changed (262) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/commands/__init__.py +15 -0
  4. quantum/cli/commands/build.py +417 -0
  5. quantum/cli/commands/dev.py +185 -0
  6. quantum/cli/commands/docs.py +329 -0
  7. quantum/cli/commands/lint.py +523 -0
  8. quantum/cli/commands/migrate.py +527 -0
  9. quantum/cli/commands/new.py +622 -0
  10. quantum/cli/commands/serve.py +190 -0
  11. quantum/cli/commands/test.py +193 -0
  12. quantum/cli/deploy.py +810 -0
  13. quantum/cli/hot_reload.py +951 -0
  14. quantum/cli/jobs.py +356 -0
  15. quantum/cli/mq.py +582 -0
  16. quantum/cli/pkg.py +390 -0
  17. quantum/cli/runner.py +547 -0
  18. quantum/cli/server_process.py +159 -0
  19. quantum/cli/utils.py +334 -0
  20. quantum/compiler/__init__.py +30 -0
  21. quantum/compiler/base_generator.py +367 -0
  22. quantum/compiler/cli.py +295 -0
  23. quantum/compiler/expression_transformer.py +444 -0
  24. quantum/compiler/javascript/__init__.py +10 -0
  25. quantum/compiler/javascript/generator.py +659 -0
  26. quantum/compiler/optimizer.py +270 -0
  27. quantum/compiler/python/__init__.py +10 -0
  28. quantum/compiler/python/generator.py +883 -0
  29. quantum/compiler/python/runtime.py +863 -0
  30. quantum/compiler/transpiler.py +330 -0
  31. quantum/core/__init__.py +3 -0
  32. quantum/core/ast_nodes.py +2611 -0
  33. quantum/core/expression_diagnostics.py +87 -0
  34. quantum/core/expression_stdlib.py +148 -0
  35. quantum/core/expressions.py +591 -0
  36. quantum/core/features/agents/src/__init__.py +30 -0
  37. quantum/core/features/agents/src/ast_node.py +540 -0
  38. quantum/core/features/conditionals/src/__init__.py +8 -0
  39. quantum/core/features/conditionals/src/ast_node.py +68 -0
  40. quantum/core/features/data_fetching/src/__init__.py +21 -0
  41. quantum/core/features/data_fetching/src/ast_node.py +312 -0
  42. quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
  43. quantum/core/features/data_fetching/src/html_adapter.py +474 -0
  44. quantum/core/features/data_fetching/src/parser.py +225 -0
  45. quantum/core/features/data_import/src/__init__.py +0 -0
  46. quantum/core/features/data_import/src/ast_node.py +291 -0
  47. quantum/core/features/data_import/src/runtime.py +538 -0
  48. quantum/core/features/dump/src/__init__.py +12 -0
  49. quantum/core/features/dump/src/ast_node.py +106 -0
  50. quantum/core/features/dump/src/parser.py +61 -0
  51. quantum/core/features/dump/src/runtime.py +246 -0
  52. quantum/core/features/functions/src/__init__.py +8 -0
  53. quantum/core/features/functions/src/ast_node.py +149 -0
  54. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  55. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  56. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  57. quantum/core/features/invocation/src/__init__.py +0 -0
  58. quantum/core/features/invocation/src/ast_node.py +146 -0
  59. quantum/core/features/invocation/src/runtime.py +327 -0
  60. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  61. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  62. quantum/core/features/knowledge_base/src/parser.py +82 -0
  63. quantum/core/features/logging/src/__init__.py +12 -0
  64. quantum/core/features/logging/src/ast_node.py +111 -0
  65. quantum/core/features/logging/src/parser.py +50 -0
  66. quantum/core/features/logging/src/runtime.py +190 -0
  67. quantum/core/features/loops/src/__init__.py +8 -0
  68. quantum/core/features/loops/src/ast_node.py +60 -0
  69. quantum/core/features/query/src/__init__.py +0 -0
  70. quantum/core/features/query/src/database_service.py +322 -0
  71. quantum/core/features/query/src/query_validators.py +20 -0
  72. quantum/core/features/state_management/src/__init__.py +11 -0
  73. quantum/core/features/state_management/src/ast_node.py +228 -0
  74. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  75. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  76. quantum/core/features/terminal_engine/src/parser.py +361 -0
  77. quantum/core/features/testing_engine/src/__init__.py +41 -0
  78. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  79. quantum/core/features/testing_engine/src/parser.py +604 -0
  80. quantum/core/features/theming/src/__init__.py +48 -0
  81. quantum/core/features/theming/src/ast_node.py +137 -0
  82. quantum/core/features/theming/src/presets.py +405 -0
  83. quantum/core/features/ui_engine/src/__init__.py +20 -0
  84. quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
  85. quantum/core/features/ui_engine/src/parser.py +1106 -0
  86. quantum/core/features/websocket/src/__init__.py +24 -0
  87. quantum/core/features/websocket/src/ast_node.py +247 -0
  88. quantum/core/html_compat.py +299 -0
  89. quantum/core/parser.py +1235 -0
  90. quantum/core/parser_registry.py +213 -0
  91. quantum/core/parsers/__init__.py +76 -0
  92. quantum/core/parsers/ai/__init__.py +12 -0
  93. quantum/core/parsers/ai/agent_parser.py +106 -0
  94. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  95. quantum/core/parsers/ai/llm_parser.py +78 -0
  96. quantum/core/parsers/ai/team_parser.py +88 -0
  97. quantum/core/parsers/base.py +322 -0
  98. quantum/core/parsers/composition/__init__.py +10 -0
  99. quantum/core/parsers/composition/import_parser.py +50 -0
  100. quantum/core/parsers/composition/slot_parser.py +48 -0
  101. quantum/core/parsers/control_flow/__init__.py +11 -0
  102. quantum/core/parsers/control_flow/if_parser.py +68 -0
  103. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  104. quantum/core/parsers/control_flow/set_parser.py +89 -0
  105. quantum/core/parsers/data/__init__.py +12 -0
  106. quantum/core/parsers/data/data_parser.py +217 -0
  107. quantum/core/parsers/data/invoke_parser.py +116 -0
  108. quantum/core/parsers/data/query_parser.py +188 -0
  109. quantum/core/parsers/data/transaction_parser.py +77 -0
  110. quantum/core/parsers/events/__init__.py +9 -0
  111. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  112. quantum/core/parsers/forms/__init__.py +11 -0
  113. quantum/core/parsers/forms/action_parser.py +54 -0
  114. quantum/core/parsers/forms/flash_parser.py +34 -0
  115. quantum/core/parsers/forms/redirect_parser.py +33 -0
  116. quantum/core/parsers/functions/__init__.py +11 -0
  117. quantum/core/parsers/functions/function_parser.py +137 -0
  118. quantum/core/parsers/functions/param_parser.py +66 -0
  119. quantum/core/parsers/functions/return_parser.py +31 -0
  120. quantum/core/parsers/html/__init__.py +10 -0
  121. quantum/core/parsers/html/component_call_parser.py +130 -0
  122. quantum/core/parsers/html/html_parser.py +115 -0
  123. quantum/core/parsers/jobs/__init__.py +11 -0
  124. quantum/core/parsers/jobs/job_parser.py +71 -0
  125. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  126. quantum/core/parsers/jobs/thread_parser.py +57 -0
  127. quantum/core/parsers/messaging/__init__.py +17 -0
  128. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  130. quantum/core/parsers/messaging/message_parser.py +114 -0
  131. quantum/core/parsers/messaging/queue_parser.py +61 -0
  132. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  133. quantum/core/parsers/persistence/__init__.py +9 -0
  134. quantum/core/parsers/persistence/persist_parser.py +64 -0
  135. quantum/core/parsers/routing/__init__.py +9 -0
  136. quantum/core/parsers/routing/route_parser.py +41 -0
  137. quantum/core/parsers/scripting/__init__.py +12 -0
  138. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  139. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  140. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  141. quantum/core/parsers/scripting/python_parser.py +49 -0
  142. quantum/core/parsers/services/__init__.py +12 -0
  143. quantum/core/parsers/services/dump_parser.py +52 -0
  144. quantum/core/parsers/services/file_parser.py +48 -0
  145. quantum/core/parsers/services/log_parser.py +46 -0
  146. quantum/core/parsers/services/mail_parser.py +65 -0
  147. quantum/core/tiers.py +82 -0
  148. quantum/packages/__init__.py +28 -0
  149. quantum/packages/manager.py +413 -0
  150. quantum/packages/manifest.py +351 -0
  151. quantum/packages/registry.py +399 -0
  152. quantum/packages/resolver.py +336 -0
  153. quantum/plugins/__init__.py +33 -0
  154. quantum/plugins/hooks.py +329 -0
  155. quantum/plugins/loader.py +479 -0
  156. quantum/plugins/manifest.py +336 -0
  157. quantum/plugins/registry.py +371 -0
  158. quantum/runtime/__init__.py +28 -0
  159. quantum/runtime/action_handler.py +443 -0
  160. quantum/runtime/adapters/__init__.py +88 -0
  161. quantum/runtime/adapters/memory_adapter.py +690 -0
  162. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  163. quantum/runtime/adapters/redis_adapter.py +582 -0
  164. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  165. quantum/runtime/agent_service.py +1133 -0
  166. quantum/runtime/api_server.py +86 -0
  167. quantum/runtime/ast_cache.py +506 -0
  168. quantum/runtime/auth_service.py +267 -0
  169. quantum/runtime/component.py +990 -0
  170. quantum/runtime/component_composer.py +319 -0
  171. quantum/runtime/component_resolver.py +174 -0
  172. quantum/runtime/database_service.py +598 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +286 -0
  176. quantum/runtime/executor_registry.py +171 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +262 -0
  184. quantum/runtime/executors/control_flow/__init__.py +11 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +93 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +307 -0
  187. quantum/runtime/executors/control_flow/set_executor.py +412 -0
  188. quantum/runtime/executors/data/__init__.py +12 -0
  189. quantum/runtime/executors/data/data_executor.py +145 -0
  190. quantum/runtime/executors/data/invoke_executor.py +176 -0
  191. quantum/runtime/executors/data/query_executor.py +256 -0
  192. quantum/runtime/executors/data/transaction_executor.py +91 -0
  193. quantum/runtime/executors/jobs/__init__.py +11 -0
  194. quantum/runtime/executors/jobs/job_executor.py +190 -0
  195. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  196. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  197. quantum/runtime/executors/messaging/__init__.py +17 -0
  198. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  199. quantum/runtime/executors/messaging/message_executor.py +174 -0
  200. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  201. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  202. quantum/runtime/executors/scripting/__init__.py +11 -0
  203. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  204. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  205. quantum/runtime/executors/scripting/python_executor.py +249 -0
  206. quantum/runtime/executors/services/__init__.py +12 -0
  207. quantum/runtime/executors/services/dump_executor.py +72 -0
  208. quantum/runtime/executors/services/file_executor.py +89 -0
  209. quantum/runtime/executors/services/log_executor.py +77 -0
  210. quantum/runtime/executors/services/mail_executor.py +81 -0
  211. quantum/runtime/expression_cache.py +498 -0
  212. quantum/runtime/file_upload_service.py +326 -0
  213. quantum/runtime/function_registry.py +118 -0
  214. quantum/runtime/game_builder.py +166 -0
  215. quantum/runtime/game_code_generator.py +2371 -0
  216. quantum/runtime/game_templates.py +2006 -0
  217. quantum/runtime/godot_code_generator.py +4681 -0
  218. quantum/runtime/godot_templates.py +1449 -0
  219. quantum/runtime/job_executor.py +1599 -0
  220. quantum/runtime/knowledge_service.py +500 -0
  221. quantum/runtime/llm_cache.py +100 -0
  222. quantum/runtime/llm_providers.py +704 -0
  223. quantum/runtime/llm_service.py +287 -0
  224. quantum/runtime/logging_setup.py +140 -0
  225. quantum/runtime/message_broker.py +364 -0
  226. quantum/runtime/message_queue_service.py +571 -0
  227. quantum/runtime/param_validation.py +184 -0
  228. quantum/runtime/pypy_compat.py +315 -0
  229. quantum/runtime/python_bridge.py +698 -0
  230. quantum/runtime/query_validators.py +304 -0
  231. quantum/runtime/renderer.py +733 -0
  232. quantum/runtime/service_container.py +444 -0
  233. quantum/runtime/terminal_builder.py +76 -0
  234. quantum/runtime/terminal_code_generator.py +607 -0
  235. quantum/runtime/terminal_templates.py +243 -0
  236. quantum/runtime/testing_builder.py +77 -0
  237. quantum/runtime/testing_code_generator.py +833 -0
  238. quantum/runtime/testing_templates.py +85 -0
  239. quantum/runtime/ui_builder.py +188 -0
  240. quantum/runtime/ui_desktop_adapter.py +1730 -0
  241. quantum/runtime/ui_desktop_templates.py +307 -0
  242. quantum/runtime/ui_html_adapter.py +2691 -0
  243. quantum/runtime/ui_html_templates.py +2297 -0
  244. quantum/runtime/ui_mobile_adapter.py +1832 -0
  245. quantum/runtime/ui_mobile_templates.py +1003 -0
  246. quantum/runtime/ui_textual_adapter.py +1866 -0
  247. quantum/runtime/ui_textual_templates.py +45 -0
  248. quantum/runtime/ui_tokens.py +465 -0
  249. quantum/runtime/ui_validator.py +365 -0
  250. quantum/runtime/validators.py +256 -0
  251. quantum/runtime/web_server.py +1766 -0
  252. quantum/runtime/websocket_adapter.py +501 -0
  253. quantum/runtime/websocket_service.py +585 -0
  254. quantum/runtime/websocket_transport.py +289 -0
  255. quantum/runtime/wsgi.py +101 -0
  256. quantum/utils/__init__.py +1 -0
  257. quantum_framework-0.9.0.dist-info/METADATA +244 -0
  258. quantum_framework-0.9.0.dist-info/RECORD +262 -0
  259. quantum_framework-0.9.0.dist-info/WHEEL +5 -0
  260. quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
  261. quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
  262. 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()