spoolctl and at/batch
Research date: 2026-09-21
spoolctl source baseline: main, commit f95600b
at/batch source baselines: macOS lineage — FreeBSD at and atrun, commit a6deeaa; Linux — Debian at (atd), commit 282d01d
Conclusion
at and batch are the original Unix way to run a command later. at runs it
at a set time. batch runs it when the machine is idle. Both save the command
as a spool file, and a periodic runner executes it once.
Use at or batch for a one-off command with nothing to install. It is on
almost every Unix box. echo 'make backup' | at 2am is the whole setup.
Use spoolctl when a job must retry on failure, run under a timeout, run in
parallel with a limit, or report machine-readable status. at and batch do
none of these. They run a job once and mail its output. There is no retry, no
timeout, no concurrency limit, and no JSON.
Scope and architecture
at writes the job as a spool file whose name encodes a queue letter and a job
number, under /var/at/jobs on macOS or /var/spool/cron/atjobs on Linux. The
queue letter sets the job’s nice value. a is the default at queue; b is
the default batch queue. batch uses an uppercase queue letter.
The spool file is not run by at itself. A separate runner is invoked on a
schedule and executes due jobs: atd on Linux, atrun on macOS. On macOS
atrun is a launchd job, com.apple.atrun, that is disabled by default. No
at job runs until an admin enables it.
The runner runs each due job once. The BSD/macOS atrun clears the file’s
execute bit before it forks the job; its scan only runs files that still have
the bit, so a job interrupted by a crash is never run again, and the spent file
is unlinked. The Linux atd hard-links the job to a = lock, runs it, then
unlinks the original once it is committed to the run. Neither runner confirms
that a dead job’s process is gone. batch runs at most one job per runner pass,
oldest first, and only while the load average is below a threshold (1.5 times
the CPU count by default). The job runs as /bin/sh. Its output is captured,
mailed to the user, and then deleted.
spoolctl has no periodic runner and no mail step. Jobs are rows in one SQLite file. A worker claims a job in one transaction and writes a heartbeat while it runs. Another worker reclaims the job only after the owner process is confirmed dead. See Guarantees.
Capability comparison
| Area | at / batch | spoolctl at f95600b |
|---|---|---|
| Primary job | Run a shell command once: at a time (at), or when load is low (batch). |
Run commands that must survive the death of any runner. |
| Coordinator | A periodic runner: atd (Linux) or atrun via launchd (macOS). |
None. One SQLite file. |
| Runner on macOS | com.apple.atrun, disabled by default. |
Workers are plain processes. |
| Queue state after a reboot | Durable. Spool files remain; the next runner pass executes due jobs. | Durable in SQLite. |
| Running job after the runner dies | macOS: not re-run (execute bit cleared first). Linux: a job caught before commit can be rescheduled once. No dead-owner check either way. | Reclaimed and run again only after the owner is confirmed dead. |
| Concurrency | at: every due job launches. batch: one job per pass, gated on load. No per-queue limit. |
Lanes, each with an optional fleet-wide --slots ceiling. |
| Automatic retry | No. | Yes. Exponential backoff, then dead. |
| Per-job timeout | No. | Yes. Default 300 s. Kills the process group. |
| Failure record | None. Output, including errors, is mailed and then deleted. | failed and dead states, attempt history, error codes. |
| Delayed start | Yes: at TIME. |
Yes: --after, --at. |
| Priority / dependencies | Queue letter sets nice only. No dependencies. |
--priority at submit; lanes. No dependencies. |
| Output | Mailed to the user, then unlinked. Not retrievable by another process. | Files per attempt; output verb from any process. |
| Machine interface | atq lists, atrm removes, at -c prints the script. No JSON. |
JSON envelope on every verb, stable error codes, schemas. |
| Install | Base system. On macOS, enable com.apple.atrun first. |
Python 3 standard library only. |
| Platforms | Unix. macOS ships it (runner off by default); Linux via the at package. |
macOS, Linux. |
What at/batch do well
- Nothing to install.
atis part of the base system on almost every Unix box. - One line schedules a job:
echo 'make backup' | at 2am. batchis a neat idle-time throttle. It holds work until the load drops.- A queued job is a plain spool file. It survives a reboot with no daemon running, and the next runner pass picks it up.
- Output arrives by mail, so a person needs no polling to read it.
What spoolctl does differently
- spoolctl retries.
atandbatchrun a job once; a failed command is just mailed output. - spoolctl bounds a job with a timeout and kills its process group.
atandbatchlet a job run forever. - spoolctl runs many jobs at once under a slot ceiling.
atlaunches every due job;batchruns one per pass. - spoolctl exposes JSON and stable error codes.
atandbatchexpose spool files and mail. - spoolctl confirms the owner process is dead before it re-runs a job.
atandbatchhave no such check.
Composition
The tools compose in one direction. Use at or cron to submit into spoolctl,
so the OS owns the schedule and spoolctl owns retry, timeout, and concurrency.
echo 'spoolctl add -- ./job.sh' | at 2am
They do not otherwise integrate.
Limits and claims not to make
- Do not say
at/batchlose the queue on a reboot. Spool files persist, and the next runner pass runs the due jobs. For a queued job,atis more durable than a process-based queue such as nq. - Do not say
atre-runs a failed job. It never retries. A nonzero exit is mailed, not retried. - Do not claim the two implementations behave the same on a mid-run crash.
macOS/BSD
atrunnever re-runs; Linuxatdcan reschedule a job caught before it commits. Neither confirms the owner is dead. - Do not say
batchruns jobs in parallel. It runs at most one job per runner pass. - Do not say
atneeds a persistent daemon. The runner is invoked periodically and exits.
Sources
- FreeBSD
atrun.c— clears the execute bit before the run; the scan andbatch/load logic are at L536-L567, the default load at L84. - FreeBSD
at.c— queue letters and the defaultat/batchqueues. - Debian
atd.c— hard-link lock and the “restart if something went wrong” window. at(1)/batch(1)manual pages:batchruns “when the load average drops below 1.5 times number of active CPUs, or the value specified in the invocation of atrun.”- macOS
/System/Library/LaunchDaemons/com.apple.atrun.plist:Disabled = 1. - spoolctl guarantees