From 4d6b6bed739155dc10c0ba6aec09b3c6770833c6 Mon Sep 17 00:00:00 2001 From: Sergey K Date: Thu, 8 Jan 2026 03:12:34 +0500 Subject: [PATCH 1/9] feat: APCu caching driver implementation --- app/Config/Cache.php | 2 + system/Cache/Handlers/ApcuHandler.php | 165 ++++++++++++++++ .../system/Cache/Handlers/ApcuHandlerTest.php | 180 ++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 system/Cache/Handlers/ApcuHandler.php create mode 100644 tests/system/Cache/Handlers/ApcuHandlerTest.php 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/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php new file mode 100644 index 000000000000..b91f1bf240b7 --- /dev/null +++ b/system/Cache/Handlers/ApcuHandler.php @@ -0,0 +1,165 @@ + + * + * 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; + } + + /** + * {@inheritDoc} + */ + 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())), + static fn ($key) => fnmatch($pattern, $key), + ); + + if ($matchedKeys) { + return count($matchedKeys) - count(apcu_delete($matchedKeys)); + } + + return 0; + } + + /** + * {@inheritDoc} + */ + public function increment(string $key, int $offset = 1): bool|int + { + $key = static::validateKey($key, $this->prefix); + + return apcu_inc($key, $offset); + } + + /** + * {@inheritDoc} + */ + public function decrement(string $key, int $offset = 1): bool|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|object|null + { + return apcu_cache_info(true); + } + + /** + * {@inheritDoc} + */ + public function getMetaData(string $key): ?array + { + $key = static::validateKey($key, $this->prefix); + + if ($metadata = apcu_key_info($key)) { + 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..d5313b3e57c9 --- /dev/null +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -0,0 +1,180 @@ + + * + * 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; + +/** + * @internal + */ +#[Group('CacheLive')] +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(); + + if (! extension_loaded('apcu')) { + $this->markTestSkipped('APCu extension not loaded.'); + } + + $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()); + } +} From 79942cbbdfca338ce9c7b8557b694845262456bd Mon Sep 17 00:00:00 2001 From: Sergey K Date: Thu, 8 Jan 2026 03:47:35 +0500 Subject: [PATCH 2/9] feat: APCu caching driver: changelog, documentation --- user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/libraries/caching.rst | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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..c5b8af5f7e94 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 ================== From 9770d8d368700bc81e013799b41b240218606d3d Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 9 Jan 2026 02:47:09 +0500 Subject: [PATCH 3/9] feat: APCu caching driver: keys iterator improvement, fixes --- system/Cache/Handlers/ApcuHandler.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php index b91f1bf240b7..6f30d846f50c 100644 --- a/system/Cache/Handlers/ApcuHandler.php +++ b/system/Cache/Handlers/ApcuHandler.php @@ -90,11 +90,11 @@ public function delete(string $key): bool public function deleteMatching(string $pattern): int { $matchedKeys = array_filter( - array_keys(iterator_to_array(new APCUIterator())), - static fn ($key) => fnmatch($pattern, $key), + array_keys(iterator_to_array(new APCUIterator(null, APC_ITER_KEY))), + static fn ($key): bool => fnmatch($pattern, $key), ); - if ($matchedKeys) { + if ($matchedKeys !== []) { return count($matchedKeys) - count(apcu_delete($matchedKeys)); } @@ -142,9 +142,10 @@ public function getCacheInfo(): array|false|object|null */ public function getMetaData(string $key): ?array { - $key = static::validateKey($key, $this->prefix); + $key = static::validateKey($key, $this->prefix); + $metadata = apcu_key_info($key); - if ($metadata = apcu_key_info($key)) { + if ($metadata !== null) { return [ 'expire' => $metadata['ttl'] > 0 ? Time::now()->getTimestamp() + $metadata['ttl'] : null, 'mtime' => $metadata['mtime'], From cbb69b9b7df9589b00ee23655fb47f7e84aa2765 Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 9 Jan 2026 02:49:00 +0500 Subject: [PATCH 4/9] feat: APCu caching driver: header fix --- user_guide_src/source/libraries/caching.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_guide_src/source/libraries/caching.rst b/user_guide_src/source/libraries/caching.rst index c5b8af5f7e94..4ec39b925061 100644 --- a/user_guide_src/source/libraries/caching.rst +++ b/user_guide_src/source/libraries/caching.rst @@ -274,7 +274,7 @@ Drivers ******* APCu Caching -================ +============ APCu is an in-memory key-value store for PHP. From f26b2ceec0a0a3da7edf3051d8cd8319a7b7f69b Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 9 Jan 2026 02:50:57 +0500 Subject: [PATCH 5/9] feat: APCu caching driver: phpunit workflow extra-ini-options --- .github/workflows/reusable-phpunit-test.yml | 5 +++++ 1 file changed, 5 insertions(+) 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' }} From aef434fd75da253bd0f62e3242fede9bfe1e6e4e Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 9 Jan 2026 02:52:16 +0500 Subject: [PATCH 6/9] feat: APCu caching driver: phpunit workflow APCu extension --- .github/workflows/test-phpunit.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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: From 8551ed1361e6c6febc1b590d46fdd4c18b983f86 Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 9 Jan 2026 20:54:42 +0500 Subject: [PATCH 7/9] feat: APCu caching driver: extension suggestion --- admin/framework/composer.json | 1 + composer.json | 1 + 2 files changed, 2 insertions(+) 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/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", From 6db7a821d2dbefd91f640d10e8a0ffebe295f936 Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 9 Jan 2026 20:58:07 +0500 Subject: [PATCH 8/9] feat: APCu caching driver: improvements --- system/Cache/Handlers/ApcuHandler.php | 9 +++------ tests/system/Cache/Handlers/ApcuHandlerTest.php | 2 ++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php index 6f30d846f50c..b9150baad9c1 100644 --- a/system/Cache/Handlers/ApcuHandler.php +++ b/system/Cache/Handlers/ApcuHandler.php @@ -33,9 +33,6 @@ public function __construct(Cache $config) $this->prefix = $config->prefix; } - /** - * {@inheritDoc} - */ public function initialize(): void { } @@ -104,7 +101,7 @@ public function deleteMatching(string $pattern): int /** * {@inheritDoc} */ - public function increment(string $key, int $offset = 1): bool|int + public function increment(string $key, int $offset = 1): false|int { $key = static::validateKey($key, $this->prefix); @@ -114,7 +111,7 @@ public function increment(string $key, int $offset = 1): bool|int /** * {@inheritDoc} */ - public function decrement(string $key, int $offset = 1): bool|int + public function decrement(string $key, int $offset = 1): false|int { $key = static::validateKey($key, $this->prefix); @@ -132,7 +129,7 @@ public function clean(): bool /** * {@inheritDoc} */ - public function getCacheInfo(): array|false|object|null + public function getCacheInfo(): array|false { return apcu_cache_info(true); } diff --git a/tests/system/Cache/Handlers/ApcuHandlerTest.php b/tests/system/Cache/Handlers/ApcuHandlerTest.php index d5313b3e57c9..5ea3dfc4a7a8 100644 --- a/tests/system/Cache/Handlers/ApcuHandlerTest.php +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -18,11 +18,13 @@ 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 { /** From 43b177a48d745a40fd357fc01f0ea01c72ed064f Mon Sep 17 00:00:00 2001 From: Sergey K Date: Sat, 10 Jan 2026 02:11:54 +0500 Subject: [PATCH 9/9] feat: APCu caching driver: improvements --- tests/system/Cache/Handlers/ApcuHandlerTest.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/system/Cache/Handlers/ApcuHandlerTest.php b/tests/system/Cache/Handlers/ApcuHandlerTest.php index 5ea3dfc4a7a8..5d5d5f7b2f6f 100644 --- a/tests/system/Cache/Handlers/ApcuHandlerTest.php +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -43,10 +43,6 @@ protected function setUp(): void { parent::setUp(); - if (! extension_loaded('apcu')) { - $this->markTestSkipped('APCu extension not loaded.'); - } - $this->handler = CacheFactory::getHandler(new Cache(), 'apcu'); }