←Projects

jr

2026

A local distributed job runner and scheduler in C++20: one scheduler, N workers, one CLI, zero dependencies.

C++20POSIX socketspthreadsCMakeGitHub

jr is a local distributed job runner: a small compute-cluster-in-a-binary built on nothing but C++20, POSIX sockets, and threads. One scheduler process owns the queue and tracks the worker fleet; worker daemons run tasks and report their capacity; a single jr binary is the CLI for all of it — submit, inspect, stream logs, drain nodes. The question behind the project: how much of a real cluster's behavior can you get in ~2,000 lines with no dependencies? The answer is most of it — dependency DAGs, retries with backoff, placement constraints, live log streaming, crash recovery, and hard resource limits are all here.

One binary, three roles.

The same executable runs as scheduler, worker, or CLI depending on its first argument. The scheduler (schedd) is the only stateful component — it holds the job queue, the worker registry, and every dependency edge. Workers are dumb: they register with a capacity, receive assignments, run fork+execvp, and report results. The CLI is thinner still — it opens a TCP connection, sends one frame, prints the reply.

jr across a small fleet
jr CLIsubmit · top · logsscheddqueue + statejournalappend-onlyworker aworker bworker …commandsrepliesassignheartbeat · results
One schedd process coordinates N workers on any host that can reach it. Workers heartbeat every 2 s; the CLI is stateless.

A wire protocol with no serialization library.

Every message on the wire is a length-prefixed TCP frame: a big-endian u32 length, a u8 type tag, then a payload of fields joined by 0x1f and inner lists by 0x1e. Eighteen message types cover the whole system. No protobuf, no codegen — parsing is a split on a byte value, and the entire codec is ~50 lines.

Anatomy of a frame
u32 len · big-endianu8 typepayload — len bytesbytes 0–3byte 4payload (Msg::Submit)name␟prio␟cpus␟mem␟env␞…␟argv␞…␟after␟require␟limit␟ = 0x1f field separator · ␞ = 0x1e record separator (lists inside a field)18 message types — no serialization library, no codegen
Field values are forbidden from containing either separator byte — checked at submit time and scrubbed from captured output.
DirectionMessages
worker → schedulerRegister, Heartbeat, TaskResult, LogData
cli → schedulerSubmit, ListJobs, JobInfo, CancelJob, ListWorkers, Stats, JobState, DrainWorker, SubLogs
scheduler → workerRegAck, Assign, Kill, LogSub
scheduler → cliReply, Error, LogChunk, LogEnd

A job's life, and the ways it can fail.

Dispatch is a sorted scan: queued jobs in (priority, submit-time) order get best-fit placement onto workers — the least free CPUs that still fit, honoring --require label constraints. A job that doesn't fit anywhere never blocks smaller jobs behind it. --after dependencies hold a job in blocked until every dependency is done; because ids are minted at submit time, dependency cycles are unconstructible rather than detected.

Job lifecycle
blockedqueuedrunningdonefailedcancelledall deps donebest-fit workerexit 0nonzero exit · worker lostretry while runs ≤ max_retries — backoff runs × 2sretries exhausted · dep failedjr cancel
Five states, one retry loop. Failed jobs requeue with linear backoff while runs ≤ max_retries; a failed dependency fails its dependents.

Failures are handled the way a real cluster would. Workers heartbeat every two seconds; a worker silent for six is marked lost and its running tasks' jobs are requeued, subject to the per-job retry cap.

Detecting a dead worker
t=0sbeatt=2sbeatt=4sbeatt=6s — marked losttasks requeuedsilence between t=4s and t=6s triggers requeue (subject to --retries)
The scheduler infers loss from silence — no failure detector, no timeout protocol. Three missed heartbeats and the work goes back in the queue.

Two more mechanisms keep the failure surface honest. --limit turns the scheduler's memory bookkeeping into a hard cap: the request becomes RLIMIT_AS on the child process. And kill is a process-group SIGKILL — setsid on the child at spawn — so a cancelled job takes its whole subtree down with it. jr drain marks a worker draining: running tasks finish, nothing new is assigned, and undrain resumes it.

Persistence is a text file.

Scheduler state lives in an append-only line journal — S records for submits, T records for state transitions (including run count, exit code, and a note), and a W watermark pinning the latest id. Replay it on startup and the entire queue is rebuilt; jobs that were running are requeued rather than lost. The journal is compacted on clean shutdown and every ~5,000 records.

The journal
S j-1 train.py --epochs 10 …S j-2 ./prep.sh …T j-1 queued→runningT j-2 queued→blockedW 3replayscheduler statequeue · workers · runsrunning jobs requeueon restart — nothingis lost mid-flightS = submit · T = transition · W = id watermarkcompacted on clean shutdown and every ~5000 records
Recovery is replay: every mutation is one line. No snapshots, no checkpoint format to keep in sync with the schema.

jr logs -f streams live output through the same plumbing: the worker tails the task's spool file and relays chunks through the scheduler to subscribed clients, and the last 48 KB are stored with every result so jr info always has a tail to show.

Scope.

The protocol is trusted-network — no auth or encryption yet, so it's for localhost and private LANs. Dispatch is O(queued × workers), fine to roughly 10⁴ jobs. RLIMIT_AS counts virtual address space, so mmap-heavy runtimes need a larger --mem than their RSS suggests. Within those bounds, the goal was a scheduler you can read end-to-end in an afternoon — and then actually use.