opengris-scaler 1.12.28__cp313-cp313-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.

Potentially problematic release.


This version of opengris-scaler might be problematic. Click here for more details.

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