SSR auto-purge: purge only the pages that show a saved record, megamenu purges everything

SsrPurgeTargetResolver finds the paths of a saved VITEC or news record: its detail URL, the pages that select it (sys_refindex), product lists by category or all products, list plugins of its type and the records embedding it one level deep. URL, visibility and timing changes, the newest news, events, unknown tables and more than 150 paths still purge everything. Megamenu records and the megamenu element now purge everything (before: only its storage page). New read-only command vitec:ssr-purge-targets shows what a save would purge. Reference index rebuilt (117 updates).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-12 12:16:44 +01:00
1 parent 06b9bc8293
commit e14f94ba63
4 files changed
+403 -8

No files matched your search

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Read-only: shows which SSR paths the auto-purge would clear when the
* given record is saved (SsrPurgeTargetResolver). Purges nothing.
*
* vitec:ssr-purge-targets tx_vitec_domain_model_product 12
* vitec:ssr-purge-targets tx_news_domain_model_news 300 --fields=title,bodytext
*/
#[AsCommand(
name: 'vitec:ssr-purge-targets',
description: 'Show which SSR paths the auto-purge would clear for a saved record (read-only, purges nothing)'
)]
final class SsrPurgeTargetsCommand extends Command
{
protected function configure(): void
{
$this->addArgument('table', InputArgument::REQUIRED, 'Table, e.g. tx_vitec_domain_model_product');
$this->addArgument('uid', InputArgument::REQUIRED, 'Record uid');
$this->addOption('fields', null, InputOption::VALUE_REQUIRED, 'Changed fields, comma-separated', 'title');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$fields = GeneralUtility::trimExplode(',', (string)$input->getOption('fields'), true);
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
->resolve((string)$input->getArgument('table'), (int)$input->getArgument('uid'), $fields);
if ($paths === []) {
$output->writeln('nothing to purge (record not rendered anywhere)');
} elseif ($paths === [SsrPurgeTargetResolver::ALL]) {
$output->writeln('<comment>* (everything)</comment>');
} else {
$output->writeln(count($paths) . ' path(s):');
foreach ($paths as $path) {
$output->writeln(' ' . $path);
}
}
return Command::SUCCESS;
}
}
@@ -6,6 +6,7 @@ namespace Evomedien\Vitec\Hooks;
use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Core\Environment;
use Evomedien\Vitec\Service\SsrPurgeService; use Evomedien\Vitec\Service\SsrPurgeService;
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
use TYPO3\CMS\Core\DataHandling\DataHandler; use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -19,10 +20,14 @@ use TYPO3\CMS\Core\Database\ConnectionPool;
* - pages: navigation-relevant field changed (title, nav_title, slug, * - pages: navigation-relevant field changed (title, nav_title, slug,
* hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page), * hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page),
* otherwise just that page's path. * otherwise just that page's path.
* - tt_content: the hosting page's path. * - tt_content: the hosting page's path; the megamenu element -> '*'.
* - tx_news_* and tx_vitec_*: '*' (news feed the megamenu spotlight, * - tx_news_* and tx_vitec_*: targeted since 2026-11-12 - the pages that
* domain models render on many pages). * show the record (SsrPurgeTargetResolver: detail URL, explicit
* - delete/move/undelete commands: pages -> '*', records -> hosting page. * selections, list plugins, embedding records); '*' for URL/visibility
* changes, the newest news, events and anything unknown.
* - megamenu records (tx_evomegamenujson_*): '*' - the menu is on every page.
* - delete/move/undelete commands: pages, records and megamenu -> '*',
* content elements -> hosting page.
* *
* Changes are queued per request and flushed ONCE in afterAllOperations, * Changes are queued per request and flushed ONCE in afterAllOperations,
* so bulk operations (importer!) end up as a single purge call. * so bulk operations (importer!) end up as a single purge call.
@@ -31,6 +36,8 @@ use TYPO3\CMS\Core\Database\ConnectionPool;
*/ */
final class DataHandlerSsrPurge final class DataHandlerSsrPurge
{ {
private const MEGAMENU_PREFIX = 'tx_evomegamenujson_';
private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime']; private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime'];
/** @var array<string,true> */ /** @var array<string,true> */
@@ -59,6 +66,10 @@ final class DataHandlerSsrPurge
} }
if ($table === 'tt_content') { if ($table === 'tt_content') {
$recordId = $this->realId($id, $dataHandler); $recordId = $this->realId($id, $dataHandler);
if ($this->isMegamenuElement($recordId)) {
self::$queue['*'] = true;
return;
}
$pid = $fieldArray['pid'] ?? null; $pid = $fieldArray['pid'] ?? null;
if (!is_numeric($pid) || (int)$pid <= 0) { if (!is_numeric($pid) || (int)$pid <= 0) {
$pid = $this->pidOfRecord($table, $recordId); $pid = $this->pidOfRecord($table, $recordId);
@@ -68,8 +79,16 @@ final class DataHandlerSsrPurge
} }
return; return;
} }
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) { if (str_starts_with($table, self::MEGAMENU_PREFIX)) {
self::$queue['*'] = true; self::$queue['*'] = true;
return;
}
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) {
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
->resolve($table, $this->realId($id, $dataHandler), $status === 'new' ? ['pid'] : array_keys($fieldArray));
foreach ($paths as $path) {
self::$queue[$path] = true;
}
} }
} }
@@ -94,17 +113,44 @@ final class DataHandlerSsrPurge
return; return;
} }
if ($table === 'tt_content') { if ($table === 'tt_content') {
if ($this->isMegamenuElement((int)$id)) {
self::$queue['*'] = true;
return;
}
$pid = $this->pidOfRecord($table, (int)$id); $pid = $this->pidOfRecord($table, (int)$id);
if ($pid > 0) { if ($pid > 0) {
$this->queuePagePath($pid); $this->queuePagePath($pid);
} }
return; return;
} }
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) { if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')
|| str_starts_with($table, self::MEGAMENU_PREFIX)) {
// Delete, move, copy, undelete: lists change everywhere.
self::$queue['*'] = true; self::$queue['*'] = true;
} }
} }
/**
* The megamenu element (CType evomegamenujson_megamenu) renders on
* every page through the site setting megamenu.contentUid.
*/
private function isMegamenuElement(int $uid): bool
{
if ($uid <= 0) {
return false;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$qb->getRestrictions()->removeAll();
$cType = $qb->select('CType')->from('tt_content')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
->executeQuery()->fetchOne();
return $cType === 'evomegamenujson_megamenu';
} catch (\Throwable) {
return false;
}
}
public function processCmdmap_afterFinish(DataHandler $dataHandler): void public function processCmdmap_afterFinish(DataHandler $dataHandler): void
{ {
$this->flush('editor-command'); $this->flush('editor-command');
@@ -115,7 +161,7 @@ final class DataHandlerSsrPurge
if (self::$queue === []) { if (self::$queue === []) {
return; return;
} }
$paths = array_keys(self::$queue); $paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue));
self::$queue = []; self::$queue = [];
GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger); GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger);
} }
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Targeted auto-purge (2026-11-12, Harry): which SSR paths show a given
* VITEC or news record, so saving one product no longer empties the
* whole SSR cache.
*
* A record shows up on
* 1. its own detail URL (route enhancers in config/sites/vitec/config.yaml),
* 2. pages whose content elements select it explicitly (sys_refindex),
* 3. pages with list plugins that pick records of its type on their own,
* 4. the pages of records that embed it (related products, product
* downloads, product solutions) - one level deep.
* Everything still goes ('*') when the change can touch links or menus on
* every page: URL, visibility or timing fields, the newest news and the
* events (the megamenu spotlight shows them live), unknown tables, or
* more than MAX_PATHS paths. When in doubt: '*'.
*/
final class SsrPurgeTargetResolver
{
public const ALL = '*';
private const MAX_PATHS = 150;
/** Changing one of these can change URLs, menus or visibility everywhere. */
private const GLOBAL_FIELDS = ['slug', 'path_segment', 'hidden', 'deleted', 'starttime', 'endtime',
'fe_group', 'pid', 'sys_language_uid', 'type', 'datetime', 'is_group'];
/** Backend-only tables: nothing of them is rendered. */
private const INTERNAL_TABLES = ['tx_vitec_form_submission', 'tx_vitec_import_mapping', 'tx_vitec_seo_research',
'tx_vitec_product_workbook', 'tx_vitec_ssrpurgelog', 'tx_vitec_ssrhealthlog', 'tx_vitec_clienterrorlog'];
/** Inline children: child table => [parent field, parent table]. */
private const CHILD_TABLES = [
'tx_vitec_product_related_text' => ['product_uid', 'tx_vitec_domain_model_product'],
'tx_vitec_product_resource' => ['product', 'tx_vitec_domain_model_product'],
'tx_vitec_product_feature' => ['product', 'tx_vitec_domain_model_product'],
'tx_news_domain_model_link' => ['parent', 'tx_news_domain_model_news'],
];
/** Shown in the megamenu or rarely changed and used broadly. */
private const ALWAYS_ALL_TABLES = ['tx_vitec_domain_model_event', 'tx_vitec_domain_model_searchcard',
'tx_news_domain_model_tag'];
/** List plugins that show records of a table without a fixed selection. */
private const LIST_CTYPES = [
// Product lists (vitec_productlist) filter by category: productListPages().
'tx_vitec_domain_model_product' => ['vitec_productcheck'],
'tx_vitec_domain_model_usecase' => ['vitec_usecaselist', 'vitec_usecaseshow'],
'tx_vitec_domain_model_solution' => ['vitec_solutionlist'],
'tx_vitec_domain_model_market' => ['vitec_marketlist', 'vitec_marketshow', 'vitec_usecaselist'],
'tx_vitec_domain_model_download' => ['vitec_downloadcardcollection'],
'tx_vitec_domain_model_location' => ['vitec_locationlist'],
'tx_vitec_domain_model_customer' => ['vitec_customerlogos'],
'tx_news_domain_model_news' => ['news_pi1', 'news_newsliststicky', 'news_newsdetail'],
];
/** Records that embed a record of the key table: [embedding table, field]. */
private const EMBEDDED_IN = [
'tx_vitec_domain_model_product' => [['tx_vitec_domain_model_product', 'relatedprodukt'],
['tx_vitec_product_related_text', 'related_uid']],
'tx_vitec_domain_model_download' => [['tx_vitec_domain_model_product', 'downloads']],
'tx_vitec_domain_model_solution' => [['tx_vitec_domain_model_product', 'solutions']],
'tx_vitec_domain_model_usecase' => [['tx_news_domain_model_news', 'vitec_canonical_usecase']],
];
/** Detail routes: table => [plugin namespace, controller, action, argument, detail page uids]. */
private const DETAIL_ROUTES = [
'tx_vitec_domain_model_product' => ['tx_vitec_productshow', 'Product', 'show', 'product', [10]],
'tx_vitec_domain_model_usecase' => ['tx_vitec_usecaseshow', 'Usecase', 'show', 'usecase', [4, 25, 26]],
'tx_news_domain_model_news' => ['tx_news_pi1', 'News', 'detail', 'news', [21]],
];
/** The megamenu spotlight shows the newest news (ProvideInsightsSpotlight, 3 + margin). */
private const SPOTLIGHT_NEWS = 5;
private const SPOTLIGHT_NEWS_PIDS = [11, 442];
/**
* @param string[] $changedFields field names of the save ([] = unknown)
* @return list<string> paths to purge, ['*'] for everything, [] for nothing visible
*/
public function resolve(string $table, int $uid, array $changedFields): array
{
try {
return $this->doResolve($table, $uid, $changedFields);
} catch (\Throwable) {
return [self::ALL];
}
}
private function doResolve(string $table, int $uid, array $changedFields): array
{
if (in_array($table, self::INTERNAL_TABLES, true)) {
return [];
}
if ($uid <= 0) {
return [self::ALL];
}
if (isset(self::CHILD_TABLES[$table])) {
[$field, $parentTable] = self::CHILD_TABLES[$table];
$parentUid = (int)$this->connection($table)->executeQuery(
'SELECT ' . $field . ' FROM ' . $table . ' WHERE uid = ?', [$uid]
)->fetchOne();
return $parentUid > 0 ? $this->doResolve($parentTable, $parentUid, []) : [self::ALL];
}
if (in_array($table, self::ALWAYS_ALL_TABLES, true) || !isset(self::LIST_CTYPES[$table])) {
return [self::ALL];
}
if (array_intersect($changedFields, self::GLOBAL_FIELDS) !== []) {
return [self::ALL];
}
if ($table === 'tx_news_domain_model_news' && $this->isSpotlightNews($uid)) {
return [self::ALL];
}
$paths = $this->pathsOf($table, $uid);
foreach (self::EMBEDDED_IN[$table] ?? [] as [$embeddingTable, $field]) {
foreach ($this->embeddingRecords($embeddingTable, $field, $table, $uid) as [$hostTable, $hostUid]) {
$paths = array_merge($paths, $this->pathsOf($hostTable, $hostUid));
}
}
foreach ($this->pagesWithCTypes(self::LIST_CTYPES[$table]) as $pid) {
$paths[] = $this->pagePath($pid);
}
if ($table === 'tx_vitec_domain_model_product') {
foreach ($this->productListPages($uid) as $pid) {
$paths[] = $this->pagePath($pid);
}
}
$paths = array_values(array_unique(array_filter($paths)));
return count($paths) > self::MAX_PATHS ? [self::ALL] : $paths;
}
/** Detail URL(s) plus the pages that select the record explicitly. */
private function pathsOf(string $table, int $uid): array
{
$paths = $this->detailPaths($table, $uid);
$pids = $this->connection('sys_refindex')->executeQuery(
'SELECT DISTINCT c.pid FROM sys_refindex r JOIN tt_content c ON c.uid = r.recuid
WHERE r.tablename = ? AND r.ref_table = ? AND r.ref_uid = ? AND c.deleted = 0',
['tt_content', $table, $uid]
)->fetchFirstColumn();
foreach ($pids as $pid) {
$paths[] = $this->pagePath((int)$pid);
}
if ($table === 'tx_vitec_domain_model_solution') {
$detailPage = (int)$this->connection($table)->executeQuery(
'SELECT detail_page FROM ' . $table . ' WHERE uid = ?', [$uid]
)->fetchOne();
if ($detailPage > 0) {
$paths[] = $this->pagePath($detailPage);
}
}
return $paths;
}
/** @return list<array{0: string, 1: int}> records embedding the given one */
private function embeddingRecords(string $embeddingTable, string $field, string $table, int $uid): array
{
if ($embeddingTable === 'tx_vitec_product_related_text') {
$rows = $this->connection($embeddingTable)->executeQuery(
'SELECT DISTINCT product_uid FROM tx_vitec_product_related_text WHERE related_uid = ?', [$uid]
)->fetchFirstColumn();
return array_map(static fn($u): array => ['tx_vitec_domain_model_product', (int)$u], $rows);
}
$rows = $this->connection('sys_refindex')->executeQuery(
'SELECT DISTINCT recuid FROM sys_refindex WHERE tablename = ? AND field = ? AND ref_table = ? AND ref_uid = ?',
[$embeddingTable, $field, $table, $uid]
)->fetchFirstColumn();
return array_map(static fn($u): array => [$embeddingTable, (int)$u], $rows);
}
private function detailPaths(string $table, int $uid): array
{
if (!isset(self::DETAIL_ROUTES[$table])) {
return [];
}
[$namespace, $controller, $action, $argument, $pageUids] = self::DETAIL_ROUTES[$table];
$paths = [];
foreach ($pageUids as $pageUid) {
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
$paths[] = $site->getRouter()->generateUri($pageUid, [
$namespace => ['action' => $action, 'controller' => $controller, $argument => $uid],
])->getPath();
} catch (\Throwable) {
// Record not routable (hidden, no slug): its pages are covered by the lists.
}
}
return $paths;
}
private function pagesWithCTypes(array $cTypes): array
{
if ($cTypes === []) {
return [];
}
return array_map('intval', $this->connection('tt_content')->executeQuery(
'SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN (?)',
[$cTypes],
[Connection::PARAM_STR_ARRAY]
)->fetchFirstColumn());
}
/**
* Pages with a product list that can show the product. Mirrors
* ProductListJsonRenderer: selected products (settings.selectedproducts)
* show exactly those - covered by the refindex; otherwise a category
* filter matches the selected categories and their whole subtree;
* "all products" or no category shows every product.
*/
private function productListPages(int $productUid): array
{
// The product's categories plus all their ancestors.
$categories = array_map('intval', $this->connection('sys_category_record_mm')->executeQuery(
"SELECT uid_local FROM sys_category_record_mm
WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories' AND uid_foreign = ?",
[$productUid]
)->fetchFirstColumn());
$seen = [];
$queue = $categories;
while ($queue !== []) {
$cat = array_shift($queue);
if ($cat <= 0 || isset($seen[$cat])) {
continue;
}
$seen[$cat] = true;
$queue[] = (int)$this->connection('sys_category')->executeQuery(
'SELECT parent FROM sys_category WHERE uid = ?', [$cat]
)->fetchOne();
}
$pages = [];
$lists = $this->connection('tt_content')->executeQuery(
"SELECT uid, pid, pi_flexform FROM tt_content WHERE deleted = 0 AND CType = 'vitec_productlist'"
)->fetchAllAssociative();
foreach ($lists as $list) {
$refs = $this->connection('sys_refindex')->executeQuery(
"SELECT ref_table, ref_uid FROM sys_refindex
WHERE tablename = 'tt_content' AND recuid = ? AND field = 'pi_flexform'
AND ref_table IN ('tx_vitec_domain_model_product', 'sys_category')",
[(int)$list['uid']]
)->fetchAllAssociative();
$selected = array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'tx_vitec_domain_model_product');
if ($selected !== []) {
continue; // fixed selection: in the refindex pages already when it contains the product
}
$listCategories = array_map('intval', array_column(
array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'sys_category'),
'ref_uid'
));
$allProducts = preg_match('#settings\.allproducts">\s*<value index="vDEF">1<#', (string)$list['pi_flexform']) === 1;
if ($allProducts || $listCategories === [] || array_intersect($listCategories, array_keys($seen)) !== []) {
$pages[] = (int)$list['pid'];
}
}
return array_values(array_unique($pages));
}
private function isSpotlightNews(int $uid): bool
{
$newest = $this->connection('tx_news_domain_model_news')->executeQuery(
'SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND type = 0 AND pid IN (?)
ORDER BY datetime DESC LIMIT ' . self::SPOTLIGHT_NEWS,
[self::SPOTLIGHT_NEWS_PIDS],
[Connection::PARAM_INT_ARRAY]
)->fetchFirstColumn();
return in_array($uid, array_map('intval', $newest), true);
}
private function pagePath(int $pageId): string
{
if ($pageId <= 0) {
return '';
}
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
return $site->getRouter()->generateUri($pageId)->getPath() ?: '/';
} catch (\Throwable) {
return ''; // Folder or page outside a site: nothing cached for it.
}
}
private function connection(string $table): Connection
{
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
}
}
@@ -130,7 +130,7 @@ settings:
vitec.ssr.autoPurge: vitec.ssr.autoPurge:
label: 'SSR auto-purge on editor changes' label: 'SSR auto-purge on editor changes'
description: 'ON: saving pages/content/news automatically evicts the affected pages from the SSR page cache (menu-relevant changes evict everything). OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.' description: 'ON: saving pages, content, news or VITEC records automatically evicts the pages that show them from the SSR page cache - a product only from its detail page and the lists that contain it. Menu-relevant changes (titles, URLs, visibility), the newest news, events and the megamenu evict everything. OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.'
category: VITEC category: VITEC
type: bool type: bool
default: true default: true