Skip to content

Commit 33d5ba5

Browse files
committed
added support for wildcards on topics and backoff behavior for listeners.
1 parent 39f7bb5 commit 33d5ba5

11 files changed

Lines changed: 122 additions & 52 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
venv
2-
dist
2+
dist
3+
.pytest_cache

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ Hello, Max! Executed only once and nevermore.
113113

114114
---
115115
## Todos:
116-
- [ ] Wildcard topics
117-
- [ ] Retry/Backoff and DLQ
116+
- [X] Wildcard topics
117+
- [X] Retry/Backoff and DLQ
118118
- [ ] Write unit tests
119119
- [ ] Roles and permissions
120120
- [ ] Payload validation

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
pythonpath = src
1.45 KB
Binary file not shown.
78 Bytes
Binary file not shown.

src/windmill_lib/event_bus.py

Lines changed: 73 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,36 @@
44
import inspect
55
from typing import Any, Callable, Dict, List, Optional, Tuple
66
from .models import Event, Handler
7+
import re
78

89
class EventBus:
10+
"""
11+
The EventBus is the main entity of this library.
12+
It handles events through associated listeners and
13+
organizes the execution flow.
14+
"""
915
def __init__(self) -> None:
1016
self._subscribers: Dict[str, List[Handler]] = {}
1117
self._lock = asyncio.Lock()
1218

13-
async def subscribe_async(self, topic: str, handler: Callable[[Event], Any], *, priority: int = 0, once: bool = False) -> str:
14-
h = Handler(neg_priority=-priority, callback=handler, once=once)
19+
async def subscribe_async(self, topic: str, handler: Callable[[Event], Any], *, priority: int = 0, once: bool = False, max_retries: int = 0) -> str:
20+
"""Same of `subscribe`, but is asynchronous."""
21+
h = Handler(neg_priority=-priority, callback=handler, once=once, max_retries=max_retries)
1522

1623
async with self._lock:
1724
self._subscribers.setdefault(topic, []).append(h)
1825

1926
return h.identifier
2027

21-
def subscribe(self, topic: str, handler: Callable[[Event], Any], *, priority: int = 0, once: bool = False) -> str:
22-
h = Handler(neg_priority=-priority, callback=handler, once=once)
28+
def subscribe(self, topic: str, handler: Callable[[Event], Any], *, priority: int = 0, once: bool = False, max_retries: int = 0) -> str:
29+
"""Subscribes a listener to an event/topic. When the event is published, the listener is executed."""
30+
h = Handler(neg_priority=-priority, callback=handler, once=once, max_retries=max_retries)
2331
self._subscribers.setdefault(topic, []).append(h)
2432

2533
return h.identifier
2634

2735
async def unsubscribe_async(self, topic: str, handler_id: str) -> bool:
36+
"""Same of `unsubscribe`, but is asynchronous."""
2837
async with self._lock:
2938
handlers = self._subscribers.get(topic)
3039

@@ -40,6 +49,7 @@ async def unsubscribe_async(self, topic: str, handler_id: str) -> bool:
4049
return len(handlers) != before
4150

4251
def unsubscribe(self, topic: str, handler_id: str) -> bool:
52+
"""Removes a listener from a topic."""
4353
handlers = self._subscribers.get(topic)
4454

4555
if not handlers:
@@ -53,69 +63,84 @@ def unsubscribe(self, topic: str, handler_id: str) -> bool:
5363

5464
return len(handlers) != before
5565

56-
def on(self, topic: str, *, priority: int = 0, once: bool = False) -> Callable[[Callable[[Event], Any]], Callable[[Event], Any]]:
57-
def decorator(fn: Callable[[Event], Any]):
58-
self.subscribe(topic, fn, priority=priority, once=once)
66+
def on(self, topic: str, *, priority: int = 0, once: bool = False, max_retries: int = 0) -> Callable[[Callable[[Event], Any]], Callable[[Event], Any]]:
67+
"""Decorator to help creating listeners. It does the same as `subscribe`, but in an easier way."""
68+
def decorator(fn: Callable[[Event], Any]) -> Callable[[Event], Any]:
69+
self.subscribe(topic, fn, priority=priority, once=once, max_retries=max_retries)
5970
return fn
60-
6171
return decorator
62-
72+
6373
async def publish_async(self, topic: str, payload: Any = None, *, metadata: Optional[Dict[str, Any]] = None) -> None:
74+
"""Same of `publish`, but is asynchronous."""
6475
event = Event(topic=topic, payload=payload, metadata=metadata or {})
6576

6677
async with self._lock:
67-
handlers = list(self._subscribers.get(topic, []))
78+
handlers = self._find_handlers(topic)
6879

6980
if not handlers:
7081
return
7182

72-
handlers.sort()
73-
loop = asyncio.get_running_loop()
7483
to_remove: List[str] = []
7584

7685
for h in handlers:
77-
cb = h.callback
86+
await self._execute_handler(h, event)
7887

79-
try:
80-
if inspect.iscoroutinefunction(cb):
81-
await cb(event)
82-
else:
83-
await loop.run_in_executor(None, cb, event)
84-
except Exception:
85-
import traceback
86-
traceback.print_exc()
87-
finally:
88-
if h.once:
89-
to_remove.append(h.identifier)
90-
88+
if h.once:
89+
to_remove.append((topic, h.identifier))
90+
9191
if to_remove:
9292
async with self._lock:
93-
current = self._subscribers.get(topic, [])
94-
current[:] = [h for h in current if h.identifier not in to_remove]
95-
96-
if not current:
97-
self._subscribers.pop(topic, None)
93+
for t, hid in to_remove:
94+
hs = self._subscribers.get(t, [])
95+
self._subscribers[t] = [x for x in hs if x.identifier != hid]
9896

9997
def publish(self, topic: str, payload: Any = None, *, metadata: Optional[Dict[str, Any]] = None) -> None:
100-
try:
101-
loop = asyncio.get_running_loop()
102-
except RuntimeError:
103-
loop = None
104-
105-
if loop is not None and loop.is_running():
106-
raise RuntimeError("synchronous publish cannot be called from a running asyncio event loop.")
107-
98+
"""Publishes an event. All the associated listeners will be executed."""
10899
asyncio.run(self.publish_async(topic, payload, metadata=metadata))
109100

110-
def list_subscribers(self, topic: Optional[str] = None) -> Dict[str, List[Tuple[str, int, bool]]]:
111-
out: Dict[str, List[Tuple[str, int, bool]]] = {}
112-
113-
for t, handlers in self._subscribers.items():
114-
if topic is not None and t != topic:
115-
continue
116-
out[t] = [(h.identifier, h.priority, h.once) for h in handlers]
117-
118-
return out
101+
def list_subscribers(self) -> Dict[str, List[Tuple[str, int, bool, int]]]:
102+
return {t: [(h.identifier, h.priority, h.once, h.max_retries) for h in hs] for t, hs in self._subscribers.items()}
119103

120104
def clear(self) -> None:
121-
self._subscribers.clear()
105+
self._subscribers.clear()
106+
107+
@staticmethod
108+
def _match_topic(pattern: str, topic: str) -> bool:
109+
regex_pattern = re.escape(pattern)
110+
regex_pattern = regex_pattern.replace(r"\*", "[^.]+")
111+
regex_pattern = regex_pattern.replace(r"\#", ".*")
112+
regex_pattern = f"^{regex_pattern}$"
113+
return re.match(regex_pattern, topic) is not None
114+
115+
def _find_handlers(self, topic: str) -> List[Handler]:
116+
handlers: List[Handler] = []
117+
for pattern, hs in self._subscribers.items():
118+
if self._match_topic(pattern, topic):
119+
handlers.extend(hs)
120+
121+
handlers.sort()
122+
return handlers
123+
124+
async def _execute_handler(self, handler: Handler, event: Event) -> None:
125+
retries = 0
126+
backoff_base = 0.2
127+
128+
while True:
129+
try:
130+
if inspect.iscoroutinefunction(handler.callback):
131+
await handler.callback(event)
132+
else:
133+
loop = asyncio.get_running_loop()
134+
await loop.run_in_executor(None, handler.callback, event)
135+
break
136+
except Exception as e:
137+
retries += 1
138+
if retries > handler.max_retries:
139+
await self.publish_async('dead.letter', {
140+
'original_topic': event.topic,
141+
'event_id': event.identifier,
142+
'payload': event.payload,
143+
'error': str(e),
144+
})
145+
break
146+
await asyncio.sleep(backoff_base * (2 ** (retries - 1)))

src/windmill_lib/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Handler:
2727
neg_priority: int
2828
callback: Callable[[Event], Any] = field(compare=False)
2929
once: bool = field(default=False, compare=False)
30+
max_retries: int = field(default=0, compare=False)
3031
identifier: str = field(default_factory=lambda: str(uuid.uuid4()), compare=False)
3132

3233
@property
2.15 KB
Binary file not shown.
2.51 KB
Binary file not shown.

tests/test_event_bus.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
if __name__ == '__main__':
2-
from src.windmill_lib.event_bus import EventBus, Event
2+
import sys
3+
import os
4+
5+
# adiciona o diretório src/ ao sys.path
6+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'src')))
7+
8+
from windmill_lib.event_bus import EventBus, Event
39
import asyncio
410

511
bus = EventBus()

0 commit comments

Comments
 (0)