Skip to content

Commit 26ac5d6

Browse files
committed
Add unsubscribe mechanism and enhance configuration dynamic loading:
- Implement `unsubscribe` method in `ProcessingQueue` for subscriber cleanup. - Refactor subscriber ID handling in forwarders. - Improve configuration loading with validation for `.yaml` files. - Comment out unsupported event handling logic.
1 parent a8095b2 commit 26ac5d6

5 files changed

Lines changed: 107 additions & 8 deletions

File tree

mongoose/core/processing.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import enum
2-
from collections import defaultdict
32
import logging
3+
from collections import defaultdict
44
from queue import Queue, Full
55
from threading import Event
66
from typing import Any, Dict, List
@@ -74,7 +74,6 @@ def publish(self, topic: ProcessingTopic, data: Any):
7474
q.put_nowait(data)
7575
except Full as e:
7676
logger.error(f"Failed to publish data to {topic}: {e}")
77-
raise e
7877

7978
def subscribe(self, topic: ProcessingTopic | List[ProcessingTopic], subscriber_id: str, queue_size=100) -> Queue:
8079
"""Subscribe to one or more topics and receive a dedicated queue for receiving data.
@@ -118,6 +117,29 @@ def subscribe(self, topic: ProcessingTopic | List[ProcessingTopic], subscriber_i
118117
self.queues[t].append(q)
119118
return q
120119

120+
def unsubscribe(self, subscriber_id: str):
121+
"""Completely unsubscribe a subscriber and remove all associated queues.
122+
123+
This method removes the subscriber from all topics they were subscribed to
124+
and cleans up their dedicated queues. If a topic has no more subscribers
125+
after this operation, it is removed from the system.
126+
127+
Args:
128+
subscriber_id: Unique identifier for the subscriber to unsubscribe.
129+
"""
130+
logger.info(f"Unsubscribing {subscriber_id}")
131+
if subscriber_id not in self.subscribers:
132+
logger.warning(f"Subscriber {subscriber_id} not found")
133+
return
134+
135+
subscribed_topics = self.subscribers.pop(subscriber_id)
136+
for topic, q in subscribed_topics.items():
137+
if topic in self.queues:
138+
if q in self.queues[topic]:
139+
self.queues[topic].remove(q)
140+
if not self.queues[topic]:
141+
del self.queues[topic]
142+
121143
def stop_processing(self):
122144
"""Signal all processing operations to stop.
123145

mongoose/core/watchdogs.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def __init__(self, config_class: Type, callback: Callable):
3232
super().__init__()
3333

3434
def _load_configuration(self, config_file: Path):
35+
if not config_file.name.endswith(".yaml"):
36+
return None
3537
with config_file.open(mode="r") as f:
3638
config_data = yaml.safe_load(f)
3739
if config_data:
@@ -53,10 +55,11 @@ def on_any_event(self, event: FileSystemEvent):
5355
new_configuration = self._load_configuration(src_file)
5456
if new_configuration:
5557
created.append(new_configuration)
56-
elif event.event_type == "modified":
57-
new_configuration = self._load_configuration(src_file)
58-
if new_configuration:
59-
modified.append(new_configuration)
58+
# Not supported yet
59+
# elif event.event_type == "modified":
60+
# new_configuration = self._load_configuration(src_file)
61+
# if new_configuration:
62+
# modified.append(new_configuration)
6063
elif event.event_type == "deleted":
6164
deleted.append(src_file)
6265

mongoose/forward/base.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ def __init__(self, topics: List[str]):
5151
self.thread: Optional[threading.Thread] = None
5252
self.queue = None
5353

54+
@property
55+
def subscriber_id(self):
56+
return f"{self.__class__.__name__.lower()}_{id(self)}"
57+
5458
def start(self):
5559
"""Start the forwarder worker thread.
5660
@@ -66,8 +70,7 @@ def start(self):
6670
return
6771

6872
# Unique subscriber ID to avoid collisions
69-
subscriber_id = f"{self.__class__.__name__.lower()}_{id(self)}"
70-
self.queue = self.processing_queue.subscribe(topics, subscriber_id=subscriber_id)
73+
self.queue = self.processing_queue.subscribe(topics, subscriber_id=self.subscriber_id)
7174

7275
self.thread = threading.Thread(target=self._run, daemon=True)
7376
self.thread.start()

mongoose/forward/webhook.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def _forward_batch(self, batch: list):
232232
def disable(self):
233233
"""Disable the forwarder."""
234234
self.config.enable = False
235+
self.processing_queue.unsubscribe(self.subscriber_id)
235236

236237
def forward(self, data: Any):
237238
"""Send formatted data to the webhook URL with retries.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import pytest
2+
3+
from mongoose.core.processing import ProcessingQueue, ProcessingTopic
4+
from mongoose.utils.exceptions import TopicNotFoundException
5+
6+
7+
def test_unsubscribe():
8+
pq = ProcessingQueue()
9+
subscriber_id = "test_subscriber"
10+
topic = ProcessingTopic.NETWORK_DPI
11+
12+
# Reset singleton-like behavior if any (though it looks like a normal class with class attributes)
13+
# Actually, subscribers and queues are class attributes, which is a bit strange if multiple instances are intended.
14+
# Let's check if they are indeed class attributes.
15+
ProcessingQueue.subscribers.clear()
16+
ProcessingQueue.queues.clear()
17+
18+
# Subscribe
19+
pq.subscribe(topic, subscriber_id)
20+
assert subscriber_id in pq.subscribers
21+
assert topic in pq.queues
22+
assert len(pq.queues[topic]) == 1
23+
24+
# Unsubscribe (method not yet implemented)
25+
if hasattr(pq, 'unsubscribe'):
26+
pq.unsubscribe(subscriber_id)
27+
assert subscriber_id not in pq.subscribers
28+
assert topic not in pq.queues or len(pq.queues[topic]) == 0
29+
30+
# Verify that publishing to the topic now raises TopicNotFoundException if no other subscribers
31+
with pytest.raises(TopicNotFoundException):
32+
pq.publish(topic, "test data")
33+
else:
34+
pytest.fail("ProcessingQueue has no unsubscribe method")
35+
36+
37+
def test_unsubscribe_not_found():
38+
pq = ProcessingQueue()
39+
ProcessingQueue.subscribers.clear()
40+
ProcessingQueue.queues.clear()
41+
42+
# Should not raise any exception
43+
pq.unsubscribe("non_existent_subscriber")
44+
45+
46+
def test_unsubscribe_partial_others_remain():
47+
pq = ProcessingQueue()
48+
sub1 = "sub1"
49+
sub2 = "sub2"
50+
topic = ProcessingTopic.NETWORK_DPI
51+
52+
ProcessingQueue.subscribers.clear()
53+
ProcessingQueue.queues.clear()
54+
55+
pq.subscribe(topic, sub1)
56+
pq.subscribe(topic, sub2)
57+
58+
assert len(pq.queues[topic]) == 2
59+
60+
pq.unsubscribe(sub1)
61+
62+
assert sub1 not in pq.subscribers
63+
assert sub2 in pq.subscribers
64+
assert topic in pq.queues
65+
assert len(pq.queues[topic]) == 1
66+
67+
# Can still publish to sub2
68+
pq.publish(topic, "data")
69+
q2 = pq.subscribers[sub2][topic]
70+
assert q2.get_nowait() == "data"

0 commit comments

Comments
 (0)