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