Production postmortemBroken networks and compressed streams

time to read 3 min | 595 words

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.

More posts in "Production postmortem" series:

  1. (05 Aug 2022) The allocating query
  2. (22 Jul 2022) Efficiency all the way to Out of Memory error
  3. (18 Jul 2022) Broken networks and compressed streams
  4. (13 Jul 2022) Your math is wrong, recursion doesn’t work this way
  5. (12 Jul 2022) The data corruption in the node.js stack
  6. (11 Jul 2022) Out of memory on a clear sky
  7. (29 Apr 2022) Deduplicating replication speed
  8. (25 Apr 2022) The network latency and the I/O spikes
  9. (22 Apr 2022) The encrypted database that was too big to replicate
  10. (20 Apr 2022) Misleading security and other production snafus
  11. (03 Jan 2022) An error on the first act will lead to data corruption on the second act…
  12. (13 Dec 2021) The memory leak that only happened on Linux
  13. (17 Sep 2021) The Guinness record for page faults & high CPU
  14. (07 Jan 2021) The file system limitation
  15. (23 Mar 2020) high CPU when there is little work to be done
  16. (21 Feb 2020) The self signed certificate that couldn’t
  17. (31 Jan 2020) The slow slowdown of large systems
  18. (07 Jun 2019) Printer out of paper and the RavenDB hang
  19. (18 Feb 2019) This data corruption bug requires 3 simultaneous race conditions
  20. (25 Dec 2018) Handled errors and the curse of recursive error handling
  21. (23 Nov 2018) The ARM is killing me
  22. (22 Feb 2018) The unavailable Linux server
  23. (06 Dec 2017) data corruption, a view from INSIDE the sausage
  24. (01 Dec 2017) The random high CPU
  25. (07 Aug 2017) 30% boost with a single line change
  26. (04 Aug 2017) The case of 99.99% percentile
  27. (02 Aug 2017) The lightly loaded trashing server
  28. (23 Aug 2016) The insidious cost of managed memory
  29. (05 Feb 2016) A null reference in our abstraction
  30. (27 Jan 2016) The Razor Suicide
  31. (13 Nov 2015) The case of the “it is slow on that machine (only)”
  32. (21 Oct 2015) The case of the slow index rebuild
  33. (22 Sep 2015) The case of the Unicode Poo
  34. (03 Sep 2015) The industry at large
  35. (01 Sep 2015) The case of the lying configuration file
  36. (31 Aug 2015) The case of the memory eater and high load
  37. (14 Aug 2015) The case of the man in the middle
  38. (05 Aug 2015) Reading the errors
  39. (29 Jul 2015) The evil licensing code
  40. (23 Jul 2015) The case of the native memory leak
  41. (16 Jul 2015) The case of the intransigent new database
  42. (13 Jul 2015) The case of the hung over server
  43. (09 Jul 2015) The case of the infected cluster