intersect-sdk 0.6.2__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.
Files changed (73) hide show
  1. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/PKG-INFO +1 -1
  2. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/pyproject.toml +1 -1
  3. intersect_sdk-0.6.3/src/intersect_sdk/_internal/control_plane/brokers/amqp_client.py +481 -0
  4. intersect_sdk-0.6.3/src/intersect_sdk/_internal/control_plane/brokers/broker_client.py +72 -0
  5. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/brokers/mqtt_client.py +61 -13
  6. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/control_plane_manager.py +52 -19
  7. intersect_sdk-0.6.3/src/intersect_sdk/_internal/control_plane/topic_handler.py +19 -0
  8. intersect_sdk-0.6.3/src/intersect_sdk/_internal/multi_flag_thread_event.py +77 -0
  9. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/app_lifecycle.py +7 -0
  10. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/client.py +47 -7
  11. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/config/client.py +15 -0
  12. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/service.py +39 -6
  13. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/version.py +1 -1
  14. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/e2e/test_examples.py +4 -0
  15. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/fixtures/example_schema.json +1 -1
  16. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/integration/test_return_type_mismatch.py +3 -2
  17. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/integration/test_service.py +17 -10
  18. intersect_sdk-0.6.2/src/intersect_sdk/_internal/control_plane/brokers/amqp_client.py +0 -280
  19. intersect_sdk-0.6.2/src/intersect_sdk/_internal/control_plane/brokers/broker_client.py +0 -58
  20. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/LICENSE +0 -0
  21. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/README.md +0 -0
  22. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/__init__.py +0 -0
  23. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/__init__.py +0 -0
  24. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/constants.py +0 -0
  25. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/__init__.py +0 -0
  26. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/brokers/__init__.py +0 -0
  27. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/control_plane/discovery_service.py +0 -0
  28. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/data_plane/__init__.py +0 -0
  29. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/data_plane/data_plane_manager.py +0 -0
  30. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/data_plane/minio_utils.py +0 -0
  31. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/event_metadata.py +0 -0
  32. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/exceptions.py +0 -0
  33. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/function_metadata.py +0 -0
  34. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/interfaces.py +0 -0
  35. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/logger.py +0 -0
  36. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/__init__.py +0 -0
  37. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/event.py +0 -0
  38. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/lifecycle.py +0 -0
  39. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/messages/userspace.py +0 -0
  40. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/pydantic_schema_generator.py +0 -0
  41. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/schema.py +0 -0
  42. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/stoppable_thread.py +0 -0
  43. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/utils.py +0 -0
  44. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/version.py +0 -0
  45. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/_internal/version_resolver.py +0 -0
  46. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/capability/__init__.py +0 -0
  47. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/capability/base.py +0 -0
  48. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/client_callback_definitions.py +0 -0
  49. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/config/__init__.py +0 -0
  50. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/config/service.py +0 -0
  51. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/config/shared.py +0 -0
  52. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/constants.py +0 -0
  53. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/core_definitions.py +0 -0
  54. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/py.typed +0 -0
  55. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/schema.py +0 -0
  56. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/src/intersect_sdk/service_definitions.py +0 -0
  57. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/__init__.py +0 -0
  58. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/conftest.py +0 -0
  59. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/e2e/__init__.py +0 -0
  60. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/fixtures/__init__.py +0 -0
  61. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/fixtures/example_schema.py +0 -0
  62. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/fixtures/return_type_mismatch.py +0 -0
  63. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/integration/__init__.py +0 -0
  64. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/__init__.py +0 -0
  65. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_annotations.py +0 -0
  66. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_base_capability_implementation.py +0 -0
  67. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_config.py +0 -0
  68. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_invalid_schema_runtime.py +0 -0
  69. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_lifecycle_message.py +0 -0
  70. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_schema_invalids.py +0 -0
  71. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_schema_valid.py +0 -0
  72. {intersect_sdk-0.6.2 → intersect_sdk-0.6.3}/tests/unit/test_userspace_message.py +0 -0
  73. {intersect_sdk-0.6.2 → 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.2
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>
@@ -34,7 +34,7 @@ dependencies = [
34
34
  "minio>=7.2.3",
35
35
  "jsonschema[format-nongpl]>=4.21.1",
36
36
  ]
37
- version = "0.6.2"
37
+ version = "0.6.3"
38
38
 
39
39
  [project.license]
40
40
  text = "BSD-3-Clause"
@@ -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
+ ...