vs-queue 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.
- vs_queue/__init__.py +6 -0
- vs_queue/base/__init__.py +0 -0
- vs_queue/base/vs_base_consumer.py +25 -0
- vs_queue/base/vs_base_queue.py +38 -0
- vs_queue/decorator/__init__.py +0 -0
- vs_queue/decorator/vs_queue_listener.py +38 -0
- vs_queue/factory/__init__.py +0 -0
- vs_queue/factory/vs_queue_factory.py +18 -0
- vs_queue/manager/__init__.py +0 -0
- vs_queue/manager/vs_queue_manager.py +116 -0
- vs_queue/provider/__init__.py +0 -0
- vs_queue/provider/vs_rabbitmq_queue.py +91 -0
- vs_queue/provider/vs_redis_queue.py +104 -0
- vs_queue/registry/__init__.py +0 -0
- vs_queue/registry/vs_queue_registry.py +24 -0
- vs_queue/retry/__init__.py +0 -0
- vs_queue/retry/vs_retry_policy.py +30 -0
- vs_queue/schema/__init__.py +0 -0
- vs_queue/schema/vs_message.py +13 -0
- vs_queue-0.1.0.dist-info/METADATA +978 -0
- vs_queue-0.1.0.dist-info/RECORD +23 -0
- vs_queue-0.1.0.dist-info/WHEEL +5 -0
- vs_queue-0.1.0.dist-info/top_level.txt +1 -0
vs_queue/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from vs_queue.registry.vs_queue_registry import VsQueueRegistry
|
|
2
|
+
from vs_queue.provider.vs_redis_queue import VsRedisQueue
|
|
3
|
+
from vs_queue.provider.vs_rabbitmq_queue import VsRabbitMQQueue
|
|
4
|
+
|
|
5
|
+
VsQueueRegistry.register("redis", VsRedisQueue)
|
|
6
|
+
VsQueueRegistry.register("rabbitmq", VsRabbitMQQueue)
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from vs_queue.schema.vs_message import VsMessage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VsBaseConsumer(ABC):
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.uuid: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def handle(self, message: VsMessage) -> None:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def start(self) -> None:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def stop(self) -> None:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
async def on_error(self, message: VsMessage, error: Exception) -> None:
|
|
25
|
+
pass
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from vs_queue.schema.vs_message import VsMessage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VsBaseQueue(ABC):
|
|
8
|
+
|
|
9
|
+
def __init__(self, host: str, port: int, credentials: Optional[Dict[str, Any]] = None):
|
|
10
|
+
self._host = host
|
|
11
|
+
self._port = port
|
|
12
|
+
self._credentials = credentials or {}
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def connect(self) -> None:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def disconnect(self) -> None:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def health_check(self) -> bool:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def publish(self, queue: str, message: VsMessage) -> None:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def subscribe(self, queue: str, consumer: "VsBaseConsumer") -> None:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
async def blpop(self, key: str, timeout: int = 30) -> Optional[str]:
|
|
35
|
+
raise NotImplementedError(f"{type(self).__name__} does not support blpop")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
from vs_queue.base.vs_base_consumer import VsBaseConsumer
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Dict, List, Optional, Type
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class VsListenerMeta:
|
|
7
|
+
queue: str
|
|
8
|
+
concurrency: int
|
|
9
|
+
max_retries: int
|
|
10
|
+
retry_backoff_seconds: float
|
|
11
|
+
dead_letter_queue: Optional[str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_listener_registry: List[tuple] = []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def queue_listener(
|
|
18
|
+
queue: str,
|
|
19
|
+
concurrency: int = 1,
|
|
20
|
+
max_retries: int = 3,
|
|
21
|
+
retry_backoff_seconds: float = 1.0,
|
|
22
|
+
dead_letter_queue: Optional[str] = None,
|
|
23
|
+
):
|
|
24
|
+
def decorator(cls):
|
|
25
|
+
meta = VsListenerMeta(
|
|
26
|
+
queue=queue,
|
|
27
|
+
concurrency=concurrency,
|
|
28
|
+
max_retries=max_retries,
|
|
29
|
+
retry_backoff_seconds=retry_backoff_seconds,
|
|
30
|
+
dead_letter_queue=dead_letter_queue,
|
|
31
|
+
)
|
|
32
|
+
_listener_registry.append((cls, meta))
|
|
33
|
+
return cls
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_registered_listeners() -> List[tuple]:
|
|
38
|
+
return list(_listener_registry)
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
from vs_queue.base.vs_base_queue import VsBaseQueue
|
|
4
|
+
from vs_queue.registry.vs_queue_registry import VsQueueRegistry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VsQueueFactory:
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def create(
|
|
11
|
+
cls,
|
|
12
|
+
provider: str,
|
|
13
|
+
host: str,
|
|
14
|
+
port: int,
|
|
15
|
+
credentials: Optional[Dict[str, Any]] = None,
|
|
16
|
+
) -> VsBaseQueue:
|
|
17
|
+
queue_class = VsQueueRegistry.get(provider)
|
|
18
|
+
return queue_class(host=host, port=port, credentials=credentials)
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, List, Optional, Tuple
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from vs_queue.base.vs_base_consumer import VsBaseConsumer
|
|
7
|
+
from vs_queue.base.vs_base_queue import VsBaseQueue
|
|
8
|
+
|
|
9
|
+
_logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VsQueueManager:
|
|
13
|
+
|
|
14
|
+
def __init__(self, queue: VsBaseQueue, max_consumer_restarts: int = 3):
|
|
15
|
+
self._queue = queue
|
|
16
|
+
self._max_consumer_restarts = max_consumer_restarts
|
|
17
|
+
self._consumers: Dict[str, Tuple[str, VsBaseConsumer]] = {} # uuid -> (queue_name, consumer)
|
|
18
|
+
self._tasks: Dict[str, asyncio.Task] = {} # uuid -> task
|
|
19
|
+
self._connected = False
|
|
20
|
+
self._listeners_registered = False
|
|
21
|
+
|
|
22
|
+
async def register(self, queue_name: str, consumer: VsBaseConsumer) -> str:
|
|
23
|
+
if not self._connected:
|
|
24
|
+
await self._queue.connect()
|
|
25
|
+
self._connected = True
|
|
26
|
+
|
|
27
|
+
uid = uuid4().hex
|
|
28
|
+
consumer.uuid = uid
|
|
29
|
+
self._consumers[uid] = (queue_name, consumer)
|
|
30
|
+
|
|
31
|
+
task = asyncio.create_task(
|
|
32
|
+
self._run(queue_name, consumer),
|
|
33
|
+
name=f"consumer:{queue_name}:{uid}",
|
|
34
|
+
)
|
|
35
|
+
self._tasks[uid] = task
|
|
36
|
+
_logger.info(f"Listener registered | queue={queue_name} uuid={uid}")
|
|
37
|
+
return uid
|
|
38
|
+
|
|
39
|
+
async def register_listeners(self) -> None:
|
|
40
|
+
if self._listeners_registered:
|
|
41
|
+
_logger.warning("register_listeners() already called, skipping")
|
|
42
|
+
return
|
|
43
|
+
self._listeners_registered = True
|
|
44
|
+
from vs_queue.decorator.vs_queue_listener import get_registered_listeners
|
|
45
|
+
for consumer_class, meta in get_registered_listeners():
|
|
46
|
+
for _ in range(meta.concurrency):
|
|
47
|
+
await self.register(meta.queue, consumer_class())
|
|
48
|
+
_logger.info(f"Listeners registered | queue={meta.queue} concurrency={meta.concurrency}")
|
|
49
|
+
|
|
50
|
+
def get_listeners(self) -> Dict[str, List[VsBaseConsumer]]:
|
|
51
|
+
result: Dict[str, List[VsBaseConsumer]] = {}
|
|
52
|
+
for uid, (queue_name, consumer) in self._consumers.items():
|
|
53
|
+
result.setdefault(queue_name, []).append(consumer)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
async def stop_listener(self, queue_name: str, uuid: Optional[str] = None) -> None:
|
|
57
|
+
if uuid and uuid in self._consumers:
|
|
58
|
+
actual_queue, _ = self._consumers[uuid]
|
|
59
|
+
if actual_queue != queue_name:
|
|
60
|
+
raise ValueError(f"UUID '{uuid}' belongs to queue '{actual_queue}', not '{queue_name}'")
|
|
61
|
+
targets = {
|
|
62
|
+
uid: consumer
|
|
63
|
+
for uid, (qname, consumer) in self._consumers.items()
|
|
64
|
+
if qname == queue_name and (uuid is None or uid == uuid)
|
|
65
|
+
}
|
|
66
|
+
if not targets:
|
|
67
|
+
_logger.warning(f"No running listener found | queue={queue_name} uuid={uuid}")
|
|
68
|
+
return
|
|
69
|
+
for uid in targets:
|
|
70
|
+
await self._cancel(uid)
|
|
71
|
+
|
|
72
|
+
async def stop_all(self) -> None:
|
|
73
|
+
for uid in list(self._tasks.keys()):
|
|
74
|
+
await self._cancel(uid)
|
|
75
|
+
await self._queue.disconnect()
|
|
76
|
+
self._connected = False
|
|
77
|
+
_logger.info("VsQueueManager stopped")
|
|
78
|
+
|
|
79
|
+
async def health_check(self) -> bool:
|
|
80
|
+
if not self._connected:
|
|
81
|
+
return False
|
|
82
|
+
return await self._queue.health_check()
|
|
83
|
+
|
|
84
|
+
async def _cancel(self, uid: str) -> None:
|
|
85
|
+
task = self._tasks.pop(uid, None)
|
|
86
|
+
if task:
|
|
87
|
+
task.cancel()
|
|
88
|
+
try:
|
|
89
|
+
await task
|
|
90
|
+
except asyncio.CancelledError:
|
|
91
|
+
pass
|
|
92
|
+
queue_name, _ = self._consumers.pop(uid, (None, None))
|
|
93
|
+
_logger.info(f"Listener stopped | queue={queue_name} uuid={uid}")
|
|
94
|
+
|
|
95
|
+
async def _run(self, queue_name: str, consumer: VsBaseConsumer) -> None:
|
|
96
|
+
attempts = 0
|
|
97
|
+
while True:
|
|
98
|
+
try:
|
|
99
|
+
await self._queue.subscribe(queue_name, consumer)
|
|
100
|
+
break
|
|
101
|
+
except asyncio.CancelledError:
|
|
102
|
+
await consumer.stop()
|
|
103
|
+
break
|
|
104
|
+
except Exception as e:
|
|
105
|
+
attempts += 1
|
|
106
|
+
if attempts > self._max_consumer_restarts:
|
|
107
|
+
_logger.critical(
|
|
108
|
+
f"Consumer permanently failed after {attempts} restarts | queue={queue_name} error={e}",
|
|
109
|
+
exc_info=True,
|
|
110
|
+
)
|
|
111
|
+
break
|
|
112
|
+
backoff = 2 ** (attempts - 1)
|
|
113
|
+
_logger.error(
|
|
114
|
+
f"Consumer crashed, restarting in {backoff}s | queue={queue_name} attempt={attempts} error={e}"
|
|
115
|
+
)
|
|
116
|
+
await asyncio.sleep(backoff)
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from vs_queue.base.vs_base_consumer import VsBaseConsumer
|
|
6
|
+
from vs_queue.base.vs_base_queue import VsBaseQueue
|
|
7
|
+
from vs_queue.retry.vs_retry_policy import VsRetryPolicy
|
|
8
|
+
from vs_queue.schema.vs_message import VsMessage
|
|
9
|
+
|
|
10
|
+
_logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VsRabbitMQQueue(VsBaseQueue):
|
|
14
|
+
|
|
15
|
+
def __init__(self, host: str, port: int, credentials: Optional[Dict[str, Any]] = None):
|
|
16
|
+
super().__init__(host, port, credentials)
|
|
17
|
+
self._connection = None
|
|
18
|
+
self._channel = None
|
|
19
|
+
|
|
20
|
+
async def connect(self) -> None:
|
|
21
|
+
try:
|
|
22
|
+
import aio_pika
|
|
23
|
+
username = self._credentials.get("username", "guest")
|
|
24
|
+
password = self._credentials.get("password", "guest")
|
|
25
|
+
vhost = self._credentials.get("vhost", "/")
|
|
26
|
+
self._connection = await aio_pika.connect_robust(
|
|
27
|
+
host=self._host,
|
|
28
|
+
port=self._port,
|
|
29
|
+
login=username,
|
|
30
|
+
password=password,
|
|
31
|
+
virtualhost=vhost,
|
|
32
|
+
)
|
|
33
|
+
self._channel = await self._connection.channel()
|
|
34
|
+
_logger.info(f"RabbitMQ connected | host={self._host}:{self._port}")
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise ImportError("Install aio-pika: pip install aio-pika")
|
|
37
|
+
|
|
38
|
+
async def disconnect(self) -> None:
|
|
39
|
+
if self._connection:
|
|
40
|
+
await self._connection.close()
|
|
41
|
+
self._connection = None
|
|
42
|
+
self._channel = None
|
|
43
|
+
|
|
44
|
+
async def health_check(self) -> bool:
|
|
45
|
+
return self._connection is not None and not self._connection.is_closed
|
|
46
|
+
|
|
47
|
+
async def publish(self, queue: str, message: VsMessage) -> None:
|
|
48
|
+
import aio_pika
|
|
49
|
+
await self._channel.declare_queue(queue, durable=True)
|
|
50
|
+
body = json.dumps({
|
|
51
|
+
"id": message.id,
|
|
52
|
+
"timestamp": message.timestamp.isoformat(),
|
|
53
|
+
"retry_count": message.retry_count,
|
|
54
|
+
"headers": message.headers,
|
|
55
|
+
"payload": message.payload or {},
|
|
56
|
+
}).encode()
|
|
57
|
+
await self._channel.default_exchange.publish(
|
|
58
|
+
aio_pika.Message(body=body, delivery_mode=aio_pika.DeliveryMode.PERSISTENT),
|
|
59
|
+
routing_key=queue,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def subscribe(self, queue: str, consumer: VsBaseConsumer, retry_policy: Optional[VsRetryPolicy] = None) -> None:
|
|
63
|
+
q = await self._channel.declare_queue(queue, durable=True)
|
|
64
|
+
await consumer.start()
|
|
65
|
+
try:
|
|
66
|
+
async with q.iterator() as iterator:
|
|
67
|
+
async for amqp_message in iterator:
|
|
68
|
+
data = json.loads(amqp_message.body)
|
|
69
|
+
message = VsMessage(
|
|
70
|
+
id=data["id"],
|
|
71
|
+
timestamp=data["timestamp"],
|
|
72
|
+
retry_count=data["retry_count"],
|
|
73
|
+
headers=data["headers"],
|
|
74
|
+
payload=data["payload"],
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
await consumer.handle(message)
|
|
78
|
+
await amqp_message.ack()
|
|
79
|
+
except Exception as e:
|
|
80
|
+
await consumer.on_error(message, e)
|
|
81
|
+
if retry_policy:
|
|
82
|
+
retried = await retry_policy.handle_failure(message, e, self)
|
|
83
|
+
if retried:
|
|
84
|
+
await amqp_message.ack()
|
|
85
|
+
else:
|
|
86
|
+
await amqp_message.nack(requeue=False)
|
|
87
|
+
else:
|
|
88
|
+
await amqp_message.nack(requeue=False)
|
|
89
|
+
_logger.error(f"Message failed, no retry policy | id={message.id} error={e}")
|
|
90
|
+
finally:
|
|
91
|
+
await consumer.stop()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import redis.asyncio as aioredis
|
|
7
|
+
|
|
8
|
+
from vs_queue.base.vs_base_consumer import VsBaseConsumer
|
|
9
|
+
from vs_queue.base.vs_base_queue import VsBaseQueue
|
|
10
|
+
from vs_queue.retry.vs_retry_policy import VsRetryPolicy
|
|
11
|
+
from vs_queue.schema.vs_message import VsMessage
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VsRedisQueue(VsBaseQueue):
|
|
17
|
+
|
|
18
|
+
def __init__(self, host: str, port: int, credentials: Optional[Dict[str, Any]] = None):
|
|
19
|
+
super().__init__(host, port, credentials)
|
|
20
|
+
self._client: Optional[aioredis.Redis] = None
|
|
21
|
+
|
|
22
|
+
async def connect(self) -> None:
|
|
23
|
+
password = self._credentials.get("password")
|
|
24
|
+
self._client = aioredis.Redis(
|
|
25
|
+
host=self._host,
|
|
26
|
+
port=self._port,
|
|
27
|
+
password=password,
|
|
28
|
+
decode_responses=True,
|
|
29
|
+
)
|
|
30
|
+
await self._client.ping()
|
|
31
|
+
|
|
32
|
+
async def disconnect(self) -> None:
|
|
33
|
+
if self._client:
|
|
34
|
+
await self._client.aclose()
|
|
35
|
+
self._client = None
|
|
36
|
+
|
|
37
|
+
async def health_check(self) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
await self._client.ping()
|
|
40
|
+
return True
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
async def publish(self, queue: str, message: VsMessage) -> None:
|
|
45
|
+
data = {
|
|
46
|
+
"id": message.id,
|
|
47
|
+
"timestamp": message.timestamp.isoformat(),
|
|
48
|
+
"retry_count": str(message.retry_count),
|
|
49
|
+
"headers": json.dumps(message.headers),
|
|
50
|
+
"payload": json.dumps(message.payload or {}),
|
|
51
|
+
}
|
|
52
|
+
await self._client.xadd(queue, data)
|
|
53
|
+
|
|
54
|
+
async def blpop(self, key: str, timeout: int = 30) -> Optional[str]:
|
|
55
|
+
result = await self._client.blpop(key, timeout=timeout)
|
|
56
|
+
if result is None:
|
|
57
|
+
return None
|
|
58
|
+
_, value = result
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
async def subscribe(self, queue: str, consumer: VsBaseConsumer, retry_policy: Optional[VsRetryPolicy] = None) -> None:
|
|
62
|
+
group = f"{queue}:group"
|
|
63
|
+
consumer_name = f"{queue}:consumer"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
await self._client.xgroup_create(queue, group, id="0", mkstream=True)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
await consumer.start()
|
|
71
|
+
try:
|
|
72
|
+
while True:
|
|
73
|
+
results = await self._client.xreadgroup(
|
|
74
|
+
groupname=group,
|
|
75
|
+
consumername=consumer_name,
|
|
76
|
+
streams={queue: ">"},
|
|
77
|
+
count=1,
|
|
78
|
+
block=5000,
|
|
79
|
+
)
|
|
80
|
+
if not results:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
for _, messages in results:
|
|
84
|
+
for msg_id, data in messages:
|
|
85
|
+
message = VsMessage(
|
|
86
|
+
id=data["id"],
|
|
87
|
+
timestamp=data["timestamp"],
|
|
88
|
+
retry_count=int(data["retry_count"]),
|
|
89
|
+
headers=json.loads(data["headers"]),
|
|
90
|
+
payload=json.loads(data["payload"]),
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
await consumer.handle(message)
|
|
94
|
+
await self._client.xack(queue, group, msg_id)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
await consumer.on_error(message, e)
|
|
97
|
+
if retry_policy:
|
|
98
|
+
retried = await retry_policy.handle_failure(message, e, self)
|
|
99
|
+
if retried:
|
|
100
|
+
await self._client.xack(queue, group, msg_id)
|
|
101
|
+
else:
|
|
102
|
+
_logger.error(f"Message failed, no retry policy | id={message.id} error={e}")
|
|
103
|
+
finally:
|
|
104
|
+
await consumer.stop()
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Dict, Type
|
|
2
|
+
|
|
3
|
+
from vs_queue.base.vs_base_queue import VsBaseQueue
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VsQueueRegistry:
|
|
7
|
+
|
|
8
|
+
_registry: Dict[str, Type[VsBaseQueue]] = {}
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def register(cls, name: str, queue_class: Type[VsBaseQueue]) -> None:
|
|
12
|
+
if not issubclass(queue_class, VsBaseQueue):
|
|
13
|
+
raise TypeError(f"'{queue_class.__name__}' must extend VsBaseQueue")
|
|
14
|
+
cls._registry[name] = queue_class
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def get(cls, name: str) -> Type[VsBaseQueue]:
|
|
18
|
+
if name not in cls._registry:
|
|
19
|
+
raise KeyError(f"Queue provider '{name}' is not registered. Available: {list(cls._registry.keys())}")
|
|
20
|
+
return cls._registry[name]
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def available(cls) -> list:
|
|
24
|
+
return list(cls._registry.keys())
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from vs_queue.schema.vs_message import VsMessage
|
|
6
|
+
|
|
7
|
+
_logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VsRetryPolicy:
|
|
11
|
+
|
|
12
|
+
def __init__(self, max_retries: int = 3, backoff_seconds: float = 1.0, dead_letter_queue: Optional[str] = None):
|
|
13
|
+
self.max_retries = max_retries
|
|
14
|
+
self.backoff_seconds = backoff_seconds
|
|
15
|
+
self.dead_letter_queue = dead_letter_queue
|
|
16
|
+
|
|
17
|
+
async def handle_failure(self, message: VsMessage, error: Exception, queue) -> bool:
|
|
18
|
+
if message.retry_count < self.max_retries:
|
|
19
|
+
message.retry_count += 1
|
|
20
|
+
delay = self.backoff_seconds * (2 ** (message.retry_count - 1))
|
|
21
|
+
_logger.warning(f"Retrying message | id={message.id} attempt={message.retry_count} delay={delay}s error={error}")
|
|
22
|
+
await asyncio.sleep(delay)
|
|
23
|
+
await queue.publish(message.headers.get("source_queue", ""), message)
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
_logger.error(f"Message exceeded max retries | id={message.id} retries={message.retry_count}")
|
|
27
|
+
if self.dead_letter_queue:
|
|
28
|
+
await queue.publish(self.dead_letter_queue, message)
|
|
29
|
+
_logger.info(f"Message sent to DLQ | id={message.id} dlq={self.dead_letter_queue}")
|
|
30
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VsMessage(BaseModel):
|
|
9
|
+
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
10
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
11
|
+
retry_count: int = 0
|
|
12
|
+
headers: Dict[str, str] = Field(default_factory=dict)
|
|
13
|
+
payload: Optional[Dict[str, Any]] = None
|