What is Trickle?
Trickle is syntactic sugar on top of ListenableFutures. Its objective is to make it easier to understand code that implements non-trivial asynchronous call graphs. A typical example of when it is useful in a Spotify context is a search view, where you need to make the following calls:
- Find out which tracks match a certain search query.
- Based on the search results, fetch data such as album covers, composers, artists, etc., about each track.
- Based on the search results, find out from a separate service how many playlists each track is included in.
- When all the results are ready, convert them into the format the caller expects and return.
Doing this synchronously is easy, but asynchronously using raw ListenableFutures it gets a lot harder. Trickle makes it more pleasant.
Key design objectives
- Improve readability of code making nested asynchronous calls, in particular with regard to making the relationships between different calls clearer.
- Type-safety and IDE-friendliness.
- Great error handling - ensure errors are always reported to the caller, unless explicitly configured to be hidden.
- Making the fact that the business logic is executed in a concurrent context as hidden as possible. This means that:
- Application code shouldn't be able to interrupt the concurrent flow (so inputs should never be futures - application code should only be invoked when results are ready).
- It's important to move as many as possible of the hard concurrency problems into the framework rather than solve them in application code.
The following diagram shows a Trickle Graph, where the colour of a node indicates which function is executed in it.
- Graph: Trickle allows you to connect a set of calls into a graph. Graphs in Trickle have the following characteristics:
- They are directed and acyclic.
- They can have any number of input parameters - values that vary between executions, such as a query string to search for.
- They have a single sink that returns the result of invoking the Graph. A sink is a node that has no outgoing edges - in Trickle, this means no nodes depend on it, either for parameter inputs or sequentially.
- Edges are either parameter dependencies (in the diagram, the function in node 2 uses the result of node 1), or sequential dependencies (node 4 doesn't need the result of node 3, but needs to happen afterwards).
- Node: there are three kinds of nodes in Trickle - inputs, inner nodes and the single sink node.
- Inputs are placeholders for values that can vary with each graph execution. Before executing a Graph, all its inputs need to have been bound to an explicit value or a Future for a value using the
- Inner nodes and the sink contain Functions that are executed with the parameters defined when connecting the node to the Graph. It is possible to execute the same exact Function in different Nodes in the same Graph.
- Node parameters are bound using the
with()method. A parameter is either an Input or the sink node of a previously defined Graph.
- Predecessors are defined using the
after()method. A predecessor is sequential dependency; a node that needs to be completed before the Function in this node can be invoked. The difference between a predecessor and a parameter is that we don't care about the value of the predecessor, so it's not going to be passed to the function.
- A fallback can be defined using the
fallback()method. This fallback will get invoked in case of an exception and returns a future to a value to use instead.
- Nodes can be assigned a name using the
named()method. This is just for debugging your graph.
- The sink node is special in that it defines the output of the Graph, and is also the node that downstream nodes get connected to when a dependency is established.
- Function: A function takes some parameters and returns a future to a result. Note that Trickle Functions are not pure functions, and thus not required to be side-effect-free. When a function is wired into a Graph, a Node is created.
- Input: call graphs are often quite static, just like the code in a regular method. But there are usually some inputs to the graph that need to vary from invocation to invocation. These are parameters that you can name and that you have to bind to concrete values before invoking a graph.
Constructing a Graph
At each step when building a Trickle Graph, you've got a complete graph, and you build up the final Graph incrementally, by adding new sink nodes. To construct the Graph in the diagram in the Concepts section, you could do this, ignoring all types:
Graph<T> g1 = Trickle.call(function1).with(INPUT_A); // g1 is a complete graph that can be run on its own // next, connect node2 to the sink node of g1 Graph<T> g2 = Trickle.call(function2).with(INPUT_A, g1); // now g2 consists of three nodes: input A, node 1 and node 2. Graph<T> g3 = Trickle.call(function2).with(g1, INPUT_B); // now g3 consists of four nodes: input A, node 1, input B and node 3. Graph<T> g4 = Trickle.call(function3).with(g2).after(g3); // g4 is the complete graph with all the nodes in the diagram.
Note that the
with() method takes either a name of an input parameter or a previously defined graph. The latter case means that the new node depends on the output of the sink node of the previous graph.
What about cycles?
The Trickle API makes cycles impossible by making all graph builders immutable, meaning that even this code (violating the API a bit) doesn't produce a cycle:
Graph<String> g1 = Trickle.call(noArgsFunction); Graph<String> noCycle = ((ConfigurableGraph<String>) g1).after(g1);
The reason is that a new Graph node is created by the call to
after, so the above code results in a graph with the same shape as:
Graph<String> g1 = Trickle.call(noArgsFunction); Graph<String> noCycle = Trickle.call(noArgsFunction).after(g1);
Note that the intention is that graph construction should be done outside of hot paths, as part of application setup and hence we can be a little more liberal with garbage creation.
For examples, see Examples.java.
Other options that can help you simplify writing asynchronous code in Java that we know of and like are:
For some reasoning around design choices we made, see Design Rationales.
For an as-yet incomplete list of best practices, see Best Practices.