Our journey with Reactor
James is an Open Source Mail Server. Our current version (3.2.0) has a default configuration relying on Cassandra.
In order to deal with Cassandra we have set up a class in charge of that:
It’s pretty straight-forward: you give a Statement
and you obtain a CompletableFuture
. Given that you will combine your CompletableFuture
in order to get a big one solving your problem:
Things become nasty when you have nested types:
Here, the goal is to transform a Stream<CompletableFuture<Optional<Attachment>>>
to a CompletableFuture<Stream<Attachement>>
.
In order to achieve that, James have been tooled with two classes: CompletableFutureUtils
and FluentFutureStream
, to help us to improve composition.
The real issue with these classes is that it’s not the aim of a Mail Server to fix language/libraries weaknesses.
Our approach had three major drawbacks:
- It had a deep impact on our design: in order to compose better with our abstractions, a huge part of some classes have been designed around them and were not only dealing with the problem they were supposed to solve.
- Concurrency issues:
CompletableFuture
represents a running code and not a runnable code, consequently, when you have aStream<CompletableFuture<?>>
on which youmap
a method deal also withStream<CompletableFuture<?>>
, there is an explosion of running things and the JVM spends a lot of its time to context switch. We also had a threads management issue: most of our business code were running in Cassandra driver threads. - Code complexity: the code was very complex, even well tested, it remains hard to understand at first eye sight.
The decision have been made to use a dedicated tool which does this job far better than us: Reactor. Here is what we have learned during our migration to Reactor.
The good
Reactor is really simple on type-level: two classes (Publisher
) are exposed: Mono (which represents 0–1 value) and Flux (which represents 0-n values).
At the time of 3.2.5.RELEASE
:
Mono
has more or less 180 methodsFlux
has more or less 375 methods
It means that there is a method for everything.
The glorious Mono.retry
We used to have a dedicated mechanism to handle retries:
It was the goal of FunctionRunnerWithRetry
to deal with all the tediousness of retry behavior, while it only takes two lines with Reactor:
It works with more or less every Mono
, additionally it can deals with back-offs, which will reduce the pressure if needed.
Easy composition
It also composes greatly, you can easily go from a Mono
to a Flux
, and conversely, you do not have to deal the presence or the absence of value, it's already done underneath.
Finally, the declarative way of building Publisher
s makes the code really neat:
null
-free
The great point of Reactor is its ability to not deal with null
. First Mono
and Flux
are perfectly aware of emptiness. Secondly, if you try to insert a null
in the steps, everything stops:
The bad
The first big problem comes from the number of methods: you can do a lot of things, but since you have only a few types, it’s very hard to find what you want easily.
The second one is the lack of information at type-level.
When you have a Mono<A>
and you want to get a A
, you have basically two ways: either you are sure that you will get a value (thanks to switchIfEmpty
for example) and you can safely call block
. Or you are not sure and you have to use a blockOptional
which returns a Optional<A>
.
This case can be solved with a simple test, sadly there is worse: all parallel by default on Flux
, look at this code:
This code is great, straightforward, nothing to drop, and, most importantly, dangerously parallel. By default this Flux
creates a huge number of parallel Mono
, it works until the connections pool is exhausted, which makes this code fail.
Thankfully, as said earlier, there is a method for everything:
The differences with CompletableFuture
Basically, it is not an issue, but for a migration, you have to take care of this:
CompletableFuture.thenApply
becomesMono.map
CompletableFuture.thenCombine
becomesMono.zip
CompletableFuture.thenCompose
becomesMono.flatMap
Coming from a strong FP background, the Reactor naming is more pleasant.
There is meanwhile a very important difference to take care of: the Void
. While CompletableFuture<Void>.thenApply
(and others) works well, giving a null
to the called method; Mono<Void>.map
(and flatMap
) never triggers the given method.
It clearly makes sense: how to give a value you can not instantiate? But on an other hand it’s destabilizing.
To continue an ongoing chain of operations you have to use Mono.then
or Mono.thenEmpty
Increase concurrency conflicts
We all know that random bug which happens 7% of the time, under very-specific circumstances, well, now it appears 80% of the time.
That is the real thing with Reactor: it not only reduces the memory and time consumption of your application, but it also reduce the pressure of it on the outer world.
While your application was previously slow enough to avoid a bug most of the time, now, you can not escape: you have to fix it, for your own good.
The ugly
There is a truth everyone need to hear: all Publisher
s are not born equal.
From time to time, Mono
are directly created from CompletableFuture
(and it was the case for all our Cassandra operations), which means that they are already running, let’s look at this piece of code:
Here, we can expect tryInsertModSeq
to just declare a Publisher
which will be run if and only if the previous one is empty, in fact, it is already running.
There is a last trap with Mono.fromFuture
: you can not replay them, so this code:
Will only be tried once if there is a Mono.fromFuture
at its root because it can not be replayed. Thanks to Mono.defer
we are able to make Supplier
s of them and to act as any other part of the Publisher
.
Conclusion
Reactor is a great tool where every details have been intensively thought of, which makes it a nice solution to our problem.
On an other hand, most of the pitfalls I have listed would have been avoided with a finer type-level.
Note: Most of the code examples come from the James code base and are de facto licensed under the Apache License, Version 2.0.