diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php index 4cedb67e9538..2b61b7d8512f 100644 --- a/system/Cache/CacheInterface.php +++ b/system/Cache/CacheInterface.php @@ -44,11 +44,11 @@ public function save(string $key, mixed $value, int $ttl = 60): bool; * Attempts to get an item from the cache, or executes the callback * and stores the result on cache miss. * - * @param string $key Cache item name - * @param int $ttl Time To Live, in seconds - * @param Closure(): mixed $callback Callback executed on cache miss + * @param string $key Cache item name + * @param callable(): int|callable(mixed): int|int $ttl Time To Live, in seconds + * @param Closure(): mixed $callback Callback executed on cache miss */ - public function remember(string $key, int $ttl, Closure $callback): mixed; + public function remember(string $key, callable|int $ttl, Closure $callback): mixed; /** * Deletes a specific item from the cache store. diff --git a/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php index ef0f51c50dc7..b8ef4f284332 100644 --- a/system/Cache/Handlers/ApcuHandler.php +++ b/system/Cache/Handlers/ApcuHandler.php @@ -55,8 +55,12 @@ public function save(string $key, $value, int $ttl = 60): bool return apcu_store($key, $value, $ttl); } - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { + if (is_callable($ttl)) { + return parent::remember($key, $ttl, $callback); + } + $key = static::validateKey($key, $this->prefix); return apcu_entry($key, $callback, $ttl); diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index 2217e98e35f9..597ac5b0d31e 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -17,6 +17,7 @@ use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Cache; +use ReflectionFunction; /** * Base class for cache handling @@ -64,7 +65,7 @@ public static function validateKey($key, $prefix = ''): string return strlen($prefix . $key) > static::MAX_KEY_LENGTH ? $prefix . md5($key) : $prefix . $key; } - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { $value = $this->get($key); @@ -72,7 +73,28 @@ public function remember(string $key, int $ttl, Closure $callback): mixed return $value; } - $this->save($key, $value = $callback(), $ttl); + $value = $callback(); + + if (is_callable($ttl)) { + $ttlClosure = Closure::fromCallable($ttl); + $rf = new ReflectionFunction($ttlClosure); + $params = $rf->getNumberOfRequiredParameters(); + + if ($params === 0) { + /** @var Closure(): int $ttlClosure */ + $ttl = $ttlClosure(); + } elseif ($params === 1) { + /** @var Closure(mixed): int $ttlClosure */ + $ttl = $ttlClosure($value); + } else { + throw new InvalidArgumentException(sprintf( + 'Argument #2 ($ttl) must accept 0 or 1 parameter, %d given.', + $params, + )); + } + } + + $this->save($key, $value, $ttl); return $value; } diff --git a/system/Cache/Handlers/DummyHandler.php b/system/Cache/Handlers/DummyHandler.php index a30475965d6f..891f8ad31d3f 100644 --- a/system/Cache/Handlers/DummyHandler.php +++ b/system/Cache/Handlers/DummyHandler.php @@ -31,7 +31,7 @@ public function get(string $key): mixed return null; } - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { return null; } diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 393246a8d0a9..dbeebe0e6faf 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -18,8 +18,10 @@ use CodeIgniter\Cache\Handlers\BaseHandler; use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Cache\LockStoreProviderInterface; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use PHPUnit\Framework\Assert; +use ReflectionFunction; class MockCache extends BaseHandler implements CacheInterface, LockStoreProviderInterface { @@ -72,7 +74,7 @@ public function get(string $key): mixed * * @return bool|null */ - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { $value = $this->get($key); @@ -80,7 +82,28 @@ public function remember(string $key, int $ttl, Closure $callback): mixed return $value; } - $this->save($key, $value = $callback(), $ttl); + $value = $callback(); + + if (is_callable($ttl)) { + $ttlClosure = Closure::fromCallable($ttl); + $rf = new ReflectionFunction($ttlClosure); + $params = $rf->getNumberOfRequiredParameters(); + + if ($params === 0) { + /** @var Closure(): int $ttlClosure */ + $ttl = $ttlClosure(); + } elseif ($params === 1) { + /** @var Closure(mixed): int $ttlClosure */ + $ttl = $ttlClosure($value); + } else { + throw new InvalidArgumentException(sprintf( + 'Argument #2 ($ttl) must accept 0 or 1 parameter, %d given.', + $params, + )); + } + } + + $this->save($key, $value, $ttl); return $value; } diff --git a/tests/system/Cache/Handlers/ApcuHandlerTest.php b/tests/system/Cache/Handlers/ApcuHandlerTest.php index 5d5d5f7b2f6f..be4bbaa3b1cb 100644 --- a/tests/system/Cache/Handlers/ApcuHandlerTest.php +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\CLI\CLI; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use Config\Cache; use PHPUnit\Framework\Attributes\Group; @@ -92,6 +93,49 @@ public function testRemember(): void $this->assertNull($this->handler->get(self::$key1)); } + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 2, static fn (): string => 'value'); + + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument #2 ($ttl) must accept 0 or 1 parameter, 2 given.'); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); diff --git a/tests/system/Cache/Handlers/DummyHandlerTest.php b/tests/system/Cache/Handlers/DummyHandlerTest.php index ab3b3ccd643d..2bdc42b5bd5d 100644 --- a/tests/system/Cache/Handlers/DummyHandlerTest.php +++ b/tests/system/Cache/Handlers/DummyHandlerTest.php @@ -48,6 +48,20 @@ public function testRemember(): void $this->assertNull($dummyHandler); } + public function testRememberWithTTLCallable(): void + { + $dummyHandler = $this->handler->remember('key', static fn (): int => 2, static fn (): string => 'value'); + + $this->assertNull($dummyHandler); + } + + public function testRememberWithTTLCallableAndValuePassed(): void + { + $dummyHandler = $this->handler->remember('key', static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertNull($dummyHandler); + } + public function testSave(): void { $this->assertTrue($this->handler->save('key', 'value')); diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index 16c6042be124..1b27c4b0e834 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\CLI\CLI; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use Config\Cache; use PHPUnit\Framework\Attributes\DataProvider; @@ -144,6 +145,49 @@ public function testRemember(): void $this->assertNull($this->handler->get(self::$key1)); } + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 2, static fn (): string => 'value'); + + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument #2 ($ttl) must accept 0 or 1 parameter, 2 given.'); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + /** * chmod('path', 0444) does not work on Windows */ diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index 4597e95ccd12..bb99e77197c3 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\Exceptions\BadMethodCallException; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use Config\Cache; use PHPUnit\Framework\Attributes\Group; @@ -101,6 +102,49 @@ public function testRemember(): void $this->assertNull($this->handler->get(self::$key1)); } + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 2, static fn (): string => 'value'); + + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument #2 ($ttl) must accept 0 or 1 parameter, 2 given.'); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 3b317df2299e..d81fb5d02243 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use Config\Cache; use PHPUnit\Framework\Attributes\Group; @@ -109,6 +110,49 @@ public function testRemember(): void $this->assertNull($this->handler->get(self::$key1)); } + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 2, static fn (): string => 'value'); + + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument #2 ($ttl) must accept 0 or 1 parameter, 2 given.'); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index 72a66f04df1b..883cd6644957 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Cache\LockStoreInterface; use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use Config\Cache; use PHPUnit\Framework\Attributes\DataProvider; @@ -110,6 +111,49 @@ public function testRemember(): void $this->assertNull($this->handler->get(self::$key1)); } + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 2, static fn (): string => 'value'); + + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument #2 ($ttl) must accept 0 or 1 parameter, 2 given.'); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 861b0f3d7a31..90349e0fe744 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -43,6 +43,7 @@ Interface Changes **NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods or method changes to ensure compatibility. +- **Cache:** ``CodeIgniter\Cache\CacheInterface::remember()`` now accepts a TTL callable. All built-in cache handlers inherit this method via ``BaseHandler``, so no changes are required for them. - **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, and ``transaction()`` methods. - **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. @@ -230,6 +231,7 @@ Model Libraries ========= +- **Cache:** Added support for TTL callables in the ``remember()`` method of cache handlers. This allows you to specify a callable that returns a TTL value, which can be useful for dynamic TTL. - **Context**: This new feature allows you to easily set and retrieve normal or hidden contextual data for the current request. See :ref:`Context ` for details. - **Images:**: Added support for the AVIF file format. - **Locks:** Added :doc:`Atomic Locks ` for owner-aware, cross-process mutual exclusion backed by supported cache handlers: **File**, **Redis**, **Predis**, and **Memcached**. Memcached support has driver-specific release limitations because Memcached has no atomic compare-and-delete command. diff --git a/user_guide_src/source/libraries/caching.rst b/user_guide_src/source/libraries/caching.rst index b208d63499df..cb898c22d67c 100644 --- a/user_guide_src/source/libraries/caching.rst +++ b/user_guide_src/source/libraries/caching.rst @@ -128,10 +128,10 @@ Class Reference .. literalinclude:: caching/003.php - .. php:method:: remember(string $key, int $ttl, Closure $callback) + .. php:method:: remember(string $key, callable|int $ttl, Closure $callback) :param string $key: Cache item name - :param int $ttl: Time to live in seconds + :param callable|int $ttl: Time to live in seconds :param Closure $callback: Callback to invoke when the cache item returns null :returns: The value of the cache item :rtype: mixed @@ -139,6 +139,29 @@ Class Reference Gets an item from the cache. If ``null`` was returned, this will invoke the callback and save the result. Either way, this will return the value. + The ``$ttl`` parameter may also be a callable, allowing the TTL to be + determined dynamically at runtime. This is especially useful when the + expiration time depends on the computed value or requires an expensive + calculation. + + When a callable is provided, it will only be executed on a cache miss, + after the callback has been invoked. The callable may optionally accept + the computed value as its first argument: + + .. literalinclude:: caching/015.php + + This ensures that TTL computation is deferred until necessary and avoids + unnecessary overhead when the cache item already exists. + + .. note:: Prior to v4.8.0, the second parameter only accepted an integer TTL value. The ability to pass a callable was added in v4.8.0. + + .. note:: When using the APCu cache handler, providing a callable TTL disables + the use of ``apcu_entry()`` and falls back to a manual cache retrieval + and storage process. As a result, the operation is no longer atomic + and may be subject to race conditions under high concurrency. + + If atomic behavior is required, use an integer TTL value. + .. php:method:: save(string $key, $data[, int $ttl = 60]) :param string $key: Cache item name @@ -276,7 +299,7 @@ Drivers APCu Caching ============ -APCu is an in-memory key-value store for PHP. +APCu is an in-memory key-value store for PHP. To use it, you need the `APCu PHP extension `_. diff --git a/user_guide_src/source/libraries/caching/015.php b/user_guide_src/source/libraries/caching/015.php new file mode 100644 index 000000000000..cb7f6ccb36f5 --- /dev/null +++ b/user_guide_src/source/libraries/caching/015.php @@ -0,0 +1,11 @@ +remember('key', static fn () => 60, static fn () => fetchData()); + +// Value-aware TTL +$cache->remember( + 'key', + static fn ($value) => $value->expires_at - time(), + static fn () => fetchData(), +);