File Coverage

blib/lib/NBI/Job.pm
Criterion Covered Total %
statement 142 207 68.6
branch 46 84 54.7
condition 1 3 33.3
subroutine 19 24 79.1
pod 16 16 100.0
total 224 334 67.0


line stmt bran cond sub pod time code
1             package NBI::Job;
2             #ABSTRACT: A class for representing a job for NBI::Slurm
3             #
4             # NBI::Job - Represents a single SLURM job to be submitted.
5             #
6             # DESCRIPTION:
7             # Encapsulates a named job consisting of one or more shell commands
8             # together with its resource options (NBI::Opts). Key responsibilities:
9             # - new() : accepts -name, -command/-commands, and -opts
10             # - script() : assembles the bash/sbatch script content
11             # - run() : writes the script to disk and submits it via sbatch,
12             # returning the numeric SLURM job ID
13             # - view() : returns a human-readable summary string
14             # - outputfile / errorfile : lvalue accessors for stdout/stderr paths
15             # (support %j interpolation after submission)
16             # - append_command / prepend_command : add commands to the job list
17             # - Array-job support: if the attached NBI::Opts has a -files list,
18             # the script uses a SLURM job array and replaces the -placeholder
19             # token with ${selected_file}.
20             #
21             # RELATIONSHIPS:
22             # - Depends on NBI::Opts (stored in $self->{opts}) for all #SBATCH header
23             # lines, the tmpdir, and array-job configuration.
24             # - $NBI::Job::VERSION is set from $NBI::Slurm::VERSION (loaded by caller).
25             # - Used directly by end-users and by the runjob bin script.
26             #
27              
28 19     19   486722 use 5.012;
  19         63  
29 19     19   74 use warnings;
  19         39  
  19         910  
30 19     19   92 use Carp qw(confess);
  19         22  
  19         959  
31 19     19   6303 use Data::Dumper;
  19         77461  
  19         1024  
32 19     19   6890 use File::Spec::Functions;
  19         11731  
  19         1415  
33             $Data::Dumper::Sortkeys = 1;
34 19     19   101 use File::Basename;
  19         25  
  19         39981  
35              
36             $NBI::Job::VERSION = $NBI::Slurm::VERSION;
37             my $DEFAULT_QUEUE = "nbi-short";
38             require Exporter;
39             our @ISA = qw(Exporter);
40              
41              
42             sub new {
43 115     115 1 1529 my $class = shift @_;
44 115         131 my ($job_name, $commands_array, $command, $opts);
45              
46             # Descriptive instantiation with parameters -param => value
47 115 50       205 if (substr($_[0], 0, 1) eq '-') {
48 115         238 my %data = @_;
49             # Try parsing
50 115         186 for my $i (keys %data) {
51 341 100       626 if ($i =~ /^-name/) {
    100          
    50          
    0          
52 115         162 $job_name = $data{$i};
53             } elsif ($i =~ /^-command$/) {
54 114         138 $command = $data{$i};
55             } elsif ($i =~ /^-opts$/) {
56             # Check that $data{$i} is an instance of NBI::Opts
57 112 50       252 if ($data{$i}->isa('NBI::Opts')) {
58             # $data{$i} is an instance of NBI::Opts
59 112         149 $opts = $data{$i};
60             } else {
61             # $data{$i} is not an instance of NBI::Opts
62 0         0 confess "ERROR NBI::Job: -opts must be an instance of NBI::Opts\n";
63             }
64            
65             } elsif ($i =~ /^-commands$/) {
66             # Check that $data{$i} is an array
67 0 0       0 if (ref($data{$i}) eq 'ARRAY') {
68 0         0 $commands_array = $data{$i};
69             } else {
70 0         0 confess "ERROR NBI::Job: -commands must be an array\n";
71             }
72             } else {
73 0         0 confess "ERROR NBI::Seq: Unknown parameter $i\n";
74             }
75             }
76             }
77            
78 115         158 my $self = bless {}, $class;
79            
80              
81 115 50       202 $self->{name} = defined $job_name ? $job_name : 'job-' . int(rand(1000000));
82 115         159 $self->{jobid} = 0;
83            
84             # Commands: if both commands_array and command are defined, append command to commands_array
85 115 50       191 if (defined $commands_array) {
    100          
86 0         0 $self->{commands} = $commands_array;
87 0 0       0 if (defined $command) {
88 0         0 push @{$self->{commands}}, $command;
  0         0  
89             }
90             } elsif (defined $command) {
91 114         170 $self->{commands} = [$command];
92             }
93              
94             # Opts must be an instance of NBI::Opts, check first
95 115 100       144 if (defined $opts) {
96             # check that $opts is an instance of NBI::Opts
97 112 50       163 if ($opts->isa('NBI::Opts')) {
98             # $opts is an instance of NBI::Opts
99 112         142 $self->{opts} = $opts;
100             } else {
101             # $opts is not an instance of NBI::Opts
102 0         0 confess "ERROR NBI::Job: -opts must be an instance of NBI::Opts\n";
103             }
104            
105             } else {
106 3         13 $self->{opts} = NBI::Opts->new($DEFAULT_QUEUE);
107             }
108              
109 115         123 $self->{script_path} = undef;
110              
111             # Check here if there is opts->placeholder in the commands.
112             # If there is then replace /placeholder/ with ${selected_file}
113              
114 115 100       167 if ($self->opts->is_array()) {
115 1         2 my $placeholder = $self->opts->placeholder;
116             # Double escape the backslash for regex
117 1         2 my $regex_placeholder = $placeholder;
118 1         3 $regex_placeholder =~ s/\\/\\\\/g;
119 1         1 my $count = 0;
120 1         1 for my $cmd (@{$self->{commands}}) {
  1         3  
121 1 50       2 if ($cmd =~ $self->opts->placeholder) {
122 1         2 $count++;
123             }
124 1         9 $cmd =~ s/\Q$regex_placeholder\E/\${selected_file}/g;
125             }
126             }
127 115         185 return $self;
128            
129             }
130              
131              
132             sub script_path : lvalue {
133             # Update script_path
134 1     1 1 1448 my ($self, $new_val) = @_;
135 1 50       9 $self->{script_path} = $new_val if (defined $new_val);
136 1         4 return $self->{script_path};
137             }
138              
139             sub name : lvalue {
140             # Update name
141 431     431 1 599 my ($self, $new_val) = @_;
142 431 50       474 $self->{name} = $new_val if (defined $new_val);
143 431         1031 return $self->{name};
144             }
145              
146             sub jobid : lvalue {
147             # Update jobid
148 4     4 1 17 my ($self, $new_val) = @_;
149 4 50 33     41 if (defined $new_val and $new_val !~ /^-?(\d+)$/) {
150 0         0 confess "ERROR NBI::Job: jobid must be an integer ". $new_val ."\n";
151             }
152 4 50       16 $self->{jobid} = $new_val if (defined $new_val);
153 4         29 return $self->{jobid};
154             }
155              
156             sub outputfile : lvalue {
157             # Update name
158 209     209 1 201 my ($self, $parameter) = @_;
159              
160 209         204 my $interpolate = 0;
161 209 50       236 if (defined $parameter) {
162 0 0       0 if ($parameter eq '-interpolate') {
163 0         0 $interpolate = 1;
164             } else {
165 0         0 $self->{output_file} = $parameter;
166             }
167             }
168              
169             # Create a default output_file if not defined
170 209 100       269 if (not defined $self->{output_file}) {
171 107         136 $self->{output_file} = catfile( $self->opts->tmpdir , $self->name . ".%j.out");
172             }
173            
174 209 50       272 if ($interpolate) {
175 0         0 my $jobid = $self->jobid;
176 0         0 my $output_file = $self->{output_file};
177 0         0 $output_file =~ s/%j/$jobid/g;
178 0         0 return $output_file;
179             } else {
180 209         298 return $self->{output_file};
181             }
182            
183             }
184              
185             sub errorfile : lvalue {
186             # Update name
187 209     209 1 216 my ($self, $parameter) = @_;
188              
189 209         183 my $interpolate = 0;
190 209 50       256 if (defined $parameter) {
191 0 0       0 if ($parameter eq '-interpolate') {
192 0         0 $interpolate = 1;
193             } else {
194 0         0 $self->{error_file} = $parameter;
195             }
196             }
197              
198             # Create a default error_file if not defined
199 209 100       291 if (not defined $self->{error_file}) {
200 107         119 $self->{error_file} = catfile( $self->opts->tmpdir , $self->name . ".%j.err");
201             }
202            
203 209 50       265 if ($interpolate) {
204 0         0 my $jobid = $self->jobid;
205 0         0 my $error_file = $self->{error_file};
206 0         0 $error_file =~ s/%j/$jobid/g;
207 0         0 return $error_file;
208             } else {
209 209         226 return $self->{error_file};
210             }
211            
212             }
213             sub append_command {
214 1     1 1 2 my ($self, $new_command) = @_;
215 1 50       23 if ($self->opts->is_array()) {
216 0         0 my $placeholder = $self->opts->placeholder;
217 0         0 $new_command =~ s/\Q$placeholder\E/\${selected_file}/g;
218             }
219 1         2 push @{$self->{commands}}, $new_command;
  1         3  
220             }
221              
222             sub prepend_command {
223 0     0 1 0 my ($self, $new_command) = @_;
224 0 0       0 if ($self->opts->is_array()) {
225 0         0 my $placeholder = $self->opts->placeholder;
226 0         0 $new_command =~ s/\Q$placeholder\E/\${selected_file}/g;
227             }
228 0         0 unshift @{$self->{commands}}, $new_command;
  0         0  
229             }
230              
231             sub commands {
232 0     0 1 0 my ($self) = @_;
233 0         0 return $self->{commands};
234             }
235              
236             sub commands_count {
237 8     8 1 14 my ($self) = @_;
238 8         10 return 0 + scalar @{$self->{commands}};
  8         26  
239             }
240              
241             sub set_opts {
242 1     1 1 5 my ($self, $opts) = @_;
243             # Check that $opts is an instance of NBI::Opts
244 1 50       6 if ($opts->isa('NBI::Opts')) {
245             # $opts is an instance of NBI::Opts
246 1         4 $self->{opts} = $opts;
247             } else {
248             # $opts is not an instance of NBI::Opts
249 0         0 confess "ERROR NBI::Job: -opts must be an instance of NBI::Opts\n";
250             }
251             }
252              
253             sub get_opts {
254 0     0 1 0 my ($self) = @_;
255 0         0 return $self->{opts};
256             }
257              
258             sub opts {
259 1188     1188 1 1115 my ($self) = @_;
260 1188         1765 return $self->{opts};
261             }
262              
263             ## Run job
264             sub script {
265 208     208 1 2105 my ($self) = @_;
266 208         303 my $template = [
267             '#SBATCH -J NBI_SLURM_JOBNAME',
268             '#SBATCH -o NBI_SLURM_OUT',
269             '#SBATCH -e NBI_SLURM_ERR',
270             ''
271             ];
272 208         235 my $header = $self->opts->header();
273            
274             # Replace the template
275 208         275 my $script = join("\n", @{$template});
  208         302  
276            
277             # Replace the values
278 208         268 my $name = $self->name;
279 208         248 my $file_out = $self->outputfile;
280 208         280 my $file_err = $self->errorfile;
281 208         499 $script =~ s/NBI_SLURM_JOBNAME/$name/g;
282 208         367 $script =~ s/NBI_SLURM_OUT/$file_out/g;
283 208         301 $script =~ s/NBI_SLURM_ERR/$file_err/g;
284            
285 208         206 my $replacements = 0;
286 208         227 my $placeholder = $self->opts->placeholder;
287            
288 208 100       232 if ($self->opts->is_array()) {
289            
290             # Prepend strings to array $self->{commands}
291             # Escape spaces in each file
292 1         2 my @prepend = ();
293 1         2 my $self_files = $self->opts->files;
294 1         1 for my $file (@{$self_files}) {
  1         2  
295 25         25 $file =~ s/ /\\ /g;
296             }
297 1         2 my $files_list = join(" ", @{$self_files});
  1         7  
298 1         2 my $list = "self_files=($files_list)";
299 1         6 push(@prepend, "# Job array list", "$list", "selected_file=\${self_files[\$SLURM_ARRAY_TASK_ID]}");
300            
301             # Prepend the array to the commands
302 1         1 unshift @{$self->{commands}}, @prepend;
  1         4  
303              
304            
305            
306             }
307 208 100       244 if ($self->opts->is_array()) {
308             # check if at least one command containts ${selected_file}
309 1         2 my $selected_file = 0;
310 1         1 for my $cmd (@{$self->{commands}}) {
  1         2  
311 4 100       11 if ($cmd =~ /\$\{selected_file\}/) {
312 1         1 $selected_file = 1;
313 1         2 last;
314             }
315             }
316 1 50       3 if ($selected_file == 0) {
317 0         0 confess "ERROR NBI::Job: No command contains the placeholder:" . $self->opts->placeholder . "\n";
318             }
319             }
320              
321 208         192 $script .= join("\n", @{$self->{commands}});
  208         269  
322 208         437 return $header . $script . "\n";
323             }
324              
325             sub run {
326 4     4 1 8 my $self = shift @_;
327             # Check it has some commands
328            
329            
330             # Check it has a queue
331 4 50       8 if (not defined $self->opts->queue) {
332 0         0 confess "ERROR NBI::Job: No queue defined for job " . $self->name . "\n";
333             }
334             # Check it has some opts
335 4 50       7 if (not defined $self->opts) {
336 0         0 confess "ERROR NBI::Job: No opts defined for job " . $self->name . "\n";
337             }
338             # Check it has some commands
339 4 50       12 if ($self->commands_count == 0) {
340 0         0 confess "ERROR NBI::Job: No commands defined for job " . $self->name . "\n";
341             }
342              
343             # Create the script
344 4         15 my $script = $self->script();
345              
346             # Create the script file
347 4         7 my $script_file = catfile($self->opts->tmpdir, $self->name . ".sh");
348              
349             # change suffix from .sh to .INT.sh if the file exists already
350 4 50       155 if (-e $script_file) {
351 0         0 my $i = 1;
352 0         0 while (-e $script_file) {
353 0         0 my $string_int = sprintf("%05d", $i);
354 0         0 $script_file = catfile($self->opts->tmpdir, $self->name . "." . $string_int . ".sh");
355 0         0 $i++;
356             }
357             }
358              
359 4         10 $self->{"script_path"} = $script_file;
360 4 50       477 open(my $fh, ">", $script_file) or confess "ERROR NBI::Job: Cannot open file $script_file for writing\n";
361 4         51 print $fh $script;
362 4         137 close($fh);
363              
364             # Run the script
365              
366 4 50       29 if (_has_command('sbatch') == 0) {
367 0         0 $self->jobid = -1;
368 0         0 return 0;
369             }
370 4         26102 my $job_output = `sbatch "$script_file"`;
371              
372             # Check the output
373 4 50       154 if ($job_output =~ /Submitted batch job (\d+)/) {
374             # Job submitted
375 4         62 my $job_id = $1;
376             # Update the job id
377 4         64 $self->jobid = $job_id;
378 4         143 return $job_id;
379             } else {
380             # Job not submitted
381 0         0 confess "ERROR NBI::Job: Job " . $self->name . " not submitted\n";
382             }
383 0         0 return $self->jobid;
384             }
385              
386              
387             sub view {
388             # Return a string representation of the object
389 0     0 1 0 my $self = shift @_;
390 0         0 my $str = " --- NBI::Job object ---\n";
391 0         0 $str .= " name: " . $self->name . "\n";
392 0         0 $str .= " commands: \n\t" . join("\n\t", @{$self->commands}) . "\n";
  0         0  
393 0         0 $str .= " jobid: " . $self->jobid . "\n";
394 0         0 $str .= " script: " . $self->script_path . "\n";
395 0         0 $str .= " output file:" . $self->outputfile('-interpolate') . "\n";
396 0         0 $str .= " error file: " . $self->errorfile('-interpolate') . "\n";
397 0         0 $str .= " ---------------------------\n";
398            
399 0         0 return $str;
400             }
401              
402             sub _has_command {
403 4     4   24 my $command = shift;
404 4         6 my $is_available = 0;
405            
406 4 50       38 if ($^O eq 'MSWin32') {
407             # Windows system
408 0         0 $is_available = system("where $command >nul 2>nul") == 0;
409             } else {
410             # Unix-like system
411 4         16693 $is_available = system("command -v $command >/dev/null 2>&1") == 0;
412             }
413            
414 4         135 return $is_available;
415             }
416              
417             sub _to_string {
418             # Convert string to a sanitized string with alphanumeric chars and dashes
419 0     0     my ($self, $string) = @_;
420 0           return $string =~ s/[^a-zA-Z0-9\-]//gr;
421             }
422             1;
423              
424             __END__