diff --git a/.env b/.env index bf9f13b95..8ae34e9aa 100644 --- a/.env +++ b/.env @@ -101,10 +101,44 @@ JWT_REFRESH_TOKEN_TTL=7200 JWT_SCREEN_REFRESH_TOKEN_TTL=2592000 ###< gesdinet/jwt-refresh-token-bundle ### +###> symfony/cache ### +# Backend for the application cache pools (feeds, calendar, screen auth/status, +# interactive slides). One of: +# redis - shared cache in Redis, connecting to REDIS_CACHE_DSN (default). +# Recommended for multi-node deployments: screen activation codes +# and screen status live in these pools and must be shared by all +# nodes. +# memcached - shared cache in Memcached, connecting to MEMCACHED_CACHE_DSN +# (requires the memcached PHP extension). Note that Memcached +# evicts under memory pressure, so long-lived entries (screen +# status, activation codes) may disappear early; Redis with its +# default noeviction policy does not have this caveat. +# dbal - shared cache in the application database (a cache_items table, +# created automatically on first use). Shared across nodes like +# redis without extra services, at the cost of extra database +# load. Expired rows are removed by the cache:clear that runs on +# every deploy. +# filesystem - per-node cache under var/cache//pools. No extra services +# needed; suitable for single-server setups without Redis. Expired +# entries are removed lazily and the pools are wiped by the +# cache:clear that runs on every deploy. +# apcu - shared memory of the local PHP process pool. Fast, but not +# shared with console commands/crons or across nodes, and emptied +# on php-fpm restart. The official images ship the apcu extension +# disabled — enable it with the container env vars +# PHP_APCU_ENABLED=1 and a suitable PHP_APCU_MEMORY_SIZE +# (image default: 16M). +CACHE_ADAPTER=redis +# Connection string for Memcached (used when CACHE_ADAPTER=memcached), e.g. +# memcached://localhost:11211 (no Memcached service ships with the dev stack). +MEMCACHED_CACHE_DSN=memcached://localhost:11211 +###< symfony/cache ### + ###> redis ### -# Prefix for Redis cache keys to avoid collisions across applications sharing a Redis instance. +# Prefix used to compute stable namespaces for cache keys, to avoid collisions +# across applications sharing a cache backend (used by all CACHE_ADAPTER choices). REDIS_CACHE_PREFIX=DisplayApiService -# Connection string for Redis cache server. +# Connection string for Redis cache server (used when CACHE_ADAPTER=redis). REDIS_CACHE_DSN=redis://redis:6379/0 # Session storage backend, consumed by framework.session.handler_id. # A `redis://...` DSN makes Symfony auto-build a RedisSessionHandler (with diff --git a/.env.test b/.env.test index b6bb163b4..05893a9c3 100644 --- a/.env.test +++ b/.env.test @@ -7,6 +7,10 @@ PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots DATABASE_URL="mysql://root:password@mariadb:3306/db_test?serverVersion=${MARIADB_VERSION}" +# Tests must not depend on a running Redis (CI has none); all cache pools use the +# filesystem under var/cache/test/pools. +CACHE_ADAPTER=filesystem + ###> lexik/jwt-authentication-bundle ### JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index d68f005a1..d998b06b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Made the cache backend for the application cache pools configurable with the new + `CACHE_ADAPTER` env var (`redis` (default), `memcached`, `dbal`, `filesystem` or + `apcu`), so installations can run without Redis — `dbal` keeps the cache shared + across nodes by storing it in the application database. The pools were previously + hard-wired to `cache.adapter.redis`; the default is unchanged. See the option + descriptions in `.env`. - Restructured `UPGRADE.md` into operator and developer guides: the operator guide separates application-config migration (now based on 2.8's `app:utils:convert-env-to-3x`) from infrastructure config, covers four hosting options (os2display-docker-server, published images diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index eb3c49c4b..adcd5ab4b 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -11,36 +11,42 @@ framework: default_redis_provider: '%env(REDIS_CACHE_DSN)%' pools: + # All pools use the env-selectable adapter (CACHE_ADAPTER, default "redis") + # built by App\Cache\CacheAdapterFactory below. + # Creates a "feeds.cache" service feeds.cache: - adapter: cache.adapter.redis + adapter: app.cache.adapter # Default expire set to 5 minutes default_lifetime: 300 feed.without.expire.cache: - adapter: cache.adapter.redis + adapter: app.cache.adapter # Safety net (24 hours) — code sets explicit TTL on items, # but this ensures orphaned keys don't persist indefinitely. default_lifetime: 86400 # Creates a "calendar.api.cache" service calendar.api.cache: - adapter: cache.adapter.redis + adapter: app.cache.adapter # Creates a "auth.screen.cache" service auth.screen.cache: - adapter: cache.adapter.redis + adapter: app.cache.adapter # Default expire set to 1 day default_lifetime: 86400 # Creates a "screen.status.cache" service ($screenStatusCache) screen.status.cache: - adapter: cache.adapter.redis + adapter: app.cache.adapter # Default expire set to infinity default_lifetime: 0 # Creates an "interactive_slide.cache" service interactive_slide.cache: - adapter: cache.adapter.redis + adapter: app.cache.adapter # Default expire set to 12 hours default_lifetime: 43200 +# The `app.cache.adapter` service the pools extend is defined in config/services.yaml +# (it must live there so the explicit App\Cache\CacheAdapterFactory definition takes +# precedence over the `App\` resource auto-registration). diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c67626df9..9b86a8573 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -9,6 +9,12 @@ doctrine: types: rrule: App\DBAL\RRuleType + # The cache_items table is created and owned by the cache layer when + # CACHE_ADAPTER=dbal (Symfony's DoctrineDbalAdapter); hide it from the + # schema tools so doctrine:migrations:diff and schema:validate don't + # try to drop it. + schema_filter: ~^(?!cache_items$)~ + orm: auto_generate_proxy_classes: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware diff --git a/config/packages/test/cache.yaml b/config/packages/test/cache.yaml deleted file mode 100644 index ed6d68236..000000000 --- a/config/packages/test/cache.yaml +++ /dev/null @@ -1,12 +0,0 @@ -framework: - cache: - pools: - feeds.cache: - adapter: cache.adapter.filesystem - default_lifetime: 300 - auth.screen.cache: - adapter: cache.adapter.filesystem - default_lifetime: 3600 - screen.status.cache: - adapter: cache.adapter.filesystem - default_lifetime: 0 diff --git a/config/services.yaml b/config/services.yaml index 6cf679bb2..3562896a5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -56,6 +56,39 @@ services: - '../src/Kernel.php' - '../src/Tests/' + # Builds the cache pool backend selected with CACHE_ADAPTER (see the pools in + # config/packages/cache.yaml and the option descriptions in .env). + App\Cache\CacheAdapterFactory: + arguments: + $adapter: '%env(CACHE_ADAPTER)%' + $redisDsn: '%env(REDIS_CACHE_DSN)%' + $memcachedDsn: '%env(MEMCACHED_CACHE_DSN)%' + $filesystemDirectory: '%kernel.cache_dir%/pools' + $logger: '@?logger' + tags: + - { name: 'monolog.logger', channel: 'cache' } + + # Abstract parent for the app's cache pools. A pool's `adapter:` must be a service + # id at container compile time, so the backend choice cannot be a `%env()%` + # placeholder in cache.yaml — instead this service builds the selected adapter at + # runtime through the factory. Symfony's CachePoolPass replaces the two arguments + # with each pool's namespace and default lifetime, and merges the tag attributes + # below into every pool, mirroring the built-in cache.adapter.* services (cleared + # on cache:clear, reset between messages in long-running workers). + app.cache.adapter: + class: Symfony\Component\Cache\Adapter\AdapterInterface + abstract: true + factory: ['@App\Cache\CacheAdapterFactory', 'create'] + arguments: + - '' # namespace + - 0 # default lifetime + tags: + - { + name: 'cache.pool', + clearer: 'cache.default_clearer', + reset: 'reset', + } + App\HttpClient\LoggingHttpClient: decorates: http_client arguments: diff --git a/src/Cache/CacheAdapterFactory.php b/src/Cache/CacheAdapterFactory.php new file mode 100644 index 000000000..b07d3d51c --- /dev/null +++ b/src/Cache/CacheAdapterFactory.php @@ -0,0 +1,63 @@ +adapter) { + // 'lazy' defers the connection until first use, matching how Symfony wires + // DSN-based providers (pools must be instantiable without a reachable Redis). + 'redis' => new RedisAdapter(RedisAdapter::createConnection($this->redisDsn, ['lazy' => true]), $namespace, $defaultLifetime), + // Requires ext-memcached (createConnection fails fast without it); the + // Memcached client itself only connects on first use. + 'memcached' => new MemcachedAdapter(MemcachedAdapter::createConnection($this->memcachedDsn), $namespace, $defaultLifetime), + // Reuses the application's Doctrine connection; the cache_items table is + // created automatically on first write (see also the schema_filter in + // config/packages/doctrine.yaml that hides it from the schema tools). + 'dbal' => new DoctrineDbalAdapter($this->dbalConnection ?? throw new \LogicException('CACHE_ADAPTER "dbal" requires a Doctrine DBAL connection.'), $namespace, $defaultLifetime), + 'filesystem' => new FilesystemAdapter($namespace, $defaultLifetime, $this->filesystemDirectory), + 'apcu' => new ApcuAdapter($namespace, $defaultLifetime), + default => throw new \InvalidArgumentException(sprintf('Unknown CACHE_ADAPTER "%s". Expected one of: "%s".', $this->adapter, implode('", "', self::ADAPTERS))), + }; + + if (null !== $this->logger) { + $adapter->setLogger($this->logger); + } + + return $adapter; + } +} diff --git a/tests/Cache/CacheAdapterFactoryTest.php b/tests/Cache/CacheAdapterFactoryTest.php new file mode 100644 index 000000000..cb1228227 --- /dev/null +++ b/tests/Cache/CacheAdapterFactoryTest.php @@ -0,0 +1,103 @@ +createFactory('redis'); + $this->assertInstanceOf(RedisAdapter::class, $factory->create('some.pool', 300)); + } + + public function testFilesystemAdapterIsCreated(): void + { + $factory = $this->createFactory('filesystem'); + $adapter = $factory->create('some.pool', 300); + $this->assertInstanceOf(FilesystemAdapter::class, $adapter); + + $item = $adapter->getItem('key'); + $adapter->save($item->set('value')); + $this->assertSame('value', $adapter->getItem('key')->get()); + } + + public function testMemcachedAdapterIsCreatedWithoutConnecting(): void + { + if (!MemcachedAdapter::isSupported()) { + $this->markTestSkipped('ext-memcached is not enabled.'); + } + + // The DSN points at nothing — creation must succeed because the + // Memcached client only connects on first cache access. + $factory = $this->createFactory('memcached'); + $this->assertInstanceOf(MemcachedAdapter::class, $factory->create('some.pool', 300)); + } + + public function testApcuAdapterIsCreated(): void + { + if (!ApcuAdapter::isSupported()) { + $this->markTestSkipped('APCu is not enabled.'); + } + + $factory = $this->createFactory('apcu'); + $this->assertInstanceOf(ApcuAdapter::class, $factory->create('some.pool', 300)); + } + + public function testDbalAdapterIsCreatedAndCreatesItsTableOnFirstUse(): void + { + if (!\extension_loaded('pdo_sqlite')) { + $this->markTestSkipped('pdo_sqlite is not enabled.'); + } + + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $factory = $this->createFactory('dbal', $connection); + $adapter = $factory->create('some.pool', 300); + $this->assertInstanceOf(DoctrineDbalAdapter::class, $adapter); + + $item = $adapter->getItem('key'); + $adapter->save($item->set('value')); + $this->assertSame('value', $adapter->getItem('key')->get()); + } + + public function testDbalAdapterRequiresAConnection(): void + { + $factory = $this->createFactory('dbal'); + + $this->expectException(\LogicException::class); + $factory->create('some.pool', 300); + } + + public function testUnknownAdapterIsRejected(): void + { + $factory = $this->createFactory('bogus'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown CACHE_ADAPTER "bogus"'); + $factory->create('some.pool', 300); + } + + private function createFactory(string $adapter, ?Connection $dbalConnection = null): CacheAdapterFactory + { + return new CacheAdapterFactory( + $adapter, + 'redis://localhost.invalid:6379/0', + 'memcached://localhost.invalid:11211', + sys_get_temp_dir().'/cache-adapter-factory-test', + $dbalConnection, + ); + } +}