Typically, Bazel rules execute actions that usually correspond to tool processes on the host OS. Sometimes this behavior can incur startup costs, like bootstrapping a JVM or initializing a compiler. To work around that, Bazel has the concept of persistent workers.

A persistent worker is essentially a long-lived process that accepts work requests and responds with work responses. Imagine a process that keeps a compiler alive and dispatches sources to compile without paying the startup cost every time.

Creating a rule that leverages workers

Because this is a fairly advanced concept in Bazel, and usually only rule authors deal with it, I tried to come up with a simple example that demonstrates it.

An uppercase rule

We will write a rule that simply uppercases the text in a given file. To begin, we need to meet a few requirements. The first one is adding dependencies in our MODULE.bazel:

bazel_dep(name = "swift_argument_parser", version = "1.7.1")
bazel_dep(name = "rules_swift", version = "3.6.1")

These will come into play a bit later.

Creating a rule

Like I said, this is a simple rule, but the code may look a bit scary at first. Create uppercase.bzl at the root of the directory:

def _uppercase_impl(ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".out")
    args_file = ctx.actions.declare_file(ctx.label.name + ".worker_args")

    # These are the per-action arguments. Bazel will send these to the
    # persistent worker inside each WorkRequest.
    ctx.actions.write(
        output = args_file,
        content = "\n".join([
            "--input=" + ctx.file.src.path,
            "--output=" + out.path,
        ]),
    )

    ctx.actions.run(
        executable = ctx.executable._worker,
        inputs = [
            ctx.file.src,
            args_file,
        ],
        outputs = [out],
        arguments = [
            # For worker actions, the last argument is special:
            # it must be an @flagfile containing the per-request args.
            "@" + args_file.path,
        ],
        mnemonic = "UppercaseWorker",
        execution_requirements = {
            "supports-workers": "1",
            "requires-worker-protocol": "json",
        },
    )

    return [DefaultInfo(files = depset([out]))]


uppercase = rule(
    implementation = _uppercase_impl,
    attrs = {
        "src": attr.label(
            allow_single_file = True,
            mandatory = True,
        ),
        "_worker": attr.label(
            default = "//tools:worker",
            executable = True,
            cfg = "exec",
        ),
    },
)

The important part here is the arguments list. For worker actions, Bazel treats the last argument specially when it is an @flagfile. The contents of that file become the per-request arguments inside the WorkRequest. Any arguments before that are considered startup arguments for the worker process.

Now that the rule is in place, we need to create the actual worker binary. Because Swift is my language of choice, we will write it using Swift, but you can implement it in any language.

The Swift worker

Typically, I would split this out into multiple Swift files, but for the sake of simplicity, I will shove everything into one Swift file called worker.swift:

import Foundation
import ArgumentParser

struct WorkRequest: Decodable {
    var arguments: [String]?
    var requestId: Int?

    // Bazel may send other fields such as inputs, verbosity, etc.
    // JSONDecoder ignores unknown fields by default, which is what we want.
}

struct WorkResponse: Encodable {
    var requestId: Int
    var exitCode: Int
    var output: String
}

struct UppercaseArgs: ParsableArguments {
    @Option(name: .long)
    var input: String

    @Option(name: .long)
    var output: String
}

func expandArgs(_ args: [String]) throws -> [String] {
    var expanded: [String] = []

    for arg in args {
        if arg.hasPrefix("@") {
            let path = String(arg.dropFirst())
            let contents = try String(contentsOfFile: path, encoding: .utf8)

            for line in contents.split(separator: "\n") {
                let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)

                if !trimmed.isEmpty {
                    expanded.append(trimmed)
                }
            }
        } else {
            expanded.append(arg)
        }
    }

    return expanded
}

func runOne(_ rawArgs: [String]) throws {
    let args = try UppercaseArgs.parse(expandArgs(rawArgs))

    let inputText = try String(contentsOfFile: args.input, encoding: .utf8)

    try inputText.uppercased().write(
        toFile: args.output,
        atomically: true,
        encoding: .utf8
    )
}

func writeResponse(requestId: Int, exitCode: Int = 0, output: String = "") {
    let response = WorkResponse(
        requestId: requestId,
        exitCode: exitCode,
        output: output
    )

    do {
        let data = try JSONEncoder().encode(response)

        FileHandle.standardOutput.write(data)
        FileHandle.standardOutput.write(Data("\n".utf8))
    } catch {
        // Important: do not print normal logs to stdout.
        // In worker mode, stdout is reserved for WorkResponse JSON.
        FileHandle.standardError.write(
            Data("failed to encode WorkResponse: \(error)\n".utf8)
        )
        exit(1)
    }
}

func persistentLoop() {
    let decoder = JSONDecoder()

    while let line = readLine() {
        do {
            let request = try decoder.decode(
                WorkRequest.self,
                from: Data(line.utf8)
            )

            let requestId = request.requestId ?? 0
            let arguments = request.arguments ?? []

            do {
                try runOne(arguments)
                writeResponse(requestId: requestId)
            } catch {
                writeResponse(
                    requestId: requestId,
                    exitCode: 1,
                    output: String(describing: error)
                )
            }
        } catch {
            writeResponse(
                requestId: 0,
                exitCode: 1,
                output: "failed to decode WorkRequest: \(error)"
            )
        }
    }
}

@main
struct Worker {
    static func main() {
        let startupArgs = Array(CommandLine.arguments.dropFirst())

        if startupArgs.contains("--persistent_worker") {
            persistentLoop()
        } else {
            // Non-worker fallback path. This lets the same executable still work
            // when Bazel uses local execution instead of worker execution.
            do {
                try runOne(startupArgs)
            } catch {
                FileHandle.standardError.write(Data("\(error)\n".utf8))
                exit(1)
            }
        }
    }
}

A persistent worker has a small protocol contract with Bazel: it should accept the --persistent_worker flag, read WorkRequests from stdin, and write WorkResponses to stdout. If the same binary is run without --persistent_worker, it should behave like a normal one-shot tool. This fallback path is useful because Bazel may still run the action without the worker strategy.

One small but important detail: in worker mode, stdout belongs to the worker protocol. If you need to log something, write it to stderr instead.

I will not get into every detail here. I do expect the reader to be familiar with Swift and the general concept of Bazel workers.

We are missing the actual Bazel target for the worker at tools/BUILD.bazel:

load("@rules_swift//swift:swift_binary.bzl", "swift_binary")

swift_binary(
    name = "worker",
    srcs = ["worker.swift"],
    visibility = ["//visibility:public"],
    deps = ["@swift_argument_parser//:ArgumentParser"],
)

Trying out the rule

At the root, it is time to create a BUILD.bazel, load our rule, and build it:

load("//:uppercase.bzl", "uppercase")

uppercase(
    name = "hello",
    src = "hello.txt",
)

hello.txt is just a text file that I created to demonstrate the rule.

Building and verifying

To try out our new rule, execute:

bazel build :hello --spawn_strategy=worker,sandboxed --worker_verbose

We set --spawn_strategy=worker,sandboxed to make sure that our rule runs using the worker strategy and falls back to the standard sandboxed strategy. The fallback is important because there are actions that run because of rules_swift that do not necessarily use workers.

--worker_verbose is here just to make it easier to see that our worker is being used.

The output should look something like this:

INFO: Analyzed target //:hello (102 packages loaded, 649 targets configured, 2 aspect applications).
INFO: Created new non-sandboxed singleplex SwiftCompile worker (id 5, key hash -1813863811), logging to /Users/adincebic/Library/Caches/bazel/_bazel_adincebic/19f2a862cd16d28bfab74de8ca294508/bazel-workers/worker-5-SwiftCompile.log
INFO: Created new non-sandboxed singleplex UppercaseWorker worker (id 6, key hash -755134554), logging to /Users/adincebic/Library/Caches/bazel/_bazel_adincebic/19f2a862cd16d28bfab74de8ca294508/bazel-workers/worker-6-UppercaseWorker.log
INFO: Found 1 target...
Target //:hello up-to-date:
  bazel-bin/hello.out
INFO: Elapsed time: 19.851s, Critical Path: 19.03s
INFO: 60 processes: 30 internal, 26 darwin-sandbox, 4 worker.
INFO: Build completed successfully, 60 total actions

To verify the result, inspect bazel-bin/hello.out. It should contain the uppercase version of hello.txt.

And that’s it.

A few notes

This is the simplest example I could come up with, and it comes with a few caveats:

  • My worker implementation does not implement cancellation.
  • This is a singleplex worker, meaning Bazel sends it one request at a time.
  • The parsing logic could be more robust.
  • The worker ignores fields like inputs and verbosity from WorkRequest, which is fine for this example but probably not what you would do in a production worker.

Conclusion

This is one of those advanced Bazel concepts that you do not run into often, even if you write your own rules, purely because it is not always needed. But if you ever need persistent workers, I hope this gets you started.