nat-engine 1__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 (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,894 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Job queue backends for the NAT worker pool.
7
+
8
+ Provides a ``JobQueue`` abstract base class and three concrete
9
+ implementations:
10
+
11
+ * :class:`InMemoryJobQueue` — in-process queue for local/dev mode.
12
+ * :class:`SQLiteJobQueue` — lightweight persistent queue backed by SQLite.
13
+ * :class:`RedisJobQueue` — production-grade distributed queue backed by Redis.
14
+
15
+ All implementations share the same interface::
16
+
17
+ queue = InMemoryJobQueue()
18
+ job_id = await queue.enqueue("scan", {"scan_id": "abc", "base_url": "…"})
19
+ job = await queue.dequeue() # returns a :class:`Job` or None
20
+ await queue.ack(job.job_id)
21
+ status = await queue.status() # returns :class:`QueueStats`
22
+
23
+ Job states
24
+ ----------
25
+ ``pending`` → ``running`` → ``completed``
26
+ ↘ ``failed``
27
+ ↘ ``retrying``
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import json
34
+ import logging
35
+ import sqlite3
36
+ import threading
37
+ import time
38
+ import uuid
39
+ from abc import ABC, abstractmethod
40
+ from dataclasses import dataclass, field
41
+ from datetime import datetime, timezone
42
+ from typing import Any, Dict, List, Optional
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Data models
48
+ # ---------------------------------------------------------------------------
49
+
50
+ #: All valid job state labels.
51
+ JOB_STATES = ("pending", "running", "completed", "failed", "retrying")
52
+
53
+
54
+ @dataclass
55
+ class Job:
56
+ """A single unit of work managed by the queue.
57
+
58
+ Parameters
59
+ ----------
60
+ job_id:
61
+ Unique identifier for this job (UUID4 string).
62
+ job_type:
63
+ Logical job type, e.g. ``"scan"``, ``"security_scan"``,
64
+ ``"autonomous_sweep"``.
65
+ payload:
66
+ Arbitrary serialisable dict of job parameters.
67
+ status:
68
+ Current state; one of ``pending``, ``running``, ``completed``,
69
+ ``failed``, ``retrying``.
70
+ created_at:
71
+ ISO-8601 UTC timestamp at which the job was enqueued.
72
+ updated_at:
73
+ ISO-8601 UTC timestamp at which the job state last changed.
74
+ worker_id:
75
+ ID of the worker that claimed this job, if any.
76
+ attempt:
77
+ Number of times this job has been attempted (starts at 0).
78
+ error:
79
+ Last error message if the job is in ``failed`` / ``retrying`` state.
80
+ dedup_key:
81
+ Optional deduplication key. A second ``enqueue()`` call with the same
82
+ key will be silently dropped when the job is still pending/running.
83
+ """
84
+
85
+ job_id: str
86
+ job_type: str
87
+ payload: Dict[str, Any]
88
+ status: str = "pending"
89
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
90
+ updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
91
+ worker_id: Optional[str] = None
92
+ attempt: int = 0
93
+ error: Optional[str] = None
94
+ dedup_key: Optional[str] = None
95
+
96
+ def to_dict(self) -> Dict[str, Any]:
97
+ return {
98
+ "job_id": self.job_id,
99
+ "job_type": self.job_type,
100
+ "payload": self.payload,
101
+ "status": self.status,
102
+ "created_at": self.created_at,
103
+ "updated_at": self.updated_at,
104
+ "worker_id": self.worker_id,
105
+ "attempt": self.attempt,
106
+ "error": self.error,
107
+ "dedup_key": self.dedup_key,
108
+ }
109
+
110
+ @classmethod
111
+ def from_dict(cls, d: Dict[str, Any]) -> "Job":
112
+ return cls(
113
+ job_id=d["job_id"],
114
+ job_type=d["job_type"],
115
+ payload=d.get("payload") or {},
116
+ status=d.get("status", "pending"),
117
+ created_at=d.get("created_at", datetime.now(timezone.utc).isoformat()),
118
+ updated_at=d.get("updated_at", datetime.now(timezone.utc).isoformat()),
119
+ worker_id=d.get("worker_id"),
120
+ attempt=int(d.get("attempt", 0)),
121
+ error=d.get("error"),
122
+ dedup_key=d.get("dedup_key"),
123
+ )
124
+
125
+
126
+ @dataclass
127
+ class QueueStats:
128
+ """Snapshot of queue depth by state."""
129
+
130
+ pending: int = 0
131
+ running: int = 0
132
+ completed: int = 0
133
+ failed: int = 0
134
+ retrying: int = 0
135
+
136
+ @property
137
+ def total(self) -> int:
138
+ return self.pending + self.running + self.completed + self.failed + self.retrying
139
+
140
+ def to_dict(self) -> Dict[str, Any]:
141
+ return {
142
+ "pending": self.pending,
143
+ "running": self.running,
144
+ "completed": self.completed,
145
+ "failed": self.failed,
146
+ "retrying": self.retrying,
147
+ "total": self.total,
148
+ }
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Abstract interface
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ class JobQueue(ABC):
157
+ """Abstract job queue interface.
158
+
159
+ All mutating operations are ``async`` to support both in-memory
160
+ (asyncio-native) and I/O-backed (SQLite / Redis) implementations.
161
+ """
162
+
163
+ @abstractmethod
164
+ async def enqueue(
165
+ self,
166
+ job_type: str,
167
+ payload: Dict[str, Any],
168
+ *,
169
+ dedup_key: Optional[str] = None,
170
+ ) -> str:
171
+ """Add a job to the queue and return its ``job_id``.
172
+
173
+ If *dedup_key* is provided and an active job (``pending`` or
174
+ ``running``) with the same key already exists, the existing
175
+ ``job_id`` is returned without adding a duplicate.
176
+ """
177
+
178
+ @abstractmethod
179
+ async def dequeue(self, worker_id: Optional[str] = None) -> Optional[Job]:
180
+ """Atomically claim the next ``pending`` job and mark it ``running``.
181
+
182
+ Returns ``None`` when the queue is empty.
183
+ """
184
+
185
+ @abstractmethod
186
+ async def ack(self, job_id: str) -> None:
187
+ """Mark a job as ``completed``."""
188
+
189
+ @abstractmethod
190
+ async def nack(
191
+ self,
192
+ job_id: str,
193
+ *,
194
+ error: Optional[str] = None,
195
+ retry: bool = False,
196
+ max_attempts: int = 3,
197
+ ) -> None:
198
+ """Mark a job as ``failed`` (or ``retrying`` if *retry* is True and the
199
+ attempt count is below *max_attempts*). A retried job is moved back
200
+ to ``pending`` so it can be claimed again.
201
+ """
202
+
203
+ @abstractmethod
204
+ async def get(self, job_id: str) -> Optional[Job]:
205
+ """Retrieve a job by ID without modifying its state."""
206
+
207
+ @abstractmethod
208
+ async def list_jobs(
209
+ self,
210
+ *,
211
+ status: Optional[str] = None,
212
+ limit: int = 100,
213
+ ) -> List[Job]:
214
+ """Return a list of jobs, optionally filtered by *status*."""
215
+
216
+ @abstractmethod
217
+ async def status(self) -> QueueStats:
218
+ """Return a point-in-time snapshot of queue depth by state."""
219
+
220
+ @abstractmethod
221
+ async def purge(self, *, status: Optional[str] = None) -> int:
222
+ """Delete jobs. If *status* is given, only that state is purged.
223
+ Returns the number of deleted jobs.
224
+ """
225
+
226
+ # ------------------------------------------------------------------
227
+ # Helpers
228
+ # ------------------------------------------------------------------
229
+
230
+ @staticmethod
231
+ def _new_job_id() -> str:
232
+ return str(uuid.uuid4())
233
+
234
+ @staticmethod
235
+ def _now() -> str:
236
+ return datetime.now(timezone.utc).isoformat()
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # In-memory implementation
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ class InMemoryJobQueue(JobQueue):
245
+ """Thread/asyncio-safe in-process queue backed by a plain dict.
246
+
247
+ Suitable for single-process local/dev/test mode. State is lost when the
248
+ process restarts.
249
+ """
250
+
251
+ def __init__(self) -> None:
252
+ self._jobs: Dict[str, Job] = {}
253
+ # asyncio.Lock is *not* thread-safe — use a threading.Lock for the
254
+ # underlying dict so worker threads can also call these methods.
255
+ self._lock = threading.Lock()
256
+
257
+ # ------------------------------------------------------------------
258
+ # Core interface
259
+ # ------------------------------------------------------------------
260
+
261
+ async def enqueue(
262
+ self,
263
+ job_type: str,
264
+ payload: Dict[str, Any],
265
+ *,
266
+ dedup_key: Optional[str] = None,
267
+ ) -> str:
268
+ with self._lock:
269
+ # Deduplication check
270
+ if dedup_key is not None:
271
+ for job in self._jobs.values():
272
+ if (
273
+ job.dedup_key == dedup_key
274
+ and job.status in ("pending", "running", "retrying")
275
+ ):
276
+ logger.debug(
277
+ "Job deduplication: key=%r already active as job_id=%s",
278
+ dedup_key,
279
+ job.job_id,
280
+ )
281
+ return job.job_id
282
+
283
+ job_id = self._new_job_id()
284
+ now = self._now()
285
+ job = Job(
286
+ job_id=job_id,
287
+ job_type=job_type,
288
+ payload=payload,
289
+ status="pending",
290
+ created_at=now,
291
+ updated_at=now,
292
+ dedup_key=dedup_key,
293
+ )
294
+ self._jobs[job_id] = job
295
+ logger.debug("Enqueued job %s type=%s", job_id, job_type)
296
+ return job_id
297
+
298
+ async def dequeue(self, worker_id: Optional[str] = None) -> Optional[Job]:
299
+ with self._lock:
300
+ for job in sorted(self._jobs.values(), key=lambda j: j.created_at):
301
+ if job.status == "pending":
302
+ job.status = "running"
303
+ job.worker_id = worker_id
304
+ job.attempt += 1
305
+ job.updated_at = self._now()
306
+ return job
307
+ return None
308
+
309
+ async def ack(self, job_id: str) -> None:
310
+ with self._lock:
311
+ job = self._jobs.get(job_id)
312
+ if job is None:
313
+ return
314
+ job.status = "completed"
315
+ job.updated_at = self._now()
316
+
317
+ async def nack(
318
+ self,
319
+ job_id: str,
320
+ *,
321
+ error: Optional[str] = None,
322
+ retry: bool = False,
323
+ max_attempts: int = 3,
324
+ ) -> None:
325
+ with self._lock:
326
+ job = self._jobs.get(job_id)
327
+ if job is None:
328
+ return
329
+ job.error = error
330
+ job.updated_at = self._now()
331
+ if retry and job.attempt < max_attempts:
332
+ # Reset to pending so the job can be claimed again
333
+ job.status = "pending"
334
+ logger.info(
335
+ "Job %s will retry (attempt %d/%d): %s",
336
+ job_id,
337
+ job.attempt,
338
+ max_attempts,
339
+ error,
340
+ )
341
+ else:
342
+ job.status = "failed"
343
+ logger.warning("Job %s failed: %s", job_id, error)
344
+
345
+ async def get(self, job_id: str) -> Optional[Job]:
346
+ with self._lock:
347
+ return self._jobs.get(job_id)
348
+
349
+ async def list_jobs(
350
+ self,
351
+ *,
352
+ status: Optional[str] = None,
353
+ limit: int = 100,
354
+ ) -> List[Job]:
355
+ with self._lock:
356
+ jobs = list(self._jobs.values())
357
+ if status is not None:
358
+ jobs = [j for j in jobs if j.status == status]
359
+ # Most-recently-created first
360
+ jobs.sort(key=lambda j: j.created_at, reverse=True)
361
+ return jobs[:limit]
362
+
363
+ async def status(self) -> QueueStats:
364
+ with self._lock:
365
+ jobs = list(self._jobs.values())
366
+ stats = QueueStats()
367
+ for job in jobs:
368
+ if job.status == "pending":
369
+ stats.pending += 1
370
+ elif job.status == "running":
371
+ stats.running += 1
372
+ elif job.status == "completed":
373
+ stats.completed += 1
374
+ elif job.status == "failed":
375
+ stats.failed += 1
376
+ elif job.status == "retrying":
377
+ stats.retrying += 1
378
+ return stats
379
+
380
+ async def purge(self, *, status: Optional[str] = None) -> int:
381
+ with self._lock:
382
+ if status is None:
383
+ count = len(self._jobs)
384
+ self._jobs.clear()
385
+ return count
386
+ to_delete = [jid for jid, j in self._jobs.items() if j.status == status]
387
+ for jid in to_delete:
388
+ del self._jobs[jid]
389
+ return len(to_delete)
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # SQLite implementation
394
+ # ---------------------------------------------------------------------------
395
+
396
+ _SQLITE_SCHEMA = """
397
+ CREATE TABLE IF NOT EXISTS nat_jobs (
398
+ job_id TEXT PRIMARY KEY,
399
+ job_type TEXT NOT NULL,
400
+ payload TEXT NOT NULL,
401
+ status TEXT NOT NULL DEFAULT 'pending',
402
+ created_at TEXT NOT NULL,
403
+ updated_at TEXT NOT NULL,
404
+ worker_id TEXT,
405
+ attempt INTEGER NOT NULL DEFAULT 0,
406
+ error TEXT,
407
+ dedup_key TEXT
408
+ );
409
+ CREATE INDEX IF NOT EXISTS nat_jobs_status ON nat_jobs (status);
410
+ CREATE INDEX IF NOT EXISTS nat_jobs_dedup ON nat_jobs (dedup_key, status);
411
+ """
412
+
413
+
414
+ class SQLiteJobQueue(JobQueue):
415
+ """Persistent job queue backed by a SQLite database.
416
+
417
+ Suitable as a single-host persistent option (survives process restarts).
418
+ Uses a threading lock around the SQLite connection for coroutine safety.
419
+
420
+ Parameters
421
+ ----------
422
+ db_path:
423
+ Filesystem path to the SQLite database file.
424
+ Defaults to ``":memory:"`` (in-process, non-persistent) for tests.
425
+ """
426
+
427
+ def __init__(self, db_path: str = ":memory:") -> None:
428
+ self._db_path = db_path
429
+ self._lock = threading.Lock()
430
+ self._conn: sqlite3.Connection | None = None
431
+ self._ensure_schema()
432
+
433
+ # ------------------------------------------------------------------
434
+ # Internal
435
+ # ------------------------------------------------------------------
436
+
437
+ def _get_conn(self) -> sqlite3.Connection:
438
+ if self._conn is None:
439
+ self._conn = sqlite3.connect(
440
+ self._db_path,
441
+ check_same_thread=False,
442
+ isolation_level=None, # autocommit
443
+ )
444
+ self._conn.row_factory = sqlite3.Row
445
+ return self._conn
446
+
447
+ def _ensure_schema(self) -> None:
448
+ with self._lock:
449
+ conn = self._get_conn()
450
+ conn.executescript(_SQLITE_SCHEMA)
451
+
452
+ @staticmethod
453
+ def _row_to_job(row: sqlite3.Row) -> Job:
454
+ return Job(
455
+ job_id=row["job_id"],
456
+ job_type=row["job_type"],
457
+ payload=json.loads(row["payload"]),
458
+ status=row["status"],
459
+ created_at=row["created_at"],
460
+ updated_at=row["updated_at"],
461
+ worker_id=row["worker_id"],
462
+ attempt=row["attempt"],
463
+ error=row["error"],
464
+ dedup_key=row["dedup_key"],
465
+ )
466
+
467
+ # ------------------------------------------------------------------
468
+ # Core interface
469
+ # ------------------------------------------------------------------
470
+
471
+ async def enqueue(
472
+ self,
473
+ job_type: str,
474
+ payload: Dict[str, Any],
475
+ *,
476
+ dedup_key: Optional[str] = None,
477
+ ) -> str:
478
+ with self._lock:
479
+ conn = self._get_conn()
480
+ # Dedup check
481
+ if dedup_key is not None:
482
+ row = conn.execute(
483
+ "SELECT job_id FROM nat_jobs WHERE dedup_key = ? AND status IN ('pending','running','retrying') LIMIT 1",
484
+ (dedup_key,),
485
+ ).fetchone()
486
+ if row is not None:
487
+ return row["job_id"]
488
+
489
+ job_id = self._new_job_id()
490
+ now = self._now()
491
+ conn.execute(
492
+ """INSERT INTO nat_jobs
493
+ (job_id, job_type, payload, status, created_at, updated_at, worker_id, attempt, error, dedup_key)
494
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
495
+ (
496
+ job_id,
497
+ job_type,
498
+ json.dumps(payload),
499
+ "pending",
500
+ now,
501
+ now,
502
+ None,
503
+ 0,
504
+ None,
505
+ dedup_key,
506
+ ),
507
+ )
508
+ return job_id
509
+
510
+ async def dequeue(self, worker_id: Optional[str] = None) -> Optional[Job]:
511
+ with self._lock:
512
+ conn = self._get_conn()
513
+ row = conn.execute(
514
+ "SELECT * FROM nat_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
515
+ ).fetchone()
516
+ if row is None:
517
+ return None
518
+ now = self._now()
519
+ conn.execute(
520
+ "UPDATE nat_jobs SET status='running', worker_id=?, attempt=attempt+1, updated_at=? WHERE job_id=?",
521
+ (worker_id, now, row["job_id"]),
522
+ )
523
+ updated = conn.execute(
524
+ "SELECT * FROM nat_jobs WHERE job_id=?", (row["job_id"],)
525
+ ).fetchone()
526
+ return self._row_to_job(updated)
527
+
528
+ async def ack(self, job_id: str) -> None:
529
+ with self._lock:
530
+ conn = self._get_conn()
531
+ conn.execute(
532
+ "UPDATE nat_jobs SET status='completed', updated_at=? WHERE job_id=?",
533
+ (self._now(), job_id),
534
+ )
535
+
536
+ async def nack(
537
+ self,
538
+ job_id: str,
539
+ *,
540
+ error: Optional[str] = None,
541
+ retry: bool = False,
542
+ max_attempts: int = 3,
543
+ ) -> None:
544
+ with self._lock:
545
+ conn = self._get_conn()
546
+ row = conn.execute(
547
+ "SELECT attempt FROM nat_jobs WHERE job_id=?", (job_id,)
548
+ ).fetchone()
549
+ if row is None:
550
+ return
551
+ now = self._now()
552
+ if retry and row["attempt"] < max_attempts:
553
+ new_status = "pending"
554
+ else:
555
+ new_status = "failed"
556
+ conn.execute(
557
+ "UPDATE nat_jobs SET status=?, error=?, updated_at=? WHERE job_id=?",
558
+ (new_status, error, now, job_id),
559
+ )
560
+
561
+ async def get(self, job_id: str) -> Optional[Job]:
562
+ with self._lock:
563
+ conn = self._get_conn()
564
+ row = conn.execute(
565
+ "SELECT * FROM nat_jobs WHERE job_id=?", (job_id,)
566
+ ).fetchone()
567
+ return self._row_to_job(row) if row else None
568
+
569
+ async def list_jobs(
570
+ self,
571
+ *,
572
+ status: Optional[str] = None,
573
+ limit: int = 100,
574
+ ) -> List[Job]:
575
+ with self._lock:
576
+ conn = self._get_conn()
577
+ if status is not None:
578
+ rows = conn.execute(
579
+ "SELECT * FROM nat_jobs WHERE status=? ORDER BY created_at DESC LIMIT ?",
580
+ (status, limit),
581
+ ).fetchall()
582
+ else:
583
+ rows = conn.execute(
584
+ "SELECT * FROM nat_jobs ORDER BY created_at DESC LIMIT ?",
585
+ (limit,),
586
+ ).fetchall()
587
+ return [self._row_to_job(r) for r in rows]
588
+
589
+ async def status(self) -> QueueStats:
590
+ with self._lock:
591
+ conn = self._get_conn()
592
+ rows = conn.execute(
593
+ "SELECT status, COUNT(*) AS cnt FROM nat_jobs GROUP BY status"
594
+ ).fetchall()
595
+ stats = QueueStats()
596
+ for row in rows:
597
+ s, cnt = row["status"], int(row["cnt"])
598
+ if s == "pending":
599
+ stats.pending = cnt
600
+ elif s == "running":
601
+ stats.running = cnt
602
+ elif s == "completed":
603
+ stats.completed = cnt
604
+ elif s == "failed":
605
+ stats.failed = cnt
606
+ elif s == "retrying":
607
+ stats.retrying = cnt
608
+ return stats
609
+
610
+ async def purge(self, *, status: Optional[str] = None) -> int:
611
+ with self._lock:
612
+ conn = self._get_conn()
613
+ if status is None:
614
+ cur = conn.execute("DELETE FROM nat_jobs")
615
+ else:
616
+ cur = conn.execute("DELETE FROM nat_jobs WHERE status=?", (status,))
617
+ return cur.rowcount
618
+
619
+ def close(self) -> None:
620
+ """Close the underlying SQLite connection."""
621
+ with self._lock:
622
+ if self._conn is not None:
623
+ self._conn.close()
624
+ self._conn = None
625
+
626
+
627
+ # ---------------------------------------------------------------------------
628
+ # Redis implementation (optional — requires redis-py)
629
+ # ---------------------------------------------------------------------------
630
+
631
+
632
+ class RedisJobQueue(JobQueue):
633
+ """Distributed job queue backed by Redis.
634
+
635
+ Uses a sorted-set-based pattern for FIFO ordering and atomic Lua scripts
636
+ for claim/dequeue operations.
637
+
638
+ Requires ``redis`` (redis-py ≥ 4.0) to be installed.
639
+
640
+ Parameters
641
+ ----------
642
+ redis_url:
643
+ Redis connection URL, e.g. ``"redis://localhost:6379/0"``.
644
+ key_prefix:
645
+ Redis key namespace prefix (default: ``"nat:jobs:"``)
646
+ ttl_completed:
647
+ Seconds to retain completed/failed jobs in Redis (default: 86400 = 1 day).
648
+ """
649
+
650
+ def __init__(
651
+ self,
652
+ redis_url: str = "redis://localhost:6379/0",
653
+ key_prefix: str = "nat:jobs:",
654
+ ttl_completed: int = 86400,
655
+ ) -> None:
656
+ try:
657
+ import redis.asyncio as aioredis # noqa: PLC0415
658
+ except ImportError as exc:
659
+ raise ImportError(
660
+ "RedisJobQueue requires 'redis' (redis-py ≥ 4.0). "
661
+ "Install it with: pip install redis"
662
+ ) from exc
663
+
664
+ self._url = redis_url
665
+ self._prefix = key_prefix
666
+ self._ttl = ttl_completed
667
+ self._redis = aioredis.from_url(redis_url, decode_responses=True)
668
+
669
+ # Key helpers
670
+ def _job_key(self, job_id: str) -> str:
671
+ return f"{self._prefix}job:{job_id}"
672
+
673
+ def _pending_key(self) -> str:
674
+ return f"{self._prefix}pending"
675
+
676
+ def _dedup_key(self, dk: str) -> str:
677
+ return f"{self._prefix}dedup:{dk}"
678
+
679
+ async def enqueue(
680
+ self,
681
+ job_type: str,
682
+ payload: Dict[str, Any],
683
+ *,
684
+ dedup_key: Optional[str] = None,
685
+ ) -> str:
686
+ # Dedup check
687
+ if dedup_key is not None:
688
+ existing = await self._redis.get(self._dedup_key(dedup_key))
689
+ if existing:
690
+ return existing
691
+
692
+ job_id = self._new_job_id()
693
+ now = self._now()
694
+ job = Job(
695
+ job_id=job_id,
696
+ job_type=job_type,
697
+ payload=payload,
698
+ status="pending",
699
+ created_at=now,
700
+ updated_at=now,
701
+ dedup_key=dedup_key,
702
+ )
703
+ pipe = self._redis.pipeline()
704
+ pipe.set(self._job_key(job_id), json.dumps(job.to_dict()))
705
+ # Score = unix timestamp for FIFO ordering
706
+ score = time.time()
707
+ pipe.zadd(self._pending_key(), {job_id: score})
708
+ if dedup_key is not None:
709
+ # Dedup key expires with a generous TTL (1 hour)
710
+ pipe.setex(self._dedup_key(dedup_key), 3600, job_id)
711
+ await pipe.execute()
712
+ return job_id
713
+
714
+ async def dequeue(self, worker_id: Optional[str] = None) -> Optional[Job]:
715
+ # Pop the lowest-score (oldest) job_id from pending set
716
+ result = await self._redis.zpopmin(self._pending_key(), count=1)
717
+ if not result:
718
+ return None
719
+ job_id, _score = result[0]
720
+ raw = await self._redis.get(self._job_key(job_id))
721
+ if raw is None:
722
+ return None
723
+ job = Job.from_dict(json.loads(raw))
724
+ job.status = "running"
725
+ job.worker_id = worker_id
726
+ job.attempt += 1
727
+ job.updated_at = self._now()
728
+ await self._redis.set(self._job_key(job_id), json.dumps(job.to_dict()))
729
+ return job
730
+
731
+ async def ack(self, job_id: str) -> None:
732
+ raw = await self._redis.get(self._job_key(job_id))
733
+ if raw is None:
734
+ return
735
+ job = Job.from_dict(json.loads(raw))
736
+ job.status = "completed"
737
+ job.updated_at = self._now()
738
+ pipe = self._redis.pipeline()
739
+ pipe.set(self._job_key(job_id), json.dumps(job.to_dict()))
740
+ pipe.expire(self._job_key(job_id), self._ttl)
741
+ await pipe.execute()
742
+
743
+ async def nack(
744
+ self,
745
+ job_id: str,
746
+ *,
747
+ error: Optional[str] = None,
748
+ retry: bool = False,
749
+ max_attempts: int = 3,
750
+ ) -> None:
751
+ raw = await self._redis.get(self._job_key(job_id))
752
+ if raw is None:
753
+ return
754
+ job = Job.from_dict(json.loads(raw))
755
+ job.error = error
756
+ job.updated_at = self._now()
757
+ if retry and job.attempt < max_attempts:
758
+ job.status = "pending"
759
+ pipe = self._redis.pipeline()
760
+ pipe.set(self._job_key(job_id), json.dumps(job.to_dict()))
761
+ pipe.zadd(self._pending_key(), {job_id: time.time()})
762
+ await pipe.execute()
763
+ else:
764
+ job.status = "failed"
765
+ pipe = self._redis.pipeline()
766
+ pipe.set(self._job_key(job_id), json.dumps(job.to_dict()))
767
+ pipe.expire(self._job_key(job_id), self._ttl)
768
+ await pipe.execute()
769
+
770
+ async def get(self, job_id: str) -> Optional[Job]:
771
+ raw = await self._redis.get(self._job_key(job_id))
772
+ if raw is None:
773
+ return None
774
+ return Job.from_dict(json.loads(raw))
775
+
776
+ async def list_jobs(
777
+ self,
778
+ *,
779
+ status: Optional[str] = None,
780
+ limit: int = 100,
781
+ ) -> List[Job]:
782
+ # Scan all job keys — not efficient for large queues; for production
783
+ # use a proper index or query the sorted set.
784
+ pattern = f"{self._prefix}job:*"
785
+ cursor = 0
786
+ jobs: List[Job] = []
787
+ while True:
788
+ cursor, keys = await self._redis.scan(cursor, match=pattern, count=200)
789
+ if keys:
790
+ raws = await self._redis.mget(*keys)
791
+ for raw in raws:
792
+ if raw is not None:
793
+ j = Job.from_dict(json.loads(raw))
794
+ if status is None or j.status == status:
795
+ jobs.append(j)
796
+ if cursor == 0:
797
+ break
798
+ jobs.sort(key=lambda j: j.created_at, reverse=True)
799
+ return jobs[:limit]
800
+
801
+ async def status(self) -> QueueStats:
802
+ pending_count = await self._redis.zcard(self._pending_key())
803
+ # For running/completed/failed, we need to scan (best-effort)
804
+ pattern = f"{self._prefix}job:*"
805
+ counts: Dict[str, int] = {"pending": 0, "running": 0, "completed": 0, "failed": 0, "retrying": 0}
806
+ counts["pending"] = pending_count
807
+ cursor = 0
808
+ while True:
809
+ cursor, keys = await self._redis.scan(cursor, match=pattern, count=500)
810
+ if keys:
811
+ raws = await self._redis.mget(*keys)
812
+ for raw in raws:
813
+ if raw is not None:
814
+ j = Job.from_dict(json.loads(raw))
815
+ if j.status != "pending" and j.status in counts:
816
+ counts[j.status] = counts.get(j.status, 0) + 1
817
+ if cursor == 0:
818
+ break
819
+ return QueueStats(
820
+ pending=counts["pending"],
821
+ running=counts["running"],
822
+ completed=counts["completed"],
823
+ failed=counts["failed"],
824
+ retrying=counts["retrying"],
825
+ )
826
+
827
+ async def purge(self, *, status: Optional[str] = None) -> int:
828
+ if status == "pending" or status is None:
829
+ await self._redis.delete(self._pending_key())
830
+ if status is None:
831
+ # Delete all job keys
832
+ pattern = f"{self._prefix}job:*"
833
+ cursor = 0
834
+ count = 0
835
+ while True:
836
+ cursor, keys = await self._redis.scan(cursor, match=pattern, count=200)
837
+ if keys:
838
+ await self._redis.delete(*keys)
839
+ count += len(keys)
840
+ if cursor == 0:
841
+ break
842
+ return count
843
+ # Status-specific purge
844
+ jobs = await self.list_jobs(status=status)
845
+ for job in jobs:
846
+ await self._redis.delete(self._job_key(job.job_id))
847
+ return len(jobs)
848
+
849
+ async def close(self) -> None:
850
+ """Close the Redis connection."""
851
+ await self._redis.aclose()
852
+
853
+
854
+ # ---------------------------------------------------------------------------
855
+ # Factory helper
856
+ # ---------------------------------------------------------------------------
857
+
858
+
859
+ def create_job_queue(backend: str = "memory", **kwargs: Any) -> JobQueue:
860
+ """Factory function to create a :class:`JobQueue` by *backend* name.
861
+
862
+ Parameters
863
+ ----------
864
+ backend:
865
+ One of ``"memory"``, ``"sqlite"``, ``"redis"``.
866
+ **kwargs:
867
+ Forwarded to the concrete implementation constructor.
868
+ """
869
+ backend = backend.lower()
870
+ if backend == "memory":
871
+ return InMemoryJobQueue()
872
+ if backend == "sqlite":
873
+ return SQLiteJobQueue(**kwargs)
874
+ if backend == "redis":
875
+ return RedisJobQueue(**kwargs)
876
+ raise ValueError(
877
+ f"Unknown queue backend {backend!r}. "
878
+ "Choose from: 'memory', 'sqlite', 'redis'."
879
+ )
880
+
881
+
882
+ # Module-level singleton — replaced by server/CLI init when configured.
883
+ _default_queue: Optional[JobQueue] = None
884
+
885
+
886
+ def get_default_queue() -> Optional[JobQueue]:
887
+ """Return the process-wide default :class:`JobQueue`, or ``None``."""
888
+ return _default_queue
889
+
890
+
891
+ def set_default_queue(queue: Optional[JobQueue]) -> None:
892
+ """Set the process-wide default :class:`JobQueue`."""
893
+ global _default_queue
894
+ _default_queue = queue