diff --git a/.github/workflows/tasks.yml b/.github/workflows/tasks.yml new file mode 100644 index 0000000..93e655c --- /dev/null +++ b/.github/workflows/tasks.yml @@ -0,0 +1,70 @@ +name: PHP Composer + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + name: "php: ${{ matrix.php }} TYPO3: ${{ matrix.typo3 }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 2 + matrix: + php: [ '81', '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 }} + typo3: ${{ matrix.typo3 }} + container: + image: ghcr.io/typo3/core-testing-php${{ matrix.php }}:latest + steps: + - name: Check OS + run: cat /etc/os-release + - name: Install Node.js (Alpine) + run: apk add --no-cache nodejs npm + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: | + ~/.composer/cache/files + vendor + node_modules + 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 + - run: composer update --with typo3/minimal:^${{ matrix.typo3 }} --ignore-platform-req=php+ + - run: ./vendor/bin/grumphp run --ansi --no-interaction + - run: composer test + - name: Save result + if: always() + run: | + mkdir -p summary + echo "PHP=${{ matrix.php }} TYPO3=${{ matrix.typo3 }} RESULT=${{ job.status }}" \ + >> summary/results.txt + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ matrix.typo3 }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + path: summary/results.txt + summary: + runs-on: ubuntu-latest + needs: build + if: always() + steps: + - uses: actions/download-artifact@v4 + with: + path: summary + - name: Show results + run: | + echo "### Matrix results" + cat summary/**/results.txt diff --git a/.gitignore b/.gitignore index 1c3fc1d..6f8a200 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ public/ var/ vendor/ +.idea/ diff --git a/Classes/Cache/CacheManager.php b/Classes/Cache/CacheManager.php index bd3eb16..a2dc1b4 100644 --- a/Classes/Cache/CacheManager.php +++ b/Classes/Cache/CacheManager.php @@ -40,11 +40,6 @@ class CacheManager extends \TYPO3\CMS\Core\Cache\CacheManager implements LoggerA */ protected array $seen = []; - /** - * @var array - */ - protected array $breadcrumb = []; - /** * @param array> $cacheConfigurations */ @@ -54,12 +49,16 @@ public function setCacheConfigurations(array $cacheConfigurations): void try { $cache = $this->getCache('weakbit__fallback_cache'); if (!$cache instanceof VariableFrontend) { - throw new InvalidCacheException('Cache must be an instance of VariableFrontend'); + throw new InvalidCacheException('Cache must be an instance of VariableFrontend', 1736962058); } $status = $cache->get('status'); if (is_array($status)) { - static::$status = $status; + foreach ($status as $identifier => $state) { + if (is_string($identifier) && $state instanceof StatusEnum) { + static::$status[$identifier] = $state; + } + } } } catch (Throwable $throwable) { $this->logger?->error($throwable->getMessage()); @@ -68,11 +67,11 @@ public function setCacheConfigurations(array $cacheConfigurations): void public function getCache($identifier): FrontendInterface { - // could set to red during runtie of this(!) process + // could set to red during runtime of this(!) process if (!isset(static::$status[$identifier]) || static::$status[$identifier] !== StatusEnum::RED) { // can be null e.g. if the class was not found. /** @var FrontendInterface|null $cache */ - $cache = parent::getCache($identifier); + $cache = @parent::getCache($identifier); if ($cache) { return $cache; } @@ -81,14 +80,14 @@ public function getCache($identifier): FrontendInterface try { $fallback = $this->getFallbackCacheOf($identifier); if (null === $fallback) { - throw new NoFallbackFoundException('No fallback found for ' . $identifier); + throw new NoFallbackFoundException('No fallback found for ' . $identifier, 5859365252); } $cache = $this->getCache($this->fallbacks[$identifier]); } catch (RecursiveFallbackCacheException | NoFallbackFoundException $exception) { $this->logger?->error($exception->getMessage()); $chain = $this->getBreadcrumb($identifier); - throw new RuntimeException('Could not instanciate cache using the chain ' . $chain, $exception->getCode(), $exception); + throw new RuntimeException('Could not create cache using the chain ' . $chain, $exception->getCode(), $exception); } return $cache; @@ -112,8 +111,8 @@ private function getBreadcrumb(string $identifier, string $breadcrumb = ''): str public function addCacheStatus(string $identifier, StatusEnum $status): void { - // do not overwrite the highest state - if (isset(static::$status[$identifier]) && static::$status[$identifier] === StatusEnum::RED) { + // Always set RED, never overwrite RED with YELLOW, but always allow GREEN + if (isset(static::$status[$identifier]) && (static::$status[$identifier] === StatusEnum::RED && $status === StatusEnum::YELLOW)) { return; } @@ -134,11 +133,10 @@ protected function createCache($identifier): void try { parent::createCache($identifier); - } catch (InvalidCacheException | InvalidBackendException | RecursiveFallbackCacheException $exception) { + } catch (InvalidCacheException | InvalidBackendException $exception) { throw $exception; } catch (Throwable $throwable) { $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); - assert($eventDispatcher instanceof EventDispatcherInterface); $eventDispatcher->dispatch(new CacheStatusEvent(StatusEnum::RED, $identifier, $throwable)); $this->createCacheWithFallback($identifier); } @@ -168,9 +166,11 @@ private function createCacheWithFallback(string $identifier): void return; } - if (!$this->hasCache($fallback)) { + if (!isset($this->caches[$fallback])) { $this->createCache($fallback); } + + $this->caches[$identifier] = $this->caches[$fallback]; } public function getFallbackCacheOf(string $identifier): ?string @@ -191,7 +191,7 @@ public function getFallbackCacheOf(string $identifier): ?string } /** - * registers all fallback caches in the chain to prevent endess loops + * registers all fallback caches in the chain to prevent endless loops */ private function isSeen(?string $fallback): bool { diff --git a/Classes/Cache/CacheStatusInterface.php b/Classes/Cache/CacheStatusInterface.php deleted file mode 100644 index f242d09..0000000 --- a/Classes/Cache/CacheStatusInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -identifier])) { + throw new Exception('No cache configuration found for identifier ' . $this->identifier, 5511139720); + } + $configuration = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'][$this->identifier]; - $concreteClassName = $configuration['conrete_frontend'] ?? $configuration['frontend'] ?? \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class; + assert(is_array($configuration)); + $concreteClassName = $configuration['concrete_frontend'] ?? $configuration['frontend'] ?? \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class; if (!is_string($concreteClassName)) { - throw new Exception('Invalid concrete frontend class name'); + throw new Exception('Invalid concrete frontend class name', 5511139721); } /** @var class-string $concreteClassName */ @@ -129,7 +137,6 @@ public function get($entryIdentifier) private function handle(Throwable|Exception $exception): void { $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); - assert($eventDispatcher instanceof EventDispatcherInterface); $eventDispatcher->dispatch(new CacheStatusEvent(StatusEnum::YELLOW, $this->identifier, $exception)); } diff --git a/Classes/Enum/StatusEnum.php b/Classes/Enum/StatusEnum.php index e77c7c3..5dcf2c3 100644 --- a/Classes/Enum/StatusEnum.php +++ b/Classes/Enum/StatusEnum.php @@ -8,4 +8,5 @@ enum StatusEnum: string { case RED = 'red'; case YELLOW = 'yellow'; + case GREEN = 'green'; } diff --git a/Classes/EventListener/CacheStatusEventListener.php b/Classes/EventListener/CacheStatusEventListener.php index dd2c571..cd7e7e7 100644 --- a/Classes/EventListener/CacheStatusEventListener.php +++ b/Classes/EventListener/CacheStatusEventListener.php @@ -1,13 +1,20 @@ \Weakbit\FallbackCache\Cache\CacheManager::class ]; +``` +in additional.php or the equivalent settings.php file. + +This ensures the override is applied early and reliably, avoiding issues with loading order or race conditions that can occur if set in extension files like ext_localconf.php. + ## Example This defines a pages cache with the fallback cache: pages_fallback. @@ -29,7 +39,7 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages'] = // If the cache creation fails (Status red) this cache is used 'fallback' => 'pages_fallback', // The concrete frontend the 'frontend' is based on - 'conrete_frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'concrete_frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, 'groups' => [ 'pages', ] diff --git a/Tests/FallbackCacheTest.php b/Tests/FallbackCacheTest.php index 5f9b494..0ad5863 100644 --- a/Tests/FallbackCacheTest.php +++ b/Tests/FallbackCacheTest.php @@ -5,6 +5,7 @@ namespace Weakbit\FallbackCache\Tests; use Psr\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\Cache\Backend\FileBackend; use TYPO3\CMS\Core\Cache\Backend\NullBackend; use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend; use TYPO3\CMS\Core\Cache\CacheManager; @@ -12,16 +13,16 @@ use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; +use TYPO3Fluid\Fluid\Core\Cache\SimpleFileCache; use Weakbit\FallbackCache\Enum\StatusEnum; use Weakbit\FallbackCache\Event\CacheStatusEvent; use Weakbit\FallbackCache\Tests\Classes\BrokenCacheBackend; class FallbackCacheTest extends FunctionalTestCase { - protected \Weakbit\FallbackCache\Cache\CacheManager $cacheManager; - protected bool $resetSingletonInstances = true; + // Required to load Services.yaml protected array $testExtensionsToLoad = [ 'weakbit/fallback-cache', ]; @@ -33,11 +34,36 @@ class FallbackCacheTest extends FunctionalTestCase 'className' => \Weakbit\FallbackCache\Cache\CacheManager::class, ], ], + // can not set cache configuration here, as the db setup uses "new". Configuring dummies to avoid access on non-set array key + 'caching' => [ + 'cacheConfigurations' => [ + 'weakbit__fallback_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + 'broken_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + 'fallback_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + 'fallback_fallback_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + 'yet_working_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + 'good_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + 'bad_cache' => [ + 'backend' => TransientMemoryBackend::class, + ], + ], + ], ], ]; - - public function setUp(): void + protected function setUp(): void { // Testing framework needed the env vars also it is not used putenv('typo3DatabaseUsername='); @@ -48,8 +74,10 @@ public function setUp(): void parent::setUp(); - $this->cacheManager = GeneralUtility::makeInstance(CacheManager::class); - $this->cacheManager->setCacheConfigurations([ + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + GeneralUtility::setSingletonInstance(CacheManager::class, $cacheManager); + $cacheManager->setCacheConfigurations([ 'weakbit__fallback_cache' => [ 'frontend' => VariableFrontend::class, 'backend' => TransientMemoryBackend::class, @@ -57,7 +85,6 @@ public function setUp(): void 'system', ], ], - 'broken_cache' => [ 'backend' => BrokenCacheBackend::class, 'fallback' => 'fallback_cache', @@ -73,33 +100,96 @@ public function setUp(): void 'backend' => TransientMemoryBackend::class, 'fallback' => 'fallback_fallback_cache', ], + 'good_cache' => [ + 'backend' => FileBackend::class, + 'fallback' => 'fallback_fallback_cache', + ], + 'bad_cache' => [ + 'backend' => BrokenCacheBackend::class, + ], ]); } + /** + * @test + * @throws NoSuchCacheException + */ + public function testGetGoodCacheReturnsCache(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + $cache = $cacheManager->getCache('good_cache'); + $this->assertTrue($cache->getBackend() instanceof FileBackend); + } + + /** + * @test + * @throws NoSuchCacheException + */ + public function testGetBadCacheThrowsException(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + self::expectExceptionMessage('Could not create cache using the chain bad_cache'); + @$cacheManager->getCache('bad_cache'); + } + /** * @test * @throws NoSuchCacheException */ public function testGetBrokenCacheReturnsFallbackCache(): void { - $cache = $this->cacheManager->getCache('broken_cache'); + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + $cache = $cacheManager->getCache('broken_cache'); $this->assertTrue($cache->getBackend() instanceof NullBackend); } /** + * @test * @throws NoSuchCacheException */ public function testGetStatusRedCacheReturnsFallbackCache(): void { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + // here we use a functional one, check it works. then dispatch the status red event, and get the cache again, it should now respond with the fallback cache - $cache = $this->cacheManager->getCache('yet_working_cache'); + $cache = $cacheManager->getCache('yet_working_cache'); $this->assertTrue($cache->getBackend() instanceof TransientMemoryBackend); $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); - assert($eventDispatcher instanceof EventDispatcherInterface); $eventDispatcher->dispatch(new CacheStatusEvent(StatusEnum::RED, 'yet_working_cache')); - $cache = $this->cacheManager->getCache('yet_working_cache'); + $cache = $cacheManager->getCache('yet_working_cache'); $this->assertTrue($cache->getBackend() instanceof NullBackend); } + + /** + * @test + * @throws NoSuchCacheException + */ + public function testCacheBackendStateVerificationAfterFallback(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + assert($cacheManager instanceof \Weakbit\FallbackCache\Cache\CacheManager); + + $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + + // recover from previous test + $eventDispatcher->dispatch(new CacheStatusEvent(StatusEnum::GREEN, 'yet_working_cache')); + + // Store a value in the original backend + $cache = $cacheManager->getCache('yet_working_cache'); + $cache->set('foo', 'bar'); + $this->assertSame('bar', $cache->get('foo')); + + // Simulate RED status (fallback) + $eventDispatcher->dispatch(new CacheStatusEvent(StatusEnum::RED, 'yet_working_cache')); + $cache = $cacheManager->getCache('yet_working_cache'); + + // NullBackend should not return the value + $this->assertFalse($cache->get('foo')); + } } diff --git a/composer.json b/composer.json index f2dbf22..13b9ac6 100644 --- a/composer.json +++ b/composer.json @@ -6,17 +6,16 @@ ], "type": "typo3-cms-extension", "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "typo3/cms-core": "~11.5.0 || ~12.4.0" + "php": "~8.1.0 || ~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": "^6.8.0", + "pluswerk/grumphp-config": "*", "rybakit/msgpack": "*", - "saschaegerer/phpstan-typo3": "^1.1", - "ssch/typo3-rector": "^1.1.3", - "typo3/cms-adminpanel": "^11.0 || ^12.0", - "typo3/testing-framework": "^7.1.1" + "saschaegerer/phpstan-typo3": "*", + "ssch/typo3-rector": "*", + "typo3/testing-framework": "*" }, "autoload": { "psr-4": { @@ -36,7 +35,8 @@ "pluswerk/grumphp-config": true, "typo3/class-alias-loader": true, "typo3/cms-composer-installers": true - } + }, + "lock": false }, "extra": { "typo3/cms": { diff --git a/ext_localconf.php b/ext_localconf.php index 90cb578..d564976 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -8,6 +8,7 @@ defined('TYPO3') || die('Access denied.'); +// @phpstan-ignore-next-line $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['weakbit__fallback_cache'] = [ 'frontend' => VariableFrontend::class, 'backend' => FileBackend::class, @@ -16,6 +17,7 @@ ] ]; +// @phpstan-ignore-next-line $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][CacheManager::class] = [ 'className' => \Weakbit\FallbackCache\Cache\CacheManager::class ]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index aab4991..71d8946 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,2 +1,3 @@ parameters: - ignoreErrors: [] + ignoreErrors: + - '#Attribute class TYPO3\\CMS\\Core\\Attribute\\AsEventListener does not exist#' diff --git a/phpstan.neon b/phpstan.neon index 3b21f99..a34cbce 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,7 +4,6 @@ includes: parameters: level: 9 - phpVersion: 80300 reportUnmatchedIgnoredErrors: false excludePaths: - - 'Tests/*' + - 'Tests/*' diff --git a/rector.php b/rector.php index 668ce78..a2f0201 100644 --- a/rector.php +++ b/rector.php @@ -22,7 +22,7 @@ $paths = array_filter( explode("\n", (string)shell_exec("git ls-files | xargs ls -d 2>/dev/null | grep -E '\.(php|html|typoscript)$'")), - static function ($path) { + static function ($path): bool { if (!$path) { return false; } @@ -30,7 +30,7 @@ static function ($path) { return !str_starts_with($path, 'Tests/'); } ); - var_dump($paths); + $rectorConfig->paths( $paths ); @@ -49,11 +49,11 @@ static function ($path) { [ ...RectorSettings::skip(), ...RectorSettings::skipTypo3(), - FinalizePublicClassConstantRector::class, + //FinalizePublicClassConstantRector::class, PrivatizeFinalClassPropertyRector::class, PrivatizeFinalClassMethodRector::class, - FinalizeClassesWithoutChildrenRector::class, - DateTimeAspectInsteadOfGlobalsExecTimeRector::class, + //FinalizeClassesWithoutChildrenRector::class, + //DateTimeAspectInsteadOfGlobalsExecTimeRector::class, RemoveExtraParametersRector::class, RemoveUnusedPrivateMethodRector::class,