nexus-queue 0.1.1__tar.gz
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.
- nexus_queue-0.1.1/.gitignore +7 -0
- nexus_queue-0.1.1/.python-version +1 -0
- nexus_queue-0.1.1/CHANGELOG.md +17 -0
- nexus_queue-0.1.1/PKG-INFO +61 -0
- nexus_queue-0.1.1/README.md +29 -0
- nexus_queue-0.1.1/nexus_queue/__init__.py +86 -0
- nexus_queue-0.1.1/nexus_queue/app.py +47 -0
- nexus_queue-0.1.1/nexus_queue/broker.py +29 -0
- nexus_queue-0.1.1/nexus_queue/config.py +98 -0
- nexus_queue-0.1.1/nexus_queue/delayed.py +91 -0
- nexus_queue-0.1.1/nexus_queue/envelope.py +56 -0
- nexus_queue-0.1.1/nexus_queue/exceptions.py +15 -0
- nexus_queue-0.1.1/nexus_queue/handlers.py +80 -0
- nexus_queue-0.1.1/nexus_queue/kicker.py +98 -0
- nexus_queue-0.1.1/nexus_queue/lifecycle.py +104 -0
- nexus_queue-0.1.1/nexus_queue/middleware/__init__.py +8 -0
- nexus_queue-0.1.1/nexus_queue/middleware/metrics.py +59 -0
- nexus_queue-0.1.1/nexus_queue/middleware/retry_dlq.py +125 -0
- nexus_queue-0.1.1/nexus_queue/naming.py +72 -0
- nexus_queue-0.1.1/nexus_queue/pipeline.py +49 -0
- nexus_queue-0.1.1/nexus_queue/ports.py +81 -0
- nexus_queue-0.1.1/nexus_queue/publisher.py +78 -0
- nexus_queue-0.1.1/nexus_queue/py.typed +0 -0
- nexus_queue-0.1.1/nexus_queue/tracing.py +38 -0
- nexus_queue-0.1.1/pyproject.toml +47 -0
- nexus_queue-0.1.1/tests/test_integration.py +322 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1](https://github.com/Zetesis-Labs/PayloadAgents/compare/nexus-queue-v0.1.0...nexus-queue-v0.1.1) (2026-06-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* introduce the Nexus-Queue standard (runtime + TS client) ([fa0672c](https://github.com/Zetesis-Labs/PayloadAgents/commit/fa0672c17f87ca76c2eaf7dd3295417c560b6d40))
|
|
9
|
+
* introduce the Nexus-Queue standard (runtime + TS client) ([73fce93](https://github.com/Zetesis-Labs/PayloadAgents/commit/73fce93fdc208efed95c573a1c89f69f1fc79d51))
|
|
10
|
+
* **nexus-queue:** configure an OTLP tracing exporter when an endpoint is set ([a9167fa](https://github.com/Zetesis-Labs/PayloadAgents/commit/a9167fa33c8ce629d51176a48c38bc73ef700545))
|
|
11
|
+
* **nexus-queue:** configure OTLP tracing exporter when endpoint set ([49e0ed8](https://github.com/Zetesis-Labs/PayloadAgents/commit/49e0ed8974ee217c23ebfca2d97e8883d8f34ad0))
|
|
12
|
+
* **nexus-queue:** harden the worker runtime (idempotency, backoff, metrics) ([9e7804a](https://github.com/Zetesis-Labs/PayloadAgents/commit/9e7804abdb5f12414bcf91ea10fbca0d874b4f97))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **security:** timing-safe internal-secret compare + LlamaParse upload limits ([77ac5c6](https://github.com/Zetesis-Labs/PayloadAgents/commit/77ac5c6954abb196b25f3cb3ef0fe120fa32ca28))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexus-queue
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Nexus-Queue — portable taskiq + Redis Streams worker runtime: namespaced streams, versioned envelope, ports-and-adapters, retry/DLQ/idempotency/tracing/metrics middleware.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Zetesis-Labs/PayloadAgents
|
|
6
|
+
Project-URL: Repository, https://github.com/Zetesis-Labs/PayloadAgents
|
|
7
|
+
Project-URL: Issues, https://github.com/Zetesis-Labs/PayloadAgents/issues
|
|
8
|
+
Author: Zetesis Labs
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: dlq,ports-and-adapters,queue,redis,taskiq,worker
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: fastapi>=0.115
|
|
21
|
+
Requires-Dist: opentelemetry-api>=1.35
|
|
22
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.35
|
|
23
|
+
Requires-Dist: opentelemetry-sdk>=1.35
|
|
24
|
+
Requires-Dist: prometheus-client>=0.20
|
|
25
|
+
Requires-Dist: pydantic>=2.9
|
|
26
|
+
Requires-Dist: redis>=5.0
|
|
27
|
+
Requires-Dist: structlog>=24.1
|
|
28
|
+
Requires-Dist: taskiq-redis>=1.1.1
|
|
29
|
+
Requires-Dist: taskiq>=0.11.18
|
|
30
|
+
Requires-Dist: uvicorn[standard]>=0.34
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# nexus-queue
|
|
34
|
+
|
|
35
|
+
Portable worker runtime for the **Nexus-Queue** standard: taskiq + Redis Streams,
|
|
36
|
+
domain-agnostic, ports-and-adapters. One project builds queues the same way as
|
|
37
|
+
the next, and a worker's handlers move between projects unchanged.
|
|
38
|
+
|
|
39
|
+
What this package owns (the parts that are the *same* across projects):
|
|
40
|
+
|
|
41
|
+
- **Namespaced streams** — `nq:{project}:{queue}` (+ `:cg`, `:dlq`) and
|
|
42
|
+
`nq:{project}:status`, so multiple projects/queues coexist on one Redis
|
|
43
|
+
(the default global `"taskiq"` stream is never used).
|
|
44
|
+
- **Versioned envelope** — standard labels (`nq_v`, `nq_task`, `nq_tenant`,
|
|
45
|
+
`nq_idem`, `nq_trace`, `nq_enqueued_at`, `nq_priority`) on top of taskiq's
|
|
46
|
+
message, plus typed pydantic payloads.
|
|
47
|
+
- **Ports** — `JobStatePort`, `BlobStorePort`, `IndexPort`, `StatusEventPort`.
|
|
48
|
+
Handlers depend only on these; each project supplies the adapters
|
|
49
|
+
(e.g. Payload vs Postgres/MinIO).
|
|
50
|
+
- **Middleware stack** (broker-level): idempotency (dedup on `nq_idem`),
|
|
51
|
+
DLQ (dead-letter on retry-exhaustion instead of silent drop), retries with
|
|
52
|
+
exponential backoff + jitter, OTel tracing (`nq_trace` propagation), and
|
|
53
|
+
Prometheus metrics.
|
|
54
|
+
- **Producer + kicker** — a Python `Publisher` and a generic HTTP kicker for
|
|
55
|
+
non-Python producers. The TypeScript producer client ships as
|
|
56
|
+
`@zetesis/nexus-queue`.
|
|
57
|
+
|
|
58
|
+
Spec: `nexus-queue-spec.md`.
|
|
59
|
+
|
|
60
|
+
Released via release-please on every conventional commit to `main`
|
|
61
|
+
(scope `nexus-queue`).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# nexus-queue
|
|
2
|
+
|
|
3
|
+
Portable worker runtime for the **Nexus-Queue** standard: taskiq + Redis Streams,
|
|
4
|
+
domain-agnostic, ports-and-adapters. One project builds queues the same way as
|
|
5
|
+
the next, and a worker's handlers move between projects unchanged.
|
|
6
|
+
|
|
7
|
+
What this package owns (the parts that are the *same* across projects):
|
|
8
|
+
|
|
9
|
+
- **Namespaced streams** — `nq:{project}:{queue}` (+ `:cg`, `:dlq`) and
|
|
10
|
+
`nq:{project}:status`, so multiple projects/queues coexist on one Redis
|
|
11
|
+
(the default global `"taskiq"` stream is never used).
|
|
12
|
+
- **Versioned envelope** — standard labels (`nq_v`, `nq_task`, `nq_tenant`,
|
|
13
|
+
`nq_idem`, `nq_trace`, `nq_enqueued_at`, `nq_priority`) on top of taskiq's
|
|
14
|
+
message, plus typed pydantic payloads.
|
|
15
|
+
- **Ports** — `JobStatePort`, `BlobStorePort`, `IndexPort`, `StatusEventPort`.
|
|
16
|
+
Handlers depend only on these; each project supplies the adapters
|
|
17
|
+
(e.g. Payload vs Postgres/MinIO).
|
|
18
|
+
- **Middleware stack** (broker-level): idempotency (dedup on `nq_idem`),
|
|
19
|
+
DLQ (dead-letter on retry-exhaustion instead of silent drop), retries with
|
|
20
|
+
exponential backoff + jitter, OTel tracing (`nq_trace` propagation), and
|
|
21
|
+
Prometheus metrics.
|
|
22
|
+
- **Producer + kicker** — a Python `Publisher` and a generic HTTP kicker for
|
|
23
|
+
non-Python producers. The TypeScript producer client ships as
|
|
24
|
+
`@zetesis/nexus-queue`.
|
|
25
|
+
|
|
26
|
+
Spec: `nexus-queue-spec.md`.
|
|
27
|
+
|
|
28
|
+
Released via release-please on every conventional commit to `main`
|
|
29
|
+
(scope `nexus-queue`).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""nexus-queue — portable taskiq + Redis Streams worker runtime.
|
|
2
|
+
|
|
3
|
+
Public API is intentionally small and stable: the config, the ports, the
|
|
4
|
+
envelope, and the error taxonomy. The runtime pieces (broker, middleware,
|
|
5
|
+
publisher, kicker) are added on top of these and re-exported as they land.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from nexus_queue.app import WorkerApp, create_worker
|
|
11
|
+
from nexus_queue.broker import create_broker
|
|
12
|
+
from nexus_queue.config import RuntimeConfig
|
|
13
|
+
from nexus_queue.delayed import DelayedRetryPoller
|
|
14
|
+
from nexus_queue.envelope import (
|
|
15
|
+
Envelope,
|
|
16
|
+
missing_required_labels,
|
|
17
|
+
require_supported_version,
|
|
18
|
+
)
|
|
19
|
+
from nexus_queue.exceptions import (
|
|
20
|
+
NexusPermanentError,
|
|
21
|
+
NexusQueueError,
|
|
22
|
+
NexusRetryableError,
|
|
23
|
+
)
|
|
24
|
+
from nexus_queue.handlers import HandlerSpec, register
|
|
25
|
+
from nexus_queue.kicker import create_kicker
|
|
26
|
+
from nexus_queue.lifecycle import (
|
|
27
|
+
IdempotencyStore,
|
|
28
|
+
configure_logging,
|
|
29
|
+
register_lifecycle,
|
|
30
|
+
)
|
|
31
|
+
from nexus_queue.naming import (
|
|
32
|
+
NQ_VERSION,
|
|
33
|
+
SINGLE_TENANT,
|
|
34
|
+
consumer_group,
|
|
35
|
+
delayed_set,
|
|
36
|
+
dlq_stream,
|
|
37
|
+
status_stream,
|
|
38
|
+
work_stream,
|
|
39
|
+
)
|
|
40
|
+
from nexus_queue.pipeline import PipelineRouter
|
|
41
|
+
from nexus_queue.ports import (
|
|
42
|
+
BlobStorePort,
|
|
43
|
+
IndexPort,
|
|
44
|
+
JobStatePort,
|
|
45
|
+
JobStatus,
|
|
46
|
+
StatusEvent,
|
|
47
|
+
StatusEventPort,
|
|
48
|
+
)
|
|
49
|
+
from nexus_queue.publisher import Publisher
|
|
50
|
+
from nexus_queue.tracing import configure_tracing
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"NQ_VERSION",
|
|
54
|
+
"SINGLE_TENANT",
|
|
55
|
+
"BlobStorePort",
|
|
56
|
+
"DelayedRetryPoller",
|
|
57
|
+
"Envelope",
|
|
58
|
+
"HandlerSpec",
|
|
59
|
+
"IdempotencyStore",
|
|
60
|
+
"IndexPort",
|
|
61
|
+
"JobStatePort",
|
|
62
|
+
"JobStatus",
|
|
63
|
+
"NexusPermanentError",
|
|
64
|
+
"NexusQueueError",
|
|
65
|
+
"NexusRetryableError",
|
|
66
|
+
"PipelineRouter",
|
|
67
|
+
"Publisher",
|
|
68
|
+
"RuntimeConfig",
|
|
69
|
+
"StatusEvent",
|
|
70
|
+
"StatusEventPort",
|
|
71
|
+
"WorkerApp",
|
|
72
|
+
"configure_logging",
|
|
73
|
+
"configure_tracing",
|
|
74
|
+
"consumer_group",
|
|
75
|
+
"create_broker",
|
|
76
|
+
"create_kicker",
|
|
77
|
+
"create_worker",
|
|
78
|
+
"delayed_set",
|
|
79
|
+
"dlq_stream",
|
|
80
|
+
"missing_required_labels",
|
|
81
|
+
"register",
|
|
82
|
+
"register_lifecycle",
|
|
83
|
+
"require_supported_version",
|
|
84
|
+
"status_stream",
|
|
85
|
+
"work_stream",
|
|
86
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Top-level factory. Consumers call ``create_worker(config, adapters, handlers)``
|
|
2
|
+
and expose the returned ``broker`` to the taskiq CLI and ``app`` to uvicorn.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterator, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from taskiq import AsyncBroker
|
|
12
|
+
|
|
13
|
+
from nexus_queue.broker import create_broker
|
|
14
|
+
from nexus_queue.config import RuntimeConfig
|
|
15
|
+
from nexus_queue.handlers import HandlerSpec, register
|
|
16
|
+
from nexus_queue.kicker import create_kicker
|
|
17
|
+
from nexus_queue.lifecycle import configure_logging, register_lifecycle
|
|
18
|
+
from nexus_queue.tracing import configure_tracing
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True, frozen=True)
|
|
22
|
+
class WorkerApp:
|
|
23
|
+
"""Bundle returned by :func:`create_worker`: ``app, broker = create_worker(...)``."""
|
|
24
|
+
|
|
25
|
+
app: FastAPI
|
|
26
|
+
broker: AsyncBroker
|
|
27
|
+
|
|
28
|
+
def __iter__(self) -> Iterator[FastAPI | AsyncBroker]:
|
|
29
|
+
yield self.app
|
|
30
|
+
yield self.broker
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_worker(
|
|
34
|
+
config: RuntimeConfig,
|
|
35
|
+
adapters: object,
|
|
36
|
+
handlers: Sequence[HandlerSpec],
|
|
37
|
+
) -> WorkerApp:
|
|
38
|
+
"""Build the broker (namespaced + middleware), register lifecycle + handlers,
|
|
39
|
+
and wrap a FastAPI kicker."""
|
|
40
|
+
configure_logging(config)
|
|
41
|
+
configure_tracing(config)
|
|
42
|
+
broker = create_broker(config)
|
|
43
|
+
register_lifecycle(broker, config, adapters)
|
|
44
|
+
for spec in handlers:
|
|
45
|
+
register(broker, spec, config)
|
|
46
|
+
app = create_kicker(broker, config)
|
|
47
|
+
return WorkerApp(app=app, broker=broker)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Broker factory — the one place that builds a namespaced, middleware-wrapped broker.
|
|
2
|
+
|
|
3
|
+
Consumers never instantiate ``RedisStreamBroker`` directly: that is how ZP and
|
|
4
|
+
nixon ended up on the default global ``"taskiq"`` stream (which collides across
|
|
5
|
+
projects). Here the stream and consumer group are always
|
|
6
|
+
``nq:{project}:{queue}`` / ``…:cg``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from taskiq import AsyncBroker
|
|
12
|
+
from taskiq_redis import RedisStreamBroker
|
|
13
|
+
|
|
14
|
+
from nexus_queue.config import RuntimeConfig
|
|
15
|
+
from nexus_queue.middleware.metrics import MetricsMiddleware
|
|
16
|
+
from nexus_queue.middleware.retry_dlq import RetryDlqMiddleware
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_broker(config: RuntimeConfig) -> AsyncBroker:
|
|
20
|
+
"""Build the standard broker: namespaced streams + the Nexus-Queue middleware stack."""
|
|
21
|
+
broker: AsyncBroker = RedisStreamBroker(
|
|
22
|
+
url=config.redis_url,
|
|
23
|
+
queue_name=config.work_stream,
|
|
24
|
+
consumer_group_name=config.consumer_group,
|
|
25
|
+
)
|
|
26
|
+
return broker.with_middlewares(
|
|
27
|
+
MetricsMiddleware(config),
|
|
28
|
+
RetryDlqMiddleware(config),
|
|
29
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Runtime configuration consumed by the worker runtime.
|
|
2
|
+
|
|
3
|
+
A single pydantic model filled in by the consumer. No env loading inside the
|
|
4
|
+
library (mirrors `agno-agent-builder` / `payload-documents-worker-builder`) so a
|
|
5
|
+
multi-tenant deploy can build several `RuntimeConfig` instances from one env.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, SecretStr, ValidationInfo, field_validator
|
|
11
|
+
|
|
12
|
+
from nexus_queue import naming
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RuntimeConfig(BaseModel):
|
|
16
|
+
"""Everything the runtime needs that is not a port adapter or a handler."""
|
|
17
|
+
|
|
18
|
+
app_name: str = Field(
|
|
19
|
+
description="FastAPI title and structlog identity; shows up in logs and /health.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# ── Identity / namespacing ─────────────────────────────────────────────
|
|
23
|
+
project: str = Field(
|
|
24
|
+
description="Stable short project slug used to namespace streams, e.g. 'zp', 'nixon'.",
|
|
25
|
+
)
|
|
26
|
+
queue: str = Field(
|
|
27
|
+
description="Logical queue/pipeline name, e.g. 'documents', 'jobs'.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ── Broker ─────────────────────────────────────────────────────────────
|
|
31
|
+
redis_url: str = Field(
|
|
32
|
+
description="Redis URL for the taskiq-redis stream broker (e.g. redis://redis:6379).",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# ── HTTP kicker ────────────────────────────────────────────────────────
|
|
36
|
+
internal_secret: SecretStr = Field(
|
|
37
|
+
default=SecretStr(""),
|
|
38
|
+
description="Shared secret required by the kicker (X-Nexus-Secret). Empty disables the kicker auth gate.",
|
|
39
|
+
)
|
|
40
|
+
public_paths: tuple[str, ...] = Field(
|
|
41
|
+
default=("/health", "/ready", "/metrics", "/docs", "/openapi.json"),
|
|
42
|
+
description="Kicker paths served without the secret.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ── Retry / DLQ / idempotency ──────────────────────────────────────────
|
|
46
|
+
max_retries: int = Field(
|
|
47
|
+
default=3,
|
|
48
|
+
ge=0,
|
|
49
|
+
description="Retry attempts before a message is dead-lettered.",
|
|
50
|
+
)
|
|
51
|
+
retry_base_delay_s: float = Field(
|
|
52
|
+
default=2.0,
|
|
53
|
+
gt=0,
|
|
54
|
+
description="Base delay for the exponential backoff applied between retries.",
|
|
55
|
+
)
|
|
56
|
+
retry_poll_interval_s: float = Field(
|
|
57
|
+
default=1.0,
|
|
58
|
+
gt=0,
|
|
59
|
+
description="How often the delayed-retry poller drains due retries back onto the work stream.",
|
|
60
|
+
)
|
|
61
|
+
idempotency_ttl_s: int = Field(
|
|
62
|
+
default=86_400,
|
|
63
|
+
ge=0,
|
|
64
|
+
description="TTL of the dedup key; 0 disables the idempotency middleware.",
|
|
65
|
+
)
|
|
66
|
+
dlq_maxlen: int = Field(
|
|
67
|
+
default=100_000,
|
|
68
|
+
ge=0,
|
|
69
|
+
description="Approx MAXLEN for the dead-letter stream; 0 = unbounded.",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# ── Logging ────────────────────────────────────────────────────────────
|
|
73
|
+
log_level: str = Field(default="INFO")
|
|
74
|
+
|
|
75
|
+
@field_validator("project", "queue")
|
|
76
|
+
@classmethod
|
|
77
|
+
def _validate_slug(cls, value: str, info: ValidationInfo) -> str:
|
|
78
|
+
return naming.validate_slug(value, kind=info.field_name or "slug")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def work_stream(self) -> str:
|
|
82
|
+
return naming.work_stream(self.project, self.queue)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def consumer_group(self) -> str:
|
|
86
|
+
return naming.consumer_group(self.project, self.queue)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def dlq_stream(self) -> str:
|
|
90
|
+
return naming.dlq_stream(self.project, self.queue)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def delayed_set(self) -> str:
|
|
94
|
+
return naming.delayed_set(self.project, self.queue)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def status_stream(self) -> str:
|
|
98
|
+
return naming.status_stream(self.project)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Delayed-retry queue: hold a failed message until its backoff elapses, then
|
|
2
|
+
re-enqueue it.
|
|
3
|
+
|
|
4
|
+
Redis Streams deliver immediately, so a backed-off retry can't ride the work
|
|
5
|
+
stream. :class:`~nexus_queue.middleware.retry_dlq.RetryDlqMiddleware` parks the
|
|
6
|
+
message in a sorted set scored by its due time; the :class:`DelayedRetryPoller`
|
|
7
|
+
(started by the worker lifecycle) drains due messages back onto the broker. The
|
|
8
|
+
atomic ``ZREM`` makes the move safe across worker replicas — only the one that
|
|
9
|
+
removes a member re-enqueues it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import redis.asyncio as aioredis
|
|
21
|
+
import structlog
|
|
22
|
+
from taskiq import AsyncBroker
|
|
23
|
+
from taskiq.kicker import AsyncKicker
|
|
24
|
+
from taskiq.message import TaskiqMessage
|
|
25
|
+
|
|
26
|
+
from nexus_queue.config import RuntimeConfig
|
|
27
|
+
|
|
28
|
+
logger = structlog.get_logger("nexus_queue.delayed")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pack_retry(message: TaskiqMessage, labels: dict[str, Any]) -> str:
|
|
32
|
+
"""Serialize a message (with its updated labels) for the delayed-retry set."""
|
|
33
|
+
return json.dumps(
|
|
34
|
+
{
|
|
35
|
+
"task_name": message.task_name,
|
|
36
|
+
"task_id": message.task_id,
|
|
37
|
+
"labels": labels,
|
|
38
|
+
"args": message.args,
|
|
39
|
+
"kwargs": message.kwargs,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DelayedRetryPoller:
|
|
45
|
+
"""Moves due retries from the delayed sorted set back onto the broker."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, broker: AsyncBroker, config: RuntimeConfig) -> None:
|
|
48
|
+
self._broker = broker
|
|
49
|
+
self._config = config
|
|
50
|
+
self._redis: aioredis.Redis | None = None
|
|
51
|
+
self._task: asyncio.Task[None] | None = None
|
|
52
|
+
|
|
53
|
+
async def startup(self) -> None:
|
|
54
|
+
self._redis = aioredis.from_url(self._config.redis_url)
|
|
55
|
+
self._task = asyncio.create_task(self._run())
|
|
56
|
+
|
|
57
|
+
async def shutdown(self) -> None:
|
|
58
|
+
if self._task is not None:
|
|
59
|
+
self._task.cancel()
|
|
60
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
61
|
+
await self._task
|
|
62
|
+
if self._redis is not None:
|
|
63
|
+
await self._redis.aclose()
|
|
64
|
+
|
|
65
|
+
async def _run(self) -> None:
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
await self._drain_due()
|
|
69
|
+
except asyncio.CancelledError:
|
|
70
|
+
raise
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.exception("delayed-poll-failed")
|
|
73
|
+
await asyncio.sleep(self._config.retry_poll_interval_s)
|
|
74
|
+
|
|
75
|
+
async def _drain_due(self) -> None:
|
|
76
|
+
if self._redis is None:
|
|
77
|
+
return
|
|
78
|
+
due = await self._redis.zrangebyscore(self._config.delayed_set, "-inf", time.time())
|
|
79
|
+
for record in due:
|
|
80
|
+
# Atomic claim: only the replica that removes the member re-enqueues it.
|
|
81
|
+
if await self._redis.zrem(self._config.delayed_set, record) == 1:
|
|
82
|
+
await self._reenqueue(json.loads(record))
|
|
83
|
+
|
|
84
|
+
async def _reenqueue(self, data: dict[str, Any]) -> None:
|
|
85
|
+
kicker: AsyncKicker[Any, Any] = AsyncKicker(
|
|
86
|
+
task_name=data["task_name"],
|
|
87
|
+
broker=self._broker,
|
|
88
|
+
labels=data["labels"],
|
|
89
|
+
).with_task_id(data["task_id"])
|
|
90
|
+
await kicker.kiq(*data["args"], **data["kwargs"])
|
|
91
|
+
logger.info("retry-reenqueued", task=data["task_name"])
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The Nexus-Queue envelope: standard labels stamped on every message.
|
|
2
|
+
|
|
3
|
+
Producers build an :class:`Envelope` and turn it into the ``labels`` dict that
|
|
4
|
+
rides on the taskiq message; consumers validate the version on receipt.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from nexus_queue import naming
|
|
15
|
+
from nexus_queue.exceptions import NexusPermanentError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class Envelope:
|
|
20
|
+
"""Routing/metadata for a single enqueue. Carried in message labels, not args."""
|
|
21
|
+
|
|
22
|
+
task: str
|
|
23
|
+
tenant: str = naming.SINGLE_TENANT
|
|
24
|
+
idempotency_key: str | None = None
|
|
25
|
+
trace: str | None = None
|
|
26
|
+
priority: str = "default"
|
|
27
|
+
|
|
28
|
+
def to_labels(self) -> dict[str, str]:
|
|
29
|
+
"""Render the standard labels. ``nq_enqueued_at`` is stamped now (UTC)."""
|
|
30
|
+
labels: dict[str, str] = {
|
|
31
|
+
naming.LABEL_VERSION: naming.NQ_VERSION,
|
|
32
|
+
naming.LABEL_TASK: self.task,
|
|
33
|
+
naming.LABEL_TENANT: self.tenant,
|
|
34
|
+
naming.LABEL_ENQUEUED_AT: datetime.now(UTC).isoformat(),
|
|
35
|
+
naming.LABEL_PRIORITY: self.priority,
|
|
36
|
+
}
|
|
37
|
+
if self.idempotency_key:
|
|
38
|
+
labels[naming.LABEL_IDEM] = self.idempotency_key
|
|
39
|
+
if self.trace:
|
|
40
|
+
labels[naming.LABEL_TRACE] = self.trace
|
|
41
|
+
return labels
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def require_supported_version(labels: Mapping[str, Any]) -> None:
|
|
45
|
+
"""Raise :class:`NexusPermanent` (→ DLQ) if the message is a version we
|
|
46
|
+
don't speak. Keeps a future ``nq_v=2`` from being silently mishandled."""
|
|
47
|
+
version = str(labels.get(naming.LABEL_VERSION, ""))
|
|
48
|
+
if version != naming.NQ_VERSION:
|
|
49
|
+
raise NexusPermanentError(
|
|
50
|
+
f"Unsupported nq_v={version!r}; this worker speaks {naming.NQ_VERSION!r}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def missing_required_labels(labels: Mapping[str, Any]) -> tuple[str, ...]:
|
|
55
|
+
"""Return the required labels absent from ``labels`` (empty tuple if valid)."""
|
|
56
|
+
return tuple(key for key in naming.REQUIRED_LABELS if not labels.get(key))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Error taxonomy that drives the runtime's retry vs dead-letter decision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NexusQueueError(Exception):
|
|
7
|
+
"""Base class for every error raised by nexus-queue."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NexusRetryableError(NexusQueueError):
|
|
11
|
+
"""Transient failure — retry until attempts are exhausted, then dead-letter."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NexusPermanentError(NexusQueueError):
|
|
15
|
+
"""Non-retryable failure — route straight to the DLQ, skip remaining retries."""
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Handler registration with the cross-cutting concerns that need an ``around``
|
|
2
|
+
scope (which taskiq middleware hooks can't give): idempotency, tracing, latency.
|
|
3
|
+
|
|
4
|
+
A handler is ``async def h(payload, deps) -> None`` — it depends only on its
|
|
5
|
+
typed payload and the project's :class:`NexusAdapters`. ``register`` wraps it
|
|
6
|
+
so the same handler runs unchanged in any project.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
from opentelemetry import trace
|
|
17
|
+
from opentelemetry.propagate import extract
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
from taskiq import AsyncBroker, Context, TaskiqDepends
|
|
20
|
+
|
|
21
|
+
from nexus_queue.config import RuntimeConfig
|
|
22
|
+
from nexus_queue.envelope import require_supported_version
|
|
23
|
+
from nexus_queue.lifecycle import IdempotencyStore
|
|
24
|
+
from nexus_queue.middleware.metrics import CONSUME_SECONDS
|
|
25
|
+
from nexus_queue.naming import LABEL_IDEM, LABEL_TENANT, LABEL_TRACE, SINGLE_TENANT
|
|
26
|
+
|
|
27
|
+
logger = structlog.get_logger("nexus_queue.handlers")
|
|
28
|
+
_tracer = trace.get_tracer("nexus_queue")
|
|
29
|
+
|
|
30
|
+
# deps is the project's adapter container; the handler types it to a Protocol
|
|
31
|
+
# of the ports it actually uses.
|
|
32
|
+
HandlerFn = Callable[[Any, Any], Awaitable[None]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class HandlerSpec:
|
|
37
|
+
"""Binds a wire task name to a handler and its payload model."""
|
|
38
|
+
|
|
39
|
+
task_name: str
|
|
40
|
+
handler: HandlerFn
|
|
41
|
+
payload_model: type[BaseModel]
|
|
42
|
+
idempotent: bool = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register(broker: AsyncBroker, spec: HandlerSpec, config: RuntimeConfig) -> None:
|
|
46
|
+
"""Register ``spec.handler`` under its wire task name, wrapped with version
|
|
47
|
+
check, idempotency dedup, an OTel consume span, and a latency histogram."""
|
|
48
|
+
|
|
49
|
+
async def _run(context: Context = TaskiqDepends(), **kwargs: Any) -> None:
|
|
50
|
+
labels = context.message.labels
|
|
51
|
+
require_supported_version(labels)
|
|
52
|
+
state = context.state
|
|
53
|
+
|
|
54
|
+
raw_idem = labels.get(LABEL_IDEM) if spec.idempotent else None
|
|
55
|
+
idem = str(raw_idem) if raw_idem else None
|
|
56
|
+
store: IdempotencyStore | None = state.nexus_idempotency if idem else None
|
|
57
|
+
if store is not None and idem and await store.already_processed(idem):
|
|
58
|
+
logger.info("duplicate-skipped", task=spec.task_name, idem=idem)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
adapters = state.nexus_adapters
|
|
62
|
+
payload = spec.payload_model(**kwargs)
|
|
63
|
+
|
|
64
|
+
traceparent = labels.get(LABEL_TRACE)
|
|
65
|
+
parent = extract({"traceparent": str(traceparent)}) if traceparent else None
|
|
66
|
+
with _tracer.start_as_current_span(
|
|
67
|
+
f"nexus_queue.consume {spec.task_name}",
|
|
68
|
+
context=parent,
|
|
69
|
+
) as span:
|
|
70
|
+
span.set_attribute("nq.task", spec.task_name)
|
|
71
|
+
span.set_attribute("nq.tenant", str(labels.get(LABEL_TENANT, SINGLE_TENANT)))
|
|
72
|
+
with CONSUME_SECONDS.labels(config.project, config.queue, spec.task_name).time():
|
|
73
|
+
await spec.handler(payload, adapters)
|
|
74
|
+
|
|
75
|
+
# Record dedup only after success: a failed attempt must stay retryable.
|
|
76
|
+
if store is not None and idem:
|
|
77
|
+
await store.mark_processed(idem)
|
|
78
|
+
|
|
79
|
+
_run.__name__ = "nexus_run_" + spec.task_name.replace(".", "_").replace(":", "_")
|
|
80
|
+
broker.task(task_name=spec.task_name)(_run)
|