Skip to content
Open
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
42 changes: 37 additions & 5 deletions system/Cache/Handlers/PredisHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,26 @@
use Predis\Client;
use Predis\Collection\Iterator\Keyspace;
use Predis\Response\Status;
use Throwable;

/**
* Predis cache handler
*/
class PredisHandler extends BaseHandler
{
/**
* Lua script to conditionally execute HMSET and EXPIREAT in a single round trip.
* Prevents EXPIREAT from mutating TTL or expiring keys when HMSET fails (e.g. WRONGTYPE).
*/
private const SAVE_SCRIPT = <<<'LUA'
local res = redis.pcall('HMSET', KEYS[1], '__ci_type', ARGV[2], '__ci_value', ARGV[3])
if type(res) == 'table' and res.err then
return 0
end
redis.call('EXPIREAT', KEYS[1], ARGV[1])
return 1
LUA;

/**
* Default config
*
Expand Down Expand Up @@ -118,15 +132,33 @@ public function save(string $key, mixed $value, int $ttl = 60): bool
return false;
}

if (! $this->redis->hmset($key, ['__ci_type' => $dataType, '__ci_value' => $value]) instanceof Status) {
return false;
}
$fields = ['__ci_type' => $dataType, '__ci_value' => $value];

if ($ttl !== 0) {
$this->redis->expireat($key, Time::now()->getTimestamp() + $ttl);
$expiresAt = Time::now()->getTimestamp() + $ttl;

try {
return (bool) $this->redis->eval(
self::SAVE_SCRIPT,
1,
$key,
(string) $expiresAt,
$dataType,
(string) $value,
);
} catch (Throwable) {
// If EVAL is unsupported or blocked, fallback to sequential execution.
if (! $this->redis->hmset($key, $fields) instanceof Status) {
return false;
}

$this->redis->expireat($key, $expiresAt);

return true;
}
}

return true;
return $this->redis->hmset($key, $fields) instanceof Status;
}

public function delete(string $key): bool
Expand Down
37 changes: 32 additions & 5 deletions system/Cache/Handlers/RedisHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,26 @@
use Config\Cache;
use Redis;
use RedisException;
use Throwable;

/**
* Redis cache handler
*/
class RedisHandler extends BaseHandler
{
/**
* Lua script to conditionally execute HMSET and EXPIREAT in a single round trip.
* Prevents EXPIREAT from mutating TTL or expiring keys when HMSET fails (e.g. WRONGTYPE).
*/
private const SAVE_SCRIPT = <<<'LUA'
local res = redis.pcall('HMSET', KEYS[1], '__ci_type', ARGV[2], '__ci_value', ARGV[3])
if type(res) == 'table' and res.err then
return 0
end
redis.call('EXPIREAT', KEYS[1], ARGV[1])
return 1
LUA;

/**
* Default config
*
Expand Down Expand Up @@ -134,15 +148,28 @@ public function save(string $key, mixed $value, int $ttl = 60): bool
return false;
}

if (! $this->redis->hMset($key, ['__ci_type' => $dataType, '__ci_value' => $value])) {
return false;
}
$fields = ['__ci_type' => $dataType, '__ci_value' => $value];

if ($ttl !== 0) {
$this->redis->expireAt($key, Time::now()->getTimestamp() + $ttl);
$expiresAt = Time::now()->getTimestamp() + $ttl;

try {
return (bool) $this->redis->eval(
self::SAVE_SCRIPT,
[$key, $expiresAt, $dataType, (string) $value],
1,
);
} catch (Throwable) {
// If EVAL is unsupported or blocked, fallback to sequential execution.
if (! $this->redis->hMset($key, $fields)) {
return false;
}

return $this->redis->expireAt($key, $expiresAt);
}
}

return true;
return (bool) $this->redis->hMset($key, $fields);
}

public function delete(string $key): bool
Expand Down
22 changes: 22 additions & 0 deletions tests/system/Cache/Handlers/PredisHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use CodeIgniter\I18n\Time;
use Config\Cache;
use PHPUnit\Framework\Attributes\Group;
use Predis\Client;

/**
* @internal
Expand Down Expand Up @@ -196,4 +197,25 @@ public function testReconnect(): void

$this->assertSame('value', $this->handler->get(self::$key1));
}

public function testSaveFailsOnNonHashKeyPreservingValueAndTtl(): void
{
/** @var Client $redis */
$redis = $this->getPrivateProperty($this->handler, 'redis');

$prefixedKey = $this->config->prefix . 'non_hash_key';
$redis->set($prefixedKey, 'raw_string_value');
$redis->expire($prefixedKey, 300);

$this->assertFalse($this->handler->save('non_hash_key', 'new_value', 60));

$this->assertSame('raw_string_value', $redis->get($prefixedKey));
$this->assertGreaterThan(250, $redis->ttl($prefixedKey));

// Handler remains usable for subsequent operations
$this->assertTrue($this->handler->save(self::$key1, 'usable_value'));
$this->assertSame('usable_value', $this->handler->get(self::$key1));

$redis->del([$prefixedKey]);
}
}
22 changes: 22 additions & 0 deletions tests/system/Cache/Handlers/RedisHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Config\Cache;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Redis;

/**
* @internal
Expand Down Expand Up @@ -238,4 +239,25 @@ public function testReconnect(): void

$this->assertSame('value', $this->handler->get(self::$key1));
}

public function testSaveFailsOnNonHashKeyPreservingValueAndTtl(): void
{
/** @var Redis $redis */
$redis = $this->getPrivateProperty($this->handler, 'redis');

$prefixedKey = $this->config->prefix . 'non_hash_key';
$redis->set($prefixedKey, 'raw_string_value');
$redis->expire($prefixedKey, 300);

$this->assertFalse($this->handler->save('non_hash_key', 'new_value', 60));

$this->assertSame('raw_string_value', $redis->get($prefixedKey));
$this->assertGreaterThan(250, $redis->ttl($prefixedKey));

// Handler remains usable for subsequent operations
$this->assertTrue($this->handler->save(self::$key1, 'usable_value'));
$this->assertSame('usable_value', $this->handler->get(self::$key1));

$redis->del($prefixedKey);
}
}
Loading