File Coverage

blib/lib/NBI/Job.pm
Criterion Covered Total %
statement 113 207 54.5
branch 35 84 41.6
condition 0 3 0.0
subroutine 15 24 62.5
pod 16 16 100.0
total 179 334 53.5


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 16     16   320078 use 5.012;
  16         66  
29 16     16   102 use warnings;
  16         55  
  16         920  
30 16     16   94 use Carp qw(confess);
  16         26  
  16         1248  
31 16     16   6285 use Data::Dumper;
  16         89740  
  16         1284  
32 16     16   8826 use File::Spec::Functions;
  16         14978  
  16         1721  
33             $Data::Dumper::Sortkeys = 1;
34 16     16   132 use File::Basename;
  16         43  
  16         53326  
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 105     105 1 2328 my $class = shift @_;
44 105         199 my ($job_name, $commands_array, $command, $opts);
45              
46             # Descriptive instantiation with parameters -param => value
47 105 50       368 if (substr($_[0], 0, 1) eq '-') {
48 105         356 my %data = @_;
49             # Try parsing
50 105         301 for my $i (keys %data) {
51 311 100       1194 if ($i =~ /^-name/) {
    100          
    50          
    0          
52 105         225 $job_name = $data{$i};
53             } elsif ($i =~ /^-command$/) {
54 104         231 $command = $data{$i};
55             } elsif ($i =~ /^-opts$/) {
56             # Check that $data{$i} is an instance of NBI::Opts
57 102 50       414 if ($data{$i}->isa('NBI::Opts')) {
58             # $data{$i} is an instance of NBI::Opts
59 102         201 $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 105         263 my $self = bless {}, $class;
79            
80              
81 105 50       390 $self->{name} = defined $job_name ? $job_name : 'job-' . int(rand(1000000));
82 105         239 $self->{jobid} = 0;
83            
84             # Commands: if both commands_array and command are defined, append command to commands_array
85 105 50       276 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 104         242 $self->{commands} = [$command];
92             }
93              
94             # Opts must be an instance of NBI::Opts, check first
95 105 100       194 if (defined $opts) {
96             # check that $opts is an instance of NBI::Opts
97 102 50       280 if ($opts->isa('NBI::Opts')) {
98             # $opts is an instance of NBI::Opts
99 102         212 $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         21 $self->{opts} = NBI::Opts->new($DEFAULT_QUEUE);
107             }
108              
109 105         200 $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 105 100       254 if ($self->opts->is_array()) {
115 1         4 my $placeholder = $self->opts->placeholder;
116             # Double escape the backslash for regex
117 1         3 my $regex_placeholder = $placeholder;
118 1         3 $regex_placeholder =~ s/\\/\\\\/g;
119 1         3 my $count = 0;
120 1         2 for my $cmd (@{$self->{commands}}) {
  1         4  
121 1 50       3 if ($cmd =~ $self->opts->placeholder) {
122 1         3 $count++;
123             }
124 1         15 $cmd =~ s/\Q$regex_placeholder\E/\${selected_file}/g;
125             }
126             }
127 105         369 return $self;
128            
129             }
130              
131              
132             sub script_path : lvalue {
133             # Update script_path
134 0     0 1 0 my ($self, $new_val) = @_;
135 0 0       0 $self->{script_path} = $new_val if (defined $new_val);
136 0         0 return $self->{script_path};
137             }
138              
139             sub name : lvalue {
140             # Update name
141 409     409 1 1013 my ($self, $new_val) = @_;
142 409 50       719 $self->{name} = $new_val if (defined $new_val);
143 409         1481 return $self->{name};
144             }
145              
146             sub jobid : lvalue {
147             # Update jobid
148 0     0 1 0 my ($self, $new_val) = @_;
149 0 0 0     0 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 0 0       0 $self->{jobid} = $new_val if (defined $new_val);
153 0         0 return $self->{jobid};
154             }
155              
156             sub outputfile : lvalue {
157             # Update name
158 203     203 1 313 my ($self, $parameter) = @_;
159              
160 203         286 my $interpolate = 0;
161 203 50       364 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 203 100       412 if (not defined $self->{output_file}) {
171 102         190 $self->{output_file} = catfile( $self->opts->tmpdir , $self->name . ".%j.out");
172             }
173            
174 203 50       463 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 203         428 return $self->{output_file};
181             }
182            
183             }
184              
185             sub errorfile : lvalue {
186             # Update name
187 203     203 1 340 my ($self, $parameter) = @_;
188              
189 203         285 my $interpolate = 0;
190 203 50       361 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 203 100       397 if (not defined $self->{error_file}) {
200 102         181 $self->{error_file} = catfile( $self->opts->tmpdir , $self->name . ".%j.err");
201             }
202            
203 203 50       391 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 203         391 return $self->{error_file};
210             }
211            
212             }
213             sub append_command {
214 1     1 1 4 my ($self, $new_command) = @_;
215 1 50       4 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         3 push @{$self->{commands}}, $new_command;
  1         32  
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 4     4 1 11 my ($self) = @_;
238 4         8 return 0 + scalar @{$self->{commands}};
  4         21  
239             }
240              
241             sub set_opts {
242 1     1 1 8 my ($self, $opts) = @_;
243             # Check that $opts is an instance of NBI::Opts
244 1 50       11 if ($opts->isa('NBI::Opts')) {
245             # $opts is an instance of NBI::Opts
246 1         6 $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 1131     1131 1 1767 my ($self) = @_;
260 1131         2845 return $self->{opts};
261             }
262              
263             ## Run job
264             sub script {
265 203     203 1 2135 my ($self) = @_;
266 203         533 my $template = [
267             '#SBATCH -J NBI_SLURM_JOBNAME',
268             '#SBATCH -o NBI_SLURM_OUT',
269             '#SBATCH -e NBI_SLURM_ERR',
270             ''
271             ];
272 203         377 my $header = $self->opts->header();
273            
274             # Replace the template
275 203         446 my $script = join("\n", @{$template});
  203         582  
276            
277             # Replace the values
278 203         478 my $name = $self->name;
279 203         415 my $file_out = $self->outputfile;
280 203         390 my $file_err = $self->errorfile;
281 203         985 $script =~ s/NBI_SLURM_JOBNAME/$name/g;
282 203         600 $script =~ s/NBI_SLURM_OUT/$file_out/g;
283 203         483 $script =~ s/NBI_SLURM_ERR/$file_err/g;
284            
285 203         333 my $replacements = 0;
286 203         346 my $placeholder = $self->opts->placeholder;
287            
288 203 100       401 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         3 my $self_files = $self->opts->files;
294 1         2 for my $file (@{$self_files}) {
  1         3  
295 20         38 $file =~ s/ /\\ /g;
296             }
297 1         3 my $files_list = join(" ", @{$self_files});
  1         14  
298 1         4 my $list = "self_files=($files_list)";
299 1         4 push(@prepend, "# Job array list", "$list", "selected_file=\${self_files[\$SLURM_ARRAY_TASK_ID]}");
300            
301             # Prepend the array to the commands
302 1         2 unshift @{$self->{commands}}, @prepend;
  1         10  
303              
304            
305            
306             }
307 203 100       408 if ($self->opts->is_array()) {
308             # check if at least one command containts ${selected_file}
309 1         2 my $selected_file = 0;
310 1         3 for my $cmd (@{$self->{commands}}) {
  1         3  
311 4 100       22 if ($cmd =~ /\$\{selected_file\}/) {
312 1         2 $selected_file = 1;
313 1         3 last;
314             }
315             }
316 1 50       4 if ($selected_file == 0) {
317 0         0 confess "ERROR NBI::Job: No command contains the placeholder:" . $self->opts->placeholder . "\n";
318             }
319             }
320              
321 203         294 $script .= join("\n", @{$self->{commands}});
  203         470  
322 203         867 return $header . $script . "\n";
323             }
324              
325             sub run {
326 0     0 1   my $self = shift @_;
327             # Check it has some commands
328            
329            
330             # Check it has a queue
331 0 0         if (not defined $self->opts->queue) {
332 0           confess "ERROR NBI::Job: No queue defined for job " . $self->name . "\n";
333             }
334             # Check it has some opts
335 0 0         if (not defined $self->opts) {
336 0           confess "ERROR NBI::Job: No opts defined for job " . $self->name . "\n";
337             }
338             # Check it has some commands
339 0 0         if ($self->commands_count == 0) {
340 0           confess "ERROR NBI::Job: No commands defined for job " . $self->name . "\n";
341             }
342              
343             # Create the script
344 0           my $script = $self->script();
345              
346             # Create the script file
347 0           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 0 0         if (-e $script_file) {
351 0           my $i = 1;
352 0           while (-e $script_file) {
353 0           my $string_int = sprintf("%05d", $i);
354 0           $script_file = catfile($self->opts->tmpdir, $self->name . "." . $string_int . ".sh");
355 0           $i++;
356             }
357             }
358              
359 0           $self->{"script_path"} = $script_file;
360 0 0         open(my $fh, ">", $script_file) or confess "ERROR NBI::Job: Cannot open file $script_file for writing\n";
361 0           print $fh $script;
362 0           close($fh);
363              
364             # Run the script
365              
366 0 0         if (_has_command('sbatch') == 0) {
367 0           $self->jobid = -1;
368 0           return 0;
369             }
370 0           my $job_output = `sbatch "$script_file"`;
371              
372             # Check the output
373 0 0         if ($job_output =~ /Submitted batch job (\d+)/) {
374             # Job submitted
375 0           my $job_id = $1;
376             # Update the job id
377 0           $self->jobid = $job_id;
378 0           return $job_id;
379             } else {
380             # Job not submitted
381 0           confess "ERROR NBI::Job: Job " . $self->name . " not submitted\n";
382             }
383 0           return $self->jobid;
384             }
385              
386              
387             sub view {
388             # Return a string representation of the object
389 0     0 1   my $self = shift @_;
390 0           my $str = " --- NBI::Job object ---\n";
391 0           $str .= " name: " . $self->name . "\n";
392 0           $str .= " commands: \n\t" . join("\n\t", @{$self->commands}) . "\n";
  0            
393 0           $str .= " jobid: " . $self->jobid . "\n";
394 0           $str .= " script: " . $self->script_path . "\n";
395 0           $str .= " output file:" . $self->outputfile('-interpolate') . "\n";
396 0           $str .= " error file: " . $self->errorfile('-interpolate') . "\n";
397 0           $str .= " ---------------------------\n";
398            
399 0           return $str;
400             }
401              
402             sub _has_command {
403 0     0     my $command = shift;
404 0           my $is_available = 0;
405            
406 0 0         if ($^O eq 'MSWin32') {
407             # Windows system
408 0           $is_available = system("where $command >nul 2>nul") == 0;
409             } else {
410             # Unix-like system
411 0           $is_available = system("command -v $command >/dev/null 2>&1") == 0;
412             }
413            
414 0           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__