Production postmortem: Broken networks and compressed streams
RavenDB introduced a TCP compression feature in version 5.3. The idea is that all internal communication in the cluster (as well as subscriptions), will use the Zstd compression format to reduce the overall bandwidth utilization by RavenDB. We have always supported HTTP compression, and that closed the circle.
The fact hat we are using Zstd means that we have a higher compression ratio and less CPU usage, so everyone was happy. Except… sometimes, they weren’t.
In some cases, we noticed that there would be network failures at a far higher rate than previous experienced. RavenDB is robust to network errors, so that was handled, but that is still a concern. We figured out that the problem was rooted in the compression code. If we enabled compression between the systems, it would have far higher rate of failures than otherwise. But only when running in secured mode, when the system is running without security, everything works.
My first suspicion is that something is in the network, monitoring it. But the whole point of secured mode is that no one can peek into the stream not interfere with the contents. Given that this is a self-healing issue, it took some time to dedicate the right amount of attention to it, but we managed to figure it out.
This is a confluence of three different features that all play together to get this to happen.
With compression, we typically do something like this:
That is pretty much how all compression stream will work. But we do have to consider the following issue, there may be no output.
When can that happen?
Let’s assume that I’m using the simplest compression algorithm (run length encoding).
In other words, it will take a buffer such as: aaaaaacccccccbbbb and turn that into a7c6b4.
Now, let’s consider what would be the output of such an algorithm if we pass it a buffer consisting of a single value?
It will only update its internal state, it will not output anything. That is fine, we need a call to Flush() to ensure that all the state is out.
That means that this will return an empty buffer, which we are then writing to the inner stream. And that is fine, right? Since writing a zero length buffer is a no-op.
Except that it isn’t a no-op. There is the concept of empty SSL records, mostly it seams to handle the BEAST attack. So when you pass an empty buffer to the SslStream, it will emit an empty record to the network.
Which is good, except that you may have a scenario where you emit a lot of those values. And it turns out that OpenSSL has a limit to how many consecutive empty records it will accept (under the assumption that it must move forward and produce output and not just loop).
So, in order to repeat this bug, we need:
- Input that will result in zero output from the compressor (fully repeating previous values, usually). Resulting in a zero length buffer as the output of the compression.
- Sending the empty SSL record over the stream.
- Repeating this for 32 times.
When all three conditions are satisfied, we get an error on the receiving end and the connection is broken. That means that the next call will have a different compression state and likely won’t have a problem at the same location.
In short, this is fun exercise in seeing how three different design decisions, all of whom are eminently reasonable, result in a very hard to trace bug.
The good thing is that this is simplicity itself to solve. We just need to avoid writing zero length buffer to the stream.