Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/reusable-phpunit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ jobs:
--health-timeout=5s
--health-retries=3
redis-sentinel:
image: bitnami/redis-sentinel:latest
ports:
- 26379:26379
env:
REDIS_MASTER_HOST: redis
REDIS_MASTER_PORT_NUMBER: 6379
REDIS_SENTINEL_QUORUM: 1

memcached:
image: memcached:1.6-alpine
ports:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/test-random-execution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ jobs:
--health-timeout=5s
--health-retries=3
redis-sentinel:
image: bitnami/redis-sentinel:latest
ports:
- 26379:26379
env:
REDIS_MASTER_HOST: redis
REDIS_MASTER_PORT_NUMBER: 6379
REDIS_SENTINEL_QUORUM: 1

memcached:
image: memcached:1.6-alpine
ports:
Expand Down
4 changes: 3 additions & 1 deletion app/Config/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ class Cache extends BaseConfig
* timeout?: int,
* async?: bool,
* persistent?: bool,
* database?: int
* database?: int,
* sentinels?: list<string>,
* service?: string
* }
*/
public array $redis = [
Expand Down
22 changes: 22 additions & 0 deletions app/Config/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,26 @@ class Session extends BaseConfig
* seconds.
*/
public int $lockMaxRetries = 300;

/**
* --------------------------------------------------------------------------
* Redis Sentinel Hosts
* --------------------------------------------------------------------------
*
* List of Redis Sentinel servers for high availability.
* Used only when $driver is RedisHandler.
*
* @var list<string>|null
*/
public ?array $redisSentinels = null;

/**
* --------------------------------------------------------------------------
* Redis Sentinel Service
* --------------------------------------------------------------------------
*
* The master group name for Redis Sentinel.
* Used only when $driver is RedisHandler.
*/
public ?string $redisSentinelService = null;
}
2 changes: 0 additions & 2 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
*/

use Rector\Caching\ValueObject\Storage\FileCacheStorage;
use Rector\CodeQuality\Rector\BooleanNot\NegatedAndsToPositiveOrsRector;
use Rector\CodeQuality\Rector\Empty_\SimplifyEmptyCheckOnEmptyArrayRector;
use Rector\CodeQuality\Rector\FuncCall\CompactToVariablesRector;
use Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector;
Expand Down Expand Up @@ -176,7 +175,6 @@
],

// to be applied in separate PRs to ease review
NegatedAndsToPositiveOrsRector::class,
])
// auto import fully qualified class names
->withImportNames()
Expand Down
100 changes: 98 additions & 2 deletions system/Cache/Handlers/PredisHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ class PredisHandler extends BaseHandler
* port: int,
* async: bool,
* persistent: bool,
* timeout: int
* timeout: int,
* sentinels: list<string>,
* service: string,
* }
*/
protected $config = [
Expand All @@ -49,6 +51,8 @@ class PredisHandler extends BaseHandler
'async' => false,
'persistent' => false,
'timeout' => 0,
'sentinels' => [],
'service' => '',
];

/**
Expand All @@ -68,16 +72,108 @@ public function __construct(Cache $config)
$this->config = array_merge($this->config, $config->redis);
}

/**
* Factory method for creating Predis Client instances.
* Override in tests to mock Client.
*/
protected function createPredisClient(array $connection, array $options = []): Client
{
return new Client($connection, $options);
}

public function initialize(): void
{
try {
$this->redis = new Client($this->config, ['prefix' => $this->prefix]);
$config = $this->config;

if ($config['sentinels'] !== [] && $config['service'] !== '') {
$this->initializeSentinel($config);

return;
}

unset($config['sentinels'], $config['service']);

$this->redis = $this->createPredisClient($config, ['prefix' => $this->prefix]);
$this->redis->time();
} catch (Exception $e) {
throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').', $e->getCode(), $e);
}
}

/**
* Initializes a connection via Redis Sentinel.
*
* Predis v3.x does not have built-in sentinel support,
* so we manually discover the master via the Sentinel protocol.
*
* @param array{
* sentinels: list<string>,
* service: string,
* timeout: int,
* password?: string|null,
* database?: int,
* prefix?: string,
* } $config
*/
protected function initializeSentinel(array $config): void
{
$sentinels = $config['sentinels'];
$service = $config['service'];
$timeout = $config['timeout'] ?? 0;

$masterHost = null;
$masterPort = null;

foreach ($sentinels as $sentinel) {
$parts = parse_url($sentinel);
$sentinelHost = $parts['host'] ?? '127.0.0.1';
$sentinelPort = $parts['port'] ?? 26379;
$sentinelScheme = $parts['scheme'] ?? 'tcp';

try {
$sentinelClient = $this->createPredisClient([
'scheme' => $sentinelScheme,
'host' => $sentinelHost,
'port' => $sentinelPort,
'timeout' => $timeout,
]);

if (isset($config['password']) && $config['password'] !== null) {
$sentinelClient->auth($config['password']);
}

$addr = $sentinelClient->rawCommand('SENTINEL', 'get-master-addr-by-name', $service);

if (is_array($addr) && count($addr) >= 2) {
$masterHost = $addr[0];
$masterPort = (int) $addr[1];
}

$sentinelClient->disconnect();
unset($sentinelClient);

if ($masterHost !== null) {
break;
}
} catch (Exception) {
continue;
}
}

if ($masterHost === null) {
throw new CriticalError('Cache: Redis Sentinel could not find a master for service "' . $service . '".');
}

$config['scheme'] = 'tcp';
$config['host'] = $masterHost;
$config['port'] = $masterPort;
unset($config['sentinels'], $config['service'], $config['async'], $config['persistent']);

$this->redis = $this->createPredisClient($config, ['prefix' => $this->prefix]);
$this->redis->time();
}

public function get(string $key): mixed
{
$key = static::validateKey($key);
Expand Down
99 changes: 98 additions & 1 deletion system/Cache/Handlers/RedisHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class RedisHandler extends BaseHandler
* timeout: int,
* persistent: bool,
* database: int,
* sentinels: list<string>,
* service: string,
* }
*/
protected $config = [
Expand All @@ -45,6 +47,8 @@ class RedisHandler extends BaseHandler
'timeout' => 0,
'persistent' => false,
'database' => 0,
'sentinels' => [],
'service' => '',
];

/**
Expand All @@ -68,9 +72,15 @@ public function initialize(): void
{
$config = $this->config;

$this->redis = new Redis();
$this->redis = $this->createRedis();

try {
if ($config['sentinels'] !== [] && $config['service'] !== '') {
$this->initializeSentinel($config);

return;
}

$funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect';

// Note:: If Redis is your primary cache choice, and it is "offline", every page load will end up been delayed by the timeout duration.
Expand Down Expand Up @@ -98,6 +108,93 @@ public function initialize(): void
}
}

/**
* Initializes a connection via Redis Sentinel.
*
* @param array{
* sentinels: list<string>,
* service: string,
* timeout: int,
* password?: string|null,
* persistent?: bool,
* database?: int,
* } $config
*/
/**
* Factory method for creating Redis connections.
* Override in tests to mock Redis.
*/
protected function createRedis(): Redis
{
return new Redis();
}

protected function initializeSentinel(array $config): void
{
$sentinels = $config['sentinels'];
$service = $config['service'];
$timeout = $config['timeout'] ?? 0;

$masterHost = null;
$masterPort = null;

foreach ($sentinels as $sentinel) {
$parts = parse_url($sentinel);
$sentinelHost = $parts['host'] ?? '127.0.0.1';
$sentinelPort = $parts['port'] ?? 26379;

$sentinelConn = $this->createRedis();

try {
$sentinelConn->connect($sentinelHost, $sentinelPort, $timeout);

if (isset($config['password']) && $config['password'] !== null) {
$sentinelConn->auth($config['password']);
}

$addr = $sentinelConn->rawCommand('SENTINEL', 'get-master-addr-by-name', $service);

if (is_array($addr) && count($addr) >= 2) {
$masterHost = $addr[0];
$masterPort = (int) $addr[1];
}

$sentinelConn->close();
unset($sentinelConn);

if ($masterHost !== null) {
break;
}
} catch (RedisException) {
continue;
}
}

if ($masterHost === null) {
throw new CriticalError('Cache: Redis Sentinel could not find a master for service "' . $service . '".');
}

$funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect';

if (! $this->redis->{$funcConnection}($masterHost, $masterPort, $timeout)) {
log_message('error', 'Cache: Redis connection to Sentinel master failed. Check your configuration.');

throw new CriticalError('Cache: Redis connection to Sentinel master failed. Check your configuration.');
}

if (isset($config['password']) && $config['password'] !== null && ! $this->redis->auth($config['password'])) {
log_message('error', 'Cache: Redis authentication failed.');

throw new CriticalError('Cache: Redis authentication failed.');
}

if (isset($config['database']) && ! $this->redis->select($config['database'])) {
log_message('error', 'Cache: Redis select database failed.');

throw new CriticalError('Cache: Redis select database failed.');
}
}

public function get(string $key): mixed
{
$key = static::validateKey($key, $this->prefix);
Expand Down
12 changes: 11 additions & 1 deletion system/DataCaster/Cast/DatetimeCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\I18n\Time;
use DateTimeInterface;
use Exception;

/**
* Class DatetimeCast
Expand Down Expand Up @@ -51,7 +53,15 @@ public static function set(
array $params = [],
?object $helper = null,
): string {
if (! $value instanceof Time) {
if (is_string($value)) {
try {
$value = Time::parse($value);
} catch (Exception) {
self::invalidTypeValueError($value);
}
}

if (! $value instanceof DateTimeInterface) {
self::invalidTypeValueError($value);
}

Expand Down
6 changes: 2 additions & 4 deletions system/Entity/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,7 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false):

// When returning everything
if (! $onlyChanged) {
return $recursive
? array_map($convert, $this->attributes)
: $this->attributes;
return array_map($convert, $this->attributes);
}

// When filtering by changed values only
Expand Down Expand Up @@ -335,7 +333,7 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false):
}

// non-recursive changed value
$return[$key] = $value;
$return[$key] = $convert($value);
}

return $return;
Expand Down
Loading
Loading