Design Principles Behind Akka Streams
It took quite a while until we were reasonably happy with the look and feel of the API and the architecture of the implementation, and while being guided by intuition the design phase was very much exploratory research. This section details the findings and codifies them into a set of principles that have emerged during the process.
Note
As detailed in the introduction keep in mind that the Akka Streams API is completely decoupled from the Reactive Streams interfaces which are just an implementation detail for how to pass stream data between individual processing stages.
What Shall Users of Akka Streams Expect?
Akka.NET is built upon a conscious decision to offer APIs that are minimal and consistent --as opposed to easy or intuitive. The credo is that we favour explicitness over magic, and if we provide a feature then it must work always, no exceptions. Another way to say this is that we minimize the number of rules a user has to learn instead of trying to keep the rules close to what we think users might expect.
From this follows that the principles implemented by Akka Streams are:
- all features are explicit in the API, no magic
- supreme compositionality: combined pieces retain the function of each part
- exhaustive model of the domain of distributed bounded stream processing
This means that we provide all the tools necessary to express any stream processing topology, that we model all the essential aspects of this domain (back-pressure, buffering, transformations, failure recovery, etc.) and that whatever the user builds is reusable in a larger context.
Akka Streams Does Not Send Dropped Stream Elements to the Dead Letter Office
One important consequence of offering only features that can be relied upon is the restriction that Akka Streams cannot ensure that all objects sent through a processing topology will be processed. Elements can be dropped for a number of reason:
- plan user code can consume on element in a Select(...) stage and produce an entirely different one as its result
- Common stream operators drop elements intentionally, e.g., take/drop/where/conflate/buffer
- stream failure will tear down the stream without waiting for processing to finish, all elements that are in flight will be discarded
- stream cancellation will propagate upstream (e.g. from a take operator) leading to upstream processing steps being terminated without having processed all of their inputs
This means that sending CLR objects into a stream that needs to be cleaned up will require the user to ensure that this happens outside of the Akka Streams facilities (e.g. by cleaning them up after a time-out or when their results are observed on the stream output, or by other means like finalizers etc.)
Resulting Implementation Constraints
Compositionality entails re-usability of partial stream topologies, which led us to the lifted approach of describing data flows as (partial) graphs that can act as composite sources, flows (a.k.a. pipes) and sinks of data. These building blocks shall then be freely shareable, with the ability to combine them freely to form larger graphs. The representation of these pieces must therefore be an immutable blueprint that is materialized in an explicit step in order to start the stream processing. The resulting stream processing engine is then also immutable in the sense of having a fixed topology that is prescribed by the blueprint. Dynamic networks need to be modelled by explicitly using the Reactive Streams interfaces for plugging different engines together.
The process of materialization will often create specific objects that are useful to interact with the processing engine once it is running, for example for shutting it down or for extracting metrics. This means that the materialization function produces a result termed the materialized value of a graph.
Inter-Operation with Other Reactive Streams Implementations
Akka Streams fully implement the Reactive Streams
specification and interoperate with all other conformant implementations. We chose to completely separate the Reactive Streams interfaces from the user-level API because we regard them to be an SPI that is not targeted at end users. In order to obtain a Publisher
or Subscriber
from an Akka Stream topology, a corresponding Sink.AsPublisher
or Source.AsSubscriber
element must be used.
All stream Processors produced by the default materialization of Akka Streams are restricted to having a single Subscriber
, additional Subscribers will be rejected. The reason for this is that the stream topologies described using our DSL never require fan-out behavior from the Publisher sides of the elements, all fan-out is done using explicit elements like Broadcast<T>
.
This means that Sink.AsPublisher<T>(true)
(for enabling fan-out support) must be used where broadcast behavior is needed for inter-operation with other Reactive Streams implementations.
What Shall Users of Streaming Libraries Expect?
We expect libraries to be built on top of Akka Streams. In order to allow users to profit from the principles that are described for Akka Streams above, the following rules are established:
- libraries shall provide their users with reusable pieces, i.e expose factories that return graphs, allowing full compositionality
- libraries may optionally and additionally provide facilities that consume and materialize graphs
The reasoning behind the first rule is that compositionality would be destroyed if different libraries only accepted graphs and expected to materialize them: using two of these together would be impossible because materialization can only happen once. As a consequence, the functionality of a library must be expressed such that materialization can be done by the user, outside of the library's control.
The second rule allows a library to additionally provide nice sugar for the common case.
Note
One important consequence of this is that a reusable flow description cannot be bound to "live" resources, any connection to or allocation of such resources must be deferred until materialization time. Examples of "live" resources are already existing TCP connections, a multicast Publisher, etc.; a TickSource does not fall into this category if its timer is created only upon materialization (as is the case for our implementation). Exceptions from this need to be well-justified and carefully documented.
Resulting Implementation Constraints
Akka Streams must enable a library to express any stream processing utility in terms of immutable blueprints. The most common building blocks are
- Source: something with exactly one output stream
- Sink: something with exactly one input stream
- Flow: something with exactly one input and one output stream
- BidiFlow: something with exactly two input streams and two output streams that conceptually behave like two Flows of opposite direction
- Graph: a packaged stream processing topology that exposes a certain set of input and output ports, characterized by an object of type
Shape
.
Note
A source that emits a stream of streams is still just a normal Source, the kind of elements that are produced does not play a role in the static stream topology that is being expressed.
The Difference Between Error and Failure
The starting point for this discussion is the definition given by the Reactive Manifesto. Translated to streams this means that an error is accessible within the stream as a normal data element, while a failure means that the stream itself has failed and is collapsing. In concrete terms, on the Reactive Streams interface level data elements (including errors) are signalled via OnNext
while failures raise the OnError
signal.
Note
Unfortunately the method name for signalling failure to a Subscriber is called OnError
for historical reasons. Always keep in mind that the Reactive Streams interfaces (Publisher/Subscription/Subscriber) are modeling the low-level infrastructure for passing streams between execution units, and errors on this level are precisely the failures that we are talking about on the higher level that is modelled by Akka Streams.
There is only limited support for treating OnError
in Akka Streams compared to the operators that are available for the transformation of data elements, which is intentional in the spirit of the previous paragraph. Since OnError
signals that the stream is collapsing, its ordering semantics are not the same as for stream completion: transformation stages of any kind will just collapse with the stream, possibly still holding elements in implicit or explicit buffers. This means that data elements emitted before a failure can still be lost if the OnError
overtakes them.
The ability for failures to propagate faster than data elements is essential for tearing down streams that are back-pressured --especially since back-pressure can be the failure mode (e.g. by tripping upstream buffers which then abort because they cannot do anything else; or if a dead-lock occurred).
The Semantics of Stream Recovery
A recovery element (i.e. any transformation that absorbs an OnError
signal and turns that into possibly more data elements followed normal stream completion) acts as a bulkhead that confines a stream collapse to a given region of the stream topology. Within the collapsed region buffered elements may be lost, but the outside is not affected by the failure.
This works in the same fashion as a try-catch
expression: it marks a region in which exceptions are caught, but the exact amount of code that was skipped within this region in case of a failure might not be known precisely-the placement of statements matters.