How jobs are scheduled

When you queue a batch job — a mesh export, a shader build, a study run — something has to decide whether this machine starts it now, later, or at all. This page explains what that decision is made of, because four parts of it are easy to guess wrong.

A request and a limit are different questions

Every job can declare two numbers for each resource it uses, and they are not two names for the same thing.

A request is what must be free before the job starts. It is reserved for the whole life of the job and returned when the job finishes. If you request two cores, two cores are set aside for you, and nothing else gets them until you are done.

A limit is the ceiling above which the job is considered to be misbehaving. Nothing is reserved for it. It is a number the system measures your job against while it runs.

The practical difference: raising a request makes your job harder to place, because more has to be free before it can start. Raising a limit does not — it changes only what counts as too much once the job is already running.

A limit is not enforced

This surprises people who have used container schedulers, so it is worth being plain about it.

The system schedules against your limit and measures your actual use. It does not stop a job that exceeds its limit. Nothing is killed, throttled or sandboxed.

The reason is portability. Real enforcement needs operating-system mechanisms — cgroups, job objects, rlimits — that differ on every platform this project runs on, and behave differently again on Android. Honest declarations plus a hard admission budget keep a machine usable, and they do it identically everywhere.

What you get instead is visibility. A job over its limit is marked in the job detail view, and the system records what each job type declared against what it actually used. A job type that consistently asks for four times what it needs wastes three quarters of a reservation on every single run — and that is invisible without the record. The system reports the drift. A person fixes it by editing a number.

A service class is derived, never chosen

You do not pick a priority class. It follows from what you declared:

You declared Class What it means
Every resource has a request, and each request equals its limit Guaranteed Placed only where the full amount is free, and never deferred once admitted
Some request, and at least one below its limit Burstable Placed on the request, may use more up to the limit
Nothing at all BestEffort Placed wherever there is room, deferred first when the machine is busy

There is no setting for this and no override. The class is a consequence, so the way to change it is to change what you declare.

The job detail shows all three together — what the job asked for, its ceiling, and the class that follows:

The resource section of a job's detail: request, limit, and the derived service class

Declaring nothing is not a mistake. Every batch job that exists in OctoMY today declares nothing, and BestEffort is the right class for them. A thumbnail render should yield to a two-hour solve.

Deferring is not starving

Under pressure the system admits Guaranteed work before Burstable, and Burstable before BestEffort. On its own, that would mean a steady stream of Guaranteed jobs keeps a BestEffort job waiting forever.

So a waiting job's effective priority rises with how long it has waited, up to a ceiling. An old BestEffort job outranks a fresh Guaranteed one. It never outranks everything forever. And a job whose wait passes a threshold is surfaced in the interface, because starvation that is merely slow is still starvation.

A resource is named by the job, not by the system

The scheduler does not know what a core is.

octomy.io/cpu-milli and octomy.io/memory-bytes ship as conventions, and no code path treats them specially. If your job type measures its capacity in solver licences, camera streams or hashes per second, you declare that name and the scheduler honours it from the first run:

example.com/su2-processes    16
banter.io/hashes-per-second  500000

Three rules come with this, and each exists because of a specific failure:

  • Names carry a domain. cfd-cores is rejected; example.com/cfd-cores is accepted. Without the domain, the first two consumers to pick the same obvious word silently share one counter that means two different things to them.
  • Quantities are whole numbers. This is why the convention is cpu-milli rather than cpu — you express half a core as 500, not 0.5. A scheduler comparing floating-point capacities has a class of rounding bug that integers simply do not have.
  • Capacity is never overcommitted. If a node offers two of something and two jobs each hold one, a third job asking for one does not schedule there. The system does not gamble that a job will use less than it asked for.

What a node offers

A machine does not have to offer all of itself. It states a mode, and the mode scales what it advertises:

Mode For
Interactive Someone is using this machine right now
Background An idle desktop that still belongs to a person
Drone A machine that exists to run jobs
Off Accepts nothing

Two things make this more useful than it first looks.

The mode is a table, not a number. Interactive gives up half the cores and a quarter of the memory, and offers no GPU at all — a single percentage could not say that.

And a node answers separately for its own work and for other nodes' work. A workstation can run its own studies flat out while lending nothing to anybody, which is one setting rather than a special case, and is what most people actually want.

Changing the mode never disturbs a job that is already running. It changes what is admitted next.

Further reading

In this section