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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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
|