rabbitkit 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
rabbitkit/core/config.py
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""Focused configuration objects — composable, immutable, validated.
|
|
2
|
+
|
|
3
|
+
Split into focused dataclasses. Each has clear responsibility.
|
|
4
|
+
RabbitConfig only composes connection/broker defaults.
|
|
5
|
+
Throughput/batching config objects are accepted by their respective components.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from urllib.parse import parse_qs, quote, unquote, urlparse
|
|
14
|
+
|
|
15
|
+
from rabbitkit.core.logging import LoggingConfig
|
|
16
|
+
from rabbitkit.core.types import ErrorSeverity, TopologyMode
|
|
17
|
+
|
|
18
|
+
# ── Connection ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _masked_repr(obj: object, *, secret_fields: tuple[str, ...] = ("password",)) -> str:
|
|
22
|
+
"""L2: generic ``__repr__`` for a config dataclass that masks *secret_fields*.
|
|
23
|
+
|
|
24
|
+
The default dataclass-generated ``__repr__`` includes every field
|
|
25
|
+
verbatim — any log line or traceback that reprs a config object (or logs
|
|
26
|
+
it directly, which falls back to ``__repr__``) leaks the plaintext
|
|
27
|
+
password. Iterates ``dataclasses.fields()`` generically so a field added
|
|
28
|
+
later is still included (just not masked unless also listed).
|
|
29
|
+
"""
|
|
30
|
+
import dataclasses
|
|
31
|
+
|
|
32
|
+
parts = []
|
|
33
|
+
for f in dataclasses.fields(obj): # type: ignore[arg-type]
|
|
34
|
+
value = "'***'" if f.name in secret_fields else repr(getattr(obj, f.name))
|
|
35
|
+
parts.append(f"{f.name}={value}")
|
|
36
|
+
return f"{type(obj).__name__}({', '.join(parts)})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ConnectionConfig:
|
|
41
|
+
"""Core connection parameters."""
|
|
42
|
+
|
|
43
|
+
host: str = "localhost"
|
|
44
|
+
port: int = 5672
|
|
45
|
+
username: str = "guest"
|
|
46
|
+
password: str = "guest"
|
|
47
|
+
vhost: str = "/"
|
|
48
|
+
heartbeat: int = 30
|
|
49
|
+
socket_timeout: float = 10.0
|
|
50
|
+
# k8s-friendly default: fail fast on a blocked connection rather than
|
|
51
|
+
# appearing healthy for 5 minutes while publishing stalls.
|
|
52
|
+
blocked_connection_timeout: float = 60.0
|
|
53
|
+
connection_name: str | None = None
|
|
54
|
+
reconnect_backoff_base: float = 1.0
|
|
55
|
+
reconnect_backoff_max: float = 30.0
|
|
56
|
+
# M13: credential rotation. When set, called at each (re)connect to fetch
|
|
57
|
+
# fresh (username, password) — e.g. from Vault/short-lived secrets — so a
|
|
58
|
+
# rotated credential is picked up on the next reconnect WITHOUT a redeploy
|
|
59
|
+
# (the frozen username/password fields are the static fallback). Excluded
|
|
60
|
+
# from the AMQP URL/repr paths; only the connection builders call it.
|
|
61
|
+
credentials_provider: Callable[[], tuple[str, str]] | None = None
|
|
62
|
+
# M9: additional cluster nodes for failover, each "host" or "host:port".
|
|
63
|
+
# The primary (host/port above) is tried first, then each node in order.
|
|
64
|
+
# All share the same credentials/vhost/TLS/heartbeat. Sync (pika) tries
|
|
65
|
+
# them natively via a ConnectionParameters list; async cycles endpoints on
|
|
66
|
+
# initial connect (connect_robust then pins to the chosen node — put a
|
|
67
|
+
# load balancer / DNS round-robin in front for per-reconnect failover).
|
|
68
|
+
nodes: tuple[str, ...] = ()
|
|
69
|
+
|
|
70
|
+
def resolve_credentials(self) -> tuple[str, str]:
|
|
71
|
+
"""Return ``(username, password)`` — from ``credentials_provider`` if
|
|
72
|
+
set (M13: called at each (re)connect so rotated secrets are picked up
|
|
73
|
+
without a redeploy), else the static ``username``/``password`` fields."""
|
|
74
|
+
if self.credentials_provider is not None:
|
|
75
|
+
return self.credentials_provider()
|
|
76
|
+
return self.username, self.password
|
|
77
|
+
|
|
78
|
+
def cluster_endpoints(self) -> list[tuple[str, int]]:
|
|
79
|
+
"""Primary host:port plus any failover nodes, in connect-attempt order."""
|
|
80
|
+
endpoints = [(self.host, self.port)]
|
|
81
|
+
for node in self.nodes:
|
|
82
|
+
if ":" in node:
|
|
83
|
+
host, _, port = node.partition(":")
|
|
84
|
+
endpoints.append((host, int(port)))
|
|
85
|
+
else:
|
|
86
|
+
endpoints.append((node, self.port))
|
|
87
|
+
return endpoints
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return _masked_repr(self)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def url(self) -> str:
|
|
94
|
+
"""Build AMQP URL from config fields.
|
|
95
|
+
|
|
96
|
+
Username, password and vhost are URL-encoded so credentials containing
|
|
97
|
+
reserved characters (``@:/#`` etc.) cannot corrupt the host/port parse
|
|
98
|
+
(mirrors the encoding done in ``async_/connection.py``).
|
|
99
|
+
|
|
100
|
+
SECURITY (L2): this embeds the plaintext password (``user:pass@host``)
|
|
101
|
+
— never log or repr it. Use :attr:`safe_url` for logging/display.
|
|
102
|
+
"""
|
|
103
|
+
if self.vhost == "/":
|
|
104
|
+
vhost = "%2F"
|
|
105
|
+
else:
|
|
106
|
+
vhost = quote(self.vhost, safe="")
|
|
107
|
+
user = quote(self.username, safe="")
|
|
108
|
+
pwd = quote(self.password, safe="")
|
|
109
|
+
return f"amqp://{user}:{pwd}@{self.host}:{self.port}/{vhost}"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def safe_url(self) -> str:
|
|
113
|
+
"""Like :attr:`url` but with the password masked (L2) — safe to log."""
|
|
114
|
+
if self.vhost == "/":
|
|
115
|
+
vhost = "%2F"
|
|
116
|
+
else:
|
|
117
|
+
vhost = quote(self.vhost, safe="")
|
|
118
|
+
user = quote(self.username, safe="")
|
|
119
|
+
return f"amqp://{user}:***@{self.host}:{self.port}/{vhost}"
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
# Surface the insecure default-credentials-against-non-local-host mistake
|
|
123
|
+
# once at construction (not per-connection). The default is kept for dev
|
|
124
|
+
# convenience; this only warns.
|
|
125
|
+
if self.username == "guest" and self.host not in {"localhost", "127.0.0.1", "::1"}:
|
|
126
|
+
warnings.warn(
|
|
127
|
+
"ConnectionConfig uses default 'guest' credentials against non-local "
|
|
128
|
+
f"host {self.host!r}; set explicit username/password for production.",
|
|
129
|
+
UserWarning,
|
|
130
|
+
stacklevel=2,
|
|
131
|
+
)
|
|
132
|
+
# M9: fail fast on a malformed node entry rather than at connect time.
|
|
133
|
+
for node in self.nodes:
|
|
134
|
+
_, sep, port = node.partition(":")
|
|
135
|
+
if sep and not port.isdigit():
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"ConnectionConfig.nodes entry {node!r} has a non-numeric port; "
|
|
138
|
+
"use 'host' or 'host:port'."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_url(cls, url: str) -> ConnectionConfig:
|
|
143
|
+
"""Parse an AMQP URL into a ConnectionConfig.
|
|
144
|
+
|
|
145
|
+
Supports: amqp://user:pass@host:port/vhost?heartbeat=30&connection_timeout=10
|
|
146
|
+
"""
|
|
147
|
+
parsed = urlparse(url)
|
|
148
|
+
query = parse_qs(parsed.query)
|
|
149
|
+
|
|
150
|
+
vhost = parsed.path.lstrip("/") if parsed.path and parsed.path != "/" else "/"
|
|
151
|
+
if vhost == "%2F" or vhost == "":
|
|
152
|
+
vhost = "/"
|
|
153
|
+
|
|
154
|
+
# M3: an amqps:// URL implies TLS, but rabbitkit enables TLS via
|
|
155
|
+
# SecurityConfig(ssl=SSLConfig(enabled=True)), NOT the URL scheme —
|
|
156
|
+
# ConnectionConfig carries no TLS state. Silently ignoring the scheme
|
|
157
|
+
# would let an operator ship PLAINTEXT while believing TLS is on. We
|
|
158
|
+
# default the port to the AMQPS port (5671) and warn loudly, so a
|
|
159
|
+
# not-actually-encrypted connection fails fast against a TLS-only port
|
|
160
|
+
# instead of leaking plaintext to a plaintext listener.
|
|
161
|
+
is_amqps = parsed.scheme == "amqps"
|
|
162
|
+
default_port = 5671 if is_amqps else 5672
|
|
163
|
+
if is_amqps:
|
|
164
|
+
import warnings
|
|
165
|
+
|
|
166
|
+
warnings.warn(
|
|
167
|
+
"amqps:// URL parsed, but rabbitkit does not enable TLS from the URL "
|
|
168
|
+
"scheme — you must pass SecurityConfig(ssl=SSLConfig(enabled=True)) to "
|
|
169
|
+
"RabbitConfig. Port defaulted to 5671; without TLS enabled this "
|
|
170
|
+
"connection will fail against a TLS-only listener (not silently send "
|
|
171
|
+
"plaintext).",
|
|
172
|
+
RuntimeWarning,
|
|
173
|
+
stacklevel=2,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
kwargs: dict[str, str | int | float | None] = {
|
|
177
|
+
"host": parsed.hostname or "localhost",
|
|
178
|
+
"port": parsed.port or default_port,
|
|
179
|
+
# Percent-decode credentials so an encoded AMQP URL round-trips
|
|
180
|
+
# without double-encoding (e.g. user%40 -> user@).
|
|
181
|
+
"username": unquote(parsed.username) if parsed.username else "guest",
|
|
182
|
+
"password": unquote(parsed.password) if parsed.password else "guest",
|
|
183
|
+
"vhost": vhost,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if "heartbeat" in query:
|
|
187
|
+
kwargs["heartbeat"] = int(query["heartbeat"][0])
|
|
188
|
+
if "connection_timeout" in query:
|
|
189
|
+
kwargs["socket_timeout"] = float(query["connection_timeout"][0])
|
|
190
|
+
if "blocked_connection_timeout" in query:
|
|
191
|
+
kwargs["blocked_connection_timeout"] = float(query["blocked_connection_timeout"][0])
|
|
192
|
+
|
|
193
|
+
return cls(**kwargs) # type: ignore[arg-type]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── TCP/Socket ─────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class SocketConfig:
|
|
201
|
+
"""Low-level TCP tuning.
|
|
202
|
+
|
|
203
|
+
Applied best-effort — not all options are universally guaranteed
|
|
204
|
+
depending on OS and backend internals.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
tcp_nodelay: bool = True
|
|
208
|
+
tcp_keepidle: int = 10
|
|
209
|
+
tcp_keepintvl: int = 5
|
|
210
|
+
tcp_keepcnt: int = 3
|
|
211
|
+
tcp_sndbuf: int = 196608 # 192KB
|
|
212
|
+
tcp_rcvbuf: int = 196608 # 192KB
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── SSL/TLS ────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True)
|
|
219
|
+
class SSLConfig:
|
|
220
|
+
"""TLS/SSL configuration."""
|
|
221
|
+
|
|
222
|
+
enabled: bool = False
|
|
223
|
+
certfile: str | None = None
|
|
224
|
+
keyfile: str | None = None
|
|
225
|
+
ca_certs: str | None = None
|
|
226
|
+
cert_reqs: str = "CERT_REQUIRED"
|
|
227
|
+
server_hostname: str | None = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ── Security ───────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True, slots=True)
|
|
234
|
+
class SecurityConfig:
|
|
235
|
+
"""SASL + authentication configuration.
|
|
236
|
+
|
|
237
|
+
Only ``mechanism="PLAIN"`` is implemented (username/password). ``EXTERNAL``
|
|
238
|
+
(x509 client-cert auth) is not wired into either transport, so accepting
|
|
239
|
+
it silently would be "config that lies" — it is rejected at construction
|
|
240
|
+
(M2). mTLS is still supported for transport *encryption* via
|
|
241
|
+
``SSLConfig(certfile=..., keyfile=...)``; it just isn't an auth mechanism.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
mechanism: str = "PLAIN"
|
|
245
|
+
ssl: SSLConfig = field(default_factory=SSLConfig)
|
|
246
|
+
|
|
247
|
+
def __post_init__(self) -> None:
|
|
248
|
+
if self.mechanism != "PLAIN":
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"SecurityConfig.mechanism={self.mechanism!r} is not supported — only "
|
|
251
|
+
"'PLAIN' (username/password) is implemented. SASL EXTERNAL/x509-auth is "
|
|
252
|
+
"not wired into the transports. For TLS client certs (encryption, not "
|
|
253
|
+
"auth), use SSLConfig(certfile=..., keyfile=...)."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Publisher ──────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True, slots=True)
|
|
261
|
+
class PublisherConfig:
|
|
262
|
+
"""Publisher behavior tuning."""
|
|
263
|
+
|
|
264
|
+
exchange: str = ""
|
|
265
|
+
confirm_delivery: bool = True
|
|
266
|
+
confirm_timeout: float = 5.0
|
|
267
|
+
mandatory: bool = False
|
|
268
|
+
persistent: bool = True
|
|
269
|
+
# M10: reject oversized message bodies at publish time (bytes). 0 =
|
|
270
|
+
# disabled (default, unchanged). Large messages are a RabbitMQ anti-pattern
|
|
271
|
+
# (memory pressure, head-of-line blocking, slow recovery) — set a cap
|
|
272
|
+
# (e.g. 1_048_576 for 1 MiB) to fail fast on a programming error instead
|
|
273
|
+
# of shipping a 50 MB message. Enforced by broker.publish() → ValueError.
|
|
274
|
+
max_message_bytes: int = 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── Consumer ───────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class ConsumerConfig:
|
|
282
|
+
"""Consumer behavior tuning."""
|
|
283
|
+
|
|
284
|
+
prefetch_count: int = 10
|
|
285
|
+
graceful_timeout: float = 30.0
|
|
286
|
+
# M6: bound the transient hot-loop on retry-less AUTO routes. By default
|
|
287
|
+
# (False), a transient error nack-requeues with no cap — legitimate
|
|
288
|
+
# "wait for the downstream to recover" behavior, but a footgun when the
|
|
289
|
+
# failure is really permanent. When True, a transient error on a message
|
|
290
|
+
# the broker has ALREADY redelivered (redelivered=True) is rejected to the
|
|
291
|
+
# DLQ instead of requeued again — a 2-strike cap using the broker's
|
|
292
|
+
# redelivered flag (the only per-message redelivery signal available
|
|
293
|
+
# without republishing; classic-queue requeues can't carry a count). For
|
|
294
|
+
# a higher cap or delays, use retry (delay ladder) or a quorum source
|
|
295
|
+
# queue with x-delivery-limit. Requires a dead-letter path — the default
|
|
296
|
+
# reject_without_dlx="auto_provision" (C3) gives AUTO routes one.
|
|
297
|
+
reject_transient_on_redelivery: bool = False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ── Pool ───────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass(frozen=True, slots=True)
|
|
304
|
+
class PoolConfig:
|
|
305
|
+
"""Connection and channel pool sizing.
|
|
306
|
+
|
|
307
|
+
``channel_pool_size`` (publisher channel pool) and ``channel_acquire_timeout``
|
|
308
|
+
are active. ``publisher_connections`` / ``consumer_connections`` are
|
|
309
|
+
**reserved**: the transport currently uses one connection per role, because
|
|
310
|
+
multiple connections sharing a single event loop showed no throughput benefit
|
|
311
|
+
in benchmarks (the loop is the bound). Scale throughput by running more
|
|
312
|
+
processes/pods, not more connections per process.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
channel_pool_size: int = 10
|
|
316
|
+
publisher_connections: int = 1 # reserved — see class docstring
|
|
317
|
+
consumer_connections: int = 1 # reserved — see class docstring
|
|
318
|
+
channel_acquire_timeout: float = 10.0 # seconds to wait for a pooled channel
|
|
319
|
+
prewarm_channels: bool = False # pre-create all pool channels on connect() to eliminate warmup jitter
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── Retry ──────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True, slots=True)
|
|
326
|
+
class RetryConfig:
|
|
327
|
+
"""Retry with delay queues configuration.
|
|
328
|
+
|
|
329
|
+
Accepted by RetryMiddleware. Can be set as broker default
|
|
330
|
+
(RabbitConfig.retry) or per-route override (route.retry_override).
|
|
331
|
+
|
|
332
|
+
``delays`` must have at least ``max_retries`` entries. Extra retries
|
|
333
|
+
beyond the length of ``delays`` would silently reuse the last delay,
|
|
334
|
+
which is almost always a misconfiguration.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
max_retries: int = 4
|
|
338
|
+
delays: tuple[int, ...] = (5, 30, 120, 600)
|
|
339
|
+
retry_header: str = "x-rabbitkit-retry-count"
|
|
340
|
+
# RESERVED / no-op (M2): queue-based retry uses a fixed per-queue
|
|
341
|
+
# x-message-ttl, so jitter would require per-message TTL — which
|
|
342
|
+
# reintroduces head-of-line blocking on classic queues. Kept for API
|
|
343
|
+
# stability; does not affect delay timing. Spread retries across a fleet
|
|
344
|
+
# via the per-process reconnect jitter, not this.
|
|
345
|
+
jitter_factor: float = 0.1
|
|
346
|
+
# F4: "off" (default — single delay queue per tier, exact legacy topology)
|
|
347
|
+
# or "sharded" — each tier becomes jitter_shards sub-queues with uniform
|
|
348
|
+
# TTLs staggered across ±jitter_factor; a message picks its shard by a
|
|
349
|
+
# STABLE hash of its message_id, decorrelating retry waves across the
|
|
350
|
+
# fleet WITHOUT per-message TTL (which would reintroduce classic-queue
|
|
351
|
+
# head-of-line blocking). Shard 0 keeps the legacy queue name/TTL, so
|
|
352
|
+
# enabling this on an existing topology is additive (no 406s).
|
|
353
|
+
jitter_mode: str = "off"
|
|
354
|
+
jitter_shards: int = 3
|
|
355
|
+
dead_letter_exchange: str = ""
|
|
356
|
+
per_queue: bool = True
|
|
357
|
+
unknown_policy: ErrorSeverity = ErrorSeverity.PERMANENT
|
|
358
|
+
strict_delays: bool = True
|
|
359
|
+
|
|
360
|
+
def __post_init__(self) -> None:
|
|
361
|
+
if self.max_retries < 0:
|
|
362
|
+
raise ValueError(f"RetryConfig.max_retries must be >= 0, got {self.max_retries}")
|
|
363
|
+
if self.jitter_mode not in ("off", "sharded"):
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"RetryConfig.jitter_mode must be 'off' or 'sharded', got {self.jitter_mode!r}"
|
|
366
|
+
)
|
|
367
|
+
if self.jitter_mode == "sharded":
|
|
368
|
+
if self.jitter_shards < 2:
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"RetryConfig.jitter_shards must be >= 2 with jitter_mode='sharded', "
|
|
371
|
+
f"got {self.jitter_shards}"
|
|
372
|
+
)
|
|
373
|
+
if not (0 < self.jitter_factor < 1):
|
|
374
|
+
raise ValueError(
|
|
375
|
+
"RetryConfig.jitter_factor must be in (0, 1) with jitter_mode='sharded' "
|
|
376
|
+
f"(it sets the TTL spread), got {self.jitter_factor}"
|
|
377
|
+
)
|
|
378
|
+
if not self.per_queue:
|
|
379
|
+
# H3: shared delay queues (rabbitkit.retry.N) bake a single
|
|
380
|
+
# x-dead-letter-routing-key into each queue at declare time. That
|
|
381
|
+
# key can only point at ONE source queue, so with >1 subscriber
|
|
382
|
+
# they either 406 at startup (conflicting args) or silently
|
|
383
|
+
# dead-letter every queue's failures back to whichever queue
|
|
384
|
+
# declared first — cross-queue misdelivery (orders' failures
|
|
385
|
+
# reappearing on payments). A shared delay queue physically
|
|
386
|
+
# cannot route each message back to its own varying source with
|
|
387
|
+
# static broker config, so there is no safe shared topology.
|
|
388
|
+
raise ValueError(
|
|
389
|
+
"RetryConfig(per_queue=False) is unsafe and unsupported: shared "
|
|
390
|
+
"delay queues misroute failed messages across source queues (or "
|
|
391
|
+
"406 at startup). Use per_queue=True (the default), which gives "
|
|
392
|
+
"each queue isolated '<queue>.retry.N'/'<queue>.dlq' topology."
|
|
393
|
+
)
|
|
394
|
+
if len(self.delays) < self.max_retries:
|
|
395
|
+
msg = (
|
|
396
|
+
f"RetryConfig.delays has {len(self.delays)} entries but max_retries={self.max_retries}. "
|
|
397
|
+
"Retries beyond the last delay entry will reuse the last delay value, "
|
|
398
|
+
"which is almost always a misconfiguration. Provide at least max_retries "
|
|
399
|
+
"delay values, or set strict_delays=False to allow the flat-tail behavior."
|
|
400
|
+
)
|
|
401
|
+
if self.strict_delays:
|
|
402
|
+
raise ValueError(msg)
|
|
403
|
+
import warnings
|
|
404
|
+
|
|
405
|
+
warnings.warn(msg, UserWarning, stacklevel=2)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# NOTE: ``RetryConfig`` no longer imports ``warnings`` at module top-level; the
|
|
409
|
+
# warning is only emitted on the non-strict path. Strict (default) raises so
|
|
410
|
+
# misconfiguration fails fast per the project's fail-fast philosophy.
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ── Compression ────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@dataclass(frozen=True, slots=True)
|
|
417
|
+
class CompressionConfig:
|
|
418
|
+
"""Compression configuration.
|
|
419
|
+
|
|
420
|
+
Accepted by CompressionMiddleware.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
algorithm: str = "gzip"
|
|
424
|
+
threshold: int = 1024
|
|
425
|
+
level: int = 6
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ── Metrics ───────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@dataclass(frozen=True, slots=True)
|
|
432
|
+
class MetricsConfig:
|
|
433
|
+
"""Metrics naming configuration.
|
|
434
|
+
|
|
435
|
+
``namespace`` sets the metric name prefix (default "rabbitkit").
|
|
436
|
+
Individual name fields override the derived default when non-empty.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
namespace: str = "rabbitkit"
|
|
440
|
+
consumed_counter: str = ""
|
|
441
|
+
processing_histogram: str = ""
|
|
442
|
+
published_counter: str = ""
|
|
443
|
+
publish_histogram: str = ""
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def consumed_total(self) -> str:
|
|
447
|
+
return self.consumed_counter or f"{self.namespace}_messages_consumed_total"
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def processing_seconds(self) -> str:
|
|
451
|
+
return self.processing_histogram or f"{self.namespace}_message_processing_seconds"
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def messages_acked_total(self) -> str:
|
|
455
|
+
return f"{self.namespace}_messages_acked_total"
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def messages_nacked_total(self) -> str:
|
|
459
|
+
return f"{self.namespace}_messages_nacked_total"
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def messages_rejected_total(self) -> str:
|
|
463
|
+
return f"{self.namespace}_messages_rejected_total"
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def messages_retried_total(self) -> str:
|
|
467
|
+
return f"{self.namespace}_messages_retried_total"
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def messages_dead_lettered_total(self) -> str:
|
|
471
|
+
return f"{self.namespace}_messages_dead_lettered_total"
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def dedup_fallback_total(self) -> str:
|
|
475
|
+
"""M9: incremented every time DeduplicationMiddleware falls back to
|
|
476
|
+
processing a message despite a Redis error (idempotency is not
|
|
477
|
+
enforced for that message) — see
|
|
478
|
+
``DeduplicationConfig.fallback_on_redis_error``."""
|
|
479
|
+
return f"{self.namespace}_dedup_fallback_total"
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def rate_limit_dropped_total(self) -> str:
|
|
483
|
+
"""L5: incremented every time RateLimitMiddleware settles a message
|
|
484
|
+
without calling the handler — nack/drop policy, or the "wait" policy's
|
|
485
|
+
deadline elapsing with no token acquired. Labeled by ``reason``
|
|
486
|
+
(``nack``/``drop``/``wait_deadline_exceeded``)."""
|
|
487
|
+
return f"{self.namespace}_rate_limit_dropped_total"
|
|
488
|
+
|
|
489
|
+
@property
|
|
490
|
+
def messages_redelivered_total(self) -> str:
|
|
491
|
+
"""Incremented (labeled by ``queue``) for every consumed message the
|
|
492
|
+
broker flagged ``redelivered=True`` — the broker-redelivery-rate
|
|
493
|
+
signal. A sustained rise means handlers are dying/timing out before
|
|
494
|
+
acking (crash loops, heartbeat kills, connection churn), which the
|
|
495
|
+
success/error consume counters alone can't distinguish from ordinary
|
|
496
|
+
traffic."""
|
|
497
|
+
return f"{self.namespace}_messages_redelivered_total"
|
|
498
|
+
|
|
499
|
+
@property
|
|
500
|
+
def reconnects_total(self) -> str:
|
|
501
|
+
"""Incremented on every transport re-connection after the first
|
|
502
|
+
successful connect — the connection-churn signal. Reconnects were
|
|
503
|
+
previously logged but never counted, so a flapping broker/network
|
|
504
|
+
was invisible to metrics-based alerting."""
|
|
505
|
+
return f"{self.namespace}_reconnects_total"
|
|
506
|
+
|
|
507
|
+
@property
|
|
508
|
+
def published_total(self) -> str:
|
|
509
|
+
return self.published_counter or f"{self.namespace}_messages_published_total"
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def publish_total(self) -> str:
|
|
513
|
+
return self.published_counter or f"{self.namespace}_publish_total"
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def publish_failures_total(self) -> str:
|
|
517
|
+
return f"{self.namespace}_publish_failures_total"
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def publish_confirm_latency_seconds(self) -> str:
|
|
521
|
+
return f"{self.namespace}_publish_confirm_latency_seconds"
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def publish_seconds(self) -> str:
|
|
525
|
+
return self.publish_histogram or f"{self.namespace}_message_publish_seconds"
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def in_flight_messages(self) -> str:
|
|
529
|
+
return f"{self.namespace}_in_flight_messages"
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def worker_pool_pending(self) -> str:
|
|
533
|
+
return f"{self.namespace}_worker_pool_pending"
|
|
534
|
+
|
|
535
|
+
@property
|
|
536
|
+
def broker_connected(self) -> str:
|
|
537
|
+
return f"{self.namespace}_broker_connected"
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def consumer_active(self) -> str:
|
|
541
|
+
return f"{self.namespace}_consumer_active"
|
|
542
|
+
|
|
543
|
+
# ── Broker-side gauges (H5: polled from the management API) ──
|
|
544
|
+
# Bridged by QueueMetricsPoller — the #1 RabbitMQ incident signal
|
|
545
|
+
# (queue growth / consumer lag) that the consume/publish counters cannot
|
|
546
|
+
# see. All labeled by {queue}.
|
|
547
|
+
|
|
548
|
+
@property
|
|
549
|
+
def queue_messages_ready(self) -> str:
|
|
550
|
+
"""Messages ready for delivery (backlog depth)."""
|
|
551
|
+
return f"{self.namespace}_queue_messages_ready"
|
|
552
|
+
|
|
553
|
+
@property
|
|
554
|
+
def queue_messages_unacked(self) -> str:
|
|
555
|
+
"""Messages delivered but not yet acked (in-flight at consumers)."""
|
|
556
|
+
return f"{self.namespace}_queue_messages_unacked"
|
|
557
|
+
|
|
558
|
+
@property
|
|
559
|
+
def queue_messages_total(self) -> str:
|
|
560
|
+
"""Total messages in the queue (ready + unacked)."""
|
|
561
|
+
return f"{self.namespace}_queue_messages_total"
|
|
562
|
+
|
|
563
|
+
@property
|
|
564
|
+
def queue_consumers(self) -> str:
|
|
565
|
+
"""Number of consumers attached to the queue (0 = nothing draining)."""
|
|
566
|
+
return f"{self.namespace}_queue_consumers"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ── Health Check ──────────────────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@dataclass(frozen=True, slots=True)
|
|
573
|
+
class HealthCheckConfig:
|
|
574
|
+
"""Thresholds for broker_health_check()."""
|
|
575
|
+
|
|
576
|
+
pending_threshold: int = 100
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ── Sentinel ──────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class RetryDisabled:
|
|
583
|
+
"""Typed singleton — explicitly disables retry on a route.
|
|
584
|
+
|
|
585
|
+
Distinct from RetryConfig(max_retries=0) which means 'retry-owned
|
|
586
|
+
terminal semantics with zero retry attempts (immediate DLQ on any
|
|
587
|
+
classified error).'
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
_instance: RetryDisabled | None = None
|
|
591
|
+
|
|
592
|
+
def __new__(cls) -> RetryDisabled:
|
|
593
|
+
if cls._instance is None:
|
|
594
|
+
cls._instance = super().__new__(cls)
|
|
595
|
+
return cls._instance
|
|
596
|
+
|
|
597
|
+
def __repr__(self) -> str:
|
|
598
|
+
return "RETRY_DISABLED"
|
|
599
|
+
|
|
600
|
+
def __bool__(self) -> bool:
|
|
601
|
+
return False
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
RETRY_DISABLED = RetryDisabled()
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ── Deduplication (active) ──────────────────────────────────
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@dataclass(frozen=True, slots=True)
|
|
611
|
+
class DeduplicationConfig:
|
|
612
|
+
"""Deduplication configuration. Active.
|
|
613
|
+
|
|
614
|
+
``mark_policy`` accepts a :class:`~rabbitkit.core.types.DeduplicationMarkPolicy`
|
|
615
|
+
member or its string value:
|
|
616
|
+
|
|
617
|
+
- ``"on_success"`` (default) — crash-safe; concurrent duplicates may both run.
|
|
618
|
+
- ``"on_start"`` — blocks concurrent duplicates but a crash mid-handler
|
|
619
|
+
LOSES the message. Advanced/dangerous.
|
|
620
|
+
- ``"claim"`` — in-flight claim (``processing_timeout``) before the
|
|
621
|
+
handler, "completed" (``ttl``) after success. Blocks concurrent
|
|
622
|
+
duplicates and survives crashes; ``processing_timeout`` must
|
|
623
|
+
comfortably exceed the worst-case handler duration or a duplicate can
|
|
624
|
+
start while the original is still running.
|
|
625
|
+
"""
|
|
626
|
+
|
|
627
|
+
key_prefix: str = "rabbitkit:dedup"
|
|
628
|
+
ttl: int = 86400
|
|
629
|
+
fallback_on_redis_error: bool = True
|
|
630
|
+
key_source: str = "message_id"
|
|
631
|
+
mark_policy: str = "on_success"
|
|
632
|
+
local_cache_size: int = 0 # 0 = disabled; >0 = in-process LRU capacity (short-circuits Redis for known duplicates)
|
|
633
|
+
processing_timeout: int = 300 # claim only: in-flight claim TTL (seconds)
|
|
634
|
+
# F5 (idempotent receiver): with mark_policy="claim", store the handler's
|
|
635
|
+
# JSON-serializable result alongside the completed mark; a duplicate
|
|
636
|
+
# delivery then REPLAYS the stored result (the pipeline re-publishes it to
|
|
637
|
+
# the route's result publisher / reply_to, byte-identical) instead of just
|
|
638
|
+
# skipping. Results that aren't JSON-serializable or exceed
|
|
639
|
+
# max_result_bytes degrade gracefully to plain skip-without-replay.
|
|
640
|
+
# This is the idempotent-receiver EFFECT — wire-level exactly-once does
|
|
641
|
+
# not exist on RabbitMQ and this does not claim otherwise.
|
|
642
|
+
store_results: bool = False
|
|
643
|
+
max_result_bytes: int = 65536
|
|
644
|
+
on_in_flight: str = "nack_requeue" # claim only: "nack_requeue" (retry-safe) | "ack_skip"
|
|
645
|
+
|
|
646
|
+
def __post_init__(self) -> None:
|
|
647
|
+
if self.store_results and self.mark_policy != "claim":
|
|
648
|
+
raise ValueError(
|
|
649
|
+
"DeduplicationConfig.store_results=True requires mark_policy='claim' "
|
|
650
|
+
f"(got {self.mark_policy!r}) — result replay is only crash-safe on the "
|
|
651
|
+
"claim state machine."
|
|
652
|
+
)
|
|
653
|
+
if self.max_result_bytes < 1:
|
|
654
|
+
raise ValueError(
|
|
655
|
+
f"DeduplicationConfig.max_result_bytes must be >= 1, got {self.max_result_bytes}"
|
|
656
|
+
)
|
|
657
|
+
if self.mark_policy not in ("on_success", "on_start", "claim"):
|
|
658
|
+
raise ValueError(
|
|
659
|
+
f"DeduplicationConfig.mark_policy must be one of "
|
|
660
|
+
f"'on_success', 'on_start', 'claim'; got {self.mark_policy!r}"
|
|
661
|
+
)
|
|
662
|
+
if self.on_in_flight not in ("nack_requeue", "ack_skip"):
|
|
663
|
+
raise ValueError(
|
|
664
|
+
f"DeduplicationConfig.on_in_flight must be 'nack_requeue' or "
|
|
665
|
+
f"'ack_skip'; got {self.on_in_flight!r}"
|
|
666
|
+
)
|
|
667
|
+
if self.processing_timeout <= 0:
|
|
668
|
+
raise ValueError(
|
|
669
|
+
f"DeduplicationConfig.processing_timeout must be > 0, got {self.processing_timeout}"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ── Safety (active) ─────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@dataclass(frozen=True, slots=True)
|
|
677
|
+
class SafetyConfig:
|
|
678
|
+
"""Message-safety policies. Active.
|
|
679
|
+
|
|
680
|
+
``reject_without_dlx`` — what to do when a route can
|
|
681
|
+
``reject(requeue=False)`` but its queue has no dead-letter exchange
|
|
682
|
+
(RabbitMQ silently discards such rejects). Accepts a
|
|
683
|
+
:class:`~rabbitkit.core.types.RejectWithoutDLXPolicy` member or its
|
|
684
|
+
string value:
|
|
685
|
+
|
|
686
|
+
- ``"auto_provision"`` (default): declare ``{queue}{dlq_suffix}`` and
|
|
687
|
+
wire the source queue's DLX to it — poison messages are preserved.
|
|
688
|
+
- ``"error"``: fail startup with ``UnsafeTopologyError``. For teams that
|
|
689
|
+
manage topology externally.
|
|
690
|
+
- ``"discard"``: explicitly allow RabbitMQ to discard rejected messages
|
|
691
|
+
(warns once per route unless ``warn_on_discard=False``).
|
|
692
|
+
|
|
693
|
+
Only applied under ``TopologyMode.AUTO_DECLARE``; per-route override via
|
|
694
|
+
``@subscriber(reject_without_dlx=...)``.
|
|
695
|
+
"""
|
|
696
|
+
|
|
697
|
+
reject_without_dlx: str = "auto_provision"
|
|
698
|
+
dlq_suffix: str = ".dlq"
|
|
699
|
+
warn_on_discard: bool = True
|
|
700
|
+
# M14: what to do when declaring a queue/exchange 406s because it already
|
|
701
|
+
# exists with incompatible arguments (drift — e.g. ops created it, or a
|
|
702
|
+
# prior rabbitkit version, with a different type/TTL/DLX).
|
|
703
|
+
# - "raise" (default): fail startup with a typed ConfigurationError. Safe
|
|
704
|
+
# default — surfaces the drift instead of silently ignoring your config.
|
|
705
|
+
# - "warn_continue": log a warning and CONTINUE using the EXISTING
|
|
706
|
+
# definition (rabbitkit's declaration is NOT applied). Unlike
|
|
707
|
+
# TopologyMode.PASSIVE_ONLY (which skips declaration for EVERY queue),
|
|
708
|
+
# this still actively declares non-conflicting queues and only tolerates
|
|
709
|
+
# the ones that drifted — the per-conflict warn-and-continue mode.
|
|
710
|
+
on_topology_conflict: str = "raise"
|
|
711
|
+
|
|
712
|
+
def __post_init__(self) -> None:
|
|
713
|
+
if self.reject_without_dlx not in ("auto_provision", "error", "discard"):
|
|
714
|
+
raise ValueError(
|
|
715
|
+
f"SafetyConfig.reject_without_dlx must be one of "
|
|
716
|
+
f"'auto_provision', 'error', 'discard'; got {self.reject_without_dlx!r}"
|
|
717
|
+
)
|
|
718
|
+
if not self.dlq_suffix:
|
|
719
|
+
raise ValueError("SafetyConfig.dlq_suffix must be non-empty")
|
|
720
|
+
if self.on_topology_conflict not in ("raise", "warn_continue"):
|
|
721
|
+
raise ValueError(
|
|
722
|
+
f"SafetyConfig.on_topology_conflict must be 'raise' or 'warn_continue'; "
|
|
723
|
+
f"got {self.on_topology_conflict!r}"
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# ── Backpressure (active) ───────────────────────────────────
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@dataclass(frozen=True, slots=True)
|
|
731
|
+
class BackpressureConfig:
|
|
732
|
+
"""Backpressure configuration. Active."""
|
|
733
|
+
|
|
734
|
+
max_in_flight: int = 1000
|
|
735
|
+
rate_limit: int | None = None
|
|
736
|
+
blocked_timeout: float = 60.0
|
|
737
|
+
on_blocked: str = "wait"
|
|
738
|
+
poll_interval_ms: int = 10
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# ── Batch (active) ──────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
@dataclass(frozen=True, slots=True)
|
|
745
|
+
class BatchPublishConfig:
|
|
746
|
+
"""Batch publish configuration. Active."""
|
|
747
|
+
|
|
748
|
+
batch_size: int = 100
|
|
749
|
+
flush_interval_ms: int = 50
|
|
750
|
+
max_in_flight: int = 1000
|
|
751
|
+
flush_workers: int = 0 # 0 = auto (min(16, max_in_flight // batch_size)); >0 = explicit count
|
|
752
|
+
|
|
753
|
+
def __post_init__(self) -> None:
|
|
754
|
+
if self.batch_size <= 0:
|
|
755
|
+
raise ValueError(f"BatchPublishConfig.batch_size must be > 0, got {self.batch_size}")
|
|
756
|
+
if self.flush_interval_ms < 0:
|
|
757
|
+
raise ValueError(
|
|
758
|
+
f"BatchPublishConfig.flush_interval_ms must be >= 0, got {self.flush_interval_ms}"
|
|
759
|
+
)
|
|
760
|
+
if self.max_in_flight <= 0:
|
|
761
|
+
raise ValueError(
|
|
762
|
+
f"BatchPublishConfig.max_in_flight must be > 0, got {self.max_in_flight}"
|
|
763
|
+
)
|
|
764
|
+
if self.flush_workers < 0:
|
|
765
|
+
raise ValueError(
|
|
766
|
+
f"BatchPublishConfig.flush_workers must be >= 0, got {self.flush_workers}"
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@dataclass(frozen=True, slots=True)
|
|
771
|
+
class BatchAckConfig:
|
|
772
|
+
"""Batch ack configuration. Active."""
|
|
773
|
+
|
|
774
|
+
batch_size: int = 100
|
|
775
|
+
flush_interval_ms: int = 200
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# ── Worker (active) ─────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@dataclass(frozen=True, slots=True)
|
|
782
|
+
class WorkerConfig:
|
|
783
|
+
"""Consumer concurrency configuration.
|
|
784
|
+
|
|
785
|
+
Accepted by broker.start(), NOT part of RabbitConfig. Added in 0.2.0.
|
|
786
|
+
|
|
787
|
+
``stop_timeout`` (H12): the drain deadline given to a multi-worker pool's
|
|
788
|
+
``stop()`` — it must exceed your slowest handler's expected run time, and
|
|
789
|
+
should be a few seconds *less* than ``terminationGracePeriodSeconds`` (k8s)
|
|
790
|
+
so the graceful drain always has a chance to finish before SIGKILL. A
|
|
791
|
+
handler still running past this deadline is **abandoned, not killed**:
|
|
792
|
+
the sync pool's daemon thread keeps running in the background (it is
|
|
793
|
+
never forcibly stopped — Python cannot interrupt an arbitrary thread),
|
|
794
|
+
and the async pool cancels the task (which does not guarantee the
|
|
795
|
+
handler reaches its own ack/nack — ``CancelledError`` is a
|
|
796
|
+
``BaseException`` and is not caught by the pipeline's exception
|
|
797
|
+
handling). Either way the abandoned delivery is logged by delivery
|
|
798
|
+
tag/message id, and — for the async pool — nacked for redelivery
|
|
799
|
+
immediately rather than relying on the implicit requeue that happens
|
|
800
|
+
when the connection eventually closes. Because the original handler may
|
|
801
|
+
still complete its side effects after abandonment, **handlers must be
|
|
802
|
+
idempotent under at-least-once delivery** regardless of ``stop_timeout``.
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
worker_count: int = 1
|
|
806
|
+
prefetch_per_worker: int | None = None
|
|
807
|
+
stop_timeout: float = 30.0
|
|
808
|
+
# M11: bound the sync worker pool's internal work queue. 0 = unbounded
|
|
809
|
+
# (default, unchanged). In practice the broker's prefetch already caps
|
|
810
|
+
# in-flight messages (effective prefetch = worker_count x
|
|
811
|
+
# prefetch_per_worker), so this is a defensive ceiling — set it >= your
|
|
812
|
+
# effective prefetch so it only trips if prefetch isn't being honored.
|
|
813
|
+
# When the queue is full, submit() blocks (backpressure); keep it above
|
|
814
|
+
# prefetch so that never happens on the I/O thread.
|
|
815
|
+
max_queue_size: int = 0
|
|
816
|
+
|
|
817
|
+
def __post_init__(self) -> None:
|
|
818
|
+
if self.worker_count < 1:
|
|
819
|
+
raise ValueError(f"WorkerConfig.worker_count must be >= 1, got {self.worker_count}")
|
|
820
|
+
if self.max_queue_size < 0:
|
|
821
|
+
raise ValueError(f"WorkerConfig.max_queue_size must be >= 0, got {self.max_queue_size}")
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
# ── Top-Level Config ─────────────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
@dataclass(frozen=True, slots=True)
|
|
828
|
+
class RabbitConfig:
|
|
829
|
+
"""Top-level config — composes focused config objects.
|
|
830
|
+
|
|
831
|
+
Frozen + slots (per project convention). Brokers that need to apply
|
|
832
|
+
per-route overrides (e.g. prefetch) hold a private ``dataclasses.replace``
|
|
833
|
+
copy rather than mutating the caller's object.
|
|
834
|
+
|
|
835
|
+
Only connection/broker defaults. Throughput/batching configs
|
|
836
|
+
are accepted by their respective components directly.
|
|
837
|
+
"""
|
|
838
|
+
|
|
839
|
+
connection: ConnectionConfig = field(default_factory=ConnectionConfig)
|
|
840
|
+
socket: SocketConfig = field(default_factory=SocketConfig)
|
|
841
|
+
security: SecurityConfig = field(default_factory=SecurityConfig)
|
|
842
|
+
publisher: PublisherConfig = field(default_factory=PublisherConfig)
|
|
843
|
+
consumer: ConsumerConfig = field(default_factory=ConsumerConfig)
|
|
844
|
+
pool: PoolConfig = field(default_factory=PoolConfig)
|
|
845
|
+
topology_mode: TopologyMode = TopologyMode.AUTO_DECLARE
|
|
846
|
+
safety: SafetyConfig = field(default_factory=SafetyConfig)
|
|
847
|
+
retry: RetryConfig | None = None
|
|
848
|
+
compression: CompressionConfig | None = None
|
|
849
|
+
logging: LoggingConfig | None = None
|