From e97748e377f3cf5255fb5e762a7c83b17dd1be54 Mon Sep 17 00:00:00 2001 From: Mikel Wohlschlegel Date: Fri, 8 May 2026 08:53:27 +0200 Subject: [PATCH 1/2] [BUGFIX] Flush runtime cache periodically in LinkAnalyzer The brofix:checklinks CLI command runs into PHP fatal "Allowed memory size exhausted" errors on large site trees because TYPO3's runtime cache (TransientMemoryBackend) accumulates entries that are never freed: - backendUtilityBeGetRootLine: rootlines for every visited page - backendUtilityPageForRootLine: full page records for rootline resolution - pageTsConfig-hash-to-object-*: parsed PageTSconfig objects - backendUtilityTscPidCached: real-PID cache entries These are populated transitively by BackendUtility::getRecord(), BEgetRootLine() and getPagesTSconfig() inside isRecordsOnPageShouldBeChecked() and the LinkParser pipeline. Flushing only between array_chunk() iterations of page IDs is not sufficient on installations where one chunk fits the whole site tree (e.g. ~18k pages with the 32k bind-parameter limit on MariaDB). Flush every 1000 processed records inside the inner loop so memory growth is bounded regardless of chunk size, plus a final flush per chunk for any remaining entries. The flush interval N=1000 was empirically tuned against an 18'320 pages / 90'110 tt_content install at a 450M memory_limit. Surprisingly, smaller N (100, 200, 400) all plateaued at ~461-468 MB RSS, while N=1000 plateaued at ~382 MB and N=2000 at ~439 MB. Smaller intervals appear to produce heap fragmentation through frequent allocate/free cycles of the runtime cache structures, while too large intervals let the cache grow back. N=1000 hit the sweet spot. Without the per-record flush at all, RSS grew unbounded past 1066 MB at a 1024M memory_limit. The runtime cache is request-scoped and rebuilt on demand, so flushing has no functional impact. CacheManager is injected as a nullable constructor argument with a GeneralUtility::makeInstance() fallback to keep the public API backwards compatible. --- Classes/LinkAnalyzer.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Classes/LinkAnalyzer.php b/Classes/LinkAnalyzer.php index f626c9399..8efacaa2b 100644 --- a/Classes/LinkAnalyzer.php +++ b/Classes/LinkAnalyzer.php @@ -17,6 +17,8 @@ use Sypets\Brofix\Repository\ContentRepository; use Sypets\Brofix\Repository\PagesRepository; use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; @@ -58,17 +60,22 @@ class LinkAnalyzer implements LoggerAwareInterface protected LinkParser $linkParser; + protected FrontendInterface $runtimeCache; + public function __construct( BrokenLinkRepository $brokenLinkRepository, ContentRepository $contentRepository, PagesRepository $pagesRepository, protected ConnectionPool $connectionPool, - protected Typo3Version $typo3Version + protected Typo3Version $typo3Version, + ?CacheManager $cacheManager = null ) { $this->getLanguageService()->includeLLFile('EXT:brofix/Resources/Private/Language/Module/locallang.xlf'); $this->brokenLinkRepository = $brokenLinkRepository; $this->contentRepository = $contentRepository; $this->pagesRepository = $pagesRepository; + $this->runtimeCache = ($cacheManager ?? GeneralUtility::makeInstance(CacheManager::class)) + ->getCache('runtime'); } /** @@ -684,6 +691,7 @@ public function generateBrokenLinkRecords(ServerRequestInterface $request, array ); $result = $queryBuilder->executeQuery(); + $processed = 0; while ($row = $result->fetchAssociative()) { $results = []; @@ -700,7 +708,19 @@ public function generateBrokenLinkRecords(ServerRequestInterface $request, array ); $this->checkLinks($results, $linkTypes); + + // Free TYPO3 runtime cache periodically inside the loop. BackendUtility::getRecord(), + // BEgetRootLine() and getPagesTSconfig() (called via isRecordsOnPageShouldBeChecked() + // and downstream) accumulate entries in the TransientMemoryBackend that are never + // freed, which causes OOM in CLI runs over large site trees. Flushing per-chunk is + // not enough on installations where one array_chunk fits the whole site tree. + if (++$processed % 1000 === 0) { + $this->runtimeCache->flush(); + } } + + // Final flush per chunk to release any remaining entries. + $this->runtimeCache->flush(); } } From b4378834c66d77672027e386ba7d235b38fa06dd Mon Sep 17 00:00:00 2001 From: Sybille Peters Date: Mon, 11 May 2026 11:14:29 +0200 Subject: [PATCH 2/2] Only flush runtime cache for table != 'pages' --- Classes/LinkAnalyzer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/LinkAnalyzer.php b/Classes/LinkAnalyzer.php index 8efacaa2b..baeab485d 100644 --- a/Classes/LinkAnalyzer.php +++ b/Classes/LinkAnalyzer.php @@ -714,7 +714,7 @@ public function generateBrokenLinkRecords(ServerRequestInterface $request, array // and downstream) accumulate entries in the TransientMemoryBackend that are never // freed, which causes OOM in CLI runs over large site trees. Flushing per-chunk is // not enough on installations where one array_chunk fits the whole site tree. - if (++$processed % 1000 === 0) { + if ($table !== 'pages' && ++$processed % 1000 === 0) { $this->runtimeCache->flush(); } }