NativeLink

Platform properties

How the scheduler decides which worker gets an action, why a mismatch queues forever instead of failing, and how to read the matching diagnostics it already emits.

Who this is for: anyone whose actions are queueing, and anyone about to run more than one kind of worker. What you'll have at the end: an accurate model of the matching rules, plus the log lines that tell you which rule you tripped. Time: fifteen minutes.

Before you start

A cache serving hits to your build. If you don't have one yet, start with Getting started.

Platform properties are the only vocabulary a build tool and a worker fleet share. The action says I need this; the worker says I have this; the scheduler decides whether those two statements are compatible. No type checking happens across that boundary beyond what you configure, and no negotiation, which is why a typo produces a build that waits rather than a build that fails.

The three places a property appears

A property key has to be declared in all three, and they have to agree.

The scheduler's supported_platform_properties is the schema. It maps each key to a type, and that type decides how the value on the action is compared against the value on the worker. A key that isn't in this map is not a valid key anywhere, for either side.

PropertyType

The four property types

TypeWhat the action's value meansWhat it takes to match
minimumA numeric floor. Parsed as u64.The worker's value must be the action's.
exactA literal string.The worker's value must be byte-for-byte identical.
priorityInformational; passed through to the worker.Any value matches, but the worker must still have the key.
ignoreRequestable but unenforced.Always matches, and the worker need not have the key at all.

Most documentation you'll find elsewhere describes three of these. ignore exists and is the only one that exempts a worker from carrying the key.

is_satisfied_by

Why priority is not the escape hatch people assume

The matching loop checks key presence before it checks value compatibility. For every property the action requests, the scheduler looks the key up on the worker; if it isn't there, the worker is rejected, regardless of type. priority only tells the scheduler to stop caring about the value.

A worker that omits OSFamily entirely will never receive an action that requests OSFamily, even though the scheduler declares OSFamily as priority. This is why the example configs advertise OSFamily: { values: [""] }: an empty string is still a value, and presence is what's being tested. Only ignore skips the presence check.

minimum is a consumable resource, not a filter

This is the most consequential and least visible behaviour in the system.

When the scheduler assigns an action to a worker, it subtracts every minimum value the action requested from the worker's advertised value. When the action completes, it adds them back.

reduce_platform_properties

A worker advertising cpu_count: 16 that receives four actions each asking for cpu_count=4 is, at that moment, advertising cpu_count: 0, and will match nothing further until one of them finishes. minimum properties are therefore your real concurrency control, and cpu_count in particular is the knob that governs how many actions a worker runs at once.

Two consequences worth internalising:

  • An action requesting cpu_count=32 against workers that advertise 16 will never be scheduled. Not slowly; never. No bin-packing across workers happens, and no error is raised.
  • Setting cpu_count=1 on every action, as the getting-started examples do, means a 16-core worker runs 16 concurrent actions. That's usually what you want; it is a decision you're making either way.

max_inflight_tasks on the worker is the separate, blunter cap: it counts tasks rather than resources, and 0 means unlimited.

What happens when it doesn't match

The two failure modes are asymmetric, and knowing which one you're in tells you where to look.

An unknown key on a worker is a hard failure at registration. Worker properties go through the same type map as action properties, so a worker advertising a key that isn't in supported_platform_properties is rejected with Bad Property during connect_worker() and never joins the pool. You'll see this in the worker's log at startup, and the effect is a fleet that looks smaller than you provisioned.

connect_worker

An unknown key on an action queues forever. The scheduler types an action's properties at match time, not at submission time. When that typing fails, the scheduler logs Error while running do_try_match (wrapping Unknown platform property 'x'), moves on to the other queued actions, and leaves this one where it is; the action is not errored out and the client is not told anything. Because the pass ended in an error, the scheduler re-runs it after 100 ms, failing identically each time. From the build's side this is indistinguishable from a busy queue.

do_try_match

Reading the scheduler's own diagnostics

The scheduler has a built-in periodic dump of exactly this. It's on by default at a ten-second interval:

schedulers: [
  {
    name: "MAIN_SCHEDULER",
    simple: {
      supported_platform_properties: {
        cpu_count: "minimum",
        OSFamily: "priority",
        "container-image": "priority",
      },
      // Seconds between full match-attempt dumps. -1 or 0 disables.
      worker_match_logging_interval_s: 10,
    },
  },
]
SimpleSpec

On each interval it logs, for every queued action, why no worker took it, plus the oldest five actions in each state (Oldest actions in state). Matching happens in two passes, and each pass has its own lines. First a capability index narrows the fleet by key presence and by exact value:

No candidate workers due to a lack of key 'container-image'. Job asked for Priority("")
No candidate workers due to a lack of matching 'gpu' = Exact("a100"). Workers have: [Exact("none")]
No workers in capability index match required properties

Then the surviving candidates are checked for free capacity, which is where minimum values are compared:

Property mismatch on worker property cpu_count. Minimum(8) < Minimum(32)
Worker <id> cannot accept work: is_paused=false, is_draining=false, inflight=4/4
find_matching_workers

Read those lines literally, because they mean different things:

  • lack of key: no worker advertises the key at all (for a priority or minimum property). Add it to the worker's platform_properties (an empty string is fine for priority), or change the scheduler's declaration to ignore.
  • lack of matching: for an exact property, no worker carries that exact value; the line lists the values workers do have, so check for trailing whitespace and case. A worker that omits the key entirely shows up here too, with an empty list.
  • Property mismatch: the worker has the key, but its remaining minimum capacity (on the left) is below what the action asks for.
  • All workers are fully allocated or cannot accept work: the properties are fine; every worker is paused, draining, or at its max_inflight_tasks cap.

None of these appearing at all, while actions queue, points at typing rather than matching; look for Unknown platform property in the same log, which is the unknown-key-on-an-action case above.

The example config

scheduler_match_logging_disable.json5

shows the opposite setting for clusters where this output is too noisy in steady state.

A worked example: routing to a GPU pool

Say you have two worker pools and want tests tagged for GPU to land only on the GPU machines.

Scheduler: declare the key and its type once:

supported_platform_properties: {
  cpu_count: "minimum",
  OSFamily: "priority",
  gpu: "exact",
}

CPU workers: they can omit the key entirely; an exact key a worker doesn't carry never matches an action that requests it, and actions that don't request gpu are unaffected either way. Advertising a value no action asks for behaves the same and keeps the intent visible in the config:

platform_properties: {
  cpu_count: { query_cmd: "nproc" },
  OSFamily: { values: [""] },
  gpu: { values: ["none"] },
}

GPU workers:

platform_properties: {
  cpu_count: { query_cmd: "nproc" },
  OSFamily: { values: [""] },
  gpu: { values: ["a100"] },
}

The build: request it per target rather than globally:

# BUILD.bazel
cc_test(
    name = "kernel_test",
    exec_properties = {"gpu": "a100"},
    ...
)

Targets that say nothing about gpu still match every worker, because the scheduler only checks properties the action requested. The asymmetry is deliberate: adding a capability to your fleet doesn't invalidate the actions that don't need it.

Note query_cmd above: a worker property can be computed at startup by running a command (not a shell; the string is split into words shell-style with shlex and executed directly, with a cleared environment) and splitting its output on newlines, one value per line. nproc is the canonical use.

WorkerProperty make_connect_worker_request

About container-image

It appears in nearly every example config and it is worth being precise about what it does, because the name suggests something that isn't happening: NativeLink does not pull or launch a container per action. No code sits behind this key. It is an ordinary string property like any other.

What it actually means is this worker process is running inside this image, which the worker asserts, and the scheduler matches on. The value has to be byte-for-byte what your build requests, or actions queue forever in the usual silent way. Containers and images covers using it correctly.

Troubleshooting

SymptomLikely causeFix
Build sits at 0s remote, no errors anywhereNo worker satisfies the action's propertiesRead the match logging (on by default every 10 s; worker_match_logging_interval_s)
Unknown platform property 'x' repeating in scheduler logAction requests a key the scheduler doesn't declareAdd x to supported_platform_properties, or remove it from the build
Fewer workers in the pool than you startedWorker advertises a key the scheduler doesn't declareLook for Bad Property during connect_worker() in the worker's log
Property mismatch ... Minimum(0) < Minimum(1)Worker is fully allocated, not misconfiguredThis is backpressure; add workers or raise cpu_count
Works on one worker, queues on anotherThe pools advertise different key setsEvery worker needs every key the actions request
No candidate workers due to a lack of key 'OSFamily'Worker omits a priority or minimum keyAdd it with an empty value, or make it ignore
No candidate workers due to a lack of matching 'x' = Exact(...)No worker carries that exact valueCompare against the Workers have: list in the same line

FAQ

NextToolchains and hermeticity

The other wall: making sure the action a worker receives can actually run on it.

SidewaysContainers and images

What container-image really does, and how to keep worker images and build requests in sync.

On this page