diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 8792d2bc18af..0a8f37422c58 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -43,6 +43,10 @@ on: description: Additional PHP extensions that are needed to be enabled type: string required: false + extra-ini-options: + description: Additional PHP configuration directives that should be appended to the php.ini + type: string + required: false extra-composer-options: description: Additional Composer options that should be appended to the `composer update` call type: string @@ -163,6 +167,7 @@ jobs: php-version: ${{ inputs.php-version }} tools: composer extensions: gd, ${{ inputs.extra-extensions }} + ini-values: ${{ inputs.extra-ini-options }} coverage: ${{ env.COVERAGE_DRIVER }} env: COVERAGE_DRIVER: ${{ inputs.enable-coverage && 'xdebug' || 'none' }} diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index 38f432c4b796..d9191cec858d 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -163,7 +163,8 @@ jobs: enable-artifact-upload: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} enable-coverage: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} enable-profiling: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} - extra-extensions: redis, memcached + extra-extensions: redis, memcached, apcu + extra-ini-options: apc.enable_cli=1 extra-composer-options: ${{ matrix.composer-option }} coveralls: diff --git a/admin/framework/composer.json b/admin/framework/composer.json index 366a0a7ecc13..ab34d89fd260 100644 --- a/admin/framework/composer.json +++ b/admin/framework/composer.json @@ -27,6 +27,7 @@ "predis/predis": "^3.0" }, "suggest": { + "ext-apcu": "If you use Cache class ApcuHandler", "ext-curl": "If you use CURLRequest class", "ext-dom": "If you use TestResponse", "ext-exif": "If you run Image class tests", diff --git a/app/Config/Cache.php b/app/Config/Cache.php index a8e3e1f053ff..38ac5419d84c 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -3,6 +3,7 @@ namespace Config; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\Handlers\ApcuHandler; use CodeIgniter\Cache\Handlers\DummyHandler; use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\Cache\Handlers\MemcachedHandler; @@ -143,6 +144,7 @@ class Cache extends BaseConfig * @var array> */ public array $validHandlers = [ + 'apcu' => ApcuHandler::class, 'dummy' => DummyHandler::class, 'file' => FileHandler::class, 'memcached' => MemcachedHandler::class, diff --git a/composer.json b/composer.json index 52efeadefde1..f09d91f7792f 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "codeigniter4/framework": "self.version" }, "suggest": { + "ext-apcu": "If you use Cache class ApcuHandler", "ext-curl": "If you use CURLRequest class", "ext-dom": "If you use TestResponse", "ext-exif": "If you run Image class tests", diff --git a/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php new file mode 100644 index 000000000000..b9150baad9c1 --- /dev/null +++ b/system/Cache/Handlers/ApcuHandler.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use APCUIterator; +use Closure; +use CodeIgniter\I18n\Time; +use Config\Cache; + +/** + * APCu cache handler + * + * @see \CodeIgniter\Cache\Handlers\ApcuHandlerTest + */ +class ApcuHandler extends BaseHandler +{ + /** + * Note: Use `CacheFactory::getHandler()` to instantiate. + */ + public function __construct(Cache $config) + { + $this->prefix = $config->prefix; + } + + public function initialize(): void + { + } + + /** + * {@inheritDoc} + */ + public function get(string $key): mixed + { + $key = static::validateKey($key, $this->prefix); + $success = false; + + $data = apcu_fetch($key, $success); + + // Success returned by reference from apcu_fetch() + return $success ? $data : null; + } + + /** + * {@inheritDoc} + */ + public function save(string $key, $value, int $ttl = 60): bool + { + $key = static::validateKey($key, $this->prefix); + + return apcu_store($key, $value, $ttl); + } + + /** + * {@inheritDoc} + */ + public function remember(string $key, int $ttl, Closure $callback): mixed + { + $key = static::validateKey($key, $this->prefix); + + return apcu_entry($key, $callback, $ttl); + } + + /** + * {@inheritDoc} + */ + public function delete(string $key): bool + { + $key = static::validateKey($key, $this->prefix); + + return apcu_delete($key); + } + + /** + * {@inheritDoc} + */ + public function deleteMatching(string $pattern): int + { + $matchedKeys = array_filter( + array_keys(iterator_to_array(new APCUIterator(null, APC_ITER_KEY))), + static fn ($key): bool => fnmatch($pattern, $key), + ); + + if ($matchedKeys !== []) { + return count($matchedKeys) - count(apcu_delete($matchedKeys)); + } + + return 0; + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1): false|int + { + $key = static::validateKey($key, $this->prefix); + + return apcu_inc($key, $offset); + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1): false|int + { + $key = static::validateKey($key, $this->prefix); + + return apcu_dec($key, $offset); + } + + /** + * {@inheritDoc} + */ + public function clean(): bool + { + return apcu_clear_cache(); + } + + /** + * {@inheritDoc} + */ + public function getCacheInfo(): array|false + { + return apcu_cache_info(true); + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key): ?array + { + $key = static::validateKey($key, $this->prefix); + $metadata = apcu_key_info($key); + + if ($metadata !== null) { + return [ + 'expire' => $metadata['ttl'] > 0 ? Time::now()->getTimestamp() + $metadata['ttl'] : null, + 'mtime' => $metadata['mtime'], + 'data' => apcu_fetch($key), + ]; + } + + return null; + } + + /** + * {@inheritDoc} + */ + public function isSupported(): bool + { + return extension_loaded('apcu') && apcu_enabled(); + } +} diff --git a/tests/system/Cache/Handlers/ApcuHandlerTest.php b/tests/system/Cache/Handlers/ApcuHandlerTest.php new file mode 100644 index 000000000000..5d5d5f7b2f6f --- /dev/null +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\CLI\CLI; +use CodeIgniter\I18n\Time; +use Config\Cache; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; + +/** + * @internal + */ +#[Group('CacheLive')] +#[RequiresPhpExtension('apcu')] +final class ApcuHandlerTest extends AbstractHandlerTestCase +{ + /** + * @return list + */ + private static function getKeyArray(): array + { + return [ + self::$key1, + self::$key2, + self::$key3, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->handler = CacheFactory::getHandler(new Cache(), 'apcu'); + } + + protected function tearDown(): void + { + foreach (self::getKeyArray() as $key) { + $this->handler->delete($key); + } + } + + public function testNew(): void + { + $this->assertInstanceOf(ApcuHandler::class, $this->handler); + } + + /** + * 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 testGet(): void + { + $this->handler->save(self::$key1, 'value', 2); + + $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 testRemember(): void + { + $this->handler->remember(self::$key1, 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)); + } + + public function testSave(): void + { + $this->assertTrue($this->handler->save(self::$key1, 'value')); + } + + public function testSavePermanent(): void + { + $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); + $metaData = $this->handler->getMetaData(self::$key1); + + $this->assertNull($metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - Time::now()->getTimestamp()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->handler->delete(self::$key1)); + } + + public function testDelete(): void + { + $this->handler->save(self::$key1, 'value'); + + $this->assertTrue($this->handler->delete(self::$key1)); + $this->assertFalse($this->handler->delete(self::$dummy)); + } + + public function testDeleteMatching(): void + { + // Save items to match on + for ($i = 1; $i <= 50; $i++) { + $this->handler->save('key_' . $i, 'value' . $i); + } + + // Checking that with pattern 'key_1*' only 11 entries deleted: + // key_1, key_10, key_11, key_12, key_13, key_14, key_15, key_16, key_17, key_18, key_19 + $this->assertSame(11, $this->handler->deleteMatching('key_1*')); + + // Checking that with pattern '*1', only 3 entries deleted: + // key_21, key_31, key_41 + $this->assertSame(3, $this->handler->deleteMatching('key_*1')); + + // Checking that with pattern '*5*' only 5 entries deleted: + // key_5, key_25, key_35, key_45, key_50 + $this->assertSame(5, $this->handler->deleteMatching('*5*')); + + // Check final number of cache entries + $this->assertSame(31, $this->handler->getCacheInfo()['num_entries']); + } + + public function testIncrementAndDecrement(): void + { + $this->handler->save('counter', 100); + + foreach (range(1, 10) as $step) { + $this->handler->increment('counter', $step); + } + + $this->assertSame(155, $this->handler->get('counter')); + + $this->handler->decrement('counter', 20); + $this->assertSame(135, $this->handler->get('counter')); + + $this->handler->increment('counter', 5); + $this->assertSame(140, $this->handler->get('counter')); + } + + public function testClean(): void + { + $this->handler->save(self::$key1, 1); + + $this->assertTrue($this->handler->clean()); + } + + public function testGetCacheInfo(): void + { + $this->handler->save(self::$key1, 'value'); + + $this->assertIsArray($this->handler->getCacheInfo()); + } + + public function testIsSupported(): void + { + $this->assertTrue($this->handler->isSupported()); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index c4dcb7d7d40f..30cb12744608 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -236,6 +236,7 @@ Libraries - **Cache:** Added ``persistent`` config item to Redis handler. - **Cache:** Added support for HTTP status in ``ResponseCache``. - **Cache:** Added ``Config\Cache::$cacheStatusCodes`` to control which HTTP status codes are allowed to be cached by the ``PageCache`` filter. Defaults to ``[]`` (all status codes for backward compatibility). Recommended value: ``[200]`` to only cache successful responses. See :ref:`Setting $cacheStatusCodes ` for details. +- **Cache:** Added `APCu `_ caching driver. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. diff --git a/user_guide_src/source/libraries/caching.rst b/user_guide_src/source/libraries/caching.rst index f4adc04a93df..4ec39b925061 100644 --- a/user_guide_src/source/libraries/caching.rst +++ b/user_guide_src/source/libraries/caching.rst @@ -36,7 +36,7 @@ $handler ======== The is the name of the handler that should be used as the primary handler when starting up the engine. -Available names are: dummy, file, memcached, redis, predis, wincache. +Available names are: apcu, dummy, file, memcached, redis, predis, wincache. $backupHandler ============== @@ -273,6 +273,13 @@ Class Reference Drivers ******* +APCu Caching +============ + +APCu is an in-memory key-value store for PHP. + +To use it, you need `APCu PHP extension `_. + File-based Caching ==================