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.