diff --git a/.github/workflows/tasks.yml b/.github/workflows/tasks.yml index 93e655c..a618522 100644 --- a/.github/workflows/tasks.yml +++ b/.github/workflows/tasks.yml @@ -13,13 +13,12 @@ jobs: fail-fast: false max-parallel: 2 matrix: - php: [ '81', '82', '83', '84' ] + php: [ '82', '83', '84' ] typo3: [ '11', '12', '13' ] exclude: - php: '84' typo3: '11' - - php: '81' - typo3: '13' + outputs: result: ${{ steps.set-result.outputs.result }} php: ${{ matrix.php }} @@ -32,13 +31,16 @@ jobs: - name: Install Node.js (Alpine) run: apk add --no-cache nodejs npm - uses: actions/checkout@v4 + - name: Get composer project name + id: composer-name + run: echo "name=$(php -r "echo str_replace('/', '-', json_decode(file_get_contents('composer.json'))->name);")" >> $GITHUB_OUTPUT - uses: actions/cache@v4 with: path: | ~/.composer/cache/files vendor node_modules - key: ${{ matrix.typo3 }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + key: ${{ matrix.typo3 }}-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} restore-keys: | ${{ matrix.typo3 }}-${{ matrix.php }}-composer- - run: git config --global --add safe.directory $GITHUB_WORKSPACE @@ -54,17 +56,23 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: ${{ matrix.typo3 }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + name: ${{ steps.composer-name.outputs.name }}-${{ matrix.typo3 }}-${{ matrix.php }} path: summary/results.txt summary: runs-on: ubuntu-latest needs: build if: always() steps: + - uses: actions/checkout@v4 + - name: Get composer project name + id: composer-name + run: echo "name=$(python3 -c "import json; d=json.load(open('composer.json')); print(d['name'].replace('/', '-'))")" >> $GITHUB_OUTPUT - uses: actions/download-artifact@v4 with: path: summary + pattern: ${{ steps.composer-name.outputs.name }}-* + merge-multiple: false - name: Show results run: | echo "### Matrix results" - cat summary/**/results.txt + find summary -name results.txt | sort | xargs cat diff --git a/Classes/Cache/CacheManager.php b/Classes/Cache/CacheManager.php index a2dc1b4..81430bc 100644 --- a/Classes/Cache/CacheManager.php +++ b/Classes/Cache/CacheManager.php @@ -4,6 +4,7 @@ namespace Weakbit\FallbackCache\Cache; +use Override; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; @@ -13,6 +14,7 @@ use TYPO3\CMS\Core\Cache\Exception\InvalidBackendException; use TYPO3\CMS\Core\Cache\Exception\InvalidCacheException; use TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException; +use TYPO3\CMS\Core\Cache\Exception\NoSuchCacheGroupException; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -154,6 +156,11 @@ private function isStatusRed(string $identifier): bool }; } + private function isImmutable(string $identifier): bool + { + return (bool)($this->cacheConfigurations[$identifier]['tags'][0]['immutable'] ?? false); + } + /** * @throws DuplicateIdentifierException * @throws InvalidBackendException @@ -203,4 +210,128 @@ private function registerFallback(string $identifier, string $fallback): void $this->logger?->warning('Registering fallback cache ' . $fallback . ' for ' . $identifier); $this->fallbacks[$identifier] = $fallback; } + + /** + * @inheritdoc + */ + #[Override] + public function flushCaches(): void + { + $this->createAllCaches(); + foreach ($this->caches as $cache) { + if ($this->isImmutable($cache->getIdentifier())) { + continue; + } + + $cache->flush(); + } + } + + /** + * @inheritdoc + */ + #[Override] + public function flushCachesInGroup($groupIdentifier): void + { + $this->createAllCaches(); + if (!isset($this->cacheGroups[$groupIdentifier])) { + throw new NoSuchCacheGroupException("No cache in the specified group '" . $groupIdentifier . "'", 1390334120); + } + + foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) { + if (isset($this->caches[$cacheIdentifier])) { + if ($this->isImmutable($cacheIdentifier)) { + continue; + } + + $this->caches[$cacheIdentifier]->flush(); + } + } + } + + /** + * @param string $groupIdentifier + * @param string $tag Tag to search for + * @inheritdoc + */ + #[Override] + public function flushCachesInGroupByTag($groupIdentifier, $tag): void + { + if (empty($tag)) { + return; + } + + $this->createAllCaches(); + if (!isset($this->cacheGroups[$groupIdentifier])) { + throw new NoSuchCacheGroupException("No cache in the specified group '" . $groupIdentifier . "'", 1390337129); + } + + foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) { + if (isset($this->caches[$cacheIdentifier])) { + if ($this->isImmutable($cacheIdentifier)) { + continue; + } + + $this->caches[$cacheIdentifier]->flushByTag($tag); + } + } + } + + /** + * @inheritdoc + */ + #[Override] + public function flushCachesInGroupByTags($groupIdentifier, array $tags): void + { + if ($tags === []) { + return; + } + + $this->createAllCaches(); + if (!isset($this->cacheGroups[$groupIdentifier])) { + throw new NoSuchCacheGroupException("No cache in the specified group '" . $groupIdentifier . "'", 1390337130); + } + + foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) { + if (isset($this->caches[$cacheIdentifier])) { + if ($this->isImmutable($cacheIdentifier)) { + continue; + } + + $this->caches[$cacheIdentifier]->flushByTags($tags); + } + } + } + + /** + * @inheritdoc + */ + #[Override] + public function flushCachesByTag($tag): void + { + $this->createAllCaches(); + foreach ($this->caches as $cache) { + if ($this->isImmutable($cache->getIdentifier())) { + continue; + } + + $cache->flushByTag($tag); + } + } + + /** + * @inheritdoc + */ + #[Override] + public function flushCachesByTags(array $tags): void + { + $this->createAllCaches(); + foreach ($this->caches as $cache) { + if ($this->isImmutable($cache->getIdentifier())) { + continue; + } + + $cache->flushByTags($tags); + } + } } diff --git a/Classes/EventListener/CacheStatusEventListener.php b/Classes/EventListener/CacheStatusEventListener.php index cd7e7e7..8d6ced3 100644 --- a/Classes/EventListener/CacheStatusEventListener.php +++ b/Classes/EventListener/CacheStatusEventListener.php @@ -12,7 +12,7 @@ use Weakbit\FallbackCache\Event\CacheStatusEvent; #[AsEventListener( - identifier: \Weakbit\FallbackCache\EventListener\CacheStatusEventListener::class, + identifier: CacheStatusEventListener::class, event: CacheStatusEvent::class, )] class CacheStatusEventListener diff --git a/Classes/EventListener/SystemInformationToolbarCollectorEventListener.php b/Classes/EventListener/SystemInformationToolbarCollectorEventListener.php index 58a2d56..145e372 100644 --- a/Classes/EventListener/SystemInformationToolbarCollectorEventListener.php +++ b/Classes/EventListener/SystemInformationToolbarCollectorEventListener.php @@ -19,7 +19,7 @@ * displays some information about caches in the system information toolbar */ #[AsEventListener( - identifier: \Weakbit\FallbackCache\EventListener\SystemInformationToolbarCollectorEventListener::class, + identifier: SystemInformationToolbarCollectorEventListener::class, event: SystemInformationToolbarCollectorEvent::class, )] class SystemInformationToolbarCollectorEventListener diff --git a/Documentation/system-information.png b/Documentation/system-information.png new file mode 100644 index 0000000..9565de9 Binary files /dev/null and b/Documentation/system-information.png differ diff --git a/README.md b/README.md index 006b2e3..9d270f0 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,71 @@ You can *chain* them and also define a fallback for the fallback cache. You could end the chain with a cache with the NullBackend, if that also fails the hope for this TYPO3 request is lost. But using no cache may bring down your server, but that depends on the server and application. +## Immutable Cache Configuration + +This extension provides the ability to mark certain caches as "immutable", which means they will not be affected by cache flushing operations. This is particularly useful for caches that contain data that rarely changes and is expensive to regenerate, such as compiled templates, code caches, or reference data. + +⚠️ **WARNING**: Immutable caches must be manually managed by developers. The system will NOT automatically clear these caches during regular maintenance operations! + +### How to Configure Immutable Caches + +To mark a cache as immutable, add the `tags` configuration with the `immutable` property set to `true`: + + + +```PHP +$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['my_immutable_cache'] = [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\FileBackend::class, + 'options' => [ + 'defaultLifetime' => 604800, + ], + 'groups' => [ + 'system', + ], + 'tags' => [ + ['name' => 'cache', 'identifier' => 'my_immutable_cache', 'immutable' => true] + ] +]; +``` + +### Behavior of Immutable Caches + +When a cache is marked as immutable: + +1. It will **not** be cleared when `flushCaches()` is called +2. It will **not** be cleared when `flushCachesByTag()` is called with any tags +3. It will **not** be cleared when `flushCachesInGroup()` is called, even if the cache belongs to that group + +This feature ensures that important cache entries remain available even during maintenance operations or when other parts of the system trigger cache flushes. + +### When to Use Immutable Caches + +Consider using immutable caches for: + +- Compiled templates or CSS/JS assets that rarely change +- Code caches that are expensive to regenerate +- Core configuration data that is only updated during system upgrades +- Any cache data where regeneration would cause significant load on the system + +When you need to update an immutable cache, you'll need to manually clear it using direct backend operations or by temporarily removing the immutable flag. + +# How to Access the Cache Status + +1. Log in to your TYPO3 backend +2. Look at the top toolbar (the black bar at the top of the screen) +3. Find the system information icon (typically shows system details like TYPO3 version) +4. Click on this icon to see a dropdown menu +5. The cache status will be displayed + +![Cache Status in System Information Toolbar](Documentation/system-information.png) + # TODO -- [ ] Give a possibility to see the actual state of the caches + - [ ] Refactor addCacheStatus to comply with external calls +# Credits Inspired by https://packagist.org/packages/b13/graceful-cache + +Uses code from https://github.com/marketing-factory/typo3_prometheus \ No newline at end of file diff --git a/Tests/FallbackCacheTest.php b/Tests/FallbackCacheTest.php index 0ad5863..d407811 100644 --- a/Tests/FallbackCacheTest.php +++ b/Tests/FallbackCacheTest.php @@ -4,6 +4,7 @@ namespace Weakbit\FallbackCache\Tests; +use PHPUnit\Framework\Attributes\Test; use Psr\EventDispatcher\EventDispatcherInterface; use TYPO3\CMS\Core\Cache\Backend\FileBackend; use TYPO3\CMS\Core\Cache\Backend\NullBackend; @@ -114,6 +115,7 @@ protected function setUp(): void * @test * @throws NoSuchCacheException */ + #[Test] public function testGetGoodCacheReturnsCache(): void { $cacheManager = GeneralUtility::makeInstance(CacheManager::class); @@ -126,6 +128,7 @@ public function testGetGoodCacheReturnsCache(): void * @test * @throws NoSuchCacheException */ + #[Test] public function testGetBadCacheThrowsException(): void { $cacheManager = GeneralUtility::makeInstance(CacheManager::class); @@ -138,6 +141,7 @@ public function testGetBadCacheThrowsException(): void * @test * @throws NoSuchCacheException */ + #[Test] public function testGetBrokenCacheReturnsFallbackCache(): void { $cacheManager = GeneralUtility::makeInstance(CacheManager::class); @@ -150,6 +154,7 @@ public function testGetBrokenCacheReturnsFallbackCache(): void * @test * @throws NoSuchCacheException */ + #[Test] public function testGetStatusRedCacheReturnsFallbackCache(): void { $cacheManager = GeneralUtility::makeInstance(CacheManager::class); @@ -170,6 +175,7 @@ public function testGetStatusRedCacheReturnsFallbackCache(): void * @test * @throws NoSuchCacheException */ + #[Test] public function testCacheBackendStateVerificationAfterFallback(): void { $cacheManager = GeneralUtility::makeInstance(CacheManager::class); diff --git a/Tests/ImmutableCacheTagTest.php b/Tests/ImmutableCacheTagTest.php new file mode 100644 index 0000000..65e4daa --- /dev/null +++ b/Tests/ImmutableCacheTagTest.php @@ -0,0 +1,169 @@ + [ + 'Objects' => [ + CacheManager::class => [ + 'className' => \Weakbit\FallbackCache\Cache\CacheManager::class, + ], + ], + 'caching' => [ + 'cacheConfigurations' => [ + 'mutable_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + 'immutable_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + ], + ], + ], + ]; + + protected function setUp(): void + { + // Testing framework needed env vars + putenv('typo3DatabaseUsername='); + putenv('typo3DatabasePassword='); + putenv('typo3DatabaseHost='); + putenv('typo3DatabaseName='); + putenv('typo3DatabaseDriver=pdo_sqlite'); + + parent::setUp(); + + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + GeneralUtility::setSingletonInstance(CacheManager::class, $cacheManager); + + $cacheManager->setCacheConfigurations([ + 'mutable_cache' => [ + 'frontend' => VariableFrontend::class, + 'backend' => TransientMemoryBackend::class, + 'groups' => ['system'], + 'tags' => [ + ['name' => 'cache', 'identifier' => 'mutable_cache', 'immutable' => false] + ] + ], + 'immutable_cache' => [ + 'frontend' => VariableFrontend::class, + 'backend' => TransientMemoryBackend::class, + 'groups' => ['system'], + 'tags' => [ + ['name' => 'cache', 'identifier' => 'immutable_cache', 'immutable' => true] + ] + ], + ]); + } + + /** + * @test + * @throws NoSuchCacheException + */ + #[Test] + public function flushMethodDoesNotFlushImmutableCaches(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + + // Store values in both mutable and immutable caches (with and without tags) + $mutableCache = $cacheManager->getCache('mutable_cache'); + $immutableCache = $cacheManager->getCache('immutable_cache'); + + $mutableCache->set('test_key', 'test_value'); + $mutableCache->set('tagged_key', 'tagged_value', ['flush_tag']); + + $immutableCache->set('test_key', 'test_value'); + $immutableCache->set('tagged_key', 'tagged_value', ['flush_tag']); + + // Verify values are set + $this->assertEquals('test_value', $mutableCache->get('test_key')); + $this->assertEquals('tagged_value', $mutableCache->get('tagged_key')); + $this->assertEquals('test_value', $immutableCache->get('test_key')); + $this->assertEquals('tagged_value', $immutableCache->get('tagged_key')); + + // First test tag-based flushing + $cacheManager->flushCachesByTag('flush_tag'); + + // Only mutable cache entry with the tag should be flushed + $this->assertEquals('test_value', $mutableCache->get('test_key')); + $this->assertFalse($mutableCache->get('tagged_key')); + $this->assertEquals('test_value', $immutableCache->get('test_key')); + $this->assertEquals('tagged_value', $immutableCache->get('tagged_key')); + + // Reset tagged entry + $mutableCache->set('tagged_key', 'tagged_value', ['flush_tag']); + + // Now flush all caches + $cacheManager->flushCaches(); + + // Mutable cache should be empty, immutable cache should still have values + $this->assertFalse($mutableCache->get('test_key')); + $this->assertFalse($mutableCache->get('tagged_key')); + $this->assertEquals('test_value', $immutableCache->get('test_key')); + $this->assertEquals('tagged_value', $immutableCache->get('tagged_key')); + } + + /** + * @test + * @throws NoSuchCacheException|NoSuchCacheGroupException + */ + #[Test] + public function flushCachesInGroupDoesNotFlushImmutableCaches(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + + // Store values in both mutable and immutable caches (with and without tags) + $mutableCache = $cacheManager->getCache('mutable_cache'); + $immutableCache = $cacheManager->getCache('immutable_cache'); + + $mutableCache->set('test_key', 'test_value'); + $mutableCache->set('tagged_key', 'tagged_value', ['group_tag']); + + $immutableCache->set('test_key', 'test_value'); + $immutableCache->set('tagged_key', 'tagged_value', ['group_tag']); + + // First test tag-based flushing + $cacheManager->flushCachesByTag('group_tag'); + + // Only mutable cache entry with the tag should be flushed + $this->assertEquals('test_value', $mutableCache->get('test_key')); + $this->assertFalse($mutableCache->get('tagged_key')); + $this->assertEquals('test_value', $immutableCache->get('test_key')); + $this->assertEquals('tagged_value', $immutableCache->get('tagged_key')); + + // Reset tagged entry + $mutableCache->set('tagged_key', 'tagged_value', ['group_tag']); + + // Flush caches in 'system' group + $cacheManager->flushCachesInGroup('system'); + + // Mutable cache should be empty, immutable cache should still have values + $this->assertFalse($mutableCache->get('test_key')); + $this->assertFalse($mutableCache->get('tagged_key')); + $this->assertEquals('test_value', $immutableCache->get('test_key')); + $this->assertEquals('tagged_value', $immutableCache->get('tagged_key')); + } +} diff --git a/composer.json b/composer.json index 13b9ac6..b218d27 100644 --- a/composer.json +++ b/composer.json @@ -6,14 +6,14 @@ ], "type": "typo3-cms-extension", "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", "typo3/cms-core": "~11.5.0 || ~12.4.0 || ~13.4.0" }, "require-dev": { "phpstan/extension-installer": "^1.1", "pluswerk/grumphp-config": "*", "rybakit/msgpack": "*", - "saschaegerer/phpstan-typo3": "*", + "saschaegerer/phpstan-typo3": "^2.0.0 || ^3.0.1", "ssch/typo3-rector": "*", "typo3/testing-framework": "*" }, diff --git a/rector.php b/rector.php index a2f0201..a852f25 100644 --- a/rector.php +++ b/rector.php @@ -11,6 +11,7 @@ use Rector\Privatization\Rector\Class_\FinalizeClassesWithoutChildrenRector; use Rector\Privatization\Rector\ClassMethod\PrivatizeFinalClassMethodRector; use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector; +use Ssch\TYPO3Rector\CodeQuality\General\GeneralUtilityMakeInstanceToConstructorPropertyRector; use Ssch\TYPO3Rector\Rector\v11\v0\DateTimeAspectInsteadOfGlobalsExecTimeRector; return static function (RectorConfig $rectorConfig): void { @@ -56,6 +57,7 @@ static function ($path): bool { //DateTimeAspectInsteadOfGlobalsExecTimeRector::class, RemoveExtraParametersRector::class, RemoveUnusedPrivateMethodRector::class, + GeneralUtilityMakeInstanceToConstructorPropertyRector::class, /** * rector should not touch these files