diff --git a/doc/modules/ROOT/nav.adoc b/doc/modules/ROOT/nav.adoc index 7c10e4ae..8fd44b67 100644 --- a/doc/modules/ROOT/nav.adoc +++ b/doc/modules/ROOT/nav.adoc @@ -3,6 +3,7 @@ * xref:cancellation.adoc[] * xref:serialization.adoc[] * xref:logging.adoc[] +* xref:sentinel.adoc[] * xref:benchmarks.adoc[] * xref:comparison.adoc[] * xref:examples.adoc[] diff --git a/doc/modules/ROOT/pages/examples.adoc b/doc/modules/ROOT/pages/examples.adoc index 6073cfb4..454e9b37 100644 --- a/doc/modules/ROOT/pages/examples.adoc +++ b/doc/modules/ROOT/pages/examples.adoc @@ -15,7 +15,7 @@ The examples below show how to use the features discussed throughout this docume * {site-url}/example/cpp20_containers.cpp[cpp20_containers.cpp]: Shows how to send and receive STL containers and how to use transactions. * {site-url}/example/cpp20_json.cpp[cpp20_json.cpp]: Shows how to serialize types using Boost.Json. * {site-url}/example/cpp20_protobuf.cpp[cpp20_protobuf.cpp]: Shows how to serialize types using protobuf. -* {site-url}/example/cpp20_resolve_with_sentinel.cpp[cpp20_resolve_with_sentinel.cpp]: Shows how to resolve a master address using sentinels. +* {site-url}/example/cpp20_sentinel.cpp[cpp20_sentinel.cpp]: Shows how to use the library with a Sentinel deployment. * {site-url}/example/cpp20_subscriber.cpp[cpp20_subscriber.cpp]: Shows how to implement pubsub with reconnection re-subscription. * {site-url}/example/cpp20_echo_server.cpp[cpp20_echo_server.cpp]: A simple TCP echo server. * {site-url}/example/cpp20_chat_room.cpp[cpp20_chat_room.cpp]: A command line chat built on Redis pubsub. diff --git a/doc/modules/ROOT/pages/reference.adoc b/doc/modules/ROOT/pages/reference.adoc index 5e1976c2..e87ee7cd 100644 --- a/doc/modules/ROOT/pages/reference.adoc +++ b/doc/modules/ROOT/pages/reference.adoc @@ -25,8 +25,12 @@ xref:reference:boost/redis/basic_connection.adoc[`basic_connection`] xref:reference:boost/redis/address.adoc[`address`] +xref:reference:boost/redis/role.adoc[`role`] + xref:reference:boost/redis/config.adoc[`config`] +xref:reference:boost/redis/sentinel_config.adoc[`sentinel_config`] + xref:reference:boost/redis/error.adoc[`error`] xref:reference:boost/redis/logger.adoc[`logger`] diff --git a/doc/modules/ROOT/pages/sentinel.adoc b/doc/modules/ROOT/pages/sentinel.adoc new file mode 100644 index 00000000..bd03faff --- /dev/null +++ b/doc/modules/ROOT/pages/sentinel.adoc @@ -0,0 +1,152 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + += Sentinel + +Boost.Redis supports Redis Sentinel deployments. Sentinel handling +in `connection` is built-in: xref:reference:boost/redis/basic_connection/async_run-04.adoc[`async_run`] +automatically connects to Sentinels, resolves the master's address, and connects to the master. + +Configuration is done using xref:reference:boost/redis/sentinel_config.adoc[`config::sentinel`]: + +[source,cpp] +---- +config cfg; + +// To enable Sentinel, set this field to a non-empty list +// of (hostname, port) pairs where Sentinels are listening +cfg.sentinel.addresses = { + {"sentinel1.example.com", "26379"}, + {"sentinel2.example.com", "26379"}, + {"sentinel3.example.com", "26379"}, +}; + +// Set master_name to the identifier that you configured +// in the "sentinel monitor" statement of your sentinel.conf file +cfg.sentinel.master_name = "mymaster"; +---- + +Once set, the connection object can be used normally. See our +our {site-url}/example/cpp20_sentinel.cpp[Sentinel example] +for a full program. + +== Connecting to replicas + +By default, the library connects to the Redis master. +You can connect to one of its replicas by using +xref:reference:boost/redis/sentinel_config/server_role.adoc[`config::sentinel::server_role`]. +This can be used to balance load, if all your commands read data from +the server and never write to it. The particular replica will be chosen randomly. + +[source,cpp] +---- +config cfg; + +// Set up Sentinel +cfg.sentinel.addresses = { + {"sentinel1.example.com", "26379"}, + {"sentinel2.example.com", "26379"}, + {"sentinel3.example.com", "26379"}, +}; +cfg.sentinel.master_name = "mymaster"; + +// Ask the library to connect to a random replica of 'mymaster', rather than the master node +cfg.sentinel.server_role = role::replica; +---- + + +== Sentinel authentication + +If your Sentinels require authentication, +you can use xref:reference:boost/redis/sentinel_config/setup.adoc[`config::sentinel::setup`] +to provide credentials. +This request is executed immediately after connecting to Sentinels, and +before any other command: + +[source,cpp] +---- +// Set up Sentinel +config cfg; +cfg.sentinel.addresses = { + {"sentinel1.example.com", "26379"}, + {"sentinel2.example.com", "26379"}, + {"sentinel3.example.com", "26379"}, +}; +cfg.sentinel.master_name = "mymaster"; + +// By default, setup contains a 'HELLO 3' command. +// Override it to add an AUTH clause to it with out credentials. +cfg.sentinel.setup.clear(); +cfg.sentinel.setup.push("HELLO", 3, "AUTH", "sentinel_user", "sentinel_password"); + +// cfg.sentinel.setup applies to Sentinels, only. +// Use cfg.setup to authenticate to masters/replicas. +cfg.use_setup = true; // Required for cfg.setup to be used, for historic reasons +cfg.setup.clear(); +cfg.setup.push("HELLO", 3, "AUTH", "master_user", "master_password"); +---- + +== Using TLS with Sentinels + +You might use TLS with Sentinels only, masters/replicas only, or both by adjusting +xref:reference:boost/redis/sentinel_config/use_ssl.adoc[`config::sentinel::use_ssl`] +and xref:reference:boost/redis/config/use_ssl.adoc[`config::use_ssl`]: + +[source,cpp] +---- +// Set up Sentinel +config cfg; +cfg.sentinel.addresses = { + {"sentinel1.example.com", "26379"}, + {"sentinel2.example.com", "26379"}, + {"sentinel3.example.com", "26379"}, +}; +cfg.sentinel.master_name = "mymaster"; + +// Adjust these switches to enable/disable TLS +cfg.use_ssl = true; // Applies to masters and replicas +cfg.sentinel.use_ssl = true; // Applies to Sentinels +---- + +== Sentinel algorithm + +This section details how `async_run` interacts with Sentinel. +Most of the algorithm follows +https://redis.io/docs/latest/develop/reference/sentinel-clients/[the official Sentinel client guidelines]. +Some of these details may vary between library versions. + +* Connections maintain an internal list of Sentinels, bootstrapped from + xref:reference:boost/redis/sentinel_config/addresses.adoc[`config::sentinel::addresses`]. +* The first Sentinel in the list is contacted by performing the following: +** A physical connection is established. +** The setup request is executed. +** The master's address is resolved using + https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#sentinel-api[`SENTINEL GET-MASTER-NAME-BY-ADDR`]. +** If `config::sentinel::server_role` is `role::replica`, replica addresses are obtained using + https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#sentinel-api[`SENTINEL REPLICAS`]. + One replica is chosen randomly. +** The address of other Sentinels also monitoring this master are retrieved using + https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#sentinel-api[`SENTINEL SENTINELS`]. +* If a Sentinel is unreachable, doesn't know about the configured master, + or returns an error while executing the above requests, the next Sentinel in the list is tried. +* If all Sentinels have been tried without success, `config::reconnect_wait_interval` + is waited, and the process starts again. +* After a successful Sentinel response, the internal Sentinel list is updated + with any newly discovered Sentinels. + Sentinels in `config::sentinel::addresses` are always kept in the list, + even if they weren't present in the output of `SENTINEL SENTINELS`. +* The retrieved address is used + to establish a connection with the master or replica. + A `ROLE` command is added at the end of the setup request. + This is used to detect situations where a Sentinel returns outdated + information due to a failover in process. If `ROLE` doesn't output + the expected role (`"master"` or `"slave"`, depending on `config::sentinel::server_role`) + `config::reconnect_wait_interval` is waited and Sentinel is contacted again. +* The connection to the master/replica is run like any other connection. + If network errors or timeouts happen, `config::reconnect_wait_interval` + is waited and Sentinel is contacted again. diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index c9b18235..7f77b4f1 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -28,11 +28,11 @@ make_testable_example(cpp20_containers 20) make_testable_example(cpp20_json 20) make_testable_example(cpp20_unix_sockets 20) make_testable_example(cpp20_timeouts 20) +make_testable_example(cpp20_sentinel 20) make_example(cpp20_subscriber 20) make_example(cpp20_streams 20) make_example(cpp20_echo_server 20) -make_example(cpp20_resolve_with_sentinel 20) make_example(cpp20_intro_tls 20) # We test the protobuf example only on gcc. diff --git a/example/cpp20_resolve_with_sentinel.cpp b/example/cpp20_resolve_with_sentinel.cpp deleted file mode 100644 index 3d443402..00000000 --- a/example/cpp20_resolve_with_sentinel.cpp +++ /dev/null @@ -1,77 +0,0 @@ -/* Copyright (c) 2018-2022 Marcelo Zimbres Silva (mzimbres@gmail.com) - * - * Distributed under the Boost Software License, Version 1.0. (See - * accompanying file LICENSE.txt) - */ - -#include - -#include -#include -#include -#include - -#include - -#if defined(BOOST_ASIO_HAS_CO_AWAIT) - -namespace asio = boost::asio; -using endpoints = asio::ip::tcp::resolver::results_type; -using boost::redis::request; -using boost::redis::response; -using boost::redis::ignore_t; -using boost::redis::config; -using boost::redis::address; -using boost::redis::connection; - -auto redir(boost::system::error_code& ec) { return asio::redirect_error(asio::use_awaitable, ec); } - -// For more info see -// - https://redis.io/docs/manual/sentinel. -// - https://redis.io/docs/reference/sentinel-clients. -auto resolve_master_address(std::vector
const& addresses) -> asio::awaitable
-{ - request req; - req.push("SENTINEL", "get-master-addr-by-name", "mymaster"); - req.push("QUIT"); - - auto conn = std::make_shared(co_await asio::this_coro::executor); - - response>, ignore_t> resp; - for (auto addr : addresses) { - boost::system::error_code ec; - config cfg; - cfg.addr = addr; - // TODO: async_run and async_exec should be lauched in - // parallel here so we can wait for async_run completion - // before eventually calling it again. - conn->async_run(cfg, asio::consign(asio::detached, conn)); - co_await conn->async_exec(req, resp, redir(ec)); - conn->cancel(); - if (!ec && std::get<0>(resp)) - co_return address{ - std::get<0>(resp).value().value().at(0), - std::get<0>(resp).value().value().at(1)}; - } - - co_return address{}; -} - -auto co_main(config cfg) -> asio::awaitable -{ - // A list of sentinel addresses from which only one is responsive. - // This simulates sentinels that are down. - std::vector
const addresses{ - address{"foo", "26379"}, - address{"bar", "26379"}, - cfg.addr - }; - - auto const ep = co_await resolve_master_address(addresses); - - std::clog << "Host: " << ep.host << "\n" - << "Port: " << ep.port << "\n" - << std::flush; -} - -#endif // defined(BOOST_ASIO_HAS_CO_AWAIT) diff --git a/example/cpp20_sentinel.cpp b/example/cpp20_sentinel.cpp new file mode 100644 index 00000000..5e51413b --- /dev/null +++ b/example/cpp20_sentinel.cpp @@ -0,0 +1,60 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include + +#include +#include +#include + +#include + +#if defined(BOOST_ASIO_HAS_CO_AWAIT) + +namespace asio = boost::asio; +using boost::redis::request; +using boost::redis::response; +using boost::redis::config; +using boost::redis::connection; + +// Called from the main function (see main.cpp) +auto co_main(config cfg) -> asio::awaitable +{ + // Boost.Redis has built-in support for Sentinel deployments. + // To enable it, set the fields in config shown here. + // sentinel.addresses should contain a list of (hostname, port) pairs + // where Sentinels are listening. IPs can also be used. + cfg.sentinel.addresses = { + {"localhost", "26379"}, + {"localhost", "26380"}, + {"localhost", "26381"}, + }; + + // Set master_name to the identifier that you configured + // in the "sentinel monitor" statement of your sentinel.conf file + cfg.sentinel.master_name = "mymaster"; + + // async_run will contact the Sentinels, obtain the master address, + // connect to it and keep the connection healthy. If a failover happens, + // the address will be resolved again and the new elected master will be contacted. + auto conn = std::make_shared(co_await asio::this_coro::executor); + conn->async_run(cfg, asio::consign(asio::detached, conn)); + + // You can now use the connection normally, as you would use a connection to a single master. + request req; + req.push("PING", "Hello world"); + response resp; + + // Execute the request. + co_await conn->async_exec(req, resp); + conn->cancel(); + + std::cout << "PING: " << std::get<0>(resp).value() << std::endl; +} + +#endif // defined(BOOST_ASIO_HAS_CO_AWAIT) diff --git a/include/boost/redis/adapter/any_adapter.hpp b/include/boost/redis/adapter/any_adapter.hpp index a32e2545..05764ab7 100644 --- a/include/boost/redis/adapter/any_adapter.hpp +++ b/include/boost/redis/adapter/any_adapter.hpp @@ -12,9 +12,7 @@ #include -#include #include -#include #include namespace boost::redis { @@ -50,20 +48,7 @@ public: using impl_t = std::function; template - static auto create_impl(T& resp) -> impl_t - { - using namespace boost::redis::adapter; - return [adapter2 = boost_redis_adapt(resp)]( - any_adapter::parse_event ev, - resp3::node_view const& nd, - system::error_code& ec) mutable { - switch (ev) { - case parse_event::init: adapter2.on_init(); break; - case parse_event::node: adapter2.on_node(nd, ec); break; - case parse_event::done: adapter2.on_done(); break; - } - }; - } + static auto create_impl(T& resp) -> impl_t; /// Contructs from a type erased adaper any_adapter(impl_t fn = [](parse_event, resp3::node_view const&, system::error_code&) { }) @@ -109,6 +94,32 @@ private: impl_t impl_; }; +namespace detail { + +template +any_adapter::impl_t make_any_adapter_impl(Adapter&& value) +{ + return [adapter = std::move(value)]( + any_adapter::parse_event ev, + resp3::node_view const& nd, + system::error_code& ec) mutable { + switch (ev) { + case any_adapter::parse_event::init: adapter.on_init(); break; + case any_adapter::parse_event::node: adapter.on_node(nd, ec); break; + case any_adapter::parse_event::done: adapter.on_done(); break; + } + }; +} + +} // namespace detail + } // namespace boost::redis +template +auto boost::redis::any_adapter::create_impl(T& resp) -> impl_t +{ + using adapter::boost_redis_adapt; + return detail::make_any_adapter_impl(boost_redis_adapt(resp)); +} + #endif diff --git a/include/boost/redis/config.hpp b/include/boost/redis/config.hpp index 5a812141..4e980679 100644 --- a/include/boost/redis/config.hpp +++ b/include/boost/redis/config.hpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace boost::redis { @@ -24,12 +25,130 @@ struct address { std::string port = "6379"; }; -/// Configure parameters used by the connection classes. -struct config { - /// Uses SSL instead of a plain connection. +/** @brief Compares two addresses for equality. + * @relates address + * + * @param a Left hand side address. + * @param b Right hand side address. + */ +inline bool operator==(address const& a, address const& b) +{ + return a.host == b.host && a.port == b.port; +} + +/** @brief Compares two addresses for inequality. + * @relates address + * + * @param a Left hand side address. + * @param b Right hand side address. + */ +inline bool operator!=(address const& a, address const& b) { return !(a == b); } + +/// Identifies the possible roles of a Redis server. +enum class role +{ + /// The server is a master. + master, + + /// The server is a replica. + replica, +}; + +/// Configuration values to use when using Sentinel. +struct sentinel_config { + /** + * @brief A list of (hostname, port) pairs where the Sentinels are listening. + * + * Sentinels in this list will be contacted in order, until a successful + * connection is made. At this point, the `SENTINEL SENTINELS` command + * will be used to retrieve any additional Sentinels monitoring the configured master. + * Thus, it is not required to keep this list comprehensive - if Sentinels are added + * later, they will be detected at runtime. + * + * Sentinel will only be used if this value is not empty. + * + * Numeric IP addresses are also allowed as hostnames. + */ + std::vector
addresses{}; + + /** + * @brief The name of the master to connect to, as configured in the + * `sentinel monitor` statement in `sentinel.conf`. + * + * This field is required even when connecting to replicas. + */ + std::string master_name{}; + + /** + * @brief Whether connections to Sentinels should use TLS or not. + * Does not affect connections to masters. + * + * When set to `true`, physical connections to Sentinels will be established + * using TLS. This setting does *not* influence how masters and replicas are contacted. + * To use TLS when connecting to these, set @ref config::use_ssl to `true`. + */ bool use_ssl = false; - /// For TCP connections, hostname and port of the Redis server. + /** + * @brief A request to be sent to Sentinels upon connection establishment. + * + * This request is executed every time a Sentinel is contacted, and before + * commands like `SENTINEL GET-MASTER-NAME-BY-ADDR` are run. + * By default, this field contains a `HELLO 3` command. + * You can use this request to set up any authorization required by Sentinels. + * + * This request should ensure that the connection is upgraded to RESP3 + * by executing `HELLO 3` or similar. RESP2 is not supported yet. + */ + request setup = detail::make_hello_request(); + + /** + * @brief Time span that the Sentinel resolve operation is allowed to elapse. + * Does not affect connections to masters and replicas, controlled by @ref config::resolve_timeout. + */ + std::chrono::steady_clock::duration resolve_timeout = std::chrono::milliseconds{500}; + + /** + * @brief Time span that the Sentinel connect operation is allowed to elapse. + * Does not affect connections to masters and replicas, controlled by @ref config::connect_timeout. + */ + std::chrono::steady_clock::duration connect_timeout = std::chrono::milliseconds{500}; + + /** + * @brief Time span that the Sentinel TLS handshake operation is allowed to elapse. + * Does not affect connections to masters and replicas, controlled by @ref config::ssl_handshake_timeout. + */ + std::chrono::steady_clock::duration ssl_handshake_timeout = std::chrono::seconds{5}; + + /** + * @brief Time span that the Sentinel request/response exchange is allowed to elapse. + * Includes executing the commands in @ref setup and the commands required to + * resolve the server's address. + */ + std::chrono::steady_clock::duration request_timeout = std::chrono::seconds{5}; + + /** + * @brief Whether to connect to a Redis master or to a replica. + * + * The library resolves and connects to the Redis master, by default. + * Set this value to @ref role::replica to connect to one of the replicas + * of the master identified by @ref master_name. + * The particular replica will be chosen randomly. + */ + role server_role = role::master; +}; + +/// Configure parameters used by the connection classes. +struct config { + /** + * @brief Whether to use TLS instead of plaintext connections. + * + * When using Sentinel, configures whether to use TLS when connecting to masters and replicas. + * Use @ref sentinel_config::use_ssl to control TLS for Sentinels. + */ + bool use_ssl = false; + + /// For TCP connections, hostname and port of the Redis server. Ignored when using Sentinel. address addr = address{"127.0.0.1", "6379"}; /** @@ -37,8 +156,11 @@ struct config { * * If non-empty, communication with the server will happen using * UNIX domain sockets, and @ref addr will be ignored. + * * UNIX domain sockets can't be used with SSL: if `unix_socket` is non-empty, - * @ref use_ssl must be `false`. + * @ref use_ssl must be `false`. UNIX domain sockets can't be used with Sentinel, either. + * + * UNIX domain sockets can't be used with Sentinel. */ std::string unix_socket; @@ -51,6 +173,9 @@ struct config { * If the username equals the literal `"default"` (the default) * and no password is specified, the `HELLO` command is sent * without authentication parameters. + * + * When using Sentinel, this setting applies to masters and replicas. + * Use @ref sentinel_config::setup to configure authorization for Sentinels. */ std::string username = "default"; @@ -63,6 +188,9 @@ struct config { * If the username equals the literal `"default"` (the default) * and no password is specified, the `HELLO` command is sent * without authentication parameters. + * + * When using Sentinel, this setting applies to masters and replicas. + * Use @ref sentinel_config::setup to configure authorization for Sentinels. */ std::string password; @@ -71,6 +199,9 @@ struct config { * If @ref use_setup is false (the default), during connection establishment, * a `HELLO` command is sent. If this field is not empty, the `HELLO` command * will contain a `SETNAME` subcommand containing this value. + * + * When using Sentinel, this setting applies to masters and replicas. + * Use @ref sentinel_config::setup to configure this value for Sentinels. */ std::string clientname = "Boost.Redis"; @@ -80,6 +211,8 @@ struct config { * non-empty optional, and its value is different than zero, * a `SELECT` command will be issued during connection establishment to set the logical * database index. By default, no `SELECT` command is sent. + * + * When using Sentinel, this setting applies to masters and replicas. */ std::optional database_index = 0; @@ -95,13 +228,22 @@ struct config { */ std::string log_prefix = "(Boost.Redis) "; - /// Time span that the resolve operation is allowed to elapse. + /** + * @brief Time span that the resolve operation is allowed to elapse. + * When using Sentinel, this setting applies to masters and replicas. + */ std::chrono::steady_clock::duration resolve_timeout = std::chrono::seconds{10}; - /// Time span that the connect operation is allowed to elapse. + /** + * @brief Time span that the connect operation is allowed to elapse. + * When using Sentinel, this setting applies to masters and replicas. + */ std::chrono::steady_clock::duration connect_timeout = std::chrono::seconds{10}; - /// Time span that the SSL handshake operation is allowed to elapse. + /** + * @brief Time span that the SSL handshake operation is allowed to elapse. + * When using Sentinel, this setting applies to masters and replicas. + */ std::chrono::steady_clock::duration ssl_handshake_timeout = std::chrono::seconds{10}; /** @brief Time span between successive health checks. @@ -123,18 +265,28 @@ struct config { * * The exact timeout values are *not* part of the interface, and might change * in future versions. + * + * When using Sentinel, this setting applies to masters and replicas. + * Sentinels are not health-checked. */ std::chrono::steady_clock::duration health_check_interval = std::chrono::seconds{2}; /** @brief Time span to wait between successive connection retries. - * Set to zero to disable reconnection. + * Set to zero to disable reconnection. + * + * When using Sentinel, this setting applies to masters, replicas and Sentinels. + * If none of the configured Sentinels can be contacted, this time span will + * be waited before trying again. After a connection error with a master or replica + * is encountered, this time span will be waited before contacting Sentinels again. */ std::chrono::steady_clock::duration reconnect_wait_interval = std::chrono::seconds{1}; /** @brief Maximum size of the socket read-buffer in bytes. * - * Sets a limit on how much data is allowed to be read into the - * read buffer. It can be used to prevent DDOS. + * Sets a limit on how much data is allowed to be read into the + * read buffer. It can be used to prevent DDOS. + * + * When using Sentinel, this setting applies to masters, replicas and Sentinels. */ std::size_t max_read_size = (std::numeric_limits::max)(); @@ -144,6 +296,8 @@ struct config { * needed. This can help avoiding some memory allocations. Once the * maximum size is reached no more memory allocations are made * since the buffer is reused. + * + * When using Sentinel, this setting applies to masters, replicas and Sentinels. */ std::size_t read_buffer_append_size = 4096; @@ -168,6 +322,9 @@ struct config { * systems that don't support `HELLO`. * * By default, this field is false, and @ref setup will not be used. + * + * When using Sentinel, this setting applies to masters and replicas. + * Use @ref sentinel_config::setup for Sentinels. */ bool use_setup = false; @@ -177,8 +334,17 @@ struct config { * @ref use_setup docs for more info. * * By default, `setup` contains a `"HELLO 3"` command. + * + * When using Sentinel, this setting applies to masters and replicas. + * Use @ref sentinel_config::setup for Sentinels. */ request setup = detail::make_hello_request(); + + /** + * @brief Configuration values for Sentinel. Sentinel is enabled only if + * @ref sentinel_config::addresses is not empty. + */ + sentinel_config sentinel{}; }; } // namespace boost::redis diff --git a/include/boost/redis/connection.hpp b/include/boost/redis/connection.hpp index 25791fea..3e8298f3 100644 --- a/include/boost/redis/connection.hpp +++ b/include/boost/redis/connection.hpp @@ -12,10 +12,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -32,9 +34,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -45,6 +49,7 @@ #include #include #include +#include #include #include @@ -230,6 +235,7 @@ struct connection_impl { template auto async_receive2(CompletionToken&& token) { + // clang-format off return receive_channel_.async_receive( asio::deferred( @@ -250,9 +256,104 @@ struct connection_impl { } ) )(std::forward(token)); + // clang-format on } }; +template +struct exec_one_op { + connection_impl* conn_; + const request* req_; + exec_one_fsm fsm_; + + explicit exec_one_op(connection_impl& conn, const request& req, any_adapter resp) + : conn_(&conn) + , req_(&req) + , fsm_(std::move(resp), req.get_expected_responses()) + { } + + template + void operator()(Self& self, system::error_code ec = {}, std::size_t bytes_written = 0u) + { + exec_one_action act = fsm_.resume( + conn_->st_.mpx.get_read_buffer(), + ec, + bytes_written, + self.get_cancellation_state().cancelled()); + + switch (act.type) { + case exec_one_action_type::done: self.complete(act.ec); return; + case exec_one_action_type::write: + asio::async_write(conn_->stream_, asio::buffer(req_->payload()), std::move(self)); + return; + case exec_one_action_type::read_some: + conn_->stream_.async_read_some( + conn_->st_.mpx.get_read_buffer().get_prepared(), + std::move(self)); + return; + } + } +}; + +template +auto async_exec_one( + connection_impl& conn, + const request& req, + any_adapter resp, + CompletionToken&& token) +{ + return asio::async_compose( + exec_one_op{conn, req, std::move(resp)}, + token, + conn); +} + +template +struct sentinel_resolve_op { + connection_impl* conn_; + sentinel_resolve_fsm fsm_; + + explicit sentinel_resolve_op(connection_impl& conn) + : conn_(&conn) + { } + + template + void operator()(Self& self, system::error_code ec = {}) + { + auto* conn = conn_; // Prevent potential use-after-move errors with cancel_after + sentinel_action act = fsm_.resume(conn_->st_, ec, self.get_cancellation_state().cancelled()); + + switch (act.get_type()) { + case sentinel_action::type::done: self.complete(act.error()); return; + case sentinel_action::type::connect: + conn->stream_.async_connect( + make_sentinel_connect_params(conn->st_.cfg, act.connect_addr()), + conn->st_.logger, + std::move(self)); + return; + case sentinel_action::type::request: + async_exec_one( + *conn, + conn->st_.cfg.sentinel.setup, + make_sentinel_adapter(conn->st_), + asio::cancel_after( + conn->reconnect_timer_, // should be safe to re-use this + conn->st_.cfg.sentinel.request_timeout, + std::move(self))); + return; + } + } +}; + +template +auto async_sentinel_resolve(connection_impl& conn, CompletionToken&& token) +{ + return asio::async_compose( + sentinel_resolve_op{conn}, + token, + conn); +} + template struct writer_op { connection_impl* conn_; @@ -379,8 +480,14 @@ public: case run_action_type::immediate: asio::async_immediate(self.get_io_executor(), std::move(self)); return; + case run_action_type::sentinel_resolve: + async_sentinel_resolve(*conn_, std::move(self)); + return; case run_action_type::connect: - conn_->stream_.async_connect(conn_->st_.cfg, conn_->st_.logger, std::move(self)); + conn_->stream_.async_connect( + make_run_connect_params(conn_->st_), + conn_->st_.logger, + std::move(self)); return; case run_action_type::parallel_group: asio::experimental::make_parallel_group( @@ -531,6 +638,8 @@ public: * This function establishes a connection to the Redis server and keeps * it healthy by performing the following operations: * + * @li For Sentinel deployments (`config::sentinel::addresses` is not empty), + * contacts Sentinels to obtain the address of the configured master. * @li For TCP connections, resolves the server hostname passed in * @ref boost::redis::config::addr. * @li Establishes a physical connection to the server. For TCP connections, @@ -728,10 +837,7 @@ public: * @returns The number of bytes read from the socket. */ BOOST_DEPRECATED("Please, use async_receive2 instead.") - std::size_t receive(system::error_code& ec) - { - return impl_->receive(ec); - } + std::size_t receive(system::error_code& ec) { return impl_->receive(ec); } /** @brief Executes commands on the Redis server asynchronously. * @@ -1119,10 +1225,7 @@ public: /// @copydoc basic_connection::receive BOOST_DEPRECATED("Please use async_receive2 instead.") - std::size_t receive(system::error_code& ec) - { - return impl_.impl_->receive(ec); - } + std::size_t receive(system::error_code& ec) { return impl_.impl_->receive(ec); } /** * @brief Calls @ref boost::redis::basic_connection::async_exec. diff --git a/include/boost/redis/detail/connect_fsm.hpp b/include/boost/redis/detail/connect_fsm.hpp index 17627615..35235566 100644 --- a/include/boost/redis/detail/connect_fsm.hpp +++ b/include/boost/redis/detail/connect_fsm.hpp @@ -9,8 +9,6 @@ #ifndef BOOST_REDIS_CONNECT_FSM_HPP #define BOOST_REDIS_CONNECT_FSM_HPP -#include - #include #include #include @@ -62,17 +60,13 @@ struct connect_action { class connect_fsm { int resume_point_{0}; - const config* cfg_{nullptr}; buffered_logger* lgr_{nullptr}; public: - connect_fsm(const config& cfg, buffered_logger& lgr) noexcept - : cfg_(&cfg) - , lgr_(&lgr) + connect_fsm(buffered_logger& lgr) noexcept + : lgr_(&lgr) { } - const config& get_config() const { return *cfg_; } - connect_action resume( system::error_code ec, const asio::ip::tcp::resolver::results_type& resolver_results, diff --git a/include/boost/redis/detail/connect_params.hpp b/include/boost/redis/detail/connect_params.hpp new file mode 100644 index 00000000..e0dfa01d --- /dev/null +++ b/include/boost/redis/detail/connect_params.hpp @@ -0,0 +1,65 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_REDIS_CONNECT_PARAMS_HPP +#define BOOST_REDIS_CONNECT_PARAMS_HPP + +// Parameters used by redis_stream::async_connect + +#include +#include + +#include +#include + +namespace boost::redis::detail { + +// Fully identifies where a server is listening. Reference type. +class any_address_view { + transport_type type_; + union { + const address* tcp_; + std::string_view unix_; + }; + +public: + any_address_view(const address& addr, bool use_ssl) noexcept + : type_(use_ssl ? transport_type::tcp_tls : transport_type::tcp) + , tcp_(&addr) + { } + + explicit any_address_view(std::string_view unix_socket) noexcept + : type_(transport_type::unix_socket) + , unix_(unix_socket) + { } + + transport_type type() const { return type_; } + + const address& tcp_address() const + { + BOOST_ASSERT(type_ == transport_type::tcp || type_ == transport_type::tcp_tls); + return *tcp_; + } + + std::string_view unix_socket() const + { + BOOST_ASSERT(type_ == transport_type::unix_socket); + return unix_; + } +}; + +struct connect_params { + any_address_view addr; + std::chrono::steady_clock::duration resolve_timeout; + std::chrono::steady_clock::duration connect_timeout; + std::chrono::steady_clock::duration ssl_handshake_timeout; +}; + +} // namespace boost::redis::detail + +#endif // BOOST_REDIS_CONNECTOR_HPP diff --git a/include/boost/redis/detail/connection_state.hpp b/include/boost/redis/detail/connection_state.hpp index f908c5d1..b4d2e1a0 100644 --- a/include/boost/redis/detail/connection_state.hpp +++ b/include/boost/redis/detail/connection_state.hpp @@ -13,20 +13,46 @@ #include #include #include +#include #include +#include #include +#include namespace boost::redis::detail { +// A random engine that gets seeded lazily. +// Seeding with std::random_device is not trivial and might fail. +class lazy_random_engine { + bool seeded_{}; + std::minstd_rand eng_; + +public: + lazy_random_engine() = default; + std::minstd_rand& get() + { + if (!seeded_) { + eng_.seed(static_cast(std::random_device{}())); + seeded_ = true; + } + return eng_; + } +}; + // Contains all the members in connection that don't depend on the Executor. // Makes implementing sans-io algorithms easier struct connection_state { buffered_logger logger; config cfg{}; multiplexer mpx{}; - std::string setup_diagnostic{}; + std::string diagnostic{}; // Used by the setup request and Sentinel request ping_req{}; + + // Sentinel stuff + lazy_random_engine eng{}; + std::vector
sentinels{}; + std::vector sentinel_resp_nodes{}; // for parsing }; } // namespace boost::redis::detail diff --git a/include/boost/redis/detail/exec_one_fsm.hpp b/include/boost/redis/detail/exec_one_fsm.hpp new file mode 100644 index 00000000..4ad3bd3e --- /dev/null +++ b/include/boost/redis/detail/exec_one_fsm.hpp @@ -0,0 +1,69 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_REDIS_EXEC_ONE_FSM_HPP +#define BOOST_REDIS_EXEC_ONE_FSM_HPP + +#include +#include + +#include +#include + +#include + +// Sans-io algorithm for async_exec_one, as a finite state machine + +namespace boost::redis::detail { + +class read_buffer; + +// What should we do next? +enum class exec_one_action_type +{ + done, // Call the final handler + write, // Write the request + read_some, // Read into the read buffer +}; + +struct exec_one_action { + exec_one_action_type type; + system::error_code ec; + + exec_one_action(exec_one_action_type type) noexcept + : type{type} + { } + + exec_one_action(system::error_code ec) noexcept + : type{exec_one_action_type::done} + , ec{ec} + { } +}; + +class exec_one_fsm { + int resume_point_{0}; + any_adapter adapter_; + std::size_t remaining_responses_; + resp3::parser parser_; + +public: + exec_one_fsm(any_adapter resp, std::size_t expected_responses) + : adapter_(std::move(resp)) + , remaining_responses_(expected_responses) + { } + + exec_one_action resume( + read_buffer& buffer, + system::error_code ec, + std::size_t bytes_transferred, + asio::cancellation_type_t cancel_state); +}; + +} // namespace boost::redis::detail + +#endif // BOOST_REDIS_CONNECTOR_HPP diff --git a/include/boost/redis/detail/multiplexer.hpp b/include/boost/redis/detail/multiplexer.hpp index 14afc6b5..797c9493 100644 --- a/include/boost/redis/detail/multiplexer.hpp +++ b/include/boost/redis/detail/multiplexer.hpp @@ -181,6 +181,12 @@ public: return std::string_view{write_buffer_}.substr(write_offset_); } + [[nodiscard]] + auto get_read_buffer() noexcept -> read_buffer& + { + return read_buffer_; + } + [[nodiscard]] auto get_prepared_read_buffer() noexcept -> read_buffer::span_type; diff --git a/include/boost/redis/detail/redis_stream.hpp b/include/boost/redis/detail/redis_stream.hpp index 7c51624c..349cc650 100644 --- a/include/boost/redis/detail/redis_stream.hpp +++ b/include/boost/redis/detail/redis_stream.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include @@ -48,12 +50,14 @@ class redis_stream { struct connect_op { redis_stream& obj_; connect_fsm fsm_; + connect_params params_; template void execute_action(Self& self, connect_action act) { - auto& obj = this->obj_; // prevent use-after-move errors - const auto& cfg = fsm_.get_config(); + // Prevent use-after-move errors + auto& obj = this->obj_; + auto params = this->params_; switch (act.type) { case connect_action_type::unix_socket_close: @@ -70,8 +74,8 @@ class redis_stream { case connect_action_type::unix_socket_connect: #ifdef BOOST_ASIO_HAS_LOCAL_SOCKETS obj.unix_socket_.async_connect( - cfg.unix_socket, - asio::cancel_after(obj.timer_, cfg.connect_timeout, std::move(self))); + params.addr.unix_socket(), + asio::cancel_after(obj.timer_, params.connect_timeout, std::move(self))); #else BOOST_ASSERT(false); #endif @@ -79,9 +83,9 @@ class redis_stream { case connect_action_type::tcp_resolve: obj.resolv_.async_resolve( - cfg.addr.host, - cfg.addr.port, - asio::cancel_after(obj.timer_, cfg.resolve_timeout, std::move(self))); + params.addr.tcp_address().host, + params.addr.tcp_address().port, + asio::cancel_after(obj.timer_, params.resolve_timeout, std::move(self))); return; case connect_action_type::ssl_stream_reset: obj.reset_stream(); @@ -91,7 +95,7 @@ class redis_stream { case connect_action_type::ssl_handshake: obj.stream_.async_handshake( asio::ssl::stream_base::client, - asio::cancel_after(obj.timer_, cfg.ssl_handshake_timeout, std::move(self))); + asio::cancel_after(obj.timer_, params.ssl_handshake_timeout, std::move(self))); return; case connect_action_type::done: self.complete(act.ec); break; // Connect should use the specialized handler, where resolver results are available @@ -124,11 +128,11 @@ class redis_stream { { auto act = fsm_.resume(ec, endpoints, obj_.st_, self.get_cancellation_state().cancelled()); if (act.type == connect_action_type::tcp_connect) { - auto& obj = this->obj_; // prevent use-after-free errors + auto& obj = this->obj_; // prevent use-after-move errors asio::async_connect( obj.stream_.next_layer(), std::move(endpoints), - asio::cancel_after(obj.timer_, fsm_.get_config().connect_timeout, std::move(self))); + asio::cancel_after(obj.timer_, params_.connect_timeout, std::move(self))); } else { execute_action(self, act); } @@ -172,10 +176,11 @@ public: // I/O template - auto async_connect(const config& cfg, buffered_logger& l, CompletionToken&& token) + auto async_connect(const connect_params& params, buffered_logger& l, CompletionToken&& token) { + this->st_.type = params.addr.type(); return asio::async_compose( - connect_op{*this, connect_fsm(cfg, l)}, + connect_op{*this, connect_fsm{l}, params}, token); } diff --git a/include/boost/redis/detail/run_fsm.hpp b/include/boost/redis/detail/run_fsm.hpp index c2a17d23..b125fa74 100644 --- a/include/boost/redis/detail/run_fsm.hpp +++ b/include/boost/redis/detail/run_fsm.hpp @@ -9,6 +9,8 @@ #ifndef BOOST_REDIS_RUN_FSM_HPP #define BOOST_REDIS_RUN_FSM_HPP +#include + #include #include @@ -25,6 +27,7 @@ enum class run_action_type done, // Call the final handler immediate, // Call asio::async_immediate connect, // Transport connection establishment + sentinel_resolve, // Contact Sentinels to resolve the master's address parallel_group, // Run the reader, writer and friends cancel_receive, // Cancel the receiver channel wait_for_reconnection, // Sleep for the reconnection period @@ -57,6 +60,8 @@ public: asio::cancellation_type_t cancel_state); }; +connect_params make_run_connect_params(const connection_state& st); + } // namespace boost::redis::detail #endif // BOOST_REDIS_CONNECTOR_HPP diff --git a/include/boost/redis/detail/sentinel_resolve_fsm.hpp b/include/boost/redis/detail/sentinel_resolve_fsm.hpp new file mode 100644 index 00000000..de8d4db6 --- /dev/null +++ b/include/boost/redis/detail/sentinel_resolve_fsm.hpp @@ -0,0 +1,93 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_REDIS_SENTINEL_RESOLVE_FSM_HPP +#define BOOST_REDIS_SENTINEL_RESOLVE_FSM_HPP + +#include +#include +#include + +#include +#include +#include + +// Sans-io algorithm for async_sentinel_resolve, as a finite state machine + +namespace boost::redis::detail { + +// Forward decls +struct connection_state; + +class sentinel_action { +public: + enum class type + { + done, // Call the final handler + connect, // Transport connection establishment + request, // Send the Sentinel request + }; + + sentinel_action(system::error_code ec) noexcept + : type_(type::done) + , ec_(ec) + { } + + sentinel_action(const address& addr) noexcept + : type_(type::connect) + , connect_(&addr) + { } + + static sentinel_action request() { return {type::request}; } + + type get_type() const { return type_; } + + [[nodiscard]] + system::error_code error() const + { + BOOST_ASSERT(type_ == type::done); + return ec_; + } + + const address& connect_addr() const + { + BOOST_ASSERT(type_ == type::connect); + return *connect_; + } + +private: + type type_; + union { + system::error_code ec_; + const address* connect_; + }; + + sentinel_action(type type) noexcept + : type_(type) + { } +}; + +class sentinel_resolve_fsm { + int resume_point_{0}; + std::size_t idx_{0u}; + +public: + sentinel_resolve_fsm() = default; + + sentinel_action resume( + connection_state& st, + system::error_code ec, + asio::cancellation_type_t cancel_state); +}; + +connect_params make_sentinel_connect_params(const config& cfg, const address& sentinel_addr); +any_adapter make_sentinel_adapter(connection_state& st); + +} // namespace boost::redis::detail + +#endif // BOOST_REDIS_CONNECTOR_HPP diff --git a/include/boost/redis/error.hpp b/include/boost/redis/error.hpp index f39699f3..346e3ad1 100644 --- a/include/boost/redis/error.hpp +++ b/include/boost/redis/error.hpp @@ -94,6 +94,24 @@ enum class error /// Timeout while writing data to the server. write_timeout, + + /// The configuration specified UNIX sockets with Sentinel, which is not supported. + sentinel_unix_sockets_unsupported, + + /// No Sentinel could be used to obtain the address of the Redis server. + /// Sentinels might be unreachable, have authentication misconfigured or may not know about + /// the configured master. Turn logging on for details. + sentinel_resolve_failed, + + /// The contacted server is not a master as expected. + /// This is likely a transient failure caused by a Sentinel failover in progress. + role_check_failed, + + /// Expects a RESP3 string, but got a different data type. + expects_resp3_string, + + /// Expects a RESP3 array, but got a different data type. + expects_resp3_array, }; /** diff --git a/include/boost/redis/impl/connect_fsm.ipp b/include/boost/redis/impl/connect_fsm.ipp index 82724588..f2699dda 100644 --- a/include/boost/redis/impl/connect_fsm.ipp +++ b/include/boost/redis/impl/connect_fsm.ipp @@ -61,20 +61,6 @@ struct log_traits { } }; -inline transport_type transport_from_config(const config& cfg) -{ - if (cfg.unix_socket.empty()) { - if (cfg.use_ssl) { - return transport_type::tcp_tls; - } else { - return transport_type::tcp; - } - } else { - BOOST_ASSERT(!cfg.use_ssl); - return transport_type::unix_socket; - } -} - inline system::error_code translate_timeout_error( system::error_code io_ec, asio::cancellation_type_t cancel_state, @@ -105,9 +91,9 @@ connect_action connect_fsm::resume( // Log it if (ec) { - log_info(*lgr_, "Error resolving the server hostname: ", ec); + log_info(*lgr_, "Connect: hostname resolution failed: ", ec); } else { - log_info(*lgr_, "Resolve results: ", resolver_results); + log_debug(*lgr_, "Connect: hostname resolution results: ", resolver_results); } // Delegate to the regular resume function @@ -125,9 +111,9 @@ connect_action connect_fsm::resume( // Log it if (ec) { - log_info(*lgr_, "Failed to connect to the server: ", ec); + log_info(*lgr_, "Connect: TCP connect failed: ", ec); } else { - log_info(*lgr_, "Connected to ", selected_endpoint); + log_debug(*lgr_, "Connect: TCP connect succeeded. Selected endpoint: ", selected_endpoint); } // Delegate to the regular resume function @@ -142,9 +128,6 @@ connect_action connect_fsm::resume( switch (resume_point_) { BOOST_REDIS_CORO_INITIAL - // Record the transport that we will be using - st.type = transport_from_config(*cfg_); - if (st.type == transport_type::unix_socket) { // Reset the socket, to discard any previous state. Ignore any errors BOOST_REDIS_YIELD(resume_point_, 1, connect_action_type::unix_socket_close) @@ -160,9 +143,9 @@ connect_action connect_fsm::resume( // Log it if (ec) { - log_info(*lgr_, "Failed to connect to the server: ", ec); + log_info(*lgr_, "Connect: UNIX socket connect failed: ", ec); } else { - log_info(*lgr_, "Connected to ", cfg_->unix_socket); + log_debug(*lgr_, "Connect: UNIX socket connect succeeded"); } // If this failed, we can't continue @@ -178,7 +161,7 @@ connect_action connect_fsm::resume( // Must be done before anything else is done on the stream. // We don't need to close the TCP socket if using plaintext TCP // because range-connect closes open sockets, while individual connect doesn't - if (cfg_->use_ssl && st.ssl_stream_used) { + if (st.type == transport_type::tcp_tls && st.ssl_stream_used) { BOOST_REDIS_YIELD(resume_point_, 3, connect_action_type::ssl_stream_reset) } @@ -200,7 +183,7 @@ connect_action connect_fsm::resume( return ec; } - if (cfg_->use_ssl) { + if (st.type == transport_type::tcp_tls) { // Mark the SSL stream as used st.ssl_stream_used = true; @@ -212,9 +195,9 @@ connect_action connect_fsm::resume( // Log it if (ec) { - log_info(*lgr_, "Failed to perform SSL handshake: ", ec); + log_info(*lgr_, "Connect: SSL handshake failed: ", ec); } else { - log_info(*lgr_, "Successfully performed SSL handshake"); + log_debug(*lgr_, "Connect: SSL handshake succeeded"); } // If this failed, we can't continue diff --git a/include/boost/redis/impl/error.ipp b/include/boost/redis/impl/error.ipp index 0ac39952..f7071506 100644 --- a/include/boost/redis/impl/error.ipp +++ b/include/boost/redis/impl/error.ipp @@ -55,8 +55,19 @@ struct error_category_impl : system::error_category { case error::exceeds_maximum_read_buffer_size: return "Reading data from the socket would exceed the maximum size allowed of the read " "buffer."; - case error::write_timeout: - return "Timeout while writing data to the server."; + case error::write_timeout: return "Timeout while writing data to the server."; + case error::sentinel_unix_sockets_unsupported: + return "The configuration specified UNIX sockets with Sentinel, which is not " + "supported."; + case error::sentinel_resolve_failed: + return "No Sentinel could be used to obtain the address of the Redis server."; + case error::role_check_failed: + return "The contacted server does not have the expected role. " + "This is likely a transient failure caused by a Sentinel failover in progress."; + case error::expects_resp3_string: + return "Expects a RESP3 string, but got a different data type."; + case error::expects_resp3_array: + return "Expects a RESP3 array, but got a different data type."; default: BOOST_ASSERT(false); return "Boost.Redis error."; } } diff --git a/include/boost/redis/impl/exec_one_fsm.ipp b/include/boost/redis/impl/exec_one_fsm.ipp new file mode 100644 index 00000000..b4a7250b --- /dev/null +++ b/include/boost/redis/impl/exec_one_fsm.ipp @@ -0,0 +1,95 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_REDIS_EXEC_ONE_FSM_IPP +#define BOOST_REDIS_EXEC_ONE_FSM_IPP + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace boost::redis::detail { + +exec_one_action exec_one_fsm::resume( + read_buffer& buffer, + system::error_code ec, + std::size_t bytes_transferred, + asio::cancellation_type_t cancel_state) +{ + switch (resume_point_) { + BOOST_REDIS_CORO_INITIAL + + // Send the request to the server + BOOST_REDIS_YIELD(resume_point_, 1, exec_one_action_type::write) + + // Errors and cancellations + if (is_terminal_cancel(cancel_state)) + return system::error_code{asio::error::operation_aborted}; + if (ec) + return ec; + + // If the request didn't expect any response, we're done + if (remaining_responses_ == 0u) + return system::error_code{}; + + // Read responses until we're done + buffer.clear(); + while (true) { + // Prepare the buffer to read some data + ec = buffer.prepare(); + if (ec) + return ec; + + // Read data + BOOST_REDIS_YIELD(resume_point_, 2, exec_one_action_type::read_some) + + // Errors and cancellations + if (is_terminal_cancel(cancel_state)) + return system::error_code{asio::error::operation_aborted}; + if (ec) + return ec; + + // Commit the data into the buffer + buffer.commit(bytes_transferred); + + // Consume the data until we run out or all the responses have been read + while (resp3::parse(parser_, buffer.get_commited(), adapter_, ec)) { + // Check for errors + if (ec) + return ec; + + // We've finished parsing a response + buffer.consume(parser_.get_consumed()); + parser_.reset(); + + // When no more responses remain, we're done. + // Don't read ahead, even if more data is available + if (--remaining_responses_ == 0u) + return system::error_code{}; + } + } + } + + BOOST_ASSERT(false); + return system::error_code(); +} + +} // namespace boost::redis::detail + +#endif diff --git a/include/boost/redis/impl/log_utils.hpp b/include/boost/redis/impl/log_utils.hpp index e4b7349c..5515319d 100644 --- a/include/boost/redis/impl/log_utils.hpp +++ b/include/boost/redis/impl/log_utils.hpp @@ -7,6 +7,7 @@ #ifndef BOOST_REDIS_LOG_UTILS_HPP #define BOOST_REDIS_LOG_UTILS_HPP +#include #include #include @@ -48,6 +49,16 @@ struct log_traits { } }; +template <> +struct log_traits
{ + static inline void log(std::string& to, const address& value) + { + to += value.host; + to += ':'; + to += value.port; + } +}; + template void format_log_args(std::string& to, const Args&... args) { diff --git a/include/boost/redis/impl/run_fsm.ipp b/include/boost/redis/impl/run_fsm.ipp index d3d08cdd..845f9d20 100644 --- a/include/boost/redis/impl/run_fsm.ipp +++ b/include/boost/redis/impl/run_fsm.ipp @@ -8,12 +8,15 @@ #include #include +#include #include #include #include #include +#include #include #include +#include #include #include @@ -28,6 +31,8 @@ inline system::error_code check_config(const config& cfg) if (!cfg.unix_socket.empty()) { if (cfg.use_ssl) return error::unix_sockets_ssl_unsupported; + if (use_sentinel(cfg)) + return error::sentinel_unix_sockets_unsupported; #ifndef BOOST_ASIO_HAS_LOCAL_SOCKETS return error::unix_sockets_unsupported; #endif @@ -41,45 +46,44 @@ inline void compose_ping_request(const config& cfg, request& to) to.push("PING", cfg.health_check_id); } -inline void process_setup_node( - connection_state& st, - resp3::basic_node const& nd, - system::error_code& ec) -{ - switch (nd.data_type) { - case resp3::type::simple_error: - case resp3::type::blob_error: - case resp3::type::null: - ec = redis::error::resp3_hello; - st.setup_diagnostic = nd.value; - break; - default:; - } -} - -inline any_adapter make_setup_adapter(connection_state& st) -{ - return any_adapter{ - [&st](any_adapter::parse_event evt, resp3::node_view const& nd, system::error_code& ec) { - if (evt == any_adapter::parse_event::node) - process_setup_node(st, nd, ec); - }}; -} - inline void on_setup_done(const multiplexer::elem& elm, connection_state& st) { const auto ec = elm.get_error(); if (ec) { - if (st.setup_diagnostic.empty()) { + if (st.diagnostic.empty()) { log_info(st.logger, "Setup request execution: ", ec); } else { - log_info(st.logger, "Setup request execution: ", ec, " (", st.setup_diagnostic, ")"); + log_info(st.logger, "Setup request execution: ", ec, " (", st.diagnostic, ")"); } } else { log_info(st.logger, "Setup request execution: success"); } } +inline any_address_view get_server_address(const connection_state& st) +{ + if (st.cfg.unix_socket.empty()) { + return {st.cfg.addr, st.cfg.use_ssl}; + } else { + return any_address_view{st.cfg.unix_socket}; + } +} + +template <> +struct log_traits { + static inline void log(std::string& to, any_address_view value) + { + if (value.type() == transport_type::unix_socket) { + to += '\''; + to += value.unix_socket(); + to += '\''; + } else { + log_traits
::log(to, value.tcp_address()); + to += value.type() == transport_type::tcp_tls ? " (TLS enabled)" : " (TLS disabled)"; + } + } +}; + run_action run_fsm::resume( connection_state& st, system::error_code ec, @@ -103,9 +107,34 @@ run_action run_fsm::resume( // Compose the PING request. Same as above compose_ping_request(st.cfg, st.ping_req); + if (use_sentinel(st.cfg)) { + // Sentinel request. Same as above + compose_sentinel_request(st.cfg); + + // Bootstrap the sentinel list with the ones configured by the user + st.sentinels = st.cfg.sentinel.addresses; + } + for (;;) { + // Sentinel resolve, if required. This leaves the address in st.cfg.address + if (use_sentinel(st.cfg)) { + // This operation does the logging for us. + BOOST_REDIS_YIELD(resume_point_, 2, run_action_type::sentinel_resolve) + + // Check for cancellations + if (is_terminal_cancel(cancel_state)) { + log_debug(st.logger, "Run: cancelled (4)"); + return {asio::error::operation_aborted}; + } + + // Check for errors + if (ec) + goto sleep_and_reconnect; + } + // Try to connect - BOOST_REDIS_YIELD(resume_point_, 2, run_action_type::connect) + log_info(st.logger, "Trying to connect to Redis server at ", get_server_address(st)); + BOOST_REDIS_YIELD(resume_point_, 4, run_action_type::connect) // Check for cancellations if (is_terminal_cancel(cancel_state)) { @@ -113,53 +142,66 @@ run_action run_fsm::resume( return system::error_code(asio::error::operation_aborted); } - // If we were successful, run all the connection tasks - if (!ec) { - // Initialization - st.mpx.reset(); - st.setup_diagnostic.clear(); - - // Add the setup request to the multiplexer - if (st.cfg.setup.get_commands() != 0u) { - auto elm = make_elem(st.cfg.setup, make_setup_adapter(st)); - elm->set_done_callback([&elem_ref = *elm, &st] { - on_setup_done(elem_ref, st); - }); - st.mpx.add(elm); - } - - // Run the tasks - BOOST_REDIS_YIELD(resume_point_, 3, run_action_type::parallel_group) - - // Store any error yielded by the tasks for later - stored_ec_ = ec; - - // We've lost connection or otherwise been cancelled. - // Remove from the multiplexer the required requests. - st.mpx.cancel_on_conn_lost(); - - // The receive operation must be cancelled because channel - // subscription does not survive a reconnection but requires - // re-subscription. - BOOST_REDIS_YIELD(resume_point_, 4, run_action_type::cancel_receive) - - // Restore the error - ec = stored_ec_; + if (ec) { + // There was an error. Skip to the reconnection loop + log_info( + st.logger, + "Failed to connect to Redis server at ", + get_server_address(st), + ": ", + ec); + goto sleep_and_reconnect; } + // We were successful + log_info(st.logger, "Connected to Redis server at ", get_server_address(st)); + + // Initialization + st.mpx.reset(); + st.diagnostic.clear(); + + // Add the setup request to the multiplexer + if (st.cfg.setup.get_commands() != 0u) { + auto elm = make_elem(st.cfg.setup, make_any_adapter_impl(setup_adapter{st})); + elm->set_done_callback([&elem_ref = *elm, &st] { + on_setup_done(elem_ref, st); + }); + st.mpx.add(elm); + } + + // Run the tasks + BOOST_REDIS_YIELD(resume_point_, 5, run_action_type::parallel_group) + + // Store any error yielded by the tasks for later + stored_ec_ = ec; + + // We've lost connection or otherwise been cancelled. + // Remove from the multiplexer the required requests. + st.mpx.cancel_on_conn_lost(); + + // The receive operation must be cancelled because channel + // subscription does not survive a reconnection but requires + // re-subscription. + BOOST_REDIS_YIELD(resume_point_, 6, run_action_type::cancel_receive) + + // Restore the error + ec = stored_ec_; + // Check for cancellations if (is_terminal_cancel(cancel_state)) { log_debug(st.logger, "Run: cancelled (2)"); return system::error_code(asio::error::operation_aborted); } +sleep_and_reconnect: + // If we are not going to try again, we're done if (st.cfg.reconnect_wait_interval.count() == 0) { return ec; } // Wait for the reconnection interval - BOOST_REDIS_YIELD(resume_point_, 5, run_action_type::wait_for_reconnection) + BOOST_REDIS_YIELD(resume_point_, 7, run_action_type::wait_for_reconnection) // Check for cancellations if (is_terminal_cancel(cancel_state)) { @@ -174,4 +216,14 @@ run_action run_fsm::resume( return system::error_code(); } +connect_params make_run_connect_params(const connection_state& st) +{ + return { + get_server_address(st), + st.cfg.resolve_timeout, + st.cfg.connect_timeout, + st.cfg.ssl_handshake_timeout, + }; +} + } // namespace boost::redis::detail diff --git a/include/boost/redis/impl/sentinel_resolve_fsm.ipp b/include/boost/redis/impl/sentinel_resolve_fsm.ipp new file mode 100644 index 00000000..96cf28f9 --- /dev/null +++ b/include/boost/redis/impl/sentinel_resolve_fsm.ipp @@ -0,0 +1,182 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_REDIS_SENTINEL_RESOLVE_FSM_IPP +#define BOOST_REDIS_SENTINEL_RESOLVE_FSM_IPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace boost::redis::detail { + +// Logs an error at info level, and also stores it in the state, +// so it can be logged at error level if all Sentinels fail. +template +void log_sentinel_error(connection_state& st, std::size_t current_idx, const Args&... args) +{ + st.diagnostic += "\n "; + std::size_t size_before = st.diagnostic.size(); + format_log_args(st.diagnostic, "Sentinel at ", st.sentinels[current_idx], ": ", args...); + log_info(st.logger, std::string_view{st.diagnostic}.substr(size_before)); +} + +sentinel_action sentinel_resolve_fsm::resume( + connection_state& st, + system::error_code ec, + asio::cancellation_type_t cancel_state) +{ + switch (resume_point_) { + BOOST_REDIS_CORO_INITIAL + + st.diagnostic.clear(); + + log_info( + st.logger, + "Trying to resolve the address of ", + st.cfg.sentinel.server_role == role::master ? "master" : "a replica of master", + " '", + st.cfg.sentinel.master_name, + "' using Sentinel"); + + // Try all Sentinels in order. Upon any errors, save the diagnostic and try with the next one. + // If none of them are available, print an error diagnostic and fail. + for (idx_ = 0u; idx_ < st.sentinels.size(); ++idx_) { + log_debug(st.logger, "Trying to contact Sentinel at ", st.sentinels[idx_]); + + // Try to connect + BOOST_REDIS_YIELD(resume_point_, 1, st.sentinels[idx_]) + + // Check for cancellations + if (is_terminal_cancel(cancel_state)) { + log_debug(st.logger, "Sentinel resolve: cancelled (1)"); + return system::error_code(asio::error::operation_aborted); + } + + // Check for errors + if (ec) { + log_sentinel_error(st, idx_, "connection establishment error: ", ec); + continue; + } + + // Execute the Sentinel request + log_debug(st.logger, "Executing Sentinel request at ", st.sentinels[idx_]); + st.sentinel_resp_nodes.clear(); + BOOST_REDIS_YIELD(resume_point_, 2, sentinel_action::request()) + + // Check for cancellations + if (is_terminal_cancel(cancel_state)) { + log_debug(st.logger, "Sentinel resolve: cancelled (2)"); + return system::error_code(asio::error::operation_aborted); + } + + // Check for errors + if (ec) { + log_sentinel_error(st, idx_, "error while executing request: ", ec); + continue; + } + + // Parse the response + sentinel_response resp; + ec = parse_sentinel_response(st.sentinel_resp_nodes, st.cfg.sentinel.server_role, resp); + + if (ec) { + if (ec == error::resp3_simple_error || ec == error::resp3_blob_error) { + log_sentinel_error(st, idx_, "responded with an error: ", resp.diagnostic); + } else if (ec == error::resp3_null) { + log_sentinel_error(st, idx_, "doesn't know about the configured master"); + } else { + log_sentinel_error( + st, + idx_, + "error parsing response (maybe forgot to upgrade to RESP3?): ", + ec); + } + + continue; + } + + // When asking for replicas, we might get no replicas + if (st.cfg.sentinel.server_role == role::replica && resp.replicas.empty()) { + log_sentinel_error(st, idx_, "the configured master has no replicas"); + continue; + } + + // Store the resulting address in a well-known place + if (st.cfg.sentinel.server_role == role::master) { + st.cfg.addr = resp.master_addr; + } else { + // Choose a random replica + std::uniform_int_distribution dist{0u, resp.replicas.size() - 1u}; + const auto idx = dist(st.eng.get()); + st.cfg.addr = resp.replicas[idx]; + } + + // Sentinel knows about this master. Log and update our config + log_info( + st.logger, + "Sentinel at ", + st.sentinels[idx_], + " resolved the server address to ", + st.cfg.addr); + + update_sentinel_list(st.sentinels, idx_, resp.sentinels, st.cfg.sentinel.addresses); + + st.sentinel_resp_nodes.clear(); // reduce memory consumption + return system::error_code(); + } + + // No Sentinel resolved our address + log_err( + st.logger, + "Failed to resolve the address of ", + st.cfg.sentinel.server_role == role::master ? "master" : "a replica of master", + " '", + st.cfg.sentinel.master_name, + "'. Tried the following Sentinels:", + st.diagnostic); + return {error::sentinel_resolve_failed}; + } + + // We should never get here + BOOST_ASSERT(false); + return system::error_code(); +} + +connect_params make_sentinel_connect_params(const config& cfg, const address& addr) +{ + return { + any_address_view{addr, cfg.sentinel.use_ssl}, + cfg.sentinel.resolve_timeout, + cfg.sentinel.connect_timeout, + cfg.sentinel.ssl_handshake_timeout, + }; +} + +any_adapter make_sentinel_adapter(connection_state& st) +{ + return any_adapter(st.sentinel_resp_nodes); +} + +} // namespace boost::redis::detail + +#endif diff --git a/include/boost/redis/impl/sentinel_utils.hpp b/include/boost/redis/impl/sentinel_utils.hpp new file mode 100644 index 00000000..b3df430b --- /dev/null +++ b/include/boost/redis/impl/sentinel_utils.hpp @@ -0,0 +1,277 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_REDIS_SENTINEL_UTILS_HPP +#define BOOST_REDIS_SENTINEL_UTILS_HPP + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace boost::redis::detail { + +// Returns true if Sentinel should be used +inline bool use_sentinel(const config& cfg) { return !cfg.sentinel.addresses.empty(); } + +// Composes the request to send to Sentinel modifying cfg.sentinel.setup +inline void compose_sentinel_request(config& cfg) +{ + // These commands should go after the user-supplied setup, as this might involve authentication. + // We ask for the master even when connecting to replicas to correctly detect when the master doesn't exist + cfg.sentinel.setup.push("SENTINEL", "GET-MASTER-ADDR-BY-NAME", cfg.sentinel.master_name); + if (cfg.sentinel.server_role == role::replica) + cfg.sentinel.setup.push("SENTINEL", "REPLICAS", cfg.sentinel.master_name); + cfg.sentinel.setup.push("SENTINEL", "SENTINELS", cfg.sentinel.master_name); + + // Note that we don't care about request flags because this is a one-time request +} + +// Parses a list of replicas or sentinels +inline system::error_code parse_server_list( + const resp3::node*& first, + const resp3::node* last, + std::vector
& out) +{ + const auto* it = first; + ignore_unused(last); + + // The root node must be an array + BOOST_ASSERT(it != last); + BOOST_ASSERT(it->depth == 0u); + if (it->data_type != resp3::type::array) + return {error::expects_resp3_array}; + const std::size_t num_servers = it->aggregate_size; + ++it; + + // Each element in the array represents a server + out.resize(num_servers); + for (std::size_t i = 0u; i < num_servers; ++i) { + // A server is a map (resp3) or array (resp2, currently unsupported) + BOOST_ASSERT(it != last); + BOOST_ASSERT(it->depth == 1u); + if (it->data_type != resp3::type::map) + return {error::expects_resp3_map}; + const std::size_t num_key_values = it->aggregate_size; + ++it; + + // The server object is composed by a set of key/value pairs. + // Skip everything except for the ones we care for. + bool ip_seen = false, port_seen = false; + for (std::size_t j = 0; j < num_key_values; ++j) { + // Key. It should be a string + BOOST_ASSERT(it != last); + BOOST_ASSERT(it->depth == 2u); + if (it->data_type != resp3::type::blob_string) + return {error::expects_resp3_string}; + const std::string_view key = it->value; + ++it; + + // Value. All values seem to be strings, too. + BOOST_ASSERT(it != last); + BOOST_ASSERT(it->depth == 2u); + if (it->data_type != resp3::type::blob_string) + return {error::expects_resp3_string}; + + // Record it + if (key == "ip") { + ip_seen = true; + out[i].host = it->value; + } else if (key == "port") { + port_seen = true; + out[i].port = it->value; + } + + ++it; + } + + // Check that the response actually contained the fields we wanted + if (!ip_seen || !port_seen) + return {error::empty_field}; + } + + // Done + first = it; + return system::error_code(); +} + +// The output type of parse_sentinel_response +struct sentinel_response { + std::string diagnostic; // In case the server returned an error + address master_addr; // Always populated + std::vector
replicas; // Populated only when connecting to replicas + std::vector
sentinels; +}; + +// Parses an array of nodes into a sentinel_response. +// The request originating this response should be: +// +// SENTINEL GET-MASTER-ADDR-BY-NAME +// SENTINEL REPLICAS (only if server_role is replica) +// SENTINEL SENTINELS +// SENTINEL SENTINELS and SENTINEL REPLICAS error when the master name is unknown. Error nodes +// should be allowed in the node array. +// This means that we can't use generic_response, since its adapter errors on error nodes. +// SENTINEL GET-MASTER-ADDR-BY-NAME is sent even when connecting to replicas +// for better diagnostics when the master name is unknown. +// Preconditions: +// * There are at least 2 (master)/3 (replica) root nodes. +// * The node array originates from parsing a valid RESP3 message. +// E.g. we won't check that the first node has depth 0. +inline system::error_code parse_sentinel_response( + span nodes, + role server_role, + sentinel_response& out) +{ + auto check_errors = [&out](const resp3::node& nd) { + switch (nd.data_type) { + case resp3::type::simple_error: + out.diagnostic = nd.value; + return system::error_code(error::resp3_simple_error); + case resp3::type::blob_error: + out.diagnostic = nd.value; + return system::error_code(error::resp3_blob_error); + default: return system::error_code(); + } + }; + + // Clear the output + out.diagnostic.clear(); + out.sentinels.clear(); + out.replicas.clear(); + + // Find the first root node of interest. It's the 2nd or 3rd, starting with the end + auto find_first = [nodes, server_role] { + const std::size_t expected_roots = server_role == role::master ? 2u : 3u; + std::size_t roots_seen = 0u; + for (auto it = nodes.rbegin();; ++it) { + BOOST_ASSERT(it != nodes.rend()); + if (it->depth == 0u && ++roots_seen == expected_roots) + return &*it; + } + }; + const resp3::node* lib_first = find_first(); + + // Iterators + const resp3::node* it = nodes.begin(); + const resp3::node* last = nodes.end(); + ignore_unused(last); + + // Go through all the responses to user-supplied requests checking for errors + for (; it != lib_first; ++it) { + if (auto ec = check_errors(*it)) + return ec; + } + + // SENTINEL GET-MASTER-ADDR-BY-NAME + + // Check for errors + if (auto ec = check_errors(*it)) + return ec; + + // If the root node is NULL, Sentinel doesn't know about this master. + // We use resp3_null to signal this fact. This doesn't reach the end user. + if (it->data_type == resp3::type::null) { + return {error::resp3_null}; + } + + // If the root node is an array, an IP and port follow + if (it->data_type != resp3::type::array) + return {error::expects_resp3_array}; + if (it->aggregate_size != 2u) + return {error::incompatible_size}; + ++it; + + // IP + BOOST_ASSERT(it != last); + BOOST_ASSERT(it->depth == 1u); + if (it->data_type != resp3::type::blob_string) + return {error::expects_resp3_string}; + out.master_addr.host = it->value; + ++it; + + // Port + BOOST_ASSERT(it != last); + BOOST_ASSERT(it->depth == 1u); + if (it->data_type != resp3::type::blob_string) + return {error::expects_resp3_string}; + out.master_addr.port = it->value; + ++it; + + if (server_role == role::replica) { + // SENTINEL REPLICAS + + // This request fails if Sentinel doesn't know about this master. + // However, that's not the case if we got here. + // Check for other errors. + if (auto ec = check_errors(*it)) + return ec; + + // Actual parsing + if (auto ec = parse_server_list(it, last, out.replicas)) + return ec; + } + + // SENTINEL SENTINELS + + // This request fails if Sentinel doesn't know about this master. + // However, that's not the case if we got here. + // Check for other errors. + if (auto ec = check_errors(*it)) + return ec; + + // Actual parsing + if (auto ec = parse_server_list(it, last, out.sentinels)) + return ec; + + // Done + return system::error_code(); +} + +// Updates the internal Sentinel list. +// to should never be empty +inline void update_sentinel_list( + std::vector
& to, + std::size_t current_index, // the one to maintain and place first + span gossip_sentinels, // the ones that SENTINEL SENTINELS returned + span bootstrap_sentinels // the ones the user supplied +) +{ + BOOST_ASSERT(!to.empty()); + + // Remove everything, except the Sentinel that succeeded + if (current_index != 0u) + std::swap(to.front(), to[current_index]); + to.resize(1u); + + // Add one group. These Sentinels are always unique and don't include the one we're currently connected to. + to.insert(to.end(), gossip_sentinels.begin(), gossip_sentinels.end()); + + // Insert any user-supplied sentinels, if not already present. + // This is O(n^2), but is okay because n will be small. + // The list can't be sorted, anyway + for (const auto& sentinel : bootstrap_sentinels) { + if (std::find(to.begin(), to.end(), sentinel) == to.end()) + to.push_back(sentinel); + } +} + +} // namespace boost::redis::detail + +#endif diff --git a/include/boost/redis/impl/setup_request_utils.hpp b/include/boost/redis/impl/setup_request_utils.hpp index 97053f2f..c220d98e 100644 --- a/include/boost/redis/impl/setup_request_utils.hpp +++ b/include/boost/redis/impl/setup_request_utils.hpp @@ -8,19 +8,27 @@ #define BOOST_REDIS_SETUP_REQUEST_UTILS_HPP #include +#include +#include +#include // use_sentinel #include +#include +#include #include +#include + namespace boost::redis::detail { // Modifies config::setup to make a request suitable to be sent // to the server using async_exec inline void compose_setup_request(config& cfg) { + auto& req = cfg.setup; + if (!cfg.use_setup) { // We're not using the setup request as-is, but should compose one based on // the values passed by the user - auto& req = cfg.setup; req.clear(); // Which parts of the command should we send? @@ -46,14 +54,71 @@ inline void compose_setup_request(config& cfg) req.push("SELECT", cfg.database_index.value()); } + // When using Sentinel, we should add a role check. + // This must happen after the other commands, as it requires authentication. + if (use_sentinel(cfg)) + req.push("ROLE"); + // In any case, the setup request should have the priority // flag set so it's executed before any other request. // The setup request should never be retried. - request_access::set_priority(cfg.setup, true); - cfg.setup.get_config().cancel_if_unresponded = true; - cfg.setup.get_config().cancel_on_connection_lost = true; + request_access::set_priority(req, true); + req.get_config().cancel_if_unresponded = true; + req.get_config().cancel_on_connection_lost = true; } +class setup_adapter { + connection_state* st_; + std::size_t response_idx_{0u}; + bool role_seen_{false}; + + system::error_code on_node_impl(const resp3::node_view& nd) + { + // An error node is always an error + switch (nd.data_type) { + case resp3::type::simple_error: + case resp3::type::blob_error: st_->diagnostic = nd.value; return error::resp3_hello; + default: ; + } + + // When using Sentinel, we add a ROLE command at the end. + // We need to ensure that this instance is a master. + if (use_sentinel(st_->cfg) && response_idx_ == st_->cfg.setup.get_expected_responses() - 1u) { + // ROLE's response should be an array of at least 1 element + if (nd.depth == 0u) { + if (nd.data_type != resp3::type::array) + return error::invalid_data_type; + if (nd.aggregate_size == 0u) + return error::incompatible_size; + } + + // The first node should be 'master' if we're connecting to a primary, + // 'slave' if we're connecting to a replica + if (nd.depth == 1u && !role_seen_) { + role_seen_ = true; + if (nd.data_type != resp3::type::blob_string) + return error::invalid_data_type; + + const char* expected_role = st_->cfg.sentinel.server_role == role::master ? "master" + : "slave"; + if (nd.value != expected_role) + return error::role_check_failed; + } + } + + return system::error_code(); + } + +public: + explicit setup_adapter(connection_state& st) noexcept + : st_(&st) + { } + + void on_init() { } + void on_done() { ++response_idx_; } + void on_node(const resp3::node_view& node, system::error_code& ec) { ec = on_node_impl(node); } +}; + } // namespace boost::redis::detail #endif // BOOST_REDIS_RUNNER_HPP diff --git a/include/boost/redis/src.hpp b/include/boost/redis/src.hpp index 06c2dcd3..eb48981b 100644 --- a/include/boost/redis/src.hpp +++ b/include/boost/redis/src.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4a9bf03c..58ad78a4 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -37,15 +37,20 @@ make_test(test_low_level) make_test(test_request) make_test(test_low_level_sync_sans_io) make_test(test_any_adapter) -make_test(test_exec_fsm) make_test(test_log_to_file) make_test(test_conn_logging) +make_test(test_exec_fsm) +make_test(test_exec_one_fsm) make_test(test_writer_fsm) make_test(test_reader_fsm) make_test(test_connect_fsm) +make_test(test_sentinel_resolve_fsm) make_test(test_run_fsm) make_test(test_setup_request_utils) +make_test(test_setup_adapter) make_test(test_multiplexer) +make_test(test_parse_sentinel_response) +make_test(test_update_sentinel_list) # Tests that require a real Redis server make_test(test_conn_quit) @@ -68,6 +73,7 @@ make_test(test_conversions) make_test(test_conn_tls) make_test(test_unix_sockets) make_test(test_conn_cancel_after) +make_test(test_conn_sentinel) # Coverage set( diff --git a/test/Jamfile b/test/Jamfile index 8e607279..4ac93900 100644 --- a/test/Jamfile +++ b/test/Jamfile @@ -54,15 +54,20 @@ local tests = test_request test_low_level_sync_sans_io test_any_adapter - test_exec_fsm test_log_to_file test_conn_logging + test_exec_fsm + test_exec_one_fsm test_writer_fsm test_reader_fsm + test_sentinel_resolve_fsm test_run_fsm test_connect_fsm test_setup_request_utils + test_setup_adapter test_multiplexer + test_parse_sentinel_response + test_update_sentinel_list ; # Build and run the tests diff --git a/test/common.cpp b/test/common.cpp index f0533bf3..cb7e1891 100644 --- a/test/common.cpp +++ b/test/common.cpp @@ -1,11 +1,16 @@ +#include +#include + #include #include +#include #include "common.hpp" #include #include #include +#include namespace net = boost::asio; @@ -71,7 +76,6 @@ void run_coroutine_test(net::awaitable op, std::chrono::steady_clock::dura // Finds a value in the output of the CLIENT INFO command // format: key1=value1 key2=value2 -// TODO: duplicated std::string_view find_client_info(std::string_view client_info, std::string_view key) { std::string prefix{key}; @@ -84,3 +88,45 @@ std::string_view find_client_info(std::string_view client_info, std::string_view auto const pos_end = client_info.find(' ', pos_begin); return client_info.substr(pos_begin, pos_end - pos_begin); } + +void create_user(std::string_view port, std::string_view username, std::string_view password) +{ + // Setup + net::io_context ioc; + boost::redis::connection conn{ioc}; + + boost::redis::config cfg; + cfg.addr.port = port; + + // Enable the user and grant them permissions on everything + boost::redis::request req; + req.push("ACL", "SETUSER", username, "on", ">" + std::string(password), "~*", "&*", "+@all"); + + bool run_finished = false, exec_finished = false; + + conn.async_run(cfg, [&](boost::system::error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, net::error::operation_aborted); + }); + + conn.async_exec(req, boost::redis::ignore, [&](boost::system::error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, boost::system::error_code()); + conn.cancel(); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(run_finished); + BOOST_TEST(exec_finished); +} + +boost::redis::logger make_string_logger(std::string& to) +{ + return { + boost::redis::logger::level::info, + [&to](boost::redis::logger::level, std::string_view msg) { + to += msg; + to += '\n'; + }}; +} diff --git a/test/common.hpp b/test/common.hpp index af190580..569a7bcd 100644 --- a/test/common.hpp +++ b/test/common.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -11,6 +12,7 @@ #include #include +#include #include // The timeout for tests involving communication to a real server. @@ -40,3 +42,8 @@ void run( // Finds a value in the output of the CLIENT INFO command // format: key1=value1 key2=value2 std::string_view find_client_info(std::string_view client_info, std::string_view key); + +// Connects to the Redis server at the given port and creates a user +void create_user(std::string_view port, std::string_view username, std::string_view password); + +boost::redis::logger make_string_logger(std::string& to); diff --git a/test/print_node.hpp b/test/print_node.hpp new file mode 100644 index 00000000..9c762cb3 --- /dev/null +++ b/test/print_node.hpp @@ -0,0 +1,28 @@ + +/* Copyright (c) 2018-2025 Marcelo Zimbres Silva (mzimbres@gmail.com) + * + * Distributed under the Boost Software License, Version 1.0. (See + * accompanying file LICENSE.txt) + */ + +#ifndef BOOST_REDIS_TEST_PRINT_NODE_HPP +#define BOOST_REDIS_TEST_PRINT_NODE_HPP + +#include +#include + +#include + +namespace boost::redis::resp3 { + +template +std::ostream& operator<<(std::ostream& os, basic_node const& nd) +{ + return os << "node{ .data_type=" << to_string(nd.data_type) + << ", .aggregate_size=" << nd.aggregate_size << ", .depth=" << nd.depth + << ", .value=" << nd.value << "}"; +} + +} // namespace boost::redis::resp3 + +#endif // BOOST_REDIS_TEST_SANSIO_UTILS_HPP diff --git a/test/sansio_utils.cpp b/test/sansio_utils.cpp index 294763e5..b3e446ac 100644 --- a/test/sansio_utils.cpp +++ b/test/sansio_utils.cpp @@ -4,8 +4,10 @@ * accompanying file LICENSE.txt) */ +#include #include +#include #include #include @@ -72,4 +74,24 @@ logger log_fixture::make_logger() }); } +std::vector nodes_from_resp3( + const std::vector& msgs, + source_location loc) +{ + std::vector nodes; + any_adapter adapter{nodes}; + + for (std::string_view resp : msgs) { + resp3::parser p; + system::error_code ec; + bool done = resp3::parse(p, resp, adapter, ec); + if (!BOOST_TEST(done)) + std::cerr << "Called from " << loc << std::endl; + if (!BOOST_TEST_EQ(ec, system::error_code())) + std::cerr << "Called from " << loc << std::endl; + } + + return nodes; +} + } // namespace boost::redis::detail diff --git a/test/sansio_utils.hpp b/test/sansio_utils.hpp index 47a49ff3..cd4d7ebc 100644 --- a/test/sansio_utils.hpp +++ b/test/sansio_utils.hpp @@ -8,6 +8,7 @@ #define BOOST_REDIS_TEST_SANSIO_UTILS_HPP #include +#include #include @@ -15,6 +16,7 @@ #include #include #include +#include namespace boost::redis::detail { @@ -50,6 +52,13 @@ constexpr auto to_milliseconds(std::chrono::steady_clock::duration d) return std::chrono::duration_cast(d).count(); } +// Creates a vector of nodes from a set of RESP3 messages. +// Using the raw RESP values ensures that the correct +// node tree is built, which is not always obvious +std::vector nodes_from_resp3( + const std::vector& msgs, + source_location loc = BOOST_CURRENT_LOCATION); + } // namespace boost::redis::detail #endif // BOOST_REDIS_TEST_SANSIO_UTILS_HPP diff --git a/test/test_conn_sentinel.cpp b/test/test_conn_sentinel.cpp new file mode 100644 index 00000000..7caec74e --- /dev/null +++ b/test/test_conn_sentinel.cpp @@ -0,0 +1,491 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "common.hpp" +#include "print_node.hpp" + +#include + +namespace net = boost::asio; +using namespace boost::redis; +using namespace std::chrono_literals; +using boost::system::error_code; + +namespace { + +// We can execute requests normally when using Sentinel run +void test_exec() +{ + // Setup + net::io_context ioc; + connection conn{ioc}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"}, + {"localhost", "26380"}, + {"localhost", "26381"}, + }; + cfg.sentinel.master_name = "mymaster"; + + // Verify that we're connected to the master + request req; + req.push("ROLE"); + + generic_response resp; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, error_code()); + + // ROLE outputs an array, 1st element should be 'master' + BOOST_TEST(resp.has_value()); + BOOST_TEST_GE(resp.value().size(), 2u); + BOOST_TEST_EQ(resp.value().at(1u).value, "master"); + + conn.cancel(); + }); + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, net::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); +} + +// We can use receive normally when using Sentinel run +void test_receive() +{ + // Setup + net::io_context ioc; + connection conn{ioc}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"}, + {"localhost", "26380"}, + {"localhost", "26381"}, + }; + cfg.sentinel.master_name = "mymaster"; + + resp3::tree resp; + conn.set_receive_response(resp); + + // Subscribe to a channel. This produces a push message on itself + request req; + req.push("SUBSCRIBE", "sentinel_channel"); + + bool exec_finished = false, receive_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, error_code()); + }); + + conn.async_receive2([&](error_code ec2) { + receive_finished = true; + BOOST_TEST_EQ(ec2, error_code()); + conn.cancel(); + }); + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, net::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(exec_finished); + BOOST_TEST(receive_finished); + BOOST_TEST(run_finished); + + // We subscribed to channel 'sentinel_channel', and have 1 active subscription + const resp3::node expected[] = { + {resp3::type::push, 3u, 0u, "" }, + {resp3::type::blob_string, 1u, 1u, "subscribe" }, + {resp3::type::blob_string, 1u, 1u, "sentinel_channel"}, + {resp3::type::number, 1u, 1u, "1" }, + }; + + BOOST_TEST_ALL_EQ(resp.begin(), resp.end(), std::begin(expected), std::end(expected)); +} + +// If connectivity to the Redis master fails, we can reconnect +void test_reconnect() +{ + // Setup + net::io_context ioc; + connection conn{ioc}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"}, + {"localhost", "26380"}, + {"localhost", "26381"}, + }; + cfg.sentinel.master_name = "mymaster"; + + // Will cause the connection to fail + request req_quit; + req_quit.push("QUIT"); + + // Will succeed if the reconnection succeeds + request req_ping; + req_ping.push("PING", "sentinel_reconnect"); + req_ping.get_config().cancel_if_unresponded = false; + + bool quit_finished = false, ping_finished = false, run_finished = false; + + conn.async_exec(req_quit, ignore, [&](error_code ec1, std::size_t) { + quit_finished = true; + BOOST_TEST_EQ(ec1, error_code()); + conn.async_exec(req_ping, ignore, [&](error_code ec2, std::size_t) { + ping_finished = true; + BOOST_TEST_EQ(ec2, error_code()); + conn.cancel(); + }); + }); + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, net::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(quit_finished); + BOOST_TEST(ping_finished); + BOOST_TEST(run_finished); +} + +// If a Sentinel is not reachable, we try the next one +void test_sentinel_not_reachable() +{ + // Setup + net::io_context ioc; + connection conn{ioc}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "45678"}, // invalid + {"localhost", "26381"}, + }; + cfg.sentinel.master_name = "mymaster"; + + // Verify that we're connected to the master, listening at port 6380 + request req; + req.push("PING", "test_sentinel_not_reachable"); + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, ignore, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, error_code()); + conn.cancel(); + }); + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, net::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); +} + +// Both Sentinels and masters may be protected with authorization +void test_auth() +{ + // Setup + net::io_context ioc; + connection conn{ioc}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"}, + }; + cfg.sentinel.master_name = "mymaster"; + cfg.sentinel.setup.push("HELLO", 3, "AUTH", "sentinel_user", "sentinel_pass"); + + cfg.use_setup = true; + cfg.setup.clear(); + cfg.setup.push("HELLO", 3, "AUTH", "redis_user", "redis_pass"); + + // Verify that we're authenticated correctly + request req; + req.push("ACL", "WHOAMI"); + + response resp; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, error_code()); + BOOST_TEST(std::get<0>(resp).has_value()); + BOOST_TEST_EQ(std::get<0>(resp).value(), "redis_user"); + conn.cancel(); + }); + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, net::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); +} + +// TLS might be used with Sentinels. In our setup, nodes don't use TLS, +// but this setting is independent from Sentinel. +void test_tls() +{ + // Setup + net::io_context ioc; + net::ssl::context ssl_ctx{net::ssl::context::tlsv13_client}; + + // The custom server uses a certificate signed by a CA + // that is not trusted by default - skip verification. + ssl_ctx.set_verify_mode(net::ssl::verify_none); + + connection conn{ioc, std::move(ssl_ctx)}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "36379"}, + {"localhost", "36380"}, + {"localhost", "36381"}, + }; + cfg.sentinel.master_name = "mymaster"; + cfg.sentinel.use_ssl = true; + + request req; + req.push("PING", "test_sentinel_tls"); + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, ignore, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST(ec == error_code()); + conn.cancel(); + }); + + conn.async_run(cfg, {}, [&](error_code ec) { + run_finished = true; + BOOST_TEST(ec == net::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); +} + +// We can also connect to replicas +void test_replica() +{ + // Setup + net::io_context ioc; + connection conn{ioc}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"}, + {"localhost", "26380"}, + {"localhost", "26381"}, + }; + cfg.sentinel.master_name = "mymaster"; + cfg.sentinel.server_role = role::replica; + + // Verify that we're connected to a replica + request req; + req.push("ROLE"); + + generic_response resp; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, error_code()); + + // ROLE outputs an array, 1st element should be 'slave' + BOOST_TEST(resp.has_value()); + BOOST_TEST_GE(resp.value().size(), 2u); + BOOST_TEST_EQ(resp.value().at(1u).value, "slave"); + + conn.cancel(); + }); + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, net::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); +} + +// If no Sentinel is reachable, an error is issued. +// This tests disabling reconnection with Sentinel, too. +void test_error_no_sentinel_reachable() +{ + // Setup + std::string logs; + net::io_context ioc; + connection conn{ioc, make_string_logger(logs)}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "43210"}, + {"localhost", "43211"}, + }; + cfg.sentinel.master_name = "mymaster"; + cfg.reconnect_wait_interval = 0s; // disable reconnection so we can verify the error + + bool run_finished = false; + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, error::sentinel_resolve_failed); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(run_finished); + + if ( + !BOOST_TEST_NE( + logs.find("Sentinel at localhost:43210: connection establishment error"), + std::string::npos) || + !BOOST_TEST_NE( + logs.find("Sentinel at localhost:43211: connection establishment error"), + std::string::npos)) { + std::cerr << "Log was:\n" << logs << std::endl; + } +} + +// If Sentinel doesn't know about the configured master, +// the appropriate error is returned +void test_error_unknown_master() +{ + // Setup + std::string logs; + net::io_context ioc; + connection conn{ioc, make_string_logger(logs)}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26380"}, + }; + cfg.sentinel.master_name = "unknown_master"; + cfg.reconnect_wait_interval = 0s; // disable reconnection so we can verify the error + + bool run_finished = false; + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, error::sentinel_resolve_failed); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(run_finished); + + if (!BOOST_TEST_NE( + logs.find("Sentinel at localhost:26380: doesn't know about the configured master"), + std::string::npos)) { + std::cerr << "Log was:\n" << logs << std::endl; + } +} + +// The same applies when connecting to replicas, too +void test_error_unknown_master_replica() +{ + // Setup + std::string logs; + net::io_context ioc; + connection conn{ioc, make_string_logger(logs)}; + + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26380"}, + }; + cfg.sentinel.master_name = "unknown_master"; + cfg.reconnect_wait_interval = 0s; // disable reconnection so we can verify the error + cfg.sentinel.server_role = role::replica; + + bool run_finished = false; + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, error::sentinel_resolve_failed); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(run_finished); + + if (!BOOST_TEST_NE( + logs.find("Sentinel at localhost:26380: doesn't know about the configured master"), + std::string::npos)) { + std::cerr << "Log was:\n" << logs << std::endl; + } +} + +} // namespace + +int main() +{ + // Create the required users in the master, replicas and sentinels + create_user("6379", "redis_user", "redis_pass"); + create_user("6380", "redis_user", "redis_pass"); + create_user("6381", "redis_user", "redis_pass"); + create_user("26379", "sentinel_user", "sentinel_pass"); + create_user("26380", "sentinel_user", "sentinel_pass"); + create_user("26381", "sentinel_user", "sentinel_pass"); + + // Actual tests + test_exec(); + test_receive(); + test_reconnect(); + test_sentinel_not_reachable(); + test_auth(); + test_tls(); + test_replica(); + + test_error_no_sentinel_reachable(); + test_error_unknown_master(); + test_error_unknown_master_replica(); + + return boost::report_errors(); +} diff --git a/test/test_conn_setup.cpp b/test/test_conn_setup.cpp index 52d7b017..9997cb6b 100644 --- a/test/test_conn_setup.cpp +++ b/test/test_conn_setup.cpp @@ -18,7 +18,6 @@ #include "common.hpp" #include -#include #include #include @@ -29,37 +28,6 @@ using boost::system::error_code; namespace { -// Creates a user with a known password. Harmless if the user already exists -void setup_password() -{ - // Setup - asio::io_context ioc; - redis::connection conn{ioc}; - - // Enable the user and grant them permissions on everything - redis::request req; - req.push("ACL", "SETUSER", "myuser", "on", ">mypass", "~*", "&*", "+@all"); - redis::generic_response resp; - - bool run_finished = false, exec_finished = false; - conn.async_run(make_test_config(), [&](error_code ec) { - run_finished = true; - BOOST_TEST_EQ(ec, asio::error::operation_aborted); - }); - - conn.async_exec(req, resp, [&](error_code ec, std::size_t) { - exec_finished = true; - BOOST_TEST_EQ(ec, error_code()); - conn.cancel(); - }); - - ioc.run_for(test_timeout); - - BOOST_TEST(run_finished); - BOOST_TEST(exec_finished); - BOOST_TEST(resp.has_value()); -} - void test_auth_success() { // Setup @@ -96,17 +64,13 @@ void test_auth_success() BOOST_TEST_EQ(std::get<0>(resp).value(), "myuser"); } +// Verify that we log appropriately (see https://github.com/boostorg/redis/issues/297) void test_auth_failure() { - // Verify that we log appropriately (see https://github.com/boostorg/redis/issues/297) - std::ostringstream oss; - redis::logger lgr(redis::logger::level::info, [&](redis::logger::level, std::string_view msg) { - oss << msg << '\n'; - }); - // Setup + std::string logs; asio::io_context ioc; - redis::connection conn{ioc, std::move(lgr)}; + redis::connection conn{ioc, make_string_logger(logs)}; // Disable reconnection so the hello error causes the connection to exit auto cfg = make_test_config(); @@ -126,9 +90,8 @@ void test_auth_failure() BOOST_TEST(run_finished); // Check the log - auto log = oss.str(); - if (!BOOST_TEST_NE(log.find("WRONGPASS"), std::string::npos)) { - std::cerr << "Log was: " << log << std::endl; + if (!BOOST_TEST_NE(logs.find("WRONGPASS"), std::string::npos)) { + std::cerr << "Log was: \n" << logs << std::endl; } } @@ -275,17 +238,13 @@ void test_setup_no_hello() BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "db"), "8"); } +// Verify that we log appropriately (see https://github.com/boostorg/redis/issues/297) void test_setup_failure() { - // Verify that we log appropriately (see https://github.com/boostorg/redis/issues/297) - std::ostringstream oss; - redis::logger lgr(redis::logger::level::info, [&](redis::logger::level, std::string_view msg) { - oss << msg << '\n'; - }); - // Setup + std::string logs; asio::io_context ioc; - redis::connection conn{ioc, std::move(lgr)}; + redis::connection conn{ioc, make_string_logger(logs)}; // Disable reconnection so the hello error causes the connection to exit auto cfg = make_test_config(); @@ -306,9 +265,8 @@ void test_setup_failure() BOOST_TEST(run_finished); // Check the log - auto log = oss.str(); - if (!BOOST_TEST_NE(log.find("wrong number of arguments"), std::string::npos)) { - std::cerr << "Log was: " << log << std::endl; + if (!BOOST_TEST_NE(logs.find("wrong number of arguments"), std::string::npos)) { + std::cerr << "Log was:\n" << logs << std::endl; } } @@ -316,7 +274,8 @@ void test_setup_failure() int main() { - setup_password(); + create_user("6379", "myuser", "mypass"); + test_auth_success(); test_auth_failure(); test_database_index(); diff --git a/test/test_conn_tls.cpp b/test/test_conn_tls.cpp index 6832a5e6..43939b40 100644 --- a/test/test_conn_tls.cpp +++ b/test/test_conn_tls.cpp @@ -55,7 +55,7 @@ static config make_tls_config() config cfg; cfg.use_ssl = true; cfg.addr.host = get_server_hostname(); - cfg.addr.port = "6380"; + cfg.addr.port = "16380"; return cfg; } diff --git a/test/test_connect_fsm.cpp b/test/test_connect_fsm.cpp index d3fde091..5c5f9aac 100644 --- a/test/test_connect_fsm.cpp +++ b/test/test_connect_fsm.cpp @@ -6,7 +6,6 @@ // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) // -#include #include #include #include @@ -103,30 +102,15 @@ auto resolver_data = [] { // Reduce duplication struct fixture : detail::log_fixture { - config cfg; buffered_logger lgr{make_logger()}; - connect_fsm fsm{cfg, lgr}; - redis_stream_state st{}; + connect_fsm fsm{lgr}; + redis_stream_state st; - fixture(config&& cfg = {}) - : cfg{std::move(cfg)} + fixture(transport_type type = transport_type::tcp) + : st{type, false} { } }; -config make_ssl_config() -{ - config cfg; - cfg.use_ssl = true; - return cfg; -} - -config make_unix_config() -{ - config cfg; - cfg.unix_socket = "/run/redis.sock"; - return cfg; -} - void test_tcp_success() { // Setup @@ -141,20 +125,21 @@ void test_tcp_success() BOOST_TEST_EQ(act, connect_action_type::done); // The transport type was appropriately set - BOOST_TEST_EQ(fix.st.type, transport_type::tcp); BOOST_TEST_NOT(fix.st.ssl_stream_used); // Check logging fix.check_log({ - {logger::level::info, "Resolve results: 192.168.10.1:1234, 192.168.10.2:1235"}, - {logger::level::info, "Connected to 192.168.10.1:1234" }, + // clang-format off + {logger::level::debug, "Connect: hostname resolution results: 192.168.10.1:1234, 192.168.10.2:1235"}, + {logger::level::debug, "Connect: TCP connect succeeded. Selected endpoint: 192.168.10.1:1234" }, + // clang-format on }); } void test_tcp_tls_success() { // Setup - fixture fix{make_ssl_config()}; + fixture fix{transport_type::tcp_tls}; // Run the algorithm. No SSL stream reset is performed here auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -167,21 +152,22 @@ void test_tcp_tls_success() BOOST_TEST_EQ(act, connect_action_type::done); // The transport type was appropriately set - BOOST_TEST_EQ(fix.st.type, transport_type::tcp_tls); BOOST_TEST(fix.st.ssl_stream_used); // Check logging fix.check_log({ - {logger::level::info, "Resolve results: 192.168.10.1:1234, 192.168.10.2:1235"}, - {logger::level::info, "Connected to 192.168.10.1:1234" }, - {logger::level::info, "Successfully performed SSL handshake" }, + // clang-format off + {logger::level::debug, "Connect: hostname resolution results: 192.168.10.1:1234, 192.168.10.2:1235"}, + {logger::level::debug, "Connect: TCP connect succeeded. Selected endpoint: 192.168.10.1:1234" }, + {logger::level::debug, "Connect: SSL handshake succeeded" }, + // clang-format on }); } void test_tcp_tls_success_reconnect() { // Setup - fixture fix{make_ssl_config()}; + fixture fix{transport_type::tcp_tls}; fix.st.ssl_stream_used = true; // Run the algorithm. The stream is used, so it needs to be reset @@ -197,21 +183,22 @@ void test_tcp_tls_success_reconnect() BOOST_TEST_EQ(act, connect_action_type::done); // The transport type was appropriately set - BOOST_TEST_EQ(fix.st.type, transport_type::tcp_tls); BOOST_TEST(fix.st.ssl_stream_used); // Check logging fix.check_log({ - {logger::level::info, "Resolve results: 192.168.10.1:1234, 192.168.10.2:1235"}, - {logger::level::info, "Connected to 192.168.10.1:1234" }, - {logger::level::info, "Successfully performed SSL handshake" }, + // clang-format off + {logger::level::debug, "Connect: hostname resolution results: 192.168.10.1:1234, 192.168.10.2:1235"}, + {logger::level::debug, "Connect: TCP connect succeeded. Selected endpoint: 192.168.10.1:1234" }, + {logger::level::debug, "Connect: SSL handshake succeeded" }, + // clang-format on }); } void test_unix_success() { // Setup - fixture fix{make_unix_config()}; + fixture fix{transport_type::unix_socket}; // Run the algorithm auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -222,12 +209,11 @@ void test_unix_success() BOOST_TEST_EQ(act, connect_action_type::done); // The transport type was appropriately set - BOOST_TEST_EQ(fix.st.type, transport_type::unix_socket); BOOST_TEST_NOT(fix.st.ssl_stream_used); // Check logging fix.check_log({ - {logger::level::info, "Connected to /run/redis.sock"}, + {logger::level::debug, "Connect: UNIX socket connect succeeded"}, }); } @@ -235,7 +221,7 @@ void test_unix_success() void test_unix_success_close_error() { // Setup - fixture fix{make_unix_config()}; + fixture fix{transport_type::unix_socket}; // Run the algorithm auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -246,12 +232,11 @@ void test_unix_success_close_error() BOOST_TEST_EQ(act, connect_action_type::done); // The transport type was appropriately set - BOOST_TEST_EQ(fix.st.type, transport_type::unix_socket); BOOST_TEST_NOT(fix.st.ssl_stream_used); // Check logging fix.check_log({ - {logger::level::info, "Connected to /run/redis.sock"}, + {logger::level::debug, "Connect: UNIX socket connect succeeded"}, }); } @@ -270,7 +255,7 @@ void test_tcp_resolve_error() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Error resolving the server hostname: Expected field value is empty. [boost.redis:5]"}, + {logger::level::info, "Connect: hostname resolution failed: Expected field value is empty. [boost.redis:5]"}, // clang-format on }); } @@ -293,7 +278,7 @@ void test_tcp_resolve_timeout() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Error resolving the server hostname: Resolve timeout. [boost.redis:17]"}, + {logger::level::info, "Connect: hostname resolution failed: Resolve timeout. [boost.redis:17]"}, // clang-format on }); } @@ -349,8 +334,8 @@ void test_tcp_connect_error() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Resolve results: 192.168.10.1:1234, 192.168.10.2:1235"}, - {logger::level::info, "Failed to connect to the server: Expected field value is empty. [boost.redis:5]"}, + {logger::level::debug, "Connect: hostname resolution results: 192.168.10.1:1234, 192.168.10.2:1235"}, + {logger::level::info, "Connect: TCP connect failed: Expected field value is empty. [boost.redis:5]"}, // clang-format on }); } @@ -375,8 +360,8 @@ void test_tcp_connect_timeout() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Resolve results: 192.168.10.1:1234, 192.168.10.2:1235"}, - {logger::level::info, "Failed to connect to the server: Connect timeout. [boost.redis:18]"}, + {logger::level::debug, "Connect: hostname resolution results: 192.168.10.1:1234, 192.168.10.2:1235"}, + {logger::level::info, "Connect: TCP connect failed: Connect timeout. [boost.redis:18]"}, // clang-format on }); } @@ -423,7 +408,7 @@ void test_tcp_connect_cancel_edge() void test_ssl_handshake_error() { // Setup - fixture fix{make_ssl_config()}; + fixture fix{transport_type::tcp_tls}; // Run the algorithm. No SSL stream reset is performed here auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -441,9 +426,9 @@ void test_ssl_handshake_error() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Resolve results: 192.168.10.1:1234, 192.168.10.2:1235"}, - {logger::level::info, "Connected to 192.168.10.1:1234" }, - {logger::level::info, "Failed to perform SSL handshake: Expected field value is empty. [boost.redis:5]"}, + {logger::level::debug, "Connect: hostname resolution results: 192.168.10.1:1234, 192.168.10.2:1235"}, + {logger::level::debug, "Connect: TCP connect succeeded. Selected endpoint: 192.168.10.1:1234"}, + {logger::level::info, "Connect: SSL handshake failed: Expected field value is empty. [boost.redis:5]"}, // clang-format on }); } @@ -451,7 +436,7 @@ void test_ssl_handshake_error() void test_ssl_handshake_timeout() { // Setup - fixture fix{make_ssl_config()}; + fixture fix{transport_type::tcp_tls}; // Run the algorithm. Timeout = operation_aborted without the cancel type set auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -469,9 +454,9 @@ void test_ssl_handshake_timeout() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Resolve results: 192.168.10.1:1234, 192.168.10.2:1235"}, - {logger::level::info, "Connected to 192.168.10.1:1234" }, - {logger::level::info, "Failed to perform SSL handshake: SSL handshake timeout. [boost.redis:20]"}, + {logger::level::debug, "Connect: hostname resolution results: 192.168.10.1:1234, 192.168.10.2:1235"}, + {logger::level::debug, "Connect: TCP connect succeeded. Selected endpoint: 192.168.10.1:1234"}, + {logger::level::info, "Connect: SSL handshake failed: SSL handshake timeout. [boost.redis:20]"}, // clang-format on }); } @@ -479,7 +464,7 @@ void test_ssl_handshake_timeout() void test_ssl_handshake_cancel() { // Setup - fixture fix{make_ssl_config()}; + fixture fix{transport_type::tcp_tls}; // Run the algorithm. Cancel = operation_aborted with the cancel type set auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -501,7 +486,7 @@ void test_ssl_handshake_cancel() void test_ssl_handshake_cancel_edge() { // Setup - fixture fix{make_ssl_config()}; + fixture fix{transport_type::tcp_tls}; // Run the algorithm. No error, but the cancel state is set auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -524,7 +509,7 @@ void test_ssl_handshake_cancel_edge() void test_unix_connect_error() { // Setup - fixture fix{make_unix_config()}; + fixture fix{transport_type::unix_socket}; // Run the algorithm auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -537,7 +522,7 @@ void test_unix_connect_error() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Failed to connect to the server: Expected field value is empty. [boost.redis:5]"}, + {logger::level::info, "Connect: UNIX socket connect failed: Expected field value is empty. [boost.redis:5]"}, // clang-format on }); } @@ -545,7 +530,7 @@ void test_unix_connect_error() void test_unix_connect_timeout() { // Setup - fixture fix{make_unix_config()}; + fixture fix{transport_type::unix_socket}; // Run the algorithm. Timeout = operation_aborted without a cancel state auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -558,7 +543,7 @@ void test_unix_connect_timeout() // Check logging fix.check_log({ // clang-format off - {logger::level::info, "Failed to connect to the server: Connect timeout. [boost.redis:18]"}, + {logger::level::info, "Connect: UNIX socket connect failed: Connect timeout. [boost.redis:18]"}, // clang-format on }); } @@ -566,7 +551,7 @@ void test_unix_connect_timeout() void test_unix_connect_cancel() { // Setup - fixture fix{make_unix_config()}; + fixture fix{transport_type::unix_socket}; // Run the algorithm. Cancel = operation_aborted with a cancel state auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); @@ -583,7 +568,7 @@ void test_unix_connect_cancel() void test_unix_connect_cancel_edge() { // Setup - fixture fix{make_unix_config()}; + fixture fix{transport_type::unix_socket}; // Run the algorithm. No error, but cancel state is set auto act = fix.fsm.resume(error_code(), fix.st, cancellation_type_t::none); diff --git a/test/test_exec_one_fsm.cpp b/test/test_exec_one_fsm.cpp new file mode 100644 index 00000000..1e5d7c02 --- /dev/null +++ b/test/test_exec_one_fsm.cpp @@ -0,0 +1,365 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "print_node.hpp" + +#include +#include +#include +#include + +using namespace boost::redis; +namespace asio = boost::asio; +using detail::exec_one_fsm; +using detail::exec_one_action; +using detail::exec_one_action_type; +using detail::read_buffer; +using boost::system::error_code; +using boost::asio::cancellation_type_t; +using parse_event = any_adapter::parse_event; +using resp3::type; + +// Operators +static const char* to_string(exec_one_action_type value) +{ + switch (value) { + case exec_one_action_type::done: return "done"; + case exec_one_action_type::write: return "write"; + case exec_one_action_type::read_some: return "read_some"; + default: return ""; + } +} + +namespace boost::redis::detail { + +bool operator==(const exec_one_action& lhs, const exec_one_action& rhs) noexcept +{ + return lhs.type == rhs.type && lhs.ec == rhs.ec; +} + +std::ostream& operator<<(std::ostream& os, const exec_one_action& act) +{ + os << "exec_one_action{ .type=" << to_string(act.type); + if (act.type == exec_one_action_type::done) + os << ", ec=" << act.ec; + return os << " }"; +} + +} // namespace boost::redis::detail + +namespace { + +struct adapter_event { + parse_event type; + resp3::node node{}; + + friend bool operator==(const adapter_event& lhs, const adapter_event& rhs) noexcept + { + return lhs.type == rhs.type && lhs.node == rhs.node; + } + + friend std::ostream& operator<<(std::ostream& os, const adapter_event& value) + { + switch (value.type) { + case parse_event::init: return os << "adapter_event{ .type=init }"; + case parse_event::done: return os << "adapter_event{ .type=done }"; + case parse_event::node: + return os << "adapter_event{ .type=node, .node=" << value.node << " }"; + default: return os << "adapter_event{ .type=unknown }"; + } + } +}; + +any_adapter make_snoop_adapter(std::vector& events) +{ + return any_adapter::impl_t{[&](parse_event ev, resp3::node_view const& nd, error_code&) { + events.push_back({ + ev, + {nd.data_type, nd.aggregate_size, nd.depth, std::string(nd.value)} + }); + }}; +} + +void copy_to(read_buffer& buff, std::string_view data) +{ + auto const buffer = buff.get_prepared(); + BOOST_TEST_GE(buffer.size(), data.size()); + std::copy(data.cbegin(), data.cend(), buffer.begin()); +} + +void test_success() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // FSM should now ask for data + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + // Read the entire response in one go + constexpr std::string_view payload = "$5\r\nhello\r\n*1\r\n+goodbye\r\n"; + copy_to(buff, payload); + act = fsm.resume(buff, error_code(), payload.size(), cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::done); + + // Verify the adapter calls + const adapter_event expected[] = { + {parse_event::init}, + {parse_event::node, {type::blob_string, 1u, 0u, "hello"}}, + {parse_event::done}, + {parse_event::init}, + {parse_event::node, {type::array, 1u, 0u, ""}}, + {parse_event::node, {type::simple_string, 1u, 1u, "goodbye"}}, + {parse_event::done}, + }; + BOOST_TEST_ALL_EQ(events.begin(), events.end(), std::begin(expected), std::end(expected)); +} + +// The request didn't have any expected response (e.g. SUBSCRIBE) +void test_no_expected_response() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 0u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // FSM shouldn't ask for data + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // No adapter calls should be done + BOOST_TEST_EQ(events.size(), 0u); +} + +// The response is scattered in several smaller fragments +void test_short_reads() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // FSM should now ask for data + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + // Read fragments + constexpr std::string_view payload = "$5\r\nhello\r\n*1\r\n+goodbye\r\n"; + copy_to(buff, payload.substr(0, 6u)); + act = fsm.resume(buff, error_code(), 6u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + copy_to(buff, payload.substr(6, 10u)); + act = fsm.resume(buff, error_code(), 10u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + copy_to(buff, payload.substr(16)); + act = fsm.resume(buff, error_code(), payload.substr(16).size(), cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::done); + + // Verify the adapter calls + const adapter_event expected[] = { + {parse_event::init}, + {parse_event::node, {type::blob_string, 1u, 0u, "hello"}}, + {parse_event::done}, + {parse_event::init}, + {parse_event::node, {type::array, 1u, 0u, ""}}, + {parse_event::node, {type::simple_string, 1u, 1u, "goodbye"}}, + {parse_event::done}, + }; + BOOST_TEST_ALL_EQ(events.begin(), events.end(), std::begin(expected), std::end(expected)); +} + +// Errors in write +void test_write_error() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // Write error + act = fsm.resume(buff, asio::error::connection_reset, 10u, cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(asio::error::connection_reset)); +} + +void test_write_cancel() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // Edge case where the operation finished successfully but with the cancellation state set + act = fsm.resume(buff, error_code(), 10u, cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); +} + +// Errors in read +void test_read_error() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // FSM should now ask for data + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + // Read error + act = fsm.resume(buff, asio::error::network_reset, 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(asio::error::network_reset)); +} + +void test_read_cancelled() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // FSM should now ask for data + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + // Edge case where the operation finished successfully but with the cancellation state set + copy_to(buff, "$5\r\n"); + act = fsm.resume(buff, error_code(), 4u, cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); +} + +// Buffer too small +void test_buffer_prepare_error() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + buff.set_config({4096u, 8u}); // max size is 8 bytes + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // When preparing the buffer, we encounter an error + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(error::exceeds_maximum_read_buffer_size)); +} + +// An invalid RESP3 message +void test_parse_error() +{ + // Setup + std::vector events; + exec_one_fsm fsm{make_snoop_adapter(events), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // FSM should now ask for data + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + // The response contains an invalid message + constexpr std::string_view payload = "$bad\r\n"; + copy_to(buff, payload); + act = fsm.resume(buff, error_code(), payload.size(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(error::not_a_number)); +} + +// Adapter signals an error +void test_adapter_error() +{ + // Setup. The adapter will fail in the 2nd node + any_adapter adapter{[](parse_event ev, resp3::node_view const&, error_code& ec) { + if (ev == parse_event::node) + ec = error::empty_field; + }}; + exec_one_fsm fsm{std::move(adapter), 2u}; + read_buffer buff; + + // Write the request + auto act = fsm.resume(buff, error_code(), 0u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::write); + + // FSM should now ask for data + act = fsm.resume(buff, error_code(), 25u, cancellation_type_t::none); + BOOST_TEST_EQ(act, exec_one_action_type::read_some); + + // Read the entire response in one go + constexpr std::string_view payload = "$5\r\nhello\r\n*1\r\n+goodbye\r\n"; + copy_to(buff, payload); + act = fsm.resume(buff, error_code(), payload.size(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(error::empty_field)); +} + +} // namespace + +int main() +{ + test_success(); + test_no_expected_response(); + test_short_reads(); + + test_write_error(); + test_write_cancel(); + + test_read_error(); + test_read_cancelled(); + + test_buffer_prepare_error(); + test_parse_error(); + test_adapter_error(); + + return boost::report_errors(); +} diff --git a/test/test_low_level.cpp b/test/test_low_level.cpp index dc68e0ec..0b208d9d 100644 --- a/test/test_low_level.cpp +++ b/test/test_low_level.cpp @@ -530,6 +530,11 @@ BOOST_AUTO_TEST_CASE(cover_error) check_error("boost.redis", boost::redis::error::resp3_hello); check_error("boost.redis", boost::redis::error::exceeds_maximum_read_buffer_size); check_error("boost.redis", boost::redis::error::write_timeout); + check_error("boost.redis", boost::redis::error::sentinel_unix_sockets_unsupported); + check_error("boost.redis", boost::redis::error::sentinel_resolve_failed); + check_error("boost.redis", boost::redis::error::role_check_failed); + check_error("boost.redis", boost::redis::error::expects_resp3_string); + check_error("boost.redis", boost::redis::error::expects_resp3_array); } std::string get_type_as_str(boost::redis::resp3::type t) diff --git a/test/test_low_level_sync_sans_io.cpp b/test/test_low_level_sync_sans_io.cpp index 246cc0f3..f60b1711 100644 --- a/test/test_low_level_sync_sans_io.cpp +++ b/test/test_low_level_sync_sans_io.cpp @@ -13,6 +13,8 @@ #include #include +#include "print_node.hpp" + #define BOOST_TEST_MODULE low_level_sync_sans_io #include @@ -337,25 +339,15 @@ BOOST_AUTO_TEST_CASE(check_counter_adapter) namespace boost::redis::resp3 { -template -std::ostream& operator<<(std::ostream& os, basic_node const& nd) -{ - os << "type: " << to_string(nd.data_type) << "\n" - << "aggregate_size: " << nd.aggregate_size << "\n" - << "depth: " << nd.depth << "\n" - << "value: " << nd.value << "\n"; - return os; -} - template std::ostream& operator<<(std::ostream& os, basic_tree const& resp) { - for (auto const& e: resp) + for (auto const& e : resp) os << e << ","; return os; } -} +} // namespace boost::redis::resp3 node from_node_view(node_view const& v) { @@ -370,7 +362,7 @@ node from_node_view(node_view const& v) tree from_flat(flat_tree const& resp) { tree ret; - for (auto const& e: resp.get_view()) + for (auto const& e : resp.get_view()) ret.push_back(from_node_view(e)); return ret; @@ -379,13 +371,12 @@ tree from_flat(flat_tree const& resp) tree from_flat(generic_flat_response const& resp) { tree ret; - for (auto const& e: resp.value().get_view()) + for (auto const& e : resp.value().get_view()) ret.push_back(from_node_view(e)); return ret; } - // Parses the same data into a tree and a // flat_tree, they should be equal to each other. BOOST_AUTO_TEST_CASE(flat_tree_views_are_set) diff --git a/test/test_multiplexer.cpp b/test/test_multiplexer.cpp index 52db5e5c..dbb6c0e0 100644 --- a/test/test_multiplexer.cpp +++ b/test/test_multiplexer.cpp @@ -15,6 +15,7 @@ #include #include +#include "print_node.hpp" #include "sansio_utils.hpp" #include @@ -33,17 +34,6 @@ using boost::redis::response; using boost::redis::any_adapter; using boost::system::error_code; -namespace boost::redis::resp3 { - -std::ostream& operator<<(std::ostream& os, node const& nd) -{ - return os << "node{ .data_type=" << to_string(nd.data_type) - << ", .aggregate_size=" << nd.aggregate_size << ", .depth=" << nd.depth - << ", .value=" << nd.value << "}"; -} - -} // namespace boost::redis::resp3 - namespace boost::redis::detail { std::ostream& operator<<(std::ostream& os, consume_result v) diff --git a/test/test_parse_sentinel_response.cpp b/test/test_parse_sentinel_response.cpp new file mode 100644 index 00000000..00a83d75 --- /dev/null +++ b/test/test_parse_sentinel_response.cpp @@ -0,0 +1,727 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "sansio_utils.hpp" + +#include +#include +#include +#include + +using namespace boost::redis; +using detail::nodes_from_resp3; +using detail::parse_sentinel_response; +using detail::sentinel_response; +using boost::system::error_code; + +// Operators +namespace boost::redis { + +std::ostream& operator<<(std::ostream& os, const address& addr) +{ + return os << "address{ .host=" << addr.host << ", .port=" << addr.port << " }"; +} + +} // namespace boost::redis + +namespace { + +struct fixture { + sentinel_response resp{ + "leftover", + {"leftover_host", "6543"}, + {address()}, + {address()}, + }; + + void check_response( + const address& expected_master_addr, + boost::span expected_replicas, + boost::span expected_sentinels, + boost::source_location loc = BOOST_CURRENT_LOCATION) const + { + if (!BOOST_TEST_EQ(resp.diagnostic, "")) + std::cerr << "Called from " << loc << std::endl; + if (!BOOST_TEST_EQ(resp.master_addr, expected_master_addr)) + std::cerr << "Called from " << loc << std::endl; + if (!BOOST_TEST_ALL_EQ( + resp.replicas.begin(), + resp.replicas.end(), + expected_replicas.begin(), + expected_replicas.end())) + std::cerr << "Called from " << loc << std::endl; + if (!BOOST_TEST_ALL_EQ( + resp.sentinels.begin(), + resp.sentinels.end(), + expected_sentinels.begin(), + expected_sentinels.end())) + std::cerr << "Called from " << loc << std::endl; + } +}; + +// Usual response when asking for a master +void test_master() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*2\r\n" + "%14\r\n" + "$4\r\nname\r\n$40\r\nf14ef06a8a478cdd66ded467ec18accd2a24b731\r\n$2\r\nip\r\n$8\r\nhost.one\r\n$4\r\nport\r\n$5\r\n26380\r\n" + "$5\r\nrunid\r\n$40\r\nf14ef06a8a478cdd66ded467ec18accd2a24b731\r\n$5\r\nflags\r\n$8\r\nsentinel\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n696\r\n$15\r\nlast-ping-reply\r\n$3\r\n696\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$18\r\nlast-hello-message\r\n$3\r\n334\r\n$12\r\nvoted-leader\r\n$1\r\n?\r\n$18\r\nvoted-leader-epoch\r\n$1\r\n0\r\n" + "%14\r\n" + "$4\r\nname\r\n$40\r\nf9b54e79e2e7d3f17ad60527504191ec8a861f27\r\n$2\r\nip\r\n$8\r\nhost.two\r\n$4\r\nport\r\n$5\r\n26381\r\n" + "$5\r\nrunid\r\n$40\r\nf9b54e79e2e7d3f17ad60527504191ec8a861f27\r\n$5\r\nflags\r\n$8\r\nsentinel\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n696\r\n$15\r\nlast-ping-reply\r\n$3\r\n696\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$18\r\nlast-hello-message\r\n$3\r\n134\r\n$12\r\nvoted-leader\r\n$1\r\n?\r\n$18\r\nvoted-leader-epoch\r\n$1\r\n0\r\n", + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::master, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + const address expected_sentinels[] = { + {"host.one", "26380"}, + {"host.two", "26381"}, + }; + fix.check_response({"localhost", "6380"}, {}, expected_sentinels); +} + +// Works correctly even if no Sentinels are present +void test_master_no_sentinels() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*0\r\n", + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::master, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + fix.check_response({"localhost", "6380"}, {}, {}); +} + +// The responses corresponding to the user-defined setup request are ignored +void test_master_setup_request() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "+OK\r\n", + "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$7\r\nversion\r\n$5\r\n7.4.2\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:3\r\n$4\r\nmode\r\n$8\r\nsentinel\r\n$7\r\nmodules\r\n*0\r\n", + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*2\r\n" + "%14\r\n" + "$4\r\nname\r\n$40\r\nf14ef06a8a478cdd66ded467ec18accd2a24b731\r\n$2\r\nip\r\n$8\r\nhost.one\r\n$4\r\nport\r\n$5\r\n26380\r\n" + "$5\r\nrunid\r\n$40\r\nf14ef06a8a478cdd66ded467ec18accd2a24b731\r\n$5\r\nflags\r\n$8\r\nsentinel\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n696\r\n$15\r\nlast-ping-reply\r\n$3\r\n696\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$18\r\nlast-hello-message\r\n$3\r\n334\r\n$12\r\nvoted-leader\r\n$1\r\n?\r\n$18\r\nvoted-leader-epoch\r\n$1\r\n0\r\n" + "%14\r\n" + "$4\r\nname\r\n$40\r\nf9b54e79e2e7d3f17ad60527504191ec8a861f27\r\n$2\r\nip\r\n$8\r\nhost.two\r\n$4\r\nport\r\n$5\r\n26381\r\n" + "$5\r\nrunid\r\n$40\r\nf9b54e79e2e7d3f17ad60527504191ec8a861f27\r\n$5\r\nflags\r\n$8\r\nsentinel\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n696\r\n$15\r\nlast-ping-reply\r\n$3\r\n696\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$18\r\nlast-hello-message\r\n$3\r\n134\r\n$12\r\nvoted-leader\r\n$1\r\n?\r\n$18\r\nvoted-leader-epoch\r\n$1\r\n0\r\n", + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::master, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + const address expected_sentinels[] = { + {"host.one", "26380"}, + {"host.two", "26381"}, + }; + fix.check_response({"localhost", "6380"}, {}, expected_sentinels); +} + +// IP and port can be out of order +void test_master_ip_port_out_of_order() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n" + "%2\r\n" + "$4\r\nport\r\n$5\r\n26380\r\n$2\r\nip\r\n$8\r\nhost.one\r\n" + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::master, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + const address expected_sentinels[] = { + {"host.one", "26380"}, + }; + fix.check_response({"localhost", "6380"}, {}, expected_sentinels); +} + +// Usual response when asking for a replica +void test_replica() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*2\r\n" + "%21\r\n" + "$4\r\nname\r\n$14\r\nlocalhost:6381\r\n$2\r\nip\r\n$9\r\nsome.host\r\n$4\r\nport\r\n$4\r\n6381\r\n" + "$5\r\nrunid\r\n$40\r\ncdfa33e2d39958c0b10c0391c0c3d4ab096edfeb\r\n$5\r\nflags\r\n$5\r\nslave\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n134\r\n$15\r\nlast-ping-reply\r\n$3\r\n134\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$12\r\ninfo-refresh\r\n$4\r\n5302\r\n$13\r\nrole-reported\r\n$5\r\nslave\r\n$18\r\nrole-reported-time\r\n$6\r\n442121\r\n" + "$21\r\nmaster-link-down-time\r\n$1\r\n0\r\n$18\r\nmaster-link-status\r\n$2\r\nok\r\n$11\r\nmaster-host\r\n$9\r\nlocalhost\r\n" + "$11\r\nmaster-port\r\n$4\r\n6380\r\n$14\r\nslave-priority\r\n$3\r\n100\r\n$17\r\nslave-repl-offset\r\n$5\r\n29110\r\n" + "$17\r\nreplica-announced\r\n$1\r\n1\r\n" + "%21\r\n" + "$4\r\nname\r\n$14\r\nlocalhost:6382\r\n$2\r\nip\r\n$9\r\ntest.host\r\n$4\r\nport\r\n$4\r\n6382\r\n" + "$5\r\nrunid\r\n$40\r\n11bfea62c25316e211fdf0e1ccd2dbd920e90815\r\n$5\r\nflags\r\n$5\r\nslave\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n134\r\n$15\r\nlast-ping-reply\r\n$3\r\n134\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$12\r\ninfo-refresh\r\n$4\r\n5302\r\n$13\r\nrole-reported\r\n$5\r\nslave\r\n$18\r\nrole-reported-time\r\n$6\r\n442132\r\n" + "$21\r\nmaster-link-down-time\r\n$1\r\n0\r\n$18\r\nmaster-link-status\r\n$2\r\nok\r\n$11\r\nmaster-host\r\n$9\r\nlocalhost\r\n" + "$11\r\nmaster-port\r\n$4\r\n6380\r\n$14\r\nslave-priority\r\n$3\r\n100\r\n$17\r\nslave-repl-offset\r\n$5\r\n29110\r\n" + "$17\r\nreplica-announced\r\n$1\r\n1\r\n", + "*2\r\n" + "%14\r\n" + "$4\r\nname\r\n$40\r\nf14ef06a8a478cdd66ded467ec18accd2a24b731\r\n$2\r\nip\r\n$8\r\nhost.one\r\n$4\r\nport\r\n$5\r\n26380\r\n" + "$5\r\nrunid\r\n$40\r\nf14ef06a8a478cdd66ded467ec18accd2a24b731\r\n$5\r\nflags\r\n$8\r\nsentinel\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n696\r\n$15\r\nlast-ping-reply\r\n$3\r\n696\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$18\r\nlast-hello-message\r\n$3\r\n334\r\n$12\r\nvoted-leader\r\n$1\r\n?\r\n$18\r\nvoted-leader-epoch\r\n$1\r\n0\r\n" + "%14\r\n" + "$4\r\nname\r\n$40\r\nf9b54e79e2e7d3f17ad60527504191ec8a861f27\r\n$2\r\nip\r\n$8\r\nhost.two\r\n$4\r\nport\r\n$5\r\n26381\r\n" + "$5\r\nrunid\r\n$40\r\nf9b54e79e2e7d3f17ad60527504191ec8a861f27\r\n$5\r\nflags\r\n$8\r\nsentinel\r\n" + "$21\r\nlink-pending-commands\r\n$1\r\n0\r\n$13\r\nlink-refcount\r\n$1\r\n1\r\n$14\r\nlast-ping-sent\r\n$1\r\n0\r\n" + "$18\r\nlast-ok-ping-reply\r\n$3\r\n696\r\n$15\r\nlast-ping-reply\r\n$3\r\n696\r\n$23\r\ndown-after-milliseconds\r\n$5\r\n10000\r\n" + "$18\r\nlast-hello-message\r\n$3\r\n134\r\n$12\r\nvoted-leader\r\n$1\r\n?\r\n$18\r\nvoted-leader-epoch\r\n$1\r\n0\r\n", + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::replica, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + const address expected_replicas[] = { + {"some.host", "6381"}, + {"test.host", "6382"}, + }; + const address expected_sentinels[] = { + {"host.one", "26380"}, + {"host.two", "26381"}, + }; + fix.check_response({"localhost", "6380"}, expected_replicas, expected_sentinels); +} + +// Like the master case +void test_replica_no_sentinels() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*2\r\n" + "%3\r\n" + "$4\r\nname\r\n$14\r\nlocalhost:6381\r\n$2\r\nip\r\n$9\r\nsome.host\r\n$4\r\nport\r\n$4\r\n6381\r\n" + "%3\r\n" + "$4\r\nname\r\n$14\r\nlocalhost:6382\r\n$2\r\nip\r\n$9\r\ntest.host\r\n$4\r\nport\r\n$4\r\n6382\r\n", + "*0\r\n" + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::replica, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + const address expected_replicas[] = { + {"some.host", "6381"}, + {"test.host", "6382"}, + }; + fix.check_response({"localhost", "6380"}, expected_replicas, {}); +} + +// Asking for replicas, but there is none +void test_replica_no_replicas() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*0\r\n", + "*0\r\n", + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::replica, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + fix.check_response({"localhost", "6380"}, {}, {}); +} + +// Setup requests work with replicas, too +void test_replica_setup_request() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n+OK\r\n+OK\r\n", + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*2\r\n" + "%3\r\n" + "$4\r\nname\r\n$14\r\nlocalhost:6381\r\n$2\r\nip\r\n$9\r\nsome.host\r\n$4\r\nport\r\n$4\r\n6381\r\n" + "%3\r\n" + "$4\r\nname\r\n$14\r\nlocalhost:6382\r\n$2\r\nip\r\n$9\r\ntest.host\r\n$4\r\nport\r\n$4\r\n6382\r\n", + "*2\r\n" + "%3\r\n" + "$4\r\nname\r\n$40\r\nf14ef06a8a478cdd66ded467ec18accd2a24b731\r\n$2\r\nip\r\n$8\r\nhost.one\r\n$4\r\nport\r\n$5\r\n26380\r\n" + "%3\r\n" + "$4\r\nname\r\n$40\r\nf9b54e79e2e7d3f17ad60527504191ec8a861f27\r\n$2\r\nip\r\n$8\r\nhost.two\r\n$4\r\nport\r\n$5\r\n26381\r\n" + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::replica, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + const address expected_replicas[] = { + {"some.host", "6381"}, + {"test.host", "6382"}, + }; + const address expected_sentinels[] = { + {"host.one", "26380"}, + {"host.two", "26381"}, + }; + fix.check_response({"localhost", "6380"}, expected_replicas, expected_sentinels); +} + +// IP and port can be out of order +void test_replica_ip_port_out_of_order() +{ + // Setup + fixture fix; + auto nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\ntest.host\r\n$4\r\n6389\r\n", + "*1\r\n" + "%2\r\n" + "$4\r\nport\r\n$4\r\n6381\r\n$2\r\nip\r\n$9\r\nsome.host\r\n", + "*0\r\n" + // clang-format on + }); + + // Call the function + auto ec = parse_sentinel_response(nodes, role::replica, fix.resp); + BOOST_TEST_EQ(ec, error_code()); + + // Check + const address expected_replicas[] = { + {"some.host", "6381"}, + }; + fix.check_response({"test.host", "6389"}, expected_replicas, {}); +} + +void test_errors() +{ + const struct { + std::string_view name; + role server_role; + std::vector responses; + std::string_view expected_diagnostic; + error_code expected_ec; + } test_cases[]{ + // clang-format off + { + // A RESP3 simple error + "setup_error_simple", + role::master, + { + "-WRONGPASS invalid username-password pair or user is disabled.\r\n", + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*0\r\n", + }, + "WRONGPASS invalid username-password pair or user is disabled.", + error::resp3_simple_error + }, + { + // A RESP3 blob error + "setup_error_blob", + role::master, + { + "!3\r\nBad\r\n", + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*0\r\n", + }, + "Bad", + error::resp3_blob_error + }, + { + // Errors in intermediate nodes of the user-supplied request + "setup_error_intermediate", + role::master, + { + "+OK\r\n", + "-Something happened!\r\n", + "+OK\r\n", + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*0\r\n", + }, + "Something happened!", + error::resp3_simple_error + }, + { + // Only the first error is processed (e.g. auth failure may cause subsequent cmds to fail) + "setup_error_intermediate", + role::master, + { + "-Something happened!\r\n", + "-Something worse happened!\r\n", + "-Bad\r\n", + "-Worse\r\n", + }, + "Something happened!", + error::resp3_simple_error + }, + { + // This works for replicas, too + "setup_error_replicas", + role::replica, + { + "-Something happened!\r\n", + "-Something worse happened!\r\n", + "-Bad\r\n", + "-Worse\r\n", + }, + "Something happened!", + error::resp3_simple_error + }, + + // SENTINEL GET-MASTER-ADDR-BY-NAME + { + // Unknown master. This returns NULL and causes SENTINEL SENTINELS to fail + "getmasteraddr_unknown_master", + role::master, + { + "_\r\n", + "-ERR Unknown master\r\n", + }, + "", + error::resp3_null + }, + { + // The request errors for any other reason + "getmasteraddr_error", + role::master, + { + "-ERR something happened\r\n", + "*0\r\n", + }, + "ERR something happened", + error::resp3_simple_error + }, + { + // Same, for replicas + "getmasteraddr_unknown_master_replica", + role::replica, + { + "_\r\n", + "-ERR Unknown master\r\n", + "-ERR Unknown master\r\n", + }, + "", + error::resp3_null + }, + { + // Root node should be a list + "getmasteraddr_not_array", + role::master, + { + "+OK\r\n", + "*0\r\n", + }, + "", + error::expects_resp3_array + }, + { + // Root node should have exactly 2 elements + "getmasteraddr_array_size_1", + role::master, + { + "*1\r\n$5\r\nhello\r\n", + "*0\r\n", + }, + "", + error::incompatible_size + }, + { + // Root node should have exactly 2 elements + "getmasteraddr_array_size_3", + role::master, + { + "*3\r\n$5\r\nhello\r\n$3\r\nbye\r\n$3\r\nabc\r\n", + "*0\r\n", + }, + "", + error::incompatible_size + }, + { + // IP should be a string + "getmasteraddr_ip_not_string", + role::master, + { + "*2\r\n+OK\r\n$5\r\nhello\r\n", + "*0\r\n", + }, + "", + error::expects_resp3_string + }, + { + // Port should be a string + "getmasteraddr_port_not_string", + role::master, + { + "*2\r\n$5\r\nhello\r\n+OK\r\n", + "*0\r\n", + }, + "", + error::expects_resp3_string + }, + + // SENTINEL SENTINELS + { + // The request errors + "sentinels_error", + role::master, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "-ERR something went wrong\r\n", + }, + "ERR something went wrong", + error::resp3_simple_error + }, + { + // The root node should be an array + "sentinels_not_array", + role::master, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "+OK\r\n", + }, + "", + error::expects_resp3_array + }, + { + // Each Sentinel object should be a map + "sentinels_subobject_not_map", + role::master, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n*1\r\n$9\r\nlocalhost\r\n", + }, + "", + error::expects_resp3_map + }, + { + // Keys in the Sentinel object should be strings + "sentinels_keys_not_strings", + role::master, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n*0\r\n$5\r\nhello\r\n", + }, + "", + error::expects_resp3_string + }, + { + // Values in the Sentinel object should be strings + "sentinels_keys_not_strings", + role::master, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n$5\r\nhello\r\n*1\r\n+OK\r\n", + }, + "", + error::expects_resp3_string + }, + { + "sentinels_ip_not_found", + role::master, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n$4\r\nport\r\n$4\r\n6380\r\n", + }, + "", + error::empty_field + }, + { + "sentinels_port_not_found", + role::master, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n$2\r\nip\r\n$9\r\nlocalhost\r\n", + }, + "", + error::empty_field + }, + + // SENTINEL REPLICAS + { + // The request errors + "replicas_error", + role::replica, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "-ERR something went wrong\r\n", + "*0\r\n", + }, + "ERR something went wrong", + error::resp3_simple_error + }, + { + // The root node should be an array + "replicas_not_array", + role::replica, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "+OK\r\n", + "*0\r\n", + }, + "", + error::expects_resp3_array + }, + { + // Each replica object should be a map + "replicas_subobject_not_map", + role::replica, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n*1\r\n$9\r\nlocalhost\r\n", + "*0\r\n", + }, + "", + error::expects_resp3_map + }, + { + // Keys in the replica object should be strings + "replicas_keys_not_strings", + role::replica, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n*0\r\n$5\r\nhello\r\n", + "*0\r\n", + }, + "", + error::expects_resp3_string + }, + { + // Values in the replica object should be strings + "replicas_keys_not_strings", + role::replica, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n$5\r\nhello\r\n*1\r\n+OK\r\n", + "*0\r\n", + }, + "", + error::expects_resp3_string + }, + { + "replicas_ip_not_found", + role::replica, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n$4\r\nport\r\n$4\r\n6380\r\n", + "*0\r\n", + }, + "", + error::empty_field + }, + { + "replicas_port_not_found", + role::replica, + { + "*2\r\n$9\r\nlocalhost\r\n$4\r\n6380\r\n", + "*1\r\n%1\r\n$2\r\nip\r\n$9\r\nlocalhost\r\n", + "*0\r\n", + }, + "", + error::empty_field + } + + // clang-format on + }; + + for (const auto& tc : test_cases) { + // Setup + std::cerr << "Running error test case: " << tc.name << std::endl; + fixture fix; + auto nodes = nodes_from_resp3(tc.responses); + + // Call the function + auto ec = parse_sentinel_response(nodes, tc.server_role, fix.resp); + BOOST_TEST_EQ(ec, tc.expected_ec); + BOOST_TEST_EQ(fix.resp.diagnostic, tc.expected_diagnostic); + } +} + +} // namespace + +int main() +{ + test_master(); + test_master_no_sentinels(); + test_master_setup_request(); + test_master_ip_port_out_of_order(); + + test_replica(); + test_replica_no_sentinels(); + test_replica_no_replicas(); + test_replica_setup_request(); + test_replica_ip_port_out_of_order(); + + test_errors(); + + return boost::report_errors(); +} diff --git a/test/test_run_fsm.cpp b/test/test_run_fsm.cpp index b67dc929..ef570661 100644 --- a/test/test_run_fsm.cpp +++ b/test/test_run_fsm.cpp @@ -39,6 +39,7 @@ static const char* to_string(run_action_type value) switch (value) { case run_action_type::done: return "run_action_type::done"; case run_action_type::immediate: return "run_action_type::immediate"; + case run_action_type::sentinel_resolve: return "run_action_type::sentinel_resolve"; case run_action_type::connect: return "run_action_type::connect"; case run_action_type::parallel_group: return "run_action_type::parallel_group"; case run_action_type::cancel_receive: return "run_action_type::cancel_receive"; @@ -142,6 +143,30 @@ void test_config_error_unix_ssl() }); } +void test_config_error_unix_sentinel() +{ + // Setup + config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + cfg.unix_socket = "/var/sock"; + fixture fix{std::move(cfg)}; + + // Launching the operation fails immediately + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::immediate); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(error::sentinel_unix_sockets_unsupported)); + + // Log + fix.check_log({ + {logger::level::err, + "Invalid configuration: The configuration specified UNIX sockets with Sentinel, which is " + "not supported. [boost.redis:28]"}, + }); +} + // An error in connect with reconnection enabled triggers a reconnection void test_connect_error() { @@ -162,10 +187,83 @@ void test_connect_error() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); BOOST_TEST_EQ(act, run_action_type::parallel_group); - // Run doesn't log, it's the subordinate tasks that do - fix.check_log({}); + // Log + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Failed to connect to Redis server at 127.0.0.1:6379 (TLS disabled): Connect timeout. [boost.redis:18]"}, + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + // clang-format on + }); } +// Check logs for other transport types +void test_connect_error_ssl() +{ + // Setup + fixture fix; + fix.st.cfg.addr = {"my_hostname", "10000"}; + fix.st.cfg.use_ssl = true; + + // Launch the operation + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + + // Connect errors. We sleep and try to connect again + act = fix.fsm.resume(fix.st, error::connect_timeout, cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::wait_for_reconnection); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + + // This time we succeed and we launch the parallel group + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::parallel_group); + + // Log + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to connect to Redis server at my_hostname:10000 (TLS enabled)" }, + {logger::level::info, "Failed to connect to Redis server at my_hostname:10000 (TLS enabled): Connect timeout. [boost.redis:18]"}, + {logger::level::info, "Trying to connect to Redis server at my_hostname:10000 (TLS enabled)" }, + {logger::level::info, "Connected to Redis server at my_hostname:10000 (TLS enabled)" }, + // clang-format on + }); +} + +#ifdef BOOST_ASIO_HAS_LOCAL_SOCKETS +void test_connect_error_unix() +{ + // Setup + fixture fix; + fix.st.cfg.unix_socket = "/tmp/sock"; + + // Launch the operation + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + + // Connect errors. We sleep and try to connect again + act = fix.fsm.resume(fix.st, error::connect_timeout, cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::wait_for_reconnection); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + + // This time we succeed and we launch the parallel group + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::parallel_group); + + // Log + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to connect to Redis server at '/tmp/sock'" }, + {logger::level::info, "Failed to connect to Redis server at '/tmp/sock': Connect timeout. [boost.redis:18]"}, + {logger::level::info, "Trying to connect to Redis server at '/tmp/sock'" }, + {logger::level::info, "Connected to Redis server at '/tmp/sock'" }, + // clang-format on + }); +} +#endif + // An error in connect without reconnection enabled makes the operation finish void test_connect_error_no_reconnect() { @@ -180,8 +278,13 @@ void test_connect_error_no_reconnect() act = fix.fsm.resume(fix.st, error::connect_timeout, cancellation_type_t::none); BOOST_TEST_EQ(act, error_code(error::connect_timeout)); - // Run doesn't log, it's the subordinate tasks that do - fix.check_log({}); + // Log + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Failed to connect to Redis server at 127.0.0.1:6379 (TLS disabled): Connect timeout. [boost.redis:18]"}, + // clang-format on + }); } // A cancellation in connect makes the operation finish even with reconnection enabled @@ -198,9 +301,10 @@ void test_connect_cancel() act = fix.fsm.resume(fix.st, asio::error::operation_aborted, cancellation_type_t::terminal); BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); - // We log on cancellation only + // Log fix.check_log({ - {logger::level::debug, "Run: cancelled (1)"} + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::debug, "Run: cancelled (1)" } }); } @@ -218,9 +322,10 @@ void test_connect_cancel_edge() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); - // We log on cancellation only + // Log fix.check_log({ - {logger::level::debug, "Run: cancelled (1)"} + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::debug, "Run: cancelled (1)" } }); } @@ -247,8 +352,13 @@ void test_parallel_group_error() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); BOOST_TEST_EQ(act, run_action_type::parallel_group); - // Run doesn't log, it's the subordinate tasks that do - fix.check_log({}); + // Log + fix.check_log({ + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + }); } // An error in the parallel group makes the operation exit if reconnection is disabled @@ -269,8 +379,11 @@ void test_parallel_group_error_no_reconnect() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); BOOST_TEST_EQ(act, error_code(error::empty_field)); - // Run doesn't log, it's the subordinate tasks that do - fix.check_log({}); + // Log + fix.check_log({ + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + }); } // A cancellation in the parallel group makes it exit, even if reconnection is enabled. @@ -292,9 +405,11 @@ void test_parallel_group_cancel() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); - // We log on cancellation only + // Log fix.check_log({ - {logger::level::debug, "Run: cancelled (2)"} + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::debug, "Run: cancelled (2)" } }); } @@ -315,9 +430,11 @@ void test_parallel_group_cancel_no_reconnect() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); - // We log on cancellation only + // Log fix.check_log({ - {logger::level::debug, "Run: cancelled (2)"} + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::debug, "Run: cancelled (2)" } }); } @@ -343,9 +460,11 @@ void test_wait_cancel() act = fix.fsm.resume(fix.st, asio::error::operation_aborted, cancellation_type_t::terminal); BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); - // We log on cancellation only + // Log fix.check_log({ - {logger::level::debug, "Run: cancelled (3)"} + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::debug, "Run: cancelled (3)" } }); } @@ -370,9 +489,11 @@ void test_wait_cancel_edge() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); - // We log on cancellation only + // Log fix.check_log({ - {logger::level::debug, "Run: cancelled (3)"} + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::debug, "Run: cancelled (3)" } }); } @@ -409,9 +530,16 @@ void test_several_reconnections() act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); - // The cancellation was logged + // Log fix.check_log({ - {logger::level::debug, "Run: cancelled (2)"} + // clang-format off + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Failed to connect to Redis server at 127.0.0.1:6379 (TLS disabled): Connect timeout. [boost.redis:18]"}, + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::debug, "Run: cancelled (2)" } // clang-format on }); } @@ -481,7 +609,11 @@ void test_setup_request_success() // Check log fix.check_log({ - {logger::level::info, "Setup request execution: success"} + // clang-format off + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Setup request execution: success"}, + // clang-format on }); } @@ -501,8 +633,13 @@ void test_setup_request_empty() // Nothing was added to the multiplexer BOOST_TEST_EQ(fix.st.mpx.prepare_write(), 0u); - // Check log - fix.check_log({}); + // Log + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, + // clang-format on + }); } // A server error would cause the reader to exit @@ -510,7 +647,7 @@ void test_setup_request_server_error() { // Setup fixture fix; - fix.st.setup_diagnostic = "leftover"; // simulate a leftover from previous runs + fix.st.diagnostic = "leftover"; // simulate a leftover from previous runs fix.st.cfg.setup.clear(); fix.st.cfg.setup.push("HELLO", 3); @@ -533,9 +670,147 @@ void test_setup_request_server_error() // Check log fix.check_log({ + {logger::level::info, "Trying to connect to Redis server at 127.0.0.1:6379 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at 127.0.0.1:6379 (TLS disabled)" }, {logger::level::info, "Setup request execution: The server response to the setup request sent during connection " - "establishment contains an error. [boost.redis:23] (ERR: wrong command)"} + "establishment contains an error. [boost.redis:23] (ERR: wrong command)" } + }); +} + +// When using Sentinel, reconnection works normally +void test_sentinel_reconnection() +{ + // Setup + fixture fix; + fix.st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + + // Resolve succeeds, and a connection is attempted + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::sentinel_resolve); + fix.st.cfg.addr = {"host1", "1000"}; + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + + // This errors, so we sleep and resolve again + act = fix.fsm.resume(fix.st, error::connect_timeout, cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::wait_for_reconnection); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::sentinel_resolve); + fix.st.cfg.addr = {"host2", "2000"}; + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::parallel_group); + + // Sentinel involves always a setup request containing the role check. Run it. + BOOST_TEST_EQ(fix.st.mpx.prepare_write(), 1u); + BOOST_TEST(fix.st.mpx.commit_write(fix.st.mpx.get_write_buffer().size())); + read(fix.st.mpx, "*1\r\n$6\r\nmaster\r\n"); + error_code ec; + auto res = fix.st.mpx.consume(ec); + BOOST_TEST_EQ(ec, error_code()); + BOOST_TEST(res.first == detail::consume_result::got_response); + + // The parallel group errors, so we sleep and resolve again + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::cancel_receive); + act = fix.fsm.resume(fix.st, error::write_timeout, cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::wait_for_reconnection); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::sentinel_resolve); + fix.st.cfg.addr = {"host3", "3000"}; + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + + // Cancel + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); + + // Log + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to connect to Redis server at host1:1000 (TLS disabled)"}, + {logger::level::info, "Failed to connect to Redis server at host1:1000 (TLS disabled): Connect timeout. [boost.redis:18]"}, + {logger::level::info, "Trying to connect to Redis server at host2:2000 (TLS disabled)"}, + {logger::level::info, "Connected to Redis server at host2:2000 (TLS disabled)"}, + {logger::level::info, "Setup request execution: success"}, + {logger::level::info, "Trying to connect to Redis server at host3:3000 (TLS disabled)"}, + {logger::level::debug, "Run: cancelled (1)"}, + // clang-format on + }); +} + +// If the Sentinel resolve operation errors, we try again +void test_sentinel_resolve_error() +{ + // Setup + fixture fix; + fix.st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + + // Start the Sentinel resolve operation + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::sentinel_resolve); + + // It fails with an error, so we go to sleep + act = fix.fsm.resume(fix.st, error::sentinel_resolve_failed, cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::wait_for_reconnection); + + // Retrying it succeeds + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::sentinel_resolve); + fix.st.cfg.addr = {"myhost", "10000"}; + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::connect); + + // Log + fix.check_log({ + {logger::level::info, "Trying to connect to Redis server at myhost:10000 (TLS disabled)"}, + }); +} + +// The reconnection setting affects Sentinel reconnection, too +void test_sentinel_resolve_error_no_reconnect() +{ + // Setup + fixture fix{config_no_reconnect()}; + fix.st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + + // Start the Sentinel resolve operation + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::sentinel_resolve); + + // It fails with an error, so we exit + act = fix.fsm.resume(fix.st, error::sentinel_resolve_failed, cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(error::sentinel_resolve_failed)); + + // Log + fix.check_log({}); +} + +void test_sentinel_resolve_cancel() +{ + // Setup + fixture fix; + fix.st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + + // Start the Sentinel resolve operation + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, run_action_type::sentinel_resolve); + act = fix.fsm.resume(fix.st, asio::error::operation_aborted, cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); + + // Log + fix.check_log({ + {logger::level::debug, "Run: cancelled (4)"}, }); } @@ -547,8 +822,13 @@ int main() test_config_error_unix(); #endif test_config_error_unix_ssl(); + test_config_error_unix_sentinel(); test_connect_error(); + test_connect_error_ssl(); +#ifdef BOOST_ASIO_HAS_LOCAL_SOCKETS + test_connect_error_unix(); +#endif test_connect_error_no_reconnect(); test_connect_cancel(); test_connect_cancel_edge(); @@ -568,5 +848,10 @@ int main() test_setup_request_empty(); test_setup_request_server_error(); + test_sentinel_reconnection(); + test_sentinel_resolve_error(); + test_sentinel_resolve_error_no_reconnect(); + test_sentinel_resolve_cancel(); + return boost::report_errors(); } diff --git a/test/test_sentinel_resolve_fsm.cpp b/test/test_sentinel_resolve_fsm.cpp new file mode 100644 index 00000000..4eb847d9 --- /dev/null +++ b/test/test_sentinel_resolve_fsm.cpp @@ -0,0 +1,682 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "sansio_utils.hpp" + +#include + +using namespace boost::redis; +namespace asio = boost::asio; +using detail::sentinel_resolve_fsm; +using detail::sentinel_action; +using detail::connection_state; +using detail::nodes_from_resp3; +using boost::system::error_code; +using boost::asio::cancellation_type_t; + +static char const* to_string(sentinel_action::type t) +{ + switch (t) { + case sentinel_action::type::done: return "sentinel_action::type::done"; + case sentinel_action::type::connect: return "sentinel_action::type::connect"; + case sentinel_action::type::request: return "sentinel_action::type::request"; + default: return "sentinel_action::type::"; + } +} + +// Operators +namespace boost::redis::detail { + +std::ostream& operator<<(std::ostream& os, sentinel_action::type type) +{ + os << to_string(type); + return os; +} + +bool operator==(sentinel_action lhs, sentinel_action rhs) noexcept +{ + if (lhs.get_type() != rhs.get_type()) + return false; + else if (lhs.get_type() == sentinel_action::type::done) + return lhs.error() == rhs.error(); + else if (lhs.get_type() == sentinel_action::type::connect) + return lhs.connect_addr() == rhs.connect_addr(); + else + return true; +} + +std::ostream& operator<<(std::ostream& os, sentinel_action act) +{ + os << "exec_action{ .type=" << act.get_type(); + if (act.get_type() == sentinel_action::type::done) + os << ", .error=" << act.error(); + else if (act.get_type() == sentinel_action::type::connect) + os << ", .addr=" << act.connect_addr().host << ":" << act.connect_addr().port; + return os << " }"; +} + +} // namespace boost::redis::detail + +namespace boost::redis { + +std::ostream& operator<<(std::ostream& os, const address& addr) +{ + return os << "address{ .host=" << addr.host << ", .port=" << addr.port << " }"; +} + +} // namespace boost::redis + +namespace { + +struct fixture : detail::log_fixture { + connection_state st{{make_logger()}}; + sentinel_resolve_fsm fsm; + + fixture() + { + st.sentinels = { + {"host1", "1000"}, + {"host2", "2000"}, + {"host3", "3000"}, + }; + st.cfg.sentinel.addresses = { + {"host1", "1000"}, + {"host4", "4000"}, + }; + st.cfg.sentinel.master_name = "mymaster"; + } +}; + +void test_success() +{ + // Setup + fixture fix; + + // Initiate. We should connect to the 1st sentinel + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + + // Now send the request + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*1\r\n" + "%2\r\n" + "$2\r\nip\r\n$8\r\nhost.one\r\n$4\r\nport\r\n$5\r\n26380\r\n", + // clang-format on + }); + + // We received a valid request, so we're done + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The master's address is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"test.host", "6380"})); + + // The Sentinel list is updated + const address expected_sentinels[] = { + {"host1", "1000" }, + {"host.one", "26380"}, + {"host4", "4000" }, + }; + BOOST_TEST_ALL_EQ( + fix.st.sentinels.begin(), + fix.st.sentinels.end(), + std::begin(expected_sentinels), + std::end(expected_sentinels)); + + // Logs + fix.check_log({ + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000 resolved the server address to test.host:6380"}, + }); +} + +void test_success_replica() +{ + // Setup. Seed the engine so that it returns index 1 + fixture fix; + fix.st.cfg.sentinel.server_role = role::replica; + fix.st.eng.get().seed(static_cast(183984887232u)); + + // Initiate. We should connect to the 1st sentinel + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + + // Now send the request + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*3\r\n" + "%2\r\n" + "$2\r\nip\r\n$11\r\nreplica.one\r\n$4\r\nport\r\n$4\r\n6379\r\n" + "%2\r\n" + "$2\r\nip\r\n$11\r\nreplica.two\r\n$4\r\nport\r\n$4\r\n6379\r\n" + "%2\r\n" + "$2\r\nip\r\n$11\r\nreplica.thr\r\n$4\r\nport\r\n$4\r\n6379\r\n", + "*0\r\n" + // clang-format on + }); + + // We received a valid request, so we're done + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The address of one of the replicas is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"replica.two", "6379"})); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of a replica of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000 resolved the server address to replica.two:6379" }, + // clang-format on + }); +} + +// The first Sentinel fails connection, but subsequent ones succeed +void test_one_connect_error() +{ + // Setup + fixture fix; + + // Initiate. We should connect to the 1st sentinel + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + + // This errors, so we connect to the 2nd sentinel + act = fix.fsm.resume(fix.st, error::connect_timeout, cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host2", "2000"})); + + // Now send the request + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*0\r\n", + }); + + // We received a valid request, so we're done + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The master's address is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"test.host", "6380"})); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: connection establishment error: Connect timeout. [boost.redis:18]" }, + {logger::level::debug, "Trying to contact Sentinel at host2:2000" }, + {logger::level::debug, "Executing Sentinel request at host2:2000" }, + {logger::level::info, "Sentinel at host2:2000 resolved the server address to test.host:6380"}, + // clang-format on + }); +} + +// The first Sentinel fails while executing the request, but subsequent ones succeed +void test_one_request_network_error() +{ + // Setup + fixture fix; + + // Initiate, connect to the 1st Sentinel, and send the request + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + + // It fails, so we connect to the 2nd sentinel. This one succeeds + act = fix.fsm.resume(fix.st, error::write_timeout, cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host2", "2000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*0\r\n", + }); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The master's address is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"test.host", "6380"})); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: error while executing request: Timeout while writing data to the server. [boost.redis:27]"}, + {logger::level::debug, "Trying to contact Sentinel at host2:2000" }, + {logger::level::debug, "Executing Sentinel request at host2:2000" }, + {logger::level::info, "Sentinel at host2:2000 resolved the server address to test.host:6380"}, + // clang-format on + }); +} + +// The first Sentinel responds with an invalid message, but subsequent ones succeed +void test_one_request_parse_error() +{ + // Setup + fixture fix; + + // Initiate, connect to the 1st Sentinel, and send the request + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "+OK\r\n", + "+OK\r\n", + }); + + // This fails parsing, so we connect to the 2nd sentinel. This one succeeds + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host2", "2000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*0\r\n", + }); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The master's address is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"test.host", "6380"})); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: error parsing response (maybe forgot to upgrade to RESP3?): " + "Expects a RESP3 array, but got a different data type. [boost.redis:32]"}, + {logger::level::debug, "Trying to contact Sentinel at host2:2000" }, + {logger::level::debug, "Executing Sentinel request at host2:2000" }, + {logger::level::info, "Sentinel at host2:2000 resolved the server address to test.host:6380"}, + // clang-format on + }); +} + +// The first Sentinel responds with an error (e.g. failed auth), but subsequent ones succeed +void test_one_request_error_node() +{ + // Setup + fixture fix; + + // Initiate, connect to the 1st Sentinel, and send the request + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "-ERR needs authentication\r\n", + "-ERR needs authentication\r\n", + }); + + // This fails, so we connect to the 2nd sentinel. This one succeeds + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host2", "2000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*0\r\n", + }); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The master's address is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"test.host", "6380"})); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: responded with an error: ERR needs authentication"}, + {logger::level::debug, "Trying to contact Sentinel at host2:2000" }, + {logger::level::debug, "Executing Sentinel request at host2:2000" }, + {logger::level::info, "Sentinel at host2:2000 resolved the server address to test.host:6380"}, + // clang-format on + }); +} + +// The first Sentinel doesn't know about the master, but others do +void test_one_master_unknown() +{ + // Setup + fixture fix; + + // Initiate, connect to the 1st Sentinel, and send the request + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "_\r\n", + "-ERR unknown master\r\n", + }); + + // It doesn't know about our master, so we connect to the 2nd sentinel. + // This one succeeds + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host2", "2000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*0\r\n", + }); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The master's address is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"test.host", "6380"})); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: doesn't know about the configured master" }, + {logger::level::debug, "Trying to contact Sentinel at host2:2000" }, + {logger::level::debug, "Executing Sentinel request at host2:2000" }, + {logger::level::info, "Sentinel at host2:2000 resolved the server address to test.host:6380"}, + // clang-format on + }); +} + +// The first Sentinel thinks there are no replicas (stale data?), but others do +void test_one_no_replicas() +{ + // Setup + fixture fix; + fix.st.cfg.sentinel.server_role = role::replica; + + // Initiate, connect to the 1st Sentinel, and send the request + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*0\r\n", + "*0\r\n", + }); + + // This errors, so we connect to the 2nd sentinel. This one succeeds + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host2", "2000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + // clang-format off + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*1\r\n" + "%2\r\n" + "$2\r\nip\r\n$11\r\nreplica.one\r\n$4\r\nport\r\n$4\r\n6379\r\n", + "*0\r\n", + // clang-format on + }); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code()); + + // The replica's address is stored + BOOST_TEST_EQ(fix.st.cfg.addr, (address{"replica.one", "6379"})); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of a replica of master 'mymaster' using Sentinel" }, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: the configured master has no replicas" }, + {logger::level::debug, "Trying to contact Sentinel at host2:2000" }, + {logger::level::debug, "Executing Sentinel request at host2:2000" }, + {logger::level::info, "Sentinel at host2:2000 resolved the server address to replica.one:6379"}, + // clang-format on + }); +} + +// If no Sentinel is available, the operation fails. A comprehensive error is logged. +void test_error() +{ + // Setup + fixture fix; + + // 1st Sentinel doesn't know about the master + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "_\r\n", + "-ERR unknown master\r\n", + }); + + // Move to the 2nd Sentinel, which fails to connect + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host2", "2000"})); + + // Move to the 3rd Sentinel, which has authentication misconfigured + act = fix.fsm.resume(fix.st, error::connect_timeout, cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host3", "3000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "-ERR unauthorized\r\n", + "-ERR unauthorized\r\n", + }); + + // Sentinel list exhausted + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(error::sentinel_resolve_failed)); + + // The Sentinel list is not updated + BOOST_TEST_EQ(fix.st.sentinels.size(), 3u); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel" }, + + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: doesn't know about the configured master" }, + + {logger::level::debug, "Trying to contact Sentinel at host2:2000" }, + {logger::level::info, "Sentinel at host2:2000: connection establishment error: Connect timeout. [boost.redis:18]" }, + + {logger::level::debug, "Trying to contact Sentinel at host3:3000" }, + {logger::level::debug, "Executing Sentinel request at host3:3000" }, + {logger::level::info, "Sentinel at host3:3000: responded with an error: ERR unauthorized"}, + + {logger::level::err, "Failed to resolve the address of master 'mymaster'. Tried the following Sentinels:" + "\n Sentinel at host1:1000: doesn't know about the configured master" + "\n Sentinel at host2:2000: connection establishment error: Connect timeout. [boost.redis:18]" + "\n Sentinel at host3:3000: responded with an error: ERR unauthorized"}, + // clang-format on + }); +} + +// The replica error text is slightly different +void test_error_replica() +{ + // Setup + fixture fix; + fix.st.sentinels = { + {"host1", "1000"} + }; + fix.st.cfg.sentinel.server_role = role::replica; + + // Initiate, connect to the only Sentinel, and send the request + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + fix.st.sentinel_resp_nodes = nodes_from_resp3({ + "*2\r\n$9\r\ntest.host\r\n$4\r\n6380\r\n", + "*0\r\n", + "*0\r\n", + }); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, error_code(error::sentinel_resolve_failed)); + + // Logs + fix.check_log({ + // clang-format off + {logger::level::info, "Trying to resolve the address of a replica of master 'mymaster' using Sentinel" }, + + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::info, "Sentinel at host1:1000: the configured master has no replicas" }, + + {logger::level::err, "Failed to resolve the address of a replica of master 'mymaster'. Tried the following Sentinels:" + "\n Sentinel at host1:1000: the configured master has no replicas"}, + // clang-format on + }); +} + +// Cancellations +void test_cancel_connect() +{ + // Setup + fixture fix; + + // Initiate. We should connect to the 1st sentinel + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + + // Cancellation + act = fix.fsm.resume(fix.st, asio::error::operation_aborted, cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); + + // Logs + fix.check_log({ + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel"}, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Sentinel resolve: cancelled (1)" }, + }); +} + +void test_cancel_connect_edge() +{ + // Setup + fixture fix; + + // Initiate. We should connect to the 1st sentinel + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + + // Cancellation (without error code) + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); + + // Logs + fix.check_log({ + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel"}, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Sentinel resolve: cancelled (1)" }, + }); +} + +void test_cancel_request() +{ + // Setup + fixture fix; + + // Initiate. We should connect to the 1st sentinel + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + act = fix.fsm.resume(fix.st, asio::error::operation_aborted, cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); + + // Logs + fix.check_log({ + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel"}, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::debug, "Sentinel resolve: cancelled (2)" }, + }); +} + +void test_cancel_request_edge() +{ + // Setup + fixture fix; + + // Initiate. We should connect to the 1st sentinel + auto act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, (address{"host1", "1000"})); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::none); + BOOST_TEST_EQ(act, sentinel_action::request()); + act = fix.fsm.resume(fix.st, error_code(), cancellation_type_t::terminal); + BOOST_TEST_EQ(act, error_code(asio::error::operation_aborted)); + + // Logs + fix.check_log({ + {logger::level::info, "Trying to resolve the address of master 'mymaster' using Sentinel"}, + {logger::level::debug, "Trying to contact Sentinel at host1:1000" }, + {logger::level::debug, "Executing Sentinel request at host1:1000" }, + {logger::level::debug, "Sentinel resolve: cancelled (2)" }, + }); +} + +} // namespace + +int main() +{ + test_success(); + test_success_replica(); + + test_one_connect_error(); + test_one_request_network_error(); + test_one_request_parse_error(); + test_one_request_error_node(); + test_one_master_unknown(); + test_one_no_replicas(); + + test_error(); + test_error_replica(); + + test_cancel_connect(); + test_cancel_connect_edge(); + test_cancel_request(); + test_cancel_request_edge(); + + return boost::report_errors(); +} diff --git a/test/test_setup_adapter.cpp b/test/test_setup_adapter.cpp new file mode 100644 index 00000000..694700eb --- /dev/null +++ b/test/test_setup_adapter.cpp @@ -0,0 +1,349 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#include + +#include +#include +#include + +#include + +using namespace boost::redis; +using detail::setup_adapter; +using detail::connection_state; +using detail::compose_setup_request; +using boost::system::error_code; + +namespace { + +void test_success() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.setup.push("SELECT", 2); + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "%1\r\n$6\r\nserver\r\n$5\r\nredis\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // Response to the SELECT command + p.reset(); + done = resp3::parse(p, "+OK\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // No diagnostic + BOOST_TEST_EQ(st.diagnostic, ""); +} + +void test_simple_error() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO contains an error + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "-ERR unauthorized\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::resp3_hello); + BOOST_TEST_EQ(st.diagnostic, "ERR unauthorized"); +} + +void test_blob_error() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.setup.push("SELECT", 1); + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "%1\r\n$6\r\nserver\r\n$5\r\nredis\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // Response to select contains an error + p.reset(); + done = resp3::parse(p, "!3\r\nBad\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::resp3_hello); + BOOST_TEST_EQ(st.diagnostic, "Bad"); +} + +// A NULL is not an error +void test_null() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "_\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // No diagnostic + BOOST_TEST_EQ(st.diagnostic, ""); +} + +// Sentinel adds a ROLE command and checks its output. +// These are real wire values. +constexpr std::string_view role_master_response = + "*3\r\n$6\r\nmaster\r\n:567942\r\n*2\r\n" + "*3\r\n$9\r\nlocalhost\r\n$4\r\n6381\r\n$6\r\n567809\r\n*3\r\n$9\r\nlocalhost\r\n" + "$4\r\n6382\r\n$6\r\n567809\r\n"; +constexpr std::string_view role_replica_response = + "*5\r\n$5\r\nslave\r\n$9\r\nlocalhost\r\n:6380\r\n$9\r\nconnected\r\n:617355\r\n"; + +void test_sentinel_master() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.setup.push("SELECT", 2); + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "%1\r\n$6\r\nserver\r\n$5\r\nredis\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // Response to the SELECT command + p.reset(); + done = resp3::parse(p, "+OK\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // Response to the ROLE command + p.reset(); + done = resp3::parse(p, role_master_response, adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // No diagnostic + BOOST_TEST_EQ(st.diagnostic, ""); +} + +void test_sentinel_replica() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + st.cfg.sentinel.server_role = role::replica; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "%1\r\n$6\r\nserver\r\n$5\r\nredis\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // Response to the ROLE command + p.reset(); + done = resp3::parse(p, role_replica_response, adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // No diagnostic + BOOST_TEST_EQ(st.diagnostic, ""); +} + +// If the role is not the one expected, a role failed error is issued +void test_sentinel_role_check_failed_master() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "%1\r\n$6\r\nserver\r\n$5\r\nredis\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // Response to the ROLE command + p.reset(); + done = resp3::parse(p, role_replica_response, adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::role_check_failed); + + // No diagnostic + BOOST_TEST_EQ(st.diagnostic, ""); +} + +void test_sentinel_role_check_failed_replica() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + st.cfg.sentinel.server_role = role::replica; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to HELLO + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "%1\r\n$6\r\nserver\r\n$5\r\nredis\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error_code()); + + // Response to the ROLE command + p.reset(); + done = resp3::parse(p, role_master_response, adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::role_check_failed); + + // No diagnostic + BOOST_TEST_EQ(st.diagnostic, ""); +} + +// If the role command errors or has an unexpected format, we fail +void test_sentinel_role_error_node() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.setup.clear(); + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to ROLE + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "-ERR unauthorized\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::resp3_hello); + BOOST_TEST_EQ(st.diagnostic, "ERR unauthorized"); +} + +void test_sentinel_role_not_array() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.setup.clear(); + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to ROLE + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "+OK\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::invalid_data_type); + BOOST_TEST_EQ(st.diagnostic, ""); +} + +void test_sentinel_role_empty_array() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.setup.clear(); + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to ROLE + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "*0\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::incompatible_size); + BOOST_TEST_EQ(st.diagnostic, ""); +} + +void test_sentinel_role_first_element_not_string() +{ + // Setup + connection_state st; + st.cfg.use_setup = true; + st.cfg.setup.clear(); + st.cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + compose_setup_request(st.cfg); + setup_adapter adapter{st}; + + // Response to ROLE + resp3::parser p; + error_code ec; + bool done = resp3::parse(p, "*1\r\n:2000\r\n", adapter, ec); + BOOST_TEST(done); + BOOST_TEST_EQ(ec, error::invalid_data_type); + BOOST_TEST_EQ(st.diagnostic, ""); +} + +} // namespace + +int main() +{ + test_success(); + test_simple_error(); + test_blob_error(); + test_null(); + + test_sentinel_master(); + test_sentinel_replica(); + test_sentinel_role_check_failed_master(); + test_sentinel_role_check_failed_replica(); + test_sentinel_role_error_node(); + test_sentinel_role_not_array(); + test_sentinel_role_empty_array(); + test_sentinel_role_first_element_not_string(); + + return boost::report_errors(); +} \ No newline at end of file diff --git a/test/test_setup_request_utils.cpp b/test/test_setup_request_utils.cpp index c6fad370..b2bab2ef 100644 --- a/test/test_setup_request_utils.cpp +++ b/test/test_setup_request_utils.cpp @@ -23,6 +23,8 @@ namespace redis = boost::redis; using redis::detail::compose_setup_request; using boost::system::error_code; +// TODO: rename the file + namespace { void test_compose_setup() @@ -178,6 +180,50 @@ void test_compose_setup_use_setup_flags() BOOST_TEST(cfg.setup.get_config().cancel_on_connection_lost); } +// When using Sentinel, a ROLE command is added. This works +// both with the old HELLO and new setup strategies. +void test_compose_setup_sentinel_auth() +{ + redis::config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + cfg.clientname = ""; + cfg.username = "foo"; + cfg.password = "bar"; + + compose_setup_request(cfg); + + std::string_view const expected = + "*5\r\n$5\r\nHELLO\r\n$1\r\n3\r\n$4\r\nAUTH\r\n$3\r\nfoo\r\n$3\r\nbar\r\n" + "*1\r\n$4\r\nROLE\r\n"; + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); + BOOST_TEST(cfg.setup.get_config().cancel_if_unresponded); + BOOST_TEST(cfg.setup.get_config().cancel_on_connection_lost); +} + +void test_compose_setup_sentinel_use_setup() +{ + redis::config cfg; + cfg.sentinel.addresses = { + {"localhost", "26379"} + }; + cfg.use_setup = true; + cfg.setup.push("SELECT", 42); + + compose_setup_request(cfg); + + std::string_view const expected = + "*2\r\n$5\r\nHELLO\r\n$1\r\n3\r\n" + "*2\r\n$6\r\nSELECT\r\n$2\r\n42\r\n" + "*1\r\n$4\r\nROLE\r\n"; + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); + BOOST_TEST(cfg.setup.get_config().cancel_if_unresponded); + BOOST_TEST(cfg.setup.get_config().cancel_on_connection_lost); +} + } // namespace int main() @@ -191,6 +237,8 @@ int main() test_compose_setup_use_setup(); test_compose_setup_use_setup_no_hello(); test_compose_setup_use_setup_flags(); + test_compose_setup_sentinel_auth(); + test_compose_setup_sentinel_use_setup(); return boost::report_errors(); } \ No newline at end of file diff --git a/test/test_unix_sockets.cpp b/test/test_unix_sockets.cpp index 75198157..be4fa139 100644 --- a/test/test_unix_sockets.cpp +++ b/test/test_unix_sockets.cpp @@ -131,7 +131,7 @@ void test_switch_between_transports() // Create configurations for TLS and UNIX connections auto tcp_tls_cfg = make_test_config(); tcp_tls_cfg.use_ssl = true; - tcp_tls_cfg.addr.port = "6380"; + tcp_tls_cfg.addr.port = "16380"; auto unix_cfg = make_test_config(); unix_cfg.unix_socket = unix_socket_path; @@ -194,7 +194,7 @@ void test_error_unix_tls() connection conn{ioc}; auto cfg = make_test_config(); cfg.use_ssl = true; - cfg.addr.port = "6380"; + cfg.addr.port = "16380"; cfg.unix_socket = unix_socket_path; bool finished = false; diff --git a/test/test_update_sentinel_list.cpp b/test/test_update_sentinel_list.cpp new file mode 100644 index 00000000..f66de049 --- /dev/null +++ b/test/test_update_sentinel_list.cpp @@ -0,0 +1,212 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include + +#include +#include + +#include + +using namespace boost::redis; +using detail::update_sentinel_list; +using boost::system::error_code; + +// Operators +namespace boost::redis { + +std::ostream& operator<<(std::ostream& os, const address& addr) +{ + return os << "address{ .host=" << addr.host << ", .port=" << addr.port << " }"; +} + +} // namespace boost::redis + +namespace { + +// The only Sentinel resolved the address successfully, and there's no newly discovered Sentinels +void test_single_sentinel() +{ + const std::vector
initial_sentinels{ + {"host1", "1000"} + }; + std::vector
sentinels{initial_sentinels}; + + update_sentinel_list(sentinels, 0u, {}, initial_sentinels); + + BOOST_TEST_ALL_EQ( + sentinels.begin(), + sentinels.end(), + initial_sentinels.begin(), + initial_sentinels.end()); +} + +// Some new Sentinels were discovered using SENTINEL SENTINELS +void test_new_sentinels() +{ + const std::vector
initial_sentinels{ + {"host1", "1000"} + }; + std::vector
sentinels{initial_sentinels}; + const address new_sentinels[]{ + {"host2", "2000"}, + {"host3", "3000"}, + }; + + update_sentinel_list(sentinels, 0u, new_sentinels, initial_sentinels); + + const address expected_sentinels[]{ + {"host1", "1000"}, + {"host2", "2000"}, + {"host3", "3000"}, + }; + + BOOST_TEST_ALL_EQ( + sentinels.begin(), + sentinels.end(), + std::begin(expected_sentinels), + std::end(expected_sentinels)); +} + +// Some of the new Sentinels are already in the list +void test_new_sentinels_known() +{ + const std::vector
initial_sentinels{ + {"host1", "1000"}, + {"host2", "2000"}, + }; + std::vector
sentinels{initial_sentinels}; + const address new_sentinels[]{ + {"host2", "2000"}, + {"host3", "3000"}, + }; + + update_sentinel_list(sentinels, 0u, new_sentinels, initial_sentinels); + + const address expected_sentinels[]{ + {"host1", "1000"}, + {"host2", "2000"}, + {"host3", "3000"}, + }; + + BOOST_TEST_ALL_EQ( + sentinels.begin(), + sentinels.end(), + std::begin(expected_sentinels), + std::end(expected_sentinels)); +} + +// The Sentinel that succeeded should be placed first +void test_success_sentinel_not_first() +{ + const std::vector
initial_sentinels{ + {"host1", "1000"}, + {"host2", "2000"}, + {"host3", "3000"}, + }; + std::vector
sentinels{initial_sentinels}; + const address new_sentinels[]{ + {"host1", "1000"}, + {"host2", "2000"}, + }; + + update_sentinel_list(sentinels, 2u, new_sentinels, initial_sentinels); + + const address expected_sentinels[]{ + {"host3", "3000"}, + {"host1", "1000"}, + {"host2", "2000"}, + }; + + BOOST_TEST_ALL_EQ( + sentinels.begin(), + sentinels.end(), + std::begin(expected_sentinels), + std::end(expected_sentinels)); +} + +// If a discovered Sentinel is not returned in subsequent iterations, it's removed from the list +void test_new_sentinel_removed() +{ + const std::vector
initial_sentinels{ + {"host1", "1000"}, + }; + std::vector
sentinels{ + {"host1", "1000"}, + {"host4", "4000"}, + }; + const address new_sentinels[]{ + {"host2", "2000"}, + {"host3", "3000"}, + }; + + update_sentinel_list(sentinels, 0u, new_sentinels, initial_sentinels); + + const address expected_sentinels[]{ + {"host1", "1000"}, + {"host2", "2000"}, + {"host3", "3000"}, + }; + + BOOST_TEST_ALL_EQ( + sentinels.begin(), + sentinels.end(), + std::begin(expected_sentinels), + std::end(expected_sentinels)); +} + +// Bootstrap Sentinels are never removed +void test_bootstrap_sentinel_removed() +{ + const std::vector
initial_sentinels{ + {"host1", "1000"}, + {"host2", "2000"}, + {"host3", "3000"}, + }; + std::vector
sentinels{ + {"host1", "1000"}, + {"host2", "2000"}, + {"host3", "3000"}, + {"host4", "4000"}, + {"host5", "5000"}, + }; + const address new_sentinels[]{ + {"host2", "2000"}, + {"host4", "4000"}, + }; + + update_sentinel_list(sentinels, 0u, new_sentinels, initial_sentinels); + + const address expected_sentinels[]{ + {"host1", "1000"}, + {"host2", "2000"}, + {"host4", "4000"}, + {"host3", "3000"}, // bootstrap Sentinels placed last + }; + + BOOST_TEST_ALL_EQ( + sentinels.begin(), + sentinels.end(), + std::begin(expected_sentinels), + std::end(expected_sentinels)); +} + +} // namespace + +int main() +{ + test_single_sentinel(); + test_new_sentinels(); + test_new_sentinels_known(); + test_success_sentinel_not_first(); + test_new_sentinel_removed(); + test_bootstrap_sentinel_removed(); + + return boost::report_errors(); +} diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index 8c139362..c524dcf1 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -1,19 +1,140 @@ services: - redis: + redis-master: + container_name: redis-master image: ${SERVER_IMAGE} - entrypoint: "/docker/entrypoint.sh" + network_mode: host + command: > + sh -c 'chmod 777 /tmp/redis-socks && + redis-server \ + --replica-announce-ip localhost \ + --port 6379 \ + --tls-port 16379 \ + --tls-cert-file /docker/tls/server.crt \ + --tls-key-file /docker/tls/server.key \ + --tls-ca-cert-file /docker/tls/ca.crt \ + --tls-auth-clients no \ + --unixsocket /tmp/redis-socks/redis.sock \ + --unixsocketperm 777' volumes: - ./docker:/docker - /tmp/redis-socks:/tmp/redis-socks - ports: - - 6379:6379 - - 6380:6380 + + redis-replica-1: + container_name: redis-replica-1 + image: ${SERVER_IMAGE} + network_mode: host + command: + [ + "redis-server", + "--replica-announce-ip", "localhost", + "--replicaof", "localhost", "6379", + "--port", "6380", + "--tls-port", "16380", + "--tls-cert-file", "/docker/tls/server.crt", + "--tls-key-file", "/docker/tls/server.key", + "--tls-ca-cert-file", "/docker/tls/ca.crt", + "--tls-auth-clients", "no", + ] + volumes: + - ./docker:/docker + + + redis-replica-2: + container_name: redis-replica-2 + image: ${SERVER_IMAGE} + network_mode: host + command: + [ + "redis-server", + "--replica-announce-ip", "localhost", + "--replicaof", "localhost", "6379", + "--port", "6381", + "--tls-port", "16381", + "--tls-cert-file", "/docker/tls/server.crt", + "--tls-key-file", "/docker/tls/server.key", + "--tls-ca-cert-file", "/docker/tls/ca.crt", + "--tls-auth-clients", "no", + ] + volumes: + - ./docker:/docker + + + sentinel-1: + container_name: sentinel-1 + image: ${SERVER_IMAGE} + network_mode: host + command: > + sh -c 'cat << EOF > /etc/sentinel.conf && redis-sentinel /etc/sentinel.conf + port 26379 + tls-port 36379 + tls-cert-file /docker/tls/server.crt + tls-key-file /docker/tls/server.key + tls-ca-cert-file /docker/tls/ca.crt + tls-auth-clients no + sentinel resolve-hostnames yes + sentinel announce-hostnames yes + sentinel announce-ip localhost + sentinel monitor mymaster localhost 6379 2 + sentinel down-after-milliseconds mymaster 10000 + sentinel failover-timeout mymaster 10000 + sentinel parallel-syncs mymaster 1 + EOF' + volumes: + - ./docker:/docker + + + sentinel-2: + container_name: sentinel-2 + image: ${SERVER_IMAGE} + network_mode: host + command: > + sh -c 'cat << EOF > /etc/sentinel.conf && redis-sentinel /etc/sentinel.conf + port 26380 + tls-port 36380 + tls-cert-file /docker/tls/server.crt + tls-key-file /docker/tls/server.key + tls-ca-cert-file /docker/tls/ca.crt + tls-auth-clients no + sentinel resolve-hostnames yes + sentinel announce-hostnames yes + sentinel announce-ip localhost + sentinel monitor mymaster localhost 6379 2 + sentinel down-after-milliseconds mymaster 10000 + sentinel failover-timeout mymaster 10000 + sentinel parallel-syncs mymaster 1 + EOF' + volumes: + - ./docker:/docker + + sentinel-3: + container_name: sentinel-3 + image: ${SERVER_IMAGE} + network_mode: host + command: > + sh -c 'cat << EOF > /etc/sentinel.conf && redis-sentinel /etc/sentinel.conf + port 26381 + tls-port 36381 + tls-cert-file /docker/tls/server.crt + tls-key-file /docker/tls/server.key + tls-ca-cert-file /docker/tls/ca.crt + tls-auth-clients no + sentinel resolve-hostnames yes + sentinel announce-hostnames yes + sentinel announce-ip localhost + sentinel monitor mymaster localhost 6379 2 + sentinel down-after-milliseconds mymaster 10000 + sentinel failover-timeout mymaster 10000 + sentinel parallel-syncs mymaster 1 + EOF' + volumes: + - ./docker:/docker + + builder: - image: ${BUILDER_IMAGE} container_name: builder + image: ${BUILDER_IMAGE} + network_mode: host tty: true - environment: - - BOOST_REDIS_TEST_SERVER=redis volumes: - ../:/boost-redis - /tmp/redis-socks:/tmp/redis-socks diff --git a/tools/docker/entrypoint.sh b/tools/docker/entrypoint.sh deleted file mode 100755 index 318ea93c..00000000 --- a/tools/docker/entrypoint.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# The Redis container entrypoint. Runs the server with the required -# flags and makes the socket accessible - -set -e - -chmod 777 /tmp/redis-socks - -redis-server \ - --tls-port 6380 \ - --tls-cert-file /docker/tls/server.crt \ - --tls-key-file /docker/tls/server.key \ - --tls-ca-cert-file /docker/tls/ca.crt \ - --tls-auth-clients no \ - --unixsocket /tmp/redis-socks/redis.sock \ - --unixsocketperm 777