feature #4338 Add template cache hot reload (fabpot)

This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Add template cache hot reload

Closes #3880

This PR achieves 2 things in the minimum amount of code (in 2 commits):

* It adds a new `RemovableCacheInterface` that allows cache to be removed.
* It adds a memory cache to allow updating existing templates in the same process (think FrakenPHP for instance) without polluting the permanent cache.

Commits
-------

00ba8046b0 Add test
dca5099aad Add hot cache reload for templates
786a67b1a9 Add RemovableCacheInterface
This commit is contained in:
Fabien Potencier
2024-09-25 18:40:47 +02:00
7 changed files with 112 additions and 6 deletions
+1
View File
@@ -1,5 +1,6 @@
# 3.15.0 (2024-XX-XX)
* Add template cache hot reload
* Allow Twig callable argument names to be free-form (snake-case or camelCase) independently of the PHP callable signature
They were automatically converted to snake-cased before
* Deprecate the `attribute` function; use the `.` notation and wrap the name with parenthesis instead
+10 -1
View File
@@ -19,7 +19,7 @@ namespace Twig\Cache;
*
* @author Quentin Devos <quentin@devos.pm>
*/
final class ChainCache implements CacheInterface
final class ChainCache implements CacheInterface, RemovableCacheInterface
{
/**
* @param iterable<CacheInterface> $caches The ordered list of caches used to store and fetch cached items
@@ -69,6 +69,15 @@ final class ChainCache implements CacheInterface
return 0;
}
public function remove(string $name, string $cls): void
{
foreach ($this->caches as $cache) {
if ($cache instanceof RemovableCacheInterface) {
$cache->remove($name, $cls);
}
}
}
/**
* @return string[]
*/
+9 -1
View File
@@ -16,7 +16,7 @@ namespace Twig\Cache;
*
* @author Andrew Tch <andrew@noop.lv>
*/
class FilesystemCache implements CacheInterface
class FilesystemCache implements CacheInterface, RemovableCacheInterface
{
public const FORCE_BYTECODE_INVALIDATION = 1;
@@ -76,6 +76,14 @@ class FilesystemCache implements CacheInterface
throw new \RuntimeException(\sprintf('Failed to write cache file "%s".', $key));
}
public function remove(string $name, string $cls): void
{
$key = $this->generateKey($name, $cls);
if (!@unlink($key) && file_exists($key)) {
throw new \RuntimeException(\sprintf('Failed to delete cache file "%s".', $key));
}
}
public function getTimestamp(string $key): int
{
if (!is_file($key)) {
+5 -1
View File
@@ -16,7 +16,7 @@ namespace Twig\Cache;
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class NullCache implements CacheInterface
final class NullCache implements CacheInterface, RemovableCacheInterface
{
public function generateKey(string $name, string $className): string
{
@@ -35,4 +35,8 @@ final class NullCache implements CacheInterface
{
return 0;
}
public function remove(string $name, string $cls): void
{
}
}
+20
View File
@@ -0,0 +1,20 @@
<?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\Cache;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
interface RemovableCacheInterface
{
public function remove(string $name, string $cls): void;
}
+19 -3
View File
@@ -14,6 +14,7 @@ namespace Twig;
use Twig\Cache\CacheInterface;
use Twig\Cache\FilesystemCache;
use Twig\Cache\NullCache;
use Twig\Cache\RemovableCacheInterface;
use Twig\Error\Error;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
@@ -71,6 +72,7 @@ class Environment
/** @var bool */
private $useYield;
private $defaultRuntimeLoader;
private array $hotCache = [];
/**
* Constructor.
@@ -233,6 +235,18 @@ class Environment
return $this->strictVariables;
}
public function removeCache(string $name): void
{
$cls = $this->getTemplateClass($name);
$this->hotCache[$name] = $cls.'_'.bin2hex(random_bytes(16));
if ($this->cache instanceof RemovableCacheInterface) {
$this->cache->remove($name, $cls);
} else {
throw new \LogicException(\sprintf('The "%s" cache class does not support removing template cache as it does not implement the "RemovableCacheInterface" interface.', \get_class($this->cache)));
}
}
/**
* Gets the current cache implementation.
*
@@ -287,7 +301,7 @@ class Environment
*/
public function getTemplateClass(string $name, ?int $index = null): string
{
$key = $this->getLoader()->getCacheKey($name).$this->optionsHash;
$key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).$this->optionsHash;
return '__TwigTemplate_'.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $key).(null === $index ? '' : '___'.$index);
}
@@ -379,8 +393,10 @@ class Environment
if (!class_exists($cls, false)) {
$source = $this->getLoader()->getSourceContext($name);
$content = $this->compileSource($source);
$this->cache->write($key, $content);
$this->cache->load($key);
if (!isset($this->hotCache[$name])) {
$this->cache->write($key, $content);
$this->cache->load($key);
}
if (!class_exists($mainCls, false)) {
/* Last line of defense if either $this->bcWriteCacheFile was used,
+48
View File
@@ -22,6 +22,7 @@ use Twig\Extension\AbstractExtension;
use Twig\Extension\ExtensionInterface;
use Twig\Extension\GlobalsInterface;
use Twig\Loader\ArrayLoader;
use Twig\Loader\FilesystemLoader;
use Twig\Loader\LoaderInterface;
use Twig\Node\Node;
use Twig\NodeVisitor\NodeVisitorInterface;
@@ -497,6 +498,53 @@ EOF
$g3 = $twig->getGlobals();
$this->assertNotSame($g3['global_ext'], $g2['global_ext']);
}
public function testHotCache()
{
$dir = sys_get_temp_dir().'/twig-hot-cache-test';
if (is_dir($dir)) {
FilesystemHelper::removeDir($dir);
}
mkdir($dir);
file_put_contents($dir.'/index.twig', 'x');
try {
$twig = new Environment(new FilesystemLoader($dir), [
'debug' => false,
'auto_reload' => false,
'cache' => $dir.'/cache',
]);
// prime the cache
$this->assertSame('x', $twig->load('index.twig')->render([]));
// update the template
file_put_contents($dir.'/index.twig', 'y');
// re-render, should use the cached version
$this->assertSame('x', $twig->load('index.twig')->render([]));
// clear the cache
$twig->removeCache('index.twig');
// re-render, should use the updated template
$this->assertSame('y', $twig->load('index.twig')->render([]));
// the new template should not be cached
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir.'/cache', \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
$count = 0;
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isDir()) {
++$count;
}
}
$this->assertSame(0, $count);
// re-render, should use the updated template
$this->assertSame('y', $twig->load('index.twig')->render([]));
} finally {
FilesystemHelper::removeDir($dir);
}
}
}
class EnvironmentTest_Extension_WithGlobals extends AbstractExtension