Persistent Workers
Persistent workers are long-lived processes that Buck2 spawns once and reuses across multiple build actions, avoiding the overhead of repeatedly starting heavy processes like compilers. This is particularly useful for JVM-based tools where startup cost is significant. Workers are shared across actions within a single build command and are terminated when the command completes — they do not persist across separate invocations.
How worker identity works
Each call to WorkerInfo(...) in Starlark is assigned a unique internal ID.
Buck2 uses this ID to decide whether to reuse an existing worker process or
spawn a new one: actions that reference the same WorkerInfo instance share
the same worker process, while actions referencing different instances get
separate workers.
This means that where you construct WorkerInfo matters:
- Creating
WorkerInfoin a separate rule (and referencing it via a dep or toolchain) ensures all actions that use that dep share one worker process. - Creating
WorkerInfoinline in a rule implementation means each target gets its own worker, because each analysis call produces a newWorkerInfowith a new ID.
Defining a worker
A worker is defined by a rule that returns a WorkerInfo provider:
def _worker_impl(ctx: AnalysisContext) -> list[Provider]:
return [
DefaultInfo(),
WorkerInfo(
exe = ctx.attrs.exe[RunInfo],
concurrency = ctx.attrs.concurrency,
),
]
worker = rule(
impl = _worker_impl,
attrs = {
"exe": attrs.exec_dep(providers = [RunInfo]),
"concurrency": attrs.option(attrs.int(), default = None),
},
)
WorkerInfo accepts the following parameters:
exe: The command to start the worker process. Typically aRunInfoorcmd_args.concurrency: Optional maximum number of concurrent commands the worker can handle. WhenNone, Buck2 sends one command at a time.
Instantiate this in your BUCK file:
worker(
name = "my_compiler_worker",
exe = "//tools:my_compiler",
concurrency = 4,
)
Using a worker in an action
To run an action through a worker, pass a WorkerRunInfo
as the exe parameter to ctx.actions.run(). WorkerRunInfo bundles a worker
with a fallback command for when workers are disabled:
def _my_binary_impl(ctx: AnalysisContext) -> list[Provider]:
output = ctx.actions.declare_output(ctx.label.name)
# The positional arguments are what gets sent to the worker process.
args = cmd_args(ctx.attrs.source, output.as_output())
ctx.actions.run(
args,
category = "compile",
exe = WorkerRunInfo(
# Which worker process to use (identified by WorkerInfo instance)
worker = ctx.attrs._worker[WorkerInfo],
# Fallback executable when workers are disabled.
# Uses .args to extract the cmd_args from a RunInfo.
# Do NOT include action-specific arguments here — the positional
# arguments above are appended automatically in the fallback path.
exe = ctx.attrs._compiler[RunInfo].args,
),
)
return [DefaultInfo(default_outputs = [output])]
my_binary = rule(
impl = _my_binary_impl,
attrs = {
"source": attrs.source(),
"_compiler": attrs.exec_dep(
default = "//tools:my_compiler",
providers = [RunInfo],
),
"_worker": attrs.exec_dep(
default = "//:my_compiler_worker",
providers = [WorkerInfo],
),
},
)
When exe is set to a WorkerRunInfo:
- With workers enabled: The worker process (identified by
WorkerRunInfo.worker) receives only the positionalarguments.WorkerRunInfo.exeis unused. - Without workers (fallback): The final command is
WorkerRunInfo.exefollowed by the positionalarguments. SoWorkerRunInfo.exeshould only contain the executable and its fixed flags — do not include the action-specific arguments, as they are appended automatically.
Sharing workers via toolchains
The recommended way to share a single worker across many targets is through a toolchain. Define the worker as a target and reference it from the toolchain:
MyToolchainInfo = provider(fields = {
"compiler": provider_field(RunInfo),
"worker": provider_field(WorkerInfo),
})
def _my_toolchain_impl(ctx: AnalysisContext) -> list[Provider]:
return [
DefaultInfo(),
MyToolchainInfo(
compiler = ctx.attrs.compiler[RunInfo],
worker = ctx.attrs.worker[WorkerInfo],
),
]
my_toolchain = rule(
impl = _my_toolchain_impl,
is_toolchain_rule = True,
attrs = {
"compiler": attrs.exec_dep(providers = [RunInfo]),
"worker": attrs.exec_dep(providers = [WorkerInfo]),
},
)
Because both compiler and worker reference the same target instance across
all consumers of the toolchain, all actions using this toolchain share one
worker process.
Do not create WorkerInfo inline in a rule implementation if you intend to
share a worker across targets. Each call to WorkerInfo(...) produces a new
instance with a new ID, resulting in a separate worker process per target.
Protocols
Buck2 supports two persistent worker protocols:
- Local workers use a Buck2-specific gRPC protocol over Unix domain sockets.
Buck2 passes the socket path in the
WORKER_SOCKETenvironment variable. - Remote workers use the
Bazel persistent worker protocol
(length-prefixed protobuf over stdin/stdout). Enable this with
supports_bazel_remote_persistent_worker_protocol = TrueonWorkerInfo.
For remote workers, command-line arguments must be passed via an argument file
(@argfile).
Configuration
Workers can be enabled or disabled at the execution platform level via the
use_persistent_workers attribute on CommandExecutorConfig, or globally via
the build.use_persistent_workers buckconfig (defaults to True).