mccode-plumber 0.6.0__tar.gz → 0.7.0__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 (39) hide show
  1. {mccode_plumber-0.6.0/src/mccode_plumber.egg-info → mccode_plumber-0.7.0}/PKG-INFO +3 -2
  2. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/pyproject.toml +2 -1
  3. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/CommandChannel.py +236 -0
  4. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/CommandHandler.py +58 -0
  5. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/CommandStatus.py +151 -0
  6. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/InThreadStatusTracker.py +228 -0
  7. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/JobHandler.py +102 -0
  8. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/JobStatus.py +147 -0
  9. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/KafkaTopicUrl.py +22 -0
  10. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/StateExtractor.py +58 -0
  11. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/WorkerFinder.py +139 -0
  12. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/WorkerJobPool.py +70 -0
  13. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/WorkerStatus.py +88 -0
  14. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/WriteJob.py +83 -0
  15. mccode_plumber-0.7.0/src/mccode_plumber/file_writer_control/__init__.py +13 -0
  16. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/writer.py +3 -3
  17. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0/src/mccode_plumber.egg-info}/PKG-INFO +3 -2
  18. mccode_plumber-0.7.0/src/mccode_plumber.egg-info/SOURCES.txt +36 -0
  19. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber.egg-info/requires.txt +2 -1
  20. mccode_plumber-0.6.0/src/mccode_plumber.egg-info/SOURCES.txt +0 -23
  21. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/.github/workflows/pip.yml +0 -0
  22. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/.github/workflows/wheels.yml +0 -0
  23. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/.gitignore +0 -0
  24. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/README.md +0 -0
  25. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/setup.cfg +0 -0
  26. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/__init__.py +0 -0
  27. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/conductor.py +0 -0
  28. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/epics.py +0 -0
  29. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/forwarder.py +0 -0
  30. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/kafka.py +0 -0
  31. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/mccode.py +0 -0
  32. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/splitrun.py +0 -0
  33. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber/utils.py +0 -0
  34. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber.egg-info/dependency_links.txt +0 -0
  35. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber.egg-info/entry_points.txt +0 -0
  36. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/src/mccode_plumber.egg-info/top_level.txt +0 -0
  37. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/tests/test_epics.py +0 -0
  38. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/tests/test_splitrun.py +0 -0
  39. {mccode_plumber-0.6.0 → mccode_plumber-0.7.0}/tests/test_writer.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mccode-plumber
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Author-email: Gregory Tucker <gregory.tucker@ess.eu>
5
5
  Classifier: License :: OSI Approved :: BSD License
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: p4p
8
- Requires-Dist: file-writer-control>=1.3.0
8
+ Requires-Dist: kafka-python>=2.0
9
+ Requires-Dist: ess-streaming-data-types>=0.14.0
9
10
  Requires-Dist: restage>=0.4.0
10
11
  Requires-Dist: mccode-to-kafka>=0.2.1
11
12
  Requires-Dist: moreniius>=0.2.3
@@ -6,7 +6,8 @@ build-backend = 'setuptools.build_meta'
6
6
  name = 'mccode-plumber'
7
7
  dependencies = [
8
8
  'p4p',
9
- 'file-writer-control>=1.3.0',
9
+ 'kafka-python>=2.0',
10
+ 'ess-streaming-data-types>=0.14.0',
10
11
  'restage>=0.4.0',
11
12
  'mccode-to-kafka>=0.2.1',
12
13
  'moreniius>=0.2.3',
@@ -0,0 +1,236 @@
1
+ import atexit
2
+ import threading
3
+ from datetime import datetime
4
+ from queue import Queue
5
+ from typing import Dict, List, Optional, Union
6
+
7
+ from kafka import KafkaConsumer
8
+ from kafka.errors import NoBrokersAvailable
9
+
10
+ from file_writer_control.CommandStatus import CommandState, CommandStatus
11
+ from file_writer_control.InThreadStatusTracker import (
12
+ DEAD_ENTITY_TIME_LIMIT,
13
+ InThreadStatusTracker,
14
+ )
15
+ from file_writer_control.JobStatus import JobStatus
16
+ from file_writer_control.KafkaTopicUrl import KafkaTopicUrl
17
+ from file_writer_control.WorkerStatus import WorkerStatus
18
+
19
+
20
+ def thread_function(
21
+ host_port: str,
22
+ topic: str,
23
+ in_queue: Queue,
24
+ out_queue: Queue,
25
+ kafka_config: Dict[str, str] = {},
26
+ ):
27
+ """
28
+ Background thread for consuming Kafka messages.
29
+ :param host_port: The host + port of the Kafka broker that we are using.
30
+ :param topic: The Kafka topic that we are listening to.
31
+ :param in_queue: A queue for sending "exit" messages to the thread.
32
+ .. note:: The queue will exit upon the reception of the string "exit" on this queue.
33
+ :param out_queue: The queue to which status updates are published.
34
+ """
35
+ status_tracker = InThreadStatusTracker(out_queue)
36
+ while True:
37
+ try:
38
+ consumer = KafkaConsumer(
39
+ topic,
40
+ bootstrap_servers=host_port,
41
+ fetch_max_bytes=52428800 * 6,
42
+ max_partition_fetch_bytes=52428800 * 10,
43
+ consumer_timeout_ms=100,
44
+ **kafka_config
45
+ ) # Roughly 300MB
46
+ break
47
+ except NoBrokersAvailable:
48
+ pass # Do not fail if the broker is not immediately available.
49
+ if not in_queue.empty():
50
+ new_msg = in_queue.get()
51
+ if new_msg == "exit":
52
+ return
53
+ while True:
54
+ for message in consumer:
55
+ status_tracker.process_message(message.value)
56
+ status_tracker.check_for_lost_connections()
57
+ if not in_queue.empty():
58
+ new_msg = in_queue.get()
59
+ if new_msg == "exit":
60
+ break
61
+ consumer.close(True)
62
+
63
+
64
+ class CommandChannel(object):
65
+ """
66
+ A class that implements the functionality for receiving and interpreting messages that are published to the
67
+ Kafka command topic of a pool of file-writers.
68
+ .. note:: This class implements a thread that will continuously attempt to connect to a Kafka broker.
69
+ """
70
+
71
+ def __init__(self, command_topic_url: str, kafka_config: Dict[str, str] = {}):
72
+ """
73
+ Constructor.
74
+ :param command_topic_url: The url of the Kafka topic to where the file-writer status/command messages are published.
75
+ """
76
+ kafka_address = KafkaTopicUrl(command_topic_url)
77
+ self.status_queue = Queue()
78
+ self.to_thread_queue = Queue()
79
+ thread_kwargs = {
80
+ "host_port": kafka_address.host_port,
81
+ "topic": kafka_address.topic,
82
+ "in_queue": self.to_thread_queue,
83
+ "out_queue": self.status_queue,
84
+ "kafka_config": kafka_config,
85
+ }
86
+ self.map_of_workers: Dict[str, WorkerStatus] = {}
87
+ self.map_of_jobs: Dict[str, JobStatus] = {}
88
+ self.map_of_commands: Dict[str, CommandStatus] = {}
89
+ self.run_thread = True
90
+ self.thread = threading.Thread(
91
+ target=thread_function, daemon=True, kwargs=thread_kwargs
92
+ )
93
+ self.thread.start()
94
+
95
+ def do_exit():
96
+ self.stop_thread()
97
+
98
+ atexit.register(do_exit)
99
+
100
+ def add_job_id(self, job_id: str):
101
+ """
102
+ Add a job identifier to the list of known jobs before it has been encountered on the command topic.
103
+ :param job_id: The identifier of the new job.
104
+ """
105
+ if job_id not in self.map_of_jobs:
106
+ self.map_of_jobs[job_id] = JobStatus(job_id)
107
+
108
+ def add_command_id(self, job_id: str, command_id: str):
109
+ """
110
+ Add a command identifier to the list of known commands before it has been encountered on the command topic.
111
+ :param job_id: The job identifier of the new command.
112
+ :param command_id: The identifier of the new command.
113
+ """
114
+ if command_id not in self.map_of_commands:
115
+ self.map_of_commands[command_id] = CommandStatus(job_id, command_id)
116
+ self.map_of_commands[command_id].state = CommandState.WAITING_RESPONSE
117
+
118
+ def stop_thread(self):
119
+ """
120
+ Stop the thread that is continuously getting command topic messages in the background. Should only be called if
121
+ we are about to get rid of the current instance of CommandChannel.
122
+ """
123
+ self.to_thread_queue.put("exit")
124
+ try:
125
+ self.thread.join()
126
+ except RuntimeError:
127
+ pass # Do not throw an exception if the thread has not yet been started.
128
+
129
+ def __del__(self):
130
+ self.stop_thread()
131
+
132
+ def update_workers(self, current_time: Optional[datetime] = None):
133
+ """
134
+ Update the list of known workers, jobs and commands. This is a non-blocking call but it might take some time
135
+ to execute if the queue of updates is long. This member function is called by many of the other member functions
136
+ in this class.
137
+ """
138
+ if current_time is None:
139
+ current_time = datetime.now()
140
+
141
+ def handle_worker_status(status_update):
142
+ if status_update.service_id not in self.map_of_workers:
143
+ self.map_of_workers[status_update.service_id] = status_update
144
+ self.map_of_workers[status_update.service_id].update_status(status_update)
145
+
146
+ def handle_job_status(status_update):
147
+ if status_update.job_id not in self.map_of_jobs:
148
+ self.map_of_jobs[status_update.job_id] = status_update
149
+ self.map_of_jobs[status_update.job_id].update_status(status_update)
150
+
151
+ def handle_command_status(status_update):
152
+ if status_update.command_id not in self.map_of_commands:
153
+ self.map_of_commands[status_update.command_id] = status_update
154
+ self.map_of_commands[status_update.command_id].update_status(status_update)
155
+
156
+ status_updater_map = {
157
+ WorkerStatus: handle_worker_status,
158
+ CommandStatus: handle_command_status,
159
+ JobStatus: handle_job_status,
160
+ }
161
+ while not self.status_queue.empty():
162
+ status_update = self.status_queue.get()
163
+ status_updater_map[type(status_update)](status_update)
164
+
165
+ for entity in (
166
+ list(self.map_of_workers.values())
167
+ + list(self.map_of_commands.values())
168
+ + list(self.map_of_jobs.values())
169
+ ):
170
+ entity.check_if_outdated(current_time)
171
+
172
+ def pruner(entities_dictionary):
173
+ for key in list(entities_dictionary.keys()):
174
+ if (
175
+ entities_dictionary[key].last_update + DEAD_ENTITY_TIME_LIMIT
176
+ < current_time
177
+ ):
178
+ del entities_dictionary[key]
179
+
180
+ pruner(self.map_of_commands)
181
+ pruner(self.map_of_workers)
182
+ pruner(self.map_of_jobs)
183
+
184
+ def list_workers(self) -> List[WorkerStatus]:
185
+ """
186
+ :return: A list of the (known) workers with state and status information.
187
+ """
188
+ self.update_workers()
189
+ return list(self.map_of_workers.values())
190
+
191
+ def list_jobs(self) -> List[JobStatus]:
192
+ """
193
+ :return: A list of the (known) jobs with state and status information.
194
+ """
195
+ self.update_workers()
196
+ return list(self.map_of_jobs.values())
197
+
198
+ def list_commands(self) -> List[CommandStatus]:
199
+ """
200
+ :return: A list of the (known) commands and their outcomes.
201
+ """
202
+ self.update_workers()
203
+ return list(self.map_of_commands.values())
204
+
205
+ def get_job(self, job_id: str) -> Union[JobStatus, None]:
206
+ """
207
+ Get the status of a single job.
208
+ :param job_id: The job identifier of the job we are interested in.
209
+ :return: The job status or None if the job is not known.
210
+ """
211
+ self.update_workers()
212
+ if job_id in self.map_of_jobs:
213
+ return self.map_of_jobs[job_id]
214
+ return None
215
+
216
+ def get_worker(self, service_id: str) -> Union[WorkerStatus, None]:
217
+ """
218
+ Get the status of a single worker.
219
+ :param service_id: The service identifier of the worker we are interested in.
220
+ :return: The worker status or None if the service id is not known.
221
+ """
222
+ self.update_workers()
223
+ if service_id in self.map_of_workers:
224
+ return self.map_of_workers[service_id]
225
+ return None
226
+
227
+ def get_command(self, command_id: str) -> Union[CommandStatus, None]:
228
+ """
229
+ Get the status of a single command.
230
+ :param command_id: The command identifier of the command we are interested in.
231
+ :return: The command status/outcome or None if the command is not known.
232
+ """
233
+ self.update_workers()
234
+ if command_id in self.map_of_commands:
235
+ return self.map_of_commands[command_id]
236
+ return None
@@ -0,0 +1,58 @@
1
+ from datetime import timedelta
2
+
3
+ from file_writer_control.CommandChannel import CommandChannel
4
+ from file_writer_control.CommandStatus import CommandState
5
+
6
+
7
+ class CommandHandler:
8
+ """
9
+ A stand in for (more easily) checking the state of a command sent to a file-writer.
10
+ """
11
+
12
+ def __init__(self, command_channel: CommandChannel, command_id: str):
13
+ """
14
+ Constructor.
15
+ :param command_channel: The instance of a CommandChannel that this class uses for getting the command status from.
16
+ :param command_id: The (unique) command identifier.
17
+ """
18
+ self.command_id = command_id
19
+ self.command_channel = command_channel
20
+
21
+ def get_state(self) -> CommandState:
22
+ """
23
+ Get the command state.
24
+ :return: The state of the command if possible. CommandState.UNKNOWN if the the state can not be determined.
25
+ """
26
+ command = self.command_channel.get_command(self.command_id)
27
+ if command is None:
28
+ return CommandState.UNKNOWN
29
+ return command.state
30
+
31
+ def is_done(self) -> bool:
32
+ """
33
+ :return: True if the command completed successfully. False otherwise.
34
+ """
35
+ current_state = self.command_channel.get_command(self.command_id).state
36
+ if current_state == CommandState.ERROR:
37
+ raise RuntimeError(
38
+ f'Command failed with error message "{self.get_message()}".'
39
+ )
40
+ if current_state == CommandState.TIMEOUT_RESPONSE:
41
+ raise RuntimeError("Timed out while trying to send command.")
42
+ return current_state == CommandState.SUCCESS
43
+
44
+ def get_message(self) -> str:
45
+ """
46
+ :return: If there was an error executing the command, this member function will return the error string as
47
+ sent by the file-writer. Will return an empty string otherwise.
48
+ """
49
+ command = self.command_channel.get_command(self.command_id)
50
+ if command is None:
51
+ return ""
52
+ return command.message
53
+
54
+ def set_timeout(self, new_timeout: timedelta):
55
+ self.command_channel.get_command(self.command_id).timeout = new_timeout
56
+
57
+ def get_timeout(self):
58
+ return self.command_channel.get_command(self.command_id).timeout
@@ -0,0 +1,151 @@
1
+ from datetime import datetime, timedelta
2
+ from enum import Enum, auto
3
+ from typing import Optional
4
+
5
+ COMMAND_STATUS_TIMEOUT = timedelta(seconds=60)
6
+
7
+
8
+ class CommandState(Enum):
9
+ """
10
+ The state of a command.
11
+ """
12
+
13
+ UNKNOWN = auto()
14
+ NO_COMMAND = auto()
15
+ WAITING_RESPONSE = auto()
16
+ TIMEOUT_RESPONSE = auto()
17
+ ERROR = auto()
18
+ SUCCESS = auto()
19
+
20
+
21
+ class CommandStatus(object):
22
+ """
23
+ The status of a command.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ job_id: str,
29
+ command_id: str,
30
+ command_timeout: timedelta = COMMAND_STATUS_TIMEOUT,
31
+ ):
32
+ self._command_timeout = command_timeout
33
+ self._job_id = job_id
34
+ self._command_id = command_id
35
+ self._last_update = datetime.now()
36
+ self._state = CommandState.NO_COMMAND
37
+ self._message = ""
38
+ self._response_code = None
39
+
40
+ def __eq__(self, other_status: "CommandStatus"):
41
+ if not isinstance(other_status, CommandStatus):
42
+ raise NotImplementedError
43
+ return (
44
+ other_status.command_id == self.command_id
45
+ and other_status.job_id == self.job_id
46
+ and other_status.state == self.state
47
+ and other_status.response_code == self.response_code
48
+ )
49
+
50
+ def update_status(self, new_status: "CommandStatus"):
51
+ """
52
+ Updates the status/state of this instance of the CommandStatus class using another instance.
53
+ .. note:: The command identifier of both this instance and the other one must be identical.
54
+ :param new_status: The other instance of the CommandStatus class.
55
+ """
56
+ if new_status.command_id != self.command_id:
57
+ raise RuntimeError(
58
+ f"Command id of status update is not correct ({self.command_id} vs {new_status.command_id})"
59
+ )
60
+ self._state = new_status.state
61
+ self._response_code = new_status.response_code
62
+ if new_status.message:
63
+ self._message = new_status.message
64
+ self._last_update = new_status.last_update
65
+
66
+ def check_if_outdated(self, current_time: datetime):
67
+ """
68
+ Given the current time, state and the time of the last update: Have we lost the connection?
69
+ :param current_time: The current time
70
+ """
71
+ if (
72
+ self.state != CommandState.SUCCESS
73
+ and self.state != CommandState.ERROR
74
+ and self.state != CommandState.TIMEOUT_RESPONSE
75
+ and current_time - self.last_update > self._command_timeout
76
+ ):
77
+ self._state = CommandState.TIMEOUT_RESPONSE
78
+ self._last_update = current_time
79
+
80
+ @property
81
+ def response_code(self) -> Optional[int]:
82
+ """
83
+ A code that mirrors the result of a command if a response has been received. Is set to None otherwise.
84
+ """
85
+ return self._response_code
86
+
87
+ @response_code.setter
88
+ def response_code(self, new_code: int):
89
+ """
90
+ Set the current response code.
91
+ """
92
+ self._response_code = new_code
93
+ self._last_update = datetime.now()
94
+
95
+ @property
96
+ def job_id(self) -> str:
97
+ """
98
+ The job identifier under which this command is executed.
99
+ """
100
+ return self._job_id
101
+
102
+ @property
103
+ def command_id(self) -> str:
104
+ """
105
+ The unique command identifier of this command.
106
+ """
107
+ return self._command_id
108
+
109
+ @property
110
+ def message(self) -> str:
111
+ """
112
+ A status/error message as returned by the file-writer that responded to the command.
113
+ :return:
114
+ """
115
+ return self._message
116
+
117
+ @message.setter
118
+ def message(self, new_message: str):
119
+ if new_message:
120
+ self._message = new_message
121
+ self._last_update = datetime.now()
122
+
123
+ @property
124
+ def state(self) -> CommandState:
125
+ """
126
+ The current state of the command.
127
+ """
128
+ return self._state
129
+
130
+ @state.setter
131
+ def state(self, new_state: CommandState):
132
+ self._state = new_state
133
+ self._last_update = datetime.now()
134
+
135
+ @property
136
+ def last_update(self) -> datetime:
137
+ """
138
+ The local time stamp of the last update of the status of the command.
139
+ """
140
+ return self._last_update
141
+
142
+ @property
143
+ def timeout(self) -> timedelta:
144
+ """
145
+ Timeout for waiting for response to command.
146
+ """
147
+ return self._command_timeout
148
+
149
+ @timeout.setter
150
+ def timeout(self, new_timeout: timedelta):
151
+ self._command_timeout = new_timeout