idds-prompt 2.6.12__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.
- idds_prompt-2.6.12/LICENSE.rst +14 -0
- idds_prompt-2.6.12/MANIFEST.in +8 -0
- idds_prompt-2.6.12/PKG-INFO +26 -0
- idds_prompt-2.6.12/README.md +4 -0
- idds_prompt-2.6.12/bin/run_prompt +109 -0
- idds_prompt-2.6.12/lib/idds/prompt/__init__.py +9 -0
- idds_prompt-2.6.12/lib/idds/prompt/brokers/__init__.py +9 -0
- idds_prompt-2.6.12/lib/idds/prompt/brokers/activemq.py +482 -0
- idds_prompt-2.6.12/lib/idds/prompt/handlers/__init__.py +9 -0
- idds_prompt-2.6.12/lib/idds/prompt/handlers/panda.py +252 -0
- idds_prompt-2.6.12/lib/idds/prompt/handlers/workflowtaskhandler.py +447 -0
- idds_prompt-2.6.12/lib/idds/prompt/payload_process.py +33 -0
- idds_prompt-2.6.12/lib/idds/prompt/transformer.py +317 -0
- idds_prompt-2.6.12/lib/idds/prompt/version.py +12 -0
- idds_prompt-2.6.12/lib/idds_prompt.egg-info/PKG-INFO +26 -0
- idds_prompt-2.6.12/lib/idds_prompt.egg-info/SOURCES.txt +24 -0
- idds_prompt-2.6.12/lib/idds_prompt.egg-info/dependency_links.txt +1 -0
- idds_prompt-2.6.12/lib/idds_prompt.egg-info/requires.txt +2 -0
- idds_prompt-2.6.12/lib/idds_prompt.egg-info/top_level.txt +1 -0
- idds_prompt-2.6.12/pyproject.toml +38 -0
- idds_prompt-2.6.12/setup.cfg +8 -0
- idds_prompt-2.6.12/setup.py +27 -0
- idds_prompt-2.6.12/tools/prompt/env/environment.yml +7 -0
- idds_prompt-2.6.12/tools/prompt/make/make.sh +81 -0
- idds_prompt-2.6.12/tools/prompt/make/zipheader +98 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Copyright 2019
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
14
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idds-prompt
|
|
3
|
+
Version: 2.6.12
|
|
4
|
+
Summary: intelligent Distributed Dispatch and Scheduling Service (iDDS) - Prompt Package
|
|
5
|
+
Author-email: PanDA Team <atlas-adc-panda@cern.ch>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Documentation, https://github.com/HSF/iDDS/wiki
|
|
8
|
+
Project-URL: Source, https://github.com/HSF/iDDS
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Natural Language :: English
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE.rst
|
|
19
|
+
Requires-Dist: stomp.py
|
|
20
|
+
Requires-Dist: idds-common>=2.2.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
idds-workflow
|
|
24
|
+
====
|
|
25
|
+
|
|
26
|
+
idds-prompt subpackage implements prompt processing based on ActiveMQ.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0OA
|
|
7
|
+
#
|
|
8
|
+
# Authors:
|
|
9
|
+
# - Wen Guan, <wen.guan@cern.ch>, 2024 - 2025
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
Run workflow.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import print_function
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
|
|
20
|
+
# import argcomplete
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import traceback
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
from idds.common.utils import setup_logging
|
|
29
|
+
from idds.common.version import release_version
|
|
30
|
+
from idds.prompt.transformer import Transformer
|
|
31
|
+
|
|
32
|
+
setup_logging(__name__, stream=sys.stdout)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_prompt(args):
|
|
36
|
+
logging.info(f"run_prompt called with args: {str(args)}")
|
|
37
|
+
|
|
38
|
+
transformer = Transformer(run_id=args.run_id, workdir=args.workdir, namespace=args.namespace, idle_timeout=args.idle_timeout)
|
|
39
|
+
logging.info("Initializing transformer brokers")
|
|
40
|
+
ret = transformer.run()
|
|
41
|
+
logging.info("Transformer run returned: %s" % str(ret))
|
|
42
|
+
return ret
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_parser():
|
|
46
|
+
"""
|
|
47
|
+
Return the argparse parser.
|
|
48
|
+
"""
|
|
49
|
+
oparser = argparse.ArgumentParser(prog=os.path.basename(sys.argv[0]), add_help=True)
|
|
50
|
+
|
|
51
|
+
# common items
|
|
52
|
+
oparser.add_argument(
|
|
53
|
+
"--version", action="version", version="%(prog)s " + release_version
|
|
54
|
+
)
|
|
55
|
+
oparser.add_argument(
|
|
56
|
+
"--verbose",
|
|
57
|
+
"-v",
|
|
58
|
+
default=False,
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Print more verbose output.",
|
|
61
|
+
)
|
|
62
|
+
oparser.add_argument(
|
|
63
|
+
"--run_id", type=int, required=True, help="Run ID for the prompt run."
|
|
64
|
+
)
|
|
65
|
+
oparser.add_argument(
|
|
66
|
+
"--workdir", type=str, required=False, default=None, help="Working directory for the prompt run."
|
|
67
|
+
)
|
|
68
|
+
oparser.add_argument(
|
|
69
|
+
"--namespace", type=str, required=False, default=None, help="Namespace for the prompt run."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
oparser.add_argument(
|
|
73
|
+
"--idle_timeout",
|
|
74
|
+
type=int,
|
|
75
|
+
default=600,
|
|
76
|
+
help="Idle timeout in seconds to stop the transformer if no messages are received.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return oparser
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
arguments = sys.argv[1:]
|
|
84
|
+
|
|
85
|
+
oparser = get_parser()
|
|
86
|
+
# argcomplete.autocomplete(oparser)
|
|
87
|
+
|
|
88
|
+
args, unknown = oparser.parse_known_args(arguments)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
if args.verbose:
|
|
92
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
93
|
+
start_time = time.time()
|
|
94
|
+
logging.info("Starting prompt with arguments: {0}".format(arguments))
|
|
95
|
+
logging.info("Starting at: %-0.4f" % start_time)
|
|
96
|
+
|
|
97
|
+
exit_code = run_prompt(args)
|
|
98
|
+
|
|
99
|
+
end_time = time.time()
|
|
100
|
+
logging.info("Finished at: %-0.4f" % end_time)
|
|
101
|
+
logging.info("Completed in %-0.4f sec." % (end_time - start_time))
|
|
102
|
+
sys.exit(exit_code)
|
|
103
|
+
except Exception as error:
|
|
104
|
+
logging.error("Strange error: {0}".format(error))
|
|
105
|
+
logging.error(traceback.format_exc())
|
|
106
|
+
end_time = time.time()
|
|
107
|
+
logging.info("Failed at: %-0.4f" % end_time)
|
|
108
|
+
logging.info("Failed in %-0.4f sec." % (end_time - start_time))
|
|
109
|
+
sys.exit(-1)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0OA
|
|
7
|
+
#
|
|
8
|
+
# Authors:
|
|
9
|
+
# - Wen Guan, <wen.guan@cern.ch>, 2023
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0OA
|
|
7
|
+
#
|
|
8
|
+
# Authors:
|
|
9
|
+
# - Wen Guan, <wen.guan@cern.ch>, 2019
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Authors:
|
|
9
|
+
# - Wen Guan, <wen.guan@cern.ch>, 2025
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import socket
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import traceback
|
|
19
|
+
import stomp
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
from idds.common.plugin.plugin_base import PluginBase
|
|
23
|
+
from idds.common.utils import setup_logging, json_dumps, json_loads
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
setup_logging(__name__)
|
|
27
|
+
# Allow enabling stomp debug via environment variable for diagnostics
|
|
28
|
+
if os.environ.get("STOMP_DEBUG") in ("1", "true", "True"):
|
|
29
|
+
logging.getLogger("stomp").setLevel(logging.DEBUG)
|
|
30
|
+
else:
|
|
31
|
+
logging.getLogger("stomp").setLevel(logging.CRITICAL)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MessagingListener(stomp.ConnectionListener):
|
|
35
|
+
"""
|
|
36
|
+
Messaging Listener
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self, broker, handler, handler_kwargs, conn, logger=None, subscriber=None, namespace=None
|
|
41
|
+
):
|
|
42
|
+
super(MessagingListener, self).__init__()
|
|
43
|
+
self.__broker = broker
|
|
44
|
+
self.handler = handler
|
|
45
|
+
self.handler_kwargs = handler_kwargs
|
|
46
|
+
self.conn = conn
|
|
47
|
+
self.subscriber = subscriber
|
|
48
|
+
self.namespace = namespace
|
|
49
|
+
|
|
50
|
+
if logger:
|
|
51
|
+
self.logger = logger
|
|
52
|
+
else:
|
|
53
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
54
|
+
|
|
55
|
+
def on_error(self, frame):
|
|
56
|
+
self.logger.error("[broker] [%s]: headers:%s, body: %s", self.__broker, frame.headers, frame.body)
|
|
57
|
+
|
|
58
|
+
def on_disconnected(self):
|
|
59
|
+
self.logger.warning("STOMP connection disconnected (server or transport).")
|
|
60
|
+
if self.subscriber is not None:
|
|
61
|
+
try:
|
|
62
|
+
self.subscriber.fail()
|
|
63
|
+
self.subscriber.monitor()
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def on_connected(self, headers, body=None):
|
|
68
|
+
try:
|
|
69
|
+
hdrs = headers.headers if hasattr(headers, "headers") else headers
|
|
70
|
+
self.logger.info("STOMP CONNECTED from broker %s: %s", self.__broker, hdrs)
|
|
71
|
+
except Exception:
|
|
72
|
+
self.logger.info("STOMP CONNECTED from broker %s (failed to extract headers)", self.__broker)
|
|
73
|
+
|
|
74
|
+
def on_heartbeat_timeout(self):
|
|
75
|
+
self.logger.warning("STOMP heartbeat timeout.")
|
|
76
|
+
if self.subscriber is not None:
|
|
77
|
+
self.subscriber.fail()
|
|
78
|
+
|
|
79
|
+
def on_message(self, frame):
|
|
80
|
+
self.logger.debug(
|
|
81
|
+
f"[broker] [{self.__broker}]: headers: {frame.headers}, body: {frame.body}"
|
|
82
|
+
)
|
|
83
|
+
headers = frame.headers
|
|
84
|
+
try:
|
|
85
|
+
if self.subscriber is not None:
|
|
86
|
+
try:
|
|
87
|
+
self.subscriber.is_processing_message = True
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
self.handler(headers, json_loads(frame.body), self.handler_kwargs)
|
|
92
|
+
if self.subscriber is None or self.subscriber.ack != "auto":
|
|
93
|
+
self.conn.ack(frame.headers["message-id"])
|
|
94
|
+
except Exception as ex:
|
|
95
|
+
self.logger.error(f"Failed to handle message: {ex}", exc_info=True)
|
|
96
|
+
if self.subscriber is not None:
|
|
97
|
+
self.subscriber.fail()
|
|
98
|
+
|
|
99
|
+
if self.subscriber is None or self.subscriber.ack != "auto":
|
|
100
|
+
try:
|
|
101
|
+
self.conn.nack(frame.headers["message-id"])
|
|
102
|
+
except Exception:
|
|
103
|
+
self.logger.exception("nack failed")
|
|
104
|
+
|
|
105
|
+
if self.subscriber is not None:
|
|
106
|
+
try:
|
|
107
|
+
self.subscriber.last_message_at = time.time()
|
|
108
|
+
self.subscriber.is_processing_message = False
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BaseActiveMQ(PluginBase):
|
|
114
|
+
def __init__(
|
|
115
|
+
self, name="BaseActiveMQ", namespace=None, logger=None, broker=None, lifetime=3600, **kwargs
|
|
116
|
+
):
|
|
117
|
+
super(BaseActiveMQ, self).__init__(
|
|
118
|
+
name=name, logger=logger, broker=broker, **kwargs
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self.logger = logger
|
|
122
|
+
self.setup_logger(self.logger)
|
|
123
|
+
self.namespace = namespace
|
|
124
|
+
|
|
125
|
+
self.has_connection_failures = False
|
|
126
|
+
self.start_at = None
|
|
127
|
+
self.lifetime = lifetime
|
|
128
|
+
|
|
129
|
+
self.name = name
|
|
130
|
+
self.broker = broker
|
|
131
|
+
|
|
132
|
+
internal_id = str(uuid.uuid4())[:8]
|
|
133
|
+
self.hostname = socket.getfqdn().split(".")[0]
|
|
134
|
+
self.internal_id = f"{self.namespace}.{self.name}.{self.hostname}.{internal_id}"
|
|
135
|
+
|
|
136
|
+
if not hasattr(self, "timetolive"):
|
|
137
|
+
self.timetolive = 12 * 3600 * 1000 # milliseconds
|
|
138
|
+
else:
|
|
139
|
+
self.timetolive = int(self.timetolive)
|
|
140
|
+
|
|
141
|
+
self.conns = []
|
|
142
|
+
self.graceful_stop = threading.Event()
|
|
143
|
+
|
|
144
|
+
def setup_logger(self, logger):
|
|
145
|
+
if logger:
|
|
146
|
+
self.logger = logger
|
|
147
|
+
else:
|
|
148
|
+
logger_name = self.name if self.name else f"{self.__class__.__name__}"
|
|
149
|
+
self.logger = logging.getLogger(logger_name)
|
|
150
|
+
|
|
151
|
+
def get_logger(self):
|
|
152
|
+
return self.logger
|
|
153
|
+
|
|
154
|
+
def stop(self):
|
|
155
|
+
self.graceful_stop.set()
|
|
156
|
+
self.disconnect(self.conns)
|
|
157
|
+
|
|
158
|
+
def connect_to_messaging_brokers(self, sender=True, resolve_addresses=False):
|
|
159
|
+
if self.conns:
|
|
160
|
+
self.disconnect(self.conns)
|
|
161
|
+
|
|
162
|
+
# Support both "broker" (singular, idds.cfg format) and "brokers" keys
|
|
163
|
+
brokers = self.broker.get("brokers") or self.broker.get("broker")
|
|
164
|
+
if brokers is None:
|
|
165
|
+
raise KeyError("broker config must contain 'broker' or 'brokers' key")
|
|
166
|
+
if not isinstance(brokers, (list, tuple)):
|
|
167
|
+
brokers = [b.strip() for b in brokers.split(",")]
|
|
168
|
+
|
|
169
|
+
broker_addresses = []
|
|
170
|
+
for b in brokers:
|
|
171
|
+
try:
|
|
172
|
+
host, port_str = b.strip().rsplit(":", 1)
|
|
173
|
+
port = int(port_str)
|
|
174
|
+
|
|
175
|
+
if not resolve_addresses:
|
|
176
|
+
broker_addresses.append((host, port))
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
addrinfos = socket.getaddrinfo(
|
|
180
|
+
host, 0, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP
|
|
181
|
+
)
|
|
182
|
+
seen = set()
|
|
183
|
+
for addrinfo in addrinfos:
|
|
184
|
+
pair = (addrinfo[4][0], port)
|
|
185
|
+
if pair not in seen:
|
|
186
|
+
seen.add(pair)
|
|
187
|
+
broker_addresses.append(pair)
|
|
188
|
+
except socket.gaierror as error:
|
|
189
|
+
self.logger.error("Cannot resolve hostname %s: %s" % (b, str(error)))
|
|
190
|
+
|
|
191
|
+
self.logger.info(
|
|
192
|
+
"Broker addresses for channel %s with brokers %s: %s"
|
|
193
|
+
% (self.name, brokers, broker_addresses)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
use_ssl = self.broker.get("use_ssl", False)
|
|
197
|
+
|
|
198
|
+
conns = []
|
|
199
|
+
for broker_host, broker_port in broker_addresses:
|
|
200
|
+
conn = stomp.Connection12(
|
|
201
|
+
host_and_ports=[(broker_host, broker_port)],
|
|
202
|
+
keepalive=True,
|
|
203
|
+
try_loopback_connect=False,
|
|
204
|
+
auto_content_length=False,
|
|
205
|
+
heartbeats=(50000, 50000),
|
|
206
|
+
)
|
|
207
|
+
if use_ssl:
|
|
208
|
+
conn.set_ssl(
|
|
209
|
+
for_hosts=[(broker_host, broker_port)],
|
|
210
|
+
key_file=self.broker.get("ssl_key_file"),
|
|
211
|
+
cert_file=self.broker.get("ssl_cert_file"),
|
|
212
|
+
ca_certs=self.broker.get("ssl_ca_certs"),
|
|
213
|
+
)
|
|
214
|
+
conns.append(conn)
|
|
215
|
+
self.conns = conns
|
|
216
|
+
|
|
217
|
+
self.has_connection_failures = False
|
|
218
|
+
|
|
219
|
+
return self.conns
|
|
220
|
+
|
|
221
|
+
def disconnect(self, conns):
|
|
222
|
+
for conn in conns:
|
|
223
|
+
try:
|
|
224
|
+
if conn.is_connected():
|
|
225
|
+
conn.disconnect()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def fail(self):
|
|
230
|
+
self.has_connection_failures = True
|
|
231
|
+
|
|
232
|
+
def get_connection(self):
|
|
233
|
+
try:
|
|
234
|
+
if self.conns:
|
|
235
|
+
conn = random.sample(self.conns, 1)[0]
|
|
236
|
+
if not conn.is_connected():
|
|
237
|
+
conn.connect(
|
|
238
|
+
self.broker["username"],
|
|
239
|
+
self.broker["password"],
|
|
240
|
+
wait=True,
|
|
241
|
+
heartbeats=(30000, 30000),
|
|
242
|
+
headers={"client-id": self.internal_id, "heart-beat": "30000,30000"},
|
|
243
|
+
)
|
|
244
|
+
return conn
|
|
245
|
+
except Exception as error:
|
|
246
|
+
self.logger.error(
|
|
247
|
+
"Failed to connect to message broker (will re-resolve brokers): %s" % str(error)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
self.disconnect(self.conns)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
self.conns = self.connect_to_messaging_brokers(sender=True)
|
|
254
|
+
conn = random.sample(self.conns, 1)[0]
|
|
255
|
+
if not conn.is_connected():
|
|
256
|
+
conn.connect(
|
|
257
|
+
self.broker["username"],
|
|
258
|
+
self.broker["password"],
|
|
259
|
+
wait=True,
|
|
260
|
+
heartbeats=(30000, 30000),
|
|
261
|
+
headers={"client-id": self.internal_id, "heart-beat": "30000,30000"},
|
|
262
|
+
)
|
|
263
|
+
return conn
|
|
264
|
+
except Exception as error:
|
|
265
|
+
self.logger.error(
|
|
266
|
+
"Failed to connect to message broker (will re-resolve brokers): %s" % str(error)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
self.fail()
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
def monitor(self):
|
|
273
|
+
try:
|
|
274
|
+
if (
|
|
275
|
+
not self.conns
|
|
276
|
+
or not self.start_at
|
|
277
|
+
or self.has_connection_failures
|
|
278
|
+
or self.start_at + self.lifetime < time.time()
|
|
279
|
+
):
|
|
280
|
+
self.conns = self.connect_to_messaging_brokers(sender=True)
|
|
281
|
+
self.start_at = time.time()
|
|
282
|
+
except Exception as error:
|
|
283
|
+
self.logger.error(
|
|
284
|
+
"Setup connections throws an exception: %s, %s"
|
|
285
|
+
% (error, traceback.format_exc())
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class Publisher(BaseActiveMQ):
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
name="Publisher",
|
|
293
|
+
instance=None,
|
|
294
|
+
logger=None,
|
|
295
|
+
broker=None,
|
|
296
|
+
lifetime=3600,
|
|
297
|
+
broadcast=False,
|
|
298
|
+
**kwargs,
|
|
299
|
+
):
|
|
300
|
+
super(Publisher, self).__init__(
|
|
301
|
+
name=name, instance=instance, logger=logger, broker=broker, lifetime=lifetime, **kwargs
|
|
302
|
+
)
|
|
303
|
+
self.broadcast = broadcast
|
|
304
|
+
|
|
305
|
+
def publish(self, msg, headers=None):
|
|
306
|
+
"""
|
|
307
|
+
Publish a message to the broker.
|
|
308
|
+
|
|
309
|
+
:param msg: Message dictionary to publish (should contain 'msg_type' and 'run_id')
|
|
310
|
+
:param headers: Optional headers dictionary (overrides defaults)
|
|
311
|
+
"""
|
|
312
|
+
namespace = getattr(self, "namespace", None)
|
|
313
|
+
msg_type = msg.get("msg_type", "unknown")
|
|
314
|
+
run_id = msg.get("run_id", "unknown")
|
|
315
|
+
|
|
316
|
+
self.logger.debug(f"Publishing message: msg_type={msg_type}, run_id={run_id}")
|
|
317
|
+
|
|
318
|
+
conn = self.get_connection()
|
|
319
|
+
if not conn:
|
|
320
|
+
self.logger.error(
|
|
321
|
+
f"No connection available to send message: msg_type={msg_type}, run_id={run_id}"
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
send_headers = {
|
|
326
|
+
"persistent": "true",
|
|
327
|
+
"ttl": self.timetolive,
|
|
328
|
+
"vo": "eic",
|
|
329
|
+
"msg_type": str(msg_type).lower(),
|
|
330
|
+
"run_id": run_id,
|
|
331
|
+
"client-id": self.internal_id,
|
|
332
|
+
}
|
|
333
|
+
if namespace is not None:
|
|
334
|
+
send_headers["namespace"] = namespace
|
|
335
|
+
|
|
336
|
+
# Caller-provided headers take precedence
|
|
337
|
+
if headers:
|
|
338
|
+
send_headers.update(headers)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
conn.send(
|
|
342
|
+
body=json_dumps(msg),
|
|
343
|
+
destination=self.broker["destination"],
|
|
344
|
+
id=self.internal_id,
|
|
345
|
+
ack="auto",
|
|
346
|
+
headers=send_headers,
|
|
347
|
+
)
|
|
348
|
+
self.logger.debug(
|
|
349
|
+
f"Message published successfully: msg_type={msg_type}, run_id={run_id}, destination={self.broker['destination']}"
|
|
350
|
+
)
|
|
351
|
+
except Exception as ex:
|
|
352
|
+
self.logger.error(
|
|
353
|
+
f"Failed to publish message: msg_type={msg_type}, run_id={run_id}, destination={self.broker['destination']}, error={ex}",
|
|
354
|
+
exc_info=True,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class Subscriber(BaseActiveMQ):
|
|
359
|
+
def __init__(
|
|
360
|
+
self,
|
|
361
|
+
name="Subscriber",
|
|
362
|
+
namespace=None,
|
|
363
|
+
logger=None,
|
|
364
|
+
broker=None,
|
|
365
|
+
lifetime=1800,
|
|
366
|
+
handler=None,
|
|
367
|
+
handler_kwargs=None,
|
|
368
|
+
selector=None,
|
|
369
|
+
ack="auto",
|
|
370
|
+
**kwargs,
|
|
371
|
+
):
|
|
372
|
+
super(Subscriber, self).__init__(
|
|
373
|
+
name=name, namespace=namespace, logger=logger, broker=broker, lifetime=lifetime, **kwargs
|
|
374
|
+
)
|
|
375
|
+
self.listener = None
|
|
376
|
+
self.handler = handler
|
|
377
|
+
self.handler_kwargs = handler_kwargs if handler_kwargs else {}
|
|
378
|
+
self.selector = selector
|
|
379
|
+
self.ack = ack
|
|
380
|
+
self.last_message_at = time.time()
|
|
381
|
+
self.idle_seconds = int(kwargs.get("idle_seconds", 5))
|
|
382
|
+
self.is_processing_message = False
|
|
383
|
+
|
|
384
|
+
def get_listener(self, broker, conn):
|
|
385
|
+
if self.listener is None:
|
|
386
|
+
self.listener = MessagingListener(
|
|
387
|
+
broker,
|
|
388
|
+
namespace=self.namespace,
|
|
389
|
+
handler=self.handler,
|
|
390
|
+
handler_kwargs=self.handler_kwargs,
|
|
391
|
+
conn=conn,
|
|
392
|
+
logger=self.logger,
|
|
393
|
+
subscriber=self,
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
# Update conn reference so ack/nack uses the live connection
|
|
397
|
+
self.listener.conn = conn
|
|
398
|
+
return self.listener
|
|
399
|
+
|
|
400
|
+
def subscribe_conn(self, conn):
|
|
401
|
+
try:
|
|
402
|
+
broker_info = conn.transport._Transport__host_and_ports[0][0]
|
|
403
|
+
except Exception:
|
|
404
|
+
broker_info = str(conn)
|
|
405
|
+
self.logger.info(f"connecting to: {broker_info}")
|
|
406
|
+
conn.set_listener("message-receiver", self.get_listener(broker_info, conn=conn))
|
|
407
|
+
conn.connect(
|
|
408
|
+
self.broker["username"],
|
|
409
|
+
self.broker["password"],
|
|
410
|
+
wait=True,
|
|
411
|
+
heartbeats=(30000, 30000),
|
|
412
|
+
headers={"client-id": self.internal_id, "heart-beat": "30000,30000"},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if self.namespace is not None:
|
|
416
|
+
if self.selector:
|
|
417
|
+
selector = f"namespace='{self.namespace}' AND ({self.selector})"
|
|
418
|
+
else:
|
|
419
|
+
selector = f"namespace='{self.namespace}'"
|
|
420
|
+
else:
|
|
421
|
+
selector = self.selector or None
|
|
422
|
+
|
|
423
|
+
self.last_message_at = time.time()
|
|
424
|
+
|
|
425
|
+
sub_headers = {}
|
|
426
|
+
if selector:
|
|
427
|
+
sub_headers["selector"] = selector
|
|
428
|
+
|
|
429
|
+
conn.subscribe(
|
|
430
|
+
destination=self.broker["destination"],
|
|
431
|
+
id=f"{self.internal_id}",
|
|
432
|
+
ack=self.ack,
|
|
433
|
+
headers=sub_headers,
|
|
434
|
+
)
|
|
435
|
+
self.logger.info(
|
|
436
|
+
f"Subscribed to {self.broker['destination']} with selector: {selector} on broker {broker_info}, ack mode: {self.ack}, headers: {sub_headers}"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def subscribe(self):
|
|
440
|
+
if not self.conns:
|
|
441
|
+
self.conns = self.connect_to_messaging_brokers()
|
|
442
|
+
|
|
443
|
+
for conn in self.conns:
|
|
444
|
+
if not conn.is_connected():
|
|
445
|
+
self.subscribe_conn(conn)
|
|
446
|
+
|
|
447
|
+
def is_idle(self, idle_seconds=None):
|
|
448
|
+
"""Return True if no message has been received for at least idle_seconds."""
|
|
449
|
+
if self.is_processing_message:
|
|
450
|
+
return False
|
|
451
|
+
if idle_seconds is None:
|
|
452
|
+
idle_seconds = self.idle_seconds
|
|
453
|
+
return (time.time() - getattr(self, "last_message_at", 0)) > float(idle_seconds)
|
|
454
|
+
|
|
455
|
+
def wait_for_idle(self, timeout=None, poll_interval=0.5):
|
|
456
|
+
"""Block until the subscriber is idle or timeout (seconds) elapses.
|
|
457
|
+
|
|
458
|
+
Returns True if idle was reached, False if timeout occurred.
|
|
459
|
+
"""
|
|
460
|
+
start = time.time()
|
|
461
|
+
while True:
|
|
462
|
+
if self.is_idle():
|
|
463
|
+
return True
|
|
464
|
+
if timeout is not None and (time.time() - start) >= timeout:
|
|
465
|
+
return False
|
|
466
|
+
time.sleep(poll_interval)
|
|
467
|
+
|
|
468
|
+
def monitor(self):
|
|
469
|
+
try:
|
|
470
|
+
if (
|
|
471
|
+
not self.conns
|
|
472
|
+
or not self.start_at
|
|
473
|
+
or self.has_connection_failures
|
|
474
|
+
or self.start_at + self.lifetime < time.time()
|
|
475
|
+
):
|
|
476
|
+
self.subscribe()
|
|
477
|
+
self.start_at = time.time()
|
|
478
|
+
except Exception as error:
|
|
479
|
+
self.logger.error(
|
|
480
|
+
"Setup connections throws an exception: %s, %s"
|
|
481
|
+
% (error, traceback.format_exc())
|
|
482
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0OA
|
|
7
|
+
#
|
|
8
|
+
# Authors:
|
|
9
|
+
# - Wen Guan, <wen.guan@cern.ch>, 2025
|