File Coverage

blib/lib/NBI/Job.pm
Criterion Covered Total %
statement 156 219 71.2
branch 59 96 61.4
condition 9 12 75.0
subroutine 22 27 81.4
pod 16 16 100.0
total 262 370 70.8


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 27     27   1018455 use 5.012;
  27         86  
29 27     27   102 use warnings;
  27         73  
  27         1253  
30 27     27   152 use Carp qw(confess);
  27         60  
  27         1463  
31 27     27   8318 use Data::Dumper;
  27         102794  
  27         1490  
32 27     27   10768 use File::Spec::Functions;
  27         18558  
  27         2123  
33             $Data::Dumper::Sortkeys = 1;
34 27     27   189 use File::Basename;
  27         86  
  27         69115  
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 122     122 1 7092 my $class = shift @_;
44 122         155 my ($job_name, $commands_array, $command, $opts);
45              
46             # Descriptive instantiation with parameters -param => value
47 122 50       281 if (substr($_[0], 0, 1) eq '-') {
48 122         313 my %data = @_;
49             # Try parsing
50 122         242 for my $i (keys %data) {
51 362 100       832 if ($i =~ /^-name/) {
    100          
    50          
    0          
52 122         208 $job_name = $data{$i};
53             } elsif ($i =~ /^-command$/) {
54 121         184 $command = $data{$i};
55             } elsif ($i =~ /^-opts$/) {
56             # Check that $data{$i} is an instance of NBI::Opts
57 119 50       369 if ($data{$i}->isa('NBI::Opts')) {
58             # $data{$i} is an instance of NBI::Opts
59 119         176 $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 122         206 my $self = bless {}, $class;
79            
80              
81 122 50       309 $self->{name} = defined $job_name ? $job_name : 'job-' . int(rand(1000000));
82 122         171 $self->{jobid} = 0;
83            
84             # Commands: if both commands_array and command are defined, append command to commands_array
85 122 50       249 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 121         216 $self->{commands} = [$command];
92             }
93              
94             # Opts must be an instance of NBI::Opts, check first
95 122 100       179 if (defined $opts) {
96             # check that $opts is an instance of NBI::Opts
97 119 50       228 if ($opts->isa('NBI::Opts')) {
98             # $opts is an instance of NBI::Opts
99 119         178 $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         16 $self->{opts} = NBI::Opts->new($DEFAULT_QUEUE);
107             }
108              
109 122         174 $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 122 100       257 if ($self->opts->is_array()) {
115 8         12 for my $cmd (@{$self->{commands}}) {
  8         33  
116 8         38 _replace_array_placeholders($self, \$cmd);
117             }
118             }
119 122         262 return $self;
120            
121             }
122              
123              
124             sub script_path : lvalue {
125             # Update script_path
126 1     1 1 1466 my ($self, $new_val) = @_;
127 1 50       13 $self->{script_path} = $new_val if (defined $new_val);
128 1         8 return $self->{script_path};
129             }
130              
131             sub name : lvalue {
132             # Update name
133 455     455 1 849 my ($self, $new_val) = @_;
134 455 50       588 $self->{name} = $new_val if (defined $new_val);
135 455         1514 return $self->{name};
136             }
137              
138             sub jobid : lvalue {
139             # Update jobid
140 10     10 1 54 my ($self, $new_val) = @_;
141 10 50 33     54 if (defined $new_val and $new_val !~ /^-?(\d+)$/) {
142 0         0 confess "ERROR NBI::Job: jobid must be an integer ". $new_val ."\n";
143             }
144 10 50       32 $self->{jobid} = $new_val if (defined $new_val);
145 10         84 return $self->{jobid};
146             }
147              
148             sub outputfile : lvalue {
149             # Update name
150 216     216 1 265 my ($self, $parameter) = @_;
151              
152 216         222 my $interpolate = 0;
153 216 50       299 if (defined $parameter) {
154 0 0       0 if ($parameter eq '-interpolate') {
155 0         0 $interpolate = 1;
156             } else {
157 0         0 $self->{output_file} = $parameter;
158             }
159             }
160              
161             # Create a default output_file if not defined
162 216 100       366 if (not defined $self->{output_file}) {
163 114         159 $self->{output_file} = catfile( $self->opts->tmpdir , $self->name . ".%j.out");
164             }
165            
166 216 50       389 if ($interpolate) {
167 0         0 my $jobid = $self->jobid;
168 0         0 my $output_file = $self->{output_file};
169 0         0 $output_file =~ s/%j/$jobid/g;
170 0         0 return $output_file;
171             } else {
172 216         329 return $self->{output_file};
173             }
174            
175             }
176              
177             sub errorfile : lvalue {
178             # Update name
179 216     216 1 259 my ($self, $parameter) = @_;
180              
181 216         235 my $interpolate = 0;
182 216 50       312 if (defined $parameter) {
183 0 0       0 if ($parameter eq '-interpolate') {
184 0         0 $interpolate = 1;
185             } else {
186 0         0 $self->{error_file} = $parameter;
187             }
188             }
189              
190             # Create a default error_file if not defined
191 216 100       332 if (not defined $self->{error_file}) {
192 114         167 $self->{error_file} = catfile( $self->opts->tmpdir , $self->name . ".%j.err");
193             }
194            
195 216 50       312 if ($interpolate) {
196 0         0 my $jobid = $self->jobid;
197 0         0 my $error_file = $self->{error_file};
198 0         0 $error_file =~ s/%j/$jobid/g;
199 0         0 return $error_file;
200             } else {
201 216         351 return $self->{error_file};
202             }
203            
204             }
205             sub append_command {
206 5     5 1 12 my ($self, $new_command) = @_;
207 5 100       14 if ($self->opts->is_array()) {
208 4         8 _replace_array_placeholders($self, \$new_command);
209             }
210 5         12 push @{$self->{commands}}, $new_command;
  5         16  
211             }
212              
213             sub prepend_command {
214 0     0 1 0 my ($self, $new_command) = @_;
215 0 0       0 if ($self->opts->is_array()) {
216 0         0 _replace_array_placeholders($self, \$new_command);
217             }
218 0         0 unshift @{$self->{commands}}, $new_command;
  0         0  
219             }
220              
221             sub commands {
222 0     0 1 0 my ($self) = @_;
223 0         0 return $self->{commands};
224             }
225              
226             sub commands_count {
227 11     11 1 26 my ($self) = @_;
228 11         19 return 0 + scalar @{$self->{commands}};
  11         50  
229             }
230              
231             sub set_opts {
232 1     1 1 7 my ($self, $opts) = @_;
233             # Check that $opts is an instance of NBI::Opts
234 1 50       12 if ($opts->isa('NBI::Opts')) {
235             # $opts is an instance of NBI::Opts
236 1         9 $self->{opts} = $opts;
237             } else {
238             # $opts is not an instance of NBI::Opts
239 0         0 confess "ERROR NBI::Job: -opts must be an instance of NBI::Opts\n";
240             }
241             }
242              
243             sub get_opts {
244 0     0 1 0 my ($self) = @_;
245 0         0 return $self->{opts};
246             }
247              
248             sub opts {
249 1224     1224 1 1483 my ($self) = @_;
250 1224         2556 return $self->{opts};
251             }
252              
253             ## Run job
254             sub script {
255 215     215 1 2256 my ($self) = @_;
256 215         427 my $template = [
257             '#SBATCH -J NBI_SLURM_JOBNAME',
258             '#SBATCH -o NBI_SLURM_OUT',
259             '#SBATCH -e NBI_SLURM_ERR',
260             ''
261             ];
262 215         306 my $header = $self->opts->header();
263            
264             # Replace the template
265 215         259 my $script = join("\n", @{$template});
  215         376  
266            
267             # Replace the values
268 215         350 my $name = $self->name;
269 215         353 my $file_out = $self->outputfile;
270 215         347 my $file_err = $self->errorfile;
271 215         722 $script =~ s/NBI_SLURM_JOBNAME/$name/g;
272 215         444 $script =~ s/NBI_SLURM_OUT/$file_out/g;
273 215         414 $script =~ s/NBI_SLURM_ERR/$file_err/g;
274            
275 215         254 my @commands = @{$self->{commands}};
  215         457  
276              
277 215 100       312 if ($self->opts->is_array()) {
278 8         28 my @prepend = _array_prelude($self);
279 8         53 unshift @commands, @prepend;
280             }
281 215 100       314 if ($self->opts->is_array()) {
282 8         11 my $has_array_variable = 0;
283 8         24 for my $cmd (@commands) {
284 65 100 100     90 if ($self->opts->is_files_array() && $cmd =~ /\$\{selected_file\}/) {
285 1         6 $has_array_variable = 1;
286 1         2 last;
287             }
288 64 100 100     90 if ($self->opts->is_params_array() && $cmd =~ /\$\{param_\d+\}/) {
289 6         16 $has_array_variable = 1;
290 6         24 last;
291             }
292             }
293 8 100       26 if ($has_array_variable == 0) {
294 1 50       2 if ($self->opts->is_files_array()) {
295 0         0 confess "ERROR NBI::Job: No command contains the placeholder:" . $self->opts->placeholder . "\n";
296             }
297 1         248 confess "ERROR NBI::Job: No command contains params-array placeholders like ##1##\n";
298             }
299             }
300              
301 214         367 $script .= join("\n", @commands);
302 214         713 return $header . $script . "\n";
303             }
304              
305             sub _replace_array_placeholders {
306 12     12   24 my ($self, $command_ref) = @_;
307 12 100       40 if ($self->opts->is_files_array()) {
308 1         2 my $placeholder = $self->opts->placeholder;
309 1         16 $$command_ref =~ s/\Q$placeholder\E/\${selected_file}/g;
310             }
311 12 100       41 if ($self->opts->is_params_array()) {
312 11         93 $$command_ref =~ s/##(\d+)##/\${param_$1}/g;
313             }
314             }
315              
316             sub _array_prelude {
317 8     8   15 my ($self) = @_;
318 8         30 my @index_prelude = ('nbi_array_index=$SLURM_ARRAY_TASK_ID');
319 8 100 66     25 if ($self->opts->{array_offset} && $self->opts->{array_offset} > 0) {
320 3         11 @index_prelude = ("nbi_array_index=\$((SLURM_ARRAY_TASK_ID + " . $self->opts->{array_offset} . "))");
321             }
322 8 100       22 if ($self->opts->is_files_array()) {
323 1         3 my $self_files = $self->opts->files;
324 1         2 my @escaped_files = @{$self_files};
  1         6  
325 1         2 for my $file (@escaped_files) {
326 28         26 $file =~ s/ /\\ /g;
327             }
328 1         4 my $files_list = join(" ", @escaped_files);
329             return (
330 1         5 "# Job array list",
331             @index_prelude,
332             "self_files=($files_list)",
333             "selected_file=\${self_files[\$nbi_array_index]}",
334             );
335             }
336 7 50       16 if ($self->opts->is_params_array()) {
337 7         84 my $params_file = _shell_single_quote($self->opts->params_array);
338 7         24 my $perl_loader = q{use strict;use warnings;my ($file,$task_id)=@ARGV;open my $fh,"<",$file or die "Cannot open $file: $!\n";my $row_idx=0;while (my $line=<$fh>) { chomp $line; $line =~ s/\r$//; next if $line =~ /^\s*$/; next if $line =~ /^\s*#/; if ($row_idx == $task_id) { my @fields = split /\t/, $line, -1; for my $field (@fields) { print $field, "\0"; } exit 0; } $row_idx++; } die "No params row for task_id=$task_id in $file\n";};
339             return (
340 7         68 "# Job array params",
341             @index_prelude,
342             "params_file=$params_file",
343             "mapfile -d '' -t params < <(perl -e '$perl_loader' \"\$params_file\" \"\$nbi_array_index\")",
344             "for i in \"\${!params[@]}\"; do",
345             " printf -v \"param_\$((i + 1))\" '%s' \"\${params[\$i]}\"",
346             "done",
347             );
348             }
349 0         0 return ();
350             }
351              
352             sub _shell_single_quote {
353 7     7   24 my ($value) = @_;
354 7         17 $value =~ s/'/'\"'\"'/g;
355 7         23 return "'$value'";
356             }
357              
358             sub run {
359 7     7 1 18 my $self = shift @_;
360             # Check it has some commands
361            
362            
363             # Check it has a queue
364 7 50       59 if (not defined $self->opts->queue) {
365 0         0 confess "ERROR NBI::Job: No queue defined for job " . $self->name . "\n";
366             }
367             # Check it has some opts
368 7 50       17 if (not defined $self->opts) {
369 0         0 confess "ERROR NBI::Job: No opts defined for job " . $self->name . "\n";
370             }
371             # Check it has some commands
372 7 50       30 if ($self->commands_count == 0) {
373 0         0 confess "ERROR NBI::Job: No commands defined for job " . $self->name . "\n";
374             }
375              
376             # Create the script
377 7         20 my $script = $self->script();
378              
379             # Create the script file
380 7         22 my $script_file = catfile($self->opts->tmpdir, $self->name . ".sh");
381              
382             # change suffix from .sh to .INT.sh if the file exists already
383 7 50       10200 if (-e $script_file) {
384 0         0 my $i = 1;
385 0         0 while (-e $script_file) {
386 0         0 my $string_int = sprintf("%05d", $i);
387 0         0 $script_file = catfile($self->opts->tmpdir, $self->name . "." . $string_int . ".sh");
388 0         0 $i++;
389             }
390             }
391              
392 7         44 $self->{"script_path"} = $script_file;
393 7 50       1065 open(my $fh, ">", $script_file) or confess "ERROR NBI::Job: Cannot open file $script_file for writing\n";
394 7         99 print $fh $script;
395 7         264 close($fh);
396              
397             # Run the script
398              
399 7 50       33 if (_has_command('sbatch') == 0) {
400 0         0 $self->jobid = -1;
401 0         0 return 0;
402             }
403 7         49138 my $job_output = `sbatch "$script_file"`;
404              
405             # Check the output
406 7 50       313 if ($job_output =~ /Submitted batch job (\d+)/) {
407             # Job submitted
408 7         110 my $job_id = $1;
409             # Update the job id
410 7         139 $self->jobid = $job_id;
411 7         254 return $job_id;
412             } else {
413             # Job not submitted
414 0         0 confess "ERROR NBI::Job: Job " . $self->name . " not submitted\n";
415             }
416 0         0 return $self->jobid;
417             }
418              
419              
420             sub view {
421             # Return a string representation of the object
422 0     0 1 0 my $self = shift @_;
423 0         0 my $str = " --- NBI::Job object ---\n";
424 0         0 $str .= " name: " . $self->name . "\n";
425 0         0 $str .= " commands: \n\t" . join("\n\t", @{$self->commands}) . "\n";
  0         0  
426 0         0 $str .= " jobid: " . $self->jobid . "\n";
427 0         0 $str .= " script: " . $self->script_path . "\n";
428 0         0 $str .= " output file:" . $self->outputfile('-interpolate') . "\n";
429 0         0 $str .= " error file: " . $self->errorfile('-interpolate') . "\n";
430 0         0 $str .= " ---------------------------\n";
431            
432 0         0 return $str;
433             }
434              
435             sub _has_command {
436 7     7   24 my $command = shift;
437 7         10 my $is_available = 0;
438            
439 7 50       58 if ($^O eq 'MSWin32') {
440             # Windows system
441 0         0 $is_available = system("where $command >nul 2>nul") == 0;
442             } else {
443             # Unix-like system
444 7         30086 $is_available = system("command -v $command >/dev/null 2>&1") == 0;
445             }
446            
447 7         333 return $is_available;
448             }
449              
450             sub _to_string {
451             # Convert string to a sanitized string with alphanumeric chars and dashes
452 0     0     my ($self, $string) = @_;
453 0           return $string =~ s/[^a-zA-Z0-9\-]//gr;
454             }
455             1;
456              
457             __END__