Levelling up Boost.Redis

Oct 7, 2025

I’ve really come to appreciate Boost.Redis design. With only three asynchronous primitives it exposes all the power of Redis, with features like automatic pipelining that make it pretty unique. Boost.Redis 1.90 will ship with some new exciting features that I’ll cover in this post.

Cancelling requests with asio::cancel_after

Boost.Redis implements a number of reliability measures, including reconnection. Suppose that you attempt to execute a request using async_exec, but the Redis server can’t be contacted (for example, because of a temporary network error). Boost.Redis will try to re-establish the connection to the failed server, and async_exec will suspend until the server is healthy again.

This is a great feature if the outage is transitory. But what would happen if the Redis server is permanently down - for example, because of deployment issue that must be manually solved? The user will see that async_exec never completes. If new requests continue to be issued, the program will end up consuming an unbound amount of resources.

Starting with Boost 1.90, you can use asio::cancel_after to set a timeout to your requests, preventing this from happening:

// Compose your request
redis::request req;
req.push("SET", "my_key", 42);

// If the request doesn't complete within 30s, consider it as failed
co_await conn.async_exec(req, redis::ignore, asio::cancel_after(30s));

For this to work, async_exec must properly support per-operation cancellation. This is tricky because Boost.Redis allows executing several requests concurrently, which are merged into a single pipeline before being sent. For the above to useful, cancelling one request shouldn’t affect other requests. In Asio parlance, async_exec should support partial cancellation, at least.

Cancelling a request that hasn’t been sent yet is trivial - you just remove it from the queue and call it a day. Cancelling requests that are in progress is more involved. We’ve solved this by using “tombstones”. If a response encounters a tombstone, it will get ignored. This way, cancelling async_exec has always an immediate effect, but the connection is kept in a well-defined state.

Custom setup requests

Redis talks the RESP3 protocol. But it’s not the only database system that speaks it. We’ve recently learnt that other systems, like Tarantool DB, are also capable of speaking RESP3. This means that Boost.Redis can be used to interact with these systems.

At least in theory. In Boost 1.89, the library uses the HELLO command to upgrade to RESP3 (Redis’ default is using the less powerful RESP2). The command is issued as part of the reconnection loop, without user intervention. It happens that systems like Tarantool DB don’t support HELLO because they don’t speak RESP2 at all, so there is nothing to upgrade.

This is part of a larger problem: users might want to run arbitrary commands when the connection is established, to perform setup tasks. This might include AUTH to provide credentials or SELECT to choose a database index.

Until now, all you could do is configure the parameters used by the HELLO command. Starting with Boost 1.90, you can run arbitrary commands at connection startup:

// At startup, don't send any HELLO, but set up authentication and select a database
redis::request setup_request;
setup_request.push("AUTH", "my_user", "my_password");
setup_request.push("SELECT", 2);

redis::config cfg {
    .use_setup = true, // use the custom setup request, rather than the default HELLO command
    .setup = std::move(setup_request), // will be run every time a connection is established
};

conn.async_run(cfg, asio::detached);

This opens the door simplifying code using PubSub. At the moment, such code needs to issue a SUBSCRIBE command every time a reconnection happens, which implies some tricks around async_receive. With this feature, you can just add a SUBSCRIBE command to your setup request and forget.

This will be further explored in the next months, since async_receive is currently aware of reconnections, so it might need some extra changes to see real benefits.

Valkey support

Valkey is a fork from Redis v7.3. At the time of writing, both databases are mostly interoperable in terms of protocol features, but they are being developed separately (as happened with MySQL and MariaDB).

In Boost.Redis we’ve committed to supporting both long-term (at the moment, by deploying CI builds to test both).

Race-free cancellation

It is very easy to introduce race conditions in cancellation with Asio. Consider the following code, which is typical in libraries that predate per-operation cancellation:

struct connection
{
    asio::ip::tcp::socket sock;
    std::string buffer;

    struct echo_op
    {
        connection* obj;
        asio::coroutine coro{};

        template <class Self>
        void operator()(Self& self, error_code ec = {}, std::size_t = {})
        {
            BOOST_ASIO_CORO_REENTER(coro)
            {
                while (true)
                {
                    // Read from the socket
                    BOOST_ASIO_CORO_YIELD
                    asio::async_read_until(obj->sock, asio::dynamic_buffer(obj->buffer), "\n", std::move(self));

                    // Check for errors
                    if (ec)
                        self.complete(ec);

                    // Write back
                    BOOST_ASIO_CORO_YIELD
                    asio::async_write(obj->sock, asio::buffer(obj->buffer), std::move(self));

                    // Done
                    self.complete(ec);
                }
            }
        }
    };

    template <class CompletionToken>
    auto async_echo(CompletionToken&& token)
    {
        return asio::async_compose<CompletionToken, void(error_code)>(echo_op{this}, token, sock);
    }

    void cancel() { sock.cancel(); }
};

There is a race condition here. cancel() may actually not cancel a running async_echo. After a read or write completes, the respective handler may not be called immediately, but queued for execution. If cancel() is called within that time frame, the cancellation will be ignored.

The proper way to handle this is using per-operation cancellation, rather than a cancel() method. async_compose knows about this problem and keeps state about received cancellations, so you can write:

// Read from the socket
BOOST_ASIO_CORO_YIELD
asio::async_read_until(obj->sock, asio::dynamic_buffer(obj->buffer), "\n", std::move(self));

// Check for errors
if (ec)
    self.complete(ec);

// Check for cancellations
if (!!(self.get_cancellation_state().cancelled() & asio::cancellation_type_t::terminal))
    self.complete(asio::error::operation_aborted);

In 1.90, the library uses this approach everywhere, so cancellation is reliable. Keeping the cancel() method is a challenge, as it involves re-wiring cancellation slots, so I won’t show it here - but we’ve managed to do it.

Next steps

I’ve got plans to keep working on Boost.Redis for a time. You can expect more features in 1.91, like Sentinel support and more reliable health checks.

All Posts by This Author