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/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)