├── .formatter.exs ├── .github ├── FUNDING.yml └── workflows │ ├── main.yml │ └── publish-to-hex.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── benchmarks └── protocol.exs ├── docker-compose.yml ├── lib ├── redix.ex └── redix │ ├── connection.ex │ ├── connector.ex │ ├── exceptions.ex │ ├── format.ex │ ├── protocol.ex │ ├── pubsub.ex │ ├── pubsub │ └── connection.ex │ ├── socket_owner.ex │ ├── start_options.ex │ ├── telemetry.ex │ └── uri.ex ├── mix.exs ├── mix.lock ├── pages ├── Real-world usage.md ├── Reconnections.md └── Telemetry.md └── test ├── docker ├── base_with_acl │ ├── Dockerfile │ ├── acl.conf │ └── redis.conf ├── base_with_disallowed_client_command │ ├── Dockerfile │ └── redis.conf ├── sentinel │ ├── Dockerfile │ ├── sentinel.conf │ └── start.sh └── sentinel_with_auth │ ├── Dockerfile │ ├── sentinel.conf │ └── start.sh ├── redix ├── connection_error_test.exs ├── format_test.exs ├── protocol_test.exs ├── pubsub_test.exs ├── sentinel_test.exs ├── start_options_test.exs ├── stateful_properties │ ├── pubsub_properties_test.exs │ └── redix_properties_test.exs ├── telemetry_test.exs └── uri_test.exs ├── redix_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "mix.exs", 4 | "lib/**/*.ex", 5 | "test/**/*.exs", 6 | "benchmarks/**/*.exs", 7 | ], 8 | import_deps: [:stream_data] 9 | ] 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: whatyouhide 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Test (Elixir ${{matrix.elixir}} | Erlang/OTP ${{matrix.otp}}) 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - otp: "27.2" 18 | elixir: "1.18" 19 | dialyzer: trueos 20 | lint: true 21 | 22 | - otp: "25.3" 23 | elixir: "1.14.3" 24 | coverage: true 25 | 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | MIX_ENV: test 29 | 30 | steps: 31 | - name: Clone the repository 32 | uses: actions/checkout@v4 33 | 34 | - name: Start Docker 35 | run: docker compose up --detach 36 | 37 | - name: Install OTP and Elixir 38 | uses: erlef/setup-beam@v1 39 | with: 40 | otp-version: ${{ matrix.otp }} 41 | elixir-version: ${{ matrix.elixir }} 42 | 43 | - name: Cache dependencies 44 | id: cache-deps 45 | uses: actions/cache@v3 46 | with: 47 | path: | 48 | deps 49 | _build 50 | key: ${{ runner.os }}-mix-otp${{ matrix.otp }}-elixir${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 51 | 52 | - name: Fetch dependencies and verify mix.lock 53 | if: steps.cache-deps.outputs.cache-hit != 'true' 54 | run: mix deps.get --check-locked 55 | 56 | - name: Compile dependencies 57 | if: steps.cache-deps.outputs.cache-hit != 'true' 58 | run: mix deps.compile 59 | 60 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 61 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 62 | - name: Cache Dialyzer's PLT 63 | if: ${{ matrix.dialyzer }} 64 | uses: actions/cache@v3 65 | id: cache-plt 66 | with: 67 | path: plts 68 | key: | 69 | plt-${{ runner.os }}-otp${{ matrix.otp }}-elixir${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 70 | restore-keys: | 71 | plt-${{ runner.os }}-otp${{ matrix.otp }}-elixir${{ matrix.elixir }}- 72 | 73 | # Create PLTs if no cache was found 74 | - name: Create PLTs 75 | if: steps.cache-plt.outputs.cache-hit != 'true' && matrix.dialyzer 76 | run: | 77 | mkdir -p plts 78 | mix dialyzer --plt 79 | 80 | - name: Check formatting 81 | run: mix format --check-formatted 82 | if: ${{ matrix.lint }} 83 | 84 | - name: Check no unused dependencies 85 | run: mix do deps.get, deps.unlock --check-unused 86 | if: ${{ matrix.lint == 'true' && steps.cache-deps.outputs.cache-hit != 'true' }} 87 | 88 | - name: Compile with --warnings-as-errors 89 | run: mix compile --warnings-as-errors 90 | if: ${{ matrix.lint }} 91 | 92 | - name: Run tests 93 | run: mix test --trace --exclude propcheck 94 | if: ${{ !matrix.coverage }} 95 | 96 | - name: Run tests with coverage 97 | run: mix coveralls.github 98 | if: ${{ matrix.coverage }} 99 | 100 | - name: Run dialyzer 101 | run: mix dialyzer --format github 102 | if: ${{ matrix.dialyzer }} 103 | 104 | - name: Dump Docker logs on failure 105 | uses: jwalton/gh-docker-logs@v1 106 | if: failure() 107 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-hex.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Hex 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | name: Publish to Hex 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout this repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Install Erlang and Elixir 19 | uses: erlef/setup-beam@v1 20 | with: 21 | otp-version: "27.2" 22 | elixir-version: "1.18" 23 | 24 | - name: Fetch and compile dependencies 25 | run: mix do deps.get + deps.compile 26 | 27 | - name: Publish to Hex 28 | run: mix hex.publish --yes 29 | env: 30 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | /bench/snapshots/ 6 | /bench/graphs/index.html 7 | /cover 8 | /doc 9 | /plts 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.5.2 4 | 5 | ### Bug fixes 6 | 7 | * Fix a bug with `Redix.transaction_pipeline/2`, which would return `{:ok, Redix.Error.t()}` in some cases. Those cases now return `{:error, Redix.Error.t()}`, which is what was documented in the spec. 8 | * Fix an issue with sentinels reporting their peers with IPv4 or IPv6 addresses. In these cases, Redix wouldn't be able to connect—that has been fixed. 9 | 10 | ## v1.5.1 11 | 12 | ### Bug fixes 13 | 14 | * Fix a race condition that would cause connections to stop and not reconnect in cases where the network would fail *after* establishing the connection but *before* issuing potential `AUTH` or `SELECT` commands. This is a recommended upgrade for everyone. 15 | 16 | ## v1.5.0 17 | 18 | ### New features 19 | 20 | * Add support for the `valkey://` scheme when using URIs. 21 | 22 | ## v1.4.2 23 | 24 | ### Bug fixes and improvements 25 | 26 | * Speed up `Redix.Protocol` a little bit for common responses (`"OK"` and friends). 27 | * Fix a bug where `:tcp_closed`/`:ssl_closed` and `:tcp_error`/`:ssl_error` messages wouldn't arrive to the socket owner, and Redix would get stuck in a disconnected state when sending would error out. See the discussion in [#265](https://github.com/whatyouhide/redix/issues/265). 28 | 29 | ## v1.4.1 30 | 31 | ### Bug fixes and improvements 32 | 33 | * `Redix.PubSub.get_client_id/1` is not available only behind the `:fetch_client_id_on_connect` option that you can pass to `Redix.PubSub.start_link/1`. This option defaults to `false`, so that this version of Redix is compatible with Redis v4 or earlier out of the box. To opt in into the behavior desired for [client-side caching](https://redis.io/docs/manual/client-side-caching/) and use `Redix.PubSub.get_client_id/1`, pass `fetch_client_id_on_connect: true` to `Redix.PubSub.start_link/1`. 34 | 35 | ## v1.4.0 36 | 37 | ### Bug fixes and improvements 38 | 39 | * Introduce `Redix.PubSub.get_client/1`, which can be used to implement [client-side caching](https://redis.io/docs/manual/client-side-caching/). 40 | 41 | ## v1.3.0 42 | 43 | ### Bug fixes and improvements 44 | 45 | * Improve EXITs that happen during calls to Redix functions. 46 | * Remove call to deprecated `Logger.warn/2`. 47 | * Support MFA for `:password` in the `:sentinel` option. 48 | * Add the `Redix.password/0` type. 49 | * Add the `Redix.sentinel_role/0` type. 50 | 51 | ## v1.2.4 52 | 53 | ### Bug fixes and improvements 54 | 55 | * Remove Dialyzer PLTs from the Hex package. This has no functional impact whatsoever on the library. The PLTs were accidentally published together with the Hex package, which just results in an unnecessarily large Hex package. 56 | 57 | ## v1.2.3 58 | 59 | ### Bug fixes and improvements 60 | 61 | * Fix a bug with validating the `:socket_opts` option, which required a keyword list and thus wouldn't support *valid* options such as `:inet6`. 62 | 63 | ## v1.2.2 64 | 65 | ### Bug fixes and improvements 66 | 67 | * Make parsing large bulk strings *a lot* faster. See [the pull request](https://github.com/whatyouhide/redix/pull/247) for benchmarks. This causes no functional changes, just a speed improvement. 68 | 69 | ## v1.2.1 70 | 71 | ### Bug fixes and improvements 72 | 73 | * Relaxed the version requirement for the `:castore` dependency to support `~> 1.0`. 74 | 75 | ## v1.2.0 76 | 77 | ### New features 78 | 79 | * Add `:telemetry_metadata` option to Redis calls. This can be used to provide custom metadata for Telemetry events. 80 | * Mark **Redis sentinel** support as *not*-experimental anymore. 81 | * Make `Redix.URI` part of the public API. 82 | 83 | ### Bug fixes and improvements 84 | 85 | * Handle Redis servers that disable the `CLIENT` command. 86 | * Bump Elixir requirement to 1.11+. 87 | * Raise an error if the `:timeout` option (supported by many of the function in the `Redix` module) is something other than a non-negative integer or `:infinity`. Before, `timeout: nil` was accidentally supported (but not documented) and would use a default timeout. 88 | 89 | ## v1.1.5 90 | 91 | ### Bug fixes and improvements 92 | 93 | * Fix formatting of Unix domain sockets when logging 94 | * Use `Logger` instead of `IO.warn/2` when warning about ACLs, so that it can be silenced more easily. 95 | * Allow the `:port` option to be set explicitly to `0` when using Unix domain sockets 96 | * Support empty string as database when using Redis URIs due to changes to how URIs are handled in Elixir 97 | 98 | ## v1.1.4 99 | 100 | ### Bug fixes and improvements 101 | 102 | * Support version 1.0 and over for the Telemetry dependency. 103 | 104 | ## v1.1.3 105 | 106 | ### Bug fixes and improvements 107 | 108 | * The `.formatter.exs` file included in this repo had some filesystem permission problems. This version fixes those. 109 | 110 | ## v1.1.2 111 | 112 | Version v1.1.1 was accidentally published with local code (from the maintainer's machine) in it instead of the code from the main Git branch. We're all humans! Version v1.1.1 has been retired. 113 | 114 | ## v1.1.1 115 | 116 | ### Bug fixes and improvements 117 | 118 | * Version v1.1.0 started using ACLs and issuing `AUTH ` when a username was provided (either via options or via URI). This broke previous documented behavior, where Redix used to ignore usernames. With this bug fix, Redix now falls back to `AUTH ` if `AUTH ` fails because of the wrong number of arguments, which indicates a version of Redis earlier than version 6 (when ACLs were introduced). 119 | 120 | ## v1.1.0 121 | 122 | ### Bug fixes and improvements 123 | 124 | * Improve handling of databases in URIs. 125 | * Add support for [ACL](https://redis.io/topics/acl), introduced in Redis 6. 126 | 127 | ## v1.0.0 128 | 129 | No bug fixes or improvements. Just enough years passed for this to become 1.0.0! 130 | 131 | ## v0.11.2 132 | 133 | ### Bug fixes and improvements 134 | 135 | * Fix a connection process crash that would very rarely happen when connecting to sentinel nodes with the wrong password or wrong database would fail to due a TCP/SSL connection issue. 136 | 137 | ## v0.11.1 138 | 139 | ### Bug fixes and improvements 140 | 141 | * Allow `nil` as a valid value for the `:password` start option again. v0.11.0 broke this feature. 142 | 143 | ## v0.11.0 144 | 145 | ### Breaking changes 146 | 147 | * Use the new Telemetry event conventions for pipeline-related events. The new events are `[:redix, :pipeline, :start]` and `[:redix, :pipeline, :stop]`. They both have new measurements associated with them. 148 | * Remove the `[:redix, :reconnection]` Telemetry event in favor or `[:redix, :connection]`, which is emitted anytime there's a successful connection to a Redis server. 149 | * Remove support for the deprecated `:log` start option (which was deprecated on v0.10.0). 150 | 151 | ### Bug fixes and improvements 152 | 153 | * Add the `:connection_metadata` name to all connection/disconnection-related Telemetry events. 154 | * Allow a `{module, function, arguments}` tuple as the value of the `:password` start option. This is useful to avoid password leaks in case of process crashes (and crash reports). 155 | * Bump minimum Elixir requirement to Elixir `~> 1.7`. 156 | 157 | ## v0.10.7 158 | 159 | ### Bug fixes and improvements 160 | 161 | * Fix a crash in `Redix.PubSub` when non-subscribed processes attempted to unsubscribe. 162 | 163 | ## v0.10.6 164 | 165 | ### Bug fixes and improvements 166 | 167 | * Fix a bug that caused a memory leak in some cases for Redix pub/sub connections. 168 | 169 | ## v0.10.5 170 | 171 | ### Bug fixes and improvements 172 | 173 | * Fix default option replacement for SSL in OTP 22.2. 174 | * Allow `:gen_statem.start_link/3,4` options in `Redix.start_link/2` and `Redix.PubSub.start_link/2`. 175 | * Change default SSL depth from 2 to 3 (see [this issue](https://github.com/whatyouhide/redix/issues/162)). 176 | 177 | ## v0.10.4 178 | 179 | ### Bug fixes and improvements 180 | 181 | * Fix the default Telemetry handler for Redis Sentinel events (wasn't properly fixed in v0.10.3). 182 | * Fix a compile-time warning about the [castore](https://github.com/elixir-mint/castore) library. 183 | 184 | ## v0.10.3 185 | 186 | ### Bug fixes and improvements 187 | 188 | * Use more secure SSL default options and optionally use [castore](https://github.com/elixir-mint/castore) if available as a certificate store. 189 | * Fix the default Telemetry handler for Redis Sentinel events. 190 | 191 | ## v0.10.2 192 | 193 | ### Bug fixes and improvements 194 | 195 | * Allow a discarded username when using Redis URIs. 196 | * Fix the `Redix.command/0` type which was `[binary()]` but which should have been `[String.Chars.t()]` since we call `to_string/1` on each command. 197 | 198 | ## v0.10.1 199 | 200 | ### Bug fixes and improvements 201 | 202 | * Improve password checking in Redis URIs. 203 | * Fix a bug when naming Redix connections with something other than a local name. 204 | 205 | ## v0.10.0 206 | 207 | ### Bug fixes and improvements 208 | 209 | * Add support for [Telemetry](https://github.com/beam-telemetry/telemetry) and publish the following events: `[:redix, :pipeline]`, `[:redix, :pipeline, :error]`, `[:redix, :disconnection]`, `[:redix, :reconnection]`, `[:redix, failed_connection]`. 210 | * Deprecate the `:log` option in `Redix.start_link/1` and `Redix.PubSub.start_link/1` in favour of Telemetry events and a default log handler that can be activated with `Redix.Telemetry.attach_default_handler/0`. See the documentation for [`Redix.Telemetry`](https://hexdocs.pm/redix/0.10.0/Redix.Telemetry.html). This is a hard deprecation that shows a warning. Support for the `:log` option will be removed in the next version. 211 | * Fix a few minor bugs in `Redix.PubSub`. 212 | 213 | ## v0.9.3 214 | 215 | ### Bug fixes and improvements 216 | 217 | * Fix a bug related to quickly reconnecting PIDs in `Redix.PubSub`. 218 | * Improve error messages here and there. 219 | 220 | ## v0.9.2 221 | 222 | ### Bug fixes and improvements 223 | 224 | * Add support for URLs with the `rediss` scheme. 225 | * Fix a bug where we used the wrong logging level in some places. 226 | 227 | ## v0.9.1 228 | 229 | ### Bug fixes and improvements 230 | 231 | * Fix a bad return type from a `gen_statem` callback (#120). 232 | * Improve logging for Redis Sentinel. 233 | 234 | ## v0.9.0 235 | 236 | ### Breaking changes 237 | 238 | * Bring `Redix.PubSub` into Redix. Pub/Sub functionality lived in a separate library, [redix_pubsub](https://github.com/whatyouhide/redix_pubsub). Now, that functionality has been moved into Redix. This means that if you use redix_pubsub and upgrade your Redix version to 0.9, you will use the redix_pubsub version of `Redix.PubSub`. If you also upgrade your redix_pubsub version, redix_pubsub will warn and avoid compiling `Redix.PubSub` so you can use the latest version in Redix. In general, if you upgrade Redix to 0.9 or later just drop the redix_pubsub dependency and make sure your application works with the latest `Redix.PubSub` API (the message format changed slightly in recent versions). 239 | 240 | * Add support for Redis Sentinel. 241 | 242 | * Don't raise `Redix.Error` errors on non-bang variants of functions. This means that for example `Redix.command/3` won't raise a `Redix.Error` exception in case of Redis errors (like wrong typing) and will return that error instead. In general, if you're pattern matching on `{:error, _}` to handle **connection errors** (for example, to retry after a while), now specifically match on `{:error, %Redix.ConnectionError{}}`. If you want to handle all possible errors the same way, keep matching on `{:error, _}`. 243 | 244 | ### Bug fixes and improvements 245 | 246 | * Fix a bug that wouldn't let you use Redis URIs without host or port. 247 | * Don't ignore the `:timeout` option when connecting to Redis. 248 | 249 | ## v0.8.2 250 | 251 | ### Bug fixes and improvements 252 | 253 | * Fix an error when setting up SSL buffers (#106). 254 | 255 | ## v0.8.1 256 | 257 | ### Bug fixes and improvements 258 | 259 | * Re-introduce `start_link/2` with two lists of options, but deprecate it. It will be removed in the next Redix version. 260 | 261 | ## v0.8.0 262 | 263 | ### Breaking changes 264 | 265 | * Drop support for Elixir < 1.6. 266 | 267 | * Unify `start_link` options: there's no more separation between "Redis options" and "connection options". Now, all the options are passed in together. You can still pass a Redis URI as the first argument. This is a breaking change because now calling `start_link/2` with two kewyord lists breaks. Note that `start_link/2` with two keyword lists still works, but emits a warning and is deprecated. 268 | 269 | ### Bug fixes and improvements 270 | 271 | * Rewrite the connection using [`gen_statem`](http://erlang.org/doc/man/gen_statem.html) in order to drop the dependency to [Connection](https://github.com/fishcakez/connection). 272 | 273 | * Add `Redix.transaction_pipeline/3` and `Redix.transaction_pipeline!/3`. 274 | 275 | * Use a timeout when connecting to Redis (which sometimes could get stuck). 276 | 277 | * Add support for SSL 🔐 278 | 279 | * Add `Redix.noreply_command/3` and `Redix.noreply_pipeline/3` (plus their bang `!` variants). 280 | 281 | ## v0.7.1 282 | 283 | * Add support for Unix domain sockets by passing `host: {:local, path}`. 284 | 285 | ## v0.7.0 286 | 287 | ### Breaking changes 288 | 289 | * Drop support for Elixir < 1.3. 290 | 291 | * Remove `Redix.format_error/1`. 292 | 293 | ### Bug fixes and improvements 294 | 295 | * Add `Redix.child_spec/1` for use with the child spec changes in Elixir 1.5. 296 | 297 | ## v0.6.1 298 | 299 | * Fix some deprecation warnings around `String.to_char_list/1`. 300 | 301 | ## v0.6.0 302 | 303 | ### Breaking changes 304 | 305 | * Start using `Redix.ConnectionError` when returning errors instead of just an atom. This is a breaking change since now `Redix.command/2` and the other functions return `{:error, %Redix.ConnectionError{reason: reason}}` instead of `{:error, reason}`. If you're matching on specific error reasons, make sure to update your code; if you're formatting errors through `Redix.format_error/1`, you can now use `Exception.message/1` on the `Redix.ConnectionError` structs. 306 | 307 | ## v0.5.2 308 | 309 | * Fix some TCP error handling during the connection setup phase. 310 | 311 | ## v0.5.1 312 | 313 | * Fix `Redix.stop/1` to be synchronous and not leave zombie processes. 314 | 315 | ## v0.5.0 316 | 317 | * Drop support for Elixir < 1.2 and OTP 17 or earlier. 318 | 319 | ## v0.4.0 320 | 321 | * Add [@lexmag](https://github.com/lexmag) to the maintainers :tada: 322 | 323 | * Handle timeouts nicely by returning `{:error, :timeout}` instead of exiting (which is the default `GenServer` behaviour). 324 | 325 | * Remove support for specifying a maximum number of reconnection attempts when connecting to Redis (it was the `:max_reconnection_attempts` option). 326 | 327 | * Use exponential backoff when reconnecting. 328 | 329 | * Don't reconnect right away after the connection to Redis is lost, but wait for a cooldown time first. 330 | 331 | * Add support for `:backoff_initial` and `:backoff_max` options in `Redix.start_link/2`. These options are used for controlling the backoff behaviour of a `Redix` connection. 332 | 333 | * Add support for the `:sync_connect` option when connecting to Redis. 334 | 335 | * Add support for the `:exit_on_disconnection` option when connecting to Redis. 336 | 337 | * Add support for the `:log` option when connecting to Redis. 338 | 339 | * Raise `ArgumentError` exceptions instead of `Redix.ConnectionError` exceptions for stuff like empty commands. 340 | 341 | * Raise `Redix.Error` exceptions from `Redix.command/3` instead of returning them wrapped in `{:error, _}`. 342 | 343 | * Expose `Redix.format_error/1`. 344 | 345 | * Add a "Reconnections" page in the documentation. 346 | 347 | * Extract the Pub/Sub functionality into [a separate project](https://github.com/whatyouhide/redix_pubsub). 348 | 349 | ## v0.3.6 350 | 351 | * Fixed a bug in the integer parsing in `Redix.Protocol`. 352 | 353 | ## v0.3.5 354 | 355 | * `Redix.Protocol` now uses continuations under the hood for a faster parsing experience. 356 | 357 | * A bug in `Redix.Protocol` that caused massive memory leaks was fixed. This bug originated upstream in Elixir itself, and I submitted a fix for it [here](https://github.com/elixir-lang/elixir/pull/4350). 358 | 359 | * Some improvements were made to error reporting in the Redix logging. 360 | 361 | ## v0.3.4 362 | 363 | * Fix a bug in the connection that was replacing the provided Redis password with `:redacted` upon successful connection, making it impossible to reconnect in case of failure (because of the original password now being unavailable). 364 | 365 | ## v0.3.3 366 | 367 | * Fix basically the same bug that was almost fixed in `v0.3.2`, but this time for real! 368 | 369 | ## v0.3.2 370 | 371 | * Fix a bug in the protocol that failed to parse integers in some cases. 372 | 373 | ## v0.3.1 374 | 375 | * Restructure the Redix architecture to use two Elixir processes per connection instead of one (a process that packs commands and sends them on the socket and a process that listens from the socket and replies to waiting clients); this should speed up Redix when it comes to multiple clients concurrently issuing requests to Redis. 376 | 377 | ## v0.3.0 378 | 379 | ### Breaking changes 380 | 381 | * Change the behaviour for an empty list of command passed to `Redix.pipeline/2` (`Redix.pipeline(conn, [])`), which now raises a `Redix.ConnectionError` complaining about the empty command. Before this release, the behaviour was just a connection timeout. 382 | 383 | * Change the behaviour of empty commands passed to `Redix.command/2` or `Redix.pipeline/2` (for example, `Redix.command(conn, [])` or `Redix.pipeline(conn, [["PING"], []])`); empty commands now return `{:error, :empty_command}`. The previous behaviour was just a connection timeout. 384 | 385 | * Remove `Redix.start_link/1` in favour of just `Redix.start_link/2`: now Redis options are separated from the connection options. Redis options can be passed as a Redis URI as well. 386 | 387 | ### Bug fixes and improvements 388 | 389 | * Change the error messages for most of the `Redix.ConnectionError` exceptions from simple atoms to more meaningful messages. 390 | 391 | ## v0.2.1 392 | 393 | * Fix a bug with single-element lists, that were parsed as single elements (and not lists with a single element in them) by `Redix.Protocol.parse_multi/2`. See [whatyouhide/redix#11](https://github.com/whatyouhide/redix/issues/11). 394 | 395 | ## v0.2.0 396 | 397 | * Rename `Redix.NetworkError` to `Redix.ConnectionError` (as it's more generic and more flexible). 398 | 399 | * Add support for PubSub. The following functions have been added to the `Redix` module: 400 | * `Redix.subscribe/4` 401 | * `Redix.subscribe!/4` 402 | * `Redix.psubscribe/4` 403 | * `Redix.psubscribe!/4` 404 | * `Redix.unsubscribe/4` 405 | * `Redix.unsubscribe!/4` 406 | * `Redix.punsubscribe/4` 407 | * `Redix.punsubscribe!/4` 408 | * `Redix.pubsub?/2` 409 | 410 | ## v0.1.0 411 | 412 | Initial release. 413 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andrea Leopardi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redix 2 | 3 | [![hex.pm badge](https://img.shields.io/badge/Package%20on%20hex.pm-informational)](https://hex.pm/packages/redix) 4 | [![Documentation badge](https://img.shields.io/badge/Documentation-ff69b4)][docs] 5 | [![CI](https://github.com/whatyouhide/redix/actions/workflows/main.yml/badge.svg)](https://github.com/whatyouhide/redix/actions/workflows/main.yml) 6 | [![Coverage Status](https://coveralls.io/repos/github/whatyouhide/redix/badge.svg?branch=main)](https://coveralls.io/github/whatyouhide/redix?branch=main) 7 | 8 | > Fast, pipelined, resilient Redis client for Elixir. 9 | 10 | ![DALL·E Golden Retriever](https://github.com/user-attachments/assets/cd7f8c7a-49ba-46e5-8d35-2b0fa371fdb9) 11 | 12 | Redix is a [Redis][redis] and [Valkey][valkey] client written in pure Elixir with focus on speed, correctness, and resiliency (that is, being able to automatically reconnect to Redis in case of network errors). 13 | 14 | This README refers to the main branch of Redix, not the latest released version on Hex. Make sure to check [the documentation][docs] for the version you're using. 15 | 16 | ## Features 17 | 18 | * Idiomatic interface for sending commands to Redis 19 | * Pipelining 20 | * Resiliency (automatic reconnections) 21 | * Pub/Sub 22 | * SSL 23 | * Redis Sentinel 24 | 25 | ## Installation 26 | 27 | Add the `:redix` dependency to your `mix.exs` file. If you plan on connecting to a Redis server [over SSL][docs-ssl] you may want to add the optional [`:castore`][castore] dependency as well: 28 | 29 | ```elixir 30 | defp deps do 31 | [ 32 | {:redix, "~> 1.1"}, 33 | {:castore, ">= 0.0.0"} 34 | ] 35 | end 36 | ``` 37 | 38 | Then, run `mix deps.get` in your shell to fetch the new dependencies. 39 | 40 | ## Usage 41 | 42 | Redix is simple: it doesn't wrap Redis commands with Elixir functions. It only provides functions to send any Redis command to the Redis server. A Redis *command* is expressed as a list of strings making up the command and its arguments. 43 | 44 | Connections are started via `start_link/0,1,2`: 45 | 46 | ```elixir 47 | {:ok, conn} = Redix.start_link(host: "example.com", port: 5000) 48 | {:ok, conn} = Redix.start_link("redis://localhost:6379/3", name: :redix) 49 | ``` 50 | 51 | Commands can be sent using `Redix.command/2,3`: 52 | 53 | ```elixir 54 | Redix.command(conn, ["SET", "mykey", "foo"]) 55 | #=> {:ok, "OK"} 56 | Redix.command(conn, ["GET", "mykey"]) 57 | #=> {:ok, "foo"} 58 | ``` 59 | 60 | Pipelines are just lists of commands sent all at once to Redis for which Redis replies with a list of responses. They can be used in Redix via `Redix.pipeline/2,3`: 61 | 62 | ```elixir 63 | Redix.pipeline(conn, [["INCR", "foo"], ["INCR", "foo"], ["INCRBY", "foo", "2"]]) 64 | #=> {:ok, [1, 2, 4]} 65 | ``` 66 | 67 | `Redix.command/2,3` and `Redix.pipeline/2,3` always return `{:ok, result}` or `{:error, reason}`. If you want to access the result directly and raise in case there's an error, bang! variants are provided: 68 | 69 | ```elixir 70 | Redix.command!(conn, ["PING"]) 71 | #=> "PONG" 72 | 73 | Redix.pipeline!(conn, [["SET", "mykey", "foo"], ["GET", "mykey"]]) 74 | #=> ["OK", "foo"] 75 | ``` 76 | 77 | #### Resiliency 78 | 79 | Redix is resilient against network errors. For example, if the connection to Redis drops, Redix will automatically try to reconnect periodically at a given "backoff" interval. Look at the documentation for the `Redix` module and at the ["Reconnections" page][docs-reconnections] in the documentation for more information on the available options and on the exact reconnection behaviour. 80 | 81 | #### Redis Sentinel 82 | 83 | Redix supports [Redis Sentinel][redis-sentinel] out of the box. You can specify a list of sentinels to connect to when starting a `Redix` (or `Redix.PubSub`) connection. Every time that connection will need to connect to a Redis server (the first time or after a disconnection), it will try to connect to one of the sentinels in order to ask that sentinel for the current primary or a replica. 84 | 85 | ```elixir 86 | sentinels = ["redis://sent1.example.com:26379", "redis://sent2.example.com:26379"] 87 | {:ok, primary} = Redix.start_link(sentinel: [sentinels: sentinels, group: "main"]) 88 | ``` 89 | 90 | ##### Terminology 91 | 92 | Redix doesn't support the use of the terms "master" and "slave" that are usually used with Redis Sentinel. I don't think those are good terms to use, period. Instead, Redix uses the terms "primary" and "replica". If you're interested in the discussions around this, [this][redis-terminology-issue] issue in the Redis repository might be interesting to you. 93 | 94 | #### Pub/Sub 95 | 96 | A `Redix.PubSub` process can be started via `Redix.PubSub.start_link/2`: 97 | 98 | ```elixir 99 | {:ok, pubsub} = Redix.PubSub.start_link() 100 | ``` 101 | 102 | Most communication with the `Redix.PubSub` process happens via Elixir messages (that simulate a Pub/Sub interaction with the pub/sub server). 103 | 104 | ```elixir 105 | {:ok, pubsub} = Redix.PubSub.start_link() 106 | 107 | Redix.PubSub.subscribe(pubsub, "my_channel", self()) 108 | #=> {:ok, ref} 109 | ``` 110 | 111 | Confirmation of subscriptions is delivered as an Elixir message: 112 | 113 | ```elixir 114 | receive do 115 | {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "my_channel"}} -> :ok 116 | end 117 | ``` 118 | 119 | If someone publishes a message on a channel we're subscribed to: 120 | 121 | ```elixir 122 | receive do 123 | {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "my_channel", payload: "hello"}} -> 124 | IO.puts("Received a message!") 125 | end 126 | ``` 127 | 128 | ## Using Redix in the Real World™ 129 | 130 | Redix is low-level, but it's still built to handle most things thrown at it. For many applications, you can avoid pooling with little to no impact on performance. Read the ["Real world usage" page][docs-real-world-usage] in the documentation for more information on this and pooling strategies that work better with Redix. 131 | 132 | ## Contributing 133 | 134 | To run the Redix test suite you will have to have Redis running locally. Redix requires a somewhat complex setup for running tests (because it needs a few instances running, for pub/sub and sentinel). For this reason, in this repository you'll find a `docker-compose.yml` file so that you can use [Docker][docker] and [docker compose][docker-compose] to spin up all the necessary Redis instances with just one command. Make sure you have Docker installed and then just run: 135 | 136 | ```bash 137 | docker compose up 138 | ``` 139 | 140 | Now, you're ready to run tests with the `$ mix test` command. 141 | 142 | ## License 143 | 144 | Redix is released under the MIT license. See the [license file](LICENSE.txt). 145 | 146 | [docs]: http://hexdocs.pm/redix 147 | [redis]: http://redis.io 148 | [redis-sentinel]: https://redis.io/topics/sentinel 149 | [castore]: https://github.com/ericmj/castore 150 | [docs-ssl]: https://hexdocs.pm/redix/Redix.html#module-ssl 151 | [docs-reconnections]: http://hexdocs.pm/redix/reconnections.html 152 | [docs-real-world-usage]: http://hexdocs.pm/redix/real-world-usage.html 153 | [docker]: https://www.docker.com 154 | [docker-compose]: https://docs.docker.com/compose/ 155 | [redis-terminology-issue]: https://github.com/antirez/redis/issues/5335 156 | [valkey]: https://valkey.io/ 157 | -------------------------------------------------------------------------------- /benchmarks/protocol.exs: -------------------------------------------------------------------------------- 1 | Mix.install([ 2 | {:redix, path: "."}, 3 | {:benchee, "~> 1.1"}, 4 | {:benchee_html, "~> 1.0"}, 5 | {:benchee_markdown, "~> 0.3"}, 6 | {:eredis, "~> 1.7"} 7 | ]) 8 | 9 | defmodule Helpers do 10 | def parse_with_continuations([data | datas], cont \\ &Redix.Protocol.parse/1) do 11 | case cont.(data) do 12 | {:continuation, new_cont} -> parse_with_continuations(datas, new_cont) 13 | {:ok, value, rest} -> {:ok, value, rest} 14 | end 15 | end 16 | end 17 | 18 | Benchee.run( 19 | %{ 20 | "Parse a bulk string split into 1kb chunks" => fn %{chunks: datas} -> 21 | {:ok, _value, _rest} = Helpers.parse_with_continuations(datas) 22 | end 23 | }, 24 | # Inputs are expressed in number of 1kb chunks 25 | inputs: %{ 26 | "1 Kb" => 1, 27 | "1 Mb" => 1024, 28 | "70 Mb" => 70 * 1024 29 | }, 30 | before_scenario: fn chunks_of_1kb -> 31 | chunks = for _ <- 1..chunks_of_1kb, do: :crypto.strong_rand_bytes(1024) 32 | total_size = chunks_of_1kb * 1024 33 | chunks = ["$#{total_size}\r\n" | chunks] ++ ["\r\n"] 34 | 35 | %{chunks: chunks} 36 | end, 37 | save: [path: "redix-main.benchee", tag: "main"] 38 | ) 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | base: 3 | image: "redis:alpine" 4 | ports: 5 | - "6379:6379" 6 | sysctls: 7 | net.core.somaxconn: 1024 8 | 9 | pubsub: 10 | image: "redis:alpine" 11 | ports: 12 | - "6380:6379" 13 | sysctls: 14 | net.core.somaxconn: 1024 15 | 16 | base_with_auth: 17 | image: "redis:5-alpine" 18 | command: redis-server --requirepass some-password 19 | ports: 20 | - "16379:6379" 21 | sysctls: 22 | net.core.somaxconn: 1024 23 | 24 | base_with_acl: 25 | build: ./test/docker/base_with_acl 26 | ports: 27 | - "6385:6379" 28 | sysctls: 29 | net.core.somaxconn: 1024 30 | 31 | sentinel: 32 | build: ./test/docker/sentinel 33 | ports: 34 | - "6381:6381" 35 | - "6382:6382" 36 | - "26379:26379" 37 | - "26380:26380" 38 | - "26381:26381" 39 | sysctls: 40 | net.core.somaxconn: 1024 41 | 42 | sentinel_with_auth: 43 | build: ./test/docker/sentinel_with_auth 44 | ports: 45 | - "6383:6383" 46 | - "26383:26383" 47 | sysctls: 48 | net.core.somaxconn: 1024 49 | 50 | base_with_stunnel: 51 | image: dweomer/stunnel@sha256:2d8fc61859475e7fef470c8a45219acea5b636c284339d811873819e532209e7 52 | environment: 53 | - STUNNEL_SERVICE=base 54 | - STUNNEL_ACCEPT=6384 55 | - STUNNEL_CONNECT=base:6379 56 | links: 57 | - base 58 | ports: 59 | - 6384:6384 60 | sysctls: 61 | net.core.somaxconn: 1024 62 | 63 | base_with_disallowed_client_command: 64 | build: ./test/docker/base_with_disallowed_client_command 65 | ports: 66 | - "6386:6379" 67 | sysctls: 68 | net.core.somaxconn: 1024 69 | -------------------------------------------------------------------------------- /lib/redix/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.Connection do 2 | @moduledoc false 3 | 4 | alias Redix.{ConnectionError, Format, Protocol, SocketOwner, StartOptions} 5 | 6 | require Logger 7 | 8 | @behaviour :gen_statem 9 | 10 | defstruct [ 11 | :opts, 12 | :transport, 13 | :socket_owner, 14 | :table, 15 | :socket, 16 | :backoff_current, 17 | :connected_address, 18 | counter: 0, 19 | client_reply: :on 20 | ] 21 | 22 | @backoff_exponent 1.5 23 | 24 | ## Public API 25 | 26 | def start_link(opts) when is_list(opts) do 27 | opts = StartOptions.sanitize(:redix, opts) 28 | {gen_statem_opts, opts} = Keyword.split(opts, [:hibernate_after, :debug, :spawn_opt]) 29 | 30 | case Keyword.fetch(opts, :name) do 31 | :error -> 32 | :gen_statem.start_link(__MODULE__, opts, gen_statem_opts) 33 | 34 | {:ok, atom} when is_atom(atom) -> 35 | :gen_statem.start_link({:local, atom}, __MODULE__, opts, gen_statem_opts) 36 | 37 | {:ok, {:global, _term} = tuple} -> 38 | :gen_statem.start_link(tuple, __MODULE__, opts, gen_statem_opts) 39 | 40 | {:ok, {:via, via_module, _term} = tuple} when is_atom(via_module) -> 41 | :gen_statem.start_link(tuple, __MODULE__, opts, gen_statem_opts) 42 | 43 | {:ok, other} -> 44 | raise ArgumentError, """ 45 | expected :name option to be one of the following: 46 | 47 | * nil 48 | * atom 49 | * {:global, term} 50 | * {:via, module, term} 51 | 52 | Got: #{inspect(other)} 53 | """ 54 | end 55 | end 56 | 57 | def stop(conn, timeout) do 58 | :gen_statem.stop(conn, :normal, timeout) 59 | end 60 | 61 | # TODO: Once we depend on Elixir 1.15+ (which requires OTP 24+, which introduces process 62 | # aliases), we can get rid of the extra work to support timeouts. 63 | def pipeline(conn, commands, timeout, telemetry_metadata) do 64 | conn_pid = GenServer.whereis(conn) 65 | 66 | request_id = Process.monitor(conn_pid) 67 | 68 | telemetry_metadata = telemetry_pipeline_metadata(conn, conn_pid, commands, telemetry_metadata) 69 | 70 | start_time = System.monotonic_time() 71 | :ok = execute_telemetry_pipeline_start(telemetry_metadata) 72 | 73 | # We cast to the connection process knowing that it will reply at some point, 74 | # either after roughly timeout or when a response is ready. 75 | cast = {:pipeline, commands, _from = {self(), request_id}, timeout} 76 | :ok = :gen_statem.cast(conn_pid, cast) 77 | 78 | receive do 79 | {^request_id, resp} -> 80 | _ = Process.demonitor(request_id, [:flush]) 81 | :ok = execute_telemetry_pipeline_stop(telemetry_metadata, start_time, resp) 82 | resp 83 | 84 | {:DOWN, ^request_id, _, _, reason} -> 85 | exit({:redix_exited_during_call, reason}) 86 | end 87 | end 88 | 89 | defp telemetry_pipeline_metadata(conn, conn_pid, commands, telemetry_metadata) do 90 | name = 91 | if is_pid(conn) do 92 | nil 93 | else 94 | conn 95 | end 96 | 97 | %{ 98 | connection: conn_pid, 99 | connection_name: name, 100 | commands: commands, 101 | extra_metadata: telemetry_metadata 102 | } 103 | end 104 | 105 | defp execute_telemetry_pipeline_start(metadata) do 106 | measurements = %{system_time: System.system_time()} 107 | :ok = :telemetry.execute([:redix, :pipeline, :start], measurements, metadata) 108 | end 109 | 110 | defp execute_telemetry_pipeline_stop(metadata, start_time, response) do 111 | measurements = %{duration: System.monotonic_time() - start_time} 112 | 113 | metadata = 114 | case response do 115 | {:ok, _response} -> metadata 116 | {:error, reason} -> Map.merge(metadata, %{kind: :error, reason: reason}) 117 | end 118 | 119 | :ok = :telemetry.execute([:redix, :pipeline, :stop], measurements, metadata) 120 | end 121 | 122 | ## Callbacks 123 | 124 | ## Init callbacks 125 | 126 | @impl true 127 | def callback_mode, do: :state_functions 128 | 129 | @impl true 130 | def init(opts) do 131 | transport = if(opts[:ssl], do: :ssl, else: :gen_tcp) 132 | queue_table = :ets.new(:queue, [:ordered_set, :public]) 133 | {:ok, socket_owner} = SocketOwner.start_link(self(), opts, queue_table) 134 | 135 | data = %__MODULE__{ 136 | opts: opts, 137 | table: queue_table, 138 | socket_owner: socket_owner, 139 | transport: transport 140 | } 141 | 142 | if opts[:sync_connect] do 143 | # We don't need to handle a timeout here because we're using a timeout in 144 | # connect/3 down the pipe. 145 | receive do 146 | {:connected, ^socket_owner, socket, address} -> 147 | :telemetry.execute([:redix, :connection], %{}, %{ 148 | connection: self(), 149 | connection_name: data.opts[:name], 150 | address: address, 151 | reconnection: false 152 | }) 153 | 154 | {:ok, :connected, %__MODULE__{data | socket: socket, connected_address: address}} 155 | 156 | {:stopped, ^socket_owner, reason} -> 157 | {:stop, %Redix.ConnectionError{reason: reason}} 158 | end 159 | else 160 | {:ok, :connecting, data} 161 | end 162 | end 163 | 164 | @impl true 165 | def terminate(reason, _state, data) do 166 | if Process.alive?(data.socket_owner) and reason == :normal do 167 | :ok = SocketOwner.normal_stop(data.socket_owner) 168 | end 169 | end 170 | 171 | ## State functions 172 | 173 | # "Disconnected" state: the connection is down and the socket owner is not alive. 174 | 175 | # We want to connect/reconnect. We start the socket owner process and then go in the :connecting 176 | # state. 177 | def disconnected({:timeout, :reconnect}, _timer_info, %__MODULE__{} = data) do 178 | {:ok, socket_owner} = SocketOwner.start_link(self(), data.opts, data.table) 179 | new_data = %{data | socket_owner: socket_owner} 180 | {:next_state, :connecting, new_data} 181 | end 182 | 183 | def disconnected({:timeout, {:client_timed_out, _counter}}, _from, _data) do 184 | :keep_state_and_data 185 | end 186 | 187 | def disconnected(:internal, {:notify_of_disconnection, _reason}, %__MODULE__{table: table}) do 188 | fun = fn {_counter, from, _ncommands, timed_out?}, _acc -> 189 | if not timed_out?, do: reply(from, {:error, %ConnectionError{reason: :disconnected}}) 190 | end 191 | 192 | :ets.foldl(fun, nil, table) 193 | :ets.delete_all_objects(table) 194 | 195 | :keep_state_and_data 196 | end 197 | 198 | def disconnected(:cast, {:pipeline, _commands, from, _timeout}, _data) do 199 | reply(from, {:error, %ConnectionError{reason: :closed}}) 200 | :keep_state_and_data 201 | end 202 | 203 | # This happens when there's a send error. We close the socket right away, but we wait for 204 | # the socket owner to die so that it can finish processing the data it's processing. When it's 205 | # dead, we go ahead and notify the remaining clients, setup backoff, and so on. 206 | def disconnected(:info, {:stopped, owner, reason}, %__MODULE__{socket_owner: owner} = data) do 207 | :telemetry.execute([:redix, :disconnection], %{}, %{ 208 | connection: self(), 209 | connection_name: data.opts[:name], 210 | address: data.connected_address, 211 | reason: %ConnectionError{reason: reason} 212 | }) 213 | 214 | data = %{data | connected_address: nil} 215 | disconnect(data, reason) 216 | end 217 | 218 | def connecting( 219 | :info, 220 | {:connected, owner, socket, address}, 221 | %__MODULE__{socket_owner: owner} = data 222 | ) do 223 | :telemetry.execute([:redix, :connection], %{}, %{ 224 | connection: self(), 225 | connection_name: data.opts[:name], 226 | address: address, 227 | reconnection: not is_nil(data.backoff_current) 228 | }) 229 | 230 | data = %{data | socket: socket, backoff_current: nil, connected_address: address} 231 | {:next_state, :connected, %{data | socket: socket}} 232 | end 233 | 234 | def connecting(:cast, {:pipeline, _commands, _from, _timeout}, _data) do 235 | {:keep_state_and_data, :postpone} 236 | end 237 | 238 | def connecting(:info, {:stopped, owner, reason}, %__MODULE__{socket_owner: owner} = data) do 239 | # We log this when the socket owner stopped while connecting. 240 | :telemetry.execute([:redix, :failed_connection], %{}, %{ 241 | connection: self(), 242 | connection_name: data.opts[:name], 243 | address: format_address(data), 244 | reason: %ConnectionError{reason: reason} 245 | }) 246 | 247 | disconnect(data, reason) 248 | end 249 | 250 | def connecting({:timeout, {:client_timed_out, _counter}}, _from, _data) do 251 | :keep_state_and_data 252 | end 253 | 254 | def connected(:cast, {:pipeline, commands, from, timeout}, data) do 255 | {ncommands, data} = get_client_reply(data, commands) 256 | 257 | if ncommands > 0 do 258 | {counter, data} = get_and_update_in(data.counter, &{&1, &1 + 1}) 259 | 260 | row = {counter, from, ncommands, _timed_out? = false} 261 | :ets.insert(data.table, row) 262 | 263 | case data.transport.send(data.socket, Enum.map(commands, &Protocol.pack/1)) do 264 | :ok -> 265 | actions = 266 | case timeout do 267 | :infinity -> [] 268 | _other -> [{{:timeout, {:client_timed_out, counter}}, timeout, from}] 269 | end 270 | 271 | {:keep_state, data, actions} 272 | 273 | {:error, _reason} -> 274 | # The socket owner is not guaranteed to get a "closed" message, even if we close the 275 | # socket here. So, we move to the disconnected state but also notify the owner that 276 | # sending failed. If the owner already got the "closed" message, it exited so this 277 | # message goes nowere, otherwise the socket owner will exit and notify the connection. 278 | # See https://github.com/whatyouhide/redix/issues/265. 279 | :ok = data.transport.close(data.socket) 280 | send(data.socket_owner, {:send_errored, self()}) 281 | {:next_state, :disconnected, data} 282 | end 283 | else 284 | reply(from, {:ok, []}) 285 | {:keep_state, data} 286 | end 287 | end 288 | 289 | def connected(:info, {:stopped, owner, reason}, %__MODULE__{socket_owner: owner} = data) do 290 | :telemetry.execute([:redix, :disconnection], %{}, %{ 291 | connection: self(), 292 | connection_name: data.opts[:name], 293 | address: data.connected_address, 294 | reason: %ConnectionError{reason: reason} 295 | }) 296 | 297 | data = %{data | connected_address: nil} 298 | disconnect(data, reason) 299 | end 300 | 301 | def connected({:timeout, {:client_timed_out, counter}}, from, %__MODULE__{} = data) do 302 | if _found? = :ets.update_element(data.table, counter, {4, _timed_out? = true}) do 303 | reply(from, {:error, %ConnectionError{reason: :timeout}}) 304 | end 305 | 306 | :keep_state_and_data 307 | end 308 | 309 | ## Helpers 310 | 311 | defp reply({pid, request_id} = _from, reply) do 312 | send(pid, {request_id, reply}) 313 | end 314 | 315 | defp disconnect(_data, %Redix.Error{} = error) do 316 | Logger.error("Disconnected from Redis due to error: #{Exception.message(error)}") 317 | {:stop, error} 318 | end 319 | 320 | defp disconnect(data, reason) do 321 | if data.opts[:exit_on_disconnection] do 322 | {:stop, %ConnectionError{reason: reason}} 323 | else 324 | {backoff, data} = next_backoff(data) 325 | 326 | actions = [ 327 | {:next_event, :internal, {:notify_of_disconnection, reason}}, 328 | {{:timeout, :reconnect}, backoff, nil} 329 | ] 330 | 331 | {:next_state, :disconnected, data, actions} 332 | end 333 | end 334 | 335 | defp next_backoff(%__MODULE__{backoff_current: nil} = data) do 336 | backoff_initial = data.opts[:backoff_initial] 337 | {backoff_initial, %{data | backoff_current: backoff_initial}} 338 | end 339 | 340 | defp next_backoff(data) do 341 | next_exponential_backoff = round(data.backoff_current * @backoff_exponent) 342 | 343 | backoff_current = 344 | if data.opts[:backoff_max] == :infinity do 345 | next_exponential_backoff 346 | else 347 | min(next_exponential_backoff, Keyword.fetch!(data.opts, :backoff_max)) 348 | end 349 | 350 | {backoff_current, %{data | backoff_current: backoff_current}} 351 | end 352 | 353 | defp get_client_reply(data, commands) do 354 | {ncommands, client_reply} = get_client_reply(commands, _ncommands = 0, data.client_reply) 355 | {ncommands, put_in(data.client_reply, client_reply)} 356 | end 357 | 358 | defp get_client_reply([], ncommands, client_reply) do 359 | {ncommands, client_reply} 360 | end 361 | 362 | defp get_client_reply([command | rest], ncommands, client_reply) do 363 | case parse_client_reply(command) do 364 | :off -> get_client_reply(rest, ncommands, :off) 365 | :skip when client_reply == :off -> get_client_reply(rest, ncommands, :off) 366 | :skip -> get_client_reply(rest, ncommands, :skip) 367 | :on -> get_client_reply(rest, ncommands + 1, :on) 368 | nil when client_reply == :on -> get_client_reply(rest, ncommands + 1, client_reply) 369 | nil when client_reply == :off -> get_client_reply(rest, ncommands, client_reply) 370 | nil when client_reply == :skip -> get_client_reply(rest, ncommands, :on) 371 | end 372 | end 373 | 374 | defp parse_client_reply(["CLIENT", "REPLY", "ON"]), do: :on 375 | defp parse_client_reply(["CLIENT", "REPLY", "OFF"]), do: :off 376 | defp parse_client_reply(["CLIENT", "REPLY", "SKIP"]), do: :skip 377 | defp parse_client_reply(["client", "reply", "on"]), do: :on 378 | defp parse_client_reply(["client", "reply", "off"]), do: :off 379 | defp parse_client_reply(["client", "reply", "skip"]), do: :skip 380 | 381 | defp parse_client_reply([part1, part2, part3]) 382 | when is_binary(part1) and byte_size(part1) == byte_size("CLIENT") and is_binary(part2) and 383 | byte_size(part2) == byte_size("REPLY") and 384 | is_binary(part3) and 385 | byte_size(part3) in [byte_size("ON"), byte_size("OFF"), byte_size("SKIP")] do 386 | # We need to do this in a "lazy" way: upcase the first string and check, then the second 387 | # one, and then the third one. Before, we were upcasing all three parts first and then 388 | # checking for a CLIENT REPLY * command. That meant that sometimes we would upcase huge 389 | # but completely unrelated commands causing big memory and CPU spikes. See 390 | # https://github.com/whatyouhide/redix/issues/177. "if" works here because and/2 391 | # short-circuits. 392 | if String.upcase(part1) == "CLIENT" and String.upcase(part2) == "REPLY" do 393 | case String.upcase(part3) do 394 | "ON" -> :on 395 | "OFF" -> :off 396 | "SKIP" -> :skip 397 | _other -> nil 398 | end 399 | else 400 | nil 401 | end 402 | end 403 | 404 | defp parse_client_reply(_other), do: nil 405 | 406 | defp format_address(%{opts: opts} = _state) do 407 | if opts[:sentinel] do 408 | "sentinel" 409 | else 410 | Format.format_host_and_port(opts[:host], opts[:port]) 411 | end 412 | end 413 | end 414 | -------------------------------------------------------------------------------- /lib/redix/connector.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.Connector do 2 | @moduledoc false 3 | 4 | @socket_opts [:binary, active: false] 5 | @default_timeout 5000 6 | @default_ssl_opts [verify: :verify_peer, depth: 3] 7 | 8 | alias Redix.{ConnectionError, Format} 9 | 10 | require Logger 11 | 12 | @spec connect(keyword(), pid()) :: 13 | {:ok, socket, connected_address} | {:error, term} | {:stop, term} 14 | when socket: :gen_tcp.socket() | :ssl.sslsocket(), 15 | connected_address: String.t() 16 | def connect(opts, conn_pid) when is_list(opts) and is_pid(conn_pid) do 17 | case Keyword.pop(opts, :sentinel) do 18 | {nil, opts} -> 19 | host = Keyword.fetch!(opts, :host) 20 | port = Keyword.fetch!(opts, :port) 21 | connect_directly(host, port, opts) 22 | 23 | {sentinel_opts, opts} when is_list(sentinel_opts) -> 24 | connect_through_sentinel(opts, sentinel_opts, conn_pid) 25 | end 26 | end 27 | 28 | defp connect_directly(host, port, opts) do 29 | transport = if opts[:ssl], do: :ssl, else: :gen_tcp 30 | socket_opts = build_socket_opts(transport, opts[:socket_opts]) 31 | timeout = Keyword.fetch!(opts, :timeout) 32 | 33 | with {:ok, socket} <- transport.connect(host, port, socket_opts, timeout), 34 | :ok <- setup_socket_buffers(transport, socket) do 35 | # Here, we should stop if AUTHing or SELECTing a DB fails with a *semantic* error 36 | # because disconnecting and retrying doesn't make sense, but we should not 37 | # stop if the issue is at the network layer, because it might happen due to 38 | # a race condition where the network conn breaks after connecting but before 39 | # AUTH/SELECT. 40 | case auth_and_select(transport, socket, opts, timeout) do 41 | :ok -> {:ok, socket, Format.format_host_and_port(host, port)} 42 | {:error, %Redix.Error{} = error} -> {:stop, error} 43 | {:error, :extra_bytes_after_reply} -> {:stop, :extra_bytes_after_reply} 44 | {:error, reason} -> {:error, reason} 45 | end 46 | end 47 | end 48 | 49 | defp auth_and_select(transport, socket, opts, timeout) do 50 | with :ok <- maybe_auth(transport, socket, opts, timeout), 51 | :ok <- maybe_select(transport, socket, opts, timeout), 52 | do: :ok 53 | end 54 | 55 | defp maybe_auth(transport, socket, opts, timeout) do 56 | username = opts[:username] 57 | 58 | password = 59 | case opts[:password] do 60 | {mod, fun, args} -> apply(mod, fun, args) 61 | password when is_binary(password) -> password 62 | nil -> nil 63 | end 64 | 65 | cond do 66 | username && password -> 67 | auth_with_username_and_password(transport, socket, username, password, timeout) 68 | 69 | password -> 70 | auth_with_password(transport, socket, password, timeout) 71 | 72 | true -> 73 | :ok 74 | end 75 | end 76 | 77 | defp auth_with_username_and_password(transport, socket, username, password, timeout) do 78 | case sync_command(transport, socket, ["AUTH", username, password], timeout) do 79 | {:ok, "OK"} -> 80 | :ok 81 | 82 | # An alternative to this hacky code would be to use the INFO command and check the Redis 83 | # version to see if it's >= 6.0.0 (when ACL was introduced). However, if you're not 84 | # authenticated, you cannot run INFO (or any other command), so that doesn't work. This 85 | # solution is a bit fragile since it relies on the exact error message, but that's the best 86 | # Redis gives use. The only alternative left would be to provide an explicit :use_username 87 | # option but that feels very orced on the user. 88 | {:error, %Redix.Error{message: "ERR wrong number of arguments for 'auth' command"}} -> 89 | Logger.warning(""" 90 | a username was provided to connect to Redis (either via options or via a URI). However, \ 91 | the Redis server version for this connection seems to not support ACLs, which are only \ 92 | supported from Redis version 6.0.0 (https://redis.io/topics/acl). Earlier versions of \ 93 | Redix used to ignore the username if provided, so Redix is now falling back to that \ 94 | behavior. Future Redix versions will raise an error in this particular case, so either \ 95 | remove the username or upgrade Redis to support ACLs.\ 96 | """) 97 | 98 | auth_with_password(transport, socket, password, timeout) 99 | 100 | {:error, reason} -> 101 | {:error, reason} 102 | end 103 | end 104 | 105 | defp auth_with_password(transport, socket, password, timeout) do 106 | with {:ok, "OK"} <- sync_command(transport, socket, ["AUTH", password], timeout), do: :ok 107 | end 108 | 109 | defp maybe_select(transport, socket, opts, timeout) do 110 | if database = opts[:database] do 111 | with {:ok, "OK"} <- sync_command(transport, socket, ["SELECT", database], timeout), do: :ok 112 | else 113 | :ok 114 | end 115 | end 116 | 117 | defp connect_through_sentinel(opts, sentinel_opts, conn_pid) do 118 | sentinels = Keyword.fetch!(sentinel_opts, :sentinels) 119 | transport = if sentinel_opts[:ssl], do: :ssl, else: :gen_tcp 120 | 121 | connect_through_sentinel(sentinels, sentinel_opts, opts, transport, conn_pid) 122 | end 123 | 124 | defp connect_through_sentinel([], _sentinel_opts, _opts, _transport, _conn_pid) do 125 | {:error, :no_viable_sentinel_connection} 126 | end 127 | 128 | defp connect_through_sentinel([sentinel | rest], sentinel_opts, opts, transport, conn_pid) do 129 | case connect_to_sentinel(sentinel, sentinel_opts, transport) do 130 | {:ok, sent_socket} -> 131 | _ = Logger.debug(fn -> "Connected to sentinel #{inspect(sentinel)}" end) 132 | 133 | with :ok <- maybe_auth(transport, sent_socket, sentinel, sentinel_opts[:timeout]), 134 | {:ok, {server_host, server_port}} <- 135 | ask_sentinel_for_server(transport, sent_socket, sentinel_opts), 136 | _ = 137 | Logger.debug(fn -> 138 | "Sentinel reported #{sentinel_opts[:role]}: #{server_host}:#{server_port}" 139 | end), 140 | server_host = string_address_to_erlang(server_host), 141 | {:ok, server_socket, address} <- 142 | connect_directly( 143 | server_host, 144 | String.to_integer(server_port), 145 | opts 146 | ), 147 | :ok <- verify_server_role(server_socket, opts, sentinel_opts) do 148 | :ok = transport.close(sent_socket) 149 | {:ok, server_socket, address} 150 | else 151 | {cause, reason} when cause in [:error, :stop] -> 152 | :telemetry.execute([:redix, :failed_connection], %{}, %{ 153 | connection: conn_pid, 154 | connection_name: opts[:name], 155 | reason: %ConnectionError{reason: reason}, 156 | sentinel_address: Format.format_host_and_port(sentinel[:host], sentinel[:port]) 157 | }) 158 | 159 | :ok = transport.close(sent_socket) 160 | connect_through_sentinel(rest, sentinel_opts, opts, transport, conn_pid) 161 | end 162 | 163 | {:error, reason} -> 164 | :telemetry.execute([:redix, :failed_connection], %{}, %{ 165 | connection: conn_pid, 166 | connection_name: opts[:name], 167 | reason: %ConnectionError{reason: reason}, 168 | sentinel_address: Format.format_host_and_port(sentinel[:host], sentinel[:port]) 169 | }) 170 | 171 | connect_through_sentinel(rest, sentinel_opts, opts, transport, conn_pid) 172 | end 173 | end 174 | 175 | defp string_address_to_erlang(address) when is_binary(address) do 176 | address = String.to_charlist(address) 177 | 178 | case :inet.parse_address(address) do 179 | {:ok, ip} -> ip 180 | {:error, :einval} -> address 181 | end 182 | end 183 | 184 | defp string_address_to_erlang(address) do 185 | address 186 | end 187 | 188 | defp connect_to_sentinel(sentinel, sentinel_opts, transport) do 189 | host = Keyword.fetch!(sentinel, :host) 190 | port = Keyword.fetch!(sentinel, :port) 191 | socket_opts = build_socket_opts(transport, sentinel_opts[:socket_opts]) 192 | transport.connect(host, port, socket_opts, sentinel_opts[:timeout]) 193 | end 194 | 195 | defp ask_sentinel_for_server(transport, sent_socket, sentinel_opts) do 196 | group = Keyword.fetch!(sentinel_opts, :group) 197 | 198 | case sentinel_opts[:role] do 199 | :primary -> 200 | command = ["SENTINEL", "get-master-addr-by-name", group] 201 | 202 | case sync_command(transport, sent_socket, command, sentinel_opts[:timeout]) do 203 | {:ok, [primary_host, primary_port]} -> {:ok, {primary_host, primary_port}} 204 | {:ok, nil} -> {:error, :sentinel_no_primary_found} 205 | {:error, reason} -> {:error, reason} 206 | end 207 | 208 | :replica -> 209 | command = ["SENTINEL", "slaves", group] 210 | 211 | case sync_command(transport, sent_socket, command, sentinel_opts[:timeout]) do 212 | {:ok, replicas} when replicas != [] -> 213 | _ = Logger.debug(fn -> "Available replicas: #{inspect(replicas)}" end) 214 | ["name", _, "ip", host, "port", port | _] = Enum.random(replicas) 215 | {:ok, {host, port}} 216 | 217 | {:ok, []} -> 218 | {:error, :sentinel_no_replicas_found_for_given_primary} 219 | 220 | {:error, reason} -> 221 | {:error, reason} 222 | end 223 | end 224 | end 225 | 226 | defp verify_server_role(server_socket, opts, sentinel_opts) do 227 | transport = if opts[:ssl], do: :ssl, else: :gen_tcp 228 | timeout = opts[:timeout] || @default_timeout 229 | 230 | expected_role = 231 | case sentinel_opts[:role] do 232 | :primary -> "master" 233 | :replica -> "slave" 234 | end 235 | 236 | case sync_command(transport, server_socket, ["ROLE"], timeout) do 237 | {:ok, [^expected_role | _]} -> :ok 238 | {:ok, [role | _]} -> {:error, {:wrong_role, role}} 239 | {:error, _reason_or_redis_error} = error -> error 240 | end 241 | end 242 | 243 | defp build_socket_opts(:gen_tcp, user_socket_opts) do 244 | @socket_opts ++ user_socket_opts 245 | end 246 | 247 | defp build_socket_opts(:ssl, user_socket_opts) do 248 | # Needs to be dynamic to avoid compile-time warnings. 249 | ca_store_mod = CAStore 250 | 251 | default_opts = 252 | if Keyword.has_key?(user_socket_opts, :cacertfile) or 253 | Keyword.has_key?(user_socket_opts, :cacerts) do 254 | @default_ssl_opts 255 | else 256 | try do 257 | [{:cacerts, :public_key.cacerts_get()} | @default_ssl_opts] 258 | rescue 259 | _ -> 260 | if Code.ensure_loaded?(ca_store_mod) do 261 | [{:cacertfile, ca_store_mod.file_path()} | @default_ssl_opts] 262 | else 263 | @default_ssl_opts 264 | end 265 | end 266 | end 267 | |> Keyword.drop(Keyword.keys(user_socket_opts)) 268 | 269 | @socket_opts ++ user_socket_opts ++ default_opts 270 | end 271 | 272 | # Setups the `:buffer` option of the given socket. 273 | defp setup_socket_buffers(transport, socket) do 274 | inet_mod = if transport == :ssl, do: :ssl, else: :inet 275 | 276 | with {:ok, opts} <- inet_mod.getopts(socket, [:sndbuf, :recbuf, :buffer]) do 277 | sndbuf = Keyword.fetch!(opts, :sndbuf) 278 | recbuf = Keyword.fetch!(opts, :recbuf) 279 | buffer = Keyword.fetch!(opts, :buffer) 280 | inet_mod.setopts(socket, buffer: buffer |> max(sndbuf) |> max(recbuf)) 281 | end 282 | end 283 | 284 | @spec sync_command( 285 | :ssl | :gen_tcp, 286 | :gen_tcp.socket() | :ssl.sslsocket(), 287 | [String.t()], 288 | integer() 289 | ) :: 290 | {:ok, any} 291 | | {:error, :extra_bytes_after_reply} 292 | | {:error, Redix.Error.t()} 293 | | {:error, :inet.posix()} 294 | def sync_command(transport, socket, command, timeout) do 295 | with :ok <- transport.send(socket, Redix.Protocol.pack(command)), 296 | do: recv_response(transport, socket, &Redix.Protocol.parse/1, timeout) 297 | end 298 | 299 | defp recv_response(transport, socket, continuation, timeout) do 300 | with {:ok, data} <- transport.recv(socket, 0, timeout) do 301 | case continuation.(data) do 302 | {:ok, %Redix.Error{} = error, ""} -> {:error, error} 303 | {:ok, response, ""} -> {:ok, response} 304 | {:ok, _response, rest} when byte_size(rest) > 0 -> {:error, :extra_bytes_after_reply} 305 | {:continuation, continuation} -> recv_response(transport, socket, continuation, timeout) 306 | end 307 | end 308 | end 309 | end 310 | -------------------------------------------------------------------------------- /lib/redix/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.Error do 2 | @moduledoc """ 3 | Error returned by Redis. 4 | 5 | This exception represents semantic errors returned by Redis: for example, 6 | non-existing commands or operations on keys with the wrong type (`INCR 7 | not_an_integer`). 8 | """ 9 | 10 | defexception [:message] 11 | 12 | @typedoc """ 13 | The type for this exception struct. 14 | """ 15 | @type t() :: %__MODULE__{message: binary()} 16 | end 17 | 18 | defmodule Redix.ConnectionError do 19 | @moduledoc """ 20 | Error in the connection to Redis. 21 | 22 | This exception represents errors in the connection to Redis: for example, 23 | request timeouts, disconnections, and similar. 24 | 25 | ## Exception fields 26 | 27 | See `t:t/0`. 28 | 29 | ## Error reasons 30 | 31 | The `:reason` field can assume a few Redix-specific values: 32 | 33 | * `:closed`: when the connection to Redis is closed (and Redix is 34 | reconnecting) and the user attempts to talk to Redis 35 | 36 | * `:disconnected`: when the connection drops while a request to Redis is in 37 | flight. 38 | 39 | * `:timeout`: when Redis doesn't reply to the request in time. 40 | 41 | """ 42 | 43 | @typedoc """ 44 | The type for this exception struct. 45 | 46 | This exception has the following public fields: 47 | 48 | * `:reason` - the error reason. It can be one of the Redix-specific 49 | reasons described in the "Error reasons" section below, or any error 50 | reason returned by functions in the `:gen_tcp` module (see the 51 | [`:inet.posix/0`](http://www.erlang.org/doc/man/inet.html#type-posix) type) or 52 | `:ssl` module. 53 | 54 | """ 55 | @type t() :: %__MODULE__{reason: atom} 56 | 57 | defexception [:reason] 58 | 59 | @impl true 60 | def message(%__MODULE__{reason: reason}) do 61 | format_reason(reason) 62 | end 63 | 64 | # :inet.format_error/1 doesn't format closed messages. 65 | defp format_reason(:tcp_closed), do: "TCP connection closed" 66 | defp format_reason(:ssl_closed), do: "SSL connection closed" 67 | 68 | # Manually returned by us when the connection is closed and someone tries to 69 | # send a command to Redis. 70 | defp format_reason(:closed), do: "the connection to Redis is closed" 71 | 72 | if System.otp_release() >= "26" do 73 | defp format_reason(reason), do: reason |> :inet.format_error() |> List.to_string() 74 | else 75 | defp format_reason(reason) do 76 | case :inet.format_error(reason) do 77 | ~c"unknown POSIX error" = message when is_atom(reason) -> "#{message}: #{reason}" 78 | ~c"unknown POSIX error" -> inspect(reason) 79 | message -> List.to_string(message) 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/redix/format.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.Format do 2 | @moduledoc false 3 | 4 | # Used for formatting things to print or log or anything like that. 5 | 6 | @spec format_host_and_port(host, :inet.port_number()) :: String.t() 7 | when host: {:local, String.t()} | charlist() | binary() | :inet.ip_address() 8 | def format_host_and_port(host, port) 9 | 10 | def format_host_and_port({:local, path}, 0) when is_binary(path), do: path 11 | 12 | def format_host_and_port(host, port) when is_binary(host) and is_integer(port), 13 | do: "#{host}:#{port}" 14 | 15 | def format_host_and_port(host, port) when is_list(host), 16 | do: format_host_and_port(IO.chardata_to_string(host), port) 17 | 18 | def format_host_and_port(host, port) when is_tuple(host) do 19 | case :inet.ntoa(host) do 20 | {:error, :einval} -> 21 | raise ArgumentError, "invalid host: #{inspect(host)}" 22 | 23 | host -> 24 | format_host_and_port(host, port) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/redix/protocol.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.Protocol do 2 | @moduledoc """ 3 | This module provides functions to work with the [Redis binary 4 | protocol](http://redis.io/topics/protocol). 5 | """ 6 | 7 | defmodule ParseError do 8 | @moduledoc """ 9 | Error in parsing data according to the 10 | [RESP](http://redis.io/topics/protocol) protocol. 11 | """ 12 | 13 | defexception [:message] 14 | end 15 | 16 | @typedoc """ 17 | Represents a Redis value. 18 | """ 19 | @type redis_value() :: binary | integer | nil | Redix.Error.t() | [redis_value()] 20 | 21 | @typedoc """ 22 | The return value of parsing functions in this module. 23 | """ 24 | @type on_parse(value) :: {:ok, value, binary} | {:continuation, (binary -> on_parse(value))} 25 | 26 | @crlf "\r\n" 27 | @crlf_iodata [?\r, ?\n] 28 | @max_integer_digits 18 29 | 30 | @doc ~S""" 31 | Packs a list of Elixir terms to a Redis (RESP) array. 32 | 33 | This function returns an iodata (instead of a binary) because the packed 34 | result is usually sent to Redis through `:gen_tcp.send/2` or similar. It can 35 | be converted to a binary with `IO.iodata_to_binary/1`. 36 | 37 | All elements of `elems` are converted to strings with `to_string/1`, hence 38 | this function supports encoding everything that implements `String.Chars`. 39 | 40 | ## Examples 41 | 42 | iex> iodata = Redix.Protocol.pack(["SET", "mykey", 1]) 43 | iex> IO.iodata_to_binary(iodata) 44 | "*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$1\r\n1\r\n" 45 | 46 | """ 47 | @spec pack([String.Chars.t()]) :: iodata 48 | def pack(items) when is_list(items) do 49 | pack(items, [], 0) 50 | end 51 | 52 | defp pack([item | rest], acc, count) do 53 | item = to_string(item) 54 | new_acc = [acc, [?$, Integer.to_string(byte_size(item)), @crlf_iodata, item, @crlf_iodata]] 55 | pack(rest, new_acc, count + 1) 56 | end 57 | 58 | defp pack([], acc, count) do 59 | [?*, Integer.to_string(count), @crlf_iodata, acc] 60 | end 61 | 62 | @doc ~S""" 63 | Parses a RESP-encoded value from the given `data`. 64 | 65 | Returns `{:ok, value, rest}` if a value is parsed successfully, or a 66 | continuation in the form `{:continuation, fun}` if the data is incomplete. 67 | 68 | ## Examples 69 | 70 | iex> Redix.Protocol.parse("+OK\r\ncruft") 71 | {:ok, "OK", "cruft"} 72 | 73 | iex> Redix.Protocol.parse("-ERR wrong type\r\n") 74 | {:ok, %Redix.Error{message: "ERR wrong type"}, ""} 75 | 76 | iex> {:continuation, fun} = Redix.Protocol.parse("+OK") 77 | iex> fun.("\r\n") 78 | {:ok, "OK", ""} 79 | 80 | """ 81 | @spec parse(binary) :: on_parse(redis_value) 82 | def parse(data) 83 | 84 | # Clause for the most common response. 85 | def parse("+OK\r\n" <> rest), do: {:ok, "OK", rest} 86 | 87 | def parse("+" <> rest), do: parse_simple_string(rest) 88 | def parse("-" <> rest), do: parse_error(rest) 89 | def parse(":" <> rest), do: parse_integer(rest) 90 | def parse("$" <> rest), do: parse_bulk_string(rest) 91 | def parse("*" <> rest), do: parse_array(rest) 92 | def parse(""), do: {:continuation, &parse/1} 93 | 94 | def parse(<> <> _), 95 | do: raise(ParseError, message: "invalid type specifier (#{inspect(<>)})") 96 | 97 | @doc ~S""" 98 | Parses `n` RESP-encoded values from the given `data`. 99 | 100 | Each element is parsed as described in `parse/1`. If an element can't be fully 101 | parsed or there are less than `n` elements encoded in `data`, then a 102 | continuation in the form of `{:continuation, fun}` is returned. Otherwise, 103 | `{:ok, values, rest}` is returned. If there's an error in decoding, a 104 | `Redix.Protocol.ParseError` exception is raised. 105 | 106 | ## Examples 107 | 108 | iex> Redix.Protocol.parse_multi("+OK\r\n+COOL\r\n", 2) 109 | {:ok, ["OK", "COOL"], ""} 110 | 111 | iex> {:continuation, fun} = Redix.Protocol.parse_multi("+OK\r\n", 2) 112 | iex> fun.("+OK\r\n") 113 | {:ok, ["OK", "OK"], ""} 114 | 115 | """ 116 | @spec parse_multi(binary, non_neg_integer) :: on_parse([redis_value]) 117 | def parse_multi(data, nelems) 118 | 119 | # We treat the case when we have just one element to parse differently as it's 120 | # a very common case since single commands are treated as pipelines with just 121 | # one command in them. 122 | def parse_multi(data, 1) do 123 | resolve_cont(parse(data), &{:ok, [&1], &2}) 124 | end 125 | 126 | def parse_multi(data, n) do 127 | take_elems(data, n, []) 128 | end 129 | 130 | # Type parsers 131 | 132 | defp parse_simple_string(data) do 133 | until_crlf(data) 134 | end 135 | 136 | defp parse_error(data) do 137 | data 138 | |> until_crlf() 139 | |> resolve_cont(&{:ok, %Redix.Error{message: &1}, &2}) 140 | end 141 | 142 | # Fast integer clauses for non-split packets. 143 | for n <- 1..@max_integer_digits do 144 | defp parse_integer(<> = binary) do 145 | String.to_integer(digits) 146 | rescue 147 | ArgumentError -> parse_integer_with_splits(binary) 148 | else 149 | int -> {:ok, int, rest} 150 | end 151 | end 152 | 153 | defp parse_integer(bin), do: parse_integer_with_splits(bin) 154 | 155 | defp parse_integer_with_splits(""), do: {:continuation, &parse_integer_with_splits/1} 156 | 157 | defp parse_integer_with_splits("-" <> rest), 158 | do: resolve_cont(parse_integer_without_sign(rest), &{:ok, -&1, &2}) 159 | 160 | defp parse_integer_with_splits(bin), do: parse_integer_without_sign(bin) 161 | 162 | defp parse_integer_without_sign("") do 163 | {:continuation, &parse_integer_without_sign/1} 164 | end 165 | 166 | defp parse_integer_without_sign(<> = bin) when digit in ?0..?9 do 167 | resolve_cont(parse_integer_digits(bin, 0), fn i, rest -> 168 | resolve_cont(crlf(rest), fn :no_value, rest -> {:ok, i, rest} end) 169 | end) 170 | end 171 | 172 | defp parse_integer_without_sign(<>) do 173 | raise ParseError, message: "expected integer, found: #{inspect(<>)}" 174 | end 175 | 176 | defp parse_integer_digits(<>, acc) when digit in ?0..?9, 177 | do: parse_integer_digits(rest, acc * 10 + (digit - ?0)) 178 | 179 | defp parse_integer_digits(<<_non_digit, _::binary>> = rest, acc), do: {:ok, acc, rest} 180 | defp parse_integer_digits(<<>>, acc), do: {:continuation, &parse_integer_digits(&1, acc)} 181 | 182 | defp parse_bulk_string(rest) do 183 | resolve_cont(parse_integer(rest), fn 184 | -1, rest -> 185 | {:ok, nil, rest} 186 | 187 | size, rest -> 188 | parse_string_of_known_size(rest, _acc = [], _size_left = size) 189 | end) 190 | end 191 | 192 | defp parse_string_of_known_size(data, acc, size_left) do 193 | case data do 194 | str when byte_size(str) < size_left -> 195 | {:continuation, &parse_string_of_known_size(&1, [acc, str], size_left - byte_size(str))} 196 | 197 | <> -> 198 | resolve_cont(crlf(rest), fn :no_value, rest -> 199 | {:ok, IO.iodata_to_binary([acc, str]), rest} 200 | end) 201 | end 202 | end 203 | 204 | defp parse_array(rest) do 205 | resolve_cont(parse_integer(rest), fn 206 | -1, rest -> 207 | {:ok, nil, rest} 208 | 209 | size, rest -> 210 | take_elems(rest, size, []) 211 | end) 212 | end 213 | 214 | defp until_crlf(data, acc \\ "") 215 | 216 | defp until_crlf(<<@crlf, rest::binary>>, acc), do: {:ok, acc, rest} 217 | defp until_crlf(<<>>, acc), do: {:continuation, &until_crlf(&1, acc)} 218 | defp until_crlf(<>, acc), do: {:continuation, &until_crlf(<>, acc)} 219 | defp until_crlf(<>, acc), do: until_crlf(rest, <>) 220 | 221 | defp crlf(<<@crlf, rest::binary>>), do: {:ok, :no_value, rest} 222 | defp crlf(<>), do: {:continuation, &crlf(<>)} 223 | defp crlf(<<>>), do: {:continuation, &crlf/1} 224 | 225 | defp crlf(<>), 226 | do: raise(ParseError, message: "expected CRLF, found: #{inspect(<>)}") 227 | 228 | defp take_elems(data, 0, acc) do 229 | {:ok, Enum.reverse(acc), data} 230 | end 231 | 232 | defp take_elems(<<_, _::binary>> = data, n, acc) when n > 0 do 233 | resolve_cont(parse(data), fn elem, rest -> 234 | take_elems(rest, n - 1, [elem | acc]) 235 | end) 236 | end 237 | 238 | defp take_elems(<<>>, n, acc) do 239 | {:continuation, &take_elems(&1, n, acc)} 240 | end 241 | 242 | defp resolve_cont({:ok, val, rest}, ok) when is_function(ok, 2), do: ok.(val, rest) 243 | 244 | defp resolve_cont({:continuation, cont}, ok), 245 | do: {:continuation, fn new_data -> resolve_cont(cont.(new_data), ok) end} 246 | end 247 | -------------------------------------------------------------------------------- /lib/redix/pubsub.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.PubSub do 2 | @moduledoc """ 3 | Interface for the Redis pub/sub functionality. 4 | 5 | The rest of this documentation will assume the reader knows how pub/sub works 6 | in Redis and knows the meaning of the following Redis commands: 7 | 8 | * `SUBSCRIBE` and `UNSUBSCRIBE` 9 | * `PSUBSCRIBE` and `PUNSUBSCRIBE` 10 | * `PUBLISH` 11 | 12 | ## Usage 13 | 14 | Each `Redix.PubSub` process is able to subscribe to/unsubscribe from multiple 15 | Redis channels/patterns, and is able to handle multiple Elixir processes subscribing 16 | each to different channels/patterns. 17 | 18 | A `Redix.PubSub` process can be started via `Redix.PubSub.start_link/2`; such 19 | a process holds a single TCP (or SSL) connection to the Redis server. 20 | 21 | `Redix.PubSub` has a message-oriented API. Subscribe operations are synchronous and return 22 | a reference that can then be used to match on all messages sent by the `Redix.PubSub` process. 23 | 24 | When `Redix.PubSub` registers a subscriptions, the subscriber process will receive a 25 | confirmation message: 26 | 27 | {:ok, pubsub} = Redix.PubSub.start_link() 28 | {:ok, ref} = Redix.PubSub.subscribe(pubsub, "my_channel", self()) 29 | 30 | receive do message -> message end 31 | #=> {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "my_channel"}} 32 | 33 | When the `:subscribed` message is received, it's guaranteed that the `Redix.PubSub` process has 34 | subscribed to the given channel. This means that after a subscription, messages published to 35 | a channel are delivered to all Elixir processes subscribed to that channel via `Redix.PubSub`: 36 | 37 | # Someone publishes "hello" on "my_channel" 38 | receive do message -> message end 39 | #=> {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "my_channel", payload: "hello"}} 40 | 41 | It's advised to wait for the subscription confirmation for a channel before doing any 42 | other operation involving that channel. 43 | 44 | Note that unsubscription confirmations are delivered right away even if the `Redix.PubSub` 45 | process is still subscribed to the given channel: this is by design, as once a process 46 | is unsubscribed from a channel it won't receive messages anyways, even if the `Redix.PubSub` 47 | process still receives them. 48 | 49 | Messages are also delivered as a confirmation of an unsubscription as well as when the 50 | `Redix.PubSub` connection goes down. See the "Messages" section below. 51 | 52 | ## Messages 53 | 54 | Most of the communication with a PubSub connection is done via (Elixir) messages: the 55 | subscribers of these messages will be the processes specified at subscription time (in 56 | `subscribe/3` or `psubscribe/3`). All `Redix.PubSub` messages have the same form: they're a 57 | five-element tuple that looks like this: 58 | 59 | {:redix_pubsub, pubsub_pid, subscription_ref, message_type, message_properties} 60 | 61 | where: 62 | 63 | * `pubsub_pid` is the pid of the `Redix.PubSub` process that sent this message. 64 | 65 | * `subscription_ref` is the reference returned by `subscribe/3` or `psubscribe/3`. 66 | 67 | * `message_type` is the type of this message, such as `:subscribed` for subscription 68 | confirmations, `:message` for pub/sub messages, and so on. 69 | 70 | * `message_properties` is a map of data related to that that varies based on `message_type`. 71 | 72 | Given this format, it's easy to match on all Redix pub/sub messages for a subscription 73 | as `{:redix_pubsub, _, ^subscription_ref, _, _}`. 74 | 75 | ### List of possible message types and properties 76 | 77 | The following is a comprehensive list of possible message types alongside the properties 78 | that each can have. 79 | 80 | * `:subscribe` - sent as confirmation of subscription to a channel (via `subscribe/3` or 81 | after a disconnection and reconnection). One `:subscribe` message is received for every 82 | channel a process subscribed to. `:subscribe` messages have the following properties: 83 | 84 | * `:channel` - the channel the process has been subscribed to. 85 | 86 | * `:psubscribe` - sent as confirmation of subscription to a pattern (via `psubscribe/3` or 87 | after a disconnection and reconnection). One `:psubscribe` message is received for every 88 | pattern a process subscribed to. `:psubscribe` messages have the following properties: 89 | 90 | * `:pattern` - the pattern the process has been subscribed to. 91 | 92 | * `:unsubscribe` - sent as confirmation of unsubscription from a channel (via 93 | `unsubscribe/3`). `:unsubscribe` messages are received for every channel a 94 | process unsubscribes from. `:unsubscribe` messages havethe following properties: 95 | 96 | * `:channel` - the channel the process has unsubscribed from. 97 | 98 | * `:punsubscribe` - sent as confirmation of unsubscription from a pattern (via 99 | `unsubscribe/3`). `:unsubscribe` messages are received for every pattern a 100 | process unsubscribes from. `:unsubscribe` messages havethe following properties: 101 | 102 | * `:pattern` - the pattern the process has unsubscribed from. 103 | 104 | * `:message` - sent to subscribers to a given channel when a message is published on 105 | that channel. `:message` messages have the following properties: 106 | 107 | * `:channel` - the channel the message was published on 108 | * `:payload` - the contents of the message 109 | 110 | * `:pmessage` - sent to subscribers to a given pattern when a message is published on 111 | a channel that matches that pattern. `:pmessage` messages have the following properties: 112 | 113 | * `:channel` - the channel the message was published on 114 | * `:pattern` - the original pattern that matched the channel 115 | * `:payload` - the contents of the message 116 | 117 | * `:disconnected` messages - sent to all subscribers to all channels/patterns when the 118 | connection to Redis is interrupted. `:disconnected` messages have the following properties: 119 | 120 | * `:error` - the reason for the disconnection, a `Redix.ConnectionError` 121 | exception struct (that can be raised or turned into a message through 122 | `Exception.message/1`). 123 | 124 | ## Reconnections 125 | 126 | `Redix.PubSub` tries to be resilient to failures: when the connection with 127 | Redis is interrupted (for whatever reason), it will try to reconnect to the 128 | Redis server. When a disconnection happens, `Redix.PubSub` will notify all 129 | clients subscribed to all channels with a `{:redix_pubsub, pid, subscription_ref, :disconnected, 130 | _}` message (more on the format of messages above). When the connection goes 131 | back up, `Redix.PubSub` takes care of actually re-subscribing to the 132 | appropriate channels on the Redis server and subscribers are notified with a 133 | `{:redix_pubsub, pid, subscription_ref, :subscribed | :psubscribed, _}` message, the same as 134 | when a client subscribes to a channel/pattern. 135 | 136 | Note that if `exit_on_disconnection: true` is passed to 137 | `Redix.PubSub.start_link/2`, the `Redix.PubSub` process will exit and not send 138 | any `:disconnected` messages to subscribed clients. 139 | 140 | ## Sentinel support 141 | 142 | Works exactly the same as for normal `Redix` connections. See the documentation for `Redix` 143 | for more information. 144 | 145 | ## Examples 146 | 147 | This is an example of a workflow using the PubSub functionality; it uses 148 | [Redix](https://github.com/whatyouhide/redix) as a Redis client for publishing 149 | messages. 150 | 151 | {:ok, pubsub} = Redix.PubSub.start_link() 152 | {:ok, client} = Redix.start_link() 153 | 154 | Redix.PubSub.subscribe(pubsub, "my_channel", self()) 155 | #=> {:ok, ref} 156 | 157 | # We wait for the subscription confirmation 158 | receive do 159 | {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "my_channel"}} -> :ok 160 | end 161 | 162 | Redix.command!(client, ~w(PUBLISH my_channel hello)) 163 | 164 | receive do 165 | {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "my_channel"} = properties} -> 166 | properties.payload 167 | end 168 | #=> "hello" 169 | 170 | Redix.PubSub.unsubscribe(pubsub, "foo", self()) 171 | #=> :ok 172 | 173 | # We wait for the unsubscription confirmation 174 | receive do 175 | {:redix_pubsub, ^pubsub, ^ref, :unsubscribed, _} -> :ok 176 | end 177 | 178 | """ 179 | 180 | @type subscriber() :: pid() | port() | atom() | {atom(), node()} 181 | @type connection() :: GenServer.server() 182 | 183 | alias Redix.StartOptions 184 | 185 | @doc """ 186 | Starts a pub/sub connection to Redis. 187 | 188 | This function returns `{:ok, pid}` if the PubSub process is started successfully. 189 | 190 | The actual TCP/SSL connection to the Redis server may happen either synchronously, 191 | before `start_link/2` returns, or asynchronously: this behaviour is decided by 192 | the `:sync_connect` option (see below). 193 | 194 | This function accepts one argument, either a Redis URI as a string or a list of options. 195 | 196 | ## Redis URI 197 | 198 | In case `uri_or_opts` is a Redis URI, it must be in the form: 199 | 200 | redis://[:password@]host[:port][/db] 201 | 202 | Here are some examples of valid URIs: 203 | 204 | redis://localhost 205 | redis://:secret@localhost:6397 206 | redis://username:secret@localhost:6397 207 | redis://example.com:6380/1 208 | 209 | The only mandatory thing when using URIs is the host. All other elements are optional 210 | and their default value can be found in the "Options" section below. 211 | 212 | In earlier versions of Redix, the username in the URI was ignored. Redis 6 introduced [ACL 213 | support](https://redis.io/topics/acl). Now, Redix supports usernames as well. 214 | 215 | ## Options 216 | 217 | The following options can be used to specify the connection: 218 | 219 | #{StartOptions.options_docs(:redix_pubsub)} 220 | 221 | ## Examples 222 | 223 | iex> Redix.PubSub.start_link() 224 | {:ok, #PID<...>} 225 | 226 | iex> Redix.PubSub.start_link(host: "example.com", port: 9999, password: "secret") 227 | {:ok, #PID<...>} 228 | 229 | iex> Redix.PubSub.start_link([database: 3], [name: :redix_3]) 230 | {:ok, #PID<...>} 231 | 232 | """ 233 | @spec start_link(String.t() | keyword()) :: {:ok, pid()} | :ignore | {:error, term()} 234 | def start_link(uri_or_opts \\ []) 235 | 236 | def start_link(uri) when is_binary(uri) do 237 | uri |> Redix.URI.to_start_options() |> start_link() 238 | end 239 | 240 | def start_link(opts) when is_list(opts) do 241 | opts = StartOptions.sanitize(:redix_pubsub, opts) 242 | {gen_statem_opts, opts} = Keyword.split(opts, [:hibernate_after, :debug, :spawn_opt]) 243 | 244 | case Keyword.fetch(opts, :name) do 245 | :error -> 246 | :gen_statem.start_link(Redix.PubSub.Connection, opts, gen_statem_opts) 247 | 248 | {:ok, atom} when is_atom(atom) -> 249 | :gen_statem.start_link({:local, atom}, Redix.PubSub.Connection, opts, gen_statem_opts) 250 | 251 | {:ok, {:global, _term} = tuple} -> 252 | :gen_statem.start_link(tuple, Redix.PubSub.Connection, opts, gen_statem_opts) 253 | 254 | {:ok, {:via, via_module, _term} = tuple} when is_atom(via_module) -> 255 | :gen_statem.start_link(tuple, Redix.PubSub.Connection, opts, gen_statem_opts) 256 | 257 | {:ok, other} -> 258 | raise ArgumentError, """ 259 | expected :name option to be one of the following: 260 | 261 | * nil 262 | * atom 263 | * {:global, term} 264 | * {:via, module, term} 265 | 266 | Got: #{inspect(other)} 267 | """ 268 | end 269 | end 270 | 271 | @doc """ 272 | Same as `start_link/1` but using both a Redis URI and a list of options. 273 | 274 | In this case, options specified in `opts` have precedence over values specified by `uri`. 275 | For example, if `uri` is `redix://example1.com` but `opts` is `[host: "example2.com"]`, then 276 | `example2.com` will be used as the host when connecting. 277 | """ 278 | @spec start_link(String.t(), keyword()) :: {:ok, pid()} | :ignore | {:error, term()} 279 | def start_link(uri, opts) when is_binary(uri) and is_list(opts) do 280 | uri |> Redix.URI.to_start_options() |> Keyword.merge(opts) |> start_link() 281 | end 282 | 283 | @doc """ 284 | Stops the given pub/sub process. 285 | 286 | This function is synchronous and blocks until the given pub/sub connection 287 | frees all its resources and disconnects from the Redis server. `timeout` can 288 | be passed to limit the amount of time allowed for the connection to exit; if 289 | it doesn't exit in the given interval, this call exits. 290 | 291 | ## Examples 292 | 293 | iex> Redix.PubSub.stop(conn) 294 | :ok 295 | 296 | """ 297 | @spec stop(connection()) :: :ok 298 | def stop(conn, timeout \\ :infinity) do 299 | :gen_statem.stop(conn, :normal, timeout) 300 | end 301 | 302 | @doc """ 303 | Subscribes `subscriber` to the given channel or list of channels. 304 | 305 | Subscribes `subscriber` (which can be anything that can be passed to `send/2`) 306 | to `channels`, which can be a single channel or a list of channels. 307 | 308 | For each of the channels in `channels` which `subscriber` successfully 309 | subscribes to, a message will be sent to `subscriber` with this form: 310 | 311 | {:redix_pubsub, pid, subscription_ref, :subscribed, %{channel: channel}} 312 | 313 | See the documentation for `Redix.PubSub` for more information about the format 314 | of messages. 315 | 316 | ## Examples 317 | 318 | iex> Redix.PubSub.subscribe(conn, ["foo", "bar"], self()) 319 | {:ok, subscription_ref} 320 | iex> flush() 321 | {:redix_pubsub, ^conn, ^subscription_ref, :subscribed, %{channel: "foo"}} 322 | {:redix_pubsub, ^conn, ^subscription_ref, :subscribed, %{channel: "bar"}} 323 | :ok 324 | 325 | """ 326 | @spec subscribe(connection(), String.t() | [String.t()], subscriber) :: {:ok, reference()} 327 | def subscribe(conn, channels, subscriber \\ self()) 328 | when is_binary(channels) or is_list(channels) do 329 | :gen_statem.call(conn, {:subscribe, List.wrap(channels), subscriber}) 330 | end 331 | 332 | @doc """ 333 | Subscribes `subscriber` to the given pattern or list of patterns. 334 | 335 | Works like `subscribe/3` but subscribing `subscriber` to a pattern (or list of 336 | patterns) instead of regular channels. 337 | 338 | Upon successful subscription to each of the `patterns`, a message will be sent 339 | to `subscriber` with the following form: 340 | 341 | {:redix_pubsub, pid, ^subscription_ref, :psubscribed, %{pattern: pattern}} 342 | 343 | See the documentation for `Redix.PubSub` for more information about the format 344 | of messages. 345 | 346 | ## Examples 347 | 348 | iex> Redix.psubscribe(conn, "ba*", self()) 349 | :ok 350 | iex> flush() 351 | {:redix_pubsub, ^conn, ^subscription_ref, :psubscribe, %{pattern: "ba*"}} 352 | :ok 353 | 354 | """ 355 | @spec psubscribe(connection(), String.t() | [String.t()], subscriber) :: {:ok, reference} 356 | def psubscribe(conn, patterns, subscriber \\ self()) 357 | when is_binary(patterns) or is_list(patterns) do 358 | :gen_statem.call(conn, {:psubscribe, List.wrap(patterns), subscriber}) 359 | end 360 | 361 | @doc """ 362 | Unsubscribes `subscriber` from the given channel or list of channels. 363 | 364 | This function basically "undoes" what `subscribe/3` does: it unsubscribes 365 | `subscriber` from the given channel or list of channels. 366 | 367 | Upon successful unsubscription from each of the `channels`, a message will be 368 | sent to `subscriber` with the following form: 369 | 370 | {:redix_pubsub, pid, ^subscription_ref, :unsubscribed, %{channel: channel}} 371 | 372 | See the documentation for `Redix.PubSub` for more information about the format 373 | of messages. 374 | 375 | ## Examples 376 | 377 | iex> Redix.unsubscribe(conn, ["foo", "bar"], self()) 378 | :ok 379 | iex> flush() 380 | {:redix_pubsub, ^conn, ^subscription_ref, :unsubscribed, %{channel: "foo"}} 381 | {:redix_pubsub, ^conn, ^subscription_ref, :unsubscribed, %{channel: "bar"}} 382 | :ok 383 | 384 | """ 385 | @spec unsubscribe(connection(), String.t() | [String.t()], subscriber) :: :ok 386 | def unsubscribe(conn, channels, subscriber \\ self()) 387 | when is_binary(channels) or is_list(channels) do 388 | :gen_statem.call(conn, {:unsubscribe, List.wrap(channels), subscriber}) 389 | end 390 | 391 | @doc """ 392 | Unsubscribes `subscriber` from the given pattern or list of patterns. 393 | 394 | This function basically "undoes" what `psubscribe/3` does: it unsubscribes 395 | `subscriber` from the given pattern or list of patterns. 396 | 397 | Upon successful unsubscription from each of the `patterns`, a message will be 398 | sent to `subscriber` with the following form: 399 | 400 | {:redix_pubsub, pid, ^subscription_ref, :punsubscribed, %{pattern: pattern}} 401 | 402 | See the documentation for `Redix.PubSub` for more information about the format 403 | of messages. 404 | 405 | ## Examples 406 | 407 | iex> Redix.punsubscribe(conn, "foo_*", self()) 408 | :ok 409 | iex> flush() 410 | {:redix_pubsub, ^conn, ^subscription_ref, :punsubscribed, %{pattern: "foo_*"}} 411 | :ok 412 | 413 | """ 414 | @spec punsubscribe(connection(), String.t() | [String.t()], subscriber) :: :ok 415 | def punsubscribe(conn, patterns, subscriber \\ self()) 416 | when is_binary(patterns) or is_list(patterns) do 417 | :gen_statem.call(conn, {:punsubscribe, List.wrap(patterns), subscriber}) 418 | end 419 | 420 | @doc """ 421 | Gets the Redis `CLIENT ID` associated with a connection. 422 | 423 | This is useful for implementing [**client-side 424 | caching**](https://redis.io/docs/manual/client-side-caching/), where you can 425 | subscribe your pub/sub connection to changes on keys. 426 | 427 | If the pub/sub connection is currently disconnected, this function returns 428 | `{:error, error}`. 429 | 430 | This function requires the `Redix.PubSub` connection to have been started 431 | with the `fetch_client_id_on_connect: true` option. This requires 432 | Redis 5.0.0 or later, since that's where the 433 | [`CLIENT ID` command](https://redis.io/commands/client-id/) 434 | was introduced. 435 | 436 | ## Examples 437 | 438 | iex> Redix.PubSub.get_client_id(conn) 439 | {:ok, 123} 440 | 441 | If the connection is not currently connected: 442 | 443 | iex> Redix.PubSub.get_client_id(conn) 444 | {:error, %Redix.ConnectionError{reason: :disconnected} 445 | 446 | If the connection was not storing the client ID: 447 | 448 | iex> Redix.PubSub.get_client_id(conn) 449 | {:error, %Redix.ConnectionError{reason: :client_id_not_stored} 450 | 451 | """ 452 | @doc since: "1.4.0" 453 | @spec get_client_id(connection()) :: {:ok, integer()} | {:error, Redix.ConnectionError.t()} 454 | def get_client_id(conn) do 455 | :gen_statem.call(conn, :get_client_id) 456 | end 457 | end 458 | -------------------------------------------------------------------------------- /lib/redix/pubsub/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.PubSub.Connection do 2 | @moduledoc false 3 | 4 | @behaviour :gen_statem 5 | 6 | alias Redix.{ConnectionError, Connector, Format, Protocol} 7 | 8 | defstruct [ 9 | :opts, 10 | :transport, 11 | :socket, 12 | :continuation, 13 | :backoff_current, 14 | :last_disconnect_reason, 15 | :connected_address, 16 | :client_id, 17 | subscriptions: %{}, 18 | monitors: %{} 19 | ] 20 | 21 | @backoff_exponent 1.5 22 | 23 | @impl true 24 | def callback_mode, do: :state_functions 25 | 26 | @impl true 27 | def init(opts) do 28 | transport = if(opts[:ssl], do: :ssl, else: :gen_tcp) 29 | data = %__MODULE__{opts: opts, transport: transport} 30 | 31 | if opts[:sync_connect] do 32 | with {:ok, socket, address, client_id} <- connect(data) do 33 | data = %__MODULE__{ 34 | data 35 | | socket: socket, 36 | last_disconnect_reason: nil, 37 | backoff_current: nil, 38 | connected_address: address, 39 | client_id: client_id 40 | } 41 | 42 | {:ok, :connected, data} 43 | else 44 | {:error, reason} -> {:stop, reason} 45 | {:stop, reason} -> {:stop, reason} 46 | end 47 | else 48 | send(self(), :handle_possible_erlang_bug) 49 | {:ok, :state_needed_because_of_possible_erlang_bug, data} 50 | end 51 | end 52 | 53 | ## States 54 | 55 | # If I use the action {:next_event, :internal, :connect} when returning 56 | # {:ok, :disconnected, data} from init/1, then Erlang 20 (not 21) blows up saying: 57 | # {:bad_return_from_init, {:next_events, :internal, :connect}}. The weird thing is 58 | # that if I use `{:next_even, :internal, :connect}` it complains but with `:next_even`, 59 | # but with `:next_event` it seems to add the final "s" (`:next_events`). No idea 60 | # what's going on and no time to fix it. 61 | def state_needed_because_of_possible_erlang_bug(:info, :handle_possible_erlang_bug, data) do 62 | {:next_state, :disconnected, data, {:next_event, :internal, :connect}} 63 | end 64 | 65 | def state_needed_because_of_possible_erlang_bug(_event, _info, _data) do 66 | {:keep_state_and_data, :postpone} 67 | end 68 | 69 | def disconnected(:internal, :handle_disconnection, data) do 70 | :telemetry.execute([:redix, :disconnection], %{}, %{ 71 | connection: self(), 72 | connection_name: data.opts[:name], 73 | address: data.connected_address, 74 | reason: data.last_disconnect_reason 75 | }) 76 | 77 | if data.opts[:exit_on_disconnection] do 78 | {:stop, data.last_disconnect_reason} 79 | else 80 | :ok = 81 | Enum.each(data.monitors, fn {pid, ref} -> 82 | send(pid, ref, :disconnected, %{error: data.last_disconnect_reason}) 83 | end) 84 | 85 | subscriptions = 86 | Map.new(data.subscriptions, fn 87 | {target_key, {:subscribed, subscribers}} -> 88 | {target_key, {:disconnected, subscribers}} 89 | 90 | {target_key, {:subscribing, subscribes, _unsubscribes}} -> 91 | {target_key, {:disconnected, subscribes}} 92 | 93 | {target_key, {:unsubscribing, resubscribers}} -> 94 | {target_key, {:disconnected, resubscribers}} 95 | end) 96 | 97 | data = %{data | subscriptions: subscriptions, connected_address: nil} 98 | 99 | {:keep_state, data} 100 | end 101 | end 102 | 103 | def disconnected({:timeout, :reconnect}, nil, _data) do 104 | {:keep_state_and_data, {:next_event, :internal, :connect}} 105 | end 106 | 107 | def disconnected(:internal, :connect, data) do 108 | with {:ok, socket, address, client_id} <- connect(data) do 109 | :telemetry.execute([:redix, :connection], %{}, %{ 110 | connection: self(), 111 | connection_name: data.opts[:name], 112 | address: address, 113 | reconnection: not is_nil(data.last_disconnect_reason) 114 | }) 115 | 116 | data = %__MODULE__{ 117 | data 118 | | socket: socket, 119 | last_disconnect_reason: nil, 120 | backoff_current: nil, 121 | connected_address: address, 122 | client_id: client_id 123 | } 124 | 125 | {:next_state, :connected, data, {:next_event, :internal, :handle_connection}} 126 | else 127 | {:error, reason} -> 128 | :telemetry.execute([:redix, :failed_connection], %{}, %{ 129 | connection: self(), 130 | connection_name: data.opts[:name], 131 | address: format_address(data), 132 | reason: %ConnectionError{reason: reason} 133 | }) 134 | 135 | disconnect(data, reason, _handle_disconnection? = false) 136 | 137 | {:stop, reason} -> 138 | {:stop, reason, data} 139 | end 140 | end 141 | 142 | def disconnected({:call, from}, {operation, targets, pid}, data) 143 | when operation in [:subscribe, :psubscribe] do 144 | {data, ref} = monitor_new(data, pid) 145 | :ok = :gen_statem.reply(from, {:ok, ref}) 146 | 147 | target_type = 148 | case operation do 149 | :subscribe -> :channel 150 | :psubscribe -> :pattern 151 | end 152 | 153 | data = 154 | Enum.reduce(targets, data, fn target_name, data_acc -> 155 | update_in(data_acc.subscriptions[{target_type, target_name}], fn 156 | {:disconnected, subscribers} -> {:disconnected, MapSet.put(subscribers, pid)} 157 | nil -> {:disconnected, MapSet.new([pid])} 158 | end) 159 | end) 160 | 161 | {:keep_state, data} 162 | end 163 | 164 | def disconnected({:call, from}, {operation, targets, pid}, data) 165 | when operation in [:unsubscribe, :punsubscribe] do 166 | :ok = :gen_statem.reply(from, :ok) 167 | 168 | target_type = 169 | case operation do 170 | :unsubscribe -> :channel 171 | :punsubscribe -> :pattern 172 | end 173 | 174 | data = 175 | Enum.reduce(targets, data, fn target_name, data_acc -> 176 | target_key = {target_type, target_name} 177 | 178 | case data_acc.subscriptions[target_key] do 179 | nil -> 180 | data_acc 181 | 182 | {:disconnected, subscribers} -> 183 | subscribers = MapSet.delete(subscribers, pid) 184 | 185 | if MapSet.size(subscribers) == 0 do 186 | update_in(data_acc.subscriptions, &Map.delete(&1, target_key)) 187 | else 188 | put_in(data_acc.subscriptions[target_key], {:disconnected, subscribers}) 189 | end 190 | end 191 | end) 192 | 193 | data = demonitor_if_not_subscribed_to_anything(data, pid) 194 | {:keep_state, data} 195 | end 196 | 197 | def disconnected({:call, from}, :get_client_id, _data) do 198 | reply = {:error, %ConnectionError{reason: :closed}} 199 | {:keep_state_and_data, {:reply, from, reply}} 200 | end 201 | 202 | def connected(:internal, :handle_connection, data) do 203 | if map_size(data.subscriptions) > 0 do 204 | case resubscribe_after_reconnection(data) do 205 | {:ok, data} -> {:keep_state, data} 206 | {:error, reason} -> disconnect(data, reason, _handle_disconnection? = true) 207 | end 208 | else 209 | {:keep_state, data} 210 | end 211 | end 212 | 213 | def connected({:call, from}, {operation, targets, pid}, data) 214 | when operation in [:subscribe, :psubscribe] do 215 | {data, ref} = monitor_new(data, pid) 216 | :ok = :gen_statem.reply(from, {:ok, ref}) 217 | 218 | with {:ok, data} <- subscribe_pid_to_targets(data, operation, targets, pid) do 219 | {:keep_state, data} 220 | end 221 | end 222 | 223 | def connected({:call, from}, {operation, targets, pid}, data) 224 | when operation in [:unsubscribe, :punsubscribe] do 225 | :ok = :gen_statem.reply(from, :ok) 226 | 227 | with {:ok, data} <- unsubscribe_pid_from_targets(data, operation, targets, pid) do 228 | data = demonitor_if_not_subscribed_to_anything(data, pid) 229 | {:keep_state, data} 230 | end 231 | end 232 | 233 | def connected({:call, from}, :get_client_id, data) do 234 | reply = 235 | if id = data.client_id do 236 | {:ok, id} 237 | else 238 | {:error, %ConnectionError{reason: :client_id_not_stored}} 239 | end 240 | 241 | {:keep_state_and_data, {:reply, from, reply}} 242 | end 243 | 244 | def connected(:info, {transport_closed, socket}, %__MODULE__{socket: socket} = data) 245 | when transport_closed in [:tcp_closed, :ssl_closed] do 246 | disconnect(data, transport_closed, _handle_disconnection? = true) 247 | end 248 | 249 | def connected(:info, {transport_error, socket, reason}, %__MODULE__{socket: socket} = data) 250 | when transport_error in [:tcp_error, :ssl_error] do 251 | disconnect(data, reason, _handle_disconnection? = true) 252 | end 253 | 254 | def connected(:info, {transport, socket, bytes}, %__MODULE__{socket: socket} = data) 255 | when transport in [:tcp, :ssl] do 256 | with :ok <- setopts(data, socket, active: :once), 257 | {:ok, data} <- new_bytes(data, bytes) do 258 | {:keep_state, data} 259 | else 260 | {:error, reason} -> disconnect(data, reason, _handle_disconnection? = true) 261 | end 262 | end 263 | 264 | def connected(:info, {:DOWN, _ref, :process, pid, _reason}, data) do 265 | data = update_in(data.monitors, &Map.delete(&1, pid)) 266 | 267 | targets = Map.keys(data.subscriptions) 268 | channels = for {:channel, channel} <- targets, do: channel 269 | patterns = for {:pattern, pattern} <- targets, do: pattern 270 | 271 | with {:ok, data} <- unsubscribe_pid_from_targets(data, :unsubscribe, channels, pid), 272 | {:ok, data} <- unsubscribe_pid_from_targets(data, :punsubscribe, patterns, pid) do 273 | {:keep_state, data} 274 | end 275 | end 276 | 277 | ## Helpers 278 | 279 | defp new_bytes(data, "") do 280 | {:ok, data} 281 | end 282 | 283 | defp new_bytes(data, bytes) do 284 | case (data.continuation || (&Protocol.parse/1)).(bytes) do 285 | {:ok, resp, rest} -> 286 | with {:ok, data} <- handle_pubsub_msg(data, resp), 287 | do: new_bytes(%{data | continuation: nil}, rest) 288 | 289 | {:continuation, continuation} -> 290 | {:ok, %{data | continuation: continuation}} 291 | end 292 | end 293 | 294 | defp handle_pubsub_msg(data, [operation, target, _subscribers_count]) 295 | when operation in ["subscribe", "psubscribe"] do 296 | target_key = 297 | case operation do 298 | "subscribe" -> {:channel, target} 299 | "psubscribe" -> {:pattern, target} 300 | end 301 | 302 | {:subscribing, subscribes, _unsubscribes} = data.subscriptions[target_key] 303 | 304 | if MapSet.size(subscribes) == 0 do 305 | case send_unsubscriptions(data, [target_key]) do 306 | :ok -> 307 | data = put_in(data.subscriptions[target_key], {:unsubscribing, MapSet.new()}) 308 | {:ok, data} 309 | 310 | {:error, reason} -> 311 | {:error, reason} 312 | end 313 | else 314 | Enum.each(subscribes, &send_subscription_confirmation(data, &1, target_key)) 315 | data = put_in(data.subscriptions[target_key], {:subscribed, subscribes}) 316 | {:ok, data} 317 | end 318 | end 319 | 320 | defp handle_pubsub_msg(data, [operation, target, _subscribers_count]) 321 | when operation in ["unsubscribe", "punsubscribe"] do 322 | operation = String.to_existing_atom(operation) 323 | target_key = key_for_target(operation, target) 324 | 325 | {:unsubscribing, resubscribers} = data.subscriptions[target_key] 326 | 327 | if MapSet.size(resubscribers) == 0 do 328 | data = update_in(data.subscriptions, &Map.delete(&1, target_key)) 329 | {:ok, data} 330 | else 331 | case send_subscriptions(data, [target_key]) do 332 | :ok -> 333 | data = 334 | put_in(data.subscriptions[target_key], {:subscribing, resubscribers, MapSet.new()}) 335 | 336 | {:ok, data} 337 | 338 | {:error, reason} -> 339 | {:error, reason} 340 | end 341 | end 342 | end 343 | 344 | defp handle_pubsub_msg(data, ["message", channel, payload]) do 345 | properties = %{channel: channel, payload: payload} 346 | handle_pubsub_message_with_payload(data, {:channel, channel}, :message, properties) 347 | end 348 | 349 | defp handle_pubsub_msg(data, ["pmessage", pattern, channel, payload]) do 350 | properties = %{channel: channel, pattern: pattern, payload: payload} 351 | handle_pubsub_message_with_payload(data, {:pattern, pattern}, :pmessage, properties) 352 | end 353 | 354 | defp handle_pubsub_message_with_payload(data, target_key, kind, properties) do 355 | case data.subscriptions[target_key] do 356 | {:subscribed, subscribers} -> 357 | for pid <- subscribers do 358 | send(pid, Map.fetch!(data.monitors, pid), kind, properties) 359 | end 360 | 361 | {:unsubscribing, _to_resubscribe} -> 362 | :ok 363 | end 364 | 365 | {:ok, data} 366 | end 367 | 368 | # Subscribing. 369 | 370 | defp subscribe_pid_to_targets(data, operation, targets, pid) do 371 | target_type = 372 | case operation do 373 | :subscribe -> :channel 374 | :psubscribe -> :pattern 375 | end 376 | 377 | {to_subscribe, data} = 378 | Enum.flat_map_reduce(targets, data, fn target_name, data_acc -> 379 | target_key = {target_type, target_name} 380 | 381 | {target_state, data_acc} = 382 | get_and_update_in(data_acc.subscriptions[target_key], &subscribe_pid_to_target(&1, pid)) 383 | 384 | case target_state do 385 | :new -> 386 | {[target_key], data_acc} 387 | 388 | :already_subscribed -> 389 | send_subscription_confirmation(data_acc, pid, target_key) 390 | {[], data_acc} 391 | 392 | :pending -> 393 | {[], data_acc} 394 | end 395 | end) 396 | 397 | case send_subscriptions(data, to_subscribe) do 398 | :ok -> {:ok, data} 399 | {:error, reason} -> disconnect(data, reason, _handle_disconnection? = true) 400 | end 401 | end 402 | 403 | defp subscribe_pid_to_target(nil, pid) do 404 | state = {:subscribing, MapSet.new([pid]), MapSet.new()} 405 | {:new, state} 406 | end 407 | 408 | defp subscribe_pid_to_target({:subscribed, subscribers}, pid) do 409 | state = {:subscribed, MapSet.put(subscribers, pid)} 410 | {:already_subscribed, state} 411 | end 412 | 413 | defp subscribe_pid_to_target({:subscribing, subscribes, unsubscribes}, pid) do 414 | state = {:subscribing, MapSet.put(subscribes, pid), MapSet.delete(unsubscribes, pid)} 415 | {:pending, state} 416 | end 417 | 418 | defp subscribe_pid_to_target({:unsubscribing, resubscribers}, pid) do 419 | state = {:unsubscribing, MapSet.put(resubscribers, pid)} 420 | {:pending, state} 421 | end 422 | 423 | defp send_subscription_confirmation(data, pid, {:channel, channel}) do 424 | send(pid, Map.fetch!(data.monitors, pid), :subscribed, %{channel: channel}) 425 | end 426 | 427 | defp send_subscription_confirmation(data, pid, {:pattern, pattern}) do 428 | send(pid, Map.fetch!(data.monitors, pid), :psubscribed, %{pattern: pattern}) 429 | end 430 | 431 | defp send_subscriptions(_data, []) do 432 | :ok 433 | end 434 | 435 | defp send_subscriptions(data, to_subscribe) do 436 | channels = for {:channel, channel} <- to_subscribe, do: channel 437 | patterns = for {:pattern, pattern} <- to_subscribe, do: pattern 438 | 439 | pipeline = 440 | case {channels, patterns} do 441 | {_, []} -> [["SUBSCRIBE" | channels]] 442 | {[], _} -> [["PSUBSCRIBE" | patterns]] 443 | {_, _} -> [["SUBSCRIBE" | channels], ["PSUBSCRIBE" | patterns]] 444 | end 445 | 446 | data.transport.send(data.socket, Enum.map(pipeline, &Protocol.pack/1)) 447 | end 448 | 449 | # Returns {targets_to_unsubscribe_from, data}. 450 | defp unsubscribe_pid_from_targets(data, operation, targets, pid) do 451 | target_type = 452 | case operation do 453 | :unsubscribe -> :channel 454 | :punsubscribe -> :pattern 455 | end 456 | 457 | {to_unsubscribe, data} = 458 | Enum.flat_map_reduce(targets, data, fn target_name, data_acc -> 459 | target_key = {target_type, target_name} 460 | 461 | {target_state, data_acc} = 462 | get_and_update_in( 463 | data_acc.subscriptions[target_key], 464 | &unsubscribe_pid_from_target(&1, pid) 465 | ) 466 | 467 | send_unsubscription_confirmation(data_acc, pid, target_key) 468 | 469 | case target_state do 470 | :now_empty -> {[target_key], data_acc} 471 | _other -> {[], data_acc} 472 | end 473 | end) 474 | 475 | case send_unsubscriptions(data, to_unsubscribe) do 476 | :ok -> {:ok, data} 477 | {:error, reason} -> disconnect(data, reason, _handle_disconnection? = true) 478 | end 479 | end 480 | 481 | defp unsubscribe_pid_from_target({:subscribed, subscribers}, pid) do 482 | if MapSet.size(subscribers) == 1 and MapSet.member?(subscribers, pid) do 483 | state = {:unsubscribing, _resubscribers = MapSet.new()} 484 | {:now_empty, state} 485 | else 486 | state = {:subscribed, MapSet.delete(subscribers, pid)} 487 | {:noop, state} 488 | end 489 | end 490 | 491 | defp unsubscribe_pid_from_target({:subscribing, subscribes, unsubscribes}, pid) do 492 | state = {:subscribing, MapSet.delete(subscribes, pid), MapSet.put(unsubscribes, pid)} 493 | {:noop, state} 494 | end 495 | 496 | defp unsubscribe_pid_from_target({:unsubscribing, resubscribers}, pid) do 497 | state = {:unsubscribing, MapSet.delete(resubscribers, pid)} 498 | {:noop, state} 499 | end 500 | 501 | defp unsubscribe_pid_from_target(_, _), do: :pop 502 | 503 | defp send_unsubscription_confirmation(data, pid, {:channel, channel}) do 504 | if ref = data.monitors[pid] do 505 | send(pid, ref, :unsubscribed, %{channel: channel}) 506 | end 507 | end 508 | 509 | defp send_unsubscription_confirmation(data, pid, {:pattern, pattern}) do 510 | if ref = data.monitors[pid] do 511 | send(pid, ref, :punsubscribed, %{pattern: pattern}) 512 | end 513 | end 514 | 515 | defp send_unsubscriptions(_data, []) do 516 | :ok 517 | end 518 | 519 | defp send_unsubscriptions(data, to_subscribe) do 520 | channels = for {:channel, channel} <- to_subscribe, do: channel 521 | patterns = for {:pattern, pattern} <- to_subscribe, do: pattern 522 | 523 | pipeline = 524 | case {channels, patterns} do 525 | {_, []} -> [["UNSUBSCRIBE" | channels]] 526 | {[], _} -> [["PUNSUBSCRIBE" | patterns]] 527 | {_, _} -> [["UNSUBSCRIBE" | channels], ["PUNSUBSCRIBE" | patterns]] 528 | end 529 | 530 | data.transport.send(data.socket, Enum.map(pipeline, &Protocol.pack/1)) 531 | end 532 | 533 | defp resubscribe_after_reconnection(data) do 534 | data = 535 | update_in(data.subscriptions, fn subscriptions -> 536 | Map.new(subscriptions, fn {target_key, {:disconnected, subscribers}} -> 537 | {target_key, {:subscribing, subscribers, MapSet.new()}} 538 | end) 539 | end) 540 | 541 | with :ok <- send_subscriptions(data, Map.keys(data.subscriptions)) do 542 | {:ok, data} 543 | end 544 | end 545 | 546 | defp monitor_new(data, pid) do 547 | case data.monitors do 548 | %{^pid => ref} -> 549 | {data, ref} 550 | 551 | _ -> 552 | ref = Process.monitor(pid) 553 | data = put_in(data.monitors[pid], ref) 554 | {data, ref} 555 | end 556 | end 557 | 558 | defp demonitor_if_not_subscribed_to_anything(data, pid) do 559 | still_subscribed_to_something? = 560 | Enum.any?(data.subscriptions, fn 561 | {_target, {:subscribing, subscribes, _unsubscribes}} -> pid in subscribes 562 | {_target, {:subscribed, subscribers}} -> pid in subscribers 563 | {_target, {:unsubscribing, resubscribers}} -> pid in resubscribers 564 | {_target, {:disconnected, subscribers}} -> pid in subscribers 565 | end) 566 | 567 | if still_subscribed_to_something? do 568 | data 569 | else 570 | {monitor_ref, data} = pop_in(data.monitors[pid]) 571 | if monitor_ref, do: Process.demonitor(monitor_ref, [:flush]) 572 | data 573 | end 574 | end 575 | 576 | defp key_for_target(:subscribe, channel), do: {:channel, channel} 577 | defp key_for_target(:unsubscribe, channel), do: {:channel, channel} 578 | defp key_for_target(:psubscribe, pattern), do: {:pattern, pattern} 579 | defp key_for_target(:punsubscribe, pattern), do: {:pattern, pattern} 580 | 581 | defp connect(%__MODULE__{opts: opts, transport: transport} = data) do 582 | timeout = Keyword.fetch!(opts, :timeout) 583 | fetch_client_id? = Keyword.fetch!(opts, :fetch_client_id_on_connect) 584 | 585 | with {:ok, socket, address} <- Connector.connect(opts, _conn_pid = self()), 586 | {:ok, client_id} <- maybe_fetch_client_id(fetch_client_id?, transport, socket, timeout), 587 | :ok <- setopts(data, socket, active: :once) do 588 | {:ok, socket, address, client_id} 589 | end 590 | end 591 | 592 | defp maybe_fetch_client_id(false, _transport, _socket, _timeout), 593 | do: {:ok, nil} 594 | 595 | defp maybe_fetch_client_id(true, transport, socket, timeout), 596 | do: Connector.sync_command(transport, socket, ["CLIENT", "ID"], timeout) 597 | 598 | defp setopts(data, socket, opts) do 599 | inets_mod(data.transport).setopts(socket, opts) 600 | end 601 | 602 | defp inets_mod(:gen_tcp), do: :inet 603 | defp inets_mod(:ssl), do: :ssl 604 | 605 | defp next_backoff(data) do 606 | backoff_current = data.backoff_current || data.opts[:backoff_initial] 607 | backoff_max = data.opts[:backoff_max] 608 | next_backoff = round(backoff_current * @backoff_exponent) 609 | 610 | backoff_current = 611 | if backoff_max == :infinity do 612 | next_backoff 613 | else 614 | min(next_backoff, backoff_max) 615 | end 616 | 617 | {backoff_current, put_in(data.backoff_current, backoff_current)} 618 | end 619 | 620 | def disconnect(data, reason, handle_disconnection?) do 621 | {next_backoff, data} = next_backoff(data) 622 | 623 | if data.socket do 624 | _ = data.transport.close(data.socket) 625 | end 626 | 627 | data = put_in(data.last_disconnect_reason, %ConnectionError{reason: reason}) 628 | data = put_in(data.socket, nil) 629 | data = put_in(data.continuation, nil) 630 | 631 | actions = [{{:timeout, :reconnect}, next_backoff, nil}] 632 | 633 | actions = 634 | if handle_disconnection? do 635 | [{:next_event, :internal, :handle_disconnection}] ++ actions 636 | else 637 | actions 638 | end 639 | 640 | {:next_state, :disconnected, data, actions} 641 | end 642 | 643 | defp send(pid, ref, kind, properties) 644 | when is_pid(pid) and is_reference(ref) and is_atom(kind) and is_map(properties) do 645 | send(pid, {:redix_pubsub, self(), ref, kind, properties}) 646 | end 647 | 648 | defp format_address(%{opts: opts} = _state) do 649 | if opts[:sentinel] do 650 | "sentinel" 651 | else 652 | Format.format_host_and_port(opts[:host], opts[:port]) 653 | end 654 | end 655 | end 656 | -------------------------------------------------------------------------------- /lib/redix/socket_owner.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.SocketOwner do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | alias Redix.{Connector, Protocol} 7 | 8 | defstruct [ 9 | :conn, 10 | :opts, 11 | :transport, 12 | :socket, 13 | :queue_table, 14 | :continuation 15 | ] 16 | 17 | def start_link(conn, opts, queue_table) do 18 | GenServer.start_link(__MODULE__, {conn, opts, queue_table}, []) 19 | end 20 | 21 | def normal_stop(conn) do 22 | GenServer.stop(conn, :normal) 23 | end 24 | 25 | @impl true 26 | def init({conn, opts, queue_table}) do 27 | state = %__MODULE__{ 28 | conn: conn, 29 | opts: opts, 30 | queue_table: queue_table, 31 | transport: if(opts[:ssl], do: :ssl, else: :gen_tcp) 32 | } 33 | 34 | send(self(), :connect) 35 | 36 | {:ok, state} 37 | end 38 | 39 | @impl true 40 | def handle_info(msg, state) 41 | 42 | def handle_info(:connect, state) do 43 | with {:ok, socket, address} <- Connector.connect(state.opts, state.conn), 44 | :ok <- setopts(state, socket, active: :once) do 45 | send(state.conn, {:connected, self(), socket, address}) 46 | {:noreply, %{state | socket: socket}} 47 | else 48 | {:error, reason} -> stop(reason, state) 49 | {:stop, reason} -> stop(reason, state) 50 | end 51 | end 52 | 53 | # The connection is notifying the socket owner that sending failed. If the socket owner 54 | # gets this, it can stop normally without waiting for the "closed"/"error" network 55 | # message from the socket. 56 | def handle_info({:send_errored, conn}, %__MODULE__{conn: conn} = state) do 57 | error = 58 | case state.transport do 59 | :ssl -> {:ssl_error, :closed} 60 | :gen_tcp -> {:tcp_error, :closed} 61 | end 62 | 63 | stop(error, state) 64 | end 65 | 66 | def handle_info({transport, socket, data}, %__MODULE__{socket: socket} = state) 67 | when transport in [:tcp, :ssl] do 68 | :ok = setopts(state, socket, active: :once) 69 | state = new_data(state, data) 70 | {:noreply, state} 71 | end 72 | 73 | def handle_info({:tcp_closed, socket}, %__MODULE__{socket: socket} = state) do 74 | stop(:tcp_closed, state) 75 | end 76 | 77 | def handle_info({:tcp_error, socket, reason}, %__MODULE__{socket: socket} = state) do 78 | stop({:tcp_error, reason}, state) 79 | end 80 | 81 | def handle_info({:ssl_closed, socket}, %__MODULE__{socket: socket} = state) do 82 | stop(:ssl_closed, state) 83 | end 84 | 85 | def handle_info({:ssl_error, socket, reason}, %__MODULE__{socket: socket} = state) do 86 | stop({:ssl_error, reason}, state) 87 | end 88 | 89 | ## Helpers 90 | 91 | defp setopts(%__MODULE__{transport: transport}, socket, opts) do 92 | case transport do 93 | :ssl -> :ssl.setopts(socket, opts) 94 | :gen_tcp -> :inet.setopts(socket, opts) 95 | end 96 | end 97 | 98 | defp new_data(state, _data = "") do 99 | state 100 | end 101 | 102 | defp new_data(%{continuation: nil} = state, data) do 103 | ncommands = peek_element_in_queue(state.queue_table, 3) 104 | continuation = &Protocol.parse_multi(&1, ncommands) 105 | new_data(%{state | continuation: continuation}, data) 106 | end 107 | 108 | defp new_data(%{continuation: continuation} = state, data) do 109 | case continuation.(data) do 110 | {:ok, resp, rest} -> 111 | {_counter, {pid, request_id}, _ncommands, timed_out?} = 112 | take_first_in_queue(state.queue_table) 113 | 114 | if not timed_out? do 115 | send(pid, {request_id, {:ok, resp}}) 116 | end 117 | 118 | new_data(%{state | continuation: nil}, rest) 119 | 120 | {:continuation, cont} -> 121 | %{state | continuation: cont} 122 | end 123 | end 124 | 125 | defp peek_element_in_queue(queue_table, index) do 126 | case :ets.first(queue_table) do 127 | :"$end_of_table" -> 128 | # We can blow up here because there is nothing we can do. 129 | # See https://github.com/whatyouhide/redix/issues/192 130 | raise """ 131 | failed to find an original command in the commands queue. This can happen, for example, \ 132 | when the Redis server you are using does not support CLIENT commands. Redix issues \ 133 | CLIENT commands under the hood when you use noreply_pipeline/3 and other noreply_* \ 134 | functions. If that's not what you are doing, you might have found a bug in Redix: \ 135 | please open an issue! https://github.com/whatyouhide/redix/issues 136 | """ 137 | 138 | first_key -> 139 | :ets.lookup_element(queue_table, first_key, index) 140 | end 141 | end 142 | 143 | defp take_first_in_queue(queue_table) do 144 | first_key = :ets.first(queue_table) 145 | [first_client] = :ets.take(queue_table, first_key) 146 | first_client 147 | end 148 | 149 | defp stop(reason, %__MODULE__{conn: conn} = state) do 150 | send(conn, {:stopped, self(), reason}) 151 | {:stop, :normal, state} 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/redix/start_options.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.StartOptions do 2 | @moduledoc false 3 | 4 | @default_timeout 5_000 5 | 6 | start_link_opts_schema = [ 7 | host: [ 8 | type: {:custom, __MODULE__, :__validate_host__, []}, 9 | doc: """ 10 | the host where the Redis server is running. If you are using a Redis URI, you cannot 11 | use this option. Defaults to `"localhost`". 12 | """, 13 | type_doc: "`t:String.t/0`" 14 | ], 15 | port: [ 16 | type: :non_neg_integer, 17 | doc: """ 18 | the port on which the Redis server is running. If you are using a Redis URI, you cannot 19 | use this option. Defaults to `6379`. 20 | """ 21 | ], 22 | database: [ 23 | type: {:or, [:non_neg_integer, :string]}, 24 | doc: """ 25 | the database to connect to. Defaults to `nil`, meaning Redix doesn't connect to a 26 | specific database (the default in this case is database `0`). When this option is provided, 27 | all Redix does is issue a `SELECT` command to Redis in order to select the given database. 28 | """, 29 | type_doc: "`t:String.t/0` or `t:non_neg_integer/0`" 30 | ], 31 | username: [ 32 | type: {:or, [:string, {:in, [nil]}]}, 33 | doc: """ 34 | the username to connect to Redis. Defaults to `nil`, meaning no username is used. 35 | Redis supports usernames only since Redis 6 (see the [ACL 36 | documentation](https://redis.io/topics/acl)). If a username is provided (either via 37 | options or via URIs) and the Redis version used doesn't support ACL, then Redix falls 38 | back to using just the password and emits a warning. In future Redix versions, Redix 39 | will raise if a username is passed and the Redis version used doesn't support ACL. 40 | """ 41 | ], 42 | password: [ 43 | type: {:or, [:string, :mfa]}, 44 | type_doc: "`t:Redix.password/0`", 45 | doc: """ 46 | the password used to connect to Redis. Defaults to 47 | `nil`, meaning no password is used. When this option is provided, all Redix 48 | does is issue an `AUTH` command to Redis in order to authenticate. MFAs are also 49 | supported in the form of `{module, function, arguments}`. This can be used 50 | to fetch the password dynamically on every reconnection but most importantly to 51 | hide the password from crash reports in case the Redix connection crashes for 52 | any reason. For example, you can set this option to: 53 | `{System, :fetch_env!, ["REDIX_PASSWORD"]}`. 54 | """ 55 | ], 56 | timeout: [ 57 | type: :timeout, 58 | default: @default_timeout, 59 | doc: """ 60 | connection timeout (in milliseconds) directly passed to the network layer. 61 | """ 62 | ], 63 | sync_connect: [ 64 | type: :boolean, 65 | default: false, 66 | doc: """ 67 | decides whether Redix should initiate the network connection to the Redis server *before* 68 | or *after* returning from `start_link/1`. This option also changes some reconnection 69 | semantics; read the "Reconnections" page in the documentation for more information. 70 | """ 71 | ], 72 | exit_on_disconnection: [ 73 | type: :boolean, 74 | default: false, 75 | doc: """ 76 | if `true`, the Redix server will exit if it fails to connect or disconnects from Redis. 77 | Note that setting this option to `true` means that the `:backoff_initial` and 78 | `:backoff_max` options will be ignored. 79 | """ 80 | ], 81 | backoff_initial: [ 82 | type: :non_neg_integer, 83 | default: 500, 84 | doc: """ 85 | the initial backoff time (in milliseconds), which is the time that the Redix process 86 | will wait before attempting to reconnect to Redis after a disconnection or failed first 87 | connection. See the "Reconnections" page in the docs for more information. 88 | """ 89 | ], 90 | backoff_max: [ 91 | type: :timeout, 92 | default: 30_000, 93 | doc: """ 94 | the maximum length (in milliseconds) of the time interval used between reconnection 95 | attempts. See the "Reconnections" page in the docs for more information. 96 | """ 97 | ], 98 | ssl: [ 99 | type: :boolean, 100 | default: false, 101 | doc: """ 102 | if `true`, connect through SSL, otherwise through TCP. The `:socket_opts` option applies 103 | to both SSL and TCP, so it can be used for things like certificates. See `:ssl.connect/4`. 104 | """ 105 | ], 106 | name: [ 107 | type: :any, 108 | doc: """ 109 | Redix is bound to the same registration rules as a `GenServer`. See the `GenServer` 110 | documentation for more information. 111 | """ 112 | ], 113 | socket_opts: [ 114 | type: {:list, :any}, 115 | default: [], 116 | doc: """ 117 | specifies a list of options that are passed to the network layer when connecting to 118 | the Redis server. Some socket options (like `:active` or `:binary`) will be 119 | overridden by Redix so that it functions properly. 120 | 121 | If `ssl: true`, then these are added to the default: `[verify: :verify_peer, depth: 3]`. 122 | If you are using Erlang/OTP 25+ or if the `CAStore` dependency is available, the 123 | `:cacerts` or `:cacertfile` option is added to the SSL options by default as well, 124 | unless either option is already specified. 125 | """ 126 | ], 127 | hibernate_after: [ 128 | type: :non_neg_integer, 129 | doc: """ 130 | if present, the Redix connection process awaits any message for the given number 131 | of milliseconds and if no message is received, the process goes into hibernation 132 | automatically (by calling `:proc_lib.hibernate/3`). See `t::gen_statem.start_opt/0`. 133 | Not present by default. 134 | """ 135 | ], 136 | spawn_opt: [ 137 | type: :keyword_list, 138 | doc: """ 139 | if present, its value is passed as options to the Redix connection process as in 140 | `Process.spawn/4`. See `t::gen_statem.start_opt/0`. Not present by default. 141 | """ 142 | ], 143 | debug: [ 144 | type: :keyword_list, 145 | doc: """ 146 | if present, the corresponding function in the 147 | [`:sys` module](http://www.erlang.org/doc/man/sys.html) is invoked. 148 | """ 149 | ], 150 | fetch_client_id_on_connect: [ 151 | type: :boolean, 152 | default: false, 153 | doc: """ 154 | if `true`, Redix will fetch the client ID after connecting to Redis and before 155 | subscribing to any topic. You can then read the client ID of the pub/sub connection 156 | with `get_client_id/1`. This option uses the `CLIENT ID` command under the hood, 157 | which is available since Redis 5.0.0. *This option is available since v1.4.1*. 158 | """ 159 | ], 160 | sentinel: [ 161 | type: :keyword_list, 162 | doc: """ 163 | options to use Redis Sentinel. If this option is present, you cannot use the `:host` and 164 | `:port` options. See the [*Sentinel Options* section below](#start_link/1-sentinel-options). 165 | """, 166 | subsection: "### Sentinel Options", 167 | keys: [ 168 | sentinels: [ 169 | type: {:custom, __MODULE__, :__validate_sentinels__, []}, 170 | required: true, 171 | type_doc: "list of `t:String.t/0` or `t:keyword/0`", 172 | doc: """ 173 | a list of sentinel addresses. Each element in this list is the address 174 | of a sentinel to be contacted in order to obtain the address of a primary. The address of 175 | a sentinel can be passed as a Redis URI (see the "Using a Redis URI" section) or 176 | a keyword list with `:host`, `:port`, `:password` options (same as when connecting to a 177 | Redis instance directly). Note that the password can either be passed in the sentinel 178 | address or globally — see the `:password` option below. 179 | """ 180 | ], 181 | group: [ 182 | type: :string, 183 | required: true, 184 | doc: """ 185 | the name of the group that identifies the primary in the sentinel configuration. 186 | """ 187 | ], 188 | role: [ 189 | type: {:in, [:primary, :replica]}, 190 | default: :primary, 191 | type_doc: "`t:Redix.sentinel_role/0`", 192 | doc: """ 193 | if `:primary`, the connection will be established 194 | with the primary for the given group. If `:replica`, Redix will ask the sentinel for all 195 | the available replicas for the given group and try to connect to one of them 196 | **at random**. 197 | """ 198 | ], 199 | socket_opts: [ 200 | type: :keyword_list, 201 | default: [], 202 | doc: """ 203 | socket options for connecting to each sentinel. Same as the `:socket_opts` option 204 | described above. 205 | """ 206 | ], 207 | timeout: [ 208 | type: :timeout, 209 | default: 500, 210 | doc: """ 211 | the timeout (in milliseconds or `:infinity`) that will be used to 212 | interact with the sentinels. This timeout will be used as the timeout when connecting to 213 | each sentinel and when asking sentinels for a primary. The Redis documentation suggests 214 | to keep this timeout short so that connection to Redis can happen quickly. 215 | """ 216 | ], 217 | ssl: [ 218 | type: :boolean, 219 | default: false, 220 | doc: """ 221 | whether to use SSL to connect to each sentinel. 222 | """ 223 | ], 224 | password: [ 225 | type: {:or, [:string, :mfa]}, 226 | type_doc: "`t:Redix.password/0`", 227 | doc: """ 228 | if you don't want to specify a password for each sentinel you 229 | list, you can use this option to specify a password that will be used to authenticate 230 | on sentinels if they don't specify a password. This option is recommended over passing 231 | a password for each sentinel because in the future we might do sentinel auto-discovery, 232 | which means authentication can only be done through a global password that works for all 233 | sentinels. 234 | """ 235 | ] 236 | ] 237 | ] 238 | ] 239 | 240 | @redix_start_link_opts_schema start_link_opts_schema 241 | |> Keyword.drop([:fetch_client_id_on_connect]) 242 | |> NimbleOptions.new!() 243 | @redix_pubsub_start_link_opts_schema NimbleOptions.new!(start_link_opts_schema) 244 | 245 | @spec options_docs(:redix | :redix_pubsub) :: String.t() 246 | def options_docs(:redix), do: NimbleOptions.docs(@redix_start_link_opts_schema) 247 | def options_docs(:redix_pubsub), do: NimbleOptions.docs(@redix_pubsub_start_link_opts_schema) 248 | 249 | @spec sanitize(:redix | :redix_pubsub, keyword()) :: keyword() 250 | def sanitize(conn_type, options) when is_list(options) do 251 | schema = 252 | case conn_type do 253 | :redix -> @redix_start_link_opts_schema 254 | :redix_pubsub -> @redix_pubsub_start_link_opts_schema 255 | end 256 | 257 | options 258 | |> NimbleOptions.validate!(schema) 259 | |> maybe_sanitize_sentinel_opts() 260 | |> maybe_sanitize_host_and_port() 261 | end 262 | 263 | defp maybe_sanitize_sentinel_opts(options) do 264 | case Keyword.fetch(options, :sentinel) do 265 | {:ok, sentinel_opts} -> 266 | if Keyword.has_key?(options, :host) or Keyword.has_key?(options, :port) do 267 | raise ArgumentError, ":host or :port can't be passed as option if :sentinel is used" 268 | end 269 | 270 | sentinel_opts = 271 | Keyword.update!( 272 | sentinel_opts, 273 | :sentinels, 274 | &Enum.map(&1, fn opts -> 275 | Keyword.merge(Keyword.take(sentinel_opts, [:password]), opts) 276 | end) 277 | ) 278 | 279 | Keyword.replace!(options, :sentinel, sentinel_opts) 280 | 281 | :error -> 282 | options 283 | end 284 | end 285 | 286 | defp maybe_sanitize_host_and_port(options) do 287 | if Keyword.has_key?(options, :sentinel) do 288 | options 289 | else 290 | {host, port} = 291 | case {Keyword.get(options, :host, "localhost"), Keyword.fetch(options, :port)} do 292 | {{:local, _unix_socket_path} = host, {:ok, 0}} -> 293 | {host, 0} 294 | 295 | {{:local, _unix_socket_path}, {:ok, non_zero_port}} -> 296 | raise ArgumentError, 297 | "when using Unix domain sockets, the port must be 0, got: #{inspect(non_zero_port)}" 298 | 299 | {{:local, _unix_socket_path} = host, :error} -> 300 | {host, 0} 301 | 302 | {host, {:ok, port}} when is_binary(host) -> 303 | {String.to_charlist(host), port} 304 | 305 | {host, :error} when is_binary(host) -> 306 | {String.to_charlist(host), 6379} 307 | end 308 | 309 | Keyword.merge(options, host: host, port: port) 310 | end 311 | end 312 | 313 | def __validate_sentinels__([_ | _] = sentinels) do 314 | sentinels = Enum.map(sentinels, &normalize_sentinel_address/1) 315 | {:ok, sentinels} 316 | end 317 | 318 | def __validate_sentinels__([]) do 319 | {:error, "expected :sentinels to be a non-empty list"} 320 | end 321 | 322 | def __validate_sentinels__(other) do 323 | {:error, "expected :sentinels to be a non-empty list, got: #{inspect(other)}"} 324 | end 325 | 326 | def __validate_host__(host) when is_binary(host) do 327 | {:ok, host} 328 | end 329 | 330 | def __validate_host__({:local, path} = value) when is_binary(path) do 331 | {:ok, value} 332 | end 333 | 334 | defp normalize_sentinel_address(sentinel_uri) when is_binary(sentinel_uri) do 335 | sentinel_uri |> Redix.URI.to_start_options() |> normalize_sentinel_address() 336 | end 337 | 338 | defp normalize_sentinel_address(opts) when is_list(opts) do 339 | unless opts[:port] do 340 | raise ArgumentError, "a port should be specified for each sentinel" 341 | end 342 | 343 | if opts[:host] do 344 | Keyword.update!(opts, :host, &to_charlist/1) 345 | else 346 | raise ArgumentError, "a host should be specified for each sentinel" 347 | end 348 | end 349 | 350 | defp normalize_sentinel_address(other) do 351 | raise ArgumentError, 352 | "sentinel address should be specified as a URI or a keyword list, got: " <> 353 | inspect(other) 354 | end 355 | end 356 | -------------------------------------------------------------------------------- /lib/redix/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.Telemetry do 2 | @moduledoc """ 3 | Telemetry integration for event tracing, metrics, and logging. 4 | 5 | Redix connections (both `Redix` and `Redix.PubSub`) execute the 6 | following Telemetry events: 7 | 8 | * `[:redix, :connection]` - executed when a Redix connection establishes the 9 | connection to Redis. There are no measurements associated with this event. 10 | Metadata are: 11 | 12 | * `:connection` - the PID of the Redix connection that emitted the event. 13 | * `:connection_name` - the name (passed to the `:name` option when the 14 | connection is started) of the Redix connection that emitted the event. 15 | `nil` if the connection was not registered with a name. 16 | * `:address` - the address the connection successfully connected to. 17 | * `:reconnection` - a boolean that specifies whether this was a first 18 | connection to Redis or a reconnection after a disconnection. This can 19 | be useful for more granular logging. 20 | 21 | * `[:redix, :disconnection]` - executed when the connection is lost 22 | with the Redis server. There are no measurements associated with 23 | this event. Metadata are: 24 | 25 | * `:connection` - the PID of the Redix connection that emitted the event. 26 | * `:connection_name` - the name (passed to the `:name` option when the 27 | * `:address` - the address the connection was connected to. 28 | connection is started) of the Redix connection that emitted the event. 29 | `nil` if the connection was not registered with a name. 30 | * `:reason` - the disconnection reason as a `Redix.ConnectionError` struct. 31 | 32 | * `[:redix, :failed_connection]` - executed when Redix can't connect to 33 | the specified Redis server, either when starting up the connection or 34 | after a disconnection. There are no measurements associated with this event. 35 | Metadata are: 36 | 37 | * `:connection` - the PID of the Redix connection that emitted the event. 38 | * `:connection_name` - the name (passed to the `:name` option when the 39 | connection is started) of the Redix connection that emitted the event. 40 | `nil` if the connection was not registered with a name. 41 | * `:address` or `:sentinel_address` - the address the connection was trying 42 | to connect to (either a Redis server or a Redis Sentinel instance). 43 | * `:reason` - the disconnection reason as a `Redix.ConnectionError` struct. 44 | 45 | `Redix` connections execute the following Telemetry events when commands or 46 | pipelines of any kind are executed. 47 | 48 | * `[:redix, :pipeline, :start]` - executed right before a pipeline (or command, 49 | which is a pipeline with just one command) is sent to the Redis server. 50 | Measurements are: 51 | 52 | * `:system_time` (integer) - the system time (in the `:native` time unit) 53 | at the time the event is emitted. See `System.system_time/0`. 54 | 55 | Metadata are: 56 | 57 | * `:connection` - the PID of the Redix connection used to send the pipeline. 58 | * `:connection_name` - the name of the Redix connection used to sent the pipeline. 59 | This is `nil` if the connection was not registered with a name or if the 60 | pipeline function was called with a PID directly (for example, if you did 61 | `Process.whereis/1` manually). 62 | * `:commands` - the commands sent to the server. This is always a list of 63 | commands, so even if you do `Redix.command(conn, ["PING"])` then the 64 | list of commands will be `[["PING"]]`. 65 | * `:extra_metadata` - any term set by users via the `:telemetry_metadata` option 66 | in `Redix.pipeline/3` and other functions. 67 | 68 | * `[:redix, :pipeline, :stop]` - executed a response to a pipeline returns 69 | from the Redis server, regardless of whether it's an error response or a 70 | successful response. Measurements are: 71 | 72 | * `:duration` - the duration (in the `:native` time unit, see `t:System.time_unit/0`) 73 | of back-and-forth between client and server. 74 | 75 | Metadata are: 76 | 77 | * `:connection` - the PID of the Redix connection used to send the pipeline. 78 | * `:connection_name` - the name of the Redix connection used to sent the pipeline. 79 | This is `nil` if the connection was not registered with a name or if the 80 | pipeline function was called with a PID directly (for example, if you did 81 | `Process.whereis/1` manually). 82 | * `:commands` - the commands sent to the server. This is always a list of 83 | commands, so even if you do `Redix.command(conn, ["PING"])` then the 84 | list of commands will be `[["PING"]]`. 85 | * `:extra_metadata` - any term set by users via the `:telemetry_metadata` option 86 | in `Redix.pipeline/3` and other functions. 87 | 88 | If the response is an error, the following metadata will also be present: 89 | 90 | * `:kind` - the atom `:error`. 91 | * `:reason` - the error reason (such as a `Redix.ConnectionError` struct). 92 | 93 | More events might be added in the future and that won't be considered a breaking 94 | change, so if you're writing a handler for Redix events be sure to ignore events 95 | that are not known. All future Redix events will start with the `:redix` atom, 96 | like the ones above. 97 | 98 | A default handler that logs these events appropriately is provided, see 99 | `attach_default_handler/0`. Otherwise, you can write your own handler to 100 | instrument or log events, see the [Telemetry page](telemetry.html) in the docs. 101 | """ 102 | 103 | require Logger 104 | 105 | @doc """ 106 | Attaches the default Redix-provided Telemetry handler. 107 | 108 | This function attaches a default Redix-provided handler that logs 109 | (using Elixir's `Logger`) the following events: 110 | 111 | * `[:redix, :disconnection]` - logged at the `:error` level 112 | * `[:redix, :failed_connection]` - logged at the `:error` level 113 | * `[:redix, :connection]` - logged at the `:info` level if it's a 114 | reconnection, not logged if it's the first connection. 115 | 116 | See the module documentation for more information. If you want to 117 | attach your own handler, look at the [Telemetry page](telemetry.html) 118 | in the documentation. 119 | 120 | ## Examples 121 | 122 | :ok = Redix.Telemetry.attach_default_handler() 123 | 124 | """ 125 | @spec attach_default_handler() :: :ok | {:error, :already_exists} 126 | def attach_default_handler do 127 | events = [ 128 | [:redix, :disconnection], 129 | [:redix, :connection], 130 | [:redix, :failed_connection] 131 | ] 132 | 133 | :telemetry.attach_many( 134 | "redix-default-telemetry-handler", 135 | events, 136 | &__MODULE__.handle_event/4, 137 | :no_config 138 | ) 139 | end 140 | 141 | # This function handles only log-related events (disconnections, reconnections, and so on). 142 | @doc false 143 | @spec handle_event([atom()], map(), map(), :no_config) :: :ok 144 | def handle_event([:redix, event], _measurements, metadata, :no_config) 145 | when event in [:failed_connection, :disconnection, :connection] do 146 | connection_name = metadata.connection_name || metadata.connection 147 | 148 | case {event, metadata} do 149 | {:failed_connection, %{sentinel_address: sentinel_address}} 150 | when is_binary(sentinel_address) -> 151 | _ = 152 | Logger.error(fn -> 153 | "Connection #{inspect(connection_name)} failed to connect to sentinel " <> 154 | "at #{sentinel_address}: #{Exception.message(metadata.reason)}" 155 | end) 156 | 157 | {:failed_connection, _metadata} -> 158 | _ = 159 | Logger.error(fn -> 160 | "Connection #{inspect(connection_name)} failed to connect to Redis " <> 161 | "at #{metadata.address}: #{Exception.message(metadata.reason)}" 162 | end) 163 | 164 | {:disconnection, _metadata} -> 165 | _ = 166 | Logger.error(fn -> 167 | "Connection #{inspect(connection_name)} disconnected from Redis " <> 168 | "at #{metadata.address}: #{Exception.message(metadata.reason)}" 169 | end) 170 | 171 | {:connection, %{reconnection: true}} -> 172 | _ = 173 | Logger.info(fn -> 174 | "Connection #{inspect(connection_name)} reconnected to Redis " <> 175 | "at #{metadata.address}" 176 | end) 177 | 178 | {:connection, %{reconnection: false}} -> 179 | :ok 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/redix/uri.ex: -------------------------------------------------------------------------------- 1 | defmodule Redix.URI do 2 | @moduledoc """ 3 | This module provides functions to work with a Redis URI. 4 | 5 | This is generally intended for library developers using Redix under the hood. 6 | """ 7 | 8 | @doc """ 9 | Returns start options from a Redis URI. 10 | 11 | A **Redis URI** looks like this: 12 | 13 | redis://[username:password@]host[:port][/db] 14 | 15 | > #### Valkey {: .tip} 16 | > 17 | > URIs also work with [Valkey](https://valkey.io/), a Redis-compatible in-memory key-value 18 | > store. Use `valkey://` as the scheme instead of `redis://`. 19 | 20 | ## Examples 21 | 22 | iex> Redix.URI.to_start_options("redis://example.com") 23 | [host: "example.com"] 24 | 25 | iex> Redix.URI.to_start_options("rediss://username:password@example.com:5000/3") 26 | [ssl: true, database: 3, password: "password", username: "username", port: 5000, host: "example.com"] 27 | 28 | """ 29 | @doc since: "1.2.0" 30 | @spec to_start_options(binary()) :: Keyword.t() 31 | def to_start_options(uri) when is_binary(uri) do 32 | %URI{host: host, port: port, scheme: scheme} = uri = URI.parse(uri) 33 | 34 | unless scheme in ["redis", "rediss", "valkey"] do 35 | raise ArgumentError, 36 | "expected scheme to be redis://, valkey://, or rediss://, got: #{scheme}://" 37 | end 38 | 39 | {username, password} = username_and_password(uri) 40 | 41 | [] 42 | |> put_if_not_nil(:host, host) 43 | |> put_if_not_nil(:port, port) 44 | |> put_if_not_nil(:username, username) 45 | |> put_if_not_nil(:password, password) 46 | |> put_if_not_nil(:database, database(uri)) 47 | |> enable_ssl_if_secure_scheme(scheme) 48 | end 49 | 50 | defp username_and_password(%URI{userinfo: nil}) do 51 | {nil, nil} 52 | end 53 | 54 | defp username_and_password(%URI{userinfo: userinfo}) do 55 | case String.split(userinfo, ":", parts: 2) do 56 | ["", password] -> 57 | {nil, password} 58 | 59 | [username, password] -> 60 | {username, password} 61 | 62 | _other -> 63 | raise ArgumentError, 64 | "expected password in the Redis URI to be given as redis://:PASSWORD@HOST or " <> 65 | "redis://USERNAME:PASSWORD@HOST" 66 | end 67 | end 68 | 69 | defp database(%URI{path: path}) when path in [nil, "", "/"] do 70 | nil 71 | end 72 | 73 | defp database(%URI{path: "/" <> path = full_path}) do 74 | case Integer.parse(path) do 75 | {db, rest} when rest == "" or binary_part(rest, 0, 1) == "/" -> 76 | db 77 | 78 | _other -> 79 | raise ArgumentError, "expected database to be an integer, got: #{inspect(full_path)}" 80 | end 81 | end 82 | 83 | defp put_if_not_nil(opts, _key, nil), do: opts 84 | defp put_if_not_nil(opts, key, value), do: Keyword.put(opts, key, value) 85 | 86 | defp enable_ssl_if_secure_scheme(opts, "rediss"), do: Keyword.put(opts, :ssl, true) 87 | defp enable_ssl_if_secure_scheme(opts, _scheme), do: opts 88 | end 89 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.Mixfile do 2 | use Mix.Project 3 | 4 | @description "Fast, pipelined, resilient Redis driver for Elixir." 5 | 6 | @repo_url "https://github.com/whatyouhide/redix" 7 | 8 | @version "1.5.2" 9 | 10 | def project do 11 | [ 12 | app: :redix, 13 | version: @version, 14 | elixir: "~> 1.14", 15 | build_embedded: Mix.env() == :prod, 16 | start_permanent: Mix.env() == :prod, 17 | deps: deps(), 18 | 19 | # Tests 20 | test_coverage: [tool: ExCoveralls], 21 | 22 | # Dialyzer 23 | dialyzer: [ 24 | plt_local_path: "plts", 25 | plt_core_path: "plts" 26 | ], 27 | 28 | # Hex 29 | package: package(), 30 | description: @description, 31 | 32 | # Docs 33 | name: "Redix", 34 | docs: [ 35 | main: "Redix", 36 | source_ref: "v#{@version}", 37 | source_url: @repo_url, 38 | extras: [ 39 | "README.md", 40 | "pages/Reconnections.md", 41 | "pages/Real-world usage.md", 42 | "pages/Telemetry.md", 43 | "CHANGELOG.md", 44 | "LICENSE.txt": [title: "License"] 45 | ] 46 | ] 47 | ] 48 | end 49 | 50 | def application do 51 | [extra_applications: [:logger, :ssl]] 52 | end 53 | 54 | defp package do 55 | [ 56 | maintainers: ["Andrea Leopardi"], 57 | licenses: ["MIT"], 58 | links: %{"GitHub" => @repo_url, "Sponsor" => "https://github.com/sponsors/whatyouhide"} 59 | ] 60 | end 61 | 62 | defp deps do 63 | [ 64 | {:telemetry, "~> 0.4.0 or ~> 1.0"}, 65 | {:castore, "~> 0.1.0 or ~> 1.0", optional: true}, 66 | {:nimble_options, "~> 0.5.0 or ~> 1.0"}, 67 | 68 | # Dev and test dependencies 69 | {:dialyxir, "~> 1.4 and >= 1.4.2", only: [:dev, :test], runtime: false}, 70 | {:ex_doc, "~> 0.28", only: :dev}, 71 | {:excoveralls, "~> 0.17", only: :test}, 72 | {:propcheck, "~> 1.1", only: :test}, 73 | {:stream_data, "~> 1.1", only: [:dev, :test]} 74 | ] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 3 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 5 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 6 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 7 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 8 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 9 | "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, 10 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 13 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 15 | "propcheck": {:hex, :propcheck, "1.4.1", "c12908dbe6f572032928548089b34ff9d40672d5d70f1562e3a9e9058d226cc9", [:mix], [{:libgraph, "~> 0.13", [hex: :libgraph, repo: "hexpm", optional: false]}, {:proper, "~> 1.4", [hex: :proper, repo: "hexpm", optional: false]}], "hexpm", "e1b088f574785c3c7e864da16f39082d5599b3aaf89086d3f9be6adb54464b19"}, 16 | "proper": {:hex, :proper, "1.4.0", "89a44b8c39d28bb9b4be8e4d715d534905b325470f2e0ec5e004d12484a79434", [:rebar3], [], "hexpm", "18285842185bd33efbda97d134a5cb5a0884384db36119fee0e3cfa488568cbb"}, 17 | "stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"}, 18 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 19 | } 20 | -------------------------------------------------------------------------------- /pages/Real-world usage.md: -------------------------------------------------------------------------------- 1 | # Real-world usage 2 | 3 | Redix is a low-level driver, but it's still built to handle most stuff thrown at it. 4 | 5 | Redix is built to handle multiple Elixir processes sending commands to Redis through it at the same time. It takes advantage of TCP being a **full-duplex** protocol (bytes are sent in both directions, often at the same time) so that the TCP stream has bytes flowing in both directions (to and from Redis). For example, if two Elixir processes send a `PING` command to Redis via `Redix.command/2`, Redix will send both commands to Redis but will concurrently start listening for the reply to these commands; at a given point, both a `PING` command as well as the `PONG` response to a previous `PING` could be flowing in the TCP stream of the socket that Redix is using. 6 | 7 | There's a few different ways to use Redix and to pool connections for better high-load support. 8 | 9 | ## Single named Redix instance 10 | 11 | For many applications, a single global Redix instance is enough. This is true especially for applications where requests to Redis are not mapping one-to-one to things like user requests (that is, a request for each user). A common pattern in these cases is to have a named Redix process started under the supervision tree: 12 | 13 | ```elixir 14 | children = [ 15 | {Redix, name: :redix} 16 | ] 17 | ``` 18 | 19 | Once Redix is started and registered, you can use it with the given name from anywhere: 20 | 21 | ```elixir 22 | Redix.command(:redix, ["PING"]) 23 | #=> {:ok, "PONG"} 24 | ``` 25 | 26 | Note that this pattern extends to more than one global (named) Redix: for example, you could have a Redix process for handling big and infrequent requests and another one to handle short and frequent requests. 27 | 28 | ## Name-based pool 29 | 30 | When you want to have a pool of connections, you can start many connections and register them by name. Say you want to have a pool of five Redis connections. You can start these connections in a supervisor under your supervision tree and then create a wrapper module that calls connections from the pool. The wrapper can use any strategy to choose which connection to use, for example a random strategy. 31 | 32 | ```elixir 33 | defmodule MyApp.Redix do 34 | @pool_size 5 35 | 36 | def child_spec(_args) do 37 | # Specs for the Redix connections. 38 | children = 39 | for index <- 0..(@pool_size - 1) do 40 | Supervisor.child_spec({Redix, name: :"redix_#{index}"}, id: {Redix, index}) 41 | end 42 | 43 | # Spec for the supervisor that will supervise the Redix connections. 44 | %{ 45 | id: RedixSupervisor, 46 | type: :supervisor, 47 | start: {Supervisor, :start_link, [children, [strategy: :one_for_one]]} 48 | } 49 | end 50 | 51 | def command(command) do 52 | Redix.command(:"redix_#{random_index()}", command) 53 | end 54 | 55 | defp random_index do 56 | Enum.random(0..@pool_size - 1) 57 | end 58 | end 59 | ``` 60 | 61 | You can then start the Redix connections and their supervisor in the application's supervision tree: 62 | 63 | ```elixir 64 | def start(_type, _args) do 65 | children = [ 66 | MyApp.Redix, 67 | # ...other children 68 | ] 69 | 70 | Supervisor.start_link(children, strategy: :one_for_one) 71 | end 72 | ``` 73 | 74 | And then use the new wrapper in your application: 75 | 76 | ```elixir 77 | MyApp.Redix.command(["PING"]) 78 | #=> {:ok, "PONG"} 79 | ``` 80 | 81 | ### Caveats of the name-based pool 82 | 83 | The name-based pool works well enough for many use cases but it has a few caveats. 84 | 85 | The first one is that the load of requests to Redis is distributed fairly among the connections in the pool, but is not distributed in a "smart" way. For example, you might want to send less requests to connections that are behaving in a worse way, such as slower connections. This avoids bottling up connections that are already slow by sending more requests to them and distributes the load more evenly. 86 | 87 | The other caveat is that you need to think about possible race conditions when using this kind of pool since every time you issue a command you could be using a different connections. If you issue commands from the same process, things will work since the process will block until it receives a reply so we know that Redis received and processed the command before we can issue a new one. However, if you issue commands from different processes, you can't be sure of the order that they get processed by Redis. After all, this is often true when doing things from different processes and is not particularly Redix specific. 88 | -------------------------------------------------------------------------------- /pages/Reconnections.md: -------------------------------------------------------------------------------- 1 | # Reconnections 2 | 3 | Redix tries to be as resilient as possible. When the connection to Redis drops for some reason, a Redix process will try to reconnect to the Redis server. 4 | 5 | If there are pending requests to Redix when a disconnection happens, the `Redix` functions will return `{:error, %Redix.ConnectionError{reason: :disconnected}}` to the caller. The caller is responsible to retry the request if interested. 6 | 7 | The first reconnection attempts happens after a backoff interval decided by the `:backoff_initial` option. If this attempt succeeds, then Redix will start to function normally again. If this attempt fails, then subsequent reconnection attempts are made until one of them succeeds. The backoff interval between these subsequent reconnection attempts is increased exponentially (with a fixed factor of `1.5`). This means that the first attempt will be made after `n` milliseconds, the second one after `n * 1.5` milliseconds, the third one after `n * 1.5 * 1.5` milliseconds, and so on. Since this growth is exponential, it won't take many attempts before this backoff interval becomes large: because of this, `Redix.start_link/2` also accepts a `:backoff_max` option. which specifies the maximum backoff interval that should be used. The `:backoff_max` option can be used to simulate constant backoff after some exponential backoff attempts: for example, by passing `backoff_max: 5_000` and `backoff_initial: 5_000`, attempts will be made regularly every 5 seconds. 8 | 9 | ## Synchronous or asynchronous connection 10 | 11 | The `:sync_connect` option passed to `Redix.start_link/2` decides whether Redix should initiate the TCP connection to the Redis server *before* or *after* `Redix.start_link/2` returns. This option also changes the behaviour of Redix when the TCP connection can't be initiated at all. 12 | 13 | When `:sync_connect` is `false`, then a failed attempt to initially connect to the Redis server is treated exactly as a disconnection: attempts to reconnect are made as described above. This behaviour should be used when Redix is not a vital part of your application: your application should be prepared to handle Redis being down (for example, using the non "bang" variants to issue commands to Redis and handling `{:error, _}` tuples). 14 | 15 | When `:sync_connect` is `true`, then a failed attempt to initiate the connection to Redis will cause the Redix process to fail and exit. This might be what you want if Redis is vital to your application. 16 | 17 | ### If Redis is vital to your application 18 | 19 | You should use `sync_connect: true` if Redis is a vital part of your application: for example, if you plan to use a Redix process under your application's supervision tree, placed *before* the parts of your application that depend on it in the tree (so that this way, the application won't be started until a connection to Redis has been established). With `sync_connect: true`, disconnections after the TCP connection has been established will behave exactly as above (with reconnection attempts at given intervals). However, if your application can't function properly without Redix, then you want to use `exit_on_disconnection: true`. With this option, the connection will crash when a disconnection happens. With `:sync_connect` and `:exit_on_disconnection`, you can isolate the part of your application that can't work without Redis under a supervisor and bring that part down when Redix crashes: 20 | 21 | ```elixir 22 | isolated_children = [ 23 | {Redix, sync_connect: true, exit_on_disconnection: true}, 24 | MyApp.MyGenServer 25 | ] 26 | 27 | isolated_supervisor = %{ 28 | id: MyChildSupervisor, 29 | type: :supervisor, 30 | start: {Supervisor, :start_link, [isolated_children, [strategy: :rest_for_one]]}, 31 | } 32 | 33 | children = [ 34 | MyApp.Child1, 35 | isolated_supervisor, 36 | MyApp.Child2 37 | ] 38 | 39 | Supervisor.start_link(children, strategy: :one_for_one) 40 | ``` 41 | -------------------------------------------------------------------------------- /pages/Telemetry.md: -------------------------------------------------------------------------------- 1 | # Telemetry 2 | 3 | Since version v0.10.0, Redix uses [Telemetry][telemetry] for instrumentation and for having an extensible way of doing logging. Telemetry is a metrics and instrumentation library for Erlang and Elixir applications that is based on publishing events through a common interface and attaching handlers to handle those events. For more information about the library itself, see [its README][telemetry]. 4 | 5 | Before version v0.10.0, `Redix.start_link/1` and `Redix.PubSub.start_link/1` supported a `:log` option to control logging. For example, if you wanted to log disconnections at the `:error` level and reconnections and the `:debug` level, you would do: 6 | 7 | Redix.start_link(log: [disconnection: :error, reconnection: :debug]) 8 | 9 | The `:log` option is now removed in favour of either using the default Redix event handler or writing your own. 10 | 11 | For information on the Telemetry events that Redix emits, see `Redix.Telemetry`. 12 | 13 | ## Writing your own handler 14 | 15 | If you want control on how Redix events are logged or on what level they're logged at, you can use your own event handler. For example, you can create a module to handle these events: 16 | 17 | defmodule MyApp.RedixTelemetryHandler do 18 | require Logger 19 | 20 | def handle_event([:redix, event], _measurements, metadata, _config) do 21 | case event do 22 | :disconnection -> 23 | human_reason = Exception.message(metadata.reason) 24 | Logger.warn("Disconnected from #{metadata.address}: #{human_reason}") 25 | 26 | :failed_connection -> 27 | human_reason = Exception.message(metadata.reason) 28 | Logger.warn("Failed to connect to #{metadata.address}: #{human_reason}") 29 | 30 | :connection -> 31 | Logger.debug("Connected/reconnected to #{metadata.address}") 32 | end 33 | end 34 | end 35 | 36 | Once you have a module like this, you can attach it when your application starts: 37 | 38 | events = [ 39 | [:redix, :disconnection], 40 | [:redix, :failed_connection], 41 | [:redix, :connection] 42 | ] 43 | 44 | :telemetry.attach_many( 45 | "my-redix-log-handler", 46 | events, 47 | &MyApp.RedixTelemetryHandler.handle_event/4, 48 | :config_not_needed_here 49 | ) 50 | 51 | [telemetry]: https://github.com/beam-telemetry/telemetry 52 | -------------------------------------------------------------------------------- /test/docker/base_with_acl/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis:6.0.0-alpine 2 | 3 | COPY ./acl.conf acl.conf 4 | COPY ./redis.conf redis.conf 5 | 6 | CMD ["redis-server", "./redis.conf"] 7 | -------------------------------------------------------------------------------- /test/docker/base_with_acl/acl.conf: -------------------------------------------------------------------------------- 1 | user superuser on >superpass ~* +@all 2 | user readonly on >ropass ~* +@read 3 | -------------------------------------------------------------------------------- /test/docker/base_with_acl/redis.conf: -------------------------------------------------------------------------------- 1 | aclfile ./acl.conf 2 | -------------------------------------------------------------------------------- /test/docker/base_with_disallowed_client_command/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis:6.0.0-alpine 2 | 3 | COPY ./redis.conf redis.conf 4 | 5 | CMD ["redis-server", "./redis.conf"] 6 | -------------------------------------------------------------------------------- /test/docker/base_with_disallowed_client_command/redis.conf: -------------------------------------------------------------------------------- 1 | rename-command CLIENT "" 2 | -------------------------------------------------------------------------------- /test/docker/sentinel/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis:5.0.1-alpine 2 | 3 | COPY ./sentinel.conf sentinel1.conf 4 | COPY ./sentinel.conf sentinel2.conf 5 | COPY ./sentinel.conf sentinel3.conf 6 | COPY ./start.sh start.sh 7 | 8 | CMD ["sh", "start.sh"] 9 | -------------------------------------------------------------------------------- /test/docker/sentinel/sentinel.conf: -------------------------------------------------------------------------------- 1 | sentinel monitor main localhost 6381 2 2 | sentinel down-after-milliseconds main 5000 3 | sentinel failover-timeout main 180000 4 | sentinel parallel-syncs main 1 5 | logfile ./out.log 6 | -------------------------------------------------------------------------------- /test/docker/sentinel/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | redis-server --port 6381 --daemonize yes --logfile ./out.log 4 | redis-server --port 6382 --daemonize yes --slaveof localhost 6381 --logfile ./out.log 5 | 6 | redis-sentinel ./sentinel1.conf --port 26379 --daemonize yes --logfile ./out.log 7 | redis-sentinel ./sentinel2.conf --port 26380 --daemonize yes --logfile ./out.log 8 | redis-sentinel ./sentinel3.conf --port 26381 --daemonize yes --logfile ./out.log 9 | 10 | tail -n 1000000000 -f ./out.log 11 | -------------------------------------------------------------------------------- /test/docker/sentinel_with_auth/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis:5.0.1-alpine 2 | 3 | COPY ./sentinel.conf sentinel.conf 4 | COPY ./start.sh start.sh 5 | 6 | CMD ["sh", "start.sh"] 7 | -------------------------------------------------------------------------------- /test/docker/sentinel_with_auth/sentinel.conf: -------------------------------------------------------------------------------- 1 | requirepass sentinel-password 2 | 3 | sentinel monitor main localhost 6383 1 4 | sentinel down-after-milliseconds main 5000 5 | sentinel failover-timeout main 180000 6 | sentinel parallel-syncs main 1 7 | sentinel auth-pass main main-password 8 | 9 | logfile ./out.log 10 | -------------------------------------------------------------------------------- /test/docker/sentinel_with_auth/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | redis-server --port 6383 --daemonize yes --logfile ./out.log --requirepass main-password 4 | 5 | redis-sentinel ./sentinel.conf --port 26383 --daemonize yes --logfile ./out.log 6 | 7 | tail -n 1000000000 -f ./out.log 8 | -------------------------------------------------------------------------------- /test/redix/connection_error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.ConnectionErrorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Redix.ConnectionError 5 | 6 | test "Exception.message/1 with a POSIX reason" do 7 | assert Exception.message(%ConnectionError{reason: :eaddrinuse}) == "address already in use" 8 | end 9 | 10 | test "Exception.message/1 with an unknown reason" do 11 | assert Exception.message(%ConnectionError{reason: :unknown}) == "unknown POSIX error: unknown" 12 | end 13 | 14 | test "Exception.message/1 with a TCP/SSL closed message" do 15 | assert Exception.message(%ConnectionError{reason: :tcp_closed}) == "TCP connection closed" 16 | assert Exception.message(%ConnectionError{reason: :ssl_closed}) == "SSL connection closed" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/redix/format_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.FormatTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | alias Redix.Format 6 | 7 | describe "format_host_and_port/2" do 8 | property "with string host" do 9 | check all host <- string(:alphanumeric, min_length: 1, max_length: 10), 10 | port <- integer(0..65535) do 11 | assert Format.format_host_and_port(host, port) == host <> ":" <> Integer.to_string(port) 12 | end 13 | 14 | assert Format.format_host_and_port("example.com", 6432) == "example.com:6432" 15 | end 16 | 17 | property "with Unix path as host" do 18 | check all path <- string([?a..?z, ?/, ?.], min_length: 1, max_length: 30) do 19 | assert Format.format_host_and_port({:local, path}, 0) == path 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/redix/protocol_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.ProtocolTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | alias Redix.{Error, Protocol.ParseError} 6 | 7 | doctest Redix.Protocol 8 | 9 | describe "pack/1" do 10 | import Redix.Protocol, only: [pack: 1] 11 | 12 | test "empty array" do 13 | assert IO.iodata_to_binary(pack([])) == "*0\r\n" 14 | end 15 | 16 | test "regular strings" do 17 | assert IO.iodata_to_binary(pack(["foo", "bar"])) == "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n" 18 | assert IO.iodata_to_binary(pack(["with spaces "])) == "*1\r\n$12\r\nwith spaces \r\n" 19 | end 20 | 21 | test "unicode" do 22 | str = "føø" 23 | size = byte_size(str) 24 | assert IO.iodata_to_binary(pack([str])) == "*1\r\n$#{size}\r\n#{str}\r\n" 25 | end 26 | 27 | test "raw bytes (non printable)" do 28 | assert IO.iodata_to_binary(pack([<<1, 2>>])) == <<"*1\r\n$2\r\n", 1, 2, "\r\n">> 29 | end 30 | end 31 | 32 | describe "parse/1" do 33 | import Redix.Protocol, only: [parse: 1] 34 | 35 | test "empty strings" do 36 | assert {:continuation, fun} = parse("") 37 | assert is_function(fun, 1) 38 | end 39 | 40 | property "simple strings" do 41 | check all string <- string(:alphanumeric), 42 | split_command <- random_splits("+#{string}\r\n"), 43 | split_command_with_rest = append_to_last(split_command, "rest") do 44 | assert parse_with_continuations(split_command) == {:ok, string, ""} 45 | assert parse_with_continuations(split_command_with_rest) == {:ok, string, "rest"} 46 | end 47 | end 48 | 49 | property "errors" do 50 | check all error_message <- string(:alphanumeric), 51 | split_command <- random_splits("-#{error_message}\r\n"), 52 | split_command_with_rest = append_to_last(split_command, "rest") do 53 | error = %Error{message: error_message} 54 | 55 | assert parse_with_continuations(split_command) == {:ok, error, ""} 56 | 57 | error = %Error{message: error_message} 58 | 59 | assert parse_with_continuations(split_command_with_rest) == {:ok, error, "rest"} 60 | end 61 | end 62 | 63 | property "integers" do 64 | check all int <- integer(), 65 | split_command <- random_splits(":#{int}\r\n"), 66 | split_command_with_rest = append_to_last(split_command, "rest") do 67 | assert parse_with_continuations(split_command) == {:ok, int, ""} 68 | assert parse_with_continuations(split_command_with_rest) == {:ok, int, "rest"} 69 | end 70 | end 71 | 72 | property "bulk strings" do 73 | check all bin <- binary(), 74 | bin_size = byte_size(bin), 75 | command = "$#{bin_size}\r\n#{bin}\r\n", 76 | split_command <- random_splits(command), 77 | split_command_with_rest = append_to_last(split_command, "rest") do 78 | assert parse_with_continuations(split_command) == {:ok, bin, ""} 79 | assert parse_with_continuations(split_command_with_rest) == {:ok, bin, "rest"} 80 | end 81 | 82 | # nil 83 | check all split_command <- random_splits("$-1\r\n"), 84 | split_command_with_rest = append_to_last(split_command, "rest"), 85 | max_runs: 10 do 86 | assert parse_with_continuations(split_command) == {:ok, nil, ""} 87 | assert parse_with_continuations(split_command_with_rest) == {:ok, nil, "rest"} 88 | end 89 | 90 | # No CRLF after bulk string contents 91 | assert_raise ParseError, "expected CRLF, found: \"n\"", fn -> 92 | parse("$3\r\nfoonocrlf") 93 | end 94 | end 95 | 96 | property "arrays" do 97 | assert parse("*0\r\n") == {:ok, [], ""} 98 | assert parse("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n") == {:ok, ["foo", "bar"], ""} 99 | 100 | # Mixed types 101 | arr = "*5\r\n:1\r\n:2\r\n:3\r\n:4\r\n$6\r\nfoobar\r\n" 102 | assert parse(arr) == {:ok, [1, 2, 3, 4, "foobar"], ""} 103 | 104 | # Says it has only 1 value, has 2 105 | assert parse("*1\r\n:1\r\n:2\r\n") == {:ok, [1], ":2\r\n"} 106 | 107 | command = "*-1\r\n" 108 | 109 | check all split_command <- random_splits(command), 110 | split_command_with_rest = append_to_last(split_command, "rest") do 111 | assert parse_with_continuations(split_command) == {:ok, nil, ""} 112 | assert parse_with_continuations(split_command_with_rest) == {:ok, nil, "rest"} 113 | end 114 | 115 | # Null values (of different types) in the array 116 | arr = "*4\r\n$3\r\nfoo\r\n$-1\r\n$3\r\nbar\r\n*-1\r\n" 117 | assert parse(arr) == {:ok, ["foo", nil, "bar", nil], ""} 118 | 119 | # Nested 120 | arr = "*2\r\n*3\r\n:1\r\n:2\r\n:3\r\n*2\r\n+Foo\r\n-ERR Bar\r\n" 121 | assert parse(arr) == {:ok, [[1, 2, 3], ["Foo", %Error{message: "ERR Bar"}]], ""} 122 | 123 | payload = ["*", "1\r", "\n", "+OK", "\r\nrest"] 124 | assert parse_with_continuations(payload) == {:ok, ["OK"], "rest"} 125 | 126 | payload = ["*2", "\r\n*1", "\r\n", "+", "OK\r\n", ":1", "\r\n"] 127 | assert parse_with_continuations(payload) == {:ok, [["OK"], 1], ""} 128 | 129 | payload = ["*2\r\n+OK\r\n", "+OK\r\nrest"] 130 | assert parse_with_continuations(payload) == {:ok, ~w(OK OK), "rest"} 131 | end 132 | 133 | test "raising when the binary value has no type specifier" do 134 | msg = ~s[invalid type specifier ("f")] 135 | assert_raise ParseError, msg, fn -> parse("foo\r\n") end 136 | assert_raise ParseError, msg, fn -> parse("foo bar baz") end 137 | end 138 | 139 | test "when the binary it's an invalid integer" do 140 | assert_raise ParseError, ~S(expected integer, found: "\r"), fn -> parse(":\r\n") end 141 | assert_raise ParseError, ~S(expected integer, found: "\r"), fn -> parse(":-\r\n") end 142 | assert_raise ParseError, ~S(expected CRLF, found: "a"), fn -> parse(":43a\r\n") end 143 | assert_raise ParseError, ~S(expected integer, found: "f"), fn -> parse(":foo\r\n") end 144 | end 145 | end 146 | 147 | describe "parse_multi/2" do 148 | import Redix.Protocol, only: [parse_multi: 2] 149 | 150 | test "enough elements" do 151 | data = "+OK\r\n+OK\r\n+OK\r\n" 152 | assert parse_multi(data, 3) == {:ok, ~w(OK OK OK), ""} 153 | assert parse_multi(data, 2) == {:ok, ~w(OK OK), "+OK\r\n"} 154 | end 155 | 156 | test "not enough data" do 157 | data = ["+OK\r\n+OK\r\n", "+", "OK", "\r\nrest"] 158 | assert parse_with_continuations(data, &parse_multi(&1, 3)) == {:ok, ~w(OK OK OK), "rest"} 159 | end 160 | end 161 | 162 | defp random_splits(splittable_part) do 163 | bytes = for <>, do: byte 164 | 165 | bytes 166 | |> Enum.map(fn _byte -> frequency([{2, false}, {1, true}]) end) 167 | |> List.replace_at(length(bytes) - 1, constant(false)) 168 | |> fixed_list() 169 | |> map(fn split_points -> split_command(bytes, split_points, [""]) end) 170 | end 171 | 172 | defp split_command([byte | bytes], [true | split_points], [current | acc]) do 173 | split_command(bytes, split_points, ["", <> | acc]) 174 | end 175 | 176 | defp split_command([byte | bytes], [false | split_points], [current | acc]) do 177 | split_command(bytes, split_points, [<> | acc]) 178 | end 179 | 180 | defp split_command([], [], acc) do 181 | Enum.reverse(acc) 182 | end 183 | 184 | defp append_to_last(parts, unsplittable_part) do 185 | {first_parts, [last_part]} = Enum.split(parts, -1) 186 | first_parts ++ [last_part <> unsplittable_part] 187 | end 188 | 189 | defp parse_with_continuations(data) do 190 | parse_with_continuations(data, &Redix.Protocol.parse/1) 191 | end 192 | 193 | defp parse_with_continuations([data], parser_fun) do 194 | parser_fun.(data) 195 | end 196 | 197 | defp parse_with_continuations([first | rest], parser_fun) do 198 | import ExUnit.Assertions 199 | 200 | {rest, [last]} = Enum.split(rest, -1) 201 | 202 | assert {:continuation, cont} = parser_fun.(first) 203 | 204 | last_cont = 205 | Enum.reduce(rest, cont, fn data, cont_acc -> 206 | assert {:continuation, cont_acc} = cont_acc.(data) 207 | cont_acc 208 | end) 209 | 210 | last_cont.(last) 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /test/redix/pubsub_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.PubSubTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | alias Redix.{ConnectionError, PubSub} 7 | 8 | @moduletag :pubsub 9 | 10 | # See docker-compose.yml. 11 | @port 6380 12 | 13 | setup do 14 | {:ok, pubsub} = PubSub.start_link(port: @port) 15 | {:ok, conn} = Redix.start_link(port: @port) 16 | {:ok, %{pubsub: pubsub, conn: conn}} 17 | end 18 | 19 | test "using gen_statem options in start_link/2" do 20 | fullsweep_after = Enum.random(0..50000) 21 | {:ok, pid} = PubSub.start_link(port: @port, spawn_opt: [fullsweep_after: fullsweep_after]) 22 | {:garbage_collection, info} = Process.info(pid, :garbage_collection) 23 | assert info[:fullsweep_after] == fullsweep_after 24 | end 25 | 26 | test "client_id/1 should be available after start_link/2" do 27 | {:ok, pid} = PubSub.start_link(port: @port, fetch_client_id_on_connect: true) 28 | assert {:ok, client_id} = PubSub.get_client_id(pid) 29 | assert is_integer(client_id) 30 | end 31 | 32 | test "client_id/1 returns an error if connection fails" do 33 | {:ok, pid} = PubSub.start_link(port: 9999, name: :redix_pubsub_telemetry_failed_conn_test) 34 | assert {:error, %ConnectionError{reason: :closed}} = PubSub.get_client_id(pid) 35 | end 36 | 37 | test "client_id/1 returns an error if :fetch_client_id_on_connect is not true" do 38 | {:ok, pid} = PubSub.start_link(port: @port, fetch_client_id_on_connect: false) 39 | assert {:error, %ConnectionError{reason: :client_id_not_stored}} = PubSub.get_client_id(pid) 40 | end 41 | 42 | test "subscribe/unsubscribe flow", %{pubsub: pubsub, conn: conn} do 43 | # First, we subscribe. 44 | assert {:ok, ref} = PubSub.subscribe(pubsub, ["foo", "bar"], self()) 45 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}} 46 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "bar"}} 47 | 48 | assert subscribed_channels(conn) == MapSet.new(["foo", "bar"]) 49 | 50 | # Then, we test messages are routed correctly. 51 | Redix.command!(conn, ~w(PUBLISH foo hello)) 52 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "foo", payload: "hello"}} 53 | Redix.command!(conn, ~w(PUBLISH bar world)) 54 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "bar", payload: "world"}} 55 | 56 | # Then, we unsubscribe. 57 | assert PubSub.unsubscribe(pubsub, ["foo"], self()) == :ok 58 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :unsubscribed, %{channel: "foo"}} 59 | 60 | wait_until_passes(200, fn -> 61 | assert subscribed_channels(conn) == MapSet.new(["bar"]) 62 | end) 63 | 64 | # And finally, we test that we don't receive messages anymore for 65 | # unsubscribed channels, but we do for subscribed channels. 66 | Redix.command!(conn, ~w(PUBLISH foo hello)) 67 | refute_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "foo", payload: "hello"}} 68 | Redix.command!(conn, ~w(PUBLISH bar world)) 69 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "bar", payload: "world"}} 70 | 71 | # We check we didn't leak messages. 72 | refute_receive _any 73 | end 74 | 75 | test "psubscribe/punsubscribe flow", %{pubsub: pubsub, conn: conn} do 76 | assert {:ok, ref} = PubSub.psubscribe(pubsub, ["foo*", "ba?"], self()) 77 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :psubscribed, %{pattern: "foo*"}} 78 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :psubscribed, %{pattern: "ba?"}} 79 | 80 | Redix.pipeline!(conn, [ 81 | ~w(PUBLISH foo_1 foo_1), 82 | ~w(PUBLISH foo_2 foo_2), 83 | ~w(PUBLISH bar bar), 84 | ~w(PUBLISH barfoo barfoo) 85 | ]) 86 | 87 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :pmessage, 88 | %{payload: "foo_1", channel: "foo_1", pattern: "foo*"}} 89 | 90 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :pmessage, 91 | %{payload: "foo_2", channel: "foo_2", pattern: "foo*"}} 92 | 93 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :pmessage, 94 | %{payload: "bar", channel: "bar", pattern: "ba?"}} 95 | 96 | refute_receive {:redix_pubsub, ^pubsub, ^ref, :pmessage, %{payload: "barfoo"}} 97 | 98 | PubSub.punsubscribe(pubsub, "foo*", self()) 99 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :punsubscribed, %{pattern: "foo*"}} 100 | 101 | Redix.pipeline!(conn, [~w(PUBLISH foo_x foo_x), ~w(PUBLISH baz baz)]) 102 | 103 | refute_receive {:redix_pubsub, ^pubsub, ^ref, :pmessage, %{payload: "foo_x"}} 104 | 105 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :pmessage, 106 | %{payload: "baz", channel: "baz", pattern: "ba?"}} 107 | end 108 | 109 | test "subscribing the same pid to the same channel more than once has no effect", 110 | %{pubsub: pubsub, conn: conn} do 111 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", self()) 112 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}} 113 | 114 | assert {:ok, ^ref} = PubSub.subscribe(pubsub, "foo", self()) 115 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}} 116 | 117 | assert subscribed_channels(conn) == MapSet.new(["foo"]) 118 | 119 | Redix.command!(conn, ~w(PUBLISH foo hello)) 120 | 121 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "foo", payload: "hello"}} 122 | refute_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "foo", payload: "hello"}} 123 | end 124 | 125 | test "pubsub: unsubscribing a recipient doesn't affect other recipients", 126 | %{pubsub: pubsub, conn: conn} do 127 | channel = "foo" 128 | parent = self() 129 | mirror = spawn_link(fn -> message_mirror(parent) end) 130 | 131 | # Let's subscribe two different pids to the same channel. 132 | assert {:ok, ref} = PubSub.subscribe(pubsub, channel, self()) 133 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, _properties} 134 | assert {:ok, mirror_ref} = PubSub.subscribe(pubsub, channel, mirror) 135 | assert_receive {^mirror, {:redix_pubsub, ^pubsub, ^mirror_ref, :subscribed, _properties}} 136 | 137 | assert subscribed_channels(conn) == MapSet.new(["foo"]) 138 | 139 | # Let's ensure both those pids receive messages published on that channel. 140 | Redix.command!(conn, ["PUBLISH", channel, "hello"]) 141 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{payload: "hello"}} 142 | assert_receive {^mirror, {:redix_pubsub, ^pubsub, ^mirror_ref, :message, %{payload: "hello"}}} 143 | 144 | # Now let's unsubscribe just one pid from that channel. 145 | PubSub.unsubscribe(pubsub, channel, self()) 146 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :unsubscribed, %{channel: ^channel}} 147 | 148 | refute_receive {^mirror, 149 | {:redix_pubsub, ^pubsub, ^mirror_ref, :unsubscribed, %{channel: ^channel}}} 150 | 151 | # The connection is still connected to the channel. 152 | wait_until_passes(200, fn -> 153 | assert subscribed_channels(conn) == MapSet.new(["foo"]) 154 | end) 155 | 156 | # Publishing now should send a message to the non-unsubscribed pid. 157 | Redix.command!(conn, ["PUBLISH", channel, "hello"]) 158 | refute_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{payload: "hello"}} 159 | assert_receive {^mirror, {:redix_pubsub, ^pubsub, ^mirror_ref, :message, %{payload: "hello"}}} 160 | end 161 | 162 | test "if a pid crashes and then resubscribes right away it is resubscribed correctly", 163 | %{pubsub: pubsub} do 164 | parent = self() 165 | {pid, monitor_ref} = spawn_monitor(fn -> message_mirror(parent) end) 166 | 167 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", pid) 168 | assert_receive {^pid, {:redix_pubsub, ^pubsub, ^ref, :subscribed, _properties}} 169 | 170 | Process.exit(pid, :kill) 171 | assert_receive {:DOWN, ^monitor_ref, _, _, _} 172 | 173 | pid = spawn(fn -> message_mirror(parent) end) 174 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", pid) 175 | assert_receive {^pid, {:redix_pubsub, ^pubsub, ^ref, :subscribed, _properties}} 176 | end 177 | 178 | test "after unsubscribing from a channel, resubscribing one recipient resubscribes correctly", 179 | %{pubsub: pubsub, conn: conn} do 180 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", self()) 181 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, _properties} 182 | 183 | assert subscribed_channels(conn) == MapSet.new(["foo"]) 184 | 185 | Redix.command!(conn, ~w(PUBLISH foo hello)) 186 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{payload: "hello"}} 187 | 188 | assert :ok = PubSub.unsubscribe(pubsub, "foo", self()) 189 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :unsubscribed, _properties} 190 | 191 | wait_until_passes(200, fn -> 192 | assert subscribed_channels(conn) == MapSet.new() 193 | end) 194 | 195 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", self()) 196 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, _properties} 197 | 198 | assert subscribed_channels(conn) == MapSet.new(["foo"]) 199 | 200 | Redix.command!(conn, ~w(PUBLISH foo hello)) 201 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{payload: "hello"}} 202 | end 203 | 204 | test "recipients are monitored and the connection unsubcribes when they go down", 205 | %{pubsub: pubsub, conn: conn} do 206 | parent = self() 207 | mirror = spawn(fn -> message_mirror(parent) end) 208 | 209 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", mirror) 210 | assert_receive {^mirror, {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}}} 211 | 212 | # Before the mirror process goes down, we're subscribed to the channel. 213 | wait_until_passes(200, fn -> 214 | assert subscribed_channels(conn) == MapSet.new(["foo"]) 215 | end) 216 | 217 | # Let's just ensure no errors happen when we kill the recipient. 218 | Process.exit(mirror, :kill) 219 | 220 | # Since the only subscribed went down, we unsubscribe from the channel. 221 | wait_until_passes(200, fn -> 222 | assert subscribed_channels(conn) == MapSet.new() 223 | end) 224 | end 225 | 226 | test "disconnections/reconnections", %{pubsub: pubsub, conn: conn} do 227 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", self()) 228 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}} 229 | 230 | capture_log(fn -> 231 | Redix.command!(conn, ~w(CLIENT KILL TYPE pubsub)) 232 | 233 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :disconnected, properties} 234 | assert %{error: %Redix.ConnectionError{}} = properties 235 | 236 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}}, 1000 237 | end) 238 | 239 | Redix.command!(conn, ~w(PUBLISH foo hello)) 240 | 241 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "foo", payload: "hello"}}, 242 | 1000 243 | end 244 | 245 | test "emits connection-related events on disconnections and reconnections", %{conn: conn} do 246 | {test_name, _arity} = __ENV__.function 247 | 248 | parent = self() 249 | ref = make_ref() 250 | 251 | handler = fn event, measurements, meta, _config -> 252 | # We need to run this test only if was called for this Redix connection so that 253 | # we can run in parallel with the other tests. 254 | if meta.connection_name == :redix_pubsub_telemetry_test do 255 | assert measurements == %{} 256 | 257 | case event do 258 | [:redix, :connection] -> send(parent, {ref, :connected, meta}) 259 | [:redix, :disconnection] -> send(parent, {ref, :disconnected, meta}) 260 | end 261 | end 262 | end 263 | 264 | events = [[:redix, :connection], [:redix, :disconnection]] 265 | :ok = :telemetry.attach_many(to_string(test_name), events, handler, :no_config) 266 | 267 | {:ok, pubsub} = PubSub.start_link(port: @port, name: :redix_pubsub_telemetry_test) 268 | # Make sure to call subscribe/3 so that Redis considers this a PubSub connection. 269 | {:ok, pubsub_ref} = PubSub.subscribe(pubsub, "foo", self()) 270 | assert_receive {:redix_pubsub, ^pubsub, ^pubsub_ref, :subscribed, %{channel: "foo"}} 271 | 272 | assert_receive {^ref, :connected, meta}, 1000 273 | assert %{address: "localhost" <> _port, reconnection: false, connection: ^pubsub} = meta 274 | 275 | capture_log(fn -> 276 | # Assert that we effectively kill one client. 277 | assert Redix.command!(conn, ~w(CLIENT KILL TYPE pubsub)) == 1 278 | 279 | assert_receive {^ref, :disconnected, meta}, 1000 280 | assert %{address: "localhost" <> _port, connection: ^pubsub} = meta 281 | 282 | assert_receive {^ref, :connected, meta}, 1000 283 | assert %{address: "localhost" <> _port, reconnection: true, connection: ^pubsub} = meta 284 | end) 285 | end 286 | 287 | test "emits connection-related events on failed connections" do 288 | {test_name, _arity} = __ENV__.function 289 | 290 | parent = self() 291 | ref = make_ref() 292 | 293 | handler = fn event, measurements, meta, _config -> 294 | # We need to run this test only if was called for this Redix connection so that 295 | # we can run in parallel with the other tests. 296 | if meta.connection_name == :redix_pubsub_telemetry_failed_conn_test do 297 | assert event == [:redix, :failed_connection] 298 | assert measurements == %{} 299 | send(parent, {ref, :failed_connection, meta}) 300 | end 301 | end 302 | 303 | :telemetry.attach(to_string(test_name), [:redix, :failed_connection], handler, :no_config) 304 | 305 | {:ok, pubsub} = PubSub.start_link(port: 9999, name: :redix_pubsub_telemetry_failed_conn_test) 306 | # Make sure to call subscribe/3 so that Redis considers this a PubSub connection. 307 | {:ok, _pubsub_ref} = PubSub.subscribe(pubsub, "foo", self()) 308 | 309 | assert_receive {^ref, :failed_connection, meta}, 1000 310 | 311 | assert %{ 312 | address: "localhost:9999", 313 | reason: %ConnectionError{reason: :econnrefused}, 314 | connection: ^pubsub 315 | } = meta 316 | end 317 | 318 | @tag :capture_log 319 | test "subscribing while the connection is down", %{pubsub: pubsub, conn: conn} do 320 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", self()) 321 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}}, 1000 322 | 323 | Redix.command!(conn, ~w(CLIENT KILL TYPE pubsub)) 324 | 325 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :disconnected, _properties} 326 | 327 | assert {:ok, ^ref} = PubSub.subscribe(pubsub, "bar", self()) 328 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "bar"}}, 1000 329 | Redix.command!(conn, ~w(PUBLISH bar hello)) 330 | 331 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "bar", payload: "hello"}}, 332 | 1000 333 | end 334 | 335 | test ":exit_on_disconnection option", %{conn: conn} do 336 | {:ok, pubsub} = PubSub.start_link(port: @port, exit_on_disconnection: true) 337 | 338 | # We need to subscribe to something so that this client becomes a PubSub 339 | # client and we can kill it with "CLIENT KILL TYPE pubsub". 340 | assert {:ok, ref} = PubSub.subscribe(pubsub, "foo", self()) 341 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}} 342 | 343 | Process.flag(:trap_exit, true) 344 | 345 | capture_log(fn -> 346 | Redix.command!(conn, ~w(CLIENT KILL TYPE pubsub)) 347 | assert_receive {:EXIT, ^pubsub, %ConnectionError{reason: :tcp_closed}} 348 | end) 349 | end 350 | 351 | test "continuation gets cleared on reconnection", %{pubsub: pubsub} do 352 | assert {:ok, ref} = PubSub.subscribe(pubsub, "my_channel", self()) 353 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "my_channel"}} 354 | 355 | # This exposes internals but I couldn't think of a better way to simulate this situation 356 | {:connected, state} = :sys.get_state(pubsub) 357 | socket = state.socket 358 | 359 | send(pubsub, {:tcp, socket, "*3\r\n$7\r\nmessage\r\n$10\r\nmy_channel\r\n$10\r\nhello"}) 360 | send(pubsub, {:tcp_closed, socket}) 361 | 362 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :disconnected, %{error: _error}} 363 | 364 | assert {:disconnected, new_state} = :sys.get_state(pubsub) 365 | refute new_state.continuation 366 | end 367 | 368 | defp wait_until_passes(timeout, fun) when timeout <= 0 do 369 | fun.() 370 | end 371 | 372 | defp wait_until_passes(timeout, fun) do 373 | try do 374 | fun.() 375 | rescue 376 | ExUnit.AssertionError -> 377 | Process.sleep(10) 378 | wait_until_passes(timeout - 10, fun) 379 | end 380 | end 381 | 382 | defp subscribed_channels(conn) do 383 | conn 384 | |> Redix.command!(~w(PUBSUB CHANNELS)) 385 | |> MapSet.new() 386 | end 387 | 388 | # This function just sends back to this process every message it receives. 389 | defp message_mirror(parent) do 390 | receive do 391 | msg -> 392 | send(parent, {self(), msg}) 393 | message_mirror(parent) 394 | end 395 | end 396 | end 397 | -------------------------------------------------------------------------------- /test/redix/sentinel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.SentinelTest do 2 | use ExUnit.Case, async: true 3 | 4 | @sentinels [ 5 | "redis://localhost:26379", 6 | "redis://localhost:26380", 7 | [host: "localhost", port: 26381] 8 | ] 9 | 10 | setup do 11 | sentinel_config = [ 12 | sentinels: Enum.shuffle(@sentinels), 13 | group: "main", 14 | timeout: 500 15 | ] 16 | 17 | %{sentinel_config: sentinel_config} 18 | end 19 | 20 | test "connection can select primary", %{sentinel_config: sentinel_config} do 21 | primary = start_supervised!({Redix, sentinel: sentinel_config, sync_connect: true}) 22 | 23 | assert Redix.command!(primary, ["PING"]) == "PONG" 24 | assert Redix.command!(primary, ["CONFIG", "GET", "port"]) == ["port", "6381"] 25 | assert ["master", _, _] = Redix.command!(primary, ["ROLE"]) 26 | end 27 | 28 | test "connection can select replica", %{sentinel_config: sentinel_config} do 29 | sentinel_config = Keyword.put(sentinel_config, :role, :replica) 30 | 31 | replica = 32 | start_supervised!({Redix, sentinel: sentinel_config, sync_connect: true, timeout: 500}) 33 | 34 | assert Redix.command!(replica, ["PING"]) == "PONG" 35 | assert Redix.command!(replica, ["CONFIG", "GET", "port"]) == ["port", "6382"] 36 | assert ["slave" | _] = Redix.command!(replica, ["ROLE"]) 37 | end 38 | 39 | test "Redix.PubSub supports sentinel as well", %{sentinel_config: sentinel_config} do 40 | primary = start_supervised!({Redix, sentinel: sentinel_config, sync_connect: true}) 41 | {:ok, pubsub} = Redix.PubSub.start_link(sentinel: sentinel_config, sync_connect: true) 42 | 43 | {:ok, ref} = Redix.PubSub.subscribe(pubsub, "foo", self()) 44 | 45 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :subscribed, %{channel: "foo"}} 46 | 47 | Redix.command!(primary, ["PUBLISH", "foo", "hello"]) 48 | 49 | assert_receive {:redix_pubsub, ^pubsub, ^ref, :message, %{channel: "foo", payload: "hello"}} 50 | end 51 | 52 | @tag :capture_log 53 | test "when no sentinels are reachable" do 54 | Process.flag(:trap_exit, true) 55 | 56 | {:ok, conn} = 57 | Redix.start_link( 58 | sentinel: [sentinels: ["redis://nonexistent:9999"], group: "main"], 59 | exit_on_disconnection: true 60 | ) 61 | 62 | assert_receive {:EXIT, ^conn, error}, 10000 63 | assert %Redix.ConnectionError{reason: :no_viable_sentinel_connection} = error 64 | end 65 | 66 | test "sentinel supports password", %{sentinel_config: sentinel_config} do 67 | sentinel_config = 68 | Keyword.merge(sentinel_config, 69 | password: "sentinel-password", 70 | sentinels: ["redis://localhost:26383"] 71 | ) 72 | 73 | pid = 74 | start_supervised!( 75 | {Redix, sentinel: sentinel_config, password: "main-password", sync_connect: true} 76 | ) 77 | 78 | assert Redix.command!(pid, ["PING"]) == "PONG" 79 | end 80 | 81 | test "sentinel supports password mfa in sentinels list", %{sentinel_config: sentinel_config} do 82 | System.put_env("REDIX_SENTINEL_MFA_PASSWORD", "sentinel-password") 83 | 84 | password_mfa = {System, :get_env, ["REDIX_SENTINEL_MFA_PASSWORD"]} 85 | 86 | sentinel_config = 87 | Keyword.merge(sentinel_config, 88 | sentinels: [[host: "localhost", port: 26383, password: password_mfa]] 89 | ) 90 | 91 | pid = 92 | start_supervised!( 93 | {Redix, sentinel: sentinel_config, password: "main-password", sync_connect: true} 94 | ) 95 | 96 | assert Redix.command!(pid, ["PING"]) == "PONG" 97 | after 98 | System.delete_env("REDIX_SENTINEL_MFA_PASSWORD") 99 | end 100 | 101 | test "sentinel supports global password mfa", %{sentinel_config: sentinel_config} do 102 | System.put_env("REDIX_SENTINEL_MFA_PASSWORD", "sentinel-password") 103 | 104 | sentinel_config = 105 | Keyword.merge(sentinel_config, 106 | password: {System, :get_env, ["REDIX_SENTINEL_MFA_PASSWORD"]}, 107 | sentinels: ["redis://localhost:26383"] 108 | ) 109 | 110 | pid = 111 | start_supervised!( 112 | {Redix, sentinel: sentinel_config, password: "main-password", sync_connect: true} 113 | ) 114 | 115 | assert Redix.command!(pid, ["PING"]) == "PONG" 116 | after 117 | System.delete_env("REDIX_SENTINEL_MFA_PASSWORD") 118 | end 119 | 120 | test "failed sentinel connection" do 121 | {test_name, _arity} = __ENV__.function 122 | 123 | parent = self() 124 | ref = make_ref() 125 | 126 | handler = fn event, measurements, meta, _config -> 127 | if meta.connection_name == :failed_sentinel_telemetry_test do 128 | send(parent, {ref, event, measurements, meta}) 129 | end 130 | end 131 | 132 | :ok = 133 | :telemetry.attach(to_string(test_name), [:redix, :failed_connection], handler, :no_config) 134 | 135 | conn = 136 | start_supervised!( 137 | {Redix, 138 | name: :failed_sentinel_telemetry_test, 139 | sentinel: [group: "main", sentinels: ["redis://localhost:9999"]]} 140 | ) 141 | 142 | assert_receive {^ref, [:redix, :failed_connection], measurements, meta} 143 | assert measurements == %{} 144 | 145 | assert meta == %{ 146 | connection: conn, 147 | connection_name: :failed_sentinel_telemetry_test, 148 | reason: %Redix.ConnectionError{reason: :econnrefused}, 149 | sentinel_address: "localhost:9999" 150 | } 151 | 152 | :telemetry.detach(to_string(test_name)) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/redix/start_options_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.StartOptionsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Redix.StartOptions 5 | 6 | describe "sanitize/1" do 7 | test "fills in defaults" do 8 | opts = sanitize(host: "foo.com", backoff_max: 0, sync_connect: true) 9 | 10 | assert opts[:host] == ~c"foo.com" 11 | assert opts[:backoff_max] == 0 12 | assert opts[:sync_connect] == true 13 | end 14 | 15 | test "raises on unknown options" do 16 | assert_raise NimbleOptions.ValidationError, ~r/unknown options \[:foo\]/, fn -> 17 | sanitize(foo: "bar") 18 | end 19 | end 20 | 21 | test "raises if the port is not an integer" do 22 | assert_raise NimbleOptions.ValidationError, ~r/invalid value for :port option/, fn -> 23 | sanitize(port: :not_an_integer) 24 | end 25 | end 26 | 27 | test "host and port are filled in based on Unix sockets" do 28 | opts = sanitize([]) 29 | assert opts[:host] == ~c"localhost" 30 | assert opts[:port] == 6379 31 | 32 | opts = sanitize(host: {:local, "some_path"}) 33 | assert opts[:port] == 0 34 | 35 | opts = sanitize(host: {:local, "some_path"}, port: 0) 36 | assert opts[:port] == 0 37 | 38 | assert_raise ArgumentError, ~r/when using Unix domain sockets, the port must be 0/, fn -> 39 | sanitize(host: {:local, "some_path"}, port: 1) 40 | end 41 | end 42 | 43 | test "sentinel options" do 44 | opts = 45 | sanitize(sentinel: [sentinels: ["redis://localhost:26379"], group: "foo"]) 46 | 47 | assert [sentinel] = opts[:sentinel][:sentinels] 48 | assert sentinel[:host] == ~c"localhost" 49 | assert sentinel[:port] == 26379 50 | 51 | assert opts[:sentinel][:group] == "foo" 52 | end 53 | 54 | test "sentinel addresses are validated" do 55 | message = ~r/sentinel address should be specified/ 56 | 57 | assert_raise ArgumentError, message, fn -> 58 | sanitize(sentinel: [sentinels: [:not_a_sentinel], group: "foo"]) 59 | end 60 | end 61 | 62 | test "sentinel options should have a :sentinels option" do 63 | assert_raise NimbleOptions.ValidationError, ~r/required :sentinels option not found/, fn -> 64 | sanitize(sentinel: []) 65 | end 66 | end 67 | 68 | test "sentinel options should have a :group option" do 69 | assert_raise NimbleOptions.ValidationError, ~r/required :group option not found/, fn -> 70 | sanitize(sentinel: [sentinels: ["redis://localhos:6379"]]) 71 | end 72 | end 73 | 74 | test "sentinel options should have a non-empty list in :sentinels" do 75 | assert_raise NimbleOptions.ValidationError, ~r/invalid value for :sentinels option/, fn -> 76 | sanitize(sentinel: [sentinels: :not_a_list]) 77 | end 78 | 79 | assert_raise NimbleOptions.ValidationError, ~r/invalid value for :sentinels option/, fn -> 80 | sanitize(sentinel: [sentinels: []]) 81 | end 82 | end 83 | 84 | test "every sentinel address must have a host and a port" do 85 | assert_raise ArgumentError, "a host should be specified for each sentinel", fn -> 86 | sanitize(sentinel: [sentinels: ["redis://:6379"]]) 87 | end 88 | 89 | assert_raise ArgumentError, "a port should be specified for each sentinel", fn -> 90 | sanitize(sentinel: [sentinels: ["redis://localhost"]]) 91 | end 92 | end 93 | 94 | test "if sentinel options are passed, :host and :port cannot be passed" do 95 | message = ":host or :port can't be passed as option if :sentinel is used" 96 | 97 | assert_raise ArgumentError, message, fn -> 98 | sanitize( 99 | sentinel: [sentinels: ["redis://localhost:6379"], group: "foo"], 100 | host: "localhost" 101 | ) 102 | end 103 | end 104 | 105 | test "sentinel password string" do 106 | opts = 107 | sanitize( 108 | sentinel: [ 109 | sentinels: [ 110 | [host: "host1", port: 26379, password: "secret1"], 111 | [host: "host2", port: 26379] 112 | ], 113 | group: "mygroup", 114 | password: "secret2" 115 | ] 116 | ) 117 | 118 | sentinels = opts[:sentinel][:sentinels] 119 | 120 | assert Enum.count(sentinels) == 2 121 | assert Enum.find(sentinels, &(&1[:host] == ~c"host1"))[:password] == "secret1" 122 | assert Enum.find(sentinels, &(&1[:host] == ~c"host2"))[:password] == "secret2" 123 | end 124 | 125 | test "sentinel password mfa" do 126 | mfa1 = {System, :fetch_env!, ["REDIS_PASS1"]} 127 | mfa2 = {System, :fetch_env!, ["REDIS_PASS2"]} 128 | 129 | opts = 130 | sanitize( 131 | sentinel: [ 132 | sentinels: [ 133 | [host: "host1", port: 26379, password: mfa1], 134 | [host: "host2", port: 26379] 135 | ], 136 | group: "mygroup", 137 | password: mfa2 138 | ] 139 | ) 140 | 141 | sentinels = opts[:sentinel][:sentinels] 142 | 143 | assert Enum.count(sentinels) == 2 144 | assert Enum.find(sentinels, &(&1[:host] == ~c"host1"))[:password] == mfa1 145 | assert Enum.find(sentinels, &(&1[:host] == ~c"host2"))[:password] == mfa2 146 | end 147 | 148 | test "gen_statem options are allowed" do 149 | opts = 150 | sanitize(hibernate_after: 1000, debug: [], spawn_opt: [fullsweep_after: 0]) 151 | 152 | assert opts[:hibernate_after] == 1000 153 | assert opts[:debug] == [] 154 | assert opts[:spawn_opt] == [fullsweep_after: 0] 155 | end 156 | end 157 | 158 | defp sanitize(opts) do 159 | StartOptions.sanitize(:redix_pubsub, opts) 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /test/redix/stateful_properties/pubsub_properties_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.PubSubPropertiesTest do 2 | use ExUnit.Case 3 | 4 | use PropCheck.StateM 5 | use PropCheck 6 | 7 | @moduletag :capture_log 8 | @moduletag :propcheck 9 | 10 | defstruct [:channels, :ref] 11 | 12 | defmodule PubSub do 13 | def start_link do 14 | Redix.PubSub.start_link( 15 | name: __MODULE__, 16 | backoff_initial: 0, 17 | sync_connect: true 18 | ) 19 | end 20 | 21 | def subscribe(channel) do 22 | {:ok, ref} = Redix.PubSub.subscribe(__MODULE__, channel, self()) 23 | ref 24 | end 25 | 26 | def unsubscribe(channel), do: Redix.PubSub.unsubscribe(__MODULE__, channel, self()) 27 | 28 | def stop, do: Redix.PubSub.stop(__MODULE__) 29 | end 30 | 31 | defmodule ControlConn do 32 | def start_link, do: Redix.start_link(name: __MODULE__, sync_connect: true) 33 | 34 | def subscribed_channels, 35 | do: MapSet.new(Redix.command!(__MODULE__, ["PUBSUB", "CHANNELS"])) 36 | 37 | def publish(channel, message), do: Redix.command!(__MODULE__, ["PUBLISH", channel, message]) 38 | 39 | def disconnect_pubsub, 40 | do: Redix.command!(__MODULE__, ["CLIENT", "KILL", "TYPE", "pubsub"]) 41 | 42 | def stop, do: Redix.stop(__MODULE__) 43 | end 44 | 45 | property "subscribing and unsubscribing from channels", [:verbose] do 46 | numtests( 47 | 50, 48 | forall cmds <- commands(__MODULE__) do 49 | trap_exit do 50 | {:ok, _} = PubSub.start_link() 51 | {:ok, _} = ControlConn.start_link() 52 | 53 | {history, state, result} = run_commands(__MODULE__, cmds) 54 | 55 | :ok = PubSub.stop() 56 | :ok = ControlConn.stop() 57 | 58 | fail_report = """ 59 | History: #{inspect(history, pretty: true)} 60 | 61 | State: #{inspect(state, pretty: true)} 62 | 63 | Result: #{inspect(result, pretty: true)} 64 | """ 65 | 66 | (result == :ok) 67 | |> when_fail(IO.puts(fail_report)) 68 | |> aggregate(command_names(cmds)) 69 | end 70 | end 71 | ) 72 | end 73 | 74 | def initial_state do 75 | %__MODULE__{channels: MapSet.new()} 76 | end 77 | 78 | def command(_state) do 79 | frequency([ 80 | {3, {:call, PubSub, :subscribe, [channel()]}}, 81 | {2, {:call, PubSub, :unsubscribe, [channel()]}}, 82 | {4, {:call, ControlConn, :publish, [channel(), "hello"]}}, 83 | {1, {:call, ControlConn, :disconnect_pubsub, []}} 84 | ]) 85 | end 86 | 87 | ## Preconditions 88 | 89 | # Only unsubcribe from channels we're subscribed to. 90 | def precondition(state, {:call, PubSub, :unsubscribe, [channel]}) do 91 | channel in state.channels 92 | end 93 | 94 | # Only disconnect if we're subscribed to something. 95 | def precondition(state, {:call, ControlConn, :disconnect_pubsub, []}) do 96 | MapSet.size(state.channels) > 0 97 | end 98 | 99 | def precondition(_state, _call) do 100 | true 101 | end 102 | 103 | ## Postconditions 104 | 105 | def postcondition(state, {:call, PubSub, :subscribe, [channel]}, ref = _result) do 106 | assert_receive {:redix_pubsub, _pid, ^ref, :subscribed, %{channel: ^channel}} 107 | MapSet.put(state.channels, channel) == ControlConn.subscribed_channels() 108 | end 109 | 110 | def postcondition(state, {:call, PubSub, :unsubscribe, [channel]}, result) do 111 | ref = state.ref 112 | 113 | assert_receive {:redix_pubsub, _pid, ^ref, :unsubscribed, %{channel: ^channel}} 114 | assert result == :ok 115 | 116 | # Redix.PubSub sends the confirmation message to the caller process *before* it sends 117 | # the UNSUBSCRIBE command to Redis, because it will eventually unsubscribe anyways. 118 | # If we call ControlConn.subscribed_channels() (which calls to Redis) right away, 119 | # the channel will still be in there. That's why we wait for a bit until this passes. 120 | wait_for_true(_timeout = 200, fn -> 121 | MapSet.delete(state.channels, channel) == ControlConn.subscribed_channels() 122 | end) 123 | end 124 | 125 | def postcondition(state, {:call, ControlConn, :publish, [channel, message]}, _result) do 126 | ref = state.ref 127 | 128 | if channel in state.channels do 129 | assert_receive {:redix_pubsub, _pid, ^ref, :message, %{channel: ^channel, payload: payload}} 130 | 131 | payload == message 132 | else 133 | true 134 | end 135 | end 136 | 137 | def postcondition(state, {:call, ControlConn, :disconnect_pubsub, []}, _result) do 138 | ref = state.ref 139 | 140 | assert_receive {:redix_pubsub, _pid, ^ref, :disconnected, %{error: _error}}, 500 141 | 142 | for channel <- state.channels do 143 | assert_receive {:redix_pubsub, _pid, ^ref, :subscribed, %{channel: ^channel}}, 1000 144 | end 145 | 146 | true 147 | end 148 | 149 | ## Next state 150 | 151 | def next_state(state, result, {:call, PubSub, :subscribe, [channel]}) do 152 | %__MODULE__{state | channels: MapSet.put(state.channels, channel), ref: result} 153 | end 154 | 155 | def next_state(state, _result, {:call, PubSub, :unsubscribe, [channel]}) do 156 | update_in(state.channels, &MapSet.delete(&1, channel)) 157 | end 158 | 159 | def next_state(state, _result, _call) do 160 | state 161 | end 162 | 163 | ## Helpers 164 | 165 | defp channel do 166 | oneof(["foo", "bar", "baz"]) 167 | end 168 | 169 | defp wait_for_true(timeout, fun) do 170 | cond do 171 | timeout < 0 -> 172 | fun.() 173 | 174 | fun.() -> 175 | true 176 | 177 | true -> 178 | Process.sleep(10) 179 | wait_for_true(timeout - 10, fun) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /test/redix/stateful_properties/redix_properties_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.PropertiesTest do 2 | use ExUnit.Case 3 | 4 | use PropCheck.StateM 5 | use PropCheck 6 | 7 | @moduletag :capture_log 8 | @moduletag :propcheck 9 | 10 | defstruct [:map, :state] 11 | 12 | setup_all do 13 | Application.ensure_all_started(:crypto) 14 | :ok 15 | end 16 | 17 | defmodule Conn do 18 | def start_link, do: Redix.start_link(name: __MODULE__, backoff_initial: 10) 19 | 20 | def set(key, value), do: Redix.command!(__MODULE__, ["SET", key, value]) 21 | 22 | def get_existing(key), do: get(key) 23 | 24 | def get_non_existing(key), do: get(key) 25 | 26 | def stop, do: Redix.stop(__MODULE__) 27 | 28 | defp get(key), do: Redix.command!(__MODULE__, ["GET", key]) 29 | end 30 | 31 | property "setting and getting keys", [:verbose] do 32 | forall cmds <- commands(__MODULE__) do 33 | trap_exit do 34 | {:ok, _} = Conn.start_link() 35 | 36 | {history, state, result} = run_commands(__MODULE__, cmds) 37 | 38 | :ok = Conn.stop() 39 | 40 | fail_report = """ 41 | History: #{inspect(history, pretty: true)} 42 | 43 | State: #{inspect(state, pretty: true)} 44 | 45 | Result: #{inspect(result, pretty: true)} 46 | """ 47 | 48 | (result == :ok) 49 | |> when_fail(IO.puts(fail_report)) 50 | |> aggregate(command_names(cmds)) 51 | end 52 | end 53 | end 54 | 55 | def initial_state do 56 | %{} 57 | end 58 | 59 | def command(state) do 60 | commands = [ 61 | {:call, Conn, :set, [key(), value()]}, 62 | {:call, Conn, :get_non_existing, [non_existing_key()]} 63 | ] 64 | 65 | commands = 66 | if map_size(state) > 0 do 67 | existing_keys = Map.keys(state) 68 | [{:call, Conn, :get_existing, [oneof(existing_keys)]}] ++ commands 69 | else 70 | commands 71 | end 72 | 73 | oneof(commands) 74 | end 75 | 76 | ## Preconditions 77 | 78 | def precondition(_state, _call) do 79 | true 80 | end 81 | 82 | ## Postconditions 83 | 84 | def postcondition(_state, {:call, Conn, :set, [_key, _value]}, result) do 85 | result == "OK" 86 | end 87 | 88 | def postcondition(state, {:call, Conn, :get_existing, [key]}, result) do 89 | Map.fetch!(state, key) == result 90 | end 91 | 92 | def postcondition(_state, {:call, Conn, :get_non_existing, [_key]}, result) do 93 | result == nil 94 | end 95 | 96 | ## Next state 97 | 98 | def next_state(state, _result, {:call, Conn, :set, [key, value]}) do 99 | Map.put(state, key, value) 100 | end 101 | 102 | def next_state(state, _result, _call) do 103 | state 104 | end 105 | 106 | ## Helpers 107 | 108 | defp key do 109 | utf8(20) 110 | end 111 | 112 | defp value do 113 | utf8(20) 114 | end 115 | 116 | defp non_existing_key do 117 | :crypto.strong_rand_bytes(40) 118 | |> Base.encode64() 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/redix/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.TelemetryTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | 6 | describe "attach_default_handler/0" do 7 | test "attaches an handler that logs disconnections and reconnections" do 8 | Redix.Telemetry.attach_default_handler() 9 | 10 | conn = start_supervised!(Redix) 11 | 12 | client_id = Redix.command!(conn, ["CLIENT", "ID"]) 13 | 14 | addr = 15 | conn 16 | |> Redix.command!(["CLIENT", "LIST"]) 17 | |> String.split("\n", trim: true) 18 | |> Enum.find(&(&1 =~ "id=#{client_id}")) 19 | |> String.split() 20 | |> Enum.find_value(fn 21 | "addr=" <> addr -> addr 22 | _other -> nil 23 | end) 24 | 25 | log = 26 | capture_log(fn -> 27 | assert Redix.command!(conn, ["CLIENT", "KILL", addr]) == "OK" 28 | assert wait_for_reconnection(conn, 1000) == :ok 29 | end) 30 | 31 | assert log =~ ~r/Connection .* disconnected from Redis at localhost:6379/ 32 | assert log =~ ~r/Connection .* reconnected to Redis at localhost:6379/ 33 | end 34 | 35 | test "attaches an handler that logs failed connections" do 36 | Redix.Telemetry.attach_default_handler() 37 | 38 | log = 39 | capture_log(fn -> 40 | start_supervised!({Redix, "redis://localhost:9999"}) 41 | # Sleep just a bit to let it log the first failed connection message. 42 | Process.sleep(100) 43 | end) 44 | 45 | assert log =~ ~r/Connection .* failed to connect to Redis at localhost:9999/ 46 | end 47 | 48 | test "attaches an handler that logs failed connections for sentinels" do 49 | Redix.Telemetry.attach_default_handler() 50 | 51 | log = 52 | capture_log(fn -> 53 | start_supervised!( 54 | {Redix, sentinel: [sentinels: ["redis://localhost:9999"], group: "main"]} 55 | ) 56 | 57 | # Sleep just a bit to let it log the first failed connection message. 58 | Process.sleep(100) 59 | end) 60 | 61 | assert log =~ ~r/Connection .* failed to connect to sentinel at localhost:9999/ 62 | end 63 | end 64 | 65 | defp wait_for_reconnection(conn, timeout) do 66 | case Redix.command(conn, ["PING"]) do 67 | {:ok, "PONG"} -> 68 | :ok 69 | 70 | {:error, _reason} when timeout > 25 -> 71 | Process.sleep(25) 72 | wait_for_reconnection(conn, timeout - 25) 73 | 74 | {:error, reason} -> 75 | {:error, reason} 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/redix/uri_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redix.URITest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Redix.URI 5 | 6 | import Redix.URI 7 | 8 | describe "to_start_options/1" do 9 | test "invalid scheme" do 10 | message = "expected scheme to be redis://, valkey://, or rediss://, got: foo://" 11 | 12 | assert_raise ArgumentError, message, fn -> 13 | to_start_options("foo://example.com") 14 | end 15 | end 16 | 17 | test "just the host" do 18 | opts = to_start_options("redis://example.com") 19 | assert opts[:host] == "example.com" 20 | assert is_nil(opts[:port]) 21 | assert is_nil(opts[:database]) 22 | assert is_nil(opts[:password]) 23 | end 24 | 25 | test "host and port" do 26 | opts = to_start_options("redis://localhost:6379") 27 | assert opts[:host] == "localhost" 28 | assert opts[:port] == 6379 29 | assert is_nil(opts[:database]) 30 | assert is_nil(opts[:password]) 31 | end 32 | 33 | test "username and password" do 34 | opts = to_start_options("redis://user:pass@localhost") 35 | assert opts[:host] == "localhost" 36 | assert opts[:username] == "user" 37 | assert opts[:password] == "pass" 38 | end 39 | 40 | test "password" do 41 | opts = to_start_options("redis://:pass@localhost") 42 | assert opts[:host] == "localhost" 43 | assert opts[:username] == nil 44 | assert opts[:password] == "pass" 45 | 46 | # If there's no ":", we error out. 47 | assert_raise ArgumentError, ~r/expected password/, fn -> 48 | to_start_options("redis://not_a_user_or_password@localhost") 49 | end 50 | end 51 | 52 | test "database" do 53 | opts = to_start_options("redis://localhost/2") 54 | assert opts[:host] == "localhost" 55 | assert opts[:database] == 2 56 | 57 | opts = to_start_options("redis://localhost/") 58 | assert opts[:host] == "localhost" 59 | assert is_nil(opts[:database]) 60 | 61 | # test without a trailing slash 62 | opts = to_start_options("redis://localhost") 63 | assert opts[:host] == "localhost" 64 | assert is_nil(opts[:database]) 65 | 66 | opts = to_start_options("redis://localhost/2/namespace") 67 | assert opts[:host] == "localhost" 68 | assert opts[:database] == 2 69 | 70 | message = "expected database to be an integer, got: \"/peanuts\"" 71 | 72 | assert_raise ArgumentError, message, fn -> 73 | to_start_options("redis://localhost/peanuts") 74 | end 75 | 76 | message = "expected database to be an integer, got: \"/0tacos\"" 77 | 78 | assert_raise ArgumentError, message, fn -> 79 | to_start_options("redis://localhost/0tacos") 80 | end 81 | end 82 | 83 | test "accepts rediss scheme" do 84 | opts = to_start_options("rediss://example.com") 85 | assert opts[:host] == "example.com" 86 | assert opts[:ssl] == true 87 | assert is_nil(opts[:port]) 88 | assert is_nil(opts[:database]) 89 | assert is_nil(opts[:password]) 90 | end 91 | 92 | test "accepts valkey scheme" do 93 | opts = to_start_options("valkey://example.com") 94 | assert opts[:host] == "example.com" 95 | assert is_nil(opts[:ssl]) 96 | assert is_nil(opts[:port]) 97 | assert is_nil(opts[:database]) 98 | assert is_nil(opts[:password]) 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(assert_receive_timeout: 500) 2 | 3 | Logger.configure(level: :info) 4 | :logger.set_application_level(:telemetry, :none) 5 | 6 | case :gen_tcp.connect(~c"localhost", 6379, []) do 7 | {:ok, socket} -> 8 | :ok = :gen_tcp.close(socket) 9 | 10 | {:error, reason} -> 11 | Mix.raise("Cannot connect to Redis (http://localhost:6379): #{:inet.format_error(reason)}") 12 | end 13 | --------------------------------------------------------------------------------