| Project: /_project.yaml |
| Book: /_book.yaml |
| |
| # A Guide to Skyframe `StateMachine`s |
| |
| {% include "_buttons.html" %} |
| |
| ## Overview |
| |
| A Skyframe `StateMachine` is a *deconstructed* function-object that resides on |
| the heap. It supports flexible and evaluation without redundancy[^1] when |
| required values are not immediately available but computed asynchronously. The |
| `StateMachine` cannot tie up a thread resource while waiting, but instead has to |
| be suspended and resumed. The deconstruction thus exposes explicit re-entry |
| points so that prior computations can be skipped. |
| |
| `StateMachine`s can be used to express sequences, branching, structured logical |
| concurrency and are tailored specifically for Skyframe interaction. |
| `StateMachine`s can be composed into larger `StateMachine`s and share |
| sub-`StateMachine`s. Concurrency is always hierarchical by construction and |
| purely logical. Every concurrent subtask runs in the single shared parent |
| SkyFunction thread. |
| |
| ## Introduction |
| |
| This section briefly motivates and introduces `StateMachine`s, found in the |
| [`java.com.google.devtools.build.skyframe.state`](https://github.com/bazelbuild/bazel/tree/master/src/main/java/com/google/devtools/build/skyframe/state) |
| package. |
| |
| ### A brief introduction to Skyframe restarts |
| |
| Skyframe is a framework that performs parallel evaluation of dependency graphs. |
| Each node in the graph corresponds with the evaluation of a SkyFunction with a |
| SkyKey specifying its parameters and SkyValue specifying its result. The |
| computational model is such that a SkyFunction may lookup SkyValues by SkyKey, |
| triggering recursive, parallel evaluation of additional SkyFunctions. Instead of |
| blocking, which would tie up a thread, when a requested SkyValue is not yet |
| ready because some subgraph of computation is incomplete, the requesting |
| SkyFunction observes a `null` `getValue` response and should return `null` |
| instead of a SkyValue, signaling that it is incomplete due to missing inputs. |
| Skyframe *restarts* the SkyFunctions when all previously requested SkyValues |
| become available. |
| |
| Before the introduction of `SkyKeyComputeState`, the traditional way of handling |
| a restart was to fully rerun the computation. Although this has quadratic |
| complexity, functions written this way eventually complete because each rerun, |
| fewer lookups return `null`. With `SkyKeyComputeState` it is possible to |
| associate hand-specified check-point data with a SkyFunction, saving significant |
| recomputation. |
| |
| `StateMachine`s are objects that live inside `SkyKeyComputeState` and eliminate |
| virtually all recomputation when a SkyFunction restarts (assuming that |
| `SkyKeyComputeState` does not fall out of cache) by exposing suspend and resume |
| execution hooks. |
| |
| ### Stateful computations inside `SkyKeyComputeState` {:#stateful-computations} |
| |
| From an object-oriented design standpoint, it makes sense to consider storing |
| computational objects inside `SkyKeyComputeState` instead of pure data values. |
| In *Java*, the bare minimum description of a behavior carrying object is a |
| *functional interface* and it turns out to be sufficient. A `StateMachine` has |
| the following, curiously recursive, definition[^2]. |
| |
| ``` |
| @FunctionalInterface |
| public interface StateMachine { |
| StateMachine step(Tasks tasks) throws InterruptedException; |
| } |
| ``` |
| |
| The `Tasks` interface is analogous to `SkyFunction.Environment` but it is |
| designed for asynchrony and adds support for logically concurrent subtasks[^3]. |
| |
| The return value of `step` is another `StateMachine`, allowing the specification |
| of a sequence of steps, inductively. `step` returns `DONE` when the |
| `StateMachine` is done. For example: |
| |
| ``` |
| class HelloWorld implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| System.out.println("hello"); |
| return this::step2; // The next step is HelloWorld.step2. |
| } |
| |
| private StateMachine step2(Tasks tasks) { |
| System.out.println("world"); |
| // DONE is special value defined in the `StateMachine` interface signaling |
| // that the computation is done. |
| return DONE; |
| } |
| } |
| ``` |
| |
| describes a `StateMachine` with the following output. |
| |
| ``` |
| hello |
| world |
| ``` |
| |
| Note that the method reference `this::step2` is also a `StateMachine` due to |
| `step2` satisfying `StateMachine`'s functional interface definition. Method |
| references are the most common way to specify the next state in a |
| `StateMachine`. |
| |
|  |
| |
| Intuitively, breaking a computation down into `StateMachine` steps, instead of a |
| monolithic function, provides the hooks needed to *suspend* and *resume* a |
| computation. When `StateMachine.step` returns, there is an explicit *suspension* |
| point. The continuation specified by the returned `StateMachine` value is an |
| explicit *resume* point. Recomputation can thus be avoided because the |
| computation can be picked up exactly where it left off. |
| |
| ### Callbacks, continuations and asynchronous computation |
| |
| In technical terms, a `StateMachine` serves as a *continuation*, determining the |
| subsequent computation to be executed. Instead of blocking, a `StateMachine` can |
| voluntarily *suspend* by returning from the `step` function, which transfers |
| control back to a [`Driver`](#drivers-and-bridging) instance. The `Driver` can |
| then switch to a ready `StateMachine` or relinquish control back to Skyframe. |
| |
| Traditionally, *callbacks* and *continuations* are conflated into one concept. |
| However, `StateMachine`s maintain a distinction between the two. |
| |
| * *Callback* - describes where to store the result of an asynchronous |
| computation. |
| * *Continuation* - specifies the next execution state. |
| |
| Callbacks are required when invoking an asynchronous operation, which means that |
| the actual operation doesn't occur immediately upon calling the method, as in |
| the case of a SkyValue lookup. Callbacks should be kept as simple as possible. |
| |
| Caution: A common pitfall of callbacks is that the asynchronous computation must |
| ensure the callback is called by the end of every reachable path. It's possible |
| to overlook some branches and the compiler doesn't give warnings about this. |
| |
| *Continuations* are the `StateMachine` return values of `StateMachine`s and |
| encapsulate the complex execution that follows once all asynchronous |
| computations resolve. This structured approach helps to keep the complexity of |
| callbacks manageable. |
| |
| ## Tasks |
| |
| The `Tasks` interface provides `StateMachine`s with an API to lookup SkyValues |
| by SkyKey and to schedule concurrent subtasks. |
| |
| ``` |
| interface Tasks { |
| void enqueue(StateMachine subtask); |
| |
| void lookUp(SkyKey key, Consumer<SkyValue> sink); |
| |
| <E extends Exception> |
| void lookUp(SkyKey key, Class<E> exceptionClass, ValueOrExceptionSink<E> sink); |
| |
| // lookUp overloads for 2 and 3 exception types exist, but are elided here. |
| } |
| ``` |
| |
| Tip: When any state uses the `Tasks` interface to perform lookups or create |
| subtasks, those lookups and subtasks will complete before the next state begins. |
| |
| Tip: (Corollary) If subtasks are complex `StateMachine`s or recursively create |
| subtasks, they all *transitively* complete before the next state begins. |
| |
| ### SkyValue lookups {:#skyvalue-lookups} |
| |
| `StateMachine`s use `Tasks.lookUp` overloads to look up SkyValues. They are |
| analogous to `SkyFunction.Environment.getValue` and |
| `SkyFunction.Environment.getValueOrThrow` and have similar exception handling |
| semantics. The implementation does not immediately perform the lookup, but |
| instead, batches[^4] as many lookups as possible before doing so. The value |
| might not be immediately available, for example, requiring a Skyframe restart, |
| so the caller specifies what to do with the resulting value using a callback. |
| |
| The `StateMachine` processor ([`Driver`s and bridging to |
| SkyFrame](#drivers-and-bridging)) guarantees that the value is available before |
| the next state begins. An example follows. |
| |
| ``` |
| class DoesLookup implements StateMachine, Consumer<SkyValue> { |
| private Value value; |
| |
| @Override |
| public StateMachine step(Tasks tasks) { |
| tasks.lookUp(new Key(), (Consumer<SkyValue>) this); |
| return this::processValue; |
| } |
| |
| // The `lookUp` call in `step` causes this to be called before `processValue`. |
| @Override // Implementation of Consumer<SkyValue>. |
| public void accept(SkyValue value) { |
| this.value = (Value)value; |
| } |
| |
| private StateMachine processValue(Tasks tasks) { |
| System.out.println(value); // Prints the string representation of `value`. |
| return DONE; |
| } |
| } |
| ``` |
| |
| In the above example, the first step does a lookup for `new Key()`, passing |
| `this` as the consumer. That is possible because `DoesLookup` implements |
| `Consumer<SkyValue>`. |
| |
| Tip: When passing `this` as a value sink, it's helpful to readers to upcast it |
| to the receiver type to narrow down the purpose of passing `this`. The example |
| passes `(Consumer<SkyValue>) this`. |
| |
| By contract, before the next state `DoesLookup.processValue` begins, all the |
| lookups of `DoesLookup.step` are complete. Therefore `value` is available when |
| it is accessed in `processValue`. |
| |
| ### Subtasks |
| |
| `Tasks.enqueue` requests the execution of logically concurrent subtasks. |
| Subtasks are also `StateMachine`s and can do anything regular `StateMachine`s |
| can do, including recursively creating more subtasks or looking up SkyValues. |
| Much like `lookUp`, the state machine driver ensures that all subtasks are |
| complete before proceeding to the next step. An example follows. |
| |
| ``` |
| class Subtasks implements StateMachine { |
| private int i = 0; |
| |
| @Override |
| public StateMachine step(Tasks tasks) { |
| tasks.enqueue(new Subtask1()); |
| tasks.enqueue(new Subtask2()); |
| // The next step is Subtasks.processResults. It won't be called until both |
| // Subtask1 and Subtask 2 are complete. |
| return this::processResults; |
| } |
| |
| private StateMachine processResults(Tasks tasks) { |
| System.out.println(i); // Prints "3". |
| return DONE; // Subtasks is done. |
| } |
| |
| private class Subtask1 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| i += 1; |
| return DONE; // Subtask1 is done. |
| } |
| } |
| |
| private class Subtask2 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| i += 2; |
| return DONE; // Subtask2 is done. |
| } |
| } |
| } |
| ``` |
| |
| Though `Subtask1` and `Subtask2` are logically concurrent, everything runs in a |
| single thread so the "concurrent" update of `i` does not need any |
| synchronization. |
| |
| ### Structured concurrency {:#structured-concurrency} |
| |
| Since every `lookUp` and `enqueue` must resolve before advancing to the next |
| state, it means that concurrency is naturally limited to tree-structures. It's |
| possible to create hierarchical[^5] concurrency as shown in the following |
| example. |
| |
|  |
| |
| It's hard to tell from the *UML* that the concurrency structure forms a tree. |
| There's an [alternate view](#concurrency-tree-diagram) that better shows the |
| tree structure. |
| |
|  |
| |
| Structured concurrency is much easier to reason about. |
| |
| ## Composition and control flow patterns |
| |
| This section presents examples for how multiple `StateMachine`s can be composed |
| and solutions to certain control flow problems. |
| |
| ### Sequential states |
| |
| This is the most common and straightforward control flow pattern. An example of |
| this is shown in [Stateful computations inside |
| `SkyKeyComputeState`](#stateful-computations). |
| |
| ### Branching |
| |
| Branching states in `StateMachine`s can be achieved by returning different |
| values using regular *Java* control flow, as shown in the following example. |
| |
| ``` |
| class Branch implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| // Returns different state machines, depending on condition. |
| if (shouldUseA()) { |
| return this::performA; |
| } |
| return this::performB; |
| } |
| … |
| } |
| ``` |
| |
| It’s very common for certain branches to return `DONE`, for early completion. |
| |
| ### Advanced sequential composition |
| |
| Since the `StateMachine` control structure is memoryless, sharing `StateMachine` |
| definitions as subtasks can sometimes be awkward. Let *M<sub>1</sub>* and |
| *M<sub>2</sub>* be `StateMachine` instances that share a `StateMachine`, *S*, |
| with *M<sub>1</sub>* and *M<sub>2</sub>* being the sequences *<A, S, B>* and |
| *<X, S, Y>* respectively. The problem is that *S* doesn’t know whether to |
| continue to *B* or *Y* after it completes and `StateMachine`s don't quite keep a |
| call stack. This section reviews some techniques for achieving this. |
| |
| #### `StateMachine` as terminal sequence element |
| |
| This doesn’t solve the initial problem posed. It only demonstrates sequential |
| composition when the shared `StateMachine` is terminal in the sequence. |
| |
| ``` |
| // S is the shared state machine. |
| class S implements StateMachine { … } |
| |
| class M1 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| performA(); |
| return new S(); |
| } |
| } |
| |
| class M2 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| performX(); |
| return new S(); |
| } |
| } |
| ``` |
| |
| This works even if *S* is itself a complex state machine. |
| |
| #### Subtask for sequential composition |
| |
| Since enqueued subtasks are guaranteed to complete before the next state, it’s |
| sometimes possible to slightly abuse[^6] the subtask mechanism. |
| |
| ``` |
| class M1 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| performA(); |
| // S starts after `step` returns and by contract must complete before `doB` |
| // begins. It is effectively sequential, inducing the sequence < A, S, B >. |
| tasks.enqueue(new S()); |
| return this::doB; |
| } |
| |
| private StateMachine doB(Tasks tasks) { |
| performB(); |
| return DONE; |
| } |
| } |
| |
| class M2 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| performX(); |
| // Similarly, this induces the sequence < X, S, Y>. |
| tasks.enqueue(new S()); |
| return this::doY; |
| } |
| |
| private StateMachine doY(Tasks tasks) { |
| performY(); |
| return DONE; |
| } |
| } |
| ``` |
| |
| #### `runAfter` injection {:#runafter-injection} |
| |
| Sometimes, abusing `Tasks.enqueue` is impossible because there are other |
| parallel subtasks or `Tasks.lookUp` calls that must be completed before *S* |
| executes. In this case, injecting a `runAfter` parameter into *S* can be used to |
| inform *S* of what to do next. |
| |
| ``` |
| class S implements StateMachine { |
| // Specifies what to run after S completes. |
| private final StateMachine runAfter; |
| |
| @Override |
| public StateMachine step(Tasks tasks) { |
| … // Performs some computations. |
| return this::processResults; |
| } |
| |
| @Nullable |
| private StateMachine processResults(Tasks tasks) { |
| … // Does some additional processing. |
| |
| // Executes the state machine defined by `runAfter` after S completes. |
| return runAfter; |
| } |
| } |
| |
| class M1 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| performA(); |
| // Passes `this::doB` as the `runAfter` parameter of S, resulting in the |
| // sequence < A, S, B >. |
| return new S(/* runAfter= */ this::doB); |
| } |
| |
| private StateMachine doB(Tasks tasks) { |
| performB(); |
| return DONE; |
| } |
| } |
| |
| class M2 implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks) { |
| performX(); |
| // Passes `this::doY` as the `runAfter` parameter of S, resulting in the |
| // sequence < X, S, Y >. |
| return new S(/* runAfter= */ this::doY); |
| } |
| |
| private StateMachine doY(Tasks tasks) { |
| performY(); |
| return DONE; |
| } |
| } |
| ``` |
| |
| This approach is cleaner than abusing subtasks. However, applying this too |
| liberally, for example, by nesting multiple `StateMachine`s with `runAfter`, is |
| the road to [Callback Hell](#callback-hell). It’s better to break up sequential |
| `runAfter`s with ordinary sequential states instead. |
| |
| ``` |
| return new S(/* runAfter= */ new T(/* runAfter= */ this::nextStep)) |
| ``` |
| |
| can be replaced with the following. |
| |
| ``` |
| private StateMachine step1(Tasks tasks) { |
| doStep1(); |
| return new S(/* runAfter= */ this::intermediateStep); |
| } |
| |
| private StateMachine intermediateStep(Tasks tasks) { |
| return new T(/* runAfter= */ this::nextStep); |
| } |
| ``` |
| |
| Note: It's possible to pass `DONE` as the `runAfter` parameter when there's |
| nothing to run afterwards. |
| |
| Tip: When using `runAfter`, always annotate the parameter with `/* runAfter= */` |
| to let the reader know the meaning at the callsite. |
| |
| #### *Forbidden* alternative: `runAfterUnlessError` |
| |
| In an earlier draft, we had considered a `runAfterUnlessError` that would abort |
| early on errors. This was motivated by the fact that errors often end up getting |
| checked twice, once by the `StateMachine` that has a `runAfter` reference and |
| once by the `runAfter` machine itself. |
| |
| After some deliberation, we decided that uniformity of the code is more |
| important than deduplicating the error checking. It would be confusing if the |
| `runAfter` mechanism did not work in a consistent manner with the |
| `tasks.enqueue` mechanism, which always requires error checking. |
| |
| Warning: When using `runAfter`, the machine that has the injected `runAfter` |
| should invoke it unconditionally at completion, even on error, for consistency. |
| |
| ### Direct delegation |
| |
| Each time there is a formal state transition, the main `Driver` loop advances. |
| As per contract, advancing states means that all previously enqueued SkyValue |
| lookups and subtasks resolve before the next state executes. Sometimes the logic |
| of a delegate `StateMachine` makes a phase advance unnecessary or |
| counterproductive. For example, if the first `step` of the delegate performs |
| SkyKey lookups that could be parallelized with lookups of the delegating state |
| then a phase advance would make them sequential. It could make more sense to |
| perform direct delegation, as shown in the example below. |
| |
| ``` |
| class Parent implements StateMachine { |
| @Override |
| public StateMachine step(Tasks tasks ) { |
| tasks.lookUp(new Key1(), this); |
| // Directly delegates to `Delegate`. |
| // |
| // The (valid) alternative: |
| // return new Delegate(this::afterDelegation); |
| // would cause `Delegate.step` to execute after `step` completes which would |
| // cause lookups of `Key1` and `Key2` to be sequential instead of parallel. |
| return new Delegate(this::afterDelegation).step(tasks); |
| } |
| |
| private StateMachine afterDelegation(Tasks tasks) { |
| … |
| } |
| } |
| |
| class Delegate implements StateMachine { |
| private final StateMachine runAfter; |
| |
| Delegate(StateMachine runAfter) { |
| this.runAfter = runAfter; |
| } |
| |
| @Override |
| public StateMachine step(Tasks tasks) { |
| tasks.lookUp(new Key2(), this); |
| return …; |
| } |
| |
| // Rest of implementation. |
| … |
| |
| private StateMachine complete(Tasks tasks) { |
| … |
| return runAfter; |
| } |
| } |
| ``` |
| |
| ## Data flow |
| |
| The focus of the previous discussion has been on managing control flow. This |
| section describes the propagation of data values. |
| |
| ### Implementing `Tasks.lookUp` callbacks |
| |
| There’s an example of implementing a `Tasks.lookUp` callback in [SkyValue |
| lookups](#skyvalue-lookups). This section provides rationale and suggests |
| approaches for handling multiple SkyValues. |
| |
| #### `Tasks.lookUp` callbacks {:#tasks-lookup-callbacks} |
| |
| The `Tasks.lookUp` method takes a callback, `sink`, as a parameter. |
| |
| ``` |
| void lookUp(SkyKey key, Consumer<SkyValue> sink); |
| ``` |
| |
| The idiomatic approach would be to use a *Java* lambda to implement this: |
| |
| ``` |
| tasks.lookUp(key, value -> myValue = (MyValueClass)value); |
| ``` |
| |
| with `myValue` being a member variable of the `StateMachine` instance doing the |
| lookup. However, the lambda requires an extra memory allocation compared to |
| implementing the `Consumer<SkyValue>` interface in the `StateMachine` |
| implementation. The lambda is still useful when there are multiple lookups that |
| would be ambiguous. |
| |
| Note: Bikeshed warning. There is a noticeable difference of approximately 1% |
| end-to-end CPU usage when implementing callbacks systematically in |
| `StateMachine` implementations compared to using lambdas, which makes this |
| recommendation debatable. To avoid unnecessary debates, it is advised to leave |
| the decision up to the individual implementing the solution. |
| |
| There are also error handling overloads of `Tasks.lookUp`, that are analogous to |
| `SkyFunction.Environment.getValueOrThrow`. |
| |
| ``` |
| <E extends Exception> void lookUp( |
| SkyKey key, Class<E> exceptionClass, ValueOrExceptionSink<E> sink); |
| |
| interface ValueOrExceptionSink<E extends Exception> { |
| void acceptValueOrException(@Nullable SkyValue value, @Nullable E exception); |
| } |
| ``` |
| |
| An example implementation is shown below. |
| |
| ``` |
| class PerformLookupWithError extends StateMachine, ValueOrExceptionSink<MyException> { |
| private MyValue value; |
| private MyException error; |
| |
| @Override |
| public StateMachine step(Tasks tasks) { |
| tasks.lookUp(new MyKey(), MyException.class, ValueOrExceptionSink<MyException>) this); |
| return this::processResult; |
| } |
| |
| @Override |
| public acceptValueOrException(@Nullable SkyValue value, @Nullable MyException exception) { |
| if (value != null) { |
| this.value = (MyValue)value; |
| return; |
| } |
| if (exception != null) { |
| this.error = exception; |
| return; |
| } |
| throw new IllegalArgumentException("Both parameters were unexpectedly null."); |
| } |
| |
| private StateMachine processResult(Tasks tasks) { |
| if (exception != null) { |
| // Handles the error. |
| … |
| return DONE; |
| } |
| // Processes `value`, which is non-null. |
| … |
| } |
| } |
| ``` |
| |
| As with lookups without error handling, having the `StateMachine` class directly |
| implement the callback saves a memory allocation for the lamba. |
| |
| [Error handling](#error-handling) provides a bit more detail, but essentially, |
| there's not much difference between the propagation of errors and normal values. |
| |
| #### Consuming multiple SkyValues |
| |
| Multiple SkyValue lookups are often required. An approach that works much of the |
| time is to switch on the type of SkyValue. The following is an example that has |
| been simplified from prototype production code. |
| |
| ``` |
| @Nullable |
| private StateMachine fetchConfigurationAndPackage(Tasks tasks) { |
| var configurationKey = configuredTarget.getConfigurationKey(); |
| if (configurationKey != null) { |
| tasks.lookUp(configurationKey, (Consumer<SkyValue>) this); |
| } |
| |
| var packageId = configuredTarget.getLabel().getPackageIdentifier(); |
| tasks.lookUp(PackageValue.key(packageId), (Consumer<SkyValue>) this); |
| |
| return this::constructResult; |
| } |
| |
| @Override // Implementation of `Consumer<SkyValue>`. |
| public void accept(SkyValue value) { |
| if (value instanceof BuildConfigurationValue) { |
| this.configurationValue = (BuildConfigurationValue) value; |
| return; |
| } |
| if (value instanceof PackageValue) { |
| this.pkg = ((PackageValue) value).getPackage(); |
| return; |
| } |
| throw new IllegalArgumentException("unexpected value: " + value); |
| } |
| ``` |
| |
| The `Consumer<SkyValue>` callback implementation can be shared unambiguously |
| because the value types are different. When that’s not the case, falling back to |
| lambda-based implementations or full inner-class instances that implement the |
| appropriate callbacks is viable. |
| |
| ### Propagating values between `StateMachine`s {:#propagating-values} |
| |
| So far, this document has only explained how to arrange work in a subtask, but |
| subtasks also need to report a values back to the caller. Since subtasks are |
| logically asynchronous, their results are communicated back to the caller using |
| a *callback*. To make this work, the subtask defines a sink interface that is |
| injected via its constructor. |
| |
| ``` |
| class BarProducer implements StateMachine { |
| // Callers of BarProducer implement the following interface to accept its |
| // results. Exactly one of the two methods will be called by the time |
| // BarProducer completes. |
| interface ResultSink { |
| void acceptBarValue(Bar value); |
| void acceptBarError(BarException exception); |
| } |
| |
| private final ResultSink sink; |
| |
| BarProducer(ResultSink sink) { |
| this.sink = sink; |
| } |
| |
| … // StateMachine steps that end with this::complete. |
| |
| private StateMachine complete(Tasks tasks) { |
| if (hasError()) { |
| sink.acceptBarError(getError()); |
| return DONE; |
| } |
| sink.acceptBarValue(getValue()); |
| return DONE; |
| } |
| } |
| ``` |
| |
| Tip: It would be tempting to use the more concise signature void `accept(Bar |
| value)` rather than the stuttery `void acceptBarValue(Bar value)` above. |
| However, `Consumer<SkyValue>` is a common overload of `void accept(Bar value)`, |
| so doing this often leads to violations of the [Overloads: never |
| split](https://google.github.io/styleguide/javaguide.html#s3.4.2-ordering-class-contents) |
| style-guide rule. |
| |
| Tip: Using a custom `ResultSink` type instead of a generic one from |
| `java.util.function` makes it easy to find implementations in the code base, |
| improving readability. |
| |
| A caller `StateMachine` would then look like the following. |
| |
| ``` |
| class Caller implements StateMachine, BarProducer.ResultSink { |
| interface ResultSink { |
| void acceptCallerValue(Bar value); |
| void acceptCallerError(BarException error); |
| } |
| |
| private final ResultSink sink; |
| |
| private Bar value; |
| |
| Caller(ResultSink sink) { |
| this.sink = sink; |
| } |
| |
| @Override |
| @Nullable |
| public StateMachine step(Tasks tasks) { |
| tasks.enqueue(new BarProducer((BarProducer.ResultSink) this)); |
| return this::processResult; |
| } |
| |
| @Override |
| public void acceptBarValue(Bar value) { |
| this.value = value; |
| } |
| |
| @Override |
| public void acceptBarError(BarException error) { |
| sink.acceptCallerError(error); |
| } |
| |
| private StateMachine processResult(Tasks tasks) { |
| // Since all enqueued subtasks resolve before `processResult` starts, one of |
| // the `BarResultSink` callbacks must have been called by this point. |
| if (value == null) { |
| return DONE; // There was a previously reported error. |
| } |
| var finalResult = computeResult(value); |
| sink.acceptCallerValue(finalResult); |
| return DONE; |
| } |
| } |
| ``` |
| |
| The preceding example demonstrates a few things. `Caller` has to propagate its |
| results back and defines its own `Caller.ResultSink`. `Caller` implements the |
| `BarProducer.ResultSink` callbacks. Upon resumption, `processResult` checks if |
| `value` is null to determine if an error occurred. This is a common behavior |
| pattern after accepting output from either a subtask or SkyValue lookup. |
| |
| Note that the implementation of `acceptBarError` eagerly forwards the result to |
| the `Caller.ResultSink`, as required by [Error bubbling](#error-bubbling). |
| |
| Alternatives for top-level `StateMachine`s are described in [`Driver`s and |
| bridging to SkyFunctions](#drivers-and-bridging). |
| |
| ### Error handling {:#error-handling} |
| |
| There's a couple of examples of error handling already in [`Tasks.lookUp` |
| callbacks](#tasks-lookup-callbacks) and [Propagating values between |
| `StateMachines`](#propagating-values). Exceptions, other than |
| `InterruptedException` are not thrown, but instead passed around through |
| callbacks as values. Such callbacks often have exclusive-or semantics, with |
| exactly one of a value or error being passed. |
| |
| The next section describes a a subtle, but important interaction with Skyframe |
| error handling. |
| |
| #### Error bubbling (--nokeep\_going) {:#error-bubbling} |
| |
| Warning: Errors need to be eagerly propagated all the way back to the |
| SkyFunction for error bubbling to function correctly. |
| |
| During error bubbling, a SkyFunction may be restarted even if not all requested |
| SkyValues are available. In such cases, the subsequent state will never be |
| reached due to the `Tasks` API contract. However, the `StateMachine` should |
| still propagate the exception. |
| |
| Since propagation must occur regardless of whether the next state is reached, |
| the error handling callback must perform this task. For an inner `StateMachine`, |
| this is achieved by invoking the parent callback. |
| |
| At the top-level `StateMachine`, which interfaces with the SkyFunction, this can |
| be done by calling the `setException` method of `ValueOrExceptionProducer`. |
| `ValueOrExceptionProducer.tryProduceValue` will then throw the exception, even |
| if there are missing SkyValues. |
| |
| If a `Driver` is being utilized directly, it is essential to check for |
| propagated errors from the SkyFunction, even if the machine has not finished |
| processing. |
| |
| ### Event Handling {:#event-handling} |
| |
| For SkyFunctions that need to emit events, a `StoredEventHandler` is injected |
| into SkyKeyComputeState and further injected into `StateMachine`s that require |
| them. Historically, the `StoredEventHandler` was needed due to Skyframe dropping |
| certain events unless they are replayed but this was subsequently fixed. |
| `StoredEventHandler` injection is preserved because it simplifies the |
| implementation of events emitted from error handling callbacks. |
| |
| ## `Driver`s and bridging to SkyFunctions {:#drivers-and-bridging} |
| |
| A `Driver` is responsible for managing the execution of `StateMachine`s, |
| beginning with a specified root `StateMachine`. As `StateMachine`s can |
| recursively enqueue subtask `StateMachine`s, a single `Driver` can manage |
| numerous subtasks. These subtasks create a tree structure, a result of |
| [Structured concurrency](#structured-concurrency). The `Driver` batches SkyValue |
| lookups across subtasks for improved efficiency. |
| |
| There are a number of classes built around the `Driver`, with the following API. |
| |
| ``` |
| public final class Driver { |
| public Driver(StateMachine root); |
| public boolean drive(SkyFunction.Environment env) throws InterruptedException; |
| } |
| ``` |
| |
| `Driver` takes a single root `StateMachine` as a parameter. Calling |
| `Driver.drive` executes the `StateMachine` as far as it can go without a |
| Skyframe restart. It returns true when the `StateMachine` completes and false |
| otherwise, indicating that not all values were available. |
| |
| `Driver` maintains the concurrent state of the `StateMachine` and it is well |
| suited for embedding in `SkyKeyComputeState`. |
| |
| ### Directly instantiating `Driver` |
| |
| `StateMachine` implementations conventionally communicate their results via |
| callbacks. It's possible to directly instantiate a `Driver` as shown in the |
| following example. |
| |
| The `Driver` is embedded in the `SkyKeyComputeState` implementation along with |
| an implementation of the corresponding `ResultSink` to be defined a bit further |
| down. At the top level, the `State` object is an appropriate receiver for the |
| result of the computation as it is guaranteed to outlive `Driver`. |
| |
| ``` |
| class State implements SkyKeyComputeState, ResultProducer.ResultSink { |
| // The `Driver` instance, containing the full tree of all `StateMachine` |
| // states. Responsible for calling `StateMachine.step` implementations when |
| // asynchronous values are available and performing batched SkyFrame lookups. |
| // |
| // Non-null while `result` is being computed. |
| private Driver resultProducer; |
| |
| // Variable for storing the result of the `StateMachine` |
| // |
| // Will be non-null after the computation completes. |
| // |
| private ResultType result; |
| |
| // Implements `ResultProducer.ResultSink`. |
| // |
| // `ResultProducer` propagates its final value through a callback that is |
| // implemented here. |
| @Override |
| public void acceptResult(ResultType result) { |
| this.result = result; |
| } |
| } |
| ``` |
| |
| The code below sketches the `ResultProducer`. |
| |
| ``` |
| class ResultProducer implements StateMachine { |
| interface ResultSink { |
| void acceptResult(ResultType value); |
| } |
| |
| private final Parameters parameters; |
| private final ResultSink sink; |
| |
| … // Other internal state. |
| |
| ResultProducer(Parameters parameters, ResultSink sink) { |
| this.parameters = parameters; |
| this.sink = sink; |
| } |
| |
| @Override |
| public StateMachine step(Tasks tasks) { |
| … // Implementation. |
| return this::complete; |
| } |
| |
| private StateMachine complete(Tasks tasks) { |
| sink.acceptResult(getResult()); |
| return DONE; |
| } |
| } |
| ``` |
| |
| Then the code for lazily computing the result could look like the following. |
| |
| ``` |
| @Nullable |
| private Result computeResult(State state, Skyfunction.Environment env) |
| throws InterruptedException { |
| if (state.result != null) { |
| return state.result; |
| } |
| if (state.resultProducer == null) { |
| state.resultProducer = new Driver(new ResultProducer( |
| new Parameters(), (ResultProducer.ResultSink)state)); |
| } |
| if (state.resultProducer.drive(env)) { |
| // Clears the `Driver` instance as it is no longer needed. |
| state.resultProducer = null; |
| } |
| return state.result; |
| } |
| ``` |
| |
| ### Embedding `Driver` {:#embedding-driver} |
| |
| If the `StateMachine` produces a value and raises no exceptions, embedding |
| `Driver` is another possible implementation, as shown in the following example. |
| |
| ``` |
| class ResultProducer implements StateMachine { |
| private final Parameters parameters; |
| private final Driver driver; |
| |
| private ResultType result; |
| |
| ResultProducer(Parameters parameters) { |
| this.parameters = parameters; |
| this.driver = new Driver(this); |
| } |
| |
| @Nullable // Null when a Skyframe restart is needed. |
| public ResultType tryProduceValue( SkyFunction.Environment env) |
| throws InterruptedException { |
| if (!driver.drive(env)) { |
| return null; |
| } |
| return result; |
| } |
| |
| @Override |
| public StateMachine step(Tasks tasks) { |
| … // Implementation. |
| } |
| ``` |
| |
| The SkyFunction may have code that looks like the following (where `State` is |
| the function specific type of `SkyKeyComputeState`). |
| |
| ``` |
| @Nullable // Null when a Skyframe restart is needed. |
| Result computeResult(SkyFunction.Environment env, State state) |
| throws InterruptedException { |
| if (state.result != null) { |
| return state.result; |
| } |
| if (state.resultProducer == null) { |
| state.resultProducer = new ResultProducer(new Parameters()); |
| } |
| var result = state.resultProducer.tryProduceValue(env); |
| if (result == null) { |
| return null; |
| } |
| state.resultProducer = null; |
| return state.result = result; |
| } |
| ``` |
| |
| Embedding `Driver` in the `StateMachine` implementation is a better fit for |
| Skyframe's synchronous coding style. |
| |
| ### StateMachines that may produce exceptions |
| |
| Otherwise, there are `SkyKeyComputeState`-embeddable `ValueOrExceptionProducer` |
| and `ValueOrException2Producer` classes that have synchronous APIs to match |
| synchronous SkyFunction code. |
| |
| The `ValueOrExceptionProducer` abstract class includes the following methods. |
| |
| ``` |
| public abstract class ValueOrExceptionProducer<V, E extends Exception> |
| implements StateMachine { |
| @Nullable |
| public final V tryProduceValue(Environment env) |
| throws InterruptedException, E { |
| … // Implementation. |
| } |
| |
| protected final void setValue(V value) { … // Implementation. } |
| protected final void setException(E exception) { … // Implementation. } |
| } |
| ``` |
| |
| It includes an embedded `Driver` instance and closely resembles the |
| `ResultProducer` class in [Embedding driver](#embedding-driver) and interfaces |
| with the SkyFunction in a similar manner. Instead of defining a `ResultSink`, |
| implementations call `setValue` or `setException` when either of those occur. |
| When both occur, the exception takes priority. The `tryProduceValue` method |
| bridges the asynchronous callback code to synchronous code and throws an |
| exception when one is set. |
| |
| As previously noted, during error bubbling, it's possible for an error to occur |
| even if the machine is not yet done because not all inputs are available. To |
| accommodate this, `tryProduceValue` throws any set exceptions, even before the |
| machine is done. |
| |
| ## Epilogue: Eventually removing callbacks |
| |
| `StateMachine`s are a highly efficient, but boilerplate intensive way to perform |
| asynchronous computation. Continuations (particularly in the form of `Runnable`s |
| passed to `ListenableFuture`) are widespread in certain parts of *Bazel* code, |
| but aren't prevalent in analysis SkyFunctions. Analysis is mostly CPU bound and |
| there are no efficient asynchronous APIs for disk I/O. Eventually, it would be |
| good to optimize away callbacks as they have a learning curve and impede |
| readability. |
| |
| One of the most promising alternatives is *Java* virtual threads. Instead of |
| having to write callbacks, everything is replaced with synchronous, blocking |
| calls. This is possible because tying up a virtual thread resource, unlike a |
| platform thread, is supposed to be cheap. However, even with virtual threads, |
| replacing simple synchronous operations with thread creation and synchronization |
| primitives is too expensive. We performed a migration from `StateMachine`s to |
| *Java* virtual threads and they were orders of magnitude slower, leading to |
| almost a 3x increase in end-to-end analysis latency. Since virtual threads are |
| still a preview feature, it's possible that this migration can be performed at a |
| later date when performance improves. |
| |
| Another approach to consider is waiting for *Loom* coroutines, if they ever |
| become available. The advantage here is that it might be possible to reduce |
| synchronization overhead by using cooperative multitasking. |
| |
| If all else fails, low-level bytecode rewriting could also be a viable |
| alternative. With enough optimization, it might be possible to achieve |
| performance that approaches hand-written callback code. |
| |
| ## Appendix |
| |
| ### Callback Hell {:#callback-hell} |
| |
| Callback hell is an infamous problem in asynchronous code that uses callbacks. |
| It stems from the fact that the continuation for a subsequent step is nested |
| within the previous step. If there are many steps, this nesting can be extremely |
| deep. If coupled with control flow the code becomes unmanageable. |
| |
| ``` |
| class CallbackHell implements StateMachine { |
| @Override |
| public StateMachine step(Tasks task) { |
| doA(); |
| return (t, l) -> { |
| doB(); |
| return (t1, l2) -> { |
| doC(); |
| return DONE; |
| }; |
| }; |
| } |
| } |
| ``` |
| |
| One of the advantages of nested implementations is that the stack frame of the |
| outer step can be preserved. In *Java*, captured lambda variables must be |
| effectively final so using such variables can be cumbersome. Deep nesting is |
| avoided by returning method references as continuations instead of lambdas as |
| shown as follows. |
| |
| ``` |
| class CallbackHellAvoided implements StateMachine { |
| @Override |
| public StateMachine step(Tasks task) { |
| doA(); |
| return this::step2; |
| } |
| |
| private StateMachine step2(Tasks tasks) { |
| doB(); |
| return this::step3; |
| } |
| |
| private StateMachine step3(Tasks tasks) { |
| doC(); |
| return DONE; |
| } |
| } |
| ``` |
| |
| Callback hell may also occur if the [`runAfter` injection](#runafter-injection) |
| pattern is used too densely, but this can be avoided by interspersing injections |
| with sequential steps. |
| |
| #### Example: Chained SkyValue lookups {:#chained-skyvalue-lookups} |
| |
| It is often the case that the application logic requires dependent chains of |
| SkyValue lookups, for example, if a second SkyKey depends on the first SkyValue. |
| Thinking about this naively, this would result in a complex, deeply nested |
| callback structure. |
| |
| ``` |
| private ValueType1 value1; |
| private ValueType2 value2; |
| |
| private StateMachine step1(...) { |
| tasks.lookUp(key1, (Consumer<SkyValue>) this); // key1 has type KeyType1. |
| return this::step2; |
| } |
| |
| @Override |
| public void accept(SkyValue value) { |
| this.value1 = (ValueType1) value; |
| } |
| |
| private StateMachine step2(...) { |
| KeyType2 key2 = computeKey(value1); |
| tasks.lookup(key2, this::acceptValueType2); |
| return this::step3; |
| } |
| |
| private void acceptValueType2(SkyValue value) { |
| this.value2 = (ValueType2) value; |
| } |
| ``` |
| |
| However, since continuations are specified as method references, the code looks |
| procedural across state transitions: `step2` follows `step1`. Note that here, a |
| lambda is used to assign `value2`. This makes the ordering of the code match the |
| ordering of the computation from top-to-bottom. |
| |
| ### Miscellaneous Tips |
| |
| #### Readability: Execution Ordering |
| |
| To improve readability, strive to keep the `StateMachine.step` implementations |
| in execution order and callback implementations immediately following where they |
| are passed in the code. This isn't always possible where the control flow |
| branches. Additional comments might be helpful in such cases. |
| |
| In [Example: Chained SkyValue lookups](#chained-skyvalue-lookups), an |
| intermediate method reference is created to achieve this. This trades a small |
| amount of performance for readability, which is likely worthwhile here. |
| |
| #### Generational Hypothesis |
| |
| Medium-lived *Java* objects break the generational hypothesis of the *Java* |
| garbage collector, which is designed to handle objects that live for a very |
| short time or objects that live forever. By definition, objects in |
| `SkyKeyComputeState` violate this hypothesis. Such objects, containing the |
| constructed tree of all still-running `StateMachine`s, rooted at `Driver` have |
| an intermediate lifespan as they suspend, waiting for asynchronous computations |
| to complete. |
| |
| It seems less bad in JDK19, but when using `StateMachine`s, it's sometimes |
| possible to observe an increase in GC time, even with dramatic decreases in |
| actual garbage generated. Since `StateMachine`s have an intermediate lifespan |
| they could be promoted to old gen, causing it to fill up more quickly, thus |
| necessitating more expensive major or full GCs to clean up. |
| |
| The initial precaution is to minimize the use of `StateMachine` variables, but |
| it is not always feasible, for example, if a value is needed across multiple |
| states. Where it is possible, local stack `step` variables are young generation |
| variables and efficiently GC'd. |
| |
| For `StateMachine` variables, breaking things down into subtasks and following |
| the recommended pattern for [Propagating values between |
| `StateMachine`s](#propagating-values) is also helpful. Observe that when |
| following the pattern, only child `StateMachine`s have references to parent |
| `StateMachine`s and not vice versa. This means that as children complete and |
| update the parents using result callbacks, the children naturally fall out of |
| scope and become eligible for GC. |
| |
| Finally, in some cases, a `StateMachine` variable is needed in earlier states |
| but not in later states. It can be beneficial to null out references of large |
| objects once it is known that they are no longer needed. |
| |
| #### Naming states |
| |
| When naming a method, it's usually possible to name a method for the behavior |
| that happens within that method. It's less clear how to do this in |
| `StateMachine`s because there is no stack. For example, suppose method `foo` |
| calls a sub-method `bar`. In a `StateMachine`, this could be translated into the |
| state sequence `foo`, followed by `bar`. `foo` no longer includes the behavior |
| `bar`. As a result, method names for states tend to be narrower in scope, |
| potentially reflecting local behavior. |
| |
| ### Concurrency tree diagram {:#concurrency-tree-diagram} |
| |
| The following is an alternative view of the diagram in [Structured |
| concurrency](#structured-concurrency) that better depicts the tree structure. |
| The blocks form a small tree. |
| |
|  |
| |
| [^1]: In contrast to Skyframe's convention of restarting from the beginning when |
| values are not available. |
| [^2]: Note that `step` is permitted to throw `InterruptedException`, but the |
| examples omit this. There are a few low methods in *Bazel* code that throw |
| this exception and it propagates up to the `Driver`, to be described later, |
| that runs the `StateMachine`. It's fine to not declare it to be thrown when |
| unneeded. |
| [^3]: Concurrent subtasks were motivated by the `ConfiguredTargetFunction` which |
| performs *independent* work for each dependency. Instead of manipulating |
| complex data structures that process all the dependencies at once, |
| introducing inefficiencies, each dependency has its own independent |
| `StateMachine`. |
| [^4]: Multiple `tasks.lookUp` calls within a single step are batched together. |
| Additional batching can be created by lookups occurring within concurrent |
| subtasks. |
| [^5]: This is conceptually similar to Java’s structured concurrency |
| [jeps/428](https://openjdk.org/jeps/428). |
| [^6]: Doing this is similar to spawning a thread and joining it to achieve |
| sequential composition. |