-
Notifications
You must be signed in to change notification settings - Fork 84
Extending Bazel
All JavaScript tools can benefit from Bazel integration, for more reproducible and scalable developer experiences.
Writing a Bazel rule requires some new skills:
- Skylark extension language
- Thinking in terms of inference engines
- Wrapping each tool so it conforms with Bazel’s hermeticity requirements
- Understanding idioms for use where performance is critical, and to allow novel composability
Of course you can read a lot from the official docs. This section is intended as the “missing overview” and also has Frontend-specific guidance.
The language for writing Bazel extensions (called skylark) uses Python syntax. But it doesn’t actually run as Python - instead this language is a DSL for making remote procedure calls to the Bazel server. This sounds obtuse and complex, but it has a benefit: it’s a scripting language that feels natural for glue code, yet the server has precise semantics and performance guarantees.
Sometimes you may let your Skylark program get complex. I once wrote a JSON marshaller that could walk a Python nested dict/struct data structure. It was totally clever, but when users started building a large number of libraries, it took minutes to run this code. So remember a rule of thumb: if you’re writing non-glue code in Skylark, you may want to introduce a quick local helper binary that can do the work.
Skylark doesn’t have great functional testing affordances. You can integration test your rules just by using them in an example within your repo, and testing that the rules build on CI. This means there is no way to test actions that fail to build. Next, you can do unit testing by just reading the skylark code with a regular py_test - the interpreter will read and execute the code. However the Skylark standard library and environment isn’t present in these tests, so you’ll have to do a lot of mocking. See example [TODO]
“Aspects” are like aspect-oriented programming. They let you visit part of the build tree, containing foreign black-box rules, and as long as the rule follows Bazel conventions like “srcs” vs. “deps”, you’ll be able to use the inputs of the rules you visit. You can also run some extra program on every node you visit, and produce extra outputs to read later down the tree. Learn aspects, because these are the truly innovative part of Bazel that I don’t think any other make-like build tool has. For example, [nodejs_sources aspect]
Most build tools are procedural: the user says to run something, and you implement how to run that thing. Bazel is not like this. Instead, the user asks for some output O, and Bazel tries to find the one rule R that declares an action A that can produce O. (Remember, rules produce a set of actions when run.) This action A has declared inputs I[], which Bazel then considers as more outputs it must produce. Then these are fed back to the beginning; again it searches for actions that produce all those outputs, and so on. Eventually Bazel walks some subgraph of the overall build graph, but only the part needed to produce O. That’s important - everything about Bazel is lazy, even the rules in the WORKSPACE file. They only get run if they are needed to produce the user’s output.
Now that Bazel knows what actions are needed, it decides which of them to run. For that action A, Bazel first calculates a checksum of each input. If these checksums are already in the cache, Bazel does nothing, it just returns the same output O from before. Imagine this like a form of change detection: we want to walk the interesting subgraph and put a dirty mark on any actions whose inputs are new.
If some inputs in I[] are different, then Bazel runs the actions, in whatever order it decides but one that satisfies the partial ordering constraint: namely that an action must run after one whose outputs it requires. Some of the actions are run remotely, or whatever.
Use bazel build –profile and bazel analyze-profile to understand the sequence of execution of the actions. You may need to shuffle them around, for example if a node in the graph is a “funnel point” and tends to have many inputs and many outputs, it will get run often (as one of the many inputs is more likely to change). Also, be careful to have a hermetic, predictable output from the action. Simply making a simple mistake like writing one of the outputs with a non-deterministicly sorted serialized hashmap will cause the output file to have a different checksum, causing unneeded dirty-marks and re-execution of actions later down the graph.
Bazel will mostly force you to declare the inputs to actions, provided that you don’t disable its sandbox. This requirement can be really annoying in cases where the action will need to read different files depending on how it’s called, and is especially hard in the JavaScript world where it’s normal for programs to scan around the filesystem looking for something that matches a convention. If the action needs to scan a filesystem subtree, you need to declare that whole subtree in the inputs.
There is a big benefit from doing this work. Later, when you need to scale up, you can use Bazel’s buildfarm to parallelize work across many machines on the cloud, and this is only possible when the exact same initial conditions can be set up to run your action on that cloud worker.
There are a handful of techniques for hosting an existing tool as a Bazel action. First, be very explicit in how you call the tool. Bazel will know the exact locations of all inputs and outputs.