@@ -205,3 +205,308 @@ cluster_job_submit <- function(script, scheduler="slurm", sched_args=NULL,
205205 return (jobid )
206206}
207207
208+
209+ # ' This function pauses execution of an R script while a scheduled qsub job is not yet complete.
210+ # '
211+ # ' It is intended to give you control over job dependencies within R when the formal PBS
212+ # ' depend approach is insufficient, especially in the case of a script that spawns child jobs that
213+ # ' need to be scheduled or complete before the parent script should continue.
214+ # '
215+ # ' @param job_ids One or more job ids of existing PBS or slurm jobs, or process ids of a local process for
216+ # ' \code{scheduler="sh"}.
217+ # ' @param repolling_interval How often to recheck the job status, in seconds. Default: 30
218+ # ' @param max_wait How long to wait on the job before giving up, in seconds. Default: 24 hours (86,400 seconds)
219+ # ' @param scheduler What scheduler is used for job execution.
220+ # ' Options: c("torque", "qsub", "slurm", "sbatch", "sh", "local")
221+ # ' @param quiet If \code{TRUE}, \code{wait_for_job} will not print out any status updates on jobs. If \code{FALSE},
222+ # ' the function prints out status updates for each tracked job so that the user knows what's holding up progress.
223+ # '
224+ # ' @return Nothing. Just returns when the blocking job completes.
225+ # '
226+ # ' @details Note that for the \code{scheduler} argument, "torque" and "qsub" are the same;
227+ # ' "slurm" and "sbatch" are the same, and "sh" and "local" are the same.
228+ # ' @examples
229+ # ' \dontrun{
230+ # ' # example on qsub/torque cluster
231+ # ' wait_for_job("7968857.torque01.util.production.int.aci.ics.psu.edu", scheduler = "torque")
232+ # '
233+ # ' # example of waiting for two jobs on slurm cluster
234+ # ' wait_for_job(c("24147864", "24147876"), scheduler = "slurm")
235+ # '
236+ # ' # example of waiting for two jobs on local machine
237+ # ' wait_for_job(c("9843", "9844"), scheduler = "local")
238+ # ' }
239+ # '
240+ # ' @author Michael Hallquist
241+ # ' @importFrom dplyr full_join if_else bind_rows
242+ # ' @export
243+ wait_for_job <- function (job_ids , repolling_interval = 60 , max_wait = 60 * 60 * 24 ,
244+ scheduler = " local" , quiet = TRUE , stop_on_timeout = TRUE ) {
245+ checkmate :: assert_number(repolling_interval , lower = 0.1 , upper = 2e5 )
246+ checkmate :: assert_number(max_wait , lower = 1 , upper = 1814400 ) # 21 days
247+ scheduler <- tolower(scheduler ) # ignore case
248+ checkmate :: assert_subset(scheduler , c(" torque" , " qsub" , " slurm" , " sbatch" , " sh" , " local" ))
249+
250+ job_complete <- FALSE
251+ wait_start <- Sys.time()
252+
253+ get_job_status <- function () { # use variables in parent environment
254+ if (scheduler %in% c(" slurm" , " sbatch" )) {
255+ status <- slurm_job_status(job_ids )
256+ state <- sapply(status $ State , function (x ) {
257+ switch (x ,
258+ " BOOT_FAIL" = " failed" ,
259+ " CANCELLED" = " cancelled" ,
260+ " COMPLETED" = " complete" ,
261+ " DEADLINE" = " failed" ,
262+ " FAILED" = " failed" ,
263+ " NODE_FAIL" = " failed" ,
264+ " OUT_OF_MEMORY" = " failed" ,
265+ " PENDING" = " queued" ,
266+ " PREEMPTED" = " failed" ,
267+ " RUNNING" = " running" ,
268+ " REQUEUED" = " queued" ,
269+ " REVOKED" = " failed" ,
270+ " SUSPENDED" = " suspended" ,
271+ " TIMEOUT" = " failed" ,
272+ " MISSING" = " missing" # scheduler has not registered the job
273+ )
274+ })
275+ } else if (scheduler %in% c(" sh" , " local" )) {
276+ status <- local_job_status(job_ids )
277+ state <- sapply(status $ STAT , function (x ) {
278+ switch (x ,
279+ " C" = " complete" ,
280+ " I" = " running" , # idle/sleeping
281+ " R" = " running" ,
282+ " S" = " running" , # sleeping
283+ " T" = " suspended" ,
284+ " U" = " running" ,
285+ " Z" = " failed" , # zombie
286+ stop(" Unable to understand job state: " , x )
287+ )
288+ })
289+ } else if (scheduler %in% c(" torque" , " qsub" )) {
290+ # QSUB
291+ status <- torque_job_status(job_ids )
292+ state <- status $ State
293+
294+ # no need for additional mapping in simple torque results
295+ # state <- sapply(status$State, function(x) {
296+ # switch(x,
297+ # "C" = "complete",
298+ # "R" = "running",
299+ # "Q" = "queued",
300+ # "H" = "suspended",
301+ # "W" = "suspended", # waiting
302+ # stop("Unable to understand job state: ", x)
303+ # )
304+ # })
305+ } else {
306+ stop(" unknown scheduler: " , scheduler )
307+ }
308+ return (state )
309+ }
310+
311+ ret_code <- NULL # should be set to TRUE if all jobs complete and FALSE if any job fails
312+
313+ while (job_complete == FALSE ) {
314+ status <- get_job_status()
315+
316+ # update wait time
317+ wait_total <- difftime(Sys.time(), wait_start , units = " sec" )
318+
319+ # Debugging
320+ # cat("Wait so far: ", wait_total, "\n")
321+
322+ if (any(status == " running" )) {
323+ if (isFALSE(quiet )) {
324+ cat(" Job(s) still running:" , paste(job_ids [status == " running" ], collapse = " , " ), " \n " )
325+ }
326+ }
327+
328+ if (any(status == " queued" )) {
329+ if (isFALSE(quiet )) {
330+ cat(" Job(s) still queued:" , paste(job_ids [status == " queued" ], collapse = " , " ), " \n " )
331+ }
332+ }
333+
334+ if (any(status == " suspended" )) {
335+ if (isFALSE(quiet )) {
336+ cat(" Job(s) suspended:" , paste(job_ids [status == " suspended" ], collapse = " , " ), " \n " )
337+ }
338+ }
339+
340+ if (any(status == " missing" )) {
341+ if (isFALSE(quiet )) {
342+ cat(" Job(s) missing from scheduler response:" , paste(job_ids [status == " missing" ], collapse = " , " ), " \n " )
343+ }
344+ }
345+
346+ if (wait_total > max_wait ) {
347+ if (isTRUE(stop_on_timeout )) {
348+ stop(" Maximum wait time: " , max_wait , " exceeded. Stopping execution of parent script because something is wrong." )
349+ } else {
350+ return (FALSE )
351+ }
352+ } else if (all(status %in% c(" failed" , " complete" ))) {
353+ job_complete <- TRUE # drop out of this loop
354+ if (isFALSE(quiet )) {
355+ cat(" All jobs have finished.\n " )
356+ }
357+ if (any(status == " failed" )) {
358+ cat(" The following jobs(s) failed:" , paste(job_ids [status == " failed" ], collapse = " , " ), " \n " )
359+ ret_code <- FALSE
360+ } else {
361+ ret_code <- TRUE
362+ }
363+ } else {
364+ Sys.sleep(repolling_interval ) # wait and repoll jobs
365+ }
366+ }
367+
368+ return (invisible (ret_code ))
369+ }
370+
371+ # calls sacct with a job list
372+ slurm_job_status <- function (job_ids = NULL , user = NULL , sacct_format = " jobid,submit,timelimit,start,end,state" ) {
373+ if (! is.null(job_ids )) {
374+ jstring <- paste(" -j" , paste(job_ids , collapse = " ," ))
375+ } else {
376+ jstring <- " "
377+ }
378+
379+ if (! is.null(user )) {
380+ ustring <- paste(" -u" , paste(user , collapse = " ," ))
381+ } else {
382+ ustring <- " "
383+ }
384+
385+ # -P specifies a parsable output separated by pipes
386+ # -X avoids printing subsidiary jobs within each job id
387+ # cmd <- paste("sacct", jstring, ustring, "-X -P -o", sacct_format)
388+ cmd <- paste(jstring , ustring , " -X -P -o" , sacct_format )
389+ # cat(cmd, "\n")
390+ res <- system2(" sacct" , args = cmd , stdout = TRUE )
391+
392+ df_base <- data.frame (JobID = job_ids )
393+ df_empty <- df_base %> %
394+ mutate(
395+ Submit = NA_character_ ,
396+ Timelimit = NA_character_ ,
397+ Start = NA_character_ ,
398+ End = NA_character_ ,
399+ State = " MISSING"
400+ )
401+
402+ # handle non-zero exit status -- return empty data
403+ if (! is.null(attr(res , " status" ))) {
404+ warning(" sacct call generated non-zero exit status" )
405+ print(cmd )
406+ return (df_empty )
407+ }
408+
409+ if (length(res ) == 1L ) {
410+ # data.table::fread will break down (see Github issue )
411+ out <- readr :: read_delim(I(res ), delim = " |" , show_col_types = FALSE )
412+ } else {
413+ out <- data.table :: fread(text = res , data.table = FALSE )
414+ }
415+
416+ if (! checkmate :: test_subset(c(" JobID" , " State" ), names(out ))) {
417+ warning(" Missing columns in sacct output" )
418+ return (df_empty )
419+ }
420+
421+ out $ JobID <- as.character(out $ JobID )
422+ df <- df_base %> %
423+ dplyr :: left_join(out , by = " JobID" ) %> %
424+ mutate(State = if_else(is.na(State ), " MISSING" , State ))
425+
426+ return (df )
427+ }
428+
429+ # torque does not keep information about completed jobs available in qstat or qselect
430+ # thus, need to log when a job is listed as queued, so that it 'going missing' is evidence of it being completed
431+ torque_job_status <- function (job_ids , user = NULL ) {
432+ # res <- system2("qstat", args = paste("-f", paste(job_ids, collapse=" "), "| grep -i 'job_state'"), stdout = TRUE)
433+
434+ q_jobs <- system2(" qselect" , args = " -u $USER -s QW" , stdout = TRUE ) # queued jobs
435+ r_jobs <- system2(" qselect" , args = " -u $USER -s EHRT" , stdout = TRUE ) # running jobs
436+ c_jobs <- system2(" qselect" , args = " -u $USER -s C" , stdout = TRUE ) # complete jobs
437+ m_jobs <- setdiff(job_ids , c(q_jobs , r_jobs , c_jobs )) # missing jobs
438+ # state <- c("queued", "running", "complete", "missing")
439+ state <- c(" queued" , " running" , " complete" , " complete" )
440+
441+ # TORQUE clusters only keep jobs with status C (complete) for a limited period of time. After that, the job comes back as missing.
442+ # Because of this, if one job finishes at time X and another finishes at time Y, job X will be 'missing' if job Y takes a very long time.
443+ # Thus, we return any missing jobs as complete, which could be problematic if they are truly missing immediately after submission (as happened with slurm).
444+ # Ideally, we would track a job within wait_for_job such that it can be missing initially, then move into running, then move into complete.
445+
446+ j_list <- list (q_jobs , r_jobs , c_jobs , m_jobs )
447+ state_list <- list ()
448+ for (ii in seq_along(j_list )) {
449+ if (length(j_list [[ii ]]) > 0L ) {
450+ state_list [[state [ii ]]] <- data.frame (JobID = j_list [[ii ]], State = state [ii ])
451+ }
452+ }
453+
454+ state_df <- bind_rows(state_list )
455+
456+ if (! is.null(attr(q_jobs , " status" ))) {
457+ warning(" qselect call generated non-zero exit status" )
458+ return (data.frame (JobID = job_ids , State = " missing" ))
459+ }
460+
461+ # job_state <- sub(".*job_state = ([A-z]).*", "\\1", res, perl = TRUE)
462+
463+ return (state_df )
464+ }
465+
466+ local_job_status <- function (job_ids = NULL , user = NULL ,
467+ ps_format = " user,pid,state,time,etime,%cpu,%mem,comm,xstat" ) {
468+ job_ids <- type.convert(job_ids , as.is = T ) # convert to integers
469+ checkmate :: assert_integerish(job_ids )
470+
471+ if (! is.null(job_ids )) {
472+ jstring <- paste(" -p" , paste(job_ids , collapse = " ," ))
473+ } else {
474+ jstring <- " "
475+ }
476+
477+ if (! is.null(user )) {
478+ ustring <- paste(" -u" , paste(user , collapse = " ," ))
479+ } else {
480+ ustring <- " "
481+ }
482+
483+ # cat(paste("ps", jstring, ustring, "-o", ps_format), sep = "\n")
484+ res <- suppressWarnings(system2(" ps" , args = paste(jstring , ustring , " -o" , ps_format ), stdout = TRUE )) # intern=TRUE)
485+
486+ # need to trap res of length 1 (just header row) to avoid data.table bug.
487+ if (! is.null(attr(res , " status" )) && attr(res , " status" ) != 0 ) {
488+ hrow <- strsplit(res , " \\ s+" )[[1 ]]
489+ dt <- data.frame (matrix (NA , nrow = length(job_ids ), ncol = length(hrow )))
490+ names(dt ) <- hrow
491+ dt $ PID <- as.integer(job_ids )
492+ } else {
493+ stopifnot(length(res ) > 1 )
494+ # fread and any other parsing can break down with consecutive spaces in body of output.
495+ # This happens with lstart and start, avoid these for now.
496+ # header <- gregexpr("\\b", res[1], perl = T)
497+ # l2 <- gregexpr("\\b", res[2], perl=T)
498+ dt <- data.table :: fread(text = res )
499+ }
500+
501+ # fix difference in column naming between FreeBSD and *nux (make all like FreeBSD)
502+ data.table :: setnames(dt , c(" S" , " COMMAND" ), c(" STAT" , " COMM" ), skip_absent = TRUE )
503+
504+ # build df that fills in missing jobs (completed/killed)
505+ all_dt <- data.frame (PID = as.integer(job_ids )) %> %
506+ dplyr :: full_join(dt , by = " PID" ) %> %
507+ mutate(STAT = substr(STAT , 1 , 1 )) # only care about first character of state
508+
509+ all_dt $ STAT [is.na(all_dt $ STAT )] <- " C" # complete
510+
511+ return (all_dt )
512+ }
0 commit comments