Skip to main content

v2.78.0

Binaries and the merged-pull-request list are on the GitHub releases page.

A whole plan runs on the lane scheduler​

orun run with --concurrency above 1 (or a plan whose execution.concurrency is) used to schedule in batches: it launched every job that was ready, waited for all of them to finish, then looked again. A job that became ready when one job of the batch finished waited for the whole batch. A chain of four short jobs beside one long job took the long job's time plus the chain's, instead of the long job's alone.

The same run now uses the scheduler a lane uses: every job starts as soon as its own upstreams finish, at most concurrency at a time, and a free slot goes to the ready job with the longest chain of dependents behind it. The sequential path (--concurrency 1), --job and --lane are unchanged. A --job whose upstreams are not completed is now refused at any concurrency, as it already was at 1.

--component-concurrency defaults to no cap​

The flag capped how many components could have jobs in flight at once, and defaulted to 1 so a terminal run's output stayed grouped by component. With the batch loop, that default admitted one new component per batch: four independent jobs of four components at --concurrency 4 ran one after the other, four times slower than asked. It now defaults to 0, no cap. Set it explicitly to keep the grouping; a job of a new component then waits, holding no slot, until a component drains.

Measured on the regression tests that pin this: four independent 300 ms jobs at --concurrency 4 went from 1.2 s to 0.3 s, and a 1 s job beside a chain of four 100 ms jobs from 1.3 s to 1.0 s.

A lane says what it waits on​

Every lane in plan.json and every lane-matrix entry now carries its level in the lane graph (0 when no other lane feeds it, else one more than the deepest lane it waits on), the upstreamLanes it waits on and, on the plan, the upstreamJobs in them. orun plan --view lanes shows them.

A GitHub matrix fans every lane out at once, so on a pool with room for all of them a lane whose upstream lane is still running takes a runner and spends it polling; with promotion, the production lanes idle for the whole staging phase. orun plan --github-output therefore also writes lane-matrix-<k> for each level k, lane-matrix-levels and lane-level-count, so a workflow can run the levels as chained matrix jobs and never provision a runner before the lanes it waits on are done. A pool that reads upstreamLanes from the matrix can release each runner on exactly its own upstreams. The recipe is in Lanes. A plan without execution.lanes is unchanged; a lane-less lane-matrix entry carries the job's own depth and dependencies.

concurrency: auto and --max-lanes​

execution.lanes.concurrency: auto (on the lanes or on a group) sizes a lane to the machine that runs it: orun run --lane reads the runner's core count and runs that many jobs at once, so a pool that mixes runner sizes gets the right number on each and no intent needs a constant tuned to one instance type. The plan carries concurrency: 0 for such a lane; --concurrency still overrides it, and a plain count still works as before.

orun plan --max-lanes N (or ORUN_MAX_LANES=N) overrides the intent's maxLanes for one plan, so a CI plan job can set auto mode's lane budget from the runner pool's free capacity instead of from a constant in intent.yaml. The plan records the budget and where it came from in metadata.lanes (mode, maxLanes, maxLanesFrom: intent, default, flag or env), and the plan summary's lanes line names an override. A plan without execution.lanes is unchanged.

A lane installs once; a job's tree is the size of its component​

With per-job isolation on, each job of a lane got a private copy of the whole tree without node_modules, so each ran the install its composition declares: the install CI used to do once per runner was done once per job. And every copy carried .git, the repository's whole history.

Composition steps can now be scope: lane: a run step every job of a lane would execute identically (an install, a cache warm). The first job of the lane to reach it runs it once in the shared source tree; every other job whose step is the same after templating waits for that run and reuses it, recorded as shared with <job>. Lane-scoped steps come first in a job, and a job's private tree is staged after them, keeping the installed dependencies (the top-level node_modules linked to the source tree's, nested ones copied with their symlinks intact).

.git is never staged any more: the job's environment gets GIT_DIR and GIT_WORK_TREE, so git in the staged tree answers from the source repository. Every staged job gets TURBO_CACHE_DIR set to one cache per run, so the second job of a lane hits what the first built. And orun run --isolation component stages only the job's own directory and links everything else to the source tree, for jobs that write only under their own directory. The lanes concept page has the recipe; the composition contract and the execution-runtime page describe the rules.

Lanes packed by work, not by count​

Auto mode weighed every job one, so a lane of three long builds and a lane of three short deploys were the same size to it, and the run waited for the longest lane. A job template can now declare resources (cpu, memory, estimatedSeconds), and orun plan --weights history takes each job's median duration from the last ten sealed runs, or --weights <file> a committed file that --write-weights produces. With weights, auto mode shares the lane budget by work, cuts an oversized chain by weight, places the longest chain of work first and fills the lane with the least work so far. Every lane carries estimatedSeconds and, where a job declared cpu, vcpu, on the plan and on the lane matrix, so a pool can size a runner per lane; concurrency: auto divides the runner's cores by the lane's vcpu. metadata.lanes.weightsFrom records the source. A plan whose jobs carry no weights is laid out exactly as before.

A run drains on SIGTERM; a lost runner is noticed within two leases​

orun run had no signal handling: a reclaimed spot instance or a cancelled CI job killed it mid-step, the platform learned nothing until the lease lapsed, and a lane waiting on one of its jobs kept waiting — the client took a running job to be alive for five minutes after its last heartbeat, whatever lease the server had granted.

SIGTERM or SIGINT now drains the run: running steps are stopped (each step runs in a process group of its own, so its children stop with it), no further job is admitted, every cut job is reported failed with an interrupted error (to the platform too, so a dependent lane stops waiting at once), and the run exits with the shell's code for the signal; a second signal exits immediately. orun run --retry re-opens the cut jobs. The lease the server grants on claim and heartbeat is remembered, and a job waiting on a running upstream whose heartbeat is older than two leases treats it as having lost its runner and ends its wait naming it — in the --job claim loop and in a lane's upstream wait alike. Before any claim, the legacy five-minute window applies.

Waits are woken by events and bounded​

A job waiting on the platform — for its dependencies in the --job claim loop, or for an upstream in another lane — polled the run's state on a timer, 2 s backing off to 15 s, so a job started up to 15 s after its upstream finished and a chain of cross-lane edges idled a runner for most of a minute on polling alone. On Orunbase the wait is now woken by the run's next event, the same long-poll orun status --watch uses, and state is re-read only then or every 15 s; elsewhere it polls as before.

Two bounds nothing had: execution.defaults.timeout in intent.yaml bounds every step whose job and step set none, and the runner's own default is 60 minutes, so no step holds a runner for the CI limit (set the default higher for a platform whose longest step needs it); and execution.lanes.upstreamWait bounds a lane job's wait for an upstream in another lane (default 60 minutes).

These are milestones RE1 through RE7 of specs/orun-runner-efficiency.