#113071 init Namoshek

This commit is contained in:
Александр Рыбкин 2025-07-30 16:07:20 +03:00
parent 3421b02c8b
commit 0e934d08ac
6 changed files with 534 additions and 17 deletions

152
README.md Normal file
View File

@ -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.

View File

@ -11,7 +11,6 @@
}
],
"require": {
"namoshek/laravel-redis-sentinel": "^0.8|^0.9"
},
"autoload": {
"psr-4": {

View File

@ -0,0 +1,258 @@
<?php
/* @noinspection PhpRedundantCatchClauseInspection */
declare(strict_types=1);
namespace Namoshek\Redis\Sentinel\Connections;
use Closure;
use Illuminate\Redis\Connections\PhpRedisConnection;
use Illuminate\Support\Str;
use Redis;
use RedisException;
/**
* The connection to Redis after connecting through a Sentinel using the PhpRedis extension.
*/
class PhpRedisSentinelConnection extends PhpRedisConnection
{
// The following array contains all exception message parts which are interpreted as a connection loss or
// another unavailability of Redis.
private const ERROR_MESSAGES_INDICATING_UNAVAILABILITY = [
'connection closed',
'connection refused',
'connection lost',
'failed while reconnecting',
'is loading the dataset in memory',
'php_network_getaddresses',
'read error on connection',
'socket',
'went away',
'loading',
'readonly',
"can't write against a read only replica",
];
/**
* {@inheritdoc}
*
* @throws RedisException
*/
public function scan($cursor, $options = []): mixed
{
try {
return parent::scan($cursor, $options);
} catch (RedisException $e) {
$this->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;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Namoshek\Redis\Sentinel\Exceptions;
/**
* Exception to be used if wrong application configuration is encountered.
*/
class ConfigurationException extends \RuntimeException
{
}

View File

@ -2,11 +2,11 @@
declare(strict_types=1);
namespace Dominion\Redis\Sentinel;
namespace Namoshek\Redis\Sentinel;
use Illuminate\Redis\RedisManager;
use Illuminate\Support\ServiceProvider;
use Dominion\Redis\Sentinel\Connectors\PhpRedisSentinelConnector;
use Namoshek\Redis\Sentinel\Connectors\PhpRedisSentinelConnector;
/**
* Registers and boots services of the Laravel Redis Sentinel package.
@ -21,7 +21,7 @@ class RedisSentinelServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->extend('redis', function (RedisManager $service) {
return $service->extend('dominion-phpredis-sentinel', fn () => new PhpRedisSentinelConnector);
return $service->extend('phpredis-sentinel', fn () => new PhpRedisSentinelConnector);
});
}
}