nodeocc 1.0.24__tar.gz → 1.0.25__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 (23) hide show
  1. {nodeocc-1.0.24 → nodeocc-1.0.25}/PKG-INFO +2 -2
  2. {nodeocc-1.0.24 → nodeocc-1.0.25}/pyproject.toml +1 -1
  3. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/controller/controller.py +103 -39
  4. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/nodeocc.egg-info/PKG-INFO +2 -2
  5. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/readers/slurmreader.py +41 -11
  6. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/view/__init__.py +2 -0
  7. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/view/curses_multiwindow.py +74 -24
  8. {nodeocc-1.0.24 → nodeocc-1.0.25}/README.md +0 -0
  9. {nodeocc-1.0.24 → nodeocc-1.0.25}/setup.cfg +0 -0
  10. {nodeocc-1.0.24 → nodeocc-1.0.25}/setup.py +0 -0
  11. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/__init__.py +0 -0
  12. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/model/infrastructure.py +0 -0
  13. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/model/job.py +0 -0
  14. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/nodeocc.egg-info/SOURCES.txt +0 -0
  15. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/nodeocc.egg-info/dependency_links.txt +0 -0
  16. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/nodeocc.egg-info/entry_points.txt +0 -0
  17. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/nodeocc.egg-info/requires.txt +0 -0
  18. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/nodeocc.egg-info/top_level.txt +0 -0
  19. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/readers/compute_wait_time.py +0 -0
  20. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/view/slurm_list.py +0 -0
  21. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/view/slurm_viz.py +0 -0
  22. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/view/styles.py +0 -0
  23. {nodeocc-1.0.24 → nodeocc-1.0.25}/src/view/utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: nodeocc
3
- Version: 1.0.24
3
+ Version: 1.0.25
4
4
  Summary: TUI application for viewing the status of GPU allocations on a Slurm cluster
5
5
  Author: Matteo Boschini
6
6
  Maintainer: Lorenzo Bonicelli, Aniello Panariello
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nodeocc"
7
- version = "1.0.24"
7
+ version = "1.0.25"
8
8
  authors = [{ name = "Matteo Boschini" }]
9
9
  maintainers = [{ name = "Lorenzo Bonicelli" }, { name = "Aniello Panariello" }]
10
10
  description = "TUI application for viewing the status of GPU allocations on a Slurm cluster"
@@ -8,7 +8,7 @@ import numpy as np
8
8
 
9
9
  conf_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10
10
  print(conf_path)
11
- sys.path.append(conf_path)
11
+ sys.path.insert(0, conf_path)
12
12
  from curses import wrapper
13
13
  from view.curses_multiwindow import main, Singleton, try_open_socket_as_slave
14
14
 
@@ -23,6 +23,7 @@ import time
23
23
  import json
24
24
  import argparse
25
25
  import traceback
26
+ import socket
26
27
  from model.infrastructure import Infrastructure
27
28
  from model.job import Job
28
29
  import asyncio
@@ -74,6 +75,7 @@ parser.add_argument('--debug', action='store_true', help='Enable logging')
74
75
  parser.add_argument('--override', action='store_true', help='Enable logging')
75
76
  parser.add_argument('--master_only', action='store_true', help='Disable all prints - only run in background')
76
77
  parser.add_argument('--basepath', type=str, default='/tmp', help='Base path for nodeocc. Must be readable by all users')
78
+ parser.add_argument('--diag', action='store_true', help='Run one-shot diagnostics and exit')
77
79
  args = parser.parse_args()
78
80
 
79
81
  # check basepath is readable
@@ -177,49 +179,61 @@ async def get_data_slave(instance):
177
179
  # listen for data from master
178
180
  # instance.sock.settimeout(6.5)
179
181
  data, _ = instance.sock.recvfrom(MAX_BUF)
180
- decoded_data = data.decode('utf-8')
181
- if BEGIN_DELIM in decoded_data:
182
- n_msgs_remaining = int(decoded_data.split(BEGIN_DELIM)[0])
183
- # msg_len += len(str(msg_len)) message len is deprecated and used for n_msgs_remaining
184
- instance.timeme(f"received first message of {n_msgs_remaining} (total len {len(data)} bytes)")
185
-
186
- inf, jobs, avg_wait_time, decoded_msg = decode_msg_slave(data)
187
- instance.timeme(f"- receive")
188
- orig_timestamp = float(decoded_msg['ts'])
189
-
190
- total_msg_n = n_msgs_remaining
191
- msg_store = {i: [] for i in range(total_msg_n)}
192
- while n_msgs_remaining > 0:
193
- data, _ = instance.sock.recvfrom(MAX_BUF)
194
- back_msg_idx = int(data.decode('utf-8').split(EXTRA_MSG_BEGIN_DELIM)[0])
195
- msg_idx = total_msg_n - back_msg_idx - 1
196
- _, new_jobs, _, new_ts = decode_msg_slave(data, delim=EXTRA_MSG_BEGIN_DELIM)
197
-
198
- # check if timestamp is the same
199
- if float(new_ts['ts']) != orig_timestamp:
200
- instance.timeme(f"Timestamp mismatch, skipping")
201
- return inf, jobs, avg_wait_time # return what we have
202
-
203
- msg_store[msg_idx] += new_jobs
204
-
205
- n_msgs_remaining -= 1
206
- instance.timeme(f"- extra receive, {n_msgs_remaining} remaining")
207
-
208
- # sort and merge
209
- for msg_idx in range(total_msg_n):
210
- if len(msg_store[msg_idx]) == 0:
211
- instance.log(f"Empty message {msg_idx}")
212
- jobs += msg_store[msg_idx]
213
- else:
214
- instance.timeme(f"- no data")
215
- return None, None, None, None
182
+ decoded_data = data.decode('utf-8', errors='replace')
183
+ if BEGIN_DELIM not in decoded_data:
184
+ if EXTRA_MSG_BEGIN_DELIM in decoded_data:
185
+ instance.log("Received extra packet before main packet, discarding")
186
+ else:
187
+ instance.log("Received packet without known delimiters, discarding")
188
+ return None, None, None
189
+
190
+ n_msgs_remaining = int(decoded_data.split(BEGIN_DELIM)[0])
191
+ # msg_len += len(str(msg_len)) message len is deprecated and used for n_msgs_remaining
192
+ instance.timeme(f"received first message of {n_msgs_remaining} (total len {len(data)} bytes)")
193
+
194
+ inf, jobs, avg_wait_time, decoded_msg = decode_msg_slave(data)
195
+ instance.timeme(f"- receive")
196
+ orig_timestamp = float(decoded_msg['ts'])
197
+
198
+ total_msg_n = n_msgs_remaining
199
+ msg_store = {i: [] for i in range(total_msg_n)}
200
+ while n_msgs_remaining > 0:
201
+ data, _ = instance.sock.recvfrom(MAX_BUF)
202
+ decoded_extra = data.decode('utf-8', errors='replace')
203
+ if EXTRA_MSG_BEGIN_DELIM not in decoded_extra:
204
+ instance.log("Received non-extra packet while waiting for extra packets, skipping")
205
+ continue
206
+
207
+ back_msg_idx = int(decoded_extra.split(EXTRA_MSG_BEGIN_DELIM)[0])
208
+ msg_idx = total_msg_n - back_msg_idx - 1
209
+ if msg_idx < 0 or msg_idx >= total_msg_n:
210
+ instance.log(f"Invalid extra packet index: {msg_idx}, skipping")
211
+ continue
212
+
213
+ _, new_jobs, _, new_ts = decode_msg_slave(data, delim=EXTRA_MSG_BEGIN_DELIM)
214
+
215
+ # check if timestamp is the same
216
+ if float(new_ts['ts']) != orig_timestamp:
217
+ instance.timeme(f"Timestamp mismatch, skipping")
218
+ return inf, jobs, avg_wait_time # return what we have
219
+
220
+ msg_store[msg_idx] += new_jobs
221
+
222
+ n_msgs_remaining -= 1
223
+ instance.timeme(f"- extra receive, {n_msgs_remaining} remaining")
224
+
225
+ # sort and merge
226
+ for msg_idx in range(total_msg_n):
227
+ if len(msg_store[msg_idx]) == 0:
228
+ instance.log(f"Empty message {msg_idx}")
229
+ jobs += msg_store[msg_idx]
216
230
 
217
231
  n_timeout = 0
218
232
 
219
- except BlockingIOError as e:
233
+ except BlockingIOError:
220
234
  pass
221
235
 
222
- except TimeoutError as e:
236
+ except TimeoutError:
223
237
  n_timeout += 1
224
238
  instance.log(f"TIMEOUT {n_timeout}/{TIMEOUT_MAX}")
225
239
  if n_timeout > TIMEOUT_MAX:
@@ -227,6 +241,10 @@ async def get_data_slave(instance):
227
241
 
228
242
  time.sleep(random.randint(100, 10000) / 1000)
229
243
 
244
+ except Exception as e:
245
+ instance.err(f"Exception while receiving data from master: {e}")
246
+ instance.err(traceback.format_exc())
247
+
230
248
  return inf, jobs, avg_wait_time
231
249
 
232
250
 
@@ -274,7 +292,53 @@ def display_main(stdscr):
274
292
  return asyncio.run(main(stdscr))
275
293
 
276
294
 
295
+ def run_diagnostics(instance: Singleton):
296
+ hostname = socket.getfqdn().split('.')[0]
297
+ print(f"Diagnostics for nodeocc on host: {hostname}")
298
+ print(f"Base path: {instance.basepath}")
299
+ print(f"Role detected: {'master' if instance.is_master else 'slave'}")
300
+ print("")
301
+
302
+ try:
303
+ port_files = instance.get_port_files()
304
+ print(f"Visible port files: {len(port_files)}")
305
+ for pf in port_files:
306
+ try:
307
+ pid = Path(pf).read_text().strip()
308
+ except Exception:
309
+ pid = "?"
310
+ print(f" - {pf} (pid={pid})")
311
+ except Exception as e:
312
+ print(f"Could not list port files: {e}")
313
+
314
+ print("")
315
+
316
+ if instance.is_master:
317
+ print("Running one master update cycle...")
318
+ try:
319
+ inf, jobs = update_data_master(instance)
320
+ print(f"Master update OK. Nodes={len(inf.nodes)}, Jobs={len(jobs)}, Port={instance.port}")
321
+ except Exception as e:
322
+ print(f"Master update FAILED: {e}")
323
+ print(traceback.format_exc())
324
+ else:
325
+ print("Waiting for one packet from master...")
326
+ try:
327
+ inf, jobs, _ = asyncio.run(get_data_slave(instance))
328
+ if inf is None or jobs is None:
329
+ print("No valid payload received from master.")
330
+ else:
331
+ print(f"Slave receive OK. Jobs in snapshot={len(jobs)}")
332
+ except Exception as e:
333
+ print(f"Slave receive FAILED: {e}")
334
+ print(traceback.format_exc())
335
+
336
+
277
337
  def _main():
338
+ if args.diag:
339
+ run_diagnostics(orig_instance)
340
+ return
341
+
278
342
  if args.master_only:
279
343
  assert orig_instance.is_master, "Instance is not `master`, run with `force_override` to override"
280
344
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: nodeocc
3
- Version: 1.0.24
3
+ Version: 1.0.25
4
4
  Summary: TUI application for viewing the status of GPU allocations on a Slurm cluster
5
5
  Author: Matteo Boschini
6
6
  Maintainer: Lorenzo Bonicelli, Aniello Panariello
@@ -45,24 +45,54 @@ def _node_from_sinfo(x):
45
45
  return n
46
46
 
47
47
 
48
- def _parse_gpu_models(line):
49
- line = line[0].split(',')
50
- if line[0] == '(null)':
48
+ def _parse_gpu_count(gres):
49
+ if not isinstance(gres, str):
50
+ return 0
51
+ gres = gres.strip()
52
+ if gres in ('(null)', ''):
53
+ return 0
54
+
55
+ n_gpu = 0
56
+ for token in gres.split(','):
57
+ token = token.strip()
58
+ if not token.lower().startswith('gpu'):
59
+ continue
60
+ last_field = token.split(':')[-1].split('(')[0]
61
+ match = re.search(r'\d+', last_field)
62
+ n_gpu += int(match.group(0)) if match else 1
63
+ return n_gpu
64
+
65
+
66
+ def _parse_gpu_models(gres):
67
+ if not isinstance(gres, str):
51
68
  return 'n/a'
52
- else:
53
- if len(line) == 1:
54
- return line[0].split(':')[1]
69
+ gres = gres.strip()
70
+ if gres in ('(null)', ''):
71
+ return 'n/a'
72
+
73
+ models = []
74
+ for token in gres.split(','):
75
+ token = token.strip()
76
+ if not token.lower().startswith('gpu'):
77
+ continue
78
+ parts = token.split(':')
79
+ if len(parts) >= 3:
80
+ model = parts[1]
81
+ elif len(parts) == 2:
82
+ model = 'gpu'
55
83
  else:
56
- # return line[0].split(':')[1] + ',' + line[1].split(':')[1]
57
- return ','.join([x.split(':')[1] for x in line])
84
+ model = 'n/a'
85
+ if model not in models:
86
+ models.append(model)
87
+
88
+ return ','.join(models) if models else 'n/a'
58
89
 
59
90
 
60
91
  def _read_nodes(reservations=None, pending_res=None, return_raw=False):
61
92
  sinfo_df = pd.read_csv(StringIO(os.popen(r'sinfo -N -o "%N;%G;%t;%m;%c;%f"').read()), sep=';')
62
93
  sinfo_df = _split_column(sinfo_df, 'NODELIST').drop_duplicates()
63
- sinfo_df.loc[:, 'n_gpu'] = sinfo_df['GRES'].apply(lambda x: 0 if x.split('(')[0].split(':')[0] in ('(null)', '') else int(x.split('(')[0].split(':')[-1]))
64
- # sinfo_df['m_gpu'] = sinfo_df['GRES'].str.split(':').apply(lambda x: 'n/a' if x[0] in ('(null)', '') else x[1])
65
- sinfo_df.loc[:, 'm_gpu'] = sinfo_df['GRES'].str.split(';').apply(_parse_gpu_models)
94
+ sinfo_df.loc[:, 'n_gpu'] = sinfo_df['GRES'].apply(_parse_gpu_count)
95
+ sinfo_df.loc[:, 'm_gpu'] = sinfo_df['GRES'].apply(_parse_gpu_models)
66
96
  sinfo_df['reserved'] = 'no'
67
97
  if reservations is not None:
68
98
  sinfo_df.loc[sinfo_df['NODELIST'].isin(reservations.Nodes.unique()), 'reserved'] = 'yes'
@@ -3,6 +3,8 @@ from . import slurm_list, slurm_viz, styles
3
3
 
4
4
  def update_views(stdscr, instance, filter):
5
5
  if instance.jobs is None or instance.inf is None:
6
+ instance.rens = "No data received from master yet.\nTry running with --debug or --diag."
7
+ instance.nocc = "Waiting for infrastructure snapshot..."
6
8
  return
7
9
 
8
10
  instance.rens = slurm_list.view_list(
@@ -44,12 +44,49 @@ def is_file_readable_byall(file_path):
44
44
  return os.stat(file_path).st_mode & stat.S_IROTH
45
45
 
46
46
 
47
+ def is_nodeocc_process(pid):
48
+ try:
49
+ cmdline = ' '.join(psutil.Process(int(pid)).cmdline())
50
+ return ('nodeocc' in cmdline) or ('controller.py' in cmdline)
51
+ except Exception:
52
+ return False
53
+
54
+
55
+ def port_from_portfile(portfile_path):
56
+ try:
57
+ return int(Path(portfile_path).name.split('.')[0].split('master_')[1])
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def process_has_udp_port(pid, port):
63
+ if port is None:
64
+ return False
65
+ try:
66
+ proc = psutil.Process(int(pid))
67
+ for conn in proc.connections(kind='udp'):
68
+ if conn.laddr and conn.laddr.port == int(port):
69
+ return True
70
+ except Exception:
71
+ return False
72
+ return False
73
+
74
+
47
75
  def try_open_socket_as_slave(instance, force=False):
48
76
  if len(instance.get_port_files()) == 0:
49
77
  raise Exception("No master running")
50
78
 
51
- cur_port = [int(f.split('.')[0].split('master_')[1]) for f in instance.get_port_files()
52
- if (pid := Path(f).read_text()) and psutil.pid_exists(int(pid))][0]
79
+ active_ports = []
80
+ for f in instance.get_port_files():
81
+ pid = Path(f).read_text().strip()
82
+ port = port_from_portfile(f)
83
+ if pid and is_nodeocc_process(pid) and process_has_udp_port(pid, port):
84
+ active_ports.append(port)
85
+
86
+ if len(active_ports) == 0:
87
+ raise Exception("No active master process found from port files")
88
+
89
+ cur_port = active_ports[0]
53
90
 
54
91
  if hasattr(instance, 'port'):
55
92
  # check if port file has same name
@@ -96,15 +133,20 @@ class Singleton:
96
133
  if not os.path.exists(bp):
97
134
  return True
98
135
  for f in os.listdir(bp):
99
- if 'master_' in f and f.endswith('.portfile') and is_file_writable_byall(os.path.join(bp, f)):
100
- # read pid from file
101
- pid = Path(os.path.join(bp, f)).read_text()
102
- # assert no process is running
103
- if psutil.pid_exists(int(pid)) and not force:
104
- self.log(f"Process {pid} is still running, killing self")
136
+ fp = os.path.join(bp, f)
137
+ if 'master_' in f and f.endswith('.portfile') and is_file_writable_byall(fp):
138
+ pid = Path(fp).read_text().strip()
139
+ port = port_from_portfile(fp)
140
+ if not pid:
141
+ os.remove(fp)
142
+ continue
143
+ if pid == str(os.getpid()):
144
+ os.remove(fp)
145
+ continue
146
+ if (not force) and is_nodeocc_process(pid) and process_has_udp_port(pid, port):
147
+ self.log(f"Process {pid} is still running, keeping existing master")
105
148
  return False
106
-
107
- os.system(f"rm {os.path.join(bp, f)}")
149
+ os.remove(fp)
108
150
  return True
109
151
 
110
152
  def __init__(self, args=None):
@@ -115,6 +157,7 @@ class Singleton:
115
157
  Singleton.__instance = self
116
158
 
117
159
  self.args = args
160
+ self.basepath = self.args.basepath
118
161
  self.setup_logging()
119
162
 
120
163
  self.try_open_counter = 0
@@ -141,11 +184,13 @@ class Singleton:
141
184
  self.k = -1
142
185
 
143
186
  # check if user group is 'student' or 'tesisti'
144
- user_group = grp.getgrgid(os.getgid()).gr_name
187
+ try:
188
+ user_group = grp.getgrgid(os.getgid()).gr_name
189
+ except KeyError:
190
+ user_group = ''
191
+ self.err(f"Could not resolve gid {os.getgid()} via grp.getgrgid")
145
192
  self.cur_partition = 'stud' if user_group in ['studenti', 'tesisti'] else 'prod'
146
193
 
147
- self.basepath = self.args.basepath
148
-
149
194
  self.is_master = not self.check_existing_master_running()
150
195
 
151
196
  if self.is_master or args.override:
@@ -158,9 +203,16 @@ class Singleton:
158
203
  if len(portfiles) > 0:
159
204
  for f in portfiles:
160
205
  # check if process is still running
161
- pid = Path(f).read_text()
162
- if pid and int(pid) != os.getpid():
163
- return psutil.pid_exists(int(pid))
206
+ pid = Path(f).read_text().strip()
207
+ if not pid:
208
+ continue
209
+ try:
210
+ pid_int = int(pid)
211
+ except ValueError:
212
+ continue
213
+ port = port_from_portfile(f)
214
+ if pid_int != os.getpid() and is_nodeocc_process(pid_int) and process_has_udp_port(pid_int, port):
215
+ return True
164
216
  return False
165
217
 
166
218
  def get_port_files(self):
@@ -182,11 +234,6 @@ class Singleton:
182
234
  # get pid of current process
183
235
  self.pid = os.getpid()
184
236
 
185
- # clean up old port files
186
- kill_ok = self.clean_port_files(force=force)
187
- if not kill_ok:
188
- return None
189
-
190
237
  try:
191
238
  # create file to store port with 666 permissions to file
192
239
  # file is in folder basepath/nodename/master_{port}.portfile
@@ -211,10 +258,15 @@ class Singleton:
211
258
  # check if file exists
212
259
  if filepath.exists():
213
260
  # delete file
214
- os.system(f"rm {filepath}")
261
+ os.remove(filepath)
215
262
  return None
216
263
 
217
264
  def create_socket_as_master(self, force=False):
265
+ # clean up old port files before creating the socket to avoid inconsistent state
266
+ kill_ok = self.clean_port_files(force=force)
267
+ if not kill_ok:
268
+ return None
269
+
218
270
  # create udp socket for broadcasting
219
271
  self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
220
272
 
@@ -248,8 +300,6 @@ class Singleton:
248
300
  self.sock.settimeout(6.5)
249
301
  self.log(f"Socket opened on port {self.port}")
250
302
 
251
- self.update_port_file(force=force)
252
-
253
303
  self.try_open_counter = 0
254
304
  return self.port
255
305
  except Exception as e:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes