HPS-MC
 
Loading...
Searching...
No Matches
batch.py
Go to the documentation of this file.
1"""!
2@package batch
3
4Defines a set of classes and a command-line interface for submitting batch jobs.
5
6Supported systems include serial execution locally, a multiprocessing pool,
7Slurm, LSF, and Auger.
8"""
9
10import os
11import argparse
12import subprocess
13import sys
14import logging
15import signal
16import multiprocessing
17import psutil
18from pathlib import Path
19import socket
20
21import xml.etree.ElementTree as ET
22from xml.dom import minidom
23from xml.sax.saxutils import unescape
24from distutils.spawn import find_executable
25
26from abc import ABC, abstractmethod
27
28from hpsmc.job import Job, JobStore, JobScriptDatabase
29
30logger = logging.getLogger("hpsmc.batch")
31
32RUN_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'job.py')
33
34
35class Batch(ABC):
36 """!
37 Generic batch processing interface.
38 """
39
40 def __init__(self):
41
42 parser = argparse.ArgumentParser(self.__class__.__name__,
43 epilog='Available scripts: %s' % ', '.join(JobScriptDatabase().get_script_names()))
44
45 parser.add_argument("-c", "--config-file", nargs='?', help="Config file", action='append')
46 parser.add_argument("-l", "--log-dir", nargs='?', help="Log file output dir", required=False, default=str(Path(os.getcwd(), 'logs')))
47 parser.add_argument("-d", "--run-dir", nargs='?', help="Base run dir for the jobs (must be an absolute path)", default=None)
48 parser.add_argument("-D", "--debug", action='store_true', help="Enable debug settings", required=False)
49 parser.add_argument("-o", "--check-output", action='store_true', required=False, help="Do not submit jobs where output files already exist")
50 parser.add_argument("-s", "--job-steps", type=int, default=None, required=False)
51 parser.add_argument("-r", "--job-range", nargs='?', help="Submit jobs numbers within range (e.g. '1:100')", required=False)
52 parser.add_argument("script", nargs='?', help="Name of job script")
53 parser.add_argument("jobstore", nargs='?', help="Job store in JSON format")
54 parser.add_argument("jobids", nargs="*", type=int, help="List of individual job IDs to submit (optional)")
55
56 self.parser = parser
57
58 def parse_args(self, args):
59 """! Parse command line arguments and perform setup."""
60
61 cl = self.parser.parse_args(args)
62
63 logger.debug(str(cl))
64
65 if cl.script is None:
66 raise Exception('The script is a required argument.')
67 self.script_name = cl.script # Name of script
68 script_db = JobScriptDatabase()
69 if not script_db.exists(self.script_name):
70 raise Exception('The script name is not valid: %s' % self.script_name)
71 self.script = script_db.get_script_path(self.script_name) # Path to script
72 if not os.path.isfile(self.script):
73 raise Exception('The job script does not exist: %s' % self.script)
74
75 if cl.jobstore is None:
76 raise Exception('The job store file is a required argument.')
77 if not os.path.isfile(cl.jobstore):
78 raise Exception('The job store does not exist: %s' % cl.jobstore)
79 self.jobstore = JobStore(cl.jobstore)
80
81 self.debug = cl.debug
82
83 # Set log dir which is for copying back log files generated by the batch system
84 self.log_dir = os.path.abspath(cl.log_dir)
85 logger.info('log dir: {}'.format(self.log_dir))
86 if not os.path.exists(self.log_dir):
87 os.makedirs(self.log_dir)
88 logger.info('Created log dir: {}'.format(self.log_dir))
89
90 # Set run dir which is a root directory under which job directories will be created
91 self.run_dir = cl.run_dir
92 if self.run_dir is not None:
93 logger.info('run dir: {}'.format(self.run_dir))
94 if not os.path.isabs(self.run_dir):
95 # Require that the run dir is supplied as an abs path
96 raise Exception("The run dir for batch processing must be an abs path.")
97
98 self.check_output = cl.check_output
99
100 if cl.jobids:
101 self.job_ids = list(map(int, cl.jobids))
102 else:
103 self.job_ids = []
104
105 self.job_steps = cl.job_steps
106
107 if cl.job_range:
108 toks = cl.job_range.split(':')
109 if len(toks) != 2:
110 raise ValueError('Bad format for job range: ' + cl.job_range)
111 self.start_job_num = int(toks[0])
112 self.end_job_num = int(toks[1])
113 if self.start_job_num > self.end_job_num:
114 raise ValueError("The start job number must be >= the end job num when using a range.")
115 if self.start_job_num < 0 or self.end_job_num < 0:
116 raise ValueError("The job range numbers must be > 0.")
117 else:
118 self.start_job_num = None
119 self.end_job_num = None
120
121 if cl.config_file:
122 self.config_files = list(map(os.path.abspath, cl.config_file))
123 else:
124 self.config_files = []
125
126 return cl
127
128 @abstractmethod
129 def submit_job(self, job_id):
130 """!
131 Submit a single batch job and return the batch ID.
132
133 This is abstract as each batch system will do this differently.
134
135 Some batch systems don't implement this but sub-classes should override this and make it a
136 no-op so that they can be instantiated.
137 """
138 pass
139
140 def submit(self):
141 """!
142 This is the generic batch submission function which gets a list of jobs to run based on command line
143 arguments and submits them individually. It calls the abstract submit_job() method and prints the batch
144 system ID that was returned, if any.
145 """
146 job_ids = self._get_filtered_job_ids()
147 logger.info('Submitting jobs: %s' % str(job_ids))
148 for job_id in job_ids:
149 if not self.jobstore.has_job_id(job_id):
150 raise Exception('Job ID was not found in job store: %s' % job_id)
151 job_data = self.jobstore.get_job(job_id)
152 batch_id = self.submit_job(job_id)
153 logger.info(f"Submitted job {job_id} with batch ID {str(batch_id)}")
154
155 def default_rundir(self, job_id=None):
156 if job_id is None:
157 raise Exception('Missing valid job ID')
158 return str(Path(os.getcwd(), 'scratch', str(job_id)))
159
160 def build_cmd(self, job_id):
161 """!
162 This is the basic implementation of building a command to run the job from a batch system.
163 """
164 cmd = [sys.executable, RUN_SCRIPT, 'run']
165 logfile = self._logfile(job_id)
166 cmd.extend(['-o', f"{logfile}.out",
167 '-e', f"{logfile}.err"])
168 if self.run_dir:
169 # Set the job's base run dir explicitly from user argument,
170 # appending the job number as a subdirectory.
171 job_dir = str(Path(self.run_dir, str(job_id)))
172 else:
173 # Set the job directory to the default.
174 job_dir = self.default_rundir(job_id)
175
176 logger.debug(f'job dir: {job_dir}')
177 cmd.extend(['-d', job_dir])
178
179 if len(self.config_files):
180 for cfg in self.config_files:
181 cmd.extend(['-c', cfg])
182 if self.job_steps:
183 cmd.extend(['--job-steps', str(self.job_steps)])
184 cmd.extend(['-i', str(job_id)])
185 cmd.append(self.script)
186 cmd.append(os.path.abspath(self.jobstore.path))
187 logger.debug("Job command: %s" % " ".join(cmd))
188 return cmd
189
190 def _logfile(self, job_id):
191 """!
192 Get the base name of a log file for the job.
193 """
194 return os.path.abspath(os.path.join(self.log_dir, 'job.%s' % str(job_id)))
195
196 @staticmethod
198 """!
199 Check if all output files exist for the given job. This is not the job ID but the full JSON job data.
200
201 Return False when first missing output is found.
202 """
203 for src, dest in job["output_files"].items():
204 if not os.path.isfile(os.path.join(job["output_dir"], dest)):
205 logger.debug('Job output does not exist: %s -> %s' % (src, dest))
206 return False
207 return True
208
210 """!
211 Get a list of job IDs to submit based on parsed command line options and whether output files are being checked.
212 """
213 submit_ids = self.jobstore.get_job_ids()
214 logger.debug('Initial pre-filtered job IDs: {}'.format(str(submit_ids)))
215 if self.start_job_num:
216 submit_ids = [job_id for job_id in submit_ids
217 if int(job_id) >= self.start_job_num and int(job_id) <= self.end_job_num]
218 elif len(self.job_ids):
219 submit_ids = self.job_ids
220 logger.debug('job IDs after range check: {}'.format(str(submit_ids)))
221 if self.check_output:
222 submit_ids = self._job_ids_missing_output(submit_ids)
223 logger.info('job IDs after output file check: {}'.format(str(submit_ids)))
224 return submit_ids
225
226 def _job_ids_missing_output(self, job_ids):
227 """! Get a list of IDs for jobs that are missing output files."""
228 return [job_id for job_id in job_ids if not self._outputs_exist(self.jobstore.get_job(job_id))]
229
230
231class BatchSystem(Batch, ABC):
232 """!
233 Represents a batch processing system that requires submission like Slurm or Auger.
234
235 This subclasses Batch because it adds a number of different parameters which do not apply to all the
236 batch system types (namely Pool and Local).
237 """
238
239 def __init__(self):
240
241 super().__init__()
242
243 self.parser.add_argument("-q", "--queue", nargs='?',
244 help="Job queue or partition",
245 required=False)
246 self.parser.add_argument("-W", "--job-length", type=int, help="Max job length in hours", required=False, default=4)
247 self.parser.add_argument("-m", "--memory", type=int, help="Max job memory allocation in MB", default=2000)
248 self.parser.add_argument("-f", "--diskspace", type=int, help="Disk space needed for job in GB", default=20)
249 self.parser.add_argument("-e", "--email", nargs='?', help="Email address for job notifications", required=False)
250
251 self.parser.add_argument("-O", "--os", nargs='?', help="Operating system of batch nodes (Auger and LSF)")
252
253 # Set site based on FQDN
254 self.site = BatchSystem._site()
255
256 def parse_args(self, args):
257 """! Parse command line arguments and perform setup."""
258
259 cl = super().parse_args(args)
260
261 self.email = cl.email
262 self.queue = cl.queue
263 self.os = cl.os
264 self.memory = cl.memory
265 self.diskspace = cl.diskspace
266 self.job_length = cl.job_length
267
268 return cl
269
270 @staticmethod
271 def _site():
272 fqdn = socket.getfqdn()
273 site = None
274 if 'slac.stanford.edu' in fqdn:
275 site = 'slac'
276 elif 'jlab.org' in fqdn:
277 site = 'jlab'
278 return site
279
280
282 """! Submit LSF batch jobs."""
283
284 def __init__(self):
285 super().__init__()
286
287 def parse_args(self, args):
288 super().parse_args(args)
289 os.environ['LSB_JOB_REPORT_MAIL'] = 'Y' if self.email else 'N'
290
291 def build_cmd(self, job_id):
292
293 log_file = os.path.abspath(os.path.join(self.log_dir, 'job.%s.log' % str(job_id)))
294
295 queue = self.queue
296 if queue is None:
297 queue = 'long'
298
299 if self.os is not None:
300 lsf_os = self.os
301 else:
302 lsf_os = 'centos7'
303
304 cmd = ['bsub',
305 '-W', str(self.job_lengthjob_length) + ':0',
306 '-q', queue,
307 '-R', lsf_os,
308 '-o', log_file,
309 '-e', log_file]
310
311 if self.email:
312 cmd.extend(['-u', self.email])
313
314 cmd.extend(super().build_cmd(self, job_id))
315
316 return cmd
317
318 def submit_job(self, job_id):
319 cmd = self.build_cmdbuild_cmd(job_id)
320 logger.info('Submitting job %s to LSF with command: %s' % (job_id, ' '.join(cmd)))
321 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
322 out, err = proc.communicate()
323 if err is not None and len(err):
324 logger.warning(err)
325 tokens = out.decode().split(" ")
326 if tokens[0] != 'Job':
327 raise Exception('Unexpected output from bsub command: %s' % out)
328 batch_id = int(tokens[1].replace('<', '').replace('>', ''))
329 return batch_id
330
331
333 """! Submit Slurm batch jobs."""
334
335 def __init__(self):
336
337 super().__init__()
338
339 self.parser.add_argument("-S", "--sh-dir", nargs='?', help="Directory to hold generated shell scripts for Slurm", default=str(Path(os.getcwd(), 'sh')))
340 self.parser.add_argument("-E", "--env", nargs='?', help="Full path to env setup script", required=False, default=None)
341 self.parser.add_argument("-A", "--account", nargs='?', help="Account name for s3df slurm jobs.", required=False, default=None)
342
343 def parse_args(self, args):
344
345 cl = super().parse_args(args)
346
347 # Set Slurm env script
348 self.env = cl.env
349 self.account = cl.account
350
351 # Set Slurm scripts dir
352 self.sh_dir = os.path.abspath(cl.sh_dir)
353 logger.info('Slurm sh dir: {}'.format(self.sh_dir))
354 if not os.path.exists(self.sh_dir):
355 os.makedirs(self.sh_dir)
356 logger.info('Created Slurm sh dir: {}'.format(self.sh_dir))
357
358 def default_rundir(self, job_id=None):
359 """!
360 Override the basic implementation for getting the default run directory.
361 """
362 if self.sitesite == 'slac':
363 run_dir = '$LSCRATCH'
364 elif self.sitesite == 'jlab':
365 run_dir = '/scratch/slurm/$SLURM_JOBID'
366 else:
367 run_dir = os.getcwd() + "/scratch/$SLURM_JOBID"
368 return run_dir
369
370 def _default_queue(self):
371 queue = self.queue
372 if queue is None:
373 if self.sitesite == 'slac':
374 queue = 'shared'
375 elif self.sitesite == 'jlab':
376 queue = 'ifarm'
377 else:
378 raise Exception('No queue name was provided.')
379 return queue
380
381 def _sbatch(self, job_id):
382 log_file = self._logfile(job_id)
383 sbatch_cmd = ['sbatch',
384 '--time=%s' % (str(self.job_lengthjob_length) + ':00:00'),
385 '--mem=%sM' % self.memorymemory,
386 '--job-name=%i_%s' % (job_id, self.script_namescript_name),
387 '--output=%s.out' % log_file,
388 '--error=%s.err' % log_file]
389 if self.queue:
390 sbatch_cmd.extend([f'--partition={self.queue}'])
391 if self.account:
392 sbatch_cmd.extend([f'--account={self.account}'])
393 if self.email:
394 sbatch_cmd.extend([f'--mail-user={self.email}',
395 f'--mail-type=ALL'])
396 return sbatch_cmd
397
398 def _sh_filename(self, job_id):
399 return self.sh_dir + '/job.%i.sh' % job_id
400
401 def build_cmd(self, job_id):
402 """!
403 Wrap submission of Slurm jobs using a generated script.
404 """
405
406 # Get the sbatch command
407 cmd = self._sbatch(job_id)
408
409 # Get name of shell script to generate
410 sh_filename = self._sh_filename(job_id)
411
412 # Build the basic job command for execution
413 job_cmd = super().build_cmd(job_id)
414 if self.run_dir is None:
415 # The superclass will have already set this if the user provided an
416 # explicit run dir. Here we set a default scratch directory if none
417 # was given.
418 job_cmd.extend(['-d', self.default_rundirdefault_rundir()])
419
420 # Write the job submission script out
421 self._write_job_script(sh_filename, job_cmd)
422
423 # Append job run script to Slurm command
424 cmd.append(sh_filename)
425
426 return cmd
427
428 def _write_job_script(self, sh_filename, job_cmd):
429 """!
430 Write the shell script for Slurm job submission using the 'sbatch' command.
431 """
432
433 script_lines = ['#!/bin/bash',
434 '']
435 if self.env:
436 script_lines.append(f'source {self.env}')
437 script_lines.extend(['echo Start time: `date`',
438 'echo PWD=`pwd`',
439 'echo ---- Start Environment ----',
440 'env | sort',
441 'echo ---- End Environment ----',
442 'time ' + ' '.join(job_cmd),
443 'echo End time: `date`'])
444
445 logger.debug("Slurm submission script:\n" + str(script_lines))
446
447 with open(sh_filename, 'w') as sh_file:
448 for script_line in script_lines:
449 sh_file.write(script_line + '\n')
450
451 logger.debug('Wrote Slurm submission script to: '.format(str(Path(self.sh_dir, sh_filename))))
452
453 def submit_job(self, job_id):
454 cmd = self.build_cmdbuild_cmd(job_id)
455 logger.info('Submitting job %s to Slurm with command: %s' % (job_id, ' '.join(cmd)))
456 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
457 out, err = proc.communicate()
458 if err is not None and len(err):
459 logger.warning(err)
460 tokens = out.decode().split(" ")
461 if tokens[0] != 'Submitted':
462 raise Exception('Unexpected output from sbatch command: %s' % out)
463 batch_id = int(tokens[3].replace('<', '').replace('>', ''))
464 return batch_id
465
466
468 """!
469 Submit Auger batch jobs.
470
471 Auger itself is actually deprecated and unavailable but its submission XML format is supported by
472 the Swif class (see below).
473 """
474
475 def __init__(self):
476
477 super().__init__()
478
479 self.setup_script = find_executable('hps-mc-env.csh')
480
481 if not self.setup_script:
482 raise Exception("Failed to find 'hps-mc-env.csh' in environment.")
483
484 def submit_job(self, job_id):
485 """!
486 Make this a no-op. Auger is a bit of a special case in terms of how batch submission works with a
487 generated XML file including all job IDs, so we do not implement single job submission.
488 """
489 pass
490
491 def submit(self):
492 """!
493 Batch submission method for Auger.
494
495 This differs from some of the other systems in that it doesn't loop over individual
496 job IDs. Instead a single XML file is submitted for all the jobs at once.
497 """
498 xml_filename = self._create_job_xml() # write request to XML file
499 auger_ids = self._jsub(xml_filename) # execute jsub to submit jobs
500 logger.info("Submitted Auger jobs: %s" % str(auger_ids))
501
503 job_ids = self._get_filtered_job_ids()
504 logger.info('Submitting jobs: %s' % str(job_ids))
505 req = self._create_req(self.script_name) # create request XML header
506 for job_id in job_ids:
507 if not self.jobstore.has_job_id(job_id):
508 raise Exception('Job ID was not found in job store: %s' % job_id)
509 job_params = self.jobstore.get_job(job_id)
510 if self.check_output and Batch._outputs_exist(job_params):
511 logger.warning("Skipping Auger submission for job "
512 "because outputs already exist: %d" % job_id)
513 else:
514 self._add_job(req, job_params) # add job to request
515 return self._write_req(req) # write request to file
516
517 def _jsub(self, xml_filename):
518 cmd = ['jsub', '-xml', xml_filename]
519 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
520 out, err = proc.communicate()
521 auger_ids = self._get_auger_ids(out)
522 return auger_ids
523
524 def _get_auger_ids(self, out):
525 auger_ids = []
526 for line in out.splitlines():
527 if line.strip().startswith(b'<jsub>'):
528 j = ET.fromstring(line)
529 for req in j.getchildren():
530 for child in req.getchildren():
531 if child.tag == 'jobIndex':
532 auger_id = int(child.text)
533 auger_ids.append(auger_id)
534 elif child.tag == 'error':
535 # Submission failed so raise an exception with the error msg
536 raise Exception(child.text)
537 break
538 return auger_ids
539
540 def _write_req(self, req, filename='temp.xml'):
541 pretty = unescape(minidom.parseString(ET.tostring(req)).toprettyxml(indent=" "))
542 with open(filename, 'w') as f:
543 f.write(pretty)
544 return f.name
545
546 def _create_req(self, req_name):
547 req = ET.Element("Request")
548 name_elem = ET.SubElement(req, "Name")
549 name_elem.set("name", req_name)
550 prj = ET.SubElement(req, "Project")
551 prj.set("name", "hps")
552 trk = ET.SubElement(req, "Track")
553 if self.debug:
554 # Queue arg is not used when debug flag is active.
555 trk.set("name", "debug")
556 else:
557 # Queue name is used to set job track.
558 queue = 'simulation'
559 if self.queue is not None:
560 queue = self.queue
561 trk.set("name", queue)
562 if self.emailemail:
563 email = ET.SubElement(req, "Email")
564 email.set("email", self.emailemail)
565 email.set("request", "true")
566 email.set("job", "true")
567 mem = ET.SubElement(req, "Memory")
568 mem.set("space", str(self.memorymemory))
569 mem.set("unit", "MB")
570 disk = ET.SubElement(req, "DiskSpace")
571 disk.set("space", str(self.diskspacediskspace))
572 disk.set("unit", "GB")
573 limit = ET.SubElement(req, "TimeLimit")
574 limit.set("time", str(self.job_lengthjob_length))
575 limit.set("unit", "hours")
576 os_elem = ET.SubElement(req, "OS")
577 if self.os is not None:
578 auger_os = self.os
579 else:
580 auger_os = 'el9'
581 os_elem.set("name", auger_os)
582 return req
583
584 def build_cmd(self, job_id):
585 cmd = [sys.executable, RUN_SCRIPT, 'run']
587 for cfg in self.config_filesconfig_files:
588 cmd.extend(['-c', cfg])
589 if self.job_stepsjob_steps is not None:
590 cmd.extend(['--job-steps', str(self.job_stepsjob_steps)])
591 cmd.extend(['-i', str(job_id)])
592 cmd.append(self.scriptscript)
593 cmd.append(os.path.abspath(self.jobstore.path))
594 logger.debug("Job command: %s" % " ".join(cmd))
595 return cmd
596
597 def _create_job(self, params):
598 """! Needed for resolving ptag output sources."""
599 j = Job()
600 j.script = self.scriptscript
601 j._load_params(params)
602 j._load_script()
603 return j
604
605 def _add_job(self, req, job_params):
606 job = ET.SubElement(req, "Job")
607 job_id = job_params['job_id']
608 year = '' # /todo change to number
609 if 'year' in job_params.keys():
610 year = job_params['year']
611
612 if 'input_files' in list(job_params.keys()):
613 inputfiles = job_params['input_files']
614 for src, dest in inputfiles.items():
615 if not src.startswith('http'):
616 input_elem = ET.SubElement(job, "Input")
617 input_elem.set("dest", dest)
618 if src.startswith("/mss"):
619 src_file = "mss:%s" % src
620 else:
621 src_file = src
622 input_elem.set("src", src_file)
623 else:
624 logger.warning("http input file will not be included in XML job descr: {}".format(src))
625 outputfiles = job_params["output_files"]
626 outputdir = job_params["output_dir"]
627 # outputdir = os.path.realpath(outputdir)
628 j = self._create_job(job_params)
629 for src, dest in outputfiles.items():
630 output_elem = ET.SubElement(job, "Output")
631 res_src = j.resolve_output_src(src)
632 output_elem.set("src", res_src)
633 dest_file = os.path.abspath(os.path.join(outputdir, dest))
634 if dest_file.startswith("/mss"):
635 dest_file = "mss:%s" % dest_file
636 logger.debug('Auger dest file: {} -> {}'.format(src, dest))
637 output_elem.set("dest", dest_file)
638
639 job_name = ET.SubElement(job, "Name")
640 job_name.set("name", '%ihps%i' % (year, job_id))
641
642 job_err = ET.SubElement(job, "Stderr")
643 stdout_file = os.path.abspath(os.path.join(self.log_dir, "job.%d.out" % job_id))
644 stderr_file = os.path.abspath(os.path.join(self.log_dir, "job.%d.err" % job_id))
645 job_err.set("dest", stderr_file)
646 job_out = ET.SubElement(job, "Stdout")
647 job_out.set("dest", stdout_file)
648
649 cmd = ET.SubElement(job, "Command")
650 cmd_lines = []
651 cmd_lines.append("<![CDATA[")
652
653 cmd_lines.append('pwd;\n')
654 cmd_lines.append('env | sort;\n')
655 cmd_lines.append('ls -lart;\n')
656 cmd_lines.append("source %s;\n" % os.path.realpath(self.setup_script))
657 cmd_lines.append("source %s/bin/jlab-env.csh;\n" % os.getenv('HPSMC_DIR'))
658
659 job_cmd = self.build_cmdbuild_cmd(job_id)
660
661 # Write log file locally so it can be copied back with Output element
662 # log_file = 'job.%d.log' % job_id
663 # job_cmd.extend(['-l', '$PWD/%s' % log_file])
664 # log_out_elem = ET.SubElement(job, "Output")
665 # log_out_elem.set('src', log_file)
666 # log_out_elem.set('dest', os.path.join(self.log_dir, log_file))
667
668 cmd_lines.extend(job_cmd)
669 cmd_lines.append(';\n')
670
671 cmd_lines.append('ls -lart; \n')
672
673 cmd_lines.append("]]>")
674
675 # logger.debug(cmd_lines)
676
677 cmd.text = ' '.join(cmd_lines)
678
679
680class Swif(Auger):
681 """!
682 Submit jobs to the 'swif2' workflow system at JLAB.
683
684 The legacy 'swif2 add-jsub' command (which consumed an Auger XML request file) has been deprecated by
685 JLAB and is being removed. This class now builds the workflow with 'swif2 create' and adds each job with
686 'swif2 add-job', mapping the parameters that the parent Auger class used to encode in XML directly onto
687 add-job command-line flags, then starts the workflow with 'swif2 run'.
688
689 Note that 'swif2 create' fails if a workflow of the same name already exists. Existing workflows generated
690 by this class should be fully cancelled and removed before resubmitting under the same name.
691 """
692
693
695 DEFAULT_PROJECT = 'hallb-pro'
696
697
698 DEFAULT_SITE = 'jlab/enp'
699
700
701 DEFAULT_OS = 'el9'
702
703
704 VALID_PARTITIONS = frozenset(['production', 'ifarm', 'priority', 'jupyter', 'gpu'])
705
706
708 LEGACY_TRACK_PARTITIONS = {
709 'simulation': 'production',
710 'analysis': 'production',
711 'debug': 'priority',
712 'one_pass': 'production',
713 }
714
715 def __init__(self):
716
717 super().__init__()
718
719 self.parser.add_argument("-w", "--workflow", nargs='?', help="Name of swif2 workflow", required=False)
720 self.parser.add_argument("-P", "--project", nargs='?', help="Project/allocation (swif2 add-job -account)",
721 required=False, default=Swif.DEFAULT_PROJECT)
722 self.parser.add_argument("--site", nargs='?', help="swif2 site name (swif2 create -site-name)",
723 required=False, default=Swif.DEFAULT_SITE)
724 self.parser.add_argument("--max-concurrent", type=int,
725 help="Max concurrent dispatched jobs (swif2 create -max-concurrent)",
726 required=False, default=None)
727 self.parser.add_argument("--recreate", action='store_true',
728 help="If the workflow already exists, cancel and recreate it (destructive)")
729
730 def parse_args(self, args):
731 cl = super().parse_args(args)
732 if cl.workflow:
733 self.workflow = cl.workflow
734 else:
735 self.workflow = self.script_name
736 self.project = cl.project
737 self.swif_site = cl.site
738 self.max_concurrent = cl.max_concurrent
739 self.recreate = cl.recreate
740 logger.debug(f'swif workflow name set to: {self.workflow}')
741 return cl
742
743 def _partition(self):
744 """!
745 Resolve the swif2 (slurm) partition. The old Auger "track" (debug mode -> 'debug', else the queue, else
746 the 'production' default) is validated against the farm's real partitions: legacy track names are
747 remapped to a valid partition with a warning, and anything else unknown is a hard error.
748 @return a valid slurm partition name
749 """
750 if self.debug:
751 track = 'debug'
752 elif self.queue is not None:
753 track = self.queue
754 else:
755 track = 'production'
756
757 if track in Swif.VALID_PARTITIONS:
758 return track
759 if track in Swif.LEGACY_TRACK_PARTITIONS:
760 partition = Swif.LEGACY_TRACK_PARTITIONS[track]
761 logger.warning("'%s' is a legacy Auger track, not a slurm partition; using partition '%s'. "
762 "Pass -q/--queue with one of (%s) to set it explicitly."
763 % (track, partition, ', '.join(sorted(Swif.VALID_PARTITIONS))))
764 return partition
765 raise Exception("Invalid slurm partition '%s'. Valid partitions are: %s."
766 % (track, ', '.join(sorted(Swif.VALID_PARTITIONS))))
767
768 def submit(self):
769
770 logger.info("Submitting swif workflow: {}".format(self.workflow))
771
772 # Create the workflow. add-job (unlike the old add-jsub) requires the workflow to already exist.
773 self._create_workflow()
774
775 # Add each job to the workflow with its own 'swif2 add-job' invocation.
776 job_ids = self._get_filtered_job_ids()
777 logger.info('Submitting jobs: %s' % str(job_ids))
778 for job_id in job_ids:
779 if not self.jobstore.has_job_id(job_id):
780 raise Exception('Job ID was not found in job store: %s' % job_id)
781 job_params = self.jobstore.get_job(job_id)
782 if self.check_output and Batch._outputs_exist(job_params):
783 logger.warning("Skipping swif submission for job "
784 "because outputs already exist: %d" % job_id)
785 continue
786 self._run_swif2(self._add_job_cmd(job_params))
787
788 # Start releasing jobs to the batch system.
789 self._run_swif2(['run', self.workflow])
790
791 def _run_swif2(self, args, check=True, quiet=False):
792 """!
793 Run a single 'swif2' subcommand, echoing its (non-empty) output.
794 @param args list of arguments following the 'swif2' executable
795 @param check if True, raise on a non-zero exit; if False, return the result for the caller to inspect
796 @param quiet if True, do not echo the command output (the caller handles it based on the outcome)
797 @return a (returncode, output) tuple
798 """
799 cmd = ['swif2'] + args
800 logger.debug('swif2 command: %s' % ' '.join(cmd))
801 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
802 out = proc.communicate()[0]
803 text = out.decode()
804 if not quiet:
805 Swif._echo(text)
806 if check and proc.returncode:
807 raise Exception("swif2 command failed (exit %d): %s" % (proc.returncode, ' '.join(cmd)))
808 return proc.returncode, text
809
810 @staticmethod
811 def _echo(text):
812 """! Print the non-empty lines of some swif2 output."""
813 printed = "".join([s for s in text.strip().splitlines(True) if s.strip()])
814 if printed:
815 print(printed)
816
817 def _create_cmd(self):
818 # The Auger request set Project/Track/OS on the request as a whole. In swif2 the project/allocation
819 # and track/OS are applied per-job on add-job (see _add_job_cmd); the site is set here on create.
820 cmd = ['create', '-workflow', self.workflow]
821 if self.swif_site:
822 cmd += ['-site-name', self.swif_site]
823 if self.max_concurrent is not None:
824 cmd += ['-max-concurrent', str(self.max_concurrent)]
825 return cmd
826
828 """!
829 Ensure the swif2 workflow exists (add-job requires it to already exist).
830
831 If a workflow of the same name already exists this is not an error: the create is skipped and jobs are
832 added to the existing workflow. Pass --recreate to cancel and recreate it instead. Any other create
833 failure is fatal. The create output is captured and only echoed on success or a genuine failure, so the
834 benign 'already exists' case does not surface swif's scary error text.
835 """
836 returncode, text = self._run_swif2(self._create_cmd(), check=False, quiet=True)
837 if returncode == 0:
838 Swif._echo(text)
839 return
840 if 'already exists' in text:
841 if self.recreate:
842 logger.warning("Workflow '%s' already exists; cancelling and recreating it (--recreate)."
843 % self.workflow)
844 self._run_swif2(['cancel', '-workflow', self.workflow], check=False)
845 self._run_swif2(self._create_cmd()) # recreate; fatal if the name still cannot be reused
846 return
847 print("Workflow '%s' already exists; adding jobs to the existing workflow "
848 "(pass --recreate to cancel and recreate it instead)." % self.workflow)
849 return
850 raise Exception("swif2 create failed for workflow '%s':\n%s" % (self.workflow, text))
851
852 def _add_job_cmd(self, job_params):
853 """!
854 Build the 'swif2 add-job' argument list for a single job.
855
856 This reproduces, as add-job flags, the per-job information the parent Auger class used to write into the
857 request XML: resource limits, inputs, outputs, stdout/stderr, the job name, and the command to run.
858 @param job_params raw job data dict from the job store
859 @return list of arguments following the 'swif2' executable
860 """
861 job_id = job_params['job_id']
862 year = job_params.get('year', '')
863
864 cmd = ['add-job', '-workflow', self.workflow]
865
866 # Unique per-workflow job name (mirrors the old Auger <Name> element).
867 cmd += ['-name', '%shps%s' % (str(year), str(job_id))]
868
869 # Resource limits (Auger Memory/DiskSpace/TimeLimit + one core).
870 cmd += ['-cores', '1']
871 cmd += ['-ram', '%dMB' % self.memorymemory]
872 cmd += ['-disk', '%dGB' % self.diskspacediskspace]
873 cmd += ['-time', '%dhours' % self.job_lengthjob_length]
874
875 # Project/allocation -> account (add-job passes this to sbatch as '-A'). Auger encoded this as the
876 # request <Project>; the working JLAB value is "hallb-pro" (see DEFAULT_PROJECT).
877 cmd += ['-account', self.project]
878
879 # Auger "Track" -> swif2 '-partition'; request OS -> '-constraint' (both confirmed against live jobs:
880 # partition=production, constraint=el9 by default).
881 cmd += ['-partition', self._partition()]
882 cmd += ['-constraint', self.os if self.os is not None else Swif.DEFAULT_OS]
883
884 # Redirect stdout/stderr to the log dir (Auger <Stdout>/<Stderr>).
885 stdout_file = os.path.abspath(os.path.join(self.log_dir, "job.%d.out" % job_id))
886 stderr_file = os.path.abspath(os.path.join(self.log_dir, "job.%d.err" % job_id))
887 cmd += ['-stdout', stdout_file]
888 cmd += ['-stderr', stderr_file]
889
890 # Inputs: '-input <local-file> <remote-uri>' (Auger <Input dest=... src=...>).
891 for dest, remote in self._job_inputs(job_params):
892 cmd += ['-input', dest, remote]
893
894 # Outputs: '-output <local-file> <remote-uri>' (Auger <Output src=... dest=...>).
895 for local, remote in self._job_outputs(job_params):
896 cmd += ['-output', local, remote]
897
898 # The command to run: an executable tcsh wrapper script, passed as a single positional argument.
899 # An inline command string does not survive: swif truncates it at the first ';' (only 'pwd' ran, empty
900 # logs otherwise), and '-shell /bin/tcsh <string>' ran nothing at all. A script path has no shell
901 # metacharacters for swif to truncate, and a bare single-token command is executed correctly.
902 cmd += [self._write_job_script(job_id)]
903
904 return cmd
905
906 def _job_inputs(self, job_params):
907 """!
908 Yield (local_dest, remote_uri) pairs for a job's inputs, applying swif2 URI schemes.
909 http inputs are skipped (they are not staged by swif), matching the previous Auger behavior.
910 """
911 pairs = []
912 for src, dest in job_params.get('input_files', {}).items():
913 if src.startswith('http'):
914 logger.warning("http input file will not be staged by swif2: {}".format(src))
915 continue
916 pairs.append((dest, self._swif_uri(src)))
917 return pairs
918
919 def _job_outputs(self, job_params):
920 """!
921 Yield (local_src, remote_uri) pairs for a job's outputs, resolving ptag sources and applying schemes.
922 """
923 outputdir = job_params["output_dir"]
924 j = self._create_job(job_params) # needed to resolve ptag output sources
925 pairs = []
926 for src, dest in job_params["output_files"].items():
927 local_src = j.resolve_output_src(src)
928 dest_file = os.path.abspath(os.path.join(outputdir, dest))
929 pairs.append((local_src, self._swif_uri(dest_file)))
930 return pairs
931
932 @staticmethod
933 def _swif_uri(path):
934 """!
935 Convert a filesystem path to a swif2 remote URI. Tape paths get the 'mss:' scheme; ordinary filesystem
936 paths (e.g. /lustre, /work) are left bare, matching both the old Auger behavior and the remote URIs
937 observed on live JLAB workflows (swif canonicalizes tape URIs to 'mss:jlab:...' on its own). Paths that
938 already carry a scheme are returned unchanged.
939 """
940 if path.startswith('mss:') or path.startswith('file:'):
941 return path
942 if path.startswith('/mss'):
943 return 'mss:%s' % path
944 return path
945
946 def _write_job_script(self, job_id):
947 """!
948 Write an executable tcsh wrapper script that sets up the (c-shell) environment and runs the job, and
949 return its absolute path.
950
951 The script contents reproduce the old Auger <Command> CDATA block. It is written to the log dir (on
952 /farm_out, which is shared with the compute nodes) and passed to add-job as the job command; passing a
953 script path avoids the shell metacharacters that swif truncates out of an inline command string.
954 @param job_id job ID
955 @return absolute path to the generated script
956 """
957 script_path = os.path.abspath(os.path.join(self.log_dir, 'swif_job.%d.csh' % job_id))
958 job_cmd = self.build_cmdbuild_cmd(job_id)
959 lines = ['#!/bin/tcsh',
960 'pwd',
961 'env | sort',
962 'ls -lart',
963 'source %s' % os.path.realpath(self.setup_scriptsetup_script),
964 'source %s/bin/jlab-env.csh' % os.getenv('HPSMC_DIR'),
965 ' '.join(job_cmd),
966 'ls -lart']
967 with open(script_path, 'w') as f:
968 f.write('\n'.join(lines) + '\n')
969 os.chmod(script_path, 0o755)
970 logger.debug('Wrote swif2 job script: %s' % script_path)
971 return script_path
972
973
975 """!
976 Run local batch jobs sequentially.
977 """
978
979 def __init__(self):
980 super().__init__()
981
982 def submit_job(self, job_id):
983 """! Run a single job locally."""
984 cmd = self.build_cmd(job_id)
985 if self.submit:
986 logger.info(f"Executing local job: {job_id}")
987 proc = subprocess.Popen(cmd, shell=False)
988 proc.communicate()
989 if proc.returncode:
990 logger.error(f"Local execution of {job_id} returned error code: {proc.returncode}")
991
992
993# Queue used to keep track of processes created by batch pool.
994mp_queue = multiprocessing.Queue()
995
996
998 """! Run the command in a new process whose PID is added to a global MP queue."""
999 try:
1000 sys.stdout.flush()
1001 proc = subprocess.Popen(cmd, preexec_fn=os.setsid)
1002 mp_queue.put(proc.pid)
1003 proc.wait()
1004 returncode = proc.returncode
1005 except subprocess.CalledProcessError as e:
1006 logger.error(str(e))
1007 sys.stdout.flush()
1008 pass
1009 return returncode
1010
1011
1012def is_running(proc):
1013 """!
1014 Check if a system process looks like it is still running.
1015 """
1016 return proc.status() in [psutil.STATUS_RUNNING,
1017 psutil.STATUS_SLEEPING,
1018 psutil.STATUS_DISK_SLEEP,
1019 psutil.STATUS_IDLE]
1020
1021
1023 """!
1024 Kill processes in the multiprocessing queue if the jobs are canceled.
1025 """
1026
1027 def __init__(self, mp_queue):
1028 self.mp_queue = mp_queue
1029
1030 def __enter__(self):
1031 return self
1032
1033 def __exit__(self, type, val, tb):
1034 """! Kill processes on exit."""
1035 while True:
1036 pid = mp_queue.get()
1037 try:
1038 parent = psutil.Process(pid)
1039 for child in parent.children(recursive=True):
1040 if is_running(child):
1041 print('Killing running process: %d' % child.pid)
1042 child.kill()
1043 if is_running(parent):
1044 parent.kill()
1045 except Exception as e:
1046 # This probably just means it already finished.
1047 pass
1048
1049 if mp_queue.empty():
1050 break
1051
1052
1054 """!
1055 Run a set of jobs in a local multiprocessing pool using Python's multiprocessing module.
1056
1057 The number of processes to spawn can be provided using the '-p' argument.
1058 """
1059
1060 # Max wait in seconds when getting results
1061 max_wait = 999999
1062
1063 def __init__(self):
1064 super().__init__()
1065 self.parser.add_argument("-p", "--pool-size", type=int,
1066 help="Job pool size (only applicable when running pool)", required=False,
1067 default=multiprocessing.cpu_count())
1068
1069 def submit_job(self, job_id):
1070 """!
1071 Make this a no-op as we do not implement single job submission for the processing pool.
1072 """
1073 pass
1074
1075 def parse_args(self, args):
1076 cl = super().parse_args(args)
1077 self.pool_size = int(cl.pool_size)
1078 return cl
1079
1080 def submit(self):
1081 """! Submit jobs to a local processing pool.
1082
1083 This method will not return until all jobs are finished or execution
1084 is interrupted.
1085 """
1086
1087 cmds = []
1088 for job_id in self._get_filtered_job_ids():
1089 cmd = self.build_cmd(job_id)
1090 cmds.append(cmd)
1091
1092 # logger.debug('Running job commands in pool ...')
1093 # logger.debug('\n'.join([' '.join(cmd) for cmd in cmds]))
1094
1095 if not len(cmds):
1096 raise Exception('No job IDs found to submit')
1097
1098 # Run jobs in an MP pool and cleanup child processes on exit
1099 with KillProcessQueue(mp_queue):
1100 original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
1101 pool = multiprocessing.Pool(self.pool_size)
1102 signal.signal(signal.SIGINT, original_sigint_handler)
1103 try:
1104 logger.info("Running %d jobs in pool ..." % len(cmds))
1105 res = pool.map_async(run_job_pool, cmds)
1106 # timeout must be properly set, otherwise tasks will crash
1107 logger.info("Pool results: " + str(res.get(Pool.max_wait)))
1108 logger.info("Normal termination")
1109 pool.close()
1110 pool.join()
1111 except KeyboardInterrupt:
1112 logger.fatal("Caught KeyboardInterrupt, terminating workers")
1113 pool.terminate()
1114 except Exception as e:
1115 logger.fatal("Caught Exception '%s', terminating workers" % (str(e)))
1116 pool.terminate()
1117 except BaseException: # catch *all* exceptions
1118 e = sys.exc_info()[0]
1119 logger.fatal("Caught non-Python Exception '%s'" % (e))
1120 pool.terminate()
1121
1122
1123if __name__ == '__main__':
1124 system_dict = {
1125 "lsf": LSF,
1126 "slurm": Slurm,
1127 "auger": Auger,
1128 "local": Local,
1129 "pool": Pool,
1130 "swif": Swif
1131 }
1132 if len(sys.argv) > 1:
1133 system = sys.argv[1].lower()
1134 if system not in list(system_dict.keys()):
1135 raise Exception(f"The batch system {system} is not valid.")
1136 batch = system_dict[system]()
1137 args = sys.argv[2:]
1138 batch.parse_args(args)
1139 batch.submit()
1140 else:
1141 print("Usage: batch.py [system] [args]")
1142 print(" Available systems: {}".format(', '.join(list(system_dict.keys()))))
Submit Auger batch jobs.
Definition batch.py:467
_create_req(self, req_name)
Definition batch.py:546
build_cmd(self, job_id)
This is the basic implementation of building a command to run the job from a batch system.
Definition batch.py:584
submit_job(self, job_id)
Make this a no-op.
Definition batch.py:484
_create_job(self, params)
Needed for resolving ptag output sources.
Definition batch.py:597
_get_auger_ids(self, out)
Definition batch.py:524
submit(self)
Batch submission method for Auger.
Definition batch.py:491
_create_job_xml(self)
Definition batch.py:502
_add_job(self, req, job_params)
Definition batch.py:605
_write_req(self, req, filename='temp.xml')
Definition batch.py:540
_jsub(self, xml_filename)
Definition batch.py:517
Represents a batch processing system that requires submission like Slurm or Auger.
Definition batch.py:231
parse_args(self, args)
Parse command line arguments and perform setup.
Definition batch.py:256
Generic batch processing interface.
Definition batch.py:35
_get_filtered_job_ids(self)
Get a list of job IDs to submit based on parsed command line options and whether output files are bei...
Definition batch.py:209
build_cmd(self, job_id)
This is the basic implementation of building a command to run the job from a batch system.
Definition batch.py:160
submit_job(self, job_id)
Submit a single batch job and return the batch ID.
Definition batch.py:129
submit(self)
This is the generic batch submission function which gets a list of jobs to run based on command line ...
Definition batch.py:140
default_rundir(self, job_id=None)
Definition batch.py:155
parse_args(self, args)
Parse command line arguments and perform setup.
Definition batch.py:58
_outputs_exist(job)
Check if all output files exist for the given job.
Definition batch.py:197
_logfile(self, job_id)
Get the base name of a log file for the job.
Definition batch.py:190
__init__(self)
Definition batch.py:40
_job_ids_missing_output(self, job_ids)
Get a list of IDs for jobs that are missing output files.
Definition batch.py:226
Kill processes in the multiprocessing queue if the jobs are canceled.
Definition batch.py:1022
__exit__(self, type, val, tb)
Kill processes on exit.
Definition batch.py:1033
__init__(self, mp_queue)
Definition batch.py:1027
Submit LSF batch jobs.
Definition batch.py:281
build_cmd(self, job_id)
This is the basic implementation of building a command to run the job from a batch system.
Definition batch.py:291
submit_job(self, job_id)
Submit a single batch job and return the batch ID.
Definition batch.py:318
parse_args(self, args)
Parse command line arguments and perform setup.
Definition batch.py:287
__init__(self)
Definition batch.py:284
Run local batch jobs sequentially.
Definition batch.py:974
submit_job(self, job_id)
Run a single job locally.
Definition batch.py:982
Run a set of jobs in a local multiprocessing pool using Python's multiprocessing module.
Definition batch.py:1053
submit_job(self, job_id)
Make this a no-op as we do not implement single job submission for the processing pool.
Definition batch.py:1069
submit(self)
Submit jobs to a local processing pool.
Definition batch.py:1080
parse_args(self, args)
Parse command line arguments and perform setup.
Definition batch.py:1075
Submit Slurm batch jobs.
Definition batch.py:332
build_cmd(self, job_id)
Wrap submission of Slurm jobs using a generated script.
Definition batch.py:401
submit_job(self, job_id)
Submit a single batch job and return the batch ID.
Definition batch.py:453
_sbatch(self, job_id)
Definition batch.py:381
default_rundir(self, job_id=None)
Override the basic implementation for getting the default run directory.
Definition batch.py:358
_default_queue(self)
Definition batch.py:370
parse_args(self, args)
Parse command line arguments and perform setup.
Definition batch.py:343
_write_job_script(self, sh_filename, job_cmd)
Write the shell script for Slurm job submission using the 'sbatch' command.
Definition batch.py:428
_sh_filename(self, job_id)
Definition batch.py:398
Submit jobs to the 'swif2' workflow system at JLAB.
Definition batch.py:680
_echo(text)
Print the non-empty lines of some swif2 output.
Definition batch.py:811
_job_inputs(self, job_params)
Yield (local_dest, remote_uri) pairs for a job's inputs, applying swif2 URI schemes.
Definition batch.py:906
_job_outputs(self, job_params)
Yield (local_src, remote_uri) pairs for a job's outputs, resolving ptag sources and applying schemes.
Definition batch.py:919
submit(self)
Batch submission method for Auger.
Definition batch.py:768
parse_args(self, args)
Parse command line arguments and perform setup.
Definition batch.py:730
_write_job_script(self, job_id)
Write an executable tcsh wrapper script that sets up the (c-shell) environment and runs the job,...
Definition batch.py:946
_create_workflow(self)
Ensure the swif2 workflow exists (add-job requires it to already exist).
Definition batch.py:827
_create_cmd(self)
Definition batch.py:817
_partition(self)
Resolve the swif2 (slurm) partition.
Definition batch.py:743
__init__(self)
Definition batch.py:715
_swif_uri(path)
Convert a filesystem path to a swif2 remote URI.
Definition batch.py:933
_add_job_cmd(self, job_params)
Build the 'swif2 add-job' argument list for a single job.
Definition batch.py:852
_run_swif2(self, args, check=True, quiet=False)
Run a single 'swif2' subcommand, echoing its (non-empty) output.
Definition batch.py:791
Database of job scripts.
Definition job.py:125
Simple JSON based store of job data.
Definition job.py:73
Primary class to run HPS jobs from a Python script.
Definition job.py:160
run_job_pool(cmd)
Run the command in a new process whose PID is added to a global MP queue.
Definition batch.py:997
is_running(proc)
Check if a system process looks like it is still running.
Definition batch.py:1012