mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-15 11:56:50 +00:00
bug #4923 Fix wrapping the Twig cache pool in a second tag aware adapter (nicolas-grekas)
This PR was merged into the 3.x branch.
Discussion
----------
Fix wrapping the Twig cache pool in a second tag aware adapter
`twig/extra-bundle` wires the `{% cache %}` pool like this:
```php
->set('twig.cache', TagAwareAdapter::class)
->args([$service('.twig.cache.inner')])
->set('.twig.cache.inner')
->parent('cache.app')
->tag('cache.pool', ['name' => 'twig.cache'])
```
`.twig.cache.inner` is a child of `cache.app`, so when an application configures
`framework.cache.app` with a natively tag aware adapter (`cache.adapter.redis_tag_aware`,
or the valkey, pdo and mongodb variants), the child pool is *already* a
`TagAwareAdapterInterface` and `twig.cache` wraps it in a second `TagAwareAdapter`.
Two nested `TagAwareAdapter`s never read back what they wrote:
```php
$pool = new TagAwareAdapter(new TagAwareAdapter(new ArrayAdapter()));
$item = $pool->getItem('k');
$item->set('value')->tag('t1');
$pool->save($item);
var_dump($pool->getItem('k')->isHit()); // bool(false)
```
So every `{% cache %}` block silently misses, on every request. This was reported here as
twigphp/Twig#3636 (the workaround in that thread is to redefine `twig.cache` and
`.twig.cache.inner` by hand as a `RedisTagAwareAdapter`), and again on the Symfony side as
symfony/symfony#54339 by `@rpkamp`. Symfony first tried to fix it in symfony/symfony#57927 by
deprecating passing a tag aware pool to `TagAwareAdapter`, but `@keulinho`'s analysis in
symfony/symfony#58830 showed that the deprecated thing was not the culprit, so that
deprecation was reverted in symfony/symfony#58950 and the defect was left where it is: in
this bundle. Symfony's own cache wiring already gets this right, it aliases
`cache.app.taggable` to `cache.app` when the configured adapter is natively tag aware
instead of decorating it.
## The fix
A small compiler pass resolves the parent chain of `.twig.cache.inner` and, when the
adapter it ends up on implements `TagAwareAdapterInterface`, drops the decorator and
aliases `twig.cache` to the pool. Nothing changes for the common case of a plain
`cache.app`, where the decorator is what makes the pool taggable and stays in place.
I picked this over declaring the pool through `framework.cache.pools` (with `tags: true`,
which would let Symfony make the same decision) because the pool is only registered when
the `cache` extension is enabled, and reproducing that condition inside a `prepend()` call
means processing the bundle's own configuration before `load()` runs. The compiler pass
stays entirely inside the bundle and only acts when `twig.cache` exists.
Detecting tag awareness from the resolved adapter class rather than from a list of adapter
ids keeps this working across `^5.4|^6.4|^7.0|^8.0` even though the set of natively tag
aware adapters grew over that range, and it also covers a `cache.app` overridden with a
custom tag aware adapter.
Two constraints are covered by the tests: `twig.cache` keeps its own namespace instead of
collapsing into the application pool, and the three argument aliases
(`TagAwareCacheInterface $twigCache`, `CacheInterface $twigCache`,
`CacheItemPoolInterface $twigCache`) keep resolving.
## Verification
Compiled containers with `framework.cache.app` left at its default and set to
`cache.adapter.redis_tag_aware`, before and after the patch:
| `framework.cache.app` | before | after |
| --- | --- | --- |
| default (filesystem) | `TagAwareAdapter(FilesystemAdapter)` | unchanged |
| `cache.adapter.redis_tag_aware` | `TagAwareAdapter(RedisTagAwareAdapter)` | `RedisTagAwareAdapter` |
| `cache.adapter.valkey_tag_aware` | `TagAwareAdapter(RedisTagAwareAdapter)` | `RedisTagAwareAdapter` |
| `cache.adapter.redis` | `TagAwareAdapter(RedisAdapter)` | unchanged |
In the fixed tag aware case the pool namespace stays distinct from `cache.app`'s.
`extra/twig-extra-bundle` test suite, on Symfony 8.2-dev and on framework-bundle 6.4:
```
PHPUnit 9.6.36 by Sebastian Bergmann and contributors.
Testing
................... 19 / 19 (100%)
Time: 00:00.534, Memory: 28.00 MB
OK (19 tests, 100 assertions)
```
The two new tests fail without the pass:
```
1) TwigCachePoolPassTest::testThePoolIsNotDecoratedWhenTheAppAdapterIsTagAware
Failed asserting that two strings are identical.
-'@.twig.cache.inner'
+'Symfony\Component\Cache\Adapter\TagAwareAdapter'
```
Commits
-------
bd939c8c3a Fix wrapping the Twig cache pool in a second tag aware adapter
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# 3.29.0 (2026-XX-XX)
|
||||
|
||||
* Fix `{% cache %}` always missing in the Symfony bundle when `framework.cache.app` uses a natively tag aware adapter
|
||||
* Fix the sandbox resolving `use` trait templates before checking that the `use` tag is allowed
|
||||
* Fix `html_attr` dropping `style` declarations whose value is `0`, `0.0` or `'0'`
|
||||
* Fix the `default` filter fallback emitting an undefined variable warning when it uses the null-safe operator
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler;
|
||||
|
||||
use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
|
||||
use Symfony\Component\DependencyInjection\ChildDefinition;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
|
||||
/**
|
||||
* Drops the "twig.cache" TagAwareAdapter when the pool it decorates is already tag aware.
|
||||
*
|
||||
* Wrapping a tag aware adapter in a TagAwareAdapter makes every read a miss, so the
|
||||
* decorator must go when "cache.app" uses a natively tag aware adapter, the same way
|
||||
* Symfony aliases "cache.app.taggable" to "cache.app" instead of decorating it.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TwigCachePoolPass implements CompilerPassInterface
|
||||
{
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
if (!$container->hasDefinition('twig.cache') || !$container->hasDefinition('.twig.cache.inner')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$definition = $container->getDefinition('.twig.cache.inner');
|
||||
$class = $definition->getClass();
|
||||
|
||||
while (null === $class && $definition instanceof ChildDefinition) {
|
||||
$parent = $definition->getParent();
|
||||
|
||||
if (!$container->hasDefinition($parent) && !$container->hasAlias($parent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$definition = $container->findDefinition($parent);
|
||||
$class = $definition->getClass();
|
||||
}
|
||||
|
||||
if (!is_a($class ?? '', TagAwareAdapterInterface::class, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$container->removeDefinition('twig.cache');
|
||||
$container->setAlias('twig.cache', '.twig.cache.inner');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\TwigExtraBundle\Tests\DependencyInjection\Compiler;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
|
||||
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Twig\Extra\TwigExtraBundle\Tests\Fixture\CacheKernel;
|
||||
|
||||
class TwigCachePoolPassTest extends TestCase
|
||||
{
|
||||
/** @var CacheKernel[] */
|
||||
private array $kernels = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->kernels as $kernel) {
|
||||
(new Filesystem())->remove($kernel->getTempDir());
|
||||
}
|
||||
|
||||
$this->kernels = [];
|
||||
}
|
||||
|
||||
public function testThePoolIsDecoratedWhenTheAppAdapterIsNotTagAware(): void
|
||||
{
|
||||
$pools = $this->compileFor(null);
|
||||
|
||||
$this->assertSame(TagAwareAdapter::class, $pools['twig.cache']);
|
||||
$this->assertSame(FilesystemAdapter::class, $pools['.twig.cache.inner']);
|
||||
$this->assertNotSame($pools['cache.app.namespace'], $pools['.twig.cache.inner.namespace']);
|
||||
}
|
||||
|
||||
public function testThePoolIsNotDecoratedWhenTheAppAdapterIsTagAware(): void
|
||||
{
|
||||
$pools = $this->compileFor('cache.adapter.redis_tag_aware');
|
||||
|
||||
$this->assertSame('@.twig.cache.inner', $pools['twig.cache']);
|
||||
$this->assertSame(RedisTagAwareAdapter::class, $pools['.twig.cache.inner']);
|
||||
$this->assertNotSame($pools['cache.app.namespace'], $pools['.twig.cache.inner.namespace']);
|
||||
}
|
||||
|
||||
private function compileFor(?string $cacheAdapter): array
|
||||
{
|
||||
$this->kernels[] = $kernel = new CacheKernel($cacheAdapter);
|
||||
$kernel->boot();
|
||||
|
||||
return $kernel->getContainer()->getParameter('twig_extra.test.cache_pools');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\TwigExtraBundle\Tests\Fixture;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\NotificationAssertionsTrait;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
use Symfony\Component\Config\Loader\LoaderInterface;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
|
||||
|
||||
/**
|
||||
* Compiles a container for a given "framework.cache.app" adapter and exposes how the
|
||||
* Twig cache pool ended up being wired in the "twig_extra.test.cache_pools" parameter.
|
||||
*/
|
||||
class CacheKernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
|
||||
private string $dir;
|
||||
|
||||
public function __construct(private ?string $cacheAdapter = null)
|
||||
{
|
||||
$this->dir = sys_get_temp_dir().'/twig-extra-bundle/'.bin2hex(random_bytes(6));
|
||||
|
||||
parent::__construct('test', false);
|
||||
}
|
||||
|
||||
public function getCacheDir(): string
|
||||
{
|
||||
return $this->dir.'/cache';
|
||||
}
|
||||
|
||||
public function getLogDir(): string
|
||||
{
|
||||
return $this->dir.'/log';
|
||||
}
|
||||
|
||||
public function getProjectDir(): string
|
||||
{
|
||||
return __DIR__;
|
||||
}
|
||||
|
||||
public function getTempDir(): string
|
||||
{
|
||||
return $this->dir;
|
||||
}
|
||||
|
||||
public function registerBundles(): iterable
|
||||
{
|
||||
yield new FrameworkBundle();
|
||||
yield new TwigBundle();
|
||||
yield new TwigExtraBundle();
|
||||
}
|
||||
|
||||
protected function build(ContainerBuilder $container): void
|
||||
{
|
||||
$container->addCompilerPass(new class implements CompilerPassInterface {
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
$pools = [];
|
||||
foreach (['twig.cache', '.twig.cache.inner', 'cache.app'] as $id) {
|
||||
if ($container->hasAlias($id)) {
|
||||
$pools[$id] = '@'.$container->getAlias($id);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$definition = $container->getDefinition($id);
|
||||
$pools[$id] = $definition->getClass();
|
||||
|
||||
// cache adapters take their namespace as their first string argument
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
if (\is_string($argument)) {
|
||||
$pools[$id.'.namespace'] = $argument;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$container->setParameter('twig_extra.test.cache_pools', $pools);
|
||||
}
|
||||
}, PassConfig::TYPE_BEFORE_REMOVING, -1000);
|
||||
}
|
||||
|
||||
protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void
|
||||
{
|
||||
$config = [
|
||||
'secret' => 'S3CRET',
|
||||
'router' => ['utf8' => true],
|
||||
'http_method_override' => false,
|
||||
'php_errors' => [
|
||||
'log' => true,
|
||||
],
|
||||
];
|
||||
|
||||
// the "handle_all_throwables" option was introduced in FrameworkBundle 6.2 (and so was the NotificationAssertionsTrait)
|
||||
if (trait_exists(NotificationAssertionsTrait::class)) {
|
||||
$config['handle_all_throwables'] = true;
|
||||
}
|
||||
|
||||
if (null !== $this->cacheAdapter) {
|
||||
$config['cache'] = ['app' => $this->cacheAdapter];
|
||||
}
|
||||
|
||||
$c->loadFromExtension('framework', $config);
|
||||
$c->loadFromExtension('twig', [
|
||||
'default_path' => __DIR__.'/views',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function configureRoutes($routes): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@
|
||||
|
||||
namespace Twig\Extra\TwigExtraBundle;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler\MissingExtensionSuggestorPass;
|
||||
use Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler\TwigCachePoolPass;
|
||||
|
||||
if (method_exists(KernelInterface::class, 'getShareDir')) {
|
||||
class TwigExtraBundle extends Bundle
|
||||
@@ -24,6 +26,8 @@ if (method_exists(KernelInterface::class, 'getShareDir')) {
|
||||
parent::build($container);
|
||||
|
||||
$container->addCompilerPass(new MissingExtensionSuggestorPass());
|
||||
// priority 64 so that it runs before Symfony's CachePoolPass (priority 32)
|
||||
$container->addCompilerPass(new TwigCachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 64);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -35,6 +39,8 @@ if (method_exists(KernelInterface::class, 'getShareDir')) {
|
||||
parent::build($container);
|
||||
|
||||
$container->addCompilerPass(new MissingExtensionSuggestorPass());
|
||||
// priority 64 so that it runs before Symfony's CachePoolPass (priority 32)
|
||||
$container->addCompilerPass(new TwigCachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user