codex-platform 0.1.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.
- codex_platform/__init__.py +1 -0
- codex_platform/notifications/__init__.py +48 -0
- codex_platform/notifications/channels.py +24 -0
- codex_platform/notifications/clients/__init__.py +3 -0
- codex_platform/notifications/clients/smtp.py +134 -0
- codex_platform/notifications/delivery/__init__.py +18 -0
- codex_platform/notifications/delivery/arq.py +69 -0
- codex_platform/notifications/delivery/base.py +39 -0
- codex_platform/notifications/delivery/direct.py +89 -0
- codex_platform/notifications/dto.py +63 -0
- codex_platform/notifications/interfaces.py +30 -0
- codex_platform/notifications/orchestrator.py +116 -0
- codex_platform/notifications/registry.py +71 -0
- codex_platform/notifications/renderer.py +111 -0
- codex_platform/redis_service/__init__.py +59 -0
- codex_platform/redis_service/base.py +69 -0
- codex_platform/redis_service/exceptions.py +20 -0
- codex_platform/redis_service/keys.py +110 -0
- codex_platform/redis_service/managers/__init__.py +18 -0
- codex_platform/redis_service/managers/base_manager.py +31 -0
- codex_platform/redis_service/operations/__init__.py +19 -0
- codex_platform/redis_service/operations/hash.py +302 -0
- codex_platform/redis_service/operations/json_module.py +129 -0
- codex_platform/redis_service/operations/json_string.py +113 -0
- codex_platform/redis_service/operations/list_.py +219 -0
- codex_platform/redis_service/operations/pipeline.py +197 -0
- codex_platform/redis_service/operations/set_.py +212 -0
- codex_platform/redis_service/operations/string.py +318 -0
- codex_platform/redis_service/operations/zset.py +277 -0
- codex_platform/redis_service/service.py +80 -0
- codex_platform/streams/__init__.py +59 -0
- codex_platform/streams/consumer.py +106 -0
- codex_platform/streams/dispatcher.py +175 -0
- codex_platform/streams/processor.py +217 -0
- codex_platform/streams/producer.py +60 -0
- codex_platform/streams/router.py +78 -0
- codex_platform/workers/__init__.py +1 -0
- codex_platform/workers/arq/__init__.py +27 -0
- codex_platform/workers/arq/base.py +170 -0
- codex_platform/workers/arq/config.py +67 -0
- codex_platform/workers/arq/task_utils.py +72 -0
- codex_platform/workers/arq/types.py +11 -0
- codex_platform-0.1.0.dist-info/METADATA +120 -0
- codex_platform-0.1.0.dist-info/RECORD +45 -0
- codex_platform-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_platform.streams
|
|
3
|
+
=============================
|
|
4
|
+
Redis Streams broker — event sourcing / pub-sub layer.
|
|
5
|
+
|
|
6
|
+
Отдельный модуль от redis_service.
|
|
7
|
+
Stream — это брокер сообщений, а не структура данных.
|
|
8
|
+
|
|
9
|
+
Components:
|
|
10
|
+
- ``StreamProducer`` — XADD (publish events)
|
|
11
|
+
- ``StreamConsumer`` — XREADGROUP + XACK (low-level read)
|
|
12
|
+
- ``StreamProcessor`` — background polling engine (wraps StreamConsumer)
|
|
13
|
+
- ``StreamRouter`` — groups handlers by event type (per-feature)
|
|
14
|
+
- ``StreamDispatcher`` — routes messages to handlers (generic, no DI)
|
|
15
|
+
|
|
16
|
+
Typical setup::
|
|
17
|
+
|
|
18
|
+
from codex_platform.streams import (
|
|
19
|
+
StreamConsumer, StreamProducer,
|
|
20
|
+
StreamProcessor, StreamRouter, StreamDispatcher,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Producer
|
|
24
|
+
producer = StreamProducer(redis_client, stream_name="events:orders")
|
|
25
|
+
|
|
26
|
+
# Consumer + Processor
|
|
27
|
+
consumer = StreamConsumer(redis_client, "events:orders", "workers", "worker_1")
|
|
28
|
+
dispatcher = StreamDispatcher()
|
|
29
|
+
|
|
30
|
+
@dispatcher.on("order.paid")
|
|
31
|
+
async def handle_order(payload: dict) -> None:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
processor = StreamProcessor(
|
|
35
|
+
storage=consumer,
|
|
36
|
+
stream_name="events:orders",
|
|
37
|
+
consumer_group_name="workers",
|
|
38
|
+
consumer_name="worker_1",
|
|
39
|
+
)
|
|
40
|
+
processor.set_callback(dispatcher.process)
|
|
41
|
+
await processor.start()
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from .consumer import StreamConsumer, StreamEvent
|
|
45
|
+
from .dispatcher import RetrySchedulerProtocol, StreamDispatcher
|
|
46
|
+
from .processor import StreamProcessor, StreamStorageProtocol
|
|
47
|
+
from .producer import StreamProducer
|
|
48
|
+
from .router import StreamRouter
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"StreamProducer",
|
|
52
|
+
"StreamConsumer",
|
|
53
|
+
"StreamEvent",
|
|
54
|
+
"StreamProcessor",
|
|
55
|
+
"StreamStorageProtocol",
|
|
56
|
+
"StreamRouter",
|
|
57
|
+
"StreamDispatcher",
|
|
58
|
+
"RetrySchedulerProtocol",
|
|
59
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_platform.streams.consumer
|
|
3
|
+
=======================================
|
|
4
|
+
Redis Stream consumer — reads events via XREADGROUP, acknowledges via XACK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from redis.asyncio import Redis
|
|
12
|
+
from redis.exceptions import ConnectionError, RedisError, TimeoutError
|
|
13
|
+
|
|
14
|
+
from codex_platform.redis_service.exceptions import RedisConnectionError, RedisServiceError
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class StreamEvent:
|
|
21
|
+
"""Parsed Redis Stream event."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
event_type: str
|
|
25
|
+
data: dict[str, str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StreamConsumer:
|
|
29
|
+
"""Reads events from a Redis Stream via a consumer group (XREADGROUP).
|
|
30
|
+
|
|
31
|
+
Acknowledges processed messages via XACK.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: Redis, stream_name: str, group: str, consumer: str) -> None:
|
|
35
|
+
self.client = client
|
|
36
|
+
self.stream_name = stream_name
|
|
37
|
+
self.group = group
|
|
38
|
+
self.consumer = consumer
|
|
39
|
+
|
|
40
|
+
async def ensure_group(self) -> None:
|
|
41
|
+
"""Create the consumer group if it does not already exist (XGROUP CREATE … MKSTREAM)."""
|
|
42
|
+
try:
|
|
43
|
+
await self.client.xgroup_create(self.stream_name, self.group, id="0", mkstream=True)
|
|
44
|
+
log.info("StreamConsumer | group created stream='%s' group='%s'", self.stream_name, self.group)
|
|
45
|
+
except RedisError as e:
|
|
46
|
+
if "BUSYGROUP" in str(e):
|
|
47
|
+
log.debug("StreamConsumer | group already exists stream='%s' group='%s'", self.stream_name, self.group)
|
|
48
|
+
else:
|
|
49
|
+
raise RedisServiceError(f"Failed to create group: {e}") from e
|
|
50
|
+
|
|
51
|
+
async def read(self, count: int = 10) -> list[StreamEvent]:
|
|
52
|
+
"""Read new events from the stream for the consumer group (XREADGROUP).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
count: Maximum number of messages to fetch per call. Defaults to ``10``.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of :class:`StreamEvent` instances. Empty list if no new messages.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RedisConnectionError: Redis connection failure.
|
|
62
|
+
RedisServiceError: Redis operation failure.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
raw = await self.client.xreadgroup(
|
|
66
|
+
groupname=self.group,
|
|
67
|
+
consumername=self.consumer,
|
|
68
|
+
streams={self.stream_name: ">"},
|
|
69
|
+
count=count,
|
|
70
|
+
)
|
|
71
|
+
if not raw:
|
|
72
|
+
return []
|
|
73
|
+
events = [self._parse(msg_id, fields) for msg_id, fields in raw[0][1]]
|
|
74
|
+
log.debug("StreamConsumer | read count=%d stream='%s'", len(events), self.stream_name)
|
|
75
|
+
return events
|
|
76
|
+
except (ConnectionError, TimeoutError) as e:
|
|
77
|
+
raise RedisConnectionError(f"Stream consumer connection failed: {e}") from e
|
|
78
|
+
except RedisError as e:
|
|
79
|
+
raise RedisServiceError(f"Stream consumer error: {e}") from e
|
|
80
|
+
|
|
81
|
+
async def ack(self, event_id: str) -> None:
|
|
82
|
+
"""Acknowledge that an event has been processed (XACK).
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
event_id: Stream entry ID returned by :meth:`read`.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
RedisConnectionError: Redis connection failure.
|
|
89
|
+
RedisServiceError: Redis operation failure.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
await self.client.xack(self.stream_name, self.group, event_id)
|
|
93
|
+
log.debug("StreamConsumer | ack id='%s' stream='%s'", event_id, self.stream_name)
|
|
94
|
+
except (ConnectionError, TimeoutError) as e:
|
|
95
|
+
raise RedisConnectionError(f"Stream ack connection failed: {e}") from e
|
|
96
|
+
except RedisError as e:
|
|
97
|
+
raise RedisServiceError(f"Stream ack error: {e}") from e
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _parse(msg_id: Any, fields: dict[bytes | str, bytes | str]) -> StreamEvent:
|
|
101
|
+
data = {
|
|
102
|
+
(k.decode() if isinstance(k, bytes) else k): (v.decode() if isinstance(v, bytes) else v)
|
|
103
|
+
for k, v in fields.items()
|
|
104
|
+
}
|
|
105
|
+
event_type = data.pop("type", "unknown")
|
|
106
|
+
return StreamEvent(id=str(msg_id), event_type=event_type, data=data)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_platform.streams.dispatcher
|
|
3
|
+
==========================================
|
|
4
|
+
Generic Stream event dispatcher — routes messages to registered handlers.
|
|
5
|
+
|
|
6
|
+
Framework-agnostic: no DI container, no bot references.
|
|
7
|
+
For framework-specific dispatchers (e.g. with a DI container) — extend this class.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from codex_platform.streams.dispatcher import StreamDispatcher
|
|
12
|
+
from codex_platform.streams.router import StreamRouter
|
|
13
|
+
|
|
14
|
+
dispatcher = StreamDispatcher()
|
|
15
|
+
|
|
16
|
+
# Register handlers directly:
|
|
17
|
+
@dispatcher.on("booking.confirmed")
|
|
18
|
+
async def handle_booking(payload: dict) -> None:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
# Or include a router from a feature module:
|
|
22
|
+
dispatcher.include_router(notifications_router)
|
|
23
|
+
|
|
24
|
+
# Connect to StreamProcessor:
|
|
25
|
+
processor.set_callback(dispatcher.process)
|
|
26
|
+
await processor.start()
|
|
27
|
+
|
|
28
|
+
Extending for framework-specific DI::
|
|
29
|
+
|
|
30
|
+
class BotDispatcher(StreamDispatcher):
|
|
31
|
+
def __init__(self, container):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.container = container
|
|
34
|
+
|
|
35
|
+
async def process(self, payload: dict) -> None:
|
|
36
|
+
# inject container into handlers, etc.
|
|
37
|
+
...
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
from collections.abc import Callable
|
|
44
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from .router import FilterFunc, HandlerFunc, StreamRouter
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class RetrySchedulerProtocol(Protocol):
|
|
54
|
+
"""Protocol for a retry scheduler (ARQ, Celery, etc.).
|
|
55
|
+
|
|
56
|
+
Pass to ``StreamDispatcher`` for automatic rescheduling of failed messages.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
async def schedule_retry(
|
|
60
|
+
self,
|
|
61
|
+
stream_name: str,
|
|
62
|
+
payload: dict[str, Any],
|
|
63
|
+
delay: int = 60,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Schedules message reprocessing after a delay.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
stream_name: Redis Stream name.
|
|
69
|
+
payload: Original message data.
|
|
70
|
+
delay: Retry delay in seconds.
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StreamDispatcher:
|
|
76
|
+
"""Routes Redis Stream messages to registered handlers by event type.
|
|
77
|
+
|
|
78
|
+
Handlers are registered via ``@dispatcher.on(event_type)`` decorator
|
|
79
|
+
or by including ``StreamRouter`` instances.
|
|
80
|
+
|
|
81
|
+
On handler failure: if a ``retry_scheduler`` is provided, the message
|
|
82
|
+
is scheduled for retry. Otherwise the exception is re-raised (message
|
|
83
|
+
stays in PEL, unacknowledged).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
retry_scheduler: Optional retry scheduler implementing ``RetrySchedulerProtocol``.
|
|
87
|
+
|
|
88
|
+
Example::
|
|
89
|
+
|
|
90
|
+
dispatcher = StreamDispatcher()
|
|
91
|
+
|
|
92
|
+
@dispatcher.on("user.registered")
|
|
93
|
+
async def welcome(payload: dict) -> None:
|
|
94
|
+
await send_welcome_email(payload["email"])
|
|
95
|
+
|
|
96
|
+
processor.set_callback(dispatcher.process)
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, retry_scheduler: RetrySchedulerProtocol | None = None) -> None:
|
|
100
|
+
self._retry_scheduler = retry_scheduler
|
|
101
|
+
self._handlers: dict[str, list[tuple[HandlerFunc, FilterFunc | None]]] = {}
|
|
102
|
+
log.info("StreamDispatcher | initialized")
|
|
103
|
+
|
|
104
|
+
def include_router(self, router: StreamRouter) -> None:
|
|
105
|
+
"""Merges handlers from a ``StreamRouter`` into this dispatcher.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
router: Router from a feature module.
|
|
109
|
+
"""
|
|
110
|
+
for event_type, handlers in router.handlers.items():
|
|
111
|
+
if event_type not in self._handlers:
|
|
112
|
+
self._handlers[event_type] = []
|
|
113
|
+
self._handlers[event_type].extend(handlers)
|
|
114
|
+
log.info("StreamDispatcher | included router types=%s", list(router.handlers.keys()))
|
|
115
|
+
|
|
116
|
+
def on(
|
|
117
|
+
self,
|
|
118
|
+
event_type: str,
|
|
119
|
+
filter_func: FilterFunc | None = None,
|
|
120
|
+
) -> Callable[[HandlerFunc], HandlerFunc]:
|
|
121
|
+
"""Decorator for registering a handler directly on the dispatcher.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
event_type: Stream message type (e.g. ``"booking.confirmed"``).
|
|
125
|
+
filter_func: Optional ``payload -> bool`` filter.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def decorator(handler: HandlerFunc) -> HandlerFunc:
|
|
129
|
+
if event_type not in self._handlers:
|
|
130
|
+
self._handlers[event_type] = []
|
|
131
|
+
self._handlers[event_type].append((handler, filter_func))
|
|
132
|
+
return handler
|
|
133
|
+
|
|
134
|
+
return decorator
|
|
135
|
+
|
|
136
|
+
async def process(self, payload: dict[str, Any], stream_name: str = "") -> None:
|
|
137
|
+
"""Dispatches an incoming message to matching handlers.
|
|
138
|
+
|
|
139
|
+
Called by ``StreamProcessor`` on each incoming message.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
payload: Message data dict. Must contain ``"type"`` field.
|
|
143
|
+
stream_name: Stream name (used for retry scheduling only).
|
|
144
|
+
"""
|
|
145
|
+
event_type = payload.get("type")
|
|
146
|
+
if not event_type:
|
|
147
|
+
log.warning("StreamDispatcher | message without 'type' field: %s", payload)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
handlers = self._handlers.get(event_type, [])
|
|
151
|
+
if not handlers:
|
|
152
|
+
log.debug("StreamDispatcher | no handlers for type='%s'", event_type)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
for handler, filter_func in handlers:
|
|
156
|
+
try:
|
|
157
|
+
if filter_func is None or filter_func(payload):
|
|
158
|
+
log.debug("StreamDispatcher | calling %s for type='%s'", handler.__name__, event_type)
|
|
159
|
+
await handler(payload)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
log.error("StreamDispatcher | handler %s failed: %s", handler.__name__, e)
|
|
162
|
+
|
|
163
|
+
if self._retry_scheduler:
|
|
164
|
+
try:
|
|
165
|
+
await self._retry_scheduler.schedule_retry(
|
|
166
|
+
stream_name=stream_name,
|
|
167
|
+
payload=payload,
|
|
168
|
+
delay=60,
|
|
169
|
+
)
|
|
170
|
+
log.info("StreamDispatcher | retry scheduled for type='%s'", event_type)
|
|
171
|
+
return
|
|
172
|
+
except Exception as retry_err:
|
|
173
|
+
log.error("StreamDispatcher | retry scheduling failed: %s", retry_err)
|
|
174
|
+
|
|
175
|
+
raise
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_platform.streams.processor
|
|
3
|
+
========================================
|
|
4
|
+
Redis Stream processing engine — continuous polling with consumer group.
|
|
5
|
+
|
|
6
|
+
Implements at-least-once delivery via XACK. Generic: works with any project,
|
|
7
|
+
not tied to any framework (bot, Django, FastAPI, etc.).
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from codex_platform.streams import StreamConsumer
|
|
12
|
+
from codex_platform.streams.processor import StreamProcessor, StreamStorageProtocol
|
|
13
|
+
|
|
14
|
+
# Option 1: Use StreamConsumer directly (implements StreamStorageProtocol)
|
|
15
|
+
consumer = StreamConsumer(redis_client, "events:bot", "bot_group", "worker_1")
|
|
16
|
+
processor = StreamProcessor(storage=consumer, ...)
|
|
17
|
+
|
|
18
|
+
# Option 2: Implement StreamStorageProtocol yourself (e.g. for testing)
|
|
19
|
+
class FakeStorage:
|
|
20
|
+
async def create_group(self, stream, group): ...
|
|
21
|
+
async def read_events(self, stream, group, consumer, count): return []
|
|
22
|
+
async def ack_event(self, stream, group, message_id): ...
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import contextlib
|
|
29
|
+
import logging
|
|
30
|
+
from collections.abc import Awaitable, Callable
|
|
31
|
+
from typing import Any, Protocol, runtime_checkable
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
MessageCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class StreamStorageProtocol(Protocol):
|
|
40
|
+
"""Adapter protocol for Redis Stream I/O.
|
|
41
|
+
|
|
42
|
+
``StreamConsumer`` from ``streams.consumer`` implements this protocol.
|
|
43
|
+
You can also implement it yourself for testing or alternative backends.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
async def create_group(self, stream_name: str, group_name: str) -> None:
|
|
47
|
+
"""Creates a consumer group idempotently (XGROUP CREATE ... MKSTREAM)."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
async def read_events(
|
|
51
|
+
self,
|
|
52
|
+
stream_name: str,
|
|
53
|
+
group_name: str,
|
|
54
|
+
consumer_name: str,
|
|
55
|
+
count: int,
|
|
56
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
57
|
+
"""Reads undelivered messages for the group (XREADGROUP ... >).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
List of (message_id, data_dict). Empty if no messages.
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
async def ack_event(self, stream_name: str, group_name: str, message_id: str) -> None:
|
|
65
|
+
"""Acknowledges message processing (XACK)."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class StreamProcessor:
|
|
70
|
+
"""Async engine for consuming and dispatching Redis Stream events.
|
|
71
|
+
|
|
72
|
+
Runs a continuous background polling loop. On each iteration reads a batch
|
|
73
|
+
of messages from the consumer group, dispatches each to the registered
|
|
74
|
+
callback, and ACKs on success.
|
|
75
|
+
|
|
76
|
+
If the callback fails — message stays in PEL (unacknowledged) for future
|
|
77
|
+
recovery. The processor recovers automatically if the consumer group is lost
|
|
78
|
+
(NOGROUP error).
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
storage: Any object implementing ``StreamStorageProtocol``.
|
|
82
|
+
Use ``StreamConsumer`` from streams.consumer.
|
|
83
|
+
stream_name: Redis Stream identifier.
|
|
84
|
+
consumer_group_name: Shared group name (same across all instances = load balancing).
|
|
85
|
+
consumer_name: Unique name for this processor instance.
|
|
86
|
+
batch_count: Max messages per poll cycle.
|
|
87
|
+
poll_interval: Sleep duration (seconds) when no messages available.
|
|
88
|
+
|
|
89
|
+
TODO:
|
|
90
|
+
PEL recovery on startup — XPENDING/XCLAIM for messages stuck in PEL
|
|
91
|
+
after a processor crash.
|
|
92
|
+
|
|
93
|
+
Example::
|
|
94
|
+
|
|
95
|
+
consumer = StreamConsumer(redis, "events:bot", "bot_group", "worker_1")
|
|
96
|
+
processor = StreamProcessor(
|
|
97
|
+
storage=consumer,
|
|
98
|
+
stream_name="events:bot",
|
|
99
|
+
consumer_group_name="bot_group",
|
|
100
|
+
consumer_name="worker_1",
|
|
101
|
+
)
|
|
102
|
+
processor.set_callback(my_async_handler)
|
|
103
|
+
await processor.start()
|
|
104
|
+
# ... later
|
|
105
|
+
await processor.stop()
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
storage: StreamStorageProtocol,
|
|
111
|
+
stream_name: str,
|
|
112
|
+
consumer_group_name: str,
|
|
113
|
+
consumer_name: str,
|
|
114
|
+
batch_count: int = 10,
|
|
115
|
+
poll_interval: float = 1.0,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.storage = storage
|
|
118
|
+
self.stream_name = stream_name
|
|
119
|
+
self.group_name = consumer_group_name
|
|
120
|
+
self.consumer_name = consumer_name
|
|
121
|
+
self.batch_count = batch_count
|
|
122
|
+
self.poll_interval = poll_interval
|
|
123
|
+
|
|
124
|
+
self.is_running = False
|
|
125
|
+
self._callback: MessageCallback | None = None
|
|
126
|
+
self._task: asyncio.Task[None] | None = None
|
|
127
|
+
|
|
128
|
+
def set_callback(self, callback: MessageCallback) -> None:
|
|
129
|
+
"""Registers the message handler.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
callback: ``async def handler(payload: dict) -> None``
|
|
133
|
+
"""
|
|
134
|
+
self._callback = callback
|
|
135
|
+
|
|
136
|
+
async def start(self) -> None:
|
|
137
|
+
"""Starts the background polling loop.
|
|
138
|
+
|
|
139
|
+
Creates the consumer group (up to 5 attempts with 3s delay),
|
|
140
|
+
then spawns ``_consume_loop`` as an asyncio Task.
|
|
141
|
+
"""
|
|
142
|
+
if self.is_running:
|
|
143
|
+
log.warning("StreamProcessor | already running stream='%s'", self.stream_name)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
for attempt in range(1, 6):
|
|
147
|
+
try:
|
|
148
|
+
await self.storage.create_group(self.stream_name, self.group_name)
|
|
149
|
+
break
|
|
150
|
+
except Exception as e:
|
|
151
|
+
log.warning("StreamProcessor | create_group attempt %d/5: %s", attempt, e)
|
|
152
|
+
if attempt < 5:
|
|
153
|
+
await asyncio.sleep(3)
|
|
154
|
+
else:
|
|
155
|
+
log.error("StreamProcessor | failed to create group, giving up")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self.is_running = True
|
|
159
|
+
self._task = asyncio.create_task(self._consume_loop())
|
|
160
|
+
log.info(
|
|
161
|
+
"StreamProcessor | started stream='%s' group='%s' consumer='%s'",
|
|
162
|
+
self.stream_name,
|
|
163
|
+
self.group_name,
|
|
164
|
+
self.consumer_name,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def stop(self) -> None:
|
|
168
|
+
"""Stops the polling loop gracefully."""
|
|
169
|
+
self.is_running = False
|
|
170
|
+
if self._task and not self._task.done():
|
|
171
|
+
self._task.cancel()
|
|
172
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
173
|
+
await self._task
|
|
174
|
+
log.info("StreamProcessor | stopped stream='%s'", self.stream_name)
|
|
175
|
+
|
|
176
|
+
async def _consume_loop(self) -> None:
|
|
177
|
+
try:
|
|
178
|
+
while self.is_running:
|
|
179
|
+
try:
|
|
180
|
+
messages = await self.storage.read_events(
|
|
181
|
+
stream_name=self.stream_name,
|
|
182
|
+
group_name=self.group_name,
|
|
183
|
+
consumer_name=self.consumer_name,
|
|
184
|
+
count=self.batch_count,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not messages:
|
|
188
|
+
await asyncio.sleep(self.poll_interval)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
for message_id, data in messages:
|
|
192
|
+
await self._process_one(message_id, data)
|
|
193
|
+
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
raise
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log.error("StreamProcessor | consume loop error: %s", e)
|
|
198
|
+
if "NOGROUP" in str(e):
|
|
199
|
+
log.warning("StreamProcessor | consumer group lost, recreating...")
|
|
200
|
+
with contextlib.suppress(Exception):
|
|
201
|
+
await self.storage.create_group(self.stream_name, self.group_name)
|
|
202
|
+
await asyncio.sleep(5)
|
|
203
|
+
|
|
204
|
+
except asyncio.CancelledError:
|
|
205
|
+
log.info("StreamProcessor | consume loop cancelled")
|
|
206
|
+
raise
|
|
207
|
+
|
|
208
|
+
async def _process_one(self, message_id: str, data: dict[str, Any]) -> None:
|
|
209
|
+
"""Processes a single message. ACKs on success, leaves in PEL on failure."""
|
|
210
|
+
try:
|
|
211
|
+
if self._callback:
|
|
212
|
+
await self._callback(data)
|
|
213
|
+
await self.storage.ack_event(self.stream_name, self.group_name, message_id)
|
|
214
|
+
log.debug("StreamProcessor | ack id='%s'", message_id)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
log.error("StreamProcessor | failed to process message id='%s': %s", message_id, e)
|
|
217
|
+
# Message stays in PEL (unacknowledged) for recovery
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_platform.streams.producer
|
|
3
|
+
=======================================
|
|
4
|
+
Redis Stream producer — writes events via XADD.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from redis.asyncio import Redis
|
|
11
|
+
from redis.exceptions import ConnectionError, RedisError, TimeoutError
|
|
12
|
+
|
|
13
|
+
from codex_platform.redis_service.exceptions import RedisConnectionError, RedisServiceError
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StreamProducer:
|
|
19
|
+
"""Writes events to a Redis Stream (XADD).
|
|
20
|
+
|
|
21
|
+
Automatically sanitizes the payload before writing:
|
|
22
|
+
- ``bool`` values are converted to ``"True"`` / ``"False"``
|
|
23
|
+
- ``None`` values are filtered out
|
|
24
|
+
- all remaining values are coerced to ``str``
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, client: Redis, stream_name: str) -> None:
|
|
28
|
+
self.client = client
|
|
29
|
+
self.stream_name = stream_name
|
|
30
|
+
|
|
31
|
+
async def add_event(self, event_type: str, data: dict[str, Any]) -> str:
|
|
32
|
+
"""Append an event to the Redis Stream (XADD).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
event_type: Event type label, e.g. ``"new_appointment"`` or ``"new_contact"``.
|
|
36
|
+
data: Event payload. ``None`` values are filtered out automatically.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The ID of the newly added stream entry (e.g. ``"1718000000000-0"``).
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
RedisConnectionError: Redis connection failure.
|
|
43
|
+
RedisServiceError: Redis operation failure.
|
|
44
|
+
"""
|
|
45
|
+
payload = self._sanitize({"type": event_type, **data})
|
|
46
|
+
try:
|
|
47
|
+
result = await self.client.xadd(self.stream_name, payload)
|
|
48
|
+
log.info("StreamProducer | added event_type='%s' id='%s' stream='%s'", event_type, result, self.stream_name)
|
|
49
|
+
return str(result)
|
|
50
|
+
except (ConnectionError, TimeoutError) as e:
|
|
51
|
+
raise RedisConnectionError(f"Stream producer connection failed: {e}") from e
|
|
52
|
+
except RedisError as e:
|
|
53
|
+
raise RedisServiceError(f"Stream producer error: {e}") from e
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _sanitize(data: dict[str, Any]) -> dict[str, str]:
|
|
57
|
+
"""Convert all values to str and filter out None entries."""
|
|
58
|
+
return {
|
|
59
|
+
k: ("True" if v is True else "False" if v is False else str(v)) for k, v in data.items() if v is not None
|
|
60
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_platform.streams.router
|
|
3
|
+
=====================================
|
|
4
|
+
Stream event router — groups handlers by message type.
|
|
5
|
+
|
|
6
|
+
Analogous to Aiogram/FastAPI routers: define handlers in feature modules,
|
|
7
|
+
then include into a dispatcher. Framework-agnostic.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from codex_platform.streams.router import StreamRouter
|
|
12
|
+
|
|
13
|
+
router = StreamRouter()
|
|
14
|
+
|
|
15
|
+
@router.on("booking.confirmed")
|
|
16
|
+
async def handle_booking(payload: dict) -> None:
|
|
17
|
+
booking_id = payload["booking_id"]
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
@router.on("notification.created", filter_func=lambda p: p.get("urgent"))
|
|
21
|
+
async def handle_urgent(payload: dict) -> None:
|
|
22
|
+
...
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
HandlerFunc = Callable[[dict[str, Any]], Any]
|
|
29
|
+
FilterFunc = Callable[[dict[str, Any]], bool]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StreamRouter:
|
|
33
|
+
"""Groups Redis Stream event handlers by message type.
|
|
34
|
+
|
|
35
|
+
After populating, include into a ``StreamDispatcher`` via ``include_router()``.
|
|
36
|
+
|
|
37
|
+
Example::
|
|
38
|
+
|
|
39
|
+
router = StreamRouter()
|
|
40
|
+
|
|
41
|
+
@router.on("order.paid")
|
|
42
|
+
async def on_order_paid(payload: dict) -> None:
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
dispatcher.include_router(router)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._handlers: dict[str, list[tuple[HandlerFunc, FilterFunc | None]]] = {}
|
|
50
|
+
|
|
51
|
+
def on(
|
|
52
|
+
self,
|
|
53
|
+
event_type: str,
|
|
54
|
+
filter_func: FilterFunc | None = None,
|
|
55
|
+
) -> Callable[[HandlerFunc], HandlerFunc]:
|
|
56
|
+
"""Decorator for registering a handler for an event type.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
event_type: Stream message type (e.g. ``"booking.confirmed"``).
|
|
60
|
+
filter_func: Optional ``payload -> bool`` filter. Handler is called
|
|
61
|
+
only when filter returns ``True``.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Decorator (returns the original handler unchanged).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def decorator(handler: HandlerFunc) -> HandlerFunc:
|
|
68
|
+
if event_type not in self._handlers:
|
|
69
|
+
self._handlers[event_type] = []
|
|
70
|
+
self._handlers[event_type].append((handler, filter_func))
|
|
71
|
+
return handler
|
|
72
|
+
|
|
73
|
+
return decorator
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def handlers(self) -> dict[str, list[tuple[HandlerFunc, FilterFunc | None]]]:
|
|
77
|
+
"""Registered handlers (read-only)."""
|
|
78
|
+
return self._handlers
|