redqueue 0.10.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.
- redqueue/__init__.py +72 -0
- redqueue/_version.py +6 -0
- redqueue/async_client.py +194 -0
- redqueue/backends/__init__.py +20 -0
- redqueue/backends/async_delay.py +242 -0
- redqueue/backends/async_list.py +308 -0
- redqueue/backends/async_stream.py +394 -0
- redqueue/backends/base.py +103 -0
- redqueue/backends/delay.py +243 -0
- redqueue/backends/list.py +303 -0
- redqueue/backends/stream.py +370 -0
- redqueue/client.py +168 -0
- redqueue/compat.py +184 -0
- redqueue/config.py +155 -0
- redqueue/exceptions.py +167 -0
- redqueue/message.py +67 -0
- redqueue/monitoring.py +112 -0
- redqueue/serialization.py +79 -0
- redqueue-0.10.0.dist-info/METADATA +312 -0
- redqueue-0.10.0.dist-info/RECORD +23 -0
- redqueue-0.10.0.dist-info/WHEEL +4 -0
- redqueue-0.10.0.dist-info/licenses/LICENSE +158 -0
- redqueue-0.10.0.dist-info/licenses/NOTICE +4 -0
redqueue/compat.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""Redis version and capability detection."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from redqueue.exceptions import BackendUnavailableError, RedisCompatibilityError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class RedisInfoClient(Protocol):
|
|
17
|
+
"""Protocol for sync Redis clients that expose INFO."""
|
|
18
|
+
|
|
19
|
+
def info(self, section: str | None = None) -> Mapping[str, Any]:
|
|
20
|
+
"""Return Redis INFO fields."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class AsyncRedisInfoClient(Protocol):
|
|
25
|
+
"""Protocol for async Redis clients that expose INFO."""
|
|
26
|
+
|
|
27
|
+
async def info(self, section: str | None = None) -> Mapping[str, Any]:
|
|
28
|
+
"""Return Redis INFO fields."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, order=True)
|
|
32
|
+
class RedisVersion:
|
|
33
|
+
"""Comparable Redis semantic version subset."""
|
|
34
|
+
|
|
35
|
+
major: int
|
|
36
|
+
minor: int
|
|
37
|
+
patch: int = 0
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def parse(cls, value: str) -> RedisVersion:
|
|
41
|
+
version_text = value.strip()
|
|
42
|
+
release_text = version_text.split("-", 1)[0]
|
|
43
|
+
parts = release_text.split(".")
|
|
44
|
+
if len(parts) < 2:
|
|
45
|
+
raise ValueError(f"invalid Redis version: {value!r}")
|
|
46
|
+
try:
|
|
47
|
+
major = cls._parse_part(parts[0], value)
|
|
48
|
+
minor = cls._parse_part(parts[1], value)
|
|
49
|
+
patch = cls._parse_part(parts[2], value) if len(parts) > 2 else 0
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
raise ValueError(f"invalid Redis version: {value!r}") from exc
|
|
52
|
+
return cls(major=major, minor=minor, patch=patch)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _parse_part(part: str, original: str) -> int:
|
|
56
|
+
digits = ""
|
|
57
|
+
for char in part:
|
|
58
|
+
if char.isdigit():
|
|
59
|
+
digits += char
|
|
60
|
+
continue
|
|
61
|
+
break
|
|
62
|
+
if not digits:
|
|
63
|
+
raise ValueError(f"invalid Redis version: {original!r}")
|
|
64
|
+
return int(digits)
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class RedisCapabilities:
|
|
72
|
+
"""Feature flags derived from Redis server version."""
|
|
73
|
+
|
|
74
|
+
version: RedisVersion
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def supports_list_blocking(self) -> bool:
|
|
78
|
+
return self.version >= RedisVersion(2, 0, 0)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def supports_list_reliable_brpoplpush(self) -> bool:
|
|
82
|
+
return self.version >= RedisVersion(2, 2, 0)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def supports_list_reliable_blmove(self) -> bool:
|
|
86
|
+
return self.version >= RedisVersion(6, 2, 0)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def supports_streams(self) -> bool:
|
|
90
|
+
return self.version >= RedisVersion(5, 0, 0)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def supports_streams_auto_claim(self) -> bool:
|
|
94
|
+
return self.version >= RedisVersion(6, 2, 0)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def supports_delay_sorted_set(self) -> bool:
|
|
98
|
+
return self.version >= RedisVersion(1, 2, 0)
|
|
99
|
+
|
|
100
|
+
def require_streams(self) -> None:
|
|
101
|
+
if not self.supports_streams:
|
|
102
|
+
raise RedisCompatibilityError.for_feature(
|
|
103
|
+
"Streams backend",
|
|
104
|
+
current_version=self.version,
|
|
105
|
+
required_version="5.0.0",
|
|
106
|
+
action="redis.require_streams",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def require_list_reliable(self) -> None:
|
|
110
|
+
if not self.supports_list_reliable_brpoplpush:
|
|
111
|
+
raise RedisCompatibilityError.for_feature(
|
|
112
|
+
"List reliable backend",
|
|
113
|
+
current_version=self.version,
|
|
114
|
+
required_version="2.2.0",
|
|
115
|
+
action="redis.require_list_reliable",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def require_delay_sorted_set(self) -> None:
|
|
119
|
+
if not self.supports_delay_sorted_set:
|
|
120
|
+
raise RedisCompatibilityError.for_feature(
|
|
121
|
+
"Delayed task backend",
|
|
122
|
+
current_version=self.version,
|
|
123
|
+
required_version="1.2.0",
|
|
124
|
+
action="redis.require_delay_sorted_set",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_info(cls, info: Mapping[str, Any]) -> RedisCapabilities:
|
|
129
|
+
"""Build capabilities from Redis INFO output."""
|
|
130
|
+
|
|
131
|
+
version = extract_redis_version(info)
|
|
132
|
+
return cls(version=version)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_redis_version(info: Mapping[str, Any]) -> RedisVersion:
|
|
136
|
+
"""Extract and parse redis_version from INFO server output."""
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
version_value = info["redis_version"]
|
|
140
|
+
except KeyError as exc:
|
|
141
|
+
raise BackendUnavailableError(
|
|
142
|
+
"Redis INFO server response does not contain redis_version",
|
|
143
|
+
action="redis.info",
|
|
144
|
+
) from exc
|
|
145
|
+
if isinstance(version_value, bytes):
|
|
146
|
+
version_text = version_value.decode("ascii")
|
|
147
|
+
else:
|
|
148
|
+
version_text = str(version_value)
|
|
149
|
+
try:
|
|
150
|
+
return RedisVersion.parse(version_text)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise BackendUnavailableError(
|
|
153
|
+
f"Redis INFO server returned invalid redis_version {version_text!r}",
|
|
154
|
+
action="redis.info",
|
|
155
|
+
details={"redis_version": version_text},
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def detect_capabilities(client: RedisInfoClient) -> RedisCapabilities:
|
|
160
|
+
"""Detect Redis capabilities using a synchronous Redis client."""
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
info = client.info("server")
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
raise BackendUnavailableError(
|
|
166
|
+
"Failed to read Redis INFO server",
|
|
167
|
+
action="redis.info",
|
|
168
|
+
) from exc
|
|
169
|
+
return RedisCapabilities.from_info(info)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def detect_capabilities_async(
|
|
173
|
+
client: AsyncRedisInfoClient,
|
|
174
|
+
) -> RedisCapabilities:
|
|
175
|
+
"""Detect Redis capabilities using an asynchronous Redis client."""
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
info = await client.info("server")
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
raise BackendUnavailableError(
|
|
181
|
+
"Failed to read Redis INFO server",
|
|
182
|
+
action="redis.info",
|
|
183
|
+
) from exc
|
|
184
|
+
return RedisCapabilities.from_info(info)
|
redqueue/config.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""Configuration models for RedQueue."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from redqueue.exceptions import QueueConfigError
|
|
13
|
+
from redqueue.monitoring import MonitoringHook, NoopMonitoringHook, SafeMonitoringHook
|
|
14
|
+
from redqueue.serialization import JsonSerializer, Serializer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BackendType(str, Enum):
|
|
18
|
+
"""Supported Redis queue backends."""
|
|
19
|
+
|
|
20
|
+
LIST = "list"
|
|
21
|
+
STREAM = "stream"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def coerce(cls, value: str | BackendType) -> BackendType:
|
|
25
|
+
"""Convert user input into a backend enum with RedQueue errors."""
|
|
26
|
+
|
|
27
|
+
if isinstance(value, cls):
|
|
28
|
+
return value
|
|
29
|
+
try:
|
|
30
|
+
return cls(value)
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
supported = ", ".join(item.value for item in cls)
|
|
33
|
+
raise QueueConfigError(
|
|
34
|
+
f"unsupported backend {value!r}; supported backends: {supported}"
|
|
35
|
+
) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RetryConfig:
|
|
40
|
+
"""Retry behavior for failed messages."""
|
|
41
|
+
|
|
42
|
+
max_retries: int = 3
|
|
43
|
+
base_delay_seconds: float = 0.0
|
|
44
|
+
max_delay_seconds: float | None = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if self.max_retries < 0:
|
|
48
|
+
raise QueueConfigError("max_retries must be greater than or equal to 0")
|
|
49
|
+
if self.base_delay_seconds < 0:
|
|
50
|
+
raise QueueConfigError(
|
|
51
|
+
"base_delay_seconds must be greater than or equal to 0"
|
|
52
|
+
)
|
|
53
|
+
if self.max_delay_seconds is not None and self.max_delay_seconds < 0:
|
|
54
|
+
raise QueueConfigError(
|
|
55
|
+
"max_delay_seconds must be greater than or equal to 0"
|
|
56
|
+
)
|
|
57
|
+
if (
|
|
58
|
+
self.max_delay_seconds is not None
|
|
59
|
+
and self.max_delay_seconds < self.base_delay_seconds
|
|
60
|
+
):
|
|
61
|
+
raise QueueConfigError(
|
|
62
|
+
"max_delay_seconds must be greater than or equal to base_delay_seconds"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def next_delay(self, attempts: int) -> float:
|
|
66
|
+
"""Return the retry delay for an attempt count."""
|
|
67
|
+
|
|
68
|
+
if attempts < 0:
|
|
69
|
+
raise QueueConfigError("attempts must be greater than or equal to 0")
|
|
70
|
+
delay = self.base_delay_seconds * attempts
|
|
71
|
+
if self.max_delay_seconds is not None:
|
|
72
|
+
return min(delay, self.max_delay_seconds)
|
|
73
|
+
return delay
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class QueueConfig:
|
|
78
|
+
"""Queue configuration shared by sync and async clients."""
|
|
79
|
+
|
|
80
|
+
queue: str
|
|
81
|
+
backend: BackendType | str = BackendType.LIST
|
|
82
|
+
enable_delay: bool = False
|
|
83
|
+
namespace: str = "rq"
|
|
84
|
+
retry: RetryConfig = field(default_factory=RetryConfig)
|
|
85
|
+
monitoring: MonitoringHook = field(
|
|
86
|
+
default_factory=lambda: SafeMonitoringHook(NoopMonitoringHook())
|
|
87
|
+
)
|
|
88
|
+
serializer: Serializer = field(default_factory=JsonSerializer)
|
|
89
|
+
visibility_timeout_seconds: float = 300.0
|
|
90
|
+
consumer_group: str = "redqueue"
|
|
91
|
+
consumer_name: str | None = None
|
|
92
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
queue = self._normalize_name(self.queue, field_name="queue")
|
|
96
|
+
namespace = self._normalize_name(self.namespace, field_name="namespace")
|
|
97
|
+
object.__setattr__(self, "queue", queue)
|
|
98
|
+
object.__setattr__(self, "namespace", namespace)
|
|
99
|
+
object.__setattr__(self, "backend", BackendType.coerce(self.backend))
|
|
100
|
+
|
|
101
|
+
if self.visibility_timeout_seconds <= 0:
|
|
102
|
+
raise QueueConfigError("visibility_timeout_seconds must be greater than 0")
|
|
103
|
+
if not isinstance(self.retry, RetryConfig):
|
|
104
|
+
raise QueueConfigError("retry must be a RetryConfig instance")
|
|
105
|
+
if not hasattr(self.monitoring, "emit"):
|
|
106
|
+
raise QueueConfigError(
|
|
107
|
+
"monitoring must implement the MonitoringHook protocol"
|
|
108
|
+
)
|
|
109
|
+
if not isinstance(self.monitoring, SafeMonitoringHook):
|
|
110
|
+
object.__setattr__(
|
|
111
|
+
self,
|
|
112
|
+
"monitoring",
|
|
113
|
+
SafeMonitoringHook(self.monitoring),
|
|
114
|
+
)
|
|
115
|
+
if not isinstance(self.serializer, Serializer):
|
|
116
|
+
raise QueueConfigError("serializer must implement the Serializer protocol")
|
|
117
|
+
if self.consumer_group is not None:
|
|
118
|
+
consumer_group = self.consumer_group.strip()
|
|
119
|
+
if not consumer_group:
|
|
120
|
+
raise QueueConfigError("consumer_group must not be empty")
|
|
121
|
+
object.__setattr__(self, "consumer_group", consumer_group)
|
|
122
|
+
if self.consumer_name is not None:
|
|
123
|
+
consumer_name = self.consumer_name.strip()
|
|
124
|
+
if not consumer_name:
|
|
125
|
+
raise QueueConfigError("consumer_name must not be empty")
|
|
126
|
+
object.__setattr__(self, "consumer_name", consumer_name)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _normalize_name(value: str, *, field_name: str) -> str:
|
|
130
|
+
if not isinstance(value, str):
|
|
131
|
+
raise QueueConfigError(f"{field_name} must be a string")
|
|
132
|
+
normalized = value.strip()
|
|
133
|
+
if not normalized:
|
|
134
|
+
raise QueueConfigError(f"{field_name} must not be empty")
|
|
135
|
+
if any(ch.isspace() for ch in normalized):
|
|
136
|
+
raise QueueConfigError(f"{field_name} must not contain whitespace")
|
|
137
|
+
return normalized
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def backend_type(self) -> BackendType:
|
|
141
|
+
"""Normalized backend type."""
|
|
142
|
+
|
|
143
|
+
return BackendType.coerce(self.backend)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def key_prefix(self) -> str:
|
|
147
|
+
"""Redis key prefix for this queue."""
|
|
148
|
+
|
|
149
|
+
return f"{self.namespace}:{{{self.queue}}}"
|
|
150
|
+
|
|
151
|
+
def key(self, suffix: str) -> str:
|
|
152
|
+
"""Build a namespaced Redis key for a queue-owned data structure."""
|
|
153
|
+
|
|
154
|
+
suffix = self._normalize_name(suffix, field_name="suffix")
|
|
155
|
+
return f"{self.key_prefix}:{suffix}"
|
redqueue/exceptions.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""RedQueue exception hierarchy."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ErrorContext:
|
|
14
|
+
"""Structured context attached to RedQueue errors."""
|
|
15
|
+
|
|
16
|
+
action: str | None = None
|
|
17
|
+
queue: str | None = None
|
|
18
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
def as_dict(self) -> dict[str, Any]:
|
|
21
|
+
"""Return only populated context fields."""
|
|
22
|
+
|
|
23
|
+
data: dict[str, Any] = {}
|
|
24
|
+
if self.action:
|
|
25
|
+
data["action"] = self.action
|
|
26
|
+
if self.queue:
|
|
27
|
+
data["queue"] = self.queue
|
|
28
|
+
if self.details:
|
|
29
|
+
data["details"] = dict(self.details)
|
|
30
|
+
return data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RedQueueError(Exception):
|
|
34
|
+
"""Base class for all RedQueue errors."""
|
|
35
|
+
|
|
36
|
+
default_message = "RedQueue operation failed"
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
message: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
action: str | None = None,
|
|
43
|
+
queue: str | None = None,
|
|
44
|
+
details: dict[str, Any] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.message = message or self.default_message
|
|
47
|
+
self.context = ErrorContext(
|
|
48
|
+
action=action,
|
|
49
|
+
queue=queue,
|
|
50
|
+
details=dict(details or {}),
|
|
51
|
+
)
|
|
52
|
+
super().__init__(self.__str__())
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
context = self.context.as_dict()
|
|
56
|
+
if not context:
|
|
57
|
+
return self.message
|
|
58
|
+
context_text = ", ".join(
|
|
59
|
+
f"{key}={value!r}" for key, value in context.items()
|
|
60
|
+
)
|
|
61
|
+
return f"{self.message} ({context_text})"
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
"""Return a structured representation suitable for logs or APIs."""
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"type": self.__class__.__name__,
|
|
68
|
+
"message": self.message,
|
|
69
|
+
**self.context.as_dict(),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RedisCompatibilityError(RedQueueError):
|
|
74
|
+
"""Raised when the connected Redis server lacks a required capability."""
|
|
75
|
+
|
|
76
|
+
default_message = "Redis server does not meet RedQueue compatibility requirements"
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def for_feature(
|
|
80
|
+
cls,
|
|
81
|
+
feature: str,
|
|
82
|
+
*,
|
|
83
|
+
current_version: Any,
|
|
84
|
+
required_version: str,
|
|
85
|
+
action: str | None = None,
|
|
86
|
+
queue: str | None = None,
|
|
87
|
+
) -> RedisCompatibilityError:
|
|
88
|
+
"""Create a compatibility error with a consistent upgrade message."""
|
|
89
|
+
|
|
90
|
+
return cls(
|
|
91
|
+
f"{feature} requires Redis >= {required_version}, "
|
|
92
|
+
f"current Redis is {current_version}. "
|
|
93
|
+
f"Disable {feature.lower()} or upgrade Redis.",
|
|
94
|
+
action=action,
|
|
95
|
+
queue=queue,
|
|
96
|
+
details={
|
|
97
|
+
"feature": feature,
|
|
98
|
+
"current_version": str(current_version),
|
|
99
|
+
"required_version": required_version,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class QueueConfigError(RedQueueError):
|
|
105
|
+
"""Raised when queue configuration is invalid."""
|
|
106
|
+
|
|
107
|
+
default_message = "Queue configuration is invalid"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MessageEncodeError(RedQueueError):
|
|
111
|
+
"""Raised when a message cannot be encoded."""
|
|
112
|
+
|
|
113
|
+
default_message = "Message encoding failed"
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_exception(
|
|
117
|
+
cls,
|
|
118
|
+
exc: Exception,
|
|
119
|
+
*,
|
|
120
|
+
action: str = "message.encode",
|
|
121
|
+
queue: str | None = None,
|
|
122
|
+
details: dict[str, Any] | None = None,
|
|
123
|
+
) -> MessageEncodeError:
|
|
124
|
+
"""Wrap a lower-level encoding exception while preserving cause."""
|
|
125
|
+
|
|
126
|
+
error = cls(str(exc), action=action, queue=queue, details=details)
|
|
127
|
+
error.__cause__ = exc
|
|
128
|
+
return error
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class MessageDecodeError(RedQueueError):
|
|
132
|
+
"""Raised when a message cannot be decoded."""
|
|
133
|
+
|
|
134
|
+
default_message = "Message decoding failed"
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_exception(
|
|
138
|
+
cls,
|
|
139
|
+
exc: Exception,
|
|
140
|
+
*,
|
|
141
|
+
action: str = "message.decode",
|
|
142
|
+
queue: str | None = None,
|
|
143
|
+
details: dict[str, Any] | None = None,
|
|
144
|
+
) -> MessageDecodeError:
|
|
145
|
+
"""Wrap a lower-level decoding exception while preserving cause."""
|
|
146
|
+
|
|
147
|
+
error = cls(str(exc), action=action, queue=queue, details=details)
|
|
148
|
+
error.__cause__ = exc
|
|
149
|
+
return error
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class BackendUnavailableError(RedQueueError):
|
|
153
|
+
"""Raised when the Redis backend is unavailable or command execution fails."""
|
|
154
|
+
|
|
155
|
+
default_message = "Redis backend is unavailable"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AckError(RedQueueError):
|
|
159
|
+
"""Raised when a message cannot be acknowledged consistently."""
|
|
160
|
+
|
|
161
|
+
default_message = "Message acknowledgement failed"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class RetryExceededError(RedQueueError):
|
|
165
|
+
"""Raised when a message exceeds the configured retry policy."""
|
|
166
|
+
|
|
167
|
+
default_message = "Message retry limit exceeded"
|
redqueue/message.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""Message model used by RedQueue backends."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from time import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from redqueue.exceptions import QueueConfigError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def new_message_id() -> str:
|
|
17
|
+
"""Create a stable opaque message identifier."""
|
|
18
|
+
|
|
19
|
+
return uuid4().hex
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Message:
|
|
24
|
+
"""A normalized message returned by RedQueue consumers."""
|
|
25
|
+
|
|
26
|
+
payload: Any
|
|
27
|
+
queue: str
|
|
28
|
+
id: str = field(default_factory=new_message_id)
|
|
29
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
attempts: int = 0
|
|
31
|
+
created_at: float = field(default_factory=time)
|
|
32
|
+
available_at: float | None = None
|
|
33
|
+
backend: str | None = None
|
|
34
|
+
raw_id: str | None = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
message_id = self._normalize_required(self.id, field_name="id")
|
|
38
|
+
queue = self._normalize_required(self.queue, field_name="queue")
|
|
39
|
+
object.__setattr__(self, "id", message_id)
|
|
40
|
+
object.__setattr__(self, "queue", queue)
|
|
41
|
+
object.__setattr__(self, "headers", dict(self.headers))
|
|
42
|
+
|
|
43
|
+
if self.attempts < 0:
|
|
44
|
+
raise QueueConfigError("attempts must be greater than or equal to 0")
|
|
45
|
+
if self.available_at is not None and self.available_at < 0:
|
|
46
|
+
raise QueueConfigError("available_at must be greater than or equal to 0")
|
|
47
|
+
if self.created_at < 0:
|
|
48
|
+
raise QueueConfigError("created_at must be greater than or equal to 0")
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _normalize_required(value: str, *, field_name: str) -> str:
|
|
52
|
+
if not isinstance(value, str):
|
|
53
|
+
raise QueueConfigError(f"message {field_name} must be a string")
|
|
54
|
+
normalized = value.strip()
|
|
55
|
+
if not normalized:
|
|
56
|
+
raise QueueConfigError(f"message {field_name} must not be empty")
|
|
57
|
+
return normalized
|
|
58
|
+
|
|
59
|
+
def with_attempt(self) -> Message:
|
|
60
|
+
"""Return a copy with attempts incremented by one."""
|
|
61
|
+
|
|
62
|
+
return replace(self, attempts=self.attempts + 1)
|
|
63
|
+
|
|
64
|
+
def with_backend(self, backend: str, *, raw_id: str | None = None) -> Message:
|
|
65
|
+
"""Return a copy tagged with backend-specific metadata."""
|
|
66
|
+
|
|
67
|
+
return replace(self, backend=backend, raw_id=raw_id)
|
redqueue/monitoring.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Author: SpringMirror-pear
|
|
3
|
+
|
|
4
|
+
"""Monitoring hooks for RedQueue lifecycle events."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from time import time
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MonitoringEventType(str, Enum):
|
|
15
|
+
"""Event names emitted by RedQueue."""
|
|
16
|
+
|
|
17
|
+
CLIENT_CREATED = "client.created"
|
|
18
|
+
MESSAGE_PUBLISHED = "message.published"
|
|
19
|
+
MESSAGE_CONSUMED = "message.consumed"
|
|
20
|
+
MESSAGE_ACKED = "message.acked"
|
|
21
|
+
MESSAGE_NACKED = "message.nacked"
|
|
22
|
+
MESSAGE_RETRIED = "message.retried"
|
|
23
|
+
MESSAGE_DEAD_LETTERED = "message.dead_lettered"
|
|
24
|
+
DELAY_SCHEDULED = "delay.scheduled"
|
|
25
|
+
DELAY_RELEASED = "delay.released"
|
|
26
|
+
BACKEND_ERROR = "backend.error"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MonitoringEvent:
|
|
31
|
+
"""A structured monitoring event emitted by clients and backends."""
|
|
32
|
+
|
|
33
|
+
type: MonitoringEventType
|
|
34
|
+
queue: str
|
|
35
|
+
timestamp: float = field(default_factory=time)
|
|
36
|
+
message_id: str | None = None
|
|
37
|
+
backend: str | None = None
|
|
38
|
+
duration_ms: float | None = None
|
|
39
|
+
error: str | None = None
|
|
40
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
"""Return a dictionary representation safe for logs and metrics."""
|
|
44
|
+
|
|
45
|
+
data: dict[str, Any] = {
|
|
46
|
+
"type": self.type.value,
|
|
47
|
+
"queue": self.queue,
|
|
48
|
+
"timestamp": self.timestamp,
|
|
49
|
+
}
|
|
50
|
+
if self.message_id is not None:
|
|
51
|
+
data["message_id"] = self.message_id
|
|
52
|
+
if self.backend is not None:
|
|
53
|
+
data["backend"] = self.backend
|
|
54
|
+
if self.duration_ms is not None:
|
|
55
|
+
data["duration_ms"] = self.duration_ms
|
|
56
|
+
if self.error is not None:
|
|
57
|
+
data["error"] = self.error
|
|
58
|
+
if self.attributes:
|
|
59
|
+
data["attributes"] = dict(self.attributes)
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MonitoringHook(Protocol):
|
|
64
|
+
"""Protocol for metrics, tracing, and logging integrations."""
|
|
65
|
+
|
|
66
|
+
def emit(self, event: MonitoringEvent) -> None:
|
|
67
|
+
"""Handle a monitoring event."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NoopMonitoringHook:
|
|
71
|
+
"""Default monitoring hook that drops events."""
|
|
72
|
+
|
|
73
|
+
def emit(self, event: MonitoringEvent) -> None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InMemoryMonitoringHook:
|
|
78
|
+
"""Monitoring hook that stores events in memory for tests or diagnostics."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self.events: list[MonitoringEvent] = []
|
|
82
|
+
|
|
83
|
+
def emit(self, event: MonitoringEvent) -> None:
|
|
84
|
+
self.events.append(event)
|
|
85
|
+
|
|
86
|
+
def clear(self) -> None:
|
|
87
|
+
self.events.clear()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CompositeMonitoringHook:
|
|
91
|
+
"""Monitoring hook that fans out events to multiple hooks."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, *hooks: MonitoringHook) -> None:
|
|
94
|
+
self.hooks = hooks
|
|
95
|
+
|
|
96
|
+
def emit(self, event: MonitoringEvent) -> None:
|
|
97
|
+
for hook in self.hooks:
|
|
98
|
+
hook.emit(event)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SafeMonitoringHook:
|
|
102
|
+
"""Monitoring hook wrapper that isolates hook failures from queue operations."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, hook: MonitoringHook) -> None:
|
|
105
|
+
self.hook = hook
|
|
106
|
+
self.errors: list[Exception] = []
|
|
107
|
+
|
|
108
|
+
def emit(self, event: MonitoringEvent) -> None:
|
|
109
|
+
try:
|
|
110
|
+
self.hook.emit(event)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
self.errors.append(exc)
|