opengris-scaler 1.12.37__cp38-cp38-musllinux_1_2_x86_64.whl

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 (196) hide show
  1. opengris_scaler-1.12.37.dist-info/METADATA +730 -0
  2. opengris_scaler-1.12.37.dist-info/RECORD +196 -0
  3. opengris_scaler-1.12.37.dist-info/WHEEL +5 -0
  4. opengris_scaler-1.12.37.dist-info/entry_points.txt +10 -0
  5. opengris_scaler-1.12.37.dist-info/licenses/LICENSE +201 -0
  6. opengris_scaler-1.12.37.dist-info/licenses/LICENSE.spdx +7 -0
  7. opengris_scaler-1.12.37.dist-info/licenses/NOTICE +8 -0
  8. opengris_scaler.libs/libcapnp-1-e88d5415.0.1.so +0 -0
  9. opengris_scaler.libs/libgcc_s-2298274a.so.1 +0 -0
  10. opengris_scaler.libs/libkj-1-9bebd8ac.0.1.so +0 -0
  11. opengris_scaler.libs/libstdc++-08d5c7eb.so.6.0.33 +0 -0
  12. scaler/__init__.py +14 -0
  13. scaler/about.py +5 -0
  14. scaler/client/__init__.py +0 -0
  15. scaler/client/agent/__init__.py +0 -0
  16. scaler/client/agent/client_agent.py +218 -0
  17. scaler/client/agent/disconnect_manager.py +27 -0
  18. scaler/client/agent/future_manager.py +112 -0
  19. scaler/client/agent/heartbeat_manager.py +74 -0
  20. scaler/client/agent/mixins.py +89 -0
  21. scaler/client/agent/object_manager.py +98 -0
  22. scaler/client/agent/task_manager.py +64 -0
  23. scaler/client/client.py +672 -0
  24. scaler/client/future.py +252 -0
  25. scaler/client/object_buffer.py +129 -0
  26. scaler/client/object_reference.py +25 -0
  27. scaler/client/serializer/__init__.py +0 -0
  28. scaler/client/serializer/default.py +16 -0
  29. scaler/client/serializer/mixins.py +38 -0
  30. scaler/cluster/__init__.py +0 -0
  31. scaler/cluster/cluster.py +95 -0
  32. scaler/cluster/combo.py +157 -0
  33. scaler/cluster/object_storage_server.py +45 -0
  34. scaler/cluster/scheduler.py +86 -0
  35. scaler/config/__init__.py +0 -0
  36. scaler/config/common/__init__.py +0 -0
  37. scaler/config/common/logging.py +41 -0
  38. scaler/config/common/web.py +18 -0
  39. scaler/config/common/worker.py +65 -0
  40. scaler/config/common/worker_adapter.py +28 -0
  41. scaler/config/config_class.py +317 -0
  42. scaler/config/defaults.py +94 -0
  43. scaler/config/mixins.py +20 -0
  44. scaler/config/section/__init__.py +0 -0
  45. scaler/config/section/cluster.py +66 -0
  46. scaler/config/section/ecs_worker_adapter.py +78 -0
  47. scaler/config/section/native_worker_adapter.py +30 -0
  48. scaler/config/section/object_storage_server.py +13 -0
  49. scaler/config/section/scheduler.py +126 -0
  50. scaler/config/section/symphony_worker_adapter.py +35 -0
  51. scaler/config/section/top.py +16 -0
  52. scaler/config/section/webui.py +16 -0
  53. scaler/config/types/__init__.py +0 -0
  54. scaler/config/types/network_backend.py +12 -0
  55. scaler/config/types/object_storage_server.py +45 -0
  56. scaler/config/types/worker.py +67 -0
  57. scaler/config/types/zmq.py +83 -0
  58. scaler/entry_points/__init__.py +0 -0
  59. scaler/entry_points/cluster.py +10 -0
  60. scaler/entry_points/object_storage_server.py +26 -0
  61. scaler/entry_points/scheduler.py +51 -0
  62. scaler/entry_points/top.py +272 -0
  63. scaler/entry_points/webui.py +6 -0
  64. scaler/entry_points/worker_adapter_ecs.py +22 -0
  65. scaler/entry_points/worker_adapter_native.py +31 -0
  66. scaler/entry_points/worker_adapter_symphony.py +26 -0
  67. scaler/io/__init__.py +0 -0
  68. scaler/io/async_binder.py +89 -0
  69. scaler/io/async_connector.py +95 -0
  70. scaler/io/async_object_storage_connector.py +225 -0
  71. scaler/io/mixins.py +154 -0
  72. scaler/io/sync_connector.py +68 -0
  73. scaler/io/sync_object_storage_connector.py +249 -0
  74. scaler/io/sync_subscriber.py +83 -0
  75. scaler/io/utility.py +80 -0
  76. scaler/io/ymq/__init__.py +0 -0
  77. scaler/io/ymq/_ymq.pyi +95 -0
  78. scaler/io/ymq/_ymq.so +0 -0
  79. scaler/io/ymq/ymq.py +138 -0
  80. scaler/io/ymq_async_object_storage_connector.py +184 -0
  81. scaler/io/ymq_sync_object_storage_connector.py +184 -0
  82. scaler/object_storage/__init__.py +0 -0
  83. scaler/object_storage/object_storage_server.so +0 -0
  84. scaler/protocol/__init__.py +0 -0
  85. scaler/protocol/capnp/__init__.py +0 -0
  86. scaler/protocol/capnp/_python.py +6 -0
  87. scaler/protocol/capnp/common.capnp +68 -0
  88. scaler/protocol/capnp/message.capnp +218 -0
  89. scaler/protocol/capnp/object_storage.capnp +57 -0
  90. scaler/protocol/capnp/status.capnp +73 -0
  91. scaler/protocol/introduction.md +105 -0
  92. scaler/protocol/python/__init__.py +0 -0
  93. scaler/protocol/python/common.py +140 -0
  94. scaler/protocol/python/message.py +751 -0
  95. scaler/protocol/python/mixins.py +13 -0
  96. scaler/protocol/python/object_storage.py +118 -0
  97. scaler/protocol/python/status.py +279 -0
  98. scaler/protocol/worker.md +228 -0
  99. scaler/scheduler/__init__.py +0 -0
  100. scaler/scheduler/allocate_policy/__init__.py +0 -0
  101. scaler/scheduler/allocate_policy/allocate_policy.py +9 -0
  102. scaler/scheduler/allocate_policy/capability_allocate_policy.py +280 -0
  103. scaler/scheduler/allocate_policy/even_load_allocate_policy.py +159 -0
  104. scaler/scheduler/allocate_policy/mixins.py +55 -0
  105. scaler/scheduler/controllers/__init__.py +0 -0
  106. scaler/scheduler/controllers/balance_controller.py +65 -0
  107. scaler/scheduler/controllers/client_controller.py +131 -0
  108. scaler/scheduler/controllers/config_controller.py +31 -0
  109. scaler/scheduler/controllers/graph_controller.py +424 -0
  110. scaler/scheduler/controllers/information_controller.py +81 -0
  111. scaler/scheduler/controllers/mixins.py +194 -0
  112. scaler/scheduler/controllers/object_controller.py +147 -0
  113. scaler/scheduler/controllers/scaling_policies/__init__.py +0 -0
  114. scaler/scheduler/controllers/scaling_policies/fixed_elastic.py +145 -0
  115. scaler/scheduler/controllers/scaling_policies/mixins.py +10 -0
  116. scaler/scheduler/controllers/scaling_policies/null.py +14 -0
  117. scaler/scheduler/controllers/scaling_policies/types.py +9 -0
  118. scaler/scheduler/controllers/scaling_policies/utility.py +20 -0
  119. scaler/scheduler/controllers/scaling_policies/vanilla.py +95 -0
  120. scaler/scheduler/controllers/task_controller.py +376 -0
  121. scaler/scheduler/controllers/worker_controller.py +169 -0
  122. scaler/scheduler/object_usage/__init__.py +0 -0
  123. scaler/scheduler/object_usage/object_tracker.py +131 -0
  124. scaler/scheduler/scheduler.py +251 -0
  125. scaler/scheduler/task/__init__.py +0 -0
  126. scaler/scheduler/task/task_state_machine.py +92 -0
  127. scaler/scheduler/task/task_state_manager.py +61 -0
  128. scaler/ui/__init__.py +0 -0
  129. scaler/ui/common/__init__.py +0 -0
  130. scaler/ui/common/constants.py +9 -0
  131. scaler/ui/common/live_display.py +147 -0
  132. scaler/ui/common/memory_window.py +146 -0
  133. scaler/ui/common/setting_page.py +40 -0
  134. scaler/ui/common/task_graph.py +840 -0
  135. scaler/ui/common/task_log.py +111 -0
  136. scaler/ui/common/utility.py +66 -0
  137. scaler/ui/common/webui.py +80 -0
  138. scaler/ui/common/worker_processors.py +104 -0
  139. scaler/ui/v1.py +76 -0
  140. scaler/ui/v2.py +102 -0
  141. scaler/ui/webui.py +21 -0
  142. scaler/utility/__init__.py +0 -0
  143. scaler/utility/debug.py +19 -0
  144. scaler/utility/event_list.py +63 -0
  145. scaler/utility/event_loop.py +58 -0
  146. scaler/utility/exceptions.py +42 -0
  147. scaler/utility/formatter.py +44 -0
  148. scaler/utility/graph/__init__.py +0 -0
  149. scaler/utility/graph/optimization.py +27 -0
  150. scaler/utility/graph/topological_sorter.py +11 -0
  151. scaler/utility/graph/topological_sorter_graphblas.py +174 -0
  152. scaler/utility/identifiers.py +107 -0
  153. scaler/utility/logging/__init__.py +0 -0
  154. scaler/utility/logging/decorators.py +25 -0
  155. scaler/utility/logging/scoped_logger.py +33 -0
  156. scaler/utility/logging/utility.py +183 -0
  157. scaler/utility/many_to_many_dict.py +123 -0
  158. scaler/utility/metadata/__init__.py +0 -0
  159. scaler/utility/metadata/profile_result.py +31 -0
  160. scaler/utility/metadata/task_flags.py +30 -0
  161. scaler/utility/mixins.py +13 -0
  162. scaler/utility/network_util.py +7 -0
  163. scaler/utility/one_to_many_dict.py +72 -0
  164. scaler/utility/queues/__init__.py +0 -0
  165. scaler/utility/queues/async_indexed_queue.py +37 -0
  166. scaler/utility/queues/async_priority_queue.py +70 -0
  167. scaler/utility/queues/async_sorted_priority_queue.py +45 -0
  168. scaler/utility/queues/indexed_queue.py +114 -0
  169. scaler/utility/serialization.py +9 -0
  170. scaler/version.txt +1 -0
  171. scaler/worker/__init__.py +0 -0
  172. scaler/worker/agent/__init__.py +0 -0
  173. scaler/worker/agent/heartbeat_manager.py +110 -0
  174. scaler/worker/agent/mixins.py +137 -0
  175. scaler/worker/agent/processor/__init__.py +0 -0
  176. scaler/worker/agent/processor/object_cache.py +107 -0
  177. scaler/worker/agent/processor/processor.py +285 -0
  178. scaler/worker/agent/processor/streaming_buffer.py +28 -0
  179. scaler/worker/agent/processor_holder.py +147 -0
  180. scaler/worker/agent/processor_manager.py +369 -0
  181. scaler/worker/agent/profiling_manager.py +109 -0
  182. scaler/worker/agent/task_manager.py +150 -0
  183. scaler/worker/agent/timeout_manager.py +19 -0
  184. scaler/worker/preload.py +84 -0
  185. scaler/worker/worker.py +265 -0
  186. scaler/worker_adapter/__init__.py +0 -0
  187. scaler/worker_adapter/common.py +26 -0
  188. scaler/worker_adapter/ecs.py +241 -0
  189. scaler/worker_adapter/native.py +138 -0
  190. scaler/worker_adapter/symphony/__init__.py +0 -0
  191. scaler/worker_adapter/symphony/callback.py +45 -0
  192. scaler/worker_adapter/symphony/heartbeat_manager.py +82 -0
  193. scaler/worker_adapter/symphony/message.py +24 -0
  194. scaler/worker_adapter/symphony/task_manager.py +289 -0
  195. scaler/worker_adapter/symphony/worker.py +204 -0
  196. scaler/worker_adapter/symphony/worker_adapter.py +123 -0
@@ -0,0 +1,840 @@
1
+ import datetime
2
+ import hashlib
3
+ import logging
4
+ from collections import deque
5
+ from enum import Enum
6
+ from threading import Lock
7
+ from typing import Deque, Dict, List, Optional, Set, Tuple
8
+
9
+ from nicegui import ui
10
+
11
+ from scaler.protocol.python.common import TaskState, WorkerState
12
+ from scaler.protocol.python.message import StateTask, StateWorker
13
+ from scaler.ui.common.setting_page import Settings
14
+ from scaler.ui.common.utility import (
15
+ COMPLETED_TASK_STATUSES,
16
+ display_capabilities,
17
+ format_timediff,
18
+ format_worker_name,
19
+ get_bounds,
20
+ make_taskstream_ticks,
21
+ make_tick_text,
22
+ )
23
+ from scaler.ui.util import NICEGUI_MAJOR_VERSION
24
+
25
+ TASK_STREAM_BACKGROUND_COLOR = "white"
26
+ TASK_STREAM_BACKGROUND_COLOR_RGB = "#000000"
27
+
28
+
29
+ class TaskShape(Enum):
30
+ NONE = ""
31
+ FAILED = "x"
32
+ CANCELED = "/"
33
+
34
+
35
+ class TaskShapes:
36
+ _task_status_to_shape = {
37
+ TaskState.Success: TaskShape.NONE.value,
38
+ TaskState.Running: TaskShape.NONE.value,
39
+ TaskState.Failed: TaskShape.FAILED.value,
40
+ TaskState.FailedWorkerDied: TaskShape.FAILED.value,
41
+ TaskState.Canceled: TaskShape.CANCELED.value,
42
+ TaskState.CanceledNotFound: TaskShape.CANCELED.value,
43
+ }
44
+
45
+ _task_shape_to_outline = {
46
+ TaskState.Success: ("black", 1),
47
+ TaskState.Running: ("yellow", 1),
48
+ TaskState.Failed: ("red", 1),
49
+ TaskState.FailedWorkerDied: ("red", 1),
50
+ TaskState.Canceled: ("black", 1),
51
+ TaskState.CanceledNotFound: ("black", 1),
52
+ }
53
+
54
+ @classmethod
55
+ def from_status(cls, status: TaskState) -> str:
56
+ return cls._task_status_to_shape[status]
57
+
58
+ @classmethod
59
+ def get_outline(cls, status: TaskState) -> Tuple[str, int]:
60
+ return cls._task_shape_to_outline[status]
61
+
62
+
63
+ class TaskStream:
64
+ def __init__(self):
65
+ self._card: Optional[ui.card] = None
66
+ self._figure = {}
67
+ self._plot = None
68
+
69
+ self._settings: Optional[Settings] = None
70
+
71
+ self._start_time = datetime.datetime.now() - datetime.timedelta(minutes=30)
72
+ self._user_axis_range: Optional[List[int]] = None
73
+
74
+ self._current_tasks: Dict[str, Dict[bytes, datetime.datetime]] = {}
75
+ self._completed_data_cache: Dict[str, Dict] = {}
76
+
77
+ self._worker_to_object_name: Dict[str, str] = {}
78
+ self._worker_to_task_ids: Dict[str, Set[bytes]] = {}
79
+ self._worker_capabilities: Dict[str, Set[str]] = {}
80
+ self._task_id_to_worker: Dict[bytes, str] = {}
81
+ self._task_id_to_printable_capabilities: Dict[bytes, str] = {}
82
+
83
+ self._seen_workers = set()
84
+
85
+ self._data_update_lock = Lock()
86
+
87
+ self._dead_workers: Deque[Tuple[datetime.datetime, str]] = deque() # type: ignore[misc]
88
+
89
+ self._capabilities_color_map: Dict[str, str] = {"<no capabilities>": "green"}
90
+
91
+ self._worker_rows: Dict[str, List[str]] = {}
92
+ self._row_to_worker: Dict[str, str] = {}
93
+ self._task_row_assignment: Dict[bytes, str] = {}
94
+ self._row_last_used: Dict[str, datetime.datetime] = {}
95
+
96
+ def __set_worker_rows(self, worker: str, rows: List[str]):
97
+ self._worker_rows[worker] = rows
98
+ for row in rows:
99
+ self._row_to_worker[row] = worker
100
+
101
+ def __add_worker_row(self, worker: str, row: str):
102
+ self._worker_rows[worker].append(row)
103
+ self._row_to_worker[row] = worker
104
+
105
+ def __clear_worker_rows(self, worker: str) -> List[str]:
106
+ rows = self._worker_rows.pop(worker, [])
107
+ for row in rows:
108
+ self._row_to_worker.pop(row, None)
109
+ self._row_last_used.pop(row, None)
110
+ return rows
111
+
112
+ def __make_initial_row(self, worker: str):
113
+ base = format_worker_name(worker)
114
+ rows = self._worker_rows.get(worker)
115
+ if rows is None:
116
+ # first row is [1]
117
+ rows = [f"{base} [1]"]
118
+ self.__set_worker_rows(worker, rows)
119
+
120
+ def __allocate_row_for_task(self, worker: str, task_id: bytes) -> str:
121
+ if task_id in self._task_row_assignment:
122
+ return self._task_row_assignment[task_id]
123
+
124
+ if worker not in self._worker_rows:
125
+ self.__make_initial_row(worker)
126
+
127
+ # pick first unused row label
128
+ used_rows = set(self._task_row_assignment.values())
129
+ for row in self._worker_rows[worker]:
130
+ if row not in used_rows:
131
+ self._task_row_assignment[task_id] = row
132
+
133
+ if row not in self._completed_data_cache:
134
+ self.__setup_row_cache(row)
135
+ row_data = self._completed_data_cache.get(row)
136
+ if row_data is not None and row not in row_data.get("y", []):
137
+ now = datetime.datetime.now()
138
+ self.__add_bar(
139
+ worker=worker,
140
+ time_taken=format_timediff(self._start_time, now),
141
+ task_color=TASK_STREAM_BACKGROUND_COLOR,
142
+ state=None,
143
+ hovertext="",
144
+ capabilities_display_string="",
145
+ row_label=row,
146
+ )
147
+ self._row_last_used[row] = now
148
+
149
+ return row
150
+
151
+ # need to add a new row
152
+ new_row = f"{format_worker_name(worker)} [{len(self._worker_rows[worker]) + 1}]"
153
+ self.__add_worker_row(worker, new_row)
154
+ self._task_row_assignment[task_id] = new_row
155
+
156
+ self.__setup_row_cache(new_row)
157
+ row_data = self._completed_data_cache.get(new_row)
158
+ if row_data is not None and new_row not in row_data.get("y", []):
159
+ now = datetime.datetime.now()
160
+ self.__add_bar(
161
+ worker=worker,
162
+ time_taken=format_timediff(self._start_time, now),
163
+ task_color=TASK_STREAM_BACKGROUND_COLOR,
164
+ state=None,
165
+ hovertext="",
166
+ capabilities_display_string="",
167
+ row_label=new_row,
168
+ )
169
+ self._row_last_used[new_row] = now
170
+
171
+ return new_row
172
+
173
+ def __free_row_for_task(self, task_id: bytes):
174
+ if task_id in self._task_row_assignment:
175
+ self._task_row_assignment.pop(task_id)
176
+
177
+ def setup_task_stream(self, settings: Settings):
178
+ self._card = ui.card()
179
+ self._card.classes("w-full").style("height: 800px; overflow:auto;")
180
+
181
+ # TODO: remove when v1 and v2 are separated
182
+ def html_func(x: str):
183
+ if NICEGUI_MAJOR_VERSION < 3:
184
+ return ui.html(x)
185
+ return ui.html(x, sanitize=False) # type: ignore[call-arg]
186
+
187
+ with self._card:
188
+ html_func(
189
+ """
190
+ <div style="margin-bottom:8px;">
191
+ <b>Legend:</b>
192
+ <span style="display:inline-block;width:18px;height:18px;border:2px solid black;
193
+ vertical-align:middle;margin-right:4px;
194
+ background:
195
+ repeating-linear-gradient(135deg, transparent, transparent 4px, black 4px, black 6px),
196
+ repeating-linear-gradient(225deg, transparent, transparent 4px, black 4px, black 6px);
197
+ "></span>
198
+ Failed
199
+ <span style="display:inline-block;width:18px;height:18px;border:2px solid black;
200
+ vertical-align:middle;margin-left:16px;margin-right:4px;
201
+ background:
202
+ repeating-linear-gradient(-45deg, transparent, transparent 4px, black 4px, black 6px);
203
+ "></span>
204
+ Canceled
205
+ </div>
206
+ """
207
+ )
208
+ fig = {
209
+ "data": [],
210
+ "layout": {
211
+ "barmode": "stack",
212
+ "autosize": True,
213
+ "margin": {"l": 163},
214
+ "xaxis": {
215
+ "autorange": False,
216
+ "range": [0, 300],
217
+ "showgrid": False,
218
+ "tickmode": "array",
219
+ "tickvals": [0, 50, 100, 150, 200, 250, 300],
220
+ "ticktext": [-300, -250, -200, -150, -100, -50, 0],
221
+ "zeroline": False,
222
+ },
223
+ "yaxis": {
224
+ "autorange": True,
225
+ "automargin": True,
226
+ "rangemode": "nonnegative",
227
+ "showgrid": False,
228
+ "type": "category",
229
+ },
230
+ },
231
+ }
232
+ self._figure = fig
233
+ self._completed_data_cache = {}
234
+ self._plot = ui.plotly(self._figure).classes("w-full h-full")
235
+ self._plot.on("plotly_relayout", self._on_plotly_relayout)
236
+ self._settings = settings
237
+
238
+ def _on_plotly_relayout(self, e):
239
+ x0 = e.args.get("xaxis.range[0]")
240
+ x1 = e.args.get("xaxis.range[1]")
241
+ if x0 is not None and x1 is not None:
242
+ self._user_axis_range = [x0, x1]
243
+ else:
244
+ self._user_axis_range = None
245
+
246
+ def __setup_row_cache(self, row_label: str):
247
+ if row_label in self._completed_data_cache:
248
+ return
249
+ self._completed_data_cache[row_label] = {
250
+ "type": "bar",
251
+ "name": "Completed",
252
+ "y": [],
253
+ "x": [],
254
+ "orientation": "h",
255
+ "marker": {"color": [], "width": 5, "pattern": {"shape": []}, "line": {"color": [], "width": []}},
256
+ "hovertemplate": [],
257
+ "hovertext": [],
258
+ "customdata": [],
259
+ "showlegend": False,
260
+ }
261
+
262
+ def __get_history_fields(self, worker: str, index: int) -> Tuple[float, str, str, str, str, str, int, str]:
263
+ row_data = self._completed_data_cache[worker]
264
+ time_taken = row_data["x"][index]
265
+ color = row_data["marker"]["color"][index]
266
+ text = row_data["hovertext"][index]
267
+ shape = row_data["marker"]["pattern"]["shape"][index]
268
+ capabilities = row_data["customdata"][index]["capabilities"]
269
+ row_label = row_data["y"][index]
270
+ outline_color = row_data["marker"]["line"]["color"][index]
271
+ outline_width = row_data["marker"]["line"]["width"][index]
272
+ return time_taken, color, text, shape, capabilities, row_label, outline_width, outline_color
273
+
274
+ def __remove_last_elements(self, row_label: str):
275
+ row_data = self._completed_data_cache[row_label]
276
+ del row_data["y"][-1]
277
+ del row_data["x"][-1]
278
+ del row_data["marker"]["color"][-1]
279
+ del row_data["marker"]["pattern"]["shape"][-1]
280
+ del row_data["marker"]["line"]["color"][-1]
281
+ del row_data["marker"]["line"]["width"][-1]
282
+ del row_data["hovertext"][-1]
283
+ del row_data["hovertemplate"][-1]
284
+ del row_data["customdata"][-1]
285
+
286
+ def __get_capabilities_color(self, capabilities: str) -> str:
287
+ if capabilities not in self._capabilities_color_map:
288
+ h = hashlib.md5(capabilities.encode()).hexdigest()
289
+ color = f"#{h[:6]}"
290
+ if color == TASK_STREAM_BACKGROUND_COLOR_RGB:
291
+ color = "#0000ff"
292
+ self._capabilities_color_map[capabilities] = color
293
+ return self._capabilities_color_map[capabilities]
294
+
295
+ def __get_task_color(self, task_id: bytes) -> str:
296
+ capabilities = self._task_id_to_printable_capabilities.get(task_id, "<no capabilities>")
297
+ color = self.__get_capabilities_color(capabilities)
298
+ return color
299
+
300
+ def __add_task_to_chart(self, worker: str, task_id: bytes, task_state: TaskState, task_time: float) -> str:
301
+ task_color = self.__get_task_color(task_id)
302
+ task_hovertext = self._worker_to_object_name.get(worker, "")
303
+ capabilities_display_string = self._task_id_to_printable_capabilities.get(task_id, "")
304
+
305
+ row_label = self._task_row_assignment.get(task_id)
306
+
307
+ self.__add_bar(
308
+ worker=worker,
309
+ time_taken=task_time,
310
+ task_color=task_color,
311
+ state=task_state,
312
+ hovertext=task_hovertext,
313
+ capabilities_display_string=capabilities_display_string,
314
+ row_label=row_label,
315
+ )
316
+
317
+ self.__free_row_for_task(task_id)
318
+
319
+ return row_label
320
+
321
+ def __add_bar(
322
+ self,
323
+ worker: str,
324
+ time_taken: float,
325
+ task_color: str,
326
+ state: Optional[TaskState],
327
+ hovertext: str,
328
+ capabilities_display_string: str,
329
+ row_label: Optional[str] = None,
330
+ ):
331
+ if row_label is None:
332
+ row_label = f"{format_worker_name(worker)} [1]"
333
+
334
+ if state:
335
+ shape = TaskShapes.from_status(state)
336
+ task_outline_color, task_outline_width = TaskShapes.get_outline(state)
337
+ state_text = state.name
338
+ else:
339
+ shape = TaskShape.NONE.value
340
+ task_outline_color, task_outline_width = ("rgba(0,0,0,0)", 0)
341
+ state_text = ""
342
+ row_history = self._completed_data_cache[row_label]
343
+
344
+ # if this is the same task repeated, merge the bars
345
+ if len(row_history["y"]) > 0:
346
+ (
347
+ last_time_taken,
348
+ last_color,
349
+ last_text,
350
+ last_shape,
351
+ last_capabilities,
352
+ last_row,
353
+ last_outline_width,
354
+ last_outline_color,
355
+ ) = self.__get_history_fields(row_label, -1)
356
+
357
+ if (
358
+ last_row == row_label
359
+ and last_color == task_color
360
+ and last_text == hovertext
361
+ and last_shape == shape
362
+ and last_capabilities == capabilities_display_string
363
+ and last_outline_width == task_outline_width
364
+ and last_outline_color == task_outline_color
365
+ ):
366
+ row_history["x"][-1] += time_taken
367
+ row_history["customdata"][-1]["task_count"] += 1
368
+ return
369
+
370
+ # if there's a very short gap from last task to current task, merge the bars
371
+ if task_color != TASK_STREAM_BACKGROUND_COLOR and len(row_history["y"]) > 2:
372
+ (
373
+ _,
374
+ penult_color,
375
+ penult_text,
376
+ penult_shape,
377
+ penult_capabilities,
378
+ penult_row,
379
+ penult_outline_width,
380
+ penult_outline_color,
381
+ ) = self.__get_history_fields(row_label, -2)
382
+
383
+ if (
384
+ last_time_taken < 0.1
385
+ and penult_row == row_label
386
+ and penult_color == task_color
387
+ and penult_text == hovertext
388
+ and penult_shape == shape
389
+ and penult_capabilities == capabilities_display_string
390
+ and penult_outline_width == task_outline_width
391
+ and penult_outline_color == task_outline_color
392
+ ):
393
+ row_history["x"][-2] += time_taken + last_time_taken
394
+ row_history["customdata"][-2]["task_count"] += 1
395
+ self.__remove_last_elements(row_label)
396
+ return
397
+
398
+ self._completed_data_cache[row_label]["y"].append(row_label)
399
+ self._completed_data_cache[row_label]["x"].append(time_taken)
400
+ self._completed_data_cache[row_label]["marker"]["color"].append(task_color)
401
+ self._completed_data_cache[row_label]["marker"]["pattern"]["shape"].append(shape)
402
+ self._completed_data_cache[row_label]["marker"]["line"]["color"].append(task_outline_color)
403
+ self._completed_data_cache[row_label]["marker"]["line"]["width"].append(task_outline_width)
404
+ self._completed_data_cache[row_label]["hovertext"].append(hovertext)
405
+ self._completed_data_cache[row_label]["customdata"].append(
406
+ {"state": state_text, "task_count": 1, "capabilities": capabilities_display_string}
407
+ )
408
+
409
+ if hovertext:
410
+ self._completed_data_cache[row_label]["hovertemplate"].append(
411
+ "%{hovertext} (%{x})<br>"
412
+ "%{customdata.state}<br>"
413
+ "Tasks: %{customdata.task_count}<br>"
414
+ "Capabilities: %{customdata.capabilities}"
415
+ )
416
+ else:
417
+ self._completed_data_cache[row_label]["hovertemplate"].append("")
418
+
419
+ def __cutoff_keep_first(self, data_list: list, cutoff_index: int):
420
+ return [data_list[0]] + data_list[cutoff_index:]
421
+
422
+ def __remove_old_tasks_from_cache(self, row_label: str, cutoff_index: int):
423
+ row_data = self._completed_data_cache[row_label]
424
+ removed_time = sum([row_data["x"][i] for i in range(1, cutoff_index)])
425
+
426
+ row_data["y"] = self.__cutoff_keep_first(row_data["y"], cutoff_index)
427
+ row_data["x"] = self.__cutoff_keep_first(row_data["x"], cutoff_index)
428
+ row_data["marker"]["color"] = self.__cutoff_keep_first(row_data["marker"]["color"], cutoff_index)
429
+ row_data["marker"]["pattern"]["shape"] = self.__cutoff_keep_first(
430
+ row_data["marker"]["pattern"]["shape"], cutoff_index
431
+ )
432
+ row_data["marker"]["line"]["color"] = self.__cutoff_keep_first(
433
+ row_data["marker"]["line"]["color"], cutoff_index
434
+ )
435
+ row_data["marker"]["line"]["width"] = self.__cutoff_keep_first(
436
+ row_data["marker"]["line"]["width"], cutoff_index
437
+ )
438
+ row_data["hovertext"] = self.__cutoff_keep_first(row_data["hovertext"], cutoff_index)
439
+ row_data["hovertemplate"] = self.__cutoff_keep_first(row_data["hovertemplate"], cutoff_index)
440
+
441
+ row_data["x"][0] += removed_time
442
+
443
+ def check_row_total_time(self, row_label: str):
444
+ row_total_time = sum(self._completed_data_cache[row_label]["x"])
445
+ now = datetime.datetime.now()
446
+ since_start = format_timediff(self._start_time, now)
447
+ delta = since_start - row_total_time
448
+
449
+ if delta > 0.1:
450
+ # hack. need to reproduce drifting behavior and find the cause.
451
+ logging.info(f"Adjusting row {row_label} by {delta:.2f}s to match total runtime")
452
+ self._completed_data_cache[row_label]["x"][0] += delta
453
+
454
+ def __handle_task_result(self, state: StateTask, now: datetime.datetime):
455
+ worker = self._task_id_to_worker.get(state.task_id, "")
456
+ if worker == "":
457
+ return
458
+
459
+ start = None
460
+ task_map = self._current_tasks.get(worker)
461
+ if task_map:
462
+ start = task_map.get(state.task_id)
463
+
464
+ if start is None:
465
+ # we don't know when this task started, so just ignore
466
+ return
467
+
468
+ task_state = state.state
469
+ task_time = format_timediff(start, now)
470
+
471
+ row_label = self.__add_task_to_chart(worker, state.task_id, task_state, task_time)
472
+ self.check_row_total_time(row_label)
473
+ with self._data_update_lock:
474
+ self.__remove_task_from_worker(worker=worker, task_id=state.task_id, now=now, force_new_time=False)
475
+ self._row_last_used[row_label] = now
476
+
477
+ if worker_tasks := self._worker_to_task_ids.get(worker):
478
+ worker_tasks.discard(state.task_id)
479
+
480
+ def __handle_new_worker(self, worker: str, now: datetime.datetime, capabilities: Set[str]):
481
+ if worker in self._seen_workers:
482
+ # If for some reason we receive a task for the worker before
483
+ # the StateWorker connect message, just update capabilities.
484
+ logging.debug(f"Handling new worker {worker} but worker is already known.")
485
+
486
+ if self._worker_capabilities.get(worker) is None and capabilities is not None:
487
+ self._worker_capabilities[worker] = capabilities
488
+ return
489
+
490
+ row_label = f"{format_worker_name(worker)} [1]"
491
+ if row_label not in self._completed_data_cache:
492
+ self.__setup_row_cache(row_label)
493
+ self.__make_initial_row(worker)
494
+ self.__add_bar(
495
+ worker=worker,
496
+ time_taken=format_timediff(self._start_time, now),
497
+ task_color=TASK_STREAM_BACKGROUND_COLOR,
498
+ state=None,
499
+ hovertext="",
500
+ capabilities_display_string="",
501
+ row_label=row_label,
502
+ )
503
+ self._row_last_used[row_label] = now
504
+ self._seen_workers.add(worker)
505
+ self._worker_capabilities[worker] = capabilities
506
+
507
+ def __remove_task_from_worker(self, worker: str, task_id: bytes, now: datetime.datetime, force_new_time: bool):
508
+ # Remove a single task from the worker's current task mapping.
509
+ self.__free_row_for_task(task_id)
510
+ self._worker_to_task_ids.get(worker, set()).discard(task_id)
511
+ task_map = self._current_tasks.get(worker)
512
+ if not task_map:
513
+ return
514
+ task_map.pop(task_id, None)
515
+ if not task_map:
516
+ # no more tasks for this worker, remove the worker entry
517
+ self._current_tasks.pop(worker, None)
518
+
519
+ def __pad_inactive_time(self, worker: str, row_label: str, now: datetime.datetime):
520
+ """If a row is re-used, it needs to be padded until current time"""
521
+ last_update = self._row_last_used.get(row_label)
522
+ self._row_last_used[row_label] = now
523
+ if not last_update:
524
+ return
525
+ idle_time = format_timediff(last_update, now)
526
+ self.__add_bar(
527
+ worker=worker,
528
+ time_taken=idle_time,
529
+ task_color=TASK_STREAM_BACKGROUND_COLOR,
530
+ state=None,
531
+ hovertext="",
532
+ capabilities_display_string="",
533
+ row_label=row_label,
534
+ )
535
+
536
+ def __handle_running_task(self, state_task: StateTask, worker: str, now: datetime.datetime):
537
+ if state_task.task_id not in self._task_id_to_printable_capabilities:
538
+ self._task_id_to_printable_capabilities[state_task.task_id] = display_capabilities(
539
+ set(state_task.capabilities.keys())
540
+ )
541
+
542
+ # if another worker was previously assigned this task, remove it
543
+ previous_worker = self._task_id_to_worker.get(state_task.task_id)
544
+ if previous_worker and previous_worker != worker:
545
+ task_start_time = self._current_tasks.get(previous_worker, {}).get(state_task.task_id)
546
+ if task_start_time:
547
+ duration = format_timediff(task_start_time, now)
548
+ self.__add_task_to_chart(previous_worker, state_task.task_id, TaskState.Canceled, duration)
549
+ self.__remove_task_from_worker(
550
+ worker=previous_worker, task_id=state_task.task_id, now=now, force_new_time=False
551
+ )
552
+
553
+ self._task_id_to_worker[state_task.task_id] = worker
554
+ self._worker_to_object_name[worker] = state_task.function_name.decode()
555
+
556
+ worker_tasks = self._worker_to_task_ids.get(worker, set())
557
+ worker_tasks.add(state_task.task_id)
558
+ self._worker_to_task_ids[worker] = worker_tasks
559
+
560
+ # Per-task start times: store start time for this task only.
561
+ task_map = self._current_tasks.get(worker)
562
+ if task_map:
563
+ with self._data_update_lock:
564
+ task_map[state_task.task_id] = now
565
+ # allocate a row for this additional task so it shows on its own row
566
+ row_label = self.__allocate_row_for_task(worker, state_task.task_id)
567
+ self.__pad_inactive_time(worker, row_label, now)
568
+ return
569
+
570
+ # first task for this worker
571
+ with self._data_update_lock:
572
+ self._current_tasks[worker] = {state_task.task_id: now}
573
+ # allocate a row for the running task
574
+ self.__allocate_row_for_task(worker, state_task.task_id)
575
+
576
+ def handle_worker_state(self, state_worker: StateWorker):
577
+ worker_id = state_worker.worker_id.decode()
578
+ worker_state = state_worker.state
579
+
580
+ now = datetime.datetime.now()
581
+
582
+ if worker_state == WorkerState.Connected:
583
+ self.__handle_new_worker(worker_id, now, set(state_worker.capabilities.keys()))
584
+ elif worker_state == WorkerState.Disconnected:
585
+ self.mark_dead_worker(worker_id)
586
+
587
+ def handle_task_state(self, state_task: StateTask):
588
+ """
589
+ The scheduler sends out `state.worker` while a Task is running.
590
+ However, as soon as the task is done, that entry is cleared.
591
+ A Success status will thus come with an empty `state.worker`, so
592
+ we store this mapping ourselves based on the Running statuses we see.
593
+ """
594
+
595
+ task_state = state_task.state
596
+ now = datetime.datetime.now()
597
+
598
+ if task_state in COMPLETED_TASK_STATUSES:
599
+ self.__handle_task_result(state_task, now)
600
+ return
601
+
602
+ if not (worker := state_task.worker):
603
+ return
604
+
605
+ worker_string = worker.decode()
606
+
607
+ if worker_string not in self._seen_workers:
608
+ logging.warning(
609
+ f"Unknown worker seen in handle_task_state: {worker_string}. "
610
+ "Did this worker connect before the UI started?"
611
+ )
612
+ self.__handle_new_worker(worker_string, now, set())
613
+ return
614
+
615
+ if task_state in {TaskState.Running}:
616
+ self.__handle_running_task(state_task, worker_string, now)
617
+
618
+ def __clear_all_task_data(self, task_id: bytes):
619
+ self._task_id_to_worker.pop(task_id, None)
620
+ self._task_id_to_printable_capabilities.pop(task_id, None)
621
+
622
+ def __clear_all_worker_data(self, worker: str):
623
+ self._worker_to_object_name.pop(worker, None)
624
+ self._worker_to_task_ids.pop(worker, None)
625
+ self._worker_capabilities.pop(worker, None)
626
+ self.__clear_worker_rows(worker)
627
+
628
+ def __remove_worker_from_history(self, worker: str):
629
+ logging.info(f"Removing worker {worker} from task stream history")
630
+ for row_label in self.__clear_worker_rows(worker):
631
+ if row_label in self._completed_data_cache:
632
+ self._completed_data_cache.pop(row_label)
633
+
634
+ worker_tasks = self._worker_to_task_ids.pop(worker, set())
635
+ for task_id in worker_tasks:
636
+ self.__clear_all_task_data(task_id)
637
+ self.__free_row_for_task(task_id)
638
+
639
+ self.__clear_all_worker_data(worker)
640
+ self._seen_workers.discard(worker)
641
+
642
+ def __remove_old_tasks_from_history(self, store_duration: datetime.timedelta):
643
+ for row_label in self._completed_data_cache.keys():
644
+ row_data = self._completed_data_cache[row_label]
645
+
646
+ storage_cutoff_index = len(row_data["x"]) - 1
647
+ time_taken = 0
648
+ store_seconds = store_duration.total_seconds()
649
+ while storage_cutoff_index > 0 and time_taken < store_seconds:
650
+ time_taken += row_data["x"][storage_cutoff_index]
651
+ storage_cutoff_index -= 1
652
+ if storage_cutoff_index > 0:
653
+ self.__remove_old_tasks_from_cache(row_label, storage_cutoff_index)
654
+
655
+ def __remove_dead_workers(self, remove_up_to: datetime.datetime):
656
+ while self._dead_workers and self._dead_workers[0][0] < remove_up_to:
657
+ _, worker = self._dead_workers.popleft()
658
+ self.__remove_worker_from_history(worker)
659
+
660
+ def __split_workers_by_status(self, now: datetime.datetime) -> List[Tuple[str, float, bytes, str]]:
661
+ workers_doing_jobs: List[Tuple[str, float, bytes, str]] = []
662
+ for worker, task_map in self._current_tasks.items():
663
+ if not task_map:
664
+ continue
665
+ object_name = self._worker_to_object_name.get(worker, "")
666
+
667
+ # For each concurrent task, allocate/lookup a row and produce a separate entry.
668
+ for task_id, start_time in list(task_map.items()):
669
+ # determine duration relative to the task's start time
670
+ duration = format_timediff(start_time, now)
671
+ row_label = self._task_row_assignment.get(task_id)
672
+ if row_label is None:
673
+ row_label = self.__allocate_row_for_task(worker, task_id)
674
+ logging.warning(f"split worker needed to allocate row {row_label} for task {task_id.hex()}")
675
+
676
+ workers_doing_jobs.append((row_label, duration, task_id, object_name))
677
+ return workers_doing_jobs
678
+
679
+ def mark_dead_worker(self, worker_name: str):
680
+ now = datetime.datetime.now()
681
+ with self._data_update_lock:
682
+ self._current_tasks.pop(worker_name, None)
683
+ self._dead_workers.append((now, worker_name))
684
+
685
+ def update_plot(self):
686
+ with self._data_update_lock:
687
+ now = datetime.datetime.now()
688
+
689
+ workers_doing_tasks = self.__split_workers_by_status(now)
690
+
691
+ self.__remove_old_tasks_from_history(self._settings.memory_store_time)
692
+
693
+ worker_retention_time = now - self._settings.memory_store_time
694
+ self.__remove_dead_workers(worker_retention_time)
695
+
696
+ worker_display_time = now - self._settings.stream_window
697
+ hidden_rows = {
698
+ row
699
+ for row in self._row_last_used.keys()
700
+ if self._row_last_used[row] < worker_display_time and self._row_to_worker[row] in self._dead_workers
701
+ }
702
+
703
+ completed_cache_values = sorted(
704
+ list(x for x in self._completed_data_cache.values() if x["y"] and x["y"][0] not in hidden_rows),
705
+ key=lambda d: d["y"][0],
706
+ )
707
+
708
+ task_ids = [t for (_, _, t, _) in workers_doing_tasks]
709
+ task_capabilities = [
710
+ (
711
+ f"Capabilities: {self._task_id_to_printable_capabilities.get(task_id, '')}"
712
+ if task_id in self._task_id_to_printable_capabilities
713
+ else ""
714
+ )
715
+ for task_id in task_ids
716
+ ]
717
+ task_colors = [self.__get_task_color(t) for t in task_ids]
718
+ running_shape = TaskShapes.from_status(TaskState.Running)
719
+ working_data = {
720
+ "type": "bar",
721
+ "name": "Working",
722
+ "y": [w for (w, _, _, _) in workers_doing_tasks],
723
+ "x": [t for (_, t, _, _) in workers_doing_tasks],
724
+ "orientation": "h",
725
+ "text": [f for (_, _, _, f) in workers_doing_tasks],
726
+ "hovertemplate": "%{text} (%{x})<br>%{customdata}",
727
+ "marker": {
728
+ "color": task_colors,
729
+ "width": 5,
730
+ "pattern": {"shape": [running_shape for _ in workers_doing_tasks]},
731
+ "line": {"color": ["yellow" for _ in workers_doing_tasks], "width": [1 for _ in workers_doing_tasks]},
732
+ },
733
+ "textfont": {"color": "black", "outline": "white", "outlinewidth": 5},
734
+ "customdata": task_capabilities,
735
+ "showlegend": False,
736
+ }
737
+ plot_data = completed_cache_values + [working_data]
738
+ self._figure["data"] = plot_data
739
+ self.__render_plot(now)
740
+
741
+ def __build_capability_boxes(self, labels: List[str], lower_bound: int, upper_bound: int) -> Tuple[Dict, Dict]:
742
+ """
743
+ Creates a legend-style set of colored boxes indicating worker capabilities.
744
+ Returns two traces: one for the colored boxes, and one for white background boxes to hide bars underneath.
745
+ """
746
+ window = max(1, upper_bound - lower_bound)
747
+ base_x = lower_bound + window * 0.01
748
+ offset = window * 0.01
749
+
750
+ worker_data: Dict[str, Dict] = {}
751
+ for worker, capabilities in self._worker_capabilities.items():
752
+ worker_data[worker] = {"x_vals": [], "colors": [], "customdata": []}
753
+ # capability_count = len(capabilities)
754
+ for index, capability in enumerate(sorted(capabilities)):
755
+ color = self.__get_capabilities_color(capability)
756
+ x = base_x + index * offset
757
+ worker_data[worker]["x_vals"].append(x)
758
+ worker_data[worker]["colors"].append(color)
759
+ worker_data[worker]["customdata"].append(capability)
760
+
761
+ x_vals = []
762
+ y_vals = []
763
+ colors = []
764
+ customdata = []
765
+
766
+ for row in labels:
767
+ worker = self._row_to_worker.get(row)
768
+ box_data = worker_data.get(worker)
769
+ if not box_data:
770
+ continue
771
+
772
+ x_vals.extend(box_data["x_vals"])
773
+ y_vals.extend([row] * len(box_data["x_vals"]))
774
+ colors.extend(box_data["colors"])
775
+ customdata.extend(box_data["customdata"])
776
+
777
+ # Background trace: slightly larger white squares to hide underlying bars where capability boxes sit
778
+ background_trace = {
779
+ "type": "scatter",
780
+ "mode": "markers",
781
+ "name": "",
782
+ "x": x_vals,
783
+ "y": y_vals,
784
+ "marker": {"symbol": "square", "size": 12, "color": "white", "line": {"color": "white", "width": 1}},
785
+ "hoverinfo": "skip",
786
+ "showlegend": False,
787
+ }
788
+
789
+ trace = {
790
+ "type": "scatter",
791
+ "mode": "markers",
792
+ "name": "Capabilities",
793
+ "x": x_vals,
794
+ "y": y_vals,
795
+ "marker": {"symbol": "square", "size": 10, "color": colors, "line": {"color": "black", "width": 1}},
796
+ "customdata": customdata,
797
+ "hovertemplate": "%{customdata}",
798
+ "showlegend": False,
799
+ }
800
+
801
+ return trace, background_trace
802
+
803
+ def __adjust_card_height(self, num_rows: int):
804
+ # these are estimates, may need adjustments for large row counts
805
+ min_height = 800
806
+ row_height = 20
807
+ header_height = 140
808
+ estimated_height = header_height + (num_rows * row_height)
809
+ target_height = max(min_height, estimated_height)
810
+ self._card.style(f"height: {target_height}px; overflow:auto;")
811
+
812
+ def __render_plot(self, now: datetime.datetime):
813
+ lower_bound, upper_bound = get_bounds(now, self._start_time, self._settings)
814
+
815
+ ticks = make_taskstream_ticks(lower_bound, upper_bound)
816
+ tick_text = make_tick_text(int(self._settings.stream_window.total_seconds()))
817
+
818
+ if self._user_axis_range:
819
+ self._figure["layout"]["xaxis"]["range"] = self._user_axis_range
820
+ else:
821
+ self._figure["layout"]["xaxis"]["range"] = [lower_bound, upper_bound]
822
+ self._figure["layout"]["xaxis"]["tickvals"] = ticks
823
+ self._figure["layout"]["xaxis"]["ticktext"] = tick_text
824
+
825
+ seen = set()
826
+ category_array: List[str] = []
827
+ for trace in self._figure.get("data", []):
828
+ if trace.get("orientation") == "h" and trace.get("y"):
829
+ for yval in trace.get("y", []):
830
+ if yval not in seen:
831
+ seen.add(yval)
832
+ category_array.append(yval)
833
+
834
+ capability_traces, background_trace = self.__build_capability_boxes(category_array, lower_bound, upper_bound)
835
+
836
+ self._figure["data"].append(background_trace)
837
+ self._figure["data"].append(capability_traces)
838
+
839
+ self.__adjust_card_height(len(category_array))
840
+ self._plot.update()