Ready, Set, Redis!
Jul 10, 2025I’m happy to announce that I’m now a co-maintainer of Boost.Redis, a high-level Redis client written on top of Asio, and hence sister of Boost.MySQL. I’m working with its author, Marcelo, to make it even better than what it is now (and that’s a lot to say).
First of all, we’re working on improving test coverage. Boost.Redis was originally
written making heavy use of asio::async_compose
. If you have a
JavaScript or Python background, this approach will feel natural to you, since it’s similar
to async
/await
. Unfortunately, this approach makes code difficult to test.
For instance, consider redis::connection::async_exec
, which enqueues a request to be
executed by the Redis server and then waits for its response. This is a (considerably simplified)
snippet of how this function could be implemented with async_compose
:
struct exec_op {
connection* conn;
std::shared_ptr<detail::request_info> info; // a request + extra info
asio::coroutine coro{}; // a coroutine polyfill that uses switch/case statements
// Will be called by Asio until self.complete() is called
template <class Self>
void operator()(Self& self , system::error_code = {}, std::size_t = 0)
{
BOOST_ASIO_CORO_REENTER(coro)
{
// Check whether the user wants to wait for the connection to
// be stablished.
if (info->req->get_config().cancel_if_not_connected && !conn->state.is_open) {
BOOST_ASIO_CORO_YIELD asio::async_immediate(self.get_io_executor(), std::move(self));
return self.complete(error::not_connected, 0);
}
// Add the request to the queue
conn->state.add_request(info);
// Notify the writer task that there is a new request available
conn->writer_timer.cancel();
while (true) {
// Wait for the request to complete. We will be notified using a channel
BOOST_ASIO_CORO_YIELD info->channel.async_wait(std::move(self));
// Are we done yet?
if (info->is_done) {
self.complete(info->ec, info->bytes_read);
return;
}
// Check for cancellations
if (self.get_cancellation_state().cancelled() != asio::cancellation_type_t::none) {
// We can only honor cancellations if the request hasn't been sent to the server
if (!info->request_sent()) {
conn->state.remove_request(info);
self.complete(asio::error::operation_aborted, 0);
return;
} else {
// Can't cancel, keep waiting
}
}
}
}
}
};
template <class CompletionToken, class Response>
auto connection::async_exec(const request& req, Response& res, CompletionToken&& token)
{
return asio::async_compose<CompletionToken, void(system::error_code, std::size_t)>(
exec_op{this, detail::make_info(req, res)},
token,
*this
);
}
The snippet above contains non-trivial logic, specially regarding cancellation. While the code is understandable, it is difficult to test, since Asio doesn’t include lots of testing utilities. The end result is usually untested code, prune to difficult to diagnose bugs.
As an alternative, we can refactor this code into two classes:
- A finite state machine object that encapsulates all logic. This should be a lightweight and should never interact with any Asio I/O object, so we can test logic easily.
- A dumb
async_compose
function that just applies the actions mandated by the finite state machine.
The finite state machine for the above code could be like:
// The finite state machine returns exec_action objects
// communicating what should be done next so the algorithm can progress
enum class exec_action_type
{
notify_writer, // We should notify the writer task
wait, // We should wait for the channel to be notified
immediate, // We should invoke asio::async_immediate() to avoid recursion problems
done, // We're done and should call self.complete()
};
struct exec_action
{
exec_action_type type;
error_code ec; // has meaning if type == exec_action_type::done
std::size_t bytes_read{}; // has meaning if type == exec_action_type::done
};
// Contains all the algorithm logic. It is cheap to create and copy.
// It is conceptually similar to a coroutine.
class exec_fsm {
asio::coroutine coro{};
std::shared_ptr<detail::request_info> info; // a request + extra info
public:
explicit exec_fsm(std::shared_ptr<detail::request_info> info) : info(std::move(info)) {}
std::shared_ptr<detail::request_info> get_info() const { return info; }
// To run the algorithm, run the resume() function until it returns exec_action_type::done.
exec_action resume(
connection_state& st, // Contains connection state, but not any I/O objects
asio::cancellation_type_t cancel_state // The cancellation state of the composed operation
)
{
BOOST_ASIO_REENTER(coro)
{
// Check whether the user wants to wait for the connection to
// be stablished.
if (info->req->get_config().cancel_if_not_connected && !st.is_open) {
BOOST_ASIO_CORO_YIELD {exec_action_type::immediate};
return {exec_action_type::done, error::not_connected, 0};
}
// Add the request to the queue
st.add_request(info);
// Notify the writer task that there is a new request available
BOOST_ASIO_CORO_YIELD {exec_action_type::notify_writer};
while (true) {
// Wait for the request to complete. We will be notified using a channel
BOOST_ASIO_CORO_YIELD {exec_action_type::wait};
// Are we done yet?
if (info->is_done) {
return {exec_action_type::done, info->ec, info->bytes_read};
}
// Check for cancellations
if (cancel_state != asio::cancellation_type_t::none) {
// We can only honor cancellations if the request hasn't been sent to the server
if (!info->request_sent()) {
conn->state.remove_request(info);
return {exec_action_type::done, asio::error::operation_aborted};
} else {
// Can't cancel, keep waiting
}
}
}
}
}
};
exec_op
no longer contains any logic:
struct exec_op {
connection* conn;
exec_fsm fsm;
// Will be called by Asio until self.complete() is called
template <class Self>
void operator()(Self& self, system::error_code = {}, std::size_t = 0)
{
// Call the FSM
auto action = fsm.resume(conn->state, self.get_cancellation_state().cancelled());
// Apply the required action
switch (action.type)
{
case exec_action_type::notify_writer:
conn->writer_timer.cancel();
(*this)(self); // This action doesn't involve a callback, so invoke ourselves again
break;
case exec_action_type::wait:
fsm.get_info()->channel.async_wait(std::move(self));
break;
case exec_action_type::immediate:
asio::async_immediate(self.get_io_executor(), std::move(self));
break;
case exec_action_type::done:
self.complete(action.ec, action.bytes_read);
break;
}
}
};
With this setup, exec_fsm
is now trivial to test, since it doesn’t invoke any I/O.
We’re migrating most of the algorithms towards this approach, and we’re finding and fixing many subtle problems in the process. There is still lots to do, but efforts are already paying off.
Boost.Redis features and docs
While I tend to get very excited about this new sans-io approach, I’ve also made other contributions that had likely had more impact on users. For instance, I’ve implemented support for UNIX sockets, which had been something recurrently requested by users that want to squeeze the last bit of performance from their setup.
I’ve also worked on logging. Since the reconnection algorithm is complex, Boost.Redis
logs some messages by default to simplify diagnostics. This is now performed through
a simple, extensible and well-documented API, allowing users to integrate third-party
logging libraries like spdlog
.
Last (but not least), I’ve migrated Boost.Redis docs to the new asciidoc/antora/mrdocs toolchain. I’m pretty impressed with the results, and would like to thank the MrDocs and Boostlook people for their efforts.
I know that comparisons are odious, but…
A great MySQL comes with a great responsibility
It’s not all been Redis these days. Boost.MySQL users also deserve some attention.
I’ve rewritten the MySQL handshake algorithm, so the caching_sha2_password
plugin
can work without TLS.
For most of you, the sentence above will likely say nothing, so let’s provide some context. When a client connects to the MySQL server, it performs a connection establishment packet exchange where several connection parameters are negotiated, the client is authenticated, and the TLS layer is optionally installed. This is collectively called the MySQL handshake, and it’s not very clean in design.
Clients can authenticate using several authentication mechanisms, called authentication
plugins. The most widespread one is mysql_native_password
, a challenge/response mechanism
where the client sends a hashed password to the server. It doesn’t require a TLS layer,
and it’s supported by MySQL 5.x, MySQL 8.x and MariaDB.
The problem with mysql_native_password
is that it uses SHA1
, which is considered nowadays weak.
MySQL 8.x introduced caching_sha2_password
and deprecated mysql_native_password
, and MySQL 9.x
has removed the latter. caching_sha2_password
uses SHA256
, but it also introduces a cache in the server.
If your user is in the cache, the password is sent hashed, as with mysql_native_password
.
But if it’s not, things get more complex:
- When using a TLS layer, the password is sent in plain text.
- When not using a TLS layer, the server supplies an RSA key, and the password is sent encrypted using it.
I never got to implement the second point, since most people were just using
the simpler mysql_native_password
. With the advent of MySQL 9, this was becoming a problem,
since it meant using TLS even for local network connections (like the ones between Docker containers),
with the overhead it implies.
Implementing this new exchange has required a big refactor and many tests,
but it has paid off, as it unravelled some buggy edge-cases.
Remember the async_compose
vs sans-io discussion at the beginning of this post?
For such complex exchanges, going sans-io has been key.
Three databases are better than two
Why MySQL and not Postgres? Well, I found myself asking this question, too. Following what I’ve learnt with Boost.MySQL, I’m writing a new library to interact with Postgres. It’s not usable yet, but it’s made some progress. You can check it out here.
New Boost citizens: OpenMethod and Bloom
I’ve also had the pleasure to participate in the review of two wonderful libraries that have been accepted into Boost: OpenMethod, which allows defining virtual functions outside classes; and Bloom, which implements Bloom filters. The family keeps growing.
C++20 modules and Boost
I’m happy to see some more Boost authors adding support for C++20 modules in their libraries. Concretely, I’ve reviewed PRs for Boost.Pfr and Boost.Any.
This is really exciting for me, and I hope to be able to dedicate some time soon to progress my C++20 prototype for Boost.Core and Boost.Mp11.
Lightweight test context
Boost.Core contains a small component to write unit tests: the lightweight test framework. It’s extremely simple, and that makes it fast, both at runtime and compile-time.
It’s sometimes too simple. I’m a big fan of parametric tests, where you run a test case over a set of different values. You can do so with lightweight test by just using a loop, but that makes failures difficult to diagnose.
I’m implementing an equivalent to BOOST_TEST_CONTEXT
for lightweight test. I’m still on the middle of it, so I’ll dive deeper onto this
in my next post. All I can say is that this addition makes lightweight test
a perfect fit for most of my testing needs!
Next steps
It looks like databases, Asio and modules are definitely part of my future. So many exciting things that I sometimes struggle to decide on which one to focus!
All Posts by This Author
- 07/10/2025 Ready, Set, Redis!
- 04/10/2025 Moving Boost forward: Asio, coroutines, and maybe even modules
- 01/06/2025 Boost.MySQL 1.87 and the new Boost citizens
- 10/20/2024 Boost.MySQL 1.87 features: with_params and with_diagnostics
- 10/27/2023 Ruben's Q3 2023 Update
- View All Posts...