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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .pytest_cache/
7
+ .env
@@ -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)