Skip to content

Commit d30f200

Browse files
authored
Merge pull request #55 from DABND19/feature/redis-sentinel
2 parents bb42e32 + 9900a0d commit d30f200

File tree

9 files changed

+835
-8
lines changed

9 files changed

+835
-8
lines changed

docker-compose.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,24 @@ services:
5858
REDIS_CLUSTER_CREATOR: "yes"
5959
ports:
6060
- 7001:6379
61+
62+
redis-master:
63+
image: bitnami/redis:6.2.5
64+
environment:
65+
ALLOW_EMPTY_PASSWORD: "yes"
66+
healthcheck:
67+
test: ["CMD", "redis-cli", "ping"]
68+
interval: 5s
69+
timeout: 5s
70+
retries: 3
71+
start_period: 10s
72+
73+
redis-sentinel:
74+
image: bitnami/redis-sentinel:latest
75+
depends_on:
76+
- redis-master
77+
environment:
78+
ALLOW_EMPTY_PASSWORD: "yes"
79+
REDIS_MASTER_HOST: "redis-master"
80+
ports:
81+
- 7002:26379

taskiq_redis/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,30 @@
22
from taskiq_redis.redis_backend import (
33
RedisAsyncClusterResultBackend,
44
RedisAsyncResultBackend,
5+
RedisAsyncSentinelResultBackend,
56
)
67
from taskiq_redis.redis_broker import ListQueueBroker, PubSubBroker
78
from taskiq_redis.redis_cluster_broker import ListQueueClusterBroker
9+
from taskiq_redis.redis_sentinel_broker import (
10+
ListQueueSentinelBroker,
11+
PubSubSentinelBroker,
12+
)
813
from taskiq_redis.schedule_source import (
914
RedisClusterScheduleSource,
1015
RedisScheduleSource,
16+
RedisSentinelScheduleSource,
1117
)
1218

1319
__all__ = [
1420
"RedisAsyncClusterResultBackend",
1521
"RedisAsyncResultBackend",
22+
"RedisAsyncSentinelResultBackend",
1623
"ListQueueBroker",
1724
"PubSubBroker",
1825
"ListQueueClusterBroker",
26+
"ListQueueSentinelBroker",
27+
"PubSubSentinelBroker",
1928
"RedisScheduleSource",
2029
"RedisClusterScheduleSource",
30+
"RedisSentinelScheduleSource",
2131
]

taskiq_redis/redis_backend.py

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
11
import pickle
2-
from typing import Any, Dict, Optional, TypeVar, Union
2+
import sys
3+
from contextlib import asynccontextmanager
4+
from typing import (
5+
TYPE_CHECKING,
6+
Any,
7+
AsyncIterator,
8+
Dict,
9+
List,
10+
Optional,
11+
Tuple,
12+
TypeVar,
13+
Union,
14+
)
315

4-
from redis.asyncio import BlockingConnectionPool, Redis
16+
from redis.asyncio import BlockingConnectionPool, Redis, Sentinel
517
from redis.asyncio.cluster import RedisCluster
618
from taskiq import AsyncResultBackend
719
from taskiq.abc.result_backend import TaskiqResult
20+
from taskiq.abc.serializer import TaskiqSerializer
821

922
from taskiq_redis.exceptions import (
1023
DuplicateExpireTimeSelectedError,
1124
ExpireTimeMustBeMoreThanZeroError,
1225
ResultIsMissingError,
1326
)
27+
from taskiq_redis.serializer import PickleSerializer
28+
29+
if sys.version_info >= (3, 10):
30+
from typing import TypeAlias
31+
else:
32+
from typing_extensions import TypeAlias
33+
34+
if TYPE_CHECKING:
35+
_Redis: TypeAlias = Redis[bytes]
36+
else:
37+
_Redis: TypeAlias = Redis
1438

1539
_ReturnType = TypeVar("_ReturnType")
1640

@@ -267,3 +291,142 @@ async def get_result(
267291
taskiq_result.log = None
268292

269293
return taskiq_result
294+
295+
296+
class RedisAsyncSentinelResultBackend(AsyncResultBackend[_ReturnType]):
297+
"""Async result based on redis sentinel."""
298+
299+
def __init__(
300+
self,
301+
sentinels: List[Tuple[str, int]],
302+
master_name: str,
303+
keep_results: bool = True,
304+
result_ex_time: Optional[int] = None,
305+
result_px_time: Optional[int] = None,
306+
min_other_sentinels: int = 0,
307+
sentinel_kwargs: Optional[Any] = None,
308+
serializer: Optional[TaskiqSerializer] = None,
309+
**connection_kwargs: Any,
310+
) -> None:
311+
"""
312+
Constructs a new result backend.
313+
314+
:param sentinels: list of sentinel host and ports pairs.
315+
:param master_name: sentinel master name.
316+
:param keep_results: flag to not remove results from Redis after reading.
317+
:param result_ex_time: expire time in seconds for result.
318+
:param result_px_time: expire time in milliseconds for result.
319+
:param max_connection_pool_size: maximum number of connections in pool.
320+
:param connection_kwargs: additional arguments for redis BlockingConnectionPool.
321+
322+
:raises DuplicateExpireTimeSelectedError: if result_ex_time
323+
and result_px_time are selected.
324+
:raises ExpireTimeMustBeMoreThanZeroError: if result_ex_time
325+
and result_px_time are equal zero.
326+
"""
327+
self.sentinel = Sentinel(
328+
sentinels=sentinels,
329+
min_other_sentinels=min_other_sentinels,
330+
sentinel_kwargs=sentinel_kwargs,
331+
**connection_kwargs,
332+
)
333+
self.master_name = master_name
334+
if serializer is None:
335+
serializer = PickleSerializer()
336+
self.serializer = serializer
337+
self.keep_results = keep_results
338+
self.result_ex_time = result_ex_time
339+
self.result_px_time = result_px_time
340+
341+
unavailable_conditions = any(
342+
(
343+
self.result_ex_time is not None and self.result_ex_time <= 0,
344+
self.result_px_time is not None and self.result_px_time <= 0,
345+
),
346+
)
347+
if unavailable_conditions:
348+
raise ExpireTimeMustBeMoreThanZeroError(
349+
"You must select one expire time param and it must be more than zero.",
350+
)
351+
352+
if self.result_ex_time and self.result_px_time:
353+
raise DuplicateExpireTimeSelectedError(
354+
"Choose either result_ex_time or result_px_time.",
355+
)
356+
357+
@asynccontextmanager
358+
async def _acquire_master_conn(self) -> AsyncIterator[_Redis]:
359+
async with self.sentinel.master_for(self.master_name) as redis_conn:
360+
yield redis_conn
361+
362+
async def set_result(
363+
self,
364+
task_id: str,
365+
result: TaskiqResult[_ReturnType],
366+
) -> None:
367+
"""
368+
Sets task result in redis.
369+
370+
Dumps TaskiqResult instance into the bytes and writes
371+
it to redis.
372+
373+
:param task_id: ID of the task.
374+
:param result: TaskiqResult instance.
375+
"""
376+
redis_set_params: Dict[str, Union[str, bytes, int]] = {
377+
"name": task_id,
378+
"value": self.serializer.dumpb(result),
379+
}
380+
if self.result_ex_time:
381+
redis_set_params["ex"] = self.result_ex_time
382+
elif self.result_px_time:
383+
redis_set_params["px"] = self.result_px_time
384+
385+
async with self._acquire_master_conn() as redis:
386+
await redis.set(**redis_set_params) # type: ignore
387+
388+
async def is_result_ready(self, task_id: str) -> bool:
389+
"""
390+
Returns whether the result is ready.
391+
392+
:param task_id: ID of the task.
393+
394+
:returns: True if the result is ready else False.
395+
"""
396+
async with self._acquire_master_conn() as redis:
397+
return bool(await redis.exists(task_id))
398+
399+
async def get_result(
400+
self,
401+
task_id: str,
402+
with_logs: bool = False,
403+
) -> TaskiqResult[_ReturnType]:
404+
"""
405+
Gets result from the task.
406+
407+
:param task_id: task's id.
408+
:param with_logs: if True it will download task's logs.
409+
:raises ResultIsMissingError: if there is no result when trying to get it.
410+
:return: task's return value.
411+
"""
412+
async with self._acquire_master_conn() as redis:
413+
if self.keep_results:
414+
result_value = await redis.get(
415+
name=task_id,
416+
)
417+
else:
418+
result_value = await redis.getdel(
419+
name=task_id,
420+
)
421+
422+
if result_value is None:
423+
raise ResultIsMissingError
424+
425+
taskiq_result: TaskiqResult[_ReturnType] = pickle.loads( # noqa: S301
426+
result_value,
427+
)
428+
429+
if not with_logs:
430+
taskiq_result.log = None
431+
432+
return taskiq_result

taskiq_redis/redis_sentinel_broker.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import sys
2+
from contextlib import asynccontextmanager
3+
from logging import getLogger
4+
from typing import (
5+
TYPE_CHECKING,
6+
Any,
7+
AsyncGenerator,
8+
AsyncIterator,
9+
Callable,
10+
List,
11+
Optional,
12+
Tuple,
13+
TypeVar,
14+
)
15+
16+
from redis.asyncio import Redis, Sentinel
17+
from taskiq import AsyncResultBackend, BrokerMessage
18+
from taskiq.abc.broker import AsyncBroker
19+
20+
if sys.version_info >= (3, 10):
21+
from typing import TypeAlias
22+
else:
23+
from typing_extensions import TypeAlias
24+
25+
if TYPE_CHECKING:
26+
_Redis: TypeAlias = Redis[bytes]
27+
else:
28+
_Redis: TypeAlias = Redis
29+
30+
_T = TypeVar("_T")
31+
32+
logger = getLogger("taskiq.redis_sentinel_broker")
33+
34+
35+
class BaseSentinelBroker(AsyncBroker):
36+
"""Base broker that works with Sentinel."""
37+
38+
def __init__(
39+
self,
40+
sentinels: List[Tuple[str, int]],
41+
master_name: str,
42+
result_backend: Optional[AsyncResultBackend[_T]] = None,
43+
task_id_generator: Optional[Callable[[], str]] = None,
44+
queue_name: str = "taskiq",
45+
min_other_sentinels: int = 0,
46+
sentinel_kwargs: Optional[Any] = None,
47+
**connection_kwargs: Any,
48+
) -> None:
49+
super().__init__(
50+
result_backend=result_backend,
51+
task_id_generator=task_id_generator,
52+
)
53+
54+
self.sentinel = Sentinel(
55+
sentinels=sentinels,
56+
min_other_sentinels=min_other_sentinels,
57+
sentinel_kwargs=sentinel_kwargs,
58+
**connection_kwargs,
59+
)
60+
self.master_name = master_name
61+
self.queue_name = queue_name
62+
63+
@asynccontextmanager
64+
async def _acquire_master_conn(self) -> AsyncIterator[_Redis]:
65+
async with self.sentinel.master_for(self.master_name) as redis_conn:
66+
yield redis_conn
67+
68+
69+
class PubSubSentinelBroker(BaseSentinelBroker):
70+
"""Broker that works with Sentinel and broadcasts tasks to all workers."""
71+
72+
async def kick(self, message: BrokerMessage) -> None:
73+
"""
74+
Publish message over PUBSUB channel.
75+
76+
:param message: message to send.
77+
"""
78+
queue_name = message.labels.get("queue_name") or self.queue_name
79+
async with self._acquire_master_conn() as redis_conn:
80+
await redis_conn.publish(queue_name, message.message)
81+
82+
async def listen(self) -> AsyncGenerator[bytes, None]:
83+
"""
84+
Listen redis queue for new messages.
85+
86+
This function listens to the pubsub channel
87+
and yields all messages with proper types.
88+
89+
:yields: broker messages.
90+
"""
91+
async with self._acquire_master_conn() as redis_conn:
92+
redis_pubsub_channel = redis_conn.pubsub()
93+
await redis_pubsub_channel.subscribe(self.queue_name)
94+
async for message in redis_pubsub_channel.listen():
95+
if not message:
96+
continue
97+
if message["type"] != "message":
98+
logger.debug("Received non-message from redis: %s", message)
99+
continue
100+
yield message["data"]
101+
102+
103+
class ListQueueSentinelBroker(BaseSentinelBroker):
104+
"""Broker that works with Sentinel and distributes tasks between workers."""
105+
106+
async def kick(self, message: BrokerMessage) -> None:
107+
"""
108+
Put a message in a list.
109+
110+
This method appends a message to the list of all messages.
111+
112+
:param message: message to append.
113+
"""
114+
queue_name = message.labels.get("queue_name") or self.queue_name
115+
async with self._acquire_master_conn() as redis_conn:
116+
await redis_conn.lpush(queue_name, message.message)
117+
118+
async def listen(self) -> AsyncGenerator[bytes, None]:
119+
"""
120+
Listen redis queue for new messages.
121+
122+
This function listens to the queue
123+
and yields new messages if they have BrokerMessage type.
124+
125+
:yields: broker messages.
126+
"""
127+
redis_brpop_data_position = 1
128+
async with self._acquire_master_conn() as redis_conn:
129+
while True:
130+
yield (await redis_conn.brpop(self.queue_name))[
131+
redis_brpop_data_position
132+
]

0 commit comments

Comments
 (0)