intersect-sdk 0.6.2a1__tar.gz → 0.6.3__tar.gz
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.
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/PKG-INFO +1 -1
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/pyproject.toml +1 -1
- intersect_sdk-0.6.3/src/intersect_sdk/_internal/control_plane/brokers/amqp_client.py +481 -0
- intersect_sdk-0.6.3/src/intersect_sdk/_internal/control_plane/brokers/broker_client.py +72 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/brokers/mqtt_client.py +61 -13
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/control_plane_manager.py +52 -19
- intersect_sdk-0.6.3/src/intersect_sdk/_internal/control_plane/topic_handler.py +19 -0
- intersect_sdk-0.6.3/src/intersect_sdk/_internal/multi_flag_thread_event.py +77 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/app_lifecycle.py +7 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/client.py +47 -7
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/config/client.py +15 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/service.py +39 -6
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/version.py +1 -1
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/e2e/test_examples.py +4 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/fixtures/example_schema.json +1 -1
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/integration/test_return_type_mismatch.py +3 -2
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/integration/test_service.py +17 -10
- intersect_sdk-0.6.2a1/src/intersect_sdk/_internal/control_plane/brokers/amqp_client.py +0 -280
- intersect_sdk-0.6.2a1/src/intersect_sdk/_internal/control_plane/brokers/broker_client.py +0 -58
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/LICENSE +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/README.md +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/constants.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/brokers/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/discovery_service.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/data_plane/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/data_plane/data_plane_manager.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/data_plane/minio_utils.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/event_metadata.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/exceptions.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/function_metadata.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/interfaces.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/logger.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/event.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/lifecycle.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/userspace.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/pydantic_schema_generator.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/schema.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/stoppable_thread.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/utils.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/version.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/version_resolver.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/capability/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/capability/base.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/client_callback_definitions.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/config/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/config/service.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/config/shared.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/constants.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/core_definitions.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/py.typed +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/schema.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/src/intersect_sdk/service_definitions.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/conftest.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/e2e/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/fixtures/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/fixtures/example_schema.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/fixtures/return_type_mismatch.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/integration/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/__init__.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_annotations.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_base_capability_implementation.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_config.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_invalid_schema_runtime.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_lifecycle_message.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_schema_invalids.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_schema_valid.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_userspace_message.py +0 -0
- {intersect_sdk-0.6.2a1 → intersect_sdk-0.6.3}/tests/unit/test_version_resolver.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: intersect-sdk
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Python SDK to interact with INTERSECT
|
|
5
5
|
Keywords: intersect
|
|
6
6
|
Author-Email: Lance Drane <dranelt@ornl.gov>, Marshall McDonnell <mcdonnellmt@ornl.gov>, Seth Hitefield <hitefieldsd@ornl.gov>, Andrew Ayres <ayresaf@ornl.gov>, Gregory Cage <cagege@ornl.gov>, Jesse McGaha <mcgahajr@ornl.gov>, Robert Smith <smithrw@ornl.gov>, Gavin Wiggins <wigginsg@ornl.gov>, Michael Brim <brimmj@ornl.gov>, Rick Archibald <archibaldrk@ornl.gov>, Addi Malviya Thakur <malviyaa@ornl.gov>
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""This module handles ALL AMQP protocol logic in INTERSECT. We seek to entirely abstract protocols away from users.
|
|
2
|
+
|
|
3
|
+
This is a very specific pub-sub model which assumes a single topic exchange.
|
|
4
|
+
AMQP topics in INTERSECT generally look like ${ORGANIZATION}.${HIERARCHY}.${SYSTEM}.${SUBSYSTEM}.${SERVICE}.${MESSAGE_TYPE} .
|
|
5
|
+
MESSAGE_TYPE refers to INTERSECT domain messages - we do not allow users to determine their own message types directly, and every message has a message type.
|
|
6
|
+
SERVICE refers to a specific application.
|
|
7
|
+
SYSTEM is generally the level where Auth should occur, and where you should configure access control on the broker itself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import threading
|
|
14
|
+
from hashlib import sha384
|
|
15
|
+
from typing import TYPE_CHECKING, Callable
|
|
16
|
+
|
|
17
|
+
import pika
|
|
18
|
+
import pika.delivery_mode
|
|
19
|
+
import pika.exceptions
|
|
20
|
+
import pika.frame
|
|
21
|
+
|
|
22
|
+
from ...logger import logger
|
|
23
|
+
from ...multi_flag_thread_event import MultiFlagThreadEvent
|
|
24
|
+
from .broker_client import BrokerClient
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pika.channel import Channel
|
|
28
|
+
from pika.frame import Frame
|
|
29
|
+
from pika.spec import Basic, BasicProperties
|
|
30
|
+
|
|
31
|
+
from ..topic_handler import TopicHandler
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_AMQP_MAX_RETRIES = 10
|
|
35
|
+
|
|
36
|
+
# Note that we deliberately do NOT want this configurable at runtime. Any two INTERSECT services/clients could potentially exchange messages between one another.
|
|
37
|
+
_INTERSECT_MESSAGE_EXCHANGE = 'intersect-messages'
|
|
38
|
+
"""All INTERSECT messages get published to one exchange on the broker."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_queue_name(routing_key: str) -> str:
|
|
42
|
+
"""Generate a valid queue name from the routing key.
|
|
43
|
+
|
|
44
|
+
We want to always be able to generate the same queue name from the routing key every time,
|
|
45
|
+
so we don't use UUIDs or want the broker to generate a key name.
|
|
46
|
+
|
|
47
|
+
We must also keep the length under 128 characters.
|
|
48
|
+
|
|
49
|
+
See https://www.rabbitmq.com/docs/queues#names for a complete reference.
|
|
50
|
+
"""
|
|
51
|
+
return sha384(routing_key.encode()).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# TODO we should be handling hierarchy parts as a list of strings until they get to the client
|
|
55
|
+
# this will be a breaking change, so only add it when ready to break
|
|
56
|
+
def _hierarchy_2_amqp(hierarchy: str) -> str:
|
|
57
|
+
"""Take the hierarchy string format saved in the Service and map it to the AMQP topic format."""
|
|
58
|
+
return hierarchy.replace('/', '.')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# TODO see above
|
|
62
|
+
def _amqp_2_hierarchy(amqp_routing_key: str) -> str:
|
|
63
|
+
"""Convert AMQP topic formats to how we store a key in the ControlPlaneManager."""
|
|
64
|
+
return amqp_routing_key.replace('.', '/')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AMQPClient(BrokerClient):
|
|
68
|
+
"""Client for performing broker actions backed by a AMQP broker.
|
|
69
|
+
|
|
70
|
+
NOTE: Currently, thread safety has been attempted, but may not be guaranteed
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
id: A string representation of the client's UUID.
|
|
74
|
+
_connection_params: connection information to the AMQP broker (includes credentials)
|
|
75
|
+
_publish_connection: AMQP connection dedicated to publishing messages
|
|
76
|
+
_consume_connection: AMQP connection dedicated to consuming messages
|
|
77
|
+
_topics_to_handlers: Dictionary of string topic names to lists of
|
|
78
|
+
Callables to invoke for messages on that topic.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
host: str,
|
|
84
|
+
port: int,
|
|
85
|
+
username: str,
|
|
86
|
+
password: str,
|
|
87
|
+
topics_to_handlers: Callable[[], dict[str, TopicHandler]],
|
|
88
|
+
) -> None:
|
|
89
|
+
"""The default constructor.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
host: String for hostname of AMQP broker
|
|
93
|
+
port: port number of AMQP broker
|
|
94
|
+
username: username credentials for AMQP broker
|
|
95
|
+
password: password credentials for AMQP broker
|
|
96
|
+
topics_to_handlers: callback function which gets the topic to handler map from the channel manager
|
|
97
|
+
"""
|
|
98
|
+
self._connection_params = pika.ConnectionParameters(
|
|
99
|
+
host=host,
|
|
100
|
+
port=port,
|
|
101
|
+
virtual_host='/',
|
|
102
|
+
credentials=pika.PlainCredentials(username, password),
|
|
103
|
+
connection_attempts=3,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# The pika connection to the broker
|
|
107
|
+
self._connection: pika.adapters.SelectConnection = None
|
|
108
|
+
self._channel_in: Channel = None
|
|
109
|
+
self._channel_out: Channel = None
|
|
110
|
+
|
|
111
|
+
self._thread: threading.Thread | None = None
|
|
112
|
+
|
|
113
|
+
# Callback to the topics_to_handler list inside of
|
|
114
|
+
self._topics_to_handlers = topics_to_handlers
|
|
115
|
+
# mapping of topics to callables which can unsubscribe from the topic
|
|
116
|
+
self._topics_to_consumer_tags: dict[str, str] = {}
|
|
117
|
+
|
|
118
|
+
self._should_disconnect = False
|
|
119
|
+
self._connection_retries = 0
|
|
120
|
+
self._unrecoverable = False
|
|
121
|
+
# tracking both channels is the best way to handle continuations
|
|
122
|
+
self._channel_flags = MultiFlagThreadEvent(2)
|
|
123
|
+
|
|
124
|
+
def connect(self) -> None:
|
|
125
|
+
"""Connect to the defined broker.
|
|
126
|
+
|
|
127
|
+
Try to connect to the broker, performing exponential backoff if connection fails.
|
|
128
|
+
"""
|
|
129
|
+
self._should_disconnect = False
|
|
130
|
+
self._channel_flags.unset_all()
|
|
131
|
+
|
|
132
|
+
if not self._thread:
|
|
133
|
+
self._thread = threading.Thread(target=self._init_connection, daemon=True)
|
|
134
|
+
self._thread.start()
|
|
135
|
+
|
|
136
|
+
while not self._channel_flags.is_set():
|
|
137
|
+
self._channel_flags.wait(1.0)
|
|
138
|
+
|
|
139
|
+
def disconnect(self) -> None:
|
|
140
|
+
"""Close all connections."""
|
|
141
|
+
self._should_disconnect = True
|
|
142
|
+
for topic, tag in self._topics_to_consumer_tags.items():
|
|
143
|
+
self._cancel_consumer_tag(topic, tag)
|
|
144
|
+
|
|
145
|
+
# since _should_disconnect was set, _connection.ioloop.stop() will now execute after explicit connection close
|
|
146
|
+
self._connection.close()
|
|
147
|
+
|
|
148
|
+
if self._thread:
|
|
149
|
+
# If gracefully shutting down, we should finish up the current job.
|
|
150
|
+
self._thread.join(5 if self.considered_unrecoverable() else None)
|
|
151
|
+
self._thread = None
|
|
152
|
+
|
|
153
|
+
def is_connected(self) -> bool:
|
|
154
|
+
"""Check if there is an active connection to the broker.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A boolean. True if there is a connection, False if not.
|
|
158
|
+
"""
|
|
159
|
+
# We are connected to the broker if either the publish or consume connections is open
|
|
160
|
+
return self._connection is not None and (
|
|
161
|
+
not self._connection.is_closed or not self._connection.is_closing
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def considered_unrecoverable(self) -> bool:
|
|
165
|
+
return self._unrecoverable
|
|
166
|
+
|
|
167
|
+
def publish(self, topic: str, payload: bytes, persist: bool) -> None:
|
|
168
|
+
"""Publish the given message.
|
|
169
|
+
|
|
170
|
+
Publish payload with the pre-existing connection (via connect()) on topic.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
topic: The topic on which to publish the message as a string
|
|
174
|
+
payload: The message to publish, as raw bytes.
|
|
175
|
+
persist: True if message should persist until consumers available, False if message should be removed immediately.
|
|
176
|
+
"""
|
|
177
|
+
topic = _hierarchy_2_amqp(topic)
|
|
178
|
+
self._channel_out.basic_publish(
|
|
179
|
+
exchange=_INTERSECT_MESSAGE_EXCHANGE,
|
|
180
|
+
routing_key=topic,
|
|
181
|
+
body=payload,
|
|
182
|
+
properties=pika.BasicProperties(
|
|
183
|
+
content_type='text/plain',
|
|
184
|
+
delivery_mode=pika.delivery_mode.DeliveryMode.Persistent
|
|
185
|
+
if persist
|
|
186
|
+
else pika.delivery_mode.DeliveryMode.Transient,
|
|
187
|
+
# expiration=None if persist else '8640000',
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def subscribe(self, topic: str, persist: bool) -> None:
|
|
192
|
+
"""Subscribe to a topic.
|
|
193
|
+
|
|
194
|
+
topic: system-of-system hierarchy. In AMQP parlance this gets translated to the routing key.
|
|
195
|
+
persist: If True, we will create an idempotent queue name which should persist
|
|
196
|
+
even on broker or application shutdown. If False, we will allow the server to create a unique
|
|
197
|
+
queue name, and the queue will be destroyed once the associated channel is closed.
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
topic = _hierarchy_2_amqp(topic)
|
|
201
|
+
cb = functools.partial(
|
|
202
|
+
self._create_queue, channel=self._channel_in, topic=topic, persist=persist
|
|
203
|
+
)
|
|
204
|
+
self._connection.ioloop.add_callback_threadsafe(cb)
|
|
205
|
+
|
|
206
|
+
def unsubscribe(self, topic: str) -> None:
|
|
207
|
+
"""Stop consuming from a topic.
|
|
208
|
+
|
|
209
|
+
With INTERSECT's AMQP configuration, each queue will only have one consumer.
|
|
210
|
+
Therefore, transient queues will be cleaned up.
|
|
211
|
+
"""
|
|
212
|
+
amqp_topic = _hierarchy_2_amqp(topic)
|
|
213
|
+
consumer_tag = self._topics_to_consumer_tags.get(amqp_topic, None)
|
|
214
|
+
if consumer_tag:
|
|
215
|
+
self._cancel_consumer_tag(amqp_topic, consumer_tag)
|
|
216
|
+
|
|
217
|
+
def _cancel_consumer_tag(self, topic: str, consumer_tag: str) -> None:
|
|
218
|
+
if self._channel_in and self._channel_in.is_open:
|
|
219
|
+
cb = functools.partial(self._cancel_consumer_tag_cb, topic=topic)
|
|
220
|
+
self._channel_in.basic_cancel(
|
|
221
|
+
consumer_tag,
|
|
222
|
+
callback=cb,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _cancel_consumer_tag_cb(self, _frame: pika.frame.Frame, topic: str) -> None:
|
|
226
|
+
try:
|
|
227
|
+
del self._topics_to_consumer_tags[topic]
|
|
228
|
+
except KeyError:
|
|
229
|
+
# shouldn't happen because ControlPlaneManager gatekeeps consecutive remove_subscription_channel() calls
|
|
230
|
+
pass
|
|
231
|
+
logger.info('Unsubscribed from %s', topic)
|
|
232
|
+
|
|
233
|
+
# BEGIN CALLBACKS + THREADSAFE FUNCTIONS #
|
|
234
|
+
|
|
235
|
+
def _init_connection(self) -> None:
|
|
236
|
+
"""Open the consuming connection and start its io loop.
|
|
237
|
+
|
|
238
|
+
NOTE: ANY functions which are not eventually called from this function
|
|
239
|
+
should be called via self._connection.ioloop.add_callback_threadsafe(cb)
|
|
240
|
+
"""
|
|
241
|
+
while not self._should_disconnect:
|
|
242
|
+
self._connection = pika.adapters.SelectConnection(
|
|
243
|
+
parameters=self._connection_params,
|
|
244
|
+
on_close_callback=self._on_connection_closed,
|
|
245
|
+
on_open_error_callback=self._on_connection_open_error,
|
|
246
|
+
on_open_callback=self._on_connection_open,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Loops forever until ioloop.stop is called WHEN self._should_disconnect is True
|
|
250
|
+
self._connection.ioloop.start()
|
|
251
|
+
|
|
252
|
+
def _on_connection_closed(self, connection: pika.SelectConnection, reason: Exception) -> None:
|
|
253
|
+
"""This method is called if the connection to RabbitMQ closes."""
|
|
254
|
+
if self._should_disconnect:
|
|
255
|
+
connection.ioloop.stop()
|
|
256
|
+
else:
|
|
257
|
+
logger.warn('Connection closed, reopening in 5 seconds: %s', reason)
|
|
258
|
+
connection.ioloop.call_later(5, connection.ioloop.stop)
|
|
259
|
+
self._channel_flags.unset_all()
|
|
260
|
+
self._channel_out = None
|
|
261
|
+
self._channel_in = None
|
|
262
|
+
|
|
263
|
+
def _on_connection_open_error(
|
|
264
|
+
self, connection: pika.SelectConnection, err: pika.exceptions.AMQPConnectionError
|
|
265
|
+
) -> None:
|
|
266
|
+
"""This gets called if the connection to RabbitMQ can't be established.
|
|
267
|
+
|
|
268
|
+
This function usually implies a misconfiguration in the application config.
|
|
269
|
+
"""
|
|
270
|
+
self._connection_retries += 1
|
|
271
|
+
logger.error(
|
|
272
|
+
f'On connect error received (probable broker config error), have tried {self._connection_retries} times'
|
|
273
|
+
)
|
|
274
|
+
logger.error(err)
|
|
275
|
+
if self._connection_retries >= _AMQP_MAX_RETRIES:
|
|
276
|
+
# This will allow us to break out of the while loop
|
|
277
|
+
# where we establish the connection, as ioloop.stop
|
|
278
|
+
# will now stop the thread for good
|
|
279
|
+
logger.error('Giving up AMQP reconnection attempt')
|
|
280
|
+
self._should_disconnect = True
|
|
281
|
+
self._unrecoverable = True
|
|
282
|
+
self._channel_flags.set_all()
|
|
283
|
+
connection.ioloop.stop()
|
|
284
|
+
else:
|
|
285
|
+
logger.error('Reopening in 5 seconds')
|
|
286
|
+
connection.ioloop.call_later(5, connection.ioloop.stop)
|
|
287
|
+
|
|
288
|
+
def _on_connection_open(self, connection: pika.SelectConnection) -> None:
|
|
289
|
+
logger.info('AMQP connection open')
|
|
290
|
+
self._connection_retries = 0
|
|
291
|
+
self._topics_to_consumer_tags.clear()
|
|
292
|
+
connection.channel(on_open_callback=self._on_input_channel_open)
|
|
293
|
+
connection.channel(on_open_callback=self._on_output_channel_open)
|
|
294
|
+
|
|
295
|
+
def _on_channel_closed(
|
|
296
|
+
self,
|
|
297
|
+
channel: Channel,
|
|
298
|
+
exception: pika.exceptions.ChannelClosed,
|
|
299
|
+
channel_num: int,
|
|
300
|
+
) -> None:
|
|
301
|
+
self._channel_flags.unset_nth_flag(channel_num)
|
|
302
|
+
if self._connection.is_open:
|
|
303
|
+
# This should rarely happen in practice, should only happen if you attempt to do something which violates the protocol.
|
|
304
|
+
logger.error(
|
|
305
|
+
'Closing connection due to closed channel %s, please check the usage of the SDK or your configuration. Exception: %s',
|
|
306
|
+
channel,
|
|
307
|
+
str(exception),
|
|
308
|
+
)
|
|
309
|
+
self._connection.close(reply_code=exception.reply_code, reply_text=exception.reply_text)
|
|
310
|
+
|
|
311
|
+
# PRODUCER #
|
|
312
|
+
def _on_output_channel_open(self, channel: Channel) -> None:
|
|
313
|
+
channel_num = 0
|
|
314
|
+
self._channel_out = channel
|
|
315
|
+
cb = functools.partial(self._on_channel_closed, channel_num=channel_num)
|
|
316
|
+
self._channel_out.add_on_close_callback(cb)
|
|
317
|
+
# producer flag should first make sure the exchange exists before publishing
|
|
318
|
+
channel.exchange_declare(
|
|
319
|
+
exchange=_INTERSECT_MESSAGE_EXCHANGE,
|
|
320
|
+
exchange_type='topic',
|
|
321
|
+
durable=True,
|
|
322
|
+
callback=lambda _frame: self._channel_flags.set_nth_flag(channel_num),
|
|
323
|
+
)
|
|
324
|
+
logger.info('AMQP: output channel ready')
|
|
325
|
+
|
|
326
|
+
# CONSUMER #
|
|
327
|
+
def _on_input_channel_open(self, channel: Channel) -> None:
|
|
328
|
+
channel_num = 1
|
|
329
|
+
self._channel_in = channel
|
|
330
|
+
# consumer channel flag can be set immediately
|
|
331
|
+
self._channel_flags.set_nth_flag(channel_num)
|
|
332
|
+
cb_1 = functools.partial(self._on_channel_closed, channel_num=channel_num)
|
|
333
|
+
self._channel_in.add_on_close_callback(cb_1)
|
|
334
|
+
cb_2 = functools.partial(self._on_exchange_declareok, channel=channel)
|
|
335
|
+
channel.exchange_declare(
|
|
336
|
+
exchange=_INTERSECT_MESSAGE_EXCHANGE, exchange_type='topic', durable=True, callback=cb_2
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _on_exchange_declareok(self, _unused_frame: Frame, channel: Channel) -> None:
|
|
340
|
+
"""Create a queue on the broker (called from AMQP).
|
|
341
|
+
|
|
342
|
+
After verifying that the exchange exists, we can now proceed to execute
|
|
343
|
+
"initial subscriptions".
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
_unused_frame: response from declaring the exchange on the broker (irrelevant).
|
|
347
|
+
channel: The Channel being instantiated.
|
|
348
|
+
"""
|
|
349
|
+
for topic, topic_handler in self._topics_to_handlers().items():
|
|
350
|
+
amqp_topic = _hierarchy_2_amqp(topic)
|
|
351
|
+
cb = functools.partial(
|
|
352
|
+
self._create_queue,
|
|
353
|
+
channel=channel,
|
|
354
|
+
topic=amqp_topic,
|
|
355
|
+
persist=topic_handler.topic_persist,
|
|
356
|
+
)
|
|
357
|
+
self._connection.ioloop.add_callback_threadsafe(cb)
|
|
358
|
+
|
|
359
|
+
def _create_queue(self, channel: Channel, topic: str, persist: bool) -> None:
|
|
360
|
+
"""Create a queue on the broker.
|
|
361
|
+
|
|
362
|
+
This can be called directly from the AMQP Client if the subscribed connection already has a Channel it's listening to.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
channel: The Channel being instantiated.
|
|
366
|
+
topic: The string name for the Channel on the broker.
|
|
367
|
+
persist: boolean value to determine how we manage the queue.
|
|
368
|
+
If True, this queue will persist forever, even on application or broker shutdown, and we need a persistent name.
|
|
369
|
+
If False, we will generate a temporary queue using the broker's naming scheme.
|
|
370
|
+
"""
|
|
371
|
+
cb = functools.partial(
|
|
372
|
+
self._on_queue_declareok, channel=channel, topic=topic, persist=persist
|
|
373
|
+
)
|
|
374
|
+
channel.queue_declare(
|
|
375
|
+
queue=_get_queue_name(topic)
|
|
376
|
+
if persist
|
|
377
|
+
else '', # if we're transient, let the broker generate a name for us
|
|
378
|
+
durable=persist,
|
|
379
|
+
exclusive=not persist, # transient queues can be exclusive
|
|
380
|
+
callback=cb,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def _on_queue_declareok(
|
|
384
|
+
self, frame: Frame, channel: Channel, topic: str, persist: bool
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Begins listening on the given queue.
|
|
387
|
+
|
|
388
|
+
Used as a listener on queue declaration.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
frame: Response from the queue declare we sent to the AMQP broker. We get the queue name from this.
|
|
392
|
+
channel: The Channel being instantiated.
|
|
393
|
+
topic: The string name for the Channel on the broker.
|
|
394
|
+
persist: Whether or not our queue should persist on either broker or application shutdown.
|
|
395
|
+
"""
|
|
396
|
+
queue_name = frame.method.queue
|
|
397
|
+
cb = functools.partial(
|
|
398
|
+
self._on_queue_bindok,
|
|
399
|
+
channel=channel,
|
|
400
|
+
topic=topic,
|
|
401
|
+
queue_name=queue_name,
|
|
402
|
+
persist=persist,
|
|
403
|
+
)
|
|
404
|
+
channel.queue_bind(
|
|
405
|
+
queue=queue_name,
|
|
406
|
+
exchange=_INTERSECT_MESSAGE_EXCHANGE,
|
|
407
|
+
routing_key=topic,
|
|
408
|
+
callback=cb,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def _on_queue_bindok(
|
|
412
|
+
self,
|
|
413
|
+
_unused_frame: Frame,
|
|
414
|
+
channel: Channel,
|
|
415
|
+
topic: str,
|
|
416
|
+
queue_name: str,
|
|
417
|
+
persist: bool,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Consumes a message from the given channel.
|
|
420
|
+
|
|
421
|
+
Used as a listener on queue binding.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
_unused_frame: AMQP response from binding to the queue. Ignored.
|
|
425
|
+
channel: The Channel being instantiated.
|
|
426
|
+
topic: Name of the topic on the broker.
|
|
427
|
+
queue_name: The name of the queue on the AMQP broker.
|
|
428
|
+
persist: Whether or not our queue should persist on either broker or application shutdown.
|
|
429
|
+
"""
|
|
430
|
+
cb = functools.partial(self._on_consume_ok, topic=topic)
|
|
431
|
+
message_cb = functools.partial(self._consume_message, persist=persist)
|
|
432
|
+
consumer_tag = channel.basic_consume(
|
|
433
|
+
queue=queue_name,
|
|
434
|
+
auto_ack=not persist, # persistent messages should be manually acked and we have no reason to NACK a message for now
|
|
435
|
+
on_message_callback=message_cb,
|
|
436
|
+
callback=cb,
|
|
437
|
+
)
|
|
438
|
+
self._topics_to_consumer_tags[topic] = consumer_tag
|
|
439
|
+
|
|
440
|
+
def _on_consume_ok(self, _unused_frame: Frame, topic: str) -> None:
|
|
441
|
+
"""Sets the consume subscription ready event.
|
|
442
|
+
|
|
443
|
+
Used as a listener on consuming an initial message on a channel.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
_unused_frame: AMQP response from successfully beginning consumption. Ignored.
|
|
447
|
+
topic: Name of the topic on the broker.
|
|
448
|
+
"""
|
|
449
|
+
logger.info('ready to start consuming to %s', topic)
|
|
450
|
+
|
|
451
|
+
def _consume_message(
|
|
452
|
+
self,
|
|
453
|
+
channel: Channel,
|
|
454
|
+
basic_deliver: Basic.Deliver,
|
|
455
|
+
_properties: BasicProperties,
|
|
456
|
+
body: bytes,
|
|
457
|
+
persist: bool,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Handles incoming messages and acknowledges them ONLY after code executes on the domain side.
|
|
460
|
+
|
|
461
|
+
Looks up all handlers for the topic and delegates message handling to them.
|
|
462
|
+
The handlers comprise the Service/Client logic, which includes all domain science logic.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
channel: The AMQP channel the message was received on. Used to manually acknowledge messages.
|
|
466
|
+
basic_deliver: Contains internal AMQP delivery information - i.e. the routing key.
|
|
467
|
+
_properties: Object from the AMQP call. Ignored.
|
|
468
|
+
body: the AMQP message to be handled.
|
|
469
|
+
persist: Whether or not our queue should persist on either broker or application shutdown.
|
|
470
|
+
"""
|
|
471
|
+
tth_key = _amqp_2_hierarchy(basic_deliver.routing_key)
|
|
472
|
+
topic_handler = self._topics_to_handlers().get(tth_key)
|
|
473
|
+
if topic_handler:
|
|
474
|
+
for cb in topic_handler.callbacks:
|
|
475
|
+
cb(body)
|
|
476
|
+
# With persistent messages, we only acknowledge the message AFTER we are done processing
|
|
477
|
+
# (this removes the message from the broker queue)
|
|
478
|
+
# this allows us to retry a message if the broker OR this application goes down
|
|
479
|
+
# We currently never NACK or reject a message because in INTERSECT, applications currently never "share" a queue.
|
|
480
|
+
if persist:
|
|
481
|
+
channel.basic_ack(basic_deliver.delivery_tag)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BrokerClient(Protocol):
|
|
5
|
+
"""Abstract definition of a Broker Client.
|
|
6
|
+
|
|
7
|
+
Will abstractly manage interaction between implementation specific message broker
|
|
8
|
+
calls and higher level pub/sub features.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def connect(self) -> None:
|
|
12
|
+
"""Connect to the defined broker. Credentials should be cached in the constructor."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
def disconnect(self) -> None:
|
|
16
|
+
"""Disconnect from the defined broker."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def is_connected(self) -> bool:
|
|
20
|
+
"""Checks if there is an active connection to the broker.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A boolean. True if there is a connection, False if not.
|
|
24
|
+
"""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def considered_unrecoverable(self) -> bool:
|
|
28
|
+
"""Checks if the broker is considered to be in a state where it would be impossible to reconnect.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A boolean. True if can't recover, False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def publish(self, topic: str, payload: bytes, persist: bool) -> None:
|
|
36
|
+
"""Publishes the given message.
|
|
37
|
+
|
|
38
|
+
Publish payload with the pre-existing connection (via connect()) on topic.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
topic: The topic on which to publish the message as a string.
|
|
42
|
+
payload: The message to publish, as raw bytes.
|
|
43
|
+
persist:
|
|
44
|
+
True = message will persist forever in associated queues until consumers are available (usually used for Userspace messages)
|
|
45
|
+
False = remove message immediately if no consumers available (usually used for Event messages and Lifecycle messages)
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def subscribe(self, topic: str, persist: bool) -> None:
|
|
50
|
+
"""Subscribe to a topic over the pre-existing connection (via connect()).
|
|
51
|
+
|
|
52
|
+
This function should ALSO be called by reconnect handlers, and not just directly.
|
|
53
|
+
When calling the function directly, it's expected that you have already connected.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
topic: Topic to subscribe to.
|
|
57
|
+
persist: Whether or not the queue subscribed to is intended to be long-lived.
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def unsubscribe(self, topic: str) -> None:
|
|
62
|
+
"""Unsubscribe from a topic over the pre-existing connection (via connect()).
|
|
63
|
+
|
|
64
|
+
Note that it should NEVER be expected to call this function as part of routine cleanup.
|
|
65
|
+
It should only be called if you want to continue staying connected to the broker,
|
|
66
|
+
but do not want to continue listening for certain topics. In general, Services should
|
|
67
|
+
NEVER call this, and clients should only call this to help clean up their queues.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
topic: Topic to unsubscribe from.
|
|
71
|
+
"""
|
|
72
|
+
...
|