From e14f94ba63ffc7d85a6996ffb22b4850610959e3 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Thu, 12 Nov 2026 12:16:44 +0100 Subject: [PATCH] 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 --- .../Command/SsrPurgeTargetsCommand.php | 53 ++++ .../Classes/Hooks/DataHandlerSsrPurge.php | 62 +++- .../Service/SsrPurgeTargetResolver.php | 296 ++++++++++++++++++ .../Sets/Vitecset/settings.definitions.yaml | 2 +- 4 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php create mode 100644 packages/vitec/Classes/Service/SsrPurgeTargetResolver.php diff --git a/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php b/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php new file mode 100644 index 0000000..dbd519f --- /dev/null +++ b/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php @@ -0,0 +1,53 @@ +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('* (everything)'); + } else { + $output->writeln(count($paths) . ' path(s):'); + foreach ($paths as $path) { + $output->writeln(' ' . $path); + } + } + return Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php index 49d5a9b..008417b 100644 --- a/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php +++ b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php @@ -6,6 +6,7 @@ namespace Evomedien\Vitec\Hooks; use TYPO3\CMS\Core\Core\Environment; use Evomedien\Vitec\Service\SsrPurgeService; +use Evomedien\Vitec\Service\SsrPurgeTargetResolver; use TYPO3\CMS\Core\DataHandling\DataHandler; use TYPO3\CMS\Core\Site\SiteFinder; 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, * hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page), * otherwise just that page's path. - * - tt_content: the hosting page's path. - * - tx_news_* and tx_vitec_*: '*' (news feed the megamenu spotlight, - * domain models render on many pages). - * - delete/move/undelete commands: pages -> '*', records -> hosting page. + * - tt_content: the hosting page's path; the megamenu element -> '*'. + * - tx_news_* and tx_vitec_*: targeted since 2026-11-12 - the pages that + * show the record (SsrPurgeTargetResolver: detail URL, explicit + * 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, * so bulk operations (importer!) end up as a single purge call. @@ -31,7 +36,9 @@ use TYPO3\CMS\Core\Database\ConnectionPool; */ final class DataHandlerSsrPurge { - private const NAV_FIELDS = ['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime']; + private const MEGAMENU_PREFIX = 'tx_evomegamenujson_'; + + private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime']; /** @var array */ private static array $queue = []; @@ -59,6 +66,10 @@ final class DataHandlerSsrPurge } if ($table === 'tt_content') { $recordId = $this->realId($id, $dataHandler); + if ($this->isMegamenuElement($recordId)) { + self::$queue['*'] = true; + return; + } $pid = $fieldArray['pid'] ?? null; if (!is_numeric($pid) || (int)$pid <= 0) { $pid = $this->pidOfRecord($table, $recordId); @@ -68,8 +79,16 @@ final class DataHandlerSsrPurge } 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; + 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; } if ($table === 'tt_content') { + if ($this->isMegamenuElement((int)$id)) { + self::$queue['*'] = true; + return; + } $pid = $this->pidOfRecord($table, (int)$id); if ($pid > 0) { $this->queuePagePath($pid); } 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; } } + /** + * 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 { $this->flush('editor-command'); @@ -115,7 +161,7 @@ final class DataHandlerSsrPurge if (self::$queue === []) { return; } - $paths = array_keys(self::$queue); + $paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue)); self::$queue = []; GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger); } diff --git a/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php b/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php new file mode 100644 index 0000000..0c75d79 --- /dev/null +++ b/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php @@ -0,0 +1,296 @@ + [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 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 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*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); + } +} diff --git a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml index ee0f04d..bcd9064 100644 --- a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml +++ b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml @@ -130,7 +130,7 @@ settings: vitec.ssr.autoPurge: 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 type: bool default: true