lbatch is an sbatch-style local queue front end for Slurm. It lets you queue large loops or large array jobs locally, then feeds work into Slurm gradually so you do not exceed user-level job limits.
The core idea is:
lbatch submission -> local group -> atomic units -> gradual sbatch submissions
Examples:
lbatch job.batchcreates one local group with one unit.lbatch --array=1-300 job.batchcreates one local group with 300 local units.lbatch daemon --max-remote-visible 10submits at most 10 unreleased units to Slurm at a time.
lbatch is not a scientific workflow scheduler. It does not decide whether your analysis succeeded. If the remote job exits, lbatch records the exit code and releases the local slot.
This repository contains a first working MVP of the v1.0 spec:
- local SQLite queue;
sbatch-style submission parsing;#SBATCHdirective parsing;- Slurm array expansion into one local unit per task;
- local group dependencies;
- external Slurm dependency pass-through;
- daemon dispatch loop;
- wrapper scripts and release events;
- status, release, reconcile, cancel, config, and doctor commands.
- Python
>=3.10. - Slurm client commands available on
PATHfor real dispatch:sbatch,squeue,sacct, andscancel. - No required third-party Python packages.
From the project root:
python -m pip install -e .If your cluster does not allow writing to the active environment:
python -m pip install --user -e .
export PATH="$HOME/.local/bin:$PATH"Verify installation:
lbatch version
lbatch doctorBy default, lbatch stores local queue state under standard XDG-style user paths:
~/.local/share/lbatch/lbatch.db
~/.local/share/lbatch/events/
~/.local/share/lbatch/wrappers/
~/.local/state/lbatch/
~/.config/lbatch/config.json
Useful reset command during testing:
rm -rf ~/.local/share/lbatch ~/.local/state/lbatch ~/.config/lbatchUse that only when you intentionally want to delete the local lbatch queue.
Submitting with lbatch only creates local records. It does not immediately submit everything to Slurm.
lbatch --array=1-100 job.batchThis queues 100 local units. A daemon must be running to feed them into Slurm:
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundAt most 10 units become Slurm-visible at one time. As wrappers finish and emit release events, the daemon submits more queued units.
lbatch job/my_job.batch
lbatch status --groups --units
lbatch daemon --max-remote-visible 1 --capacity-mode local-owned --foregroundmkdir -p logs
lbatch --array=1-5 \
--partition=workq \
--account=YOUR_ACCOUNT \
--time=00:05:00 \
--output=logs/small_%A_%a.out \
--error=logs/small_%A_%a.err \
--export=ALL,GROUP=1 \
job/test_sleep.batch
lbatch status --groups --units
lbatch daemon --max-remote-visible 2 --capacity-mode local-owned --foregroundCheck Slurm and logs from another terminal:
squeue -u "$USER"
ls -lh logs
cat logs/small_*_1.outUse --lbatch-dry-run to inspect how a submission will expand:
lbatch --lbatch-dry-run --array=1-5 --export=ALL,GROUP=1 job/test_sleep.batchDry-run prints planned units and effective options without writing to SQLite.
This repository includes two demo scripts:
job/test_sleep.batch
job/submit_lbatch_test.sh
This is the batch script executed by Slurm. It prints the group, array task ID, Slurm job ID, hostname, and timestamps, sleeps for 10 seconds, then prints a completion line.
It expects GROUP to be provided through --export=ALL,GROUP=<N> and uses SLURM_ARRAY_TASK_ID from Slurm.
This script queues five local groups:
GROUP=1 -> array 1-50
GROUP=2 -> array 1-50
GROUP=3 -> array 1-50
GROUP=4 -> array 1-50
GROUP=5 -> array 1-50
Total local work:
5 groups x 50 array tasks = 250 local units
It does not run the daemon. It only queues local work.
Start from the project root:
mkdir -p logs
bash job/submit_lbatch_test.shConfirm the local queue:
lbatch status --groups
lbatch status --groups --unitsYou should see 5 groups and 250 total units.
Start dispatching to Slurm:
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundIn another terminal, monitor Slurm and lbatch:
watch -n 2 'squeue -u "$USER"; echo; lbatch status --groups'Inspect logs:
ls -lh logs
head -n 20 logs/test_g1_*_1.outExpected behavior:
lbatch statusinitially shows manyQUEUEDunits.- The daemon submits up to 10 remote-visible units.
- As the 10-second jobs finish, wrapper release events mark units
RELEASED. - The daemon submits more units until all 250 are released.
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundForeground mode is best for first tests because errors are visible immediately. Stop it with Ctrl-C.
The current MVP does not fork into the background itself. Use shell tools:
mkdir -p logs
nohup lbatch daemon --max-remote-visible 10 --capacity-mode local-owned > logs/lbatch-daemon.log 2>&1 &
echo $! > logs/lbatch-daemon.pidCheck it:
tail -f logs/lbatch-daemon.log
ps -p "$(cat logs/lbatch-daemon.pid)"Stop it:
kill "$(cat logs/lbatch-daemon.pid)"Recommended on clusters:
tmux new -s lbatch
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundDetach with Ctrl-b d and reattach with:
tmux attach -t lbatchlbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundCounts only jobs submitted by this lbatch queue. This is the simplest and recommended mode for initial use.
lbatch daemon --max-remote-visible 10 --capacity-mode slurm-on-demand --foregroundQueries squeue before dispatching to account for other visible Slurm jobs owned by the user. It is more conservative, but depends on squeue behavior at your site.
lbatch daemon --max-remote-visible 10 --capacity-mode manual --foregroundDoes not query Slurm during normal dispatch. Use explicit commands such as lbatch capacity-check or lbatch reconcile when needed.
Summary:
lbatch statusGroups:
lbatch status --groupsUnits:
lbatch status --unitsJSON:
lbatch status --jsonCapacity:
lbatch capacity-checkDoctor:
lbatch doctorlbatch --parsable returns a local group ID:
a=$(lbatch --parsable --array=1-10 job/A.batch)
echo "$a"
# lb:g000001Run group B only after all units in group A have released:
a=$(lbatch --parsable --array=1-10 job/A.batch)
lbatch --lbatch-afterany "$a" --array=1-20 job/B.batchRun group B only after all units in group A release with exit code 0:
a=$(lbatch --parsable --array=1-10 job/A.batch)
lbatch --lbatch-afterok "$a" job/B.batchRun group B after group A releases and at least one upstream unit has nonzero or unknown exit code:
a=$(lbatch --parsable --array=1-10 job/A.batch)
lbatch --lbatch-afternotok "$a" job/B.batchNumeric Slurm dependencies are passed through. lb: dependencies are handled locally:
a=$(lbatch --parsable --array=1-10 job/A.batch)
lbatch --dependency=afterany:123456:$a job/C.batchIn this example:
lb:g000001is consumed bylbatchas a local dependency;123456remains in the remotesbatch --dependencyoption;- group C does not become locally eligible until group A releases.
lbatch expands arrays locally:
lbatch --array=1-300 job.batchThis creates 300 local units. Each unit is submitted to Slurm as a one-element array:
--array=1
--array=2
...
--array=300
Important filename behavior:
%aremains the intended array task ID.%Ais the Slurm job ID of the one-element remote array, not one shared ID for the original local array.
The wrapper exports compatibility variables:
LBATCH_GROUP_ID
LBATCH_UNIT_ID
LBATCH_ORIGINAL_ARRAY_SPEC
LBATCH_ARRAY_TASK_ID
LBATCH_ARRAY_TASK_COUNT
LBATCH_ARRAY_TASK_MIN
LBATCH_ARRAY_TASK_MAX
LBATCH_ARRAY_TASK_STEPlbatch does not override SLURM_JOB_ID, SLURM_ARRAY_JOB_ID, or SLURM_ARRAY_TASK_ID.
If a wrapper event is missing because of manual scancel, node failure, or another cluster issue, local units may remain REMOTE_VISIBLE.
Inspect:
lbatch status --unitsReconcile dry-run:
lbatch reconcile --dry-runApply reconciliation:
lbatch reconcile --applyManual release one unit:
lbatch release UNIT_IDManual release all remote-visible units in a group:
lbatch release --group lb:g000001Cancel a known remote Slurm job for one local unit:
lbatch cancel --unit UNIT_IDCancel all known remote Slurm jobs in a group:
lbatch cancel --group lb:g000001Cancel and locally force release:
lbatch cancel --group lb:g000001 --force-releaseCancellation does not automatically mean scientific failure. It is local queue cleanup plus Slurm cancellation.
Show config:
lbatch config showSet config:
lbatch config set max_remote_visible 20
lbatch config set capacity_mode local-owned
lbatch config set dispatch_batch_size 10Config is stored at:
~/.config/lbatch/config.json
Daemon command-line flags can override config for that daemon run:
lbatch daemon --max-remote-visible 5 --dispatch-batch-size 5 --foregroundThis section describes what happens when common operational failures occur and what you should do next.
If the daemon exits, is killed, loses its terminal, or the login session closes, the local queue is not lost. Queue state is durable in SQLite under ~/.local/share/lbatch/lbatch.db.
What happens:
QUEUEDunits stay queued locally.HELD_DEPENDENCYunits stay held locally.REMOTE_VISIBLEunits already submitted to Slurm keep running or pending in Slurm.- No new units are submitted while the daemon is stopped.
- Completed remote jobs may write release events, but those events are not ingested until a later
lbatch statusor daemon run.
Resume by starting the daemon again:
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundOr resume in the background:
nohup lbatch daemon --max-remote-visible 10 --capacity-mode local-owned > logs/lbatch-daemon.log 2>&1 &Recommended checks after restart:
lbatch doctor
lbatch status --groups --units
squeue -u "$USER"lbatch doctor recovers ambiguous SUBMITTING units. A unit stuck in SUBMITTING without a recorded Slurm job ID is returned to QUEUED and may be submitted again. This is part of the at-least-once delivery model.
There is one inherently ambiguous window: Slurm may accept a job, but the daemon may crash before storing the Slurm job ID in SQLite.
What happens:
- If the unit was
SUBMITTINGwith noslurm_job_id, restart recovery returns it toQUEUED. - That unit may be submitted again.
- This can create duplicate execution for the same logical unit.
What to do:
lbatch doctor
lbatch status --units
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundYour job scripts should be idempotent so duplicate execution is safe.
The wrapper writes a release event at job exit. If the daemon is not running, the event just sits in ~/.local/share/lbatch/events/.
What happens:
- The local unit may still show
REMOTE_VISIBLEuntil events are ingested. - Running
lbatch statusingests events. - Starting the daemon also ingests events before dispatching more work.
Resume:
lbatch status --groups --units
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundIf you run scancel manually, Slurm cancels the remote jobs, but lbatch does not automatically know your intent.
scancel 123456Possible outcomes:
- If the wrapper receives the signal and exits cleanly, it writes a release event.
lbatch statusor the daemon will mark the unitRELEASEDwith a signal-style exit code such as143. - If the job is canceled before the wrapper starts, or the node fails before the event is written, no release event exists. The unit can remain
REMOTE_VISIBLElocally. - In
local-ownedmode, a markerless canceled job still occupies local capacity until you reconcile or force release it.
After manual scancel, inspect state:
lbatch status --units
squeue -u "$USER"If units remain REMOTE_VISIBLE but are no longer in Slurm, use reconciliation:
lbatch reconcile --dry-run
lbatch reconcile --applyOr release explicitly:
lbatch release UNIT_ID
lbatch release --group lb:g000001When possible, cancel through lbatch so it can use known Slurm job IDs:
lbatch cancel --unit UNIT_ID
lbatch cancel --group lb:g000001If you also want to free local capacity immediately:
lbatch cancel --group lb:g000001 --force-releaseTradeoff: --force-release tells lbatch to stop counting those units locally even if no wrapper event is written. This is useful for cleanup, but it means downstream afterok dependencies will not be satisfied by those force-released units.
If sbatch returns a retryable limit error, such as QOSMaxJobsPerUserLimit or MaxSubmit, lbatch returns that unit from SUBMITTING to QUEUED and stops the current dispatch cycle.
What happens:
- The unit is not failed.
submit_attemptsincrements.last_errorrecords the Slurm rejection.- A later daemon cycle can try again.
Inspect errors:
sqlite3 ~/.local/share/lbatch/lbatch.db \
"select unit_id,state,submit_attempts,last_error from units where last_error is not null limit 20;"Resume with a smaller cap if needed:
lbatch daemon --max-remote-visible 2 --capacity-mode local-owned --foregroundIf sbatch rejects the job for a non-retryable reason, such as an invalid account, partition, QOS, script path, or malformed option, the unit becomes HELD_INVALID.
Inspect:
lbatch status --units
sqlite3 ~/.local/share/lbatch/lbatch.db \
"select unit_id,state,last_error from units where state='HELD_INVALID' limit 20;"Current MVP behavior is conservative: fix the underlying submission issue and submit a new corrected lbatch group. Do not edit the SQLite DB unless you are deliberately doing manual recovery.
If the compute node dies, the wrapper may not write its release JSON event.
What happens:
- Slurm may show the job as gone or terminal.
lbatchmay still show the unit asREMOTE_VISIBLE.- Local capacity remains occupied until reconciliation or manual release.
Recover:
lbatch reconcile --dry-run
lbatch reconcile --applyIf reconciliation cannot determine enough information, manually force release:
lbatch release UNIT_IDIf you delete ~/.local/share/lbatch/lbatch.db while Slurm jobs are still running, lbatch loses the mapping between local units and remote Slurm jobs.
What happens:
- Already submitted Slurm jobs continue independently.
- New
lbatch statusstarts from an empty queue. - Wrapper scripts may still write event files, but there are no matching unit records.
Avoid deleting the DB unless all related Slurm jobs are finished or canceled.
If you accidentally delete it, inspect and clean Slurm directly:
squeue -u "$USER"
scancel <jobid>SQLite uses WAL mode and the queue is durable, but an interruption can leave temporary files or stale locks.
Recover:
lbatch doctor
lbatch status --groups --units
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundIf a stale daemon lock prevents startup and you are sure no daemon is running:
ps -fu "$USER" | grep '[l]batch daemon'
rm -f ~/.local/state/lbatch/daemon.lockOnly remove the lock after confirming no daemon process is active.
Local dependencies use release metadata, not scientific interpretation except for afterok and afternotok.
afterany: downstream group is eligible after all upstream units areRELEASEDorFORCE_RELEASED.afterok: downstream group is eligible only if all upstream units areRELEASEDwith exit code0.afternotok: downstream group is eligible if all upstream units are released and at least one has nonzero or unknown exit code.
Important consequence:
- Manual
lbatch releasecreatesFORCE_RELEASEDunits. FORCE_RELEASEDsatisfiesafterany.FORCE_RELEASEDdoes not satisfyafterok.FORCE_RELEASEDcan satisfyafternotokbecause the exit code is unknown.
Expected. That script only queues local work. Start the daemon:
lbatch daemon --max-remote-visible 10 --capacity-mode local-owned --foregroundLogs are Slurm stdout/stderr files. They appear only after Slurm starts and runs wrapper jobs.
Check:
lbatch status --units
squeue -u "$USER"If units are HELD_INVALID, inspect errors:
sqlite3 ~/.local/share/lbatch/lbatch.db \
"select unit_id,state,last_error from units where last_error is not null limit 10;"Common causes:
- invalid partition;
- invalid account;
- invalid QOS;
- cluster does not allow the requested time/resources;
sbatchis not available on the node where the daemon is running.
lbatch requires Python >=3.10.
Check:
python --version
python -m pip --versionAdd the user bin directory:
export PATH="$HOME/.local/bin:$PATH"Put that line in your shell startup file if needed.
lbatch is intentionally at-least-once. A daemon crash after Slurm accepts a job but before the local DB records the Slurm job ID can cause resubmission.
Write job scripts to tolerate duplicates:
out="results/${SLURM_ARRAY_TASK_ID}.txt"
tmp="${out}.tmp.${SLURM_JOB_ID}"
done="${out}.done"
if [[ -s "$done" ]]; then
echo "Already done: ${SLURM_ARRAY_TASK_ID}"
exit 0
fi
run_analysis > "$tmp"
mv "$tmp" "$out"
touch "$done"Recommended rules:
- Check whether final output already exists and is valid.
- Write temporary outputs first.
- Atomically rename temporary outputs to final outputs.
- Use
.donemarkers where appropriate. - Avoid destructive overwrites.
Run tests:
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python -m unittest discover -s tests -vRun without installing:
PYTHONPATH=src python -m lbatch versionUseful local smoke test:
PYTHONPATH=src python -m lbatch --lbatch-dry-run --array=1-5 job/test_sleep.batch