|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +nav-class: dark |
| 4 | +categories: ruben |
| 5 | +title: "Ready, Set, Redis!" |
| 6 | +author-id: ruben |
| 7 | +author-name: Rubén Pérez Hidalgo |
| 8 | +--- |
| 9 | + |
| 10 | +I'm happy to announce that I'm now a co-maintainer of [Boost.Redis](https://github.com/boostorg/redis), |
| 11 | +a high-level Redis client written on top of Asio, and hence sister of Boost.MySQL. |
| 12 | +I'm working with its author, Marcelo, to make it even better than what it is now |
| 13 | +(and that's a lot to say). |
| 14 | + |
| 15 | +First of all, we're working on improving test coverage. Boost.Redis was originally |
| 16 | +written making heavy use of `asio::async_compose`. If you have a |
| 17 | +JavaScript or Python background, this approach will feel natural to you, since it's similar |
| 18 | +to `async`/`await`. Unfortunately, this approach makes code difficult to test. |
| 19 | + |
| 20 | +For instance, consider `redis::connection::async_exec`, which enqueues a request to be |
| 21 | +executed by the Redis server and then waits for its response. This is a (considerably simplified) |
| 22 | +snippet of how this function could be implemented with `async_compose`: |
| 23 | + |
| 24 | +```cpp |
| 25 | +struct exec_op { |
| 26 | + connection* conn; |
| 27 | + std::shared_ptr<detail::request_info> info; // a request + extra info |
| 28 | + asio::coroutine coro{}; // a coroutine polyfill that uses switch/case statements |
| 29 | + |
| 30 | + // Will be called by Asio until self.complete() is called |
| 31 | + template <class Self> |
| 32 | + void operator()(Self& self , system::error_code = {}, std::size_t = 0) |
| 33 | + { |
| 34 | + BOOST_ASIO_CORO_REENTER(coro) |
| 35 | + { |
| 36 | + // Check whether the user wants to wait for the connection to |
| 37 | + // be stablished. |
| 38 | + if (info->req->get_config().cancel_if_not_connected && !conn->state.is_open) { |
| 39 | + BOOST_ASIO_CORO_YIELD asio::async_immediate(self.get_io_executor(), std::move(self)); |
| 40 | + return self.complete(error::not_connected, 0); |
| 41 | + } |
| 42 | + |
| 43 | + // Add the request to the queue |
| 44 | + conn->state.add_request(info); |
| 45 | + |
| 46 | + // Notify the writer task that there is a new request available |
| 47 | + conn->writer_timer.cancel(); |
| 48 | + |
| 49 | + while (true) { |
| 50 | + // Wait for the request to complete. We will be notified using a channel |
| 51 | + BOOST_ASIO_CORO_YIELD info->channel.async_wait(std::move(self)); |
| 52 | + |
| 53 | + // Are we done yet? |
| 54 | + if (info->is_done) { |
| 55 | + self.complete(info->ec, info->bytes_read); |
| 56 | + return; |
| 57 | + } |
| 58 | + |
| 59 | + // Check for cancellations |
| 60 | + if (self.get_cancellation_state().cancelled() != asio::cancellation_type_t::none) { |
| 61 | + // We can only honor cancellations if the request hasn't been sent to the server |
| 62 | + if (!info->request_sent()) { |
| 63 | + conn->state.remove_request(info); |
| 64 | + self.complete(asio::error::operation_aborted, 0); |
| 65 | + return; |
| 66 | + } else { |
| 67 | + // Can't cancel, keep waiting |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | +}; |
| 74 | + |
| 75 | +template <class CompletionToken, class Response> |
| 76 | +auto connection::async_exec(const request& req, Response& res, CompletionToken&& token) |
| 77 | +{ |
| 78 | + return asio::async_compose<CompletionToken, void(system::error_code, std::size_t)>( |
| 79 | + exec_op{this, detail::make_info(req, res)}, |
| 80 | + token, |
| 81 | + *this |
| 82 | + ); |
| 83 | +} |
| 84 | +``` |
| 85 | +
|
| 86 | +The snippet above contains non-trivial logic, specially regarding cancellation. |
| 87 | +While the code is understandable, it is difficult to test, |
| 88 | +since Asio doesn't include lots of testing utilities. |
| 89 | +The end result is usually untested code, prune to difficult to diagnose bugs. |
| 90 | +
|
| 91 | +As an alternative, we can refactor this code into two classes: |
| 92 | +
|
| 93 | +* A finite state machine object that encapsulates all logic. This should be a |
| 94 | + lightweight and should never interact with any Asio I/O object, so we can test |
| 95 | + logic easily. |
| 96 | +* A dumb `async_compose` function that just applies the actions mandated by the |
| 97 | + finite state machine. |
| 98 | +
|
| 99 | +The finite state machine for the above code could be like: |
| 100 | +
|
| 101 | +```cpp |
| 102 | +
|
| 103 | +// The finite state machine returns exec_action objects |
| 104 | +// communicating what should be done next so the algorithm can progress |
| 105 | +enum class exec_action_type |
| 106 | +{ |
| 107 | + notify_writer, // We should notify the writer task |
| 108 | + wait, // We should wait for the channel to be notified |
| 109 | + immediate, // We should invoke asio::async_immediate() to avoid recursion problems |
| 110 | + done, // We're done and should call self.complete() |
| 111 | +}; |
| 112 | +
|
| 113 | +struct exec_action |
| 114 | +{ |
| 115 | + exec_action_type type; |
| 116 | + error_code ec; // has meaning if type == exec_action_type::done |
| 117 | + std::size_t bytes_read{}; // has meaning if type == exec_action_type::done |
| 118 | +}; |
| 119 | +
|
| 120 | +// Contains all the algorithm logic. It is cheap to create and copy. |
| 121 | +// It is conceptually similar to a coroutine. |
| 122 | +class exec_fsm { |
| 123 | + asio::coroutine coro{}; |
| 124 | + std::shared_ptr<detail::request_info> info; // a request + extra info |
| 125 | +public: |
| 126 | + explicit exec_fsm(std::shared_ptr<detail::request_info> info) : info(std::move(info)) {} |
| 127 | + |
| 128 | + std::shared_ptr<detail::request_info> get_info() const { return info; } |
| 129 | +
|
| 130 | + // To run the algorithm, run the resume() function until it returns exec_action_type::done. |
| 131 | + exec_action resume( |
| 132 | + connection_state& st, // Contains connection state, but not any I/O objects |
| 133 | + asio::cancellation_type_t cancel_state // The cancellation state of the composed operation |
| 134 | + ) |
| 135 | + { |
| 136 | + BOOST_ASIO_REENTER(coro) |
| 137 | + { |
| 138 | + // Check whether the user wants to wait for the connection to |
| 139 | + // be stablished. |
| 140 | + if (info->req->get_config().cancel_if_not_connected && !st.is_open) { |
| 141 | + BOOST_ASIO_CORO_YIELD {exec_action_type::immediate}; |
| 142 | + return {exec_action_type::done, error::not_connected, 0}; |
| 143 | + } |
| 144 | +
|
| 145 | + // Add the request to the queue |
| 146 | + st.add_request(info); |
| 147 | +
|
| 148 | + // Notify the writer task that there is a new request available |
| 149 | + BOOST_ASIO_CORO_YIELD {exec_action_type::notify_writer}; |
| 150 | +
|
| 151 | + while (true) { |
| 152 | + // Wait for the request to complete. We will be notified using a channel |
| 153 | + BOOST_ASIO_CORO_YIELD {exec_action_type::wait}; |
| 154 | +
|
| 155 | + // Are we done yet? |
| 156 | + if (info->is_done) { |
| 157 | + return {exec_action_type::done, info->ec, info->bytes_read}; |
| 158 | + } |
| 159 | +
|
| 160 | + // Check for cancellations |
| 161 | + if (cancel_state != asio::cancellation_type_t::none) { |
| 162 | + // We can only honor cancellations if the request hasn't been sent to the server |
| 163 | + if (!info->request_sent()) { |
| 164 | + conn->state.remove_request(info); |
| 165 | + return {exec_action_type::done, asio::error::operation_aborted}; |
| 166 | + } else { |
| 167 | + // Can't cancel, keep waiting |
| 168 | + } |
| 169 | + } |
| 170 | + } |
| 171 | + } |
| 172 | + } |
| 173 | +}; |
| 174 | +``` |
| 175 | + |
| 176 | +`exec_op` no longer contains any logic: |
| 177 | + |
| 178 | +```cpp |
| 179 | +struct exec_op { |
| 180 | + connection* conn; |
| 181 | + exec_fsm fsm; |
| 182 | + |
| 183 | + // Will be called by Asio until self.complete() is called |
| 184 | + template <class Self> |
| 185 | + void operator()(Self& self, system::error_code = {}, std::size_t = 0) |
| 186 | + { |
| 187 | + // Call the FSM |
| 188 | + auto action = fsm.resume(conn->state, self.get_cancellation_state().cancelled()); |
| 189 | + |
| 190 | + // Apply the required action |
| 191 | + switch (action.type) |
| 192 | + { |
| 193 | + case exec_action_type::notify_writer: |
| 194 | + conn->writer_timer.cancel(); |
| 195 | + (*this)(self); // This action doesn't involve a callback, so invoke ourselves again |
| 196 | + break; |
| 197 | + case exec_action_type::wait: |
| 198 | + fsm.get_info()->channel.async_wait(std::move(self)); |
| 199 | + break; |
| 200 | + case exec_action_type::immediate: |
| 201 | + asio::async_immediate(self.get_io_executor(), std::move(self)); |
| 202 | + break; |
| 203 | + case exec_action_type::done: |
| 204 | + self.complete(action.ec, action.bytes_read); |
| 205 | + break; |
| 206 | + } |
| 207 | + } |
| 208 | +}; |
| 209 | +``` |
| 210 | + |
| 211 | +With this setup, `exec_fsm` is now trivial to test, since it doesn't invoke any I/O. |
| 212 | + |
| 213 | +We're migrating most of the algorithms towards this approach, and we're |
| 214 | +finding and fixing many subtle problems in the process. There is still lots to do, |
| 215 | +but efforts are already paying off. |
| 216 | + |
| 217 | +## Boost.Redis features and docs |
| 218 | + |
| 219 | +While I tend to get very excited about this new sans-io approach, I've also |
| 220 | +made other contributions that had likely had more impact on users. For instance, |
| 221 | +I've implemented support for UNIX sockets, which had been something recurrently |
| 222 | +requested by users that want to squeeze the last bit of performance from their setup. |
| 223 | + |
| 224 | +I've also worked on logging. Since the reconnection algorithm is complex, Boost.Redis |
| 225 | +logs some messages by default to simplify diagnostics. This is now performed through |
| 226 | +a simple, extensible and well-documented API, allowing users to integrate third-party |
| 227 | +logging libraries like `spdlog`. |
| 228 | + |
| 229 | +Last (but not least), I've migrated Boost.Redis docs to the new asciidoc/antora/mrdocs |
| 230 | +toolchain. I'm pretty impressed with [the results](https://www.boost.org/doc/libs/develop/libs/redis/doc/html/redis/index.html), |
| 231 | +and would like to thank the MrDocs and Boostlook people for their efforts. |
| 232 | + |
| 233 | +I know that comparisons are odious, but... |
| 234 | + |
| 235 | +* [Old docs for `basic_connection`](https://www.boost.org/doc/libs/1_88_0/libs/redis/doc/html/classboost_1_1redis_1_1basic__connection.html). |
| 236 | +* [New docs for `basic_connection`](https://www.boost.org/doc/libs/develop/libs/redis/doc/html/redis/reference/boost/redis/basic_connection.html). |
| 237 | + |
| 238 | +## A great MySQL comes with a great responsibility |
| 239 | + |
| 240 | +It's not all been Redis these days. Boost.MySQL users also deserve some attention. |
| 241 | +I've rewritten the MySQL handshake algorithm, so the `caching_sha2_password` plugin |
| 242 | +can work without TLS. |
| 243 | + |
| 244 | +For most of you, the sentence above will likely say nothing, so let's provide some context. |
| 245 | +When a client connects to the MySQL server, it performs a connection establishment packet |
| 246 | +exchange where several connection parameters are negotiated, the client is authenticated, |
| 247 | +and the TLS layer is optionally installed. This is collectively called the MySQL handshake, |
| 248 | +and it's not very clean in design. |
| 249 | + |
| 250 | +Clients can authenticate using several authentication mechanisms, called authentication |
| 251 | +plugins. The most widespread one is `mysql_native_password`, a challenge/response mechanism |
| 252 | +where the client sends a hashed password to the server. It doesn't require a TLS layer, |
| 253 | +and it's supported by MySQL 5.x, MySQL 8.x and MariaDB. |
| 254 | + |
| 255 | +The problem with `mysql_native_password` is that it uses `SHA1`, which is considered nowadays weak. |
| 256 | +MySQL 8.x introduced `caching_sha2_password` and deprecated `mysql_native_password`, and MySQL 9.x |
| 257 | +has removed the latter. `caching_sha2_password` uses `SHA256`, but it also introduces a cache in the server. |
| 258 | +If your user is in the cache, the password is sent hashed, as with `mysql_native_password`. |
| 259 | +But if it's not, things get more complex: |
| 260 | + |
| 261 | +* When using a TLS layer, the password is sent in plain text. |
| 262 | +* When not using a TLS layer, the server supplies an RSA key, and the password is sent encrypted using it. |
| 263 | + |
| 264 | +I never got to implement the second point, since most people were just using |
| 265 | +the simpler `mysql_native_password`. With the advent of MySQL 9, this was becoming a problem, |
| 266 | +since it meant using TLS even for local network connections (like the ones between Docker containers), |
| 267 | +with the overhead it implies. |
| 268 | + |
| 269 | +Implementing this new exchange has required a big refactor and many tests, |
| 270 | +but it has paid off, as it unravelled some buggy edge-cases. |
| 271 | +Remember the `async_compose` vs sans-io discussion at the beginning of this post? |
| 272 | +For such complex exchanges, going sans-io has been key. |
| 273 | + |
| 274 | +## Three databases are better than two |
| 275 | + |
| 276 | +Why MySQL and not Postgres? Well, I found myself asking this question, too. |
| 277 | +Following what I've learnt with Boost.MySQL, I'm writing a new library |
| 278 | +to interact with Postgres. It's not usable yet, but it's made some |
| 279 | +progress. You can check it out [here](https://github.com/anarthal/nativepg). |
| 280 | + |
| 281 | +## New Boost citizens: OpenMethod and Bloom |
| 282 | + |
| 283 | +I've also had the pleasure to participate in the review of two wonderful |
| 284 | +libraries that have been accepted into Boost: [OpenMethod](https://github.com/jll63/Boost.OpenMethod), |
| 285 | +which allows defining virtual functions outside classes; and [Bloom](https://github.com/boostorg/bloom), |
| 286 | +which implements Bloom filters. The family keeps growing. |
| 287 | + |
| 288 | +## C++20 modules and Boost |
| 289 | + |
| 290 | +I'm happy to see some more Boost authors adding support for C++20 modules |
| 291 | +in their libraries. Concretely, I've reviewed PRs for Boost.Pfr and Boost.Any. |
| 292 | + |
| 293 | +This is really exciting for me, and I hope to be able to dedicate some time soon |
| 294 | +to progress my C++20 prototype for Boost.Core and Boost.Mp11. |
| 295 | + |
| 296 | +## Lightweight test context |
| 297 | + |
| 298 | +Boost.Core contains a small component to write unit tests: the [lightweight test](https://live.boost.org/doc/libs/master/libs/core/doc/html/core/lightweight_test.html) |
| 299 | +framework. It's extremely simple, and that makes it fast, both at runtime and compile-time. |
| 300 | + |
| 301 | +It's sometimes too simple. I'm a big fan of parametric tests, where you run a test case |
| 302 | +over a set of different values. You can do so with lightweight test by just using a loop, |
| 303 | +but that makes failures difficult to diagnose. |
| 304 | + |
| 305 | +I'm implementing an equivalent to [`BOOST_TEST_CONTEXT`](https://live.boost.org/doc/libs/1_88_0/libs/test/doc/html/boost_test/test_output/test_tools_support_for_logging/contexts.html) |
| 306 | +for lightweight test. I'm still on the middle of it, so I'll dive deeper onto this |
| 307 | +in my next post. All I can say is that this addition makes lightweight test |
| 308 | +a perfect fit for most of my testing needs! |
| 309 | + |
| 310 | +## Next steps |
| 311 | + |
| 312 | +It looks like databases, Asio and modules are definitely part of my future. |
| 313 | +So many exciting things that I sometimes struggle to decide on which one to focus! |
0 commit comments