Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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/<env>/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
Expand Down
4 changes: 4 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions config/packages/cache.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
6 changes: 6 additions & 0 deletions config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 0 additions & 12 deletions config/packages/test/cache.yaml

This file was deleted.

33 changes: 33 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 63 additions & 0 deletions src/Cache/CacheAdapterFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace App\Cache;

use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Cache\Adapter\ApcuAdapter;
use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
use Symfony\Component\Cache\Adapter\RedisAdapter;

/**
* Builds cache pool adapters for the backend selected with the CACHE_ADAPTER env var.
*
* Symfony resolves a pool's `adapter:` option to a concrete service id when the container
* is compiled, so the backend cannot be picked with a runtime `%env()%` placeholder in
* cache.yaml. Instead the pools extend the abstract `app.cache.adapter` service (defined
* in config/services.yaml), which delegates to this factory at runtime. Symfony's
* CachePoolPass injects each pool's computed namespace and configured default lifetime
* as the factory arguments.
*/
final readonly class CacheAdapterFactory
{
public const ADAPTERS = ['redis', 'memcached', 'dbal', 'filesystem', 'apcu'];

public function __construct(
private string $adapter,
private string $redisDsn,
private string $memcachedDsn,
private string $filesystemDirectory,
private ?Connection $dbalConnection = null,
private ?LoggerInterface $logger = null,
) {}

public function create(string $namespace = '', int $defaultLifetime = 0): AdapterInterface
{
$adapter = match ($this->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;
}
}
103 changes: 103 additions & 0 deletions tests/Cache/CacheAdapterFactoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace App\Tests\Cache;

use App\Cache\CacheAdapterFactory;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ApcuAdapter;
use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
use Symfony\Component\Cache\Adapter\RedisAdapter;

class CacheAdapterFactoryTest extends TestCase
{
public function testRedisAdapterIsCreatedWithoutConnecting(): void
{
// The DSN points at nothing — creation must succeed because the
// connection is lazy and only opened on first cache access.
$factory = $this->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,
);
}
}
Loading