The problem batchit solves
A long loop in one R session can accumulate memory that it never gives back. R may keep a large allocation after the objects that used it are gone. The loop then carries that memory to the end of the run. Letting a process exit is the reliable way to reclaim it.
batchit runs one R function once per item, and gives each call its own worker process. The worker process exits when its item finishes, and its memory goes back to the operating system. This matters when one item peaks at many gigabytes.
Four functions do that dispatch. They share one shape. They differ on two axes: how each call’s arguments reach the worker process, and what comes back.
Some work outlasts one R session, and a worker process cannot help
with that. Three more functions describe a chain of Slurm jobs, write it
as bash, and submit it. They take no fn and no
items. The section Slurm: write a chain of jobs
covers them.
Most examples in this article run when R builds the article, and show their real output. Two kinds do not run. The streaming example needs a package that you install yourself. The self-containedness fragments are there to be read.
Your first dispatch
Square three numbers, in separate R processes, two at a time.
library(batchit)
results <- run_and_collect(
fn = function(x) list(value = x^2, worker_pid = Sys.getpid()),
items = list(list(x = 2), list(x = 3), list(x = 4)),
n_workers = 2
)
#> [0/3] dispatching workers...
#> [1/3] complete 11:49:00
#> [2/3] complete 11:49:00
#> [3/3] complete 11:49:00
results[[1]]
#> $value
#> [1] 4
#>
#> $worker_pid
#> [1] 7913batchit prints one dispatch line, and then one progress line per completed item. Most later examples in this article hide those lines, to keep the output short.
Each item also reported the process id it ran in. Compare those three ids with this session’s id:
worker_pids <- vapply(results, function(r) r$worker_pid, integer(1))
worker_pids
#> [1] 7913 7919 7943
Sys.getpid()
#> [1] 7883
Sys.getpid() %in% worker_pids
#> [1] FALSEbatchit started a fresh R process for each of the three items, and none of them was this session.
What just happened
Five terms carry through every batchit call.
fn is the function to run once per
item.
An item is one call’s arguments, as a named list.
list(x = 2) means “call fn with
x = 2”. Three of the four dispatch functions take a list of
items, as items.
n_workers is how many items run at the
same time.
A worker process is a separate R process.
run(), run_and_collect() and
run_and_write_files_atomically() start a fresh one for
every item. stream_from_parent_and_write_files_atomically()
instead reuses a small pool of persistent worker processes
(mirai daemons).
The parent R session is the session you call batchit from. This article uses that term throughout.
Which of the four do I want?
Two questions decide it.
What do you want back? Pick one of three answers:
- each item’s return value;
- nothing, because
fnhas a side effect outside R; - output files that batchit writes and guards for you.
Can you hold every item in memory at once? Usually yes. Say no when one item’s arguments are a large data slice. Say no when there are far too many items to keep as one list.
| What you want back | Items | Use |
|---|---|---|
| each item’s return value | all in memory | run_and_collect() |
| nothing | all in memory | run() |
| output files batchit writes | all in memory | run_and_write_files_atomically() |
| output files batchit writes | produced one at a time | stream_from_parent_and_write_files_atomically() |
Lazy production is available only in combination with batchit-managed output files. There is no streaming function that hands back raw return values.
Consider the simpler alternative before you reach for the streaming
function. Put a small identifier or file path in each item. Have
fn load the large data itself, inside its worker process.
That keeps the fresh-process-per-item memory behaviour, which the
streaming function gives up.
run_and_collect(): a parallel
lapply()
Use this when you need every item’s return value.
squares <- run_and_collect(
fn = function(x) x^2,
items = list(small = list(x = 2), medium = list(x = 3), large = list(x = 4)),
n_workers = 2
)
squares
#> [[1]]
#> [1] 4
#>
#> [[2]]
#> [1] 9
#>
#> [[3]]
#> [1] 16The returned list carries no names, even though items
was named small, medium and large
here. The two file-writing functions below do name their result, so this
difference is worth remembering.
Results also come back in the order of
items, not in the order the worker processes
finished. The first item below sleeps for a second and still lands
first:
run_and_collect(
fn = function(label, seconds) {
Sys.sleep(seconds)
label
},
items = list(
list(label = "slow", seconds = 1),
list(label = "fast", seconds = 0)
),
n_workers = 2
)
#> [[1]]
#> [1] "slow"
#>
#> [[2]]
#> [1] "fast"Every return value travels from the worker process back to the parent
R session. A small object can also travel into a worker process as part
of an item. For a large object, it is usually better to have
fn read it from disk itself.
run(): run items and discard the return values
Use this when fn is called for an effect outside R. It
writes its own files, or it updates a database. It works like
run_and_collect() and returns
invisible(NULL).
The effect has to be external. Each item runs in a separate R
process. An assignment to a global variable inside fn
changes that worker process, not the parent R session.
run_dir <- file.path(tempdir(), "batchit-run")
dir.create(run_dir, showWarnings = FALSE)
run(
fn = function(x, dir) qs2::qs_save(x^2, file.path(dir, paste0(x, ".qs2"))),
items = list(
list(x = 1, dir = run_dir),
list(x = 2, dir = run_dir),
list(x = 3, dir = run_dir)
),
n_workers = 2
)
list.files(run_dir)
#> [1] "1.qs2" "2.qs2" "3.qs2"
qs2::qs_read(file.path(run_dir, "2.qs2"))
#> [1] 4Files that fn writes on its own, as here, get no
protection from batchit. An interruption partway through writing
2.qs2 leaves a half-written 2.qs2 at that
path. run_and_write_files_atomically() is what prevents
that.
run_and_write_files_atomically(): batchit writes the
files
Declare, per item, the final path of every file that item must produce. batchit writes each one to a temporary file, in the same directory as its destination. It replaces the final files only after every one of that item’s outputs is written successfully.
outputs is one named character vector per item, in the
same order as items. When items is named,
outputs MAY carry the same names instead, in any order.
Three rules apply to every declared path:
- the path MUST be absolute;
- its parent directory MUST already exist;
- the path MUST be unique across the whole call.
A destination MAY already exist as a plain file, and batchit then replaces it. batchit rejects a directory or a symlink at a destination. Base R cannot portably reject a FIFO, socket or device file, so batchit does not catch those.
style = "return" (the default)
fn returns a named list, one element per declared
output. Its names MUST be the same set as the declared output names, in
any order. A duplicate name, a missing name and an extra name are all
errors. batchit saves each value to its path in the qs2
format.
write_dir <- file.path(tempdir(), "batchit-write")
dir.create(write_dir, showWarnings = FALSE)
record <- run_and_write_files_atomically(
fn = function(x) list(squared = x^2, doubled = x * 2),
items = list(two = list(x = 2), three = list(x = 3)),
outputs = list(
two = c(
squared = file.path(write_dir, "sq_two.qs2"),
doubled = file.path(write_dir, "db_two.qs2")
),
three = c(
squared = file.path(write_dir, "sq_three.qs2"),
doubled = file.path(write_dir, "db_three.qs2")
)
),
n_workers = 2
)
qs2::qs_read(file.path(write_dir, "sq_three.qs2"))
#> [1] 9The return value is not fn’s return value. It is a
record of what batchit wrote. The record holds one element per item, in
the order of items, named by item id:
str(record)
#> List of 2
#> $ two :List of 2
#> ..$ committed: Named chr [1:2] "/tmp/RtmpJN8rqB/batchit-write/sq_two.qs2" "/tmp/RtmpJN8rqB/batchit-write/db_two.qs2"
#> .. ..- attr(*, "names")= chr [1:2] "squared" "doubled"
#> ..$ attempt : chr "1ecb45b8af21"
#> $ three:List of 2
#> ..$ committed: Named chr [1:2] "/tmp/RtmpJN8rqB/batchit-write/sq_three.qs2" "/tmp/RtmpJN8rqB/batchit-write/db_three.qs2"
#> .. ..- attr(*, "names")= chr [1:2] "squared" "doubled"
#> ..$ attempt : chr "1ecb5eeccd1a"committed maps each declared output name to the final
path batchit wrote. attempt is an internal per-item
identifier; ignore it.
style = "staged_writer": fn writes the
files itself
Ask where_to_write_output(name) for the path to write
each declared output to, and write there. batchit discards
fn’s return value without examining it.
staged <- run_and_write_files_atomically(
fn = function(x) {
qs2::qs_save(x^2, batchit::where_to_write_output("squared"))
writeLines(as.character(x * 2), batchit::where_to_write_output("doubled"))
invisible(NULL)
},
items = list(two = list(x = 2), three = list(x = 3)),
outputs = list(
two = c(
squared = file.path(write_dir, "sq2.qs2"),
doubled = file.path(write_dir, "db2.txt")
),
three = c(
squared = file.path(write_dir, "sq3.qs2"),
doubled = file.path(write_dir, "db3.txt")
)
),
style = "staged_writer",
n_workers = 2
)
readLines(file.path(write_dir, "db2.txt"))
#> [1] "4"A discarded return value does not mean batchit takes your word for it. Before it commits, batchit checks that every declared output now exists at its staging path, as a plain, non-symlink file. It errors and names the output that is missing.
Two rules go with where_to_write_output():
- Write to the path it returns, and otherwise leave that path alone. batchit moves the file into place itself.
- Call it as
batchit::where_to_write_output()from an inline function. An inline function MAY only call another package in package-qualified form.
Pick "staged_writer" when fn already writes
files on its own. That covers a format other than qs2, and
another package’s writer. fn then writes each file
directly, instead of building the whole object in memory for batchit to
serialize. Pick "return" the rest of the time, which is
most of the time.
stream_from_parent_and_write_files_atomically(): build
items lazily
Use this when building the full items list would itself
use too much memory. Instead of items, give an
ids vector and a producer(id) function that
builds one item.
batchit keeps at most min(2 * n_workers, length(ids))
items in flight, and calls producer() only when one of
those slots is free. A slot is not the same as an idle worker process.
When every worker process is busy, roughly n_workers
further items can already be produced and queued.
The bound covers produced item arguments only. The
ids vector, the outputs list and the
accumulating result list live in the parent R session for the whole
call. n_workers bounds none of those three. Streaming
solves the problem of items too large to hold all at once. It does not
make the per-item bookkeeping free.
This example does not run here, because it needs a function inside a package that you install yourself.
# `write_one_slice()` must live in an INSTALLED package: this function loads it
# by package name and function name, never by value. Put it in your own
# package's R/ directory, install, and replace "yourpkg" below.
#
# write_one_slice <- function(slice) list(main = slice)
stream_dir <- file.path(tempdir(), "batchit-stream")
dir.create(stream_dir, showWarnings = FALSE)
ids <- c("a", "b", "c")
stream_from_parent_and_write_files_atomically(
fn = package_function("yourpkg", "write_one_slice"),
ids = ids,
# Replace this with your own loader; it runs in the parent R session.
producer = function(id) list(slice = toupper(id)),
outputs = stats::setNames(
lapply(ids, function(id) c(main = file.path(stream_dir, paste0(id, ".qs2")))),
ids
),
n_workers = 2
)producer() runs in the parent R session, never in a
worker process, so it is an ordinary function. The self-containedness
rules below apply to fn, not to it. Load or build each
item’s data inside producer(). Doing it beforehand defeats
the point.
Once producer() returns an item, batchit applies the
same outputs validation, the same two styles
and the same commit record as
run_and_write_files_atomically().
Three differences from the other three functions.
-
fnMUST come frompackage_function(). An inline function is not accepted. - It needs the
miraipackage:pak::pak("mirai"). - Its worker processes are persistent
miraidaemons. Their memory is not reclaimed between items, the way process exit reclaims it elsewhere. So this function suits many small-to-medium items better than a few huge ones.
Those daemons live in a private compute profile that batchit creates
and tears down for the one call. It never touches a mirai
daemon configuration you set up yourself.
Naming the function: inline or package_function()
An inline function is an ordinary R function, passed by value. Write it directly in the call, or assign it to a variable first. It is the quick option, and every example above uses it, except the streaming one.
It must be self-contained. It may use only its own arguments, base R,
and pkg::fun()-qualified calls to other packages. The four
fragments below are there to be read, so they do not run:
# Allowed, own argument and base R:
function(x) x^2
# Allowed, another package, package-qualified:
function(path) qs2::qs_read(path)
# NOT allowed, `threshold` is not an argument of this function:
threshold <- 10
function(x) x > threshold
# NOT allowed, `my_helper` is a plain call to a function defined outside:
my_helper <- function(x) x * 2
function(x) my_helper(x)batchit checks this with a best-effort static scan, not a proof. An
indirect lookup can pass the check and still fail in the worker process.
get(), eval(parse()) and a string-based
do.call() are all indirect lookups. They fail because
batchit replaces the function’s environment before it sends the
function. Keep to the rule even where the check lets something
through.
If fn needs something from outside its own arguments,
you have three options. Pass it in as another argument, reference it as
pkg::fun(), or move it into a package.
package_function(package, symbol) names
a function in an installed package instead. It records a hash of that
function’s code. A worker process refuses to run if the definition it
loads does not match. Use it for production runs.
fn <- package_function("batchit", "where_to_write_output")
names(fn)
#> [1] "package" "symbol" "version" "hash" "formal_names"
fn$formal_names
#> [1] "name"The hash is deliberately narrow. It covers the target function’s own
body and formals, and nothing else. A changed helper it calls, a changed
constant, and a different dependency version are all outside it. A match
proves that the body and the formals are the definition the parent R
session hashed. It does not prove that behaviour is identical. The hash
ignores comments and whitespace, so an installed package and a
devtools::load_all() tree agree on identical code.
symbol may name an internal function. It does not have
to be exported.
Name every argument, every time
For every item, batchit checks that you named every
formal fn has, including ones with a default, and nothing
else. It checks in the parent R session before dispatching, and again in
the worker process. Leaving out scale here is an error,
even though scale has a default:
run_and_collect(
fn = function(x, scale = 1) x * scale,
items = list(list(x = 2)),
n_workers = 1
)
#> Error:
#> ! .batch parent-validation [item '1']: <adhoc fn> -- formal(s) not supplied: scale. Every formal must be named explicitly, including optional ones -- that is what catches a silently-defaulted argument (the shape of a real dropped-argument bug).Name scale in the item and the same call succeeds:
run_and_collect(
fn = function(x, scale = 1) x * scale,
items = list(list(x = 2, scale = 10)),
n_workers = 1
)
#> [[1]]
#> [1] 20A named default is what makes a forgotten argument visible. An
argument you meant to set, but left out, otherwise looks exactly like
one you left at its default deliberately. For the same reason,
fn MUST NOT take ....
What the atomic-write guarantee covers
batchit stages every one of an item’s declared outputs before it replaces any final file. It then replaces the final files one at a time, each by a rename within the destination directory. Last, it writes a marker recording the commit. The guarantee is different in each of those three phases.
Before the first replacement: fully covered. The item can fail here in four ways:
- an error in
fn; - a
"return"value whose names do not match the declared outputs; - a declared output a
"staged_writer"never wrote; - a failure serializing one of the staged files.
After any of those four, no declared output is touched. Whatever was at those paths stays exactly as it was, and if nothing was there, nothing appears. batchit removes the item’s own previous marker before the first replacement, so that marker does not survive even here.
During the replacement loop: not transactional. The worker process can be killed partway through the loop, or a rename can fail. Some of the item’s outputs are then new while others are still old. batchit never rolls back a file it already renamed. An individual file is not left half-written, since rename replacement is atomic under the filesystem semantics batchit supports, but the set can be torn.
After the marker is written: committed, but the call may still report failure. The worker serializes its result envelope only after it commits. That serialization can fail, or the worker can be killed in that window. Every final file and the marker are then correctly in place, while the call reports the item as failed.
A timeout is not confined to one phase.
timeout measures the worker process’s whole lifetime, so it
can land in any of the three phases. batchit’s own source notes that a
timeout can kill a worker mid-commit. Do not read “timeout” as a
pre-replacement failure.
The marker is what tells a complete commit from a torn one. It is a
small file named .batchit__<item id>, written only
after every replacement has succeeded. A torn set has no valid marker
vouching for it. batchit places one per item, in the directory of the
item’s lexicographically first declared output path. That is beside the
outputs when they share a directory, and in one of them when they do
not. Do not create, read or depend on that file yourself; batchit
manages it. Because the name is built from the item id, an id used with
either file-writing function MUST NOT contain / or
\.
Not a skip mechanism. batchit never treats an existing output file as a reason to skip an item. It schedules every item, subject only to validation or an earlier failure stopping the call first.
Per item, not per call. When one item fails the call stops, but items that already committed keep their files. batchit does not undo them.
When something fails
If an item’s worker process raises an error, exits unexpectedly, or
runs past timeout, the call stops with an R error. It does
not continue past the failure. It never returns a partial result with an
error object in the failed slot. Work already running may finish before
the call unwinds.
run_and_collect(
fn = function(x) stop("this item cannot run"),
items = list(fit_03 = list(x = 1)),
n_workers = 1
)
#> [0/1] dispatching workers...
#> Error:
#> ! run_and_collect(): item 'fit_03' returned an error: this item cannot runItem ids come from the names of items, or from
ids when streaming. They label progress and error messages.
Name your items, so that a message reads
item 'fit_03' exceeded the timeout rather than
item '3'.
run(), run_and_collect() and
run_and_write_files_atomically() print the failing worker
process’s combined stdout and stderr first, so the cause is visible.
That log tail is the last 64,000 bytes, and at most 100 lines.
stream_from_parent_and_write_files_atomically() reports the
error that came back through mirai. It has no equivalent
captured-log tail.
On success, a worker process’s stdout and stderr are discarded. Warnings are the exception. batchit carries back up to the first 100 warnings per item. It re-raises them in the parent R session, labelled with the item id:
run_and_collect(
fn = function(x) {
warning("value looks unusual")
x
},
items = list(fit_01 = list(x = 1)),
n_workers = 1
)
#> [0/1] dispatching workers...
#> Warning: [batch item 'fit_01'] value looks unusual
#> [1/1] complete 11:49:07
#> [[1]]
#> [1] 1Only a completed item’s warnings reach you. If an item warns and then fails, the error envelope carries no warnings, and the call raises the error alone. Those warnings stay in the worker’s captured output. The three non-streaming functions print that output when they report the failure. Read that log; do not wait for an R warning.
timeout is 6 hours by default, per item. Pass
Inf to disable it.
Two settings worth knowing
dev_path loads a package source tree in
each worker process with devtools::load_all(), instead of
using the installed package. Which tree it must name depends on
fn:
- with a
package_function()target, it is that package’s source tree. Load the same tree in the parent R session before you build the descriptor. The hash the parent records then matches the code the worker process loads. - with an inline function, there is no consumer package to load, and
dev_pathcan only name batchit’s own source tree. It is useful when you develop batchit itself.
Leave it NULL to use installed packages. A path that
does not exist, or that does not hold the expected package, is an error.
It never falls back to the installed version quietly.
Thread counts. batchit does not set BLAS or
data.table thread counts for you. If fn is
itself multi-threaded, lower its thread count when you run many worker
processes. Otherwise they compete for the same cores.
Writing one file atomically, with no dispatch
write_qs2_atomically(object, path, ...) is the commit
engine’s rename-into-place step, on its own. It writes to a
uniquely-named temporary file in the destination’s own directory, then
renames it over the destination. No workers, no items, no
fn. It returns the destination path, invisibly.
cars_path <- write_qs2_atomically(mtcars, file.path(tempdir(), "cars.qs2"))
basename(cars_path)
#> [1] "cars.qs2"
nrow(qs2::qs_read(cars_path))
#> [1] 32Use it wherever an interrupted write would leave a truncated file. A later read halts on that file, and a long pipeline then restarts from the beginning. The destination is left either absent or complete, never partial.
It is independent of run_and_write_files_atomically().
It writes no marker. Its temporary file carries no dispatch attempt
token, and that function’s failure cleanup does not sweep it. Read the
three things it does not promise in
?write_qs2_atomically before you rely on it:
- it is not durability, because a rename is not an
fsync, so a power loss can still lose the file; - it is not a lock, because two concurrent writers each produce a complete file and the last rename wins;
- a
SIGKILLleaves the temporary file behind, sinceon.exit()cannot run.
Slurm: write a chain of jobs
slurm_it(), slurm_write() and
slurm_submit() are batchit’s third execution shape.
slurm_it() describes one Slurm job,
slurm_write() writes that description as bash, and
slurm_submit() runs the written driver. None of the three
takes an fn or an items argument, because no R
function crosses to a worker here.
slurm_it() validates one job and returns an object. It
writes nothing.
s1 <- slurm_it(
script = "Rscript s1_build.R",
name = "proj_s1",
cpus = 6,
mem = "85G",
time = "12:00:00"
)
class(s1)
#> [1] "slurm_it"
s1[["name"]]
#> [1] "proj_s1"script is shell text, and not an R function. batchit
checks nothing in it against an R function’s formals, because there is
no R function here.
slurm_write() takes one of those objects, or a list of
them. It writes one bash file per job, plus a submit.sh. It
returns the paths.
chain_dir <- file.path(tempdir(), "slurm-chain")
paths <- slurm_write(
list(
s1,
slurm_it(
script = "Rscript s2_report.R",
name = "proj_s2",
cpus = 2,
mem = "8G",
time = "01:00:00"
)
),
dir = chain_dir
)
basename(paths)
#> [1] "proj_s1.sh" "proj_s2.sh" "submit.sh"Each job file opens with the #SBATCH directives that
slurm_it() validated.
writeLines(head(readLines(paths[1]), 15))
#> #!/bin/bash
#> #SBATCH --job-name=proj_s1
#> #SBATCH --cpus-per-task=6
#> #SBATCH --mem=85G
#> #SBATCH --time=12:00:00
#> #SBATCH --output=/tmp/RtmpJN8rqB/slurm-chain/proj_s1_%j.out
#> #SBATCH --error=/tmp/RtmpJN8rqB/slurm-chain/proj_s1_%j.err
#> #SBATCH --requeue
#>
#> # Written by batchit::slurm_write(). An edit here is lost the next time
#> # the chain is written.
#> #
#> # The body stops at its first failing command, which is what a chain
#> # built on --dependency=afterok needs.
#> set -euo pipefailYour script goes at the end, unchanged.
writeLines(tail(readLines(paths[1]), 3))
#> # The job body, as the caller wrote it.
#> Rscript s1_build.RWriting and submitting are two calls
slurm_write() writes submit.sh and submits
nothing. slurm_submit() runs that driver, and only when you
call it. It returns the job ids as a named character vector, with one
name per stage. Read submit.sh between the two calls.
submit.sh is the only generated file that names
sbatch. A job file runs under Slurm and knows nothing about
the chain. The two chunks below print the whole of
submit.sh, unfiltered.
The first part is everything above the first sbatch
call: the preflight.
submit <- readLines(paths[3])
first_submission <- grep("sbatch", submit)[1]
writeLines(submit[seq_len(first_submission - 1)])
#> #!/bin/bash
#> #
#> # Written by batchit::slurm_write(). An edit here is lost the next time
#> # the chain is written.
#> #
#> # This is the only generated file that names the submission command.
#> set -euo pipefail
#>
#> batchit_dir='/tmp/RtmpJN8rqB/slurm-chain'
#>
#> # --- preflight ---------------------------------------------------------
#> # Both checks run before the first submission, so a refusal costs
#> # seconds and leaves the queue as it was.
#>
#> batchit_refuse() {
#> printf 'batchit: REFUSED: %s\n' "$*" >&2
#> exit 1
#> }
#>
#> # 1. THIS node must be able to accept work. A check on any node in any
#> # state passes while the one node that matters is drained.
#> if ! batchit_node="$(hostname -s)"; then
#> batchit_refuse 'hostname -s failed, so the node this chain would run on cannot be identified.'
#> fi
#> if ! batchit_state="$(sinfo -h -n "$batchit_node" -o '%T' | tr -d '*' | sort -u | paste -sd,)"; then
#> batchit_refuse "sinfo failed for node $batchit_node. Is slurmctld running?"
#> fi
#> if [ -z "$batchit_state" ]; then
#> batchit_refuse "sinfo reported no state for node $batchit_node. It exits 0 and prints nothing for a node it does not know, so check NodeName in slurm.conf."
#> fi
#> # EVERY state must be able to run work. A node drained in one partition
#> # and idle in another arrives here as `drained,idle`. It will not run
#> # the job, so the driver refuses.
#> IFS=, read -ra batchit_states <<< "$batchit_state"
#> for batchit_one in "${batchit_states[@]}"; do
#> case "$batchit_one" in
#> idle | mixed | allocated) : ;;
#> *)
#> batchit_refuse "node $batchit_node is in state '$batchit_state' and will not run work. If it is drained, fix the cause first, then: sudo scontrol update NodeName=$batchit_node State=RESUME"
#> ;;
#> esac
#> done
#>
#> # 2. Refuse a duplicate, and fail closed. A squeue that cannot answer
#> # leaves a second chain possible, so it is a reason to stop.
#> if ! batchit_queued="$(squeue -h -o '%j')"; then
#> batchit_refuse 'squeue failed, so a duplicate submission cannot be ruled out.'
#> fi
#> # A here-string, and not printf | grep -q: grep -q exits at the first
#> # match, printf takes SIGPIPE, and pipefail then reports 141 as no match.
#> for batchit_name in 'proj_s1' 'proj_s2'; do
#> if grep -qxF -- "$batchit_name" <<< "$batchit_queued"; then
#> batchit_refuse "a job named $batchit_name is already queued or running."
#> fi
#> doneThe second part is the rest of the file, one sbatch call
per job. List position is chain order, and each job after the first
waits for the one before it to succeed.
writeLines(submit[seq(first_submission, length(submit))])
#> batchit_jid_1="$(sbatch --parsable "$batchit_dir/proj_s1.sh" | cut -d';' -f1)"
#> printf 'batchit_submitted %s %s\n' 'proj_s1' "$batchit_jid_1"
#>
#> batchit_jid_2="$(sbatch --parsable --dependency=afterok:"$batchit_jid_1" --kill-on-invalid-dep=yes "$batchit_dir/proj_s2.sh" | cut -d';' -f1)"
#> printf 'batchit_submitted %s %s\n' 'proj_s2' "$batchit_jid_2"
set -euo pipefail changes what your script means
Every generated job body runs under set -euo pipefail.
The chain is built on --dependency=afterok, so a job that
fails MUST stop rather than exit 0.
That changes the meaning of your script. Two cases
behave differently here than in an interactive shell:
- a command that fails, where you relied on the next command running anyway;
- an unset variable, where you relied on it expanding to empty.
Check your script under bash -euo pipefail
before you rely on the chain.
What the tests of submit.sh do not prove
submit.sh refuses to submit in two cases. It refuses
when the node is in a state that cannot run work. It also refuses when a
job name in the chain already appears in squeue.
The driver names the node with hostname -s, so it checks
the node submit.sh runs on. On a cluster with a separate
login node, that is the login node.
The tests drive both checks with stub hostname,
sinfo, squeue and sbatch programs
on PATH. So they prove the shell branching, against a
protocol the tests wrote. Four things stay unproven, and
?slurm_write lists them:
- the argument spelling that the real
sinfoandsqueueaccept; - the output grammar that the real
sinfoandsqueueproduce; - which users’ jobs
squeuereports, because no option scopes the check to one user; - that two
submit.shruns started at the same time cannot both pass.
Treat the duplicate check as a guard against your own second submission, and not as a lock.
Where to go next
-
?run,?run_and_collect,?run_and_write_files_atomicallyand?stream_from_parent_and_write_files_atomicallydocument every argument. -
?package_functionand?where_to_write_outputdocument the two helpers. -
?write_qs2_atomicallydocuments the standalone atomic writer. -
?slurm_it,?slurm_writeand?slurm_submitdocument the Slurm chain.?slurm_writelists the four things the preflight tests do not prove. - The reference index lists every exported function on one page.
