From 0e934d08ac7cc89a8fc97fdac23cf20e73fffce5 Mon Sep 17 00:00:00 2001 From: Sasha Rybkin Date: Wed, 30 Jul 2025 16:07:20 +0300 Subject: [PATCH] #113071 init Namoshek --- README.md | 152 +++++++++++ composer.json | 1 - .../PhpRedisSentinelConnection.php | 258 ++++++++++++++++++ src/Connectors/PhpRedisSentinelConnector.php | 122 ++++++++- src/Exceptions/ConfigurationException.php | 12 + src/RedisSentinelServiceProvider.php | 6 +- 6 files changed, 534 insertions(+), 17 deletions(-) create mode 100644 README.md create mode 100644 src/Connections/PhpRedisSentinelConnection.php create mode 100644 src/Exceptions/ConfigurationException.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..67a2c64 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +скопированно отсюда https://github.com/Namoshek/laravel-redis-sentinel/tree/master + +# Laravel Redis Sentinel driver for `phpredis/phpredis` PHP extension + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/namoshek/laravel-redis-sentinel.svg?style=flat-square)](https://packagist.org/packages/namoshek/laravel-redis-sentinel) +[![Total Downloads](https://img.shields.io/packagist/dt/namoshek/laravel-redis-sentinel.svg?style=flat-square)](https://packagist.org/packages/namoshek/laravel-redis-sentinel) +[![Tests](https://github.com/Namoshek/laravel-redis-sentinel/workflows/Tests/badge.svg)](https://github.com/Namoshek/laravel-redis-sentinel/actions?query=workflow%3ATests) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=namoshek_laravel-redis-sentinel&metric=alert_status)](https://sonarcloud.io/dashboard?id=namoshek_laravel-redis-sentinel) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=namoshek_laravel-redis-sentinel&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=namoshek_laravel-redis-sentinel) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=namoshek_laravel-redis-sentinel&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=namoshek_laravel-redis-sentinel) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=namoshek_laravel-redis-sentinel&metric=security_rating)](https://sonarcloud.io/dashboard?id=namoshek_laravel-redis-sentinel) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=namoshek_laravel-redis-sentinel&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=namoshek_laravel-redis-sentinel) +[![License](https://poser.pugx.org/namoshek/laravel-redis-sentinel/license)](https://packagist.org/packages/namoshek/laravel-redis-sentinel) + +This package provides a Laravel Redis driver which allows connecting to a Redis master through a Redis Sentinel instance. +The package is intended to be used in a Kubernetes environment or similar, where connecting to Redis Sentinels is possible through a load balancer. + +This driver is an alternative to [`monospice/laravel-redis-sentinel-drivers`](https://github.com/monospice/laravel-redis-sentinel-drivers). +The primary difference is that this driver supports the [`phpredis/phpredis` PHP extension](https://github.com/phpredis/phpredis) +and has significantly simpler configuration, due to a simpler architecture. +In detail this means that this package does not override the entire Redis subsystem of Laravel, it only adds an additional driver. + +By default, Laravel supports the `predis` and `phpredis` drivers. This package adds a third `phpredis-sentinel` driver, +which is an extension of the `phpredis` driver for Redis Sentinel. +An extension for `predis` is currently not available and not necessary, since [`predis/predis`](https://github.com/predis/predis) already supports +connecting to Redis through one or more Sentinels. + +## Installation + +You can install the package via composer: + +```bash +composer require namoshek/laravel-redis-sentinel +``` + +The service provider which comes with the package is registered automatically. + +## Configuration + +The package requires no extra configuration and does therefore not provide an additional configuration file. + +## Usage + +To use the Redis Sentinel driver, the `redis` section in `config/database.php` needs to be adjusted: + +```php +'redis' => [ + 'client' => env('REDIS_CLIENT', 'phpredis-sentinel'), + + 'default' => [ + 'sentinel_host' => env('REDIS_SENTINEL_HOST', '127.0.0.1'), + 'sentinel_port' => (int) env('REDIS_SENTINEL_PORT', 26379), + 'sentinel_service' => env('REDIS_SENTINEL_SERVICE', 'mymaster'), + 'sentinel_timeout' => (float) env('REDIS_SENTINEL_TIMEOUT', 0), + 'sentinel_persistent' => env('REDIS_SENTINEL_PERSISTENT'), + 'sentinel_retry_interval' => (int) env('REDIS_SENTINEL_RETRY_INTERVAL', 0), + 'sentinel_read_timeout' => (float) env('REDIS_SENTINEL_READ_TIMEOUT', 0), + 'sentinel_username' => env('REDIS_SENTINEL_USERNAME'), + 'sentinel_password' => env('REDIS_SENTINEL_PASSWORD'), + 'password' => env('REDIS_PASSWORD'), + 'database' => (int) env('REDIS_DB', 0), + ] +] +``` + +Instead of changing `redis.client` in the configuration file directly, you can also set `REDIS_CLIENT=phpredis-sentinel` in the environment variables. + +As you can see, there are also a few new `sentinel_*` options available for each Redis connection. +Most of them work very similar to the normal Redis options, except that they are used for the connection to Redis Sentinel. +Noteworthy is the `sentinel_service`, which represents the instance name of the monitored Redis master. + +All other options are the same for the Redis Sentinel driver, except that `url` is not supported and `host` and `port` are ignored. + +### SSL/TLS Support + +If you want to use SSL/TLS to connect to Redis Sentinel, you need to add an additional configuration option `sentinel_ssl` next to the other `sentinel_*` settings: + +```php +'sentinel_ssl' => [ + // ... SSL settings ... +], +``` + +Available SSL context options can be found in the [official PHP documentation](https://www.php.net/manual/en/context.ssl.php). Please note that SSL support for the Sentinel connection was added to the `phpredis` extension starting in version 6.1. + +Also note that if your Redis Sentinel resolves SSL connections to Redis, you potentially need to add additional context options for your Redis connection: + +```php +'context' => [ + 'stream' => [ + // ... SSL settings ... + ] +], +'scheme' => 'tls', +``` + +A full configuration example using SSL for Redis Sentinel as well as Redis looks like this if authentication is also enabled (environment variables omitted for clarity): + +```php +'redis' => [ + 'client' => 'phpredis-sentinel', + + 'redis_with_tls' => [ + 'sentinel_host' => 'tls://sentinel_host', + 'sentinel_port' => 26379, + 'sentinel_service' => 'mymaster', + 'sentinel_timeout' => 0, + 'sentinel_persistent' => false, + 'sentinel_retry_interval' => 0, + 'sentinel_read_timeout' => 0, + 'sentinel_username' => 'sentinel_username', + 'sentinel_password' => 'sentinel_password', + 'sentinel_ssl' => [ + 'cafile' => '/path/to/sentinel_ca.crt', + ], + 'context' => [ + 'stream' => [ + 'cafile' => '/path/to/redis_ca.crt', + ], + ], + 'scheme' => 'tls', + 'username' => 'redis_username', + 'password' => 'redis_password', + 'database' => 1, + ] +] +``` + +The important parts are the `tls://` protocol in `sentinel_host` as well as the `tls` in `scheme`, plus the `sentinel_ssl` and `context.stream` options. + +Because Redis Sentinel resolves Redis instances by IP and port, your Redis certificate needs to have the IP as SAN. Alternatively, you can set `verify_peer` and maybe also `verify_peer_name` to `false`. + +### How does it work? + +An additional Laravel Redis driver is added (`phpredis-sentinel`), which resolves the currently declared master instance of a replication +cluster as active Redis instance. Under the hood, this driver relies on the framework driver for [`phpredis/phpredis`](https://github.com/phpredis/phpredis), +it only wraps the connection part of it and adds some error handling which forcefully reconnects in case of a failover. + +Please be aware that this package does not manage load balancing between Sentinels (which is supposed to be done on an infrastructure level) +and does also not load balance read/write calls to replica/master nodes. All traffic is sent to the currently reported master. + +## Developing + +To run the tests locally, a Redis cluster needs to be running. +The repository contains a script (thanks to [`monospice/laravel-redis-sentinel-drivers`](https://github.com/monospice/laravel-redis-sentinel-drivers)) +which can be used to start one by running `sh start-redis-cluster.sh`. +The script requires that Redis is installed on your machine. To install Redis on Ubuntu or Debian, +you can use `sudo apt update && sudo apt install redis-server`. For other operating systems, please see [redis.io](https://redis.io/). + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json index 8ea0750..d0bfdbd 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,6 @@ } ], "require": { - "namoshek/laravel-redis-sentinel": "^0.8|^0.9" }, "autoload": { "psr-4": { diff --git a/src/Connections/PhpRedisSentinelConnection.php b/src/Connections/PhpRedisSentinelConnection.php new file mode 100644 index 0000000..61cb4c5 --- /dev/null +++ b/src/Connections/PhpRedisSentinelConnection.php @@ -0,0 +1,258 @@ +reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function zscan($key, $cursor, $options = []): mixed + { + try { + return parent::zscan($key, $cursor, $options); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function hscan($key, $cursor, $options = []): mixed + { + try { + return parent::hscan($key, $cursor, $options); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function sscan($key, $cursor, $options = []): mixed + { + try { + return parent::sscan($key, $cursor, $options); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function pipeline(?callable $callback = null): Redis|array + { + try { + return parent::pipeline($callback); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function transaction(?callable $callback = null): Redis|array + { + try { + return parent::transaction($callback); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function evalsha($script, $numkeys, ...$arguments): mixed + { + try { + return parent::evalsha($script, $numkeys, $arguments); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function subscribe($channels, Closure $callback): void + { + try { + parent::subscribe($channels, $callback); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function psubscribe($channels, Closure $callback): void + { + try { + parent::psubscribe($channels, $callback); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function flushdb(): void + { + try { + parent::flushdb(); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function command($method, array $parameters = []): mixed + { + try { + return parent::command($method, $parameters); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function __call($method, $parameters): mixed + { + try { + return parent::__call(strtolower($method), $parameters); + } catch (RedisException $e) { + $this->reconnectIfRedisIsUnavailableOrReadonly($e); + + throw $e; + } + } + + /** + * Inspects the given exception and reconnects the client if the reported error indicates that the server + * went away or is in readonly mode, which may happen in case of a Redis Sentinel failover. + */ + private function reconnectIfRedisIsUnavailableOrReadonly(RedisException $exception): void + { + // We convert the exception message to lower-case in order to perform case-insensitive comparison. + $exceptionMessage = strtolower($exception->getMessage()); + + // Because we also match only partial exception messages, we cannot use in_array() at this point. + foreach (self::ERROR_MESSAGES_INDICATING_UNAVAILABILITY as $errorMessage) { + if (str_contains($exceptionMessage, $errorMessage)) { + // Here we reconnect through Redis Sentinel if we lost connection to the server or if another unavailability occurred. + // We may actually reconnect to the same, broken server. But after a failover occured, we should be ok. + // It may take a moment until the Sentinel returns the new master, so this may be triggered multiple times. + $this->reconnect(); + + return; + } + } + } + + /** + * Reconnects to the Redis server by overriding the current connection. + */ + private function reconnect(): void + { + $this->client = $this->connector ? call_user_func($this->connector) : $this->client; + } +} diff --git a/src/Connectors/PhpRedisSentinelConnector.php b/src/Connectors/PhpRedisSentinelConnector.php index e87bf2e..61d81d4 100644 --- a/src/Connectors/PhpRedisSentinelConnector.php +++ b/src/Connectors/PhpRedisSentinelConnector.php @@ -2,29 +2,125 @@ declare(strict_types=1); -namespace Dominion\Redis\Sentinel\Connectors; +namespace Namoshek\Redis\Sentinel\Connectors; +use Illuminate\Redis\Connectors\PhpRedisConnector; +use Illuminate\Support\Arr; +use Namoshek\Redis\Sentinel\Connections\PhpRedisSentinelConnection; +use Namoshek\Redis\Sentinel\Exceptions\ConfigurationException; +use Redis; +use RedisException; use RedisSentinel; /** * Allows to connect to a Sentinel driven Redis master using the PhpRedis extension. */ -class PhpRedisSentinelConnector extends \Namoshek\Redis\Sentinel\Connectors\PhpRedisSentinelConnector +class PhpRedisSentinelConnector extends PhpRedisConnector { + /** + * {@inheritdoc} + * + * @throws RedisException + */ + public function connect(array $config, array $options): PhpRedisSentinelConnection + { + $connector = function () use ($config, $options) { + return $this->createClient(array_merge( + $config, + $options, + Arr::pull($config, 'options', []) + )); + }; + return new PhpRedisSentinelConnection($connector(), $connector, $config); + } + + /** + * Create the PhpRedis client instance which connects to Redis Sentinel. + * + * @throws ConfigurationException + * @throws RedisException + */ + protected function createClient(array $config): Redis + { + $service = $config['sentinel_service'] ?? 'mymaster'; + + $sentinel = $this->connectToSentinel($config); + + $master = $sentinel->master($service); + + if (! $this->isValidMaster($master)) { + throw new RedisException(sprintf("No master found for service '%s'.", $service)); + } + + return parent::createClient(array_merge($config, [ + 'host' => $master['ip'], + 'port' => $master['port'], + ])); + } + + /** + * Check whether master is valid or not. + */ + protected function isValidMaster(mixed $master): bool + { + return is_array($master) && isset($master['ip']) && isset($master['port']); + } + + /** + * Connect to the configured Redis Sentinel instance. + * + * @throws ConfigurationException + */ private function connectToSentinel(array $config): RedisSentinel { - $hosts = is_array($config['sentinel_host']) ? $config['sentinel_host'] : [$config['sentinel_host']]; - foreach ($hosts as $host) - { - $newConfig = $config; - $newConfig['sentinel_host'] = $host; - $RedisSentinel = parent::connectToSentinel($newConfig); - if($RedisSentinel->ping()) - { - break; - } + $host = $config['sentinel_host'] ?? ''; + $port = $config['sentinel_port'] ?? 26379; + $timeout = $config['sentinel_timeout'] ?? 0.2; + $persistent = $config['sentinel_persistent'] ?? null; + $retryInterval = $config['sentinel_retry_interval'] ?? 0; + $readTimeout = $config['sentinel_read_timeout'] ?? 0; + $username = $config['sentinel_username'] ?? ''; + $password = $config['sentinel_password'] ?? ''; + $ssl = $config['sentinel_ssl'] ?? null; + + if (strlen(trim($host)) === 0) { + throw new ConfigurationException('No host has been specified for the Redis Sentinel connection.'); } - return $RedisSentinel; + + $auth = null; + if (strlen(trim($username)) !== 0 && strlen(trim($password)) !== 0) { + $auth = [$username, $password]; + } elseif (strlen(trim($password)) !== 0) { + $auth = $password; + } + + if (version_compare(phpversion('redis'), '6.0', '>=')) { + $options = [ + 'host' => $host, + 'port' => $port, + 'connectTimeout' => $timeout, + 'persistent' => $persistent, + 'retryInterval' => $retryInterval, + 'readTimeout' => $readTimeout, + ]; + + if ($auth !== null) { + $options['auth'] = $auth; + } + + if (version_compare(phpversion('redis'), '6.1', '>=') && $ssl !== null) { + $options['ssl'] = $ssl; + } + + return new RedisSentinel($options); + } + + if ($auth !== null) { + /** @noinspection PhpMethodParametersCountMismatchInspection */ + return new RedisSentinel($host, $port, $timeout, $persistent, $retryInterval, $readTimeout, $auth); + } + + return new RedisSentinel($host, $port, $timeout, $persistent, $retryInterval, $readTimeout); } } diff --git a/src/Exceptions/ConfigurationException.php b/src/Exceptions/ConfigurationException.php new file mode 100644 index 0000000..d3e4f6c --- /dev/null +++ b/src/Exceptions/ConfigurationException.php @@ -0,0 +1,12 @@ +app->extend('redis', function (RedisManager $service) { - return $service->extend('dominion-phpredis-sentinel', fn () => new PhpRedisSentinelConnector); + return $service->extend('phpredis-sentinel', fn () => new PhpRedisSentinelConnector); }); } }