From e40079afe0e25dbd0f941742c53f1ad0d8b4027d Mon Sep 17 00:00:00 2001 From: Timon Heuser Date: Tue, 8 Sep 2026 10:26:55 +0200 Subject: [PATCH 1/2] FEATURE: add content release metrics logging --- .../Metrics/ContentReleaseMetricsService.php | 107 +++++++++ Classes/Metrics/Dto/ContentReleaseMetrics.php | 153 +++++++++++++ Classes/Metrics/Dto/RenderingError.php | 58 +++++ Classes/Metrics/Dto/RenderingIteration.php | 43 ++++ Classes/Metrics/Dto/WorkerError.php | 80 +++++++ Classes/Metrics/NodeIdentifierExtractor.php | 28 +++ README.md | 32 +++ .../Metrics/Dto/ContentReleaseMetricsTest.php | 213 ++++++++++++++++++ 8 files changed, 714 insertions(+) create mode 100644 Classes/Metrics/ContentReleaseMetricsService.php create mode 100644 Classes/Metrics/Dto/ContentReleaseMetrics.php create mode 100644 Classes/Metrics/Dto/RenderingError.php create mode 100644 Classes/Metrics/Dto/RenderingIteration.php create mode 100644 Classes/Metrics/Dto/WorkerError.php create mode 100644 Classes/Metrics/NodeIdentifierExtractor.php create mode 100644 Tests/Unit/Metrics/Dto/ContentReleaseMetricsTest.php diff --git a/Classes/Metrics/ContentReleaseMetricsService.php b/Classes/Metrics/ContentReleaseMetricsService.php new file mode 100644 index 0000000..af5135d --- /dev/null +++ b/Classes/Metrics/ContentReleaseMetricsService.php @@ -0,0 +1,107 @@ +redisContentReleaseService->fetchMetadataForContentRelease( + $contentReleaseIdentifier, + $redisInstanceIdentifier, + ); + if ($metadata === null) { + return null; + } + + $renderingStatistics = array_map( + fn(string $entry) => RenderingStatistics::fromJsonString($entry), + $this->redisRenderingStatisticsStore->getRenderingStatistics( + $contentReleaseIdentifier, + $redisInstanceIdentifier, + ), + ); + + return ContentReleaseMetrics::create( + $contentReleaseIdentifier, + $metadata, + // the enumeration is only ever written on the primary instance, so this count ignores the instance + $this->redisEnumerationRepository->count($contentReleaseIdentifier), + $renderingStatistics, + $this->redisRenderingErrorManager->getRenderingErrors($contentReleaseIdentifier, $redisInstanceIdentifier), + $this->collectWorkerErrorLogs($metadata), + new DateTimeImmutable(), + ); + } + + /** + * The stacktrace of a failed render worker lives only in its prunner log, which is read over the prunner API. + * That API can be unreachable exactly when a release went wrong, and losing the whole measurement over it + * would defeat the purpose - so a failure is reported as "unknown" (NULL) rather than as an empty list. + * + * @return WorkerErrorLog[]|null + */ + private function collectWorkerErrorLogs(ContentReleaseMetadata $metadata): ?array + { + try { + $job = $this->prunnerApiService->loadJobDetail($metadata->getPrunnerJobId()->toJobId()); + if ($job === null) { + return null; + } + + return $this->workerErrorLogAggregator->aggregate($job); + } catch (Throwable $throwable) { + return null; + } + } +} diff --git a/Classes/Metrics/Dto/ContentReleaseMetrics.php b/Classes/Metrics/Dto/ContentReleaseMetrics.php new file mode 100644 index 0000000..aa8f905 --- /dev/null +++ b/Classes/Metrics/Dto/ContentReleaseMetrics.php @@ -0,0 +1,153 @@ + $statistics) { + $iteration = RenderingIteration::fromRenderingStatistics($index + 1, $statistics); + $iterations[] = $iteration; + $renderingCount += $iteration->completedRenderings; + } + + $startTime = $metadata->getStartTime(); + // The end time is written when the rendering finished, so the duration covers enumeration and rendering - + // not the transfer and switch tasks which run after them. + $endTime = $metadata->getEndTime(); + // A release which was aborted never gets an end time written, so its duration is measured up to the + // moment this report is taken - which is the moment the pipeline gave up on it. + $durationEnd = $endTime ?? $now; + + $rawRenderingErrors = array_values($rawRenderingErrors); + $renderingErrors = array_map( + fn(string $rawEntry) => RenderingError::fromRawEntry($rawEntry), + array_slice($rawRenderingErrors, 0, self::MAX_REPORTED_ERRORS), + ); + + $workerErrors = $workerErrorLogs === null + ? null + : array_map( + fn(WorkerErrorLog $workerErrorLog) => WorkerError::fromWorkerErrorLog($workerErrorLog), + array_slice(array_values($workerErrorLogs), 0, self::MAX_REPORTED_WORKER_ERRORS), + ); + + return new self( + $contentReleaseIdentifier->getIdentifier(), + $metadata->getPrunnerJobId()->getIdentifier(), + $metadata->getStatus()->getStatus(), + $metadata->getWorkspaceName(), + $metadata->getAccountId(), + $startTime?->format(DateTimeInterface::RFC3339_EXTENDED), + $endTime?->format(DateTimeInterface::RFC3339_EXTENDED), + $startTime !== null ? $durationEnd->getTimestamp() - $startTime->getTimestamp() : null, + $metadata->getContentReleaseSize(), + $documentCount, + // The first iteration copies everything which is still complete in the content cache and schedules + // the rest - so its scheduled count is the number of documents this release rendered anew. + $iterations !== [] ? $iterations[0]->scheduledRenderings : 0, + $renderingCount, + count($iterations), + $iterations, + count($rawRenderingErrors), + $renderingErrors, + $workerErrors, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'contentReleaseIdentifier' => $this->contentReleaseIdentifier, + 'prunnerJobId' => $this->prunnerJobId, + 'status' => $this->status, + 'workspaceName' => $this->workspaceName, + 'accountId' => $this->accountId, + 'startTime' => $this->startTime, + 'endTime' => $this->endTime, + 'durationSeconds' => $this->durationSeconds, + 'sizeMegabytes' => $this->sizeMegabytes, + 'documentCount' => $this->documentCount, + 'newlyRenderedDocumentCount' => $this->newlyRenderedDocumentCount, + 'renderingCount' => $this->renderingCount, + 'iterationCount' => $this->iterationCount, + 'iterations' => $this->iterations, + 'renderingErrorCount' => $this->renderingErrorCount, + 'renderingErrors' => $this->renderingErrors, + 'workerErrors' => $this->workerErrors, + ]; + } +} diff --git a/Classes/Metrics/Dto/RenderingError.php b/Classes/Metrics/Dto/RenderingError.php new file mode 100644 index 0000000..bbabd12 --- /dev/null +++ b/Classes/Metrics/Dto/RenderingError.php @@ -0,0 +1,58 @@ + - ". That string is what the Backend UI displays, so it + * stays as it is and is parsed back apart here. An entry which does not match keeps its full text as message. + */ + public static function fromRawEntry(string $rawEntry): self + { + if (preg_match('/^(.*) - (\[]|\{.*})$/s', $rawEntry, $matches) !== 1) { + return new self($rawEntry, null, null, null); + } + + $additionalData = json_decode($matches[2], true); + if (!is_array($additionalData)) { + return new self($rawEntry, null, null, null); + } + + $node = array_key_exists('node', $additionalData) ? (string) $additionalData['node'] : null; + $nodeUri = array_key_exists('nodeUri', $additionalData) ? (string) $additionalData['nodeUri'] : null; + + return new self($matches[1], $node, NodeIdentifierExtractor::fromText($node), $nodeUri); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'message' => $this->message, + 'node' => $this->node, + 'nodeIdentifier' => $this->nodeIdentifier, + 'nodeUri' => $this->nodeUri, + ]; + } +} diff --git a/Classes/Metrics/Dto/RenderingIteration.php b/Classes/Metrics/Dto/RenderingIteration.php new file mode 100644 index 0000000..a5241ae --- /dev/null +++ b/Classes/Metrics/Dto/RenderingIteration.php @@ -0,0 +1,43 @@ +getTotalJobs(), + $renderingStatistics->getRenderedJobs(), + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'iteration' => $this->iteration, + 'scheduledRenderings' => $this->scheduledRenderings, + 'completedRenderings' => $this->completedRenderings, + ]; + } +} diff --git a/Classes/Metrics/Dto/WorkerError.php b/Classes/Metrics/Dto/WorkerError.php new file mode 100644 index 0000000..22809ff --- /dev/null +++ b/Classes/Metrics/Dto/WorkerError.php @@ -0,0 +1,80 @@ + mb_strlen($block) > self::MAX_ERROR_BLOCK_LENGTH + ? mb_substr($block, 0, self::MAX_ERROR_BLOCK_LENGTH) . ' [truncated]' + : $block, + array_slice(array_values($workerErrorLog->errorBlocks), 0, self::MAX_ERROR_BLOCKS), + ); + + return new self( + $workerErrorLog->workerName, + $workerErrorLog->status, + $workerErrorLog->exitCode, + $workerErrorLog->wasKilledByOrchestrator, + $workerErrorLog->taskError, + $workerErrorLog->lastAttemptedNode, + NodeIdentifierExtractor::fromText($workerErrorLog->lastAttemptedNode), + $errorBlocks, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'workerName' => $this->workerName, + 'status' => $this->status, + 'exitCode' => $this->exitCode, + 'wasKilledByOrchestrator' => $this->wasKilledByOrchestrator, + 'taskError' => $this->taskError, + 'lastAttemptedNode' => $this->lastAttemptedNode, + 'lastAttemptedNodeIdentifier' => $this->lastAttemptedNodeIdentifier, + 'errorBlocks' => $this->errorBlocks, + ]; + } +} diff --git a/Classes/Metrics/NodeIdentifierExtractor.php b/Classes/Metrics/NodeIdentifierExtractor.php new file mode 100644 index 0000000..521738f --- /dev/null +++ b/Classes/Metrics/NodeIdentifierExtractor.php @@ -0,0 +1,28 @@ +createMetrics( + [ + RenderingStatistics::create(0, 120, []), + RenderingStatistics::create(0, 8, []), + RenderingStatistics::create(0, 0, []), + ], + [], + ); + + self::assertSame(3, $metrics->iterationCount); + self::assertSame( + [ + ['iteration' => 1, 'scheduledRenderings' => 120, 'completedRenderings' => 120], + ['iteration' => 2, 'scheduledRenderings' => 8, 'completedRenderings' => 8], + ['iteration' => 3, 'scheduledRenderings' => 0, 'completedRenderings' => 0], + ], + array_map(fn($iteration) => $iteration->jsonSerialize(), $metrics->iterations), + ); + self::assertSame(128, $metrics->renderingCount); + } + + public function testNewlyRenderedDocumentsAreTheOnesTheFirstIterationScheduled(): void + { + $metrics = $this->createMetrics( + [RenderingStatistics::create(0, 12, []), RenderingStatistics::create(0, 0, [])], + [], + ); + + self::assertSame(500, $metrics->documentCount); + self::assertSame(12, $metrics->newlyRenderedDocumentCount); + } + + public function testAReleaseWithoutAnyIterationReportsZeroRenderings(): void + { + $metrics = $this->createMetrics([], []); + + self::assertSame(0, $metrics->iterationCount); + self::assertSame(0, $metrics->newlyRenderedDocumentCount); + self::assertSame(0, $metrics->renderingCount); + } + + public function testDurationOfAFinishedReleaseIsMeasuredBetweenStartAndEndTime(): void + { + $metadata = ContentReleaseMetadata::create( + PrunnerJobId::fromString('job-1'), + new \DateTimeImmutable('2026-09-04T12:00:00+00:00'), + )->withEndTime(new \DateTimeImmutable('2026-09-04T12:03:20+00:00')); + + $metrics = ContentReleaseMetrics::create( + ContentReleaseIdentifier::fromString(self::RELEASE_ID), + $metadata, + 500, + [], + [], + [], + new \DateTimeImmutable('2026-09-04T13:00:00+00:00'), + ); + + self::assertSame(200, $metrics->durationSeconds); + self::assertStringStartsWith('2026-09-04T12:03:20', (string) $metrics->endTime); + } + + public function testDurationOfAnAbortedReleaseIsMeasuredUpToTheReportingTime(): void + { + // an aborted release never gets an end time written + $metrics = $this->createMetrics([], [], new \DateTimeImmutable('2026-09-04T12:01:00+00:00')); + + self::assertNull($metrics->endTime); + self::assertSame(60, $metrics->durationSeconds); + } + + public function testRenderingErrorIsSplitIntoMessageAndNode(): void + { + $rawEntry = 'Exception while rendering - ' + . json_encode([ + 'node' => 'Louis.Site:Document.Page ' . self::NODE_IDENTIFIER . ' (/sites/louis@live;language=de) - htmlViaFusion', + 'nodeUri' => 'https://de.louis.de/foo', + ]); + + $metrics = $this->createMetrics([], [$rawEntry]); + + self::assertSame(1, $metrics->renderingErrorCount); + self::assertSame('Exception while rendering', $metrics->renderingErrors[0]->message); + self::assertSame(self::NODE_IDENTIFIER, $metrics->renderingErrors[0]->nodeIdentifier); + self::assertSame('https://de.louis.de/foo', $metrics->renderingErrors[0]->nodeUri); + } + + public function testRenderingErrorWithoutAdditionalDataKeepsItsMessage(): void + { + $metrics = $this->createMetrics([], ['Invalid release due to low URL count - []']); + + self::assertSame('Invalid release due to low URL count', $metrics->renderingErrors[0]->message); + self::assertNull($metrics->renderingErrors[0]->nodeIdentifier); + } + + public function testAnUnparseableErrorEntryIsReportedAsIs(): void + { + $metrics = $this->createMetrics([], ['something nobody formatted']); + + self::assertSame('something nobody formatted', $metrics->renderingErrors[0]->message); + self::assertNull($metrics->renderingErrors[0]->node); + } + + public function testErrorListIsTruncatedButTheCountIsNot(): void + { + $rawEntries = array_map(fn(int $i) => 'Error ' . $i . ' - []', range(1, 120)); + + $metrics = $this->createMetrics([], $rawEntries); + + self::assertSame(120, $metrics->renderingErrorCount); + self::assertCount(50, $metrics->renderingErrors); + } + + public function testWorkerErrorCarriesTheNodeTheWorkerDiedOn(): void + { + $workerErrorLog = new WorkerErrorLog( + 'render_3', + 'error', + 255, + null, + ["PHP Fatal error: Allowed memory size exhausted\n#0 /app/Foo.php(1)"], + 'Louis.Site:Document.Page ' . self::NODE_IDENTIFIER . ' (/sites/louis@live) | https://de.louis.de/foo', + ); + + $metrics = $this->createMetrics([], [], null, [$workerErrorLog]); + + self::assertCount(1, $metrics->workerErrors); + self::assertSame('render_3', $metrics->workerErrors[0]->workerName); + self::assertFalse($metrics->workerErrors[0]->wasKilledByOrchestrator); + self::assertSame(self::NODE_IDENTIFIER, $metrics->workerErrors[0]->lastAttemptedNodeIdentifier); + self::assertStringContainsString('memory size exhausted', $metrics->workerErrors[0]->errorBlocks[0]); + } + + public function testWorkerKilledByTheOrchestratorIsMarkedAsSuch(): void + { + // exit code 143 = 128 + SIGTERM: the orchestrator stopped this worker after another one failed + $workerErrorLog = new WorkerErrorLog('render_7', 'error', 143, null, [], null); + + $metrics = $this->createMetrics([], [], null, [$workerErrorLog]); + + self::assertTrue($metrics->workerErrors[0]->wasKilledByOrchestrator); + self::assertNull($metrics->workerErrors[0]->lastAttemptedNodeIdentifier); + } + + public function testUnreadableWorkerLogsAreReportedAsUnknownRatherThanEmpty(): void + { + $metrics = $this->createMetrics([], [], null, null); + + self::assertNull($metrics->workerErrors); + } + + public function testWorkerErrorListIsCappedAtTenEntries(): void + { + $workerErrorLogs = array_map( + fn(int $i) => new WorkerErrorLog('render_' . $i, 'error', 143, null, [], null), + range(1, 24), + ); + + $metrics = $this->createMetrics([], [], null, $workerErrorLogs); + + self::assertCount(10, $metrics->workerErrors); + } + + /** + * @param RenderingStatistics[] $renderingStatistics + * @param string[] $rawRenderingErrors + * @param WorkerErrorLog[]|null $workerErrorLogs + */ + private function createMetrics( + array $renderingStatistics, + array $rawRenderingErrors, + ?\DateTimeInterface $now = null, + ?array $workerErrorLogs = [], + ): ContentReleaseMetrics { + return ContentReleaseMetrics::create( + ContentReleaseIdentifier::fromString(self::RELEASE_ID), + ContentReleaseMetadata::create( + PrunnerJobId::fromString('job-1'), + new \DateTimeImmutable('2026-09-04T12:00:00+00:00'), + ), + 500, + $renderingStatistics, + $rawRenderingErrors, + $workerErrorLogs, + $now ?? new \DateTimeImmutable('2026-09-04T12:00:00+00:00'), + ); + } +} From c7c42026a84fed58671191293c6c988cec1e2440 Mon Sep 17 00:00:00 2001 From: Timon Heuser Date: Thu, 1 Oct 2026 13:41:36 +0200 Subject: [PATCH 2/2] FEATURE: keep logs even after content release is deleted --- Classes/BackendUi/BackendUiDataService.php | 78 +++++++++++ .../BackendUi/Dto/ContentReleaseDetails.php | 12 ++ .../Dto/RemovedContentReleaseOverviewRow.php | 61 ++++++++ Classes/Controller/BackendController.php | 18 ++- .../AutomaticReleasePauseState.php | 4 +- .../Infrastructure/RedisClientManager.php | 9 ++ Classes/Core/RedisPruneService.php | 6 +- .../RedisContentReleaseService.php | 12 ++ Classes/Transfer/ContentReleaseCleaner.php | 8 ++ .../Dto/RedisKeyPostfixForEachRelease.php | 11 ++ Configuration/Settings.yaml | 16 +++ README.md | 15 ++ .../Integration/Backend.Details.fusion | 40 ++++-- .../Integration/Backend.Index.fusion | 63 +++++++++ .../BackendUi/BackendUiDataServiceTest.php | 131 ++++++++++++++++++ .../Metrics/Dto/ContentReleaseMetricsTest.php | 3 + .../Transfer/ContentReleaseCleanerTest.php | 109 +++++++++++++++ .../RedisKeyPostfixesForEachReleaseTest.php | 19 ++- phpstan-baseline.neon | 2 +- 19 files changed, 597 insertions(+), 20 deletions(-) create mode 100644 Classes/BackendUi/Dto/RemovedContentReleaseOverviewRow.php create mode 100644 Tests/Unit/BackendUi/BackendUiDataServiceTest.php create mode 100644 Tests/Unit/Transfer/ContentReleaseCleanerTest.php diff --git a/Classes/BackendUi/BackendUiDataService.php b/Classes/BackendUi/BackendUiDataService.php index a8d6e5a..a908836 100644 --- a/Classes/BackendUi/BackendUiDataService.php +++ b/Classes/BackendUi/BackendUiDataService.php @@ -6,6 +6,8 @@ use Flowpack\DecoupledContentStore\BackendUi\Dto\ContentReleaseDetails; use Flowpack\DecoupledContentStore\BackendUi\Dto\ContentReleaseOverviewRow; +use Flowpack\DecoupledContentStore\BackendUi\Dto\RemovedContentReleaseOverviewRow; +use Flowpack\DecoupledContentStore\ContentReleaseManager; use Flowpack\DecoupledContentStore\Core\Domain\ValueObject\ContentReleaseIdentifier; use Flowpack\DecoupledContentStore\Core\Domain\ValueObject\PrunnerJobId; use Flowpack\DecoupledContentStore\Core\Domain\ValueObject\RedisInstanceIdentifier; @@ -16,6 +18,7 @@ use Flowpack\DecoupledContentStore\PrepareContentRelease\Dto\ContentReleaseMetadata; use Flowpack\DecoupledContentStore\PrepareContentRelease\Infrastructure\RedisContentReleaseService; use Flowpack\DecoupledContentStore\ReleaseSwitch\Infrastructure\RedisReleaseSwitchService; +use Flowpack\Prunner\Dto\Job; use Flowpack\Prunner\PrunnerApiService; use Neos\Flow\Annotations as Flow; @@ -121,6 +124,80 @@ public function loadBackendOverviewData(RedisInstanceIdentifier $redisInstanceId return $result; } + /** + * The releases which Redis no longer holds but whose prunner job - and so its logs - is still there, newest first. + * + * Only the primary content store builds releases, so on any other one a job without a registered release is just + * a release which was never transferred there. Jobs which never started are left out: with queue_strategy + * "replace", most of them were replaced while waiting and have no logs at all. + * + * @return RemovedContentReleaseOverviewRow[]|null NULL when the prunner API could not be reached + */ + public function loadRemovedReleasesOverviewData(RedisInstanceIdentifier $redisInstanceIdentifier): ?array + { + if (!$redisInstanceIdentifier->isPrimary()) { + return []; + } + + try { + $jobs = $this->prunnerApiService->loadPipelinesAndJobs()->getJobs(); + } catch (\Throwable $throwable) { + // the overview also holds the controls to pause releases, which must keep working while prunner is down + return null; + } + + $registeredReleaseIds = array_map( + static fn(ContentReleaseIdentifier $id): string => $id->getIdentifier(), + $this->redisContentReleaseService->fetchAllReleaseIds($redisInstanceIdentifier), + ); + $releasePipelines = [ + ContentReleaseManager::CONTENT_RELEASE_PIPELINE_NAME, + ContentReleaseManager::QUICK_CONTENT_RELEASE_PIPELINE_NAME, + ]; + $removedReleaseJobs = $jobs->filter( + static function (Job $job) use ($releasePipelines, $registeredReleaseIds): bool { + $contentReleaseId = $job->getVariables()['contentReleaseId'] ?? null; + return ( + in_array($job->getPipeline(), $releasePipelines, true) + && $job->getStart() !== null + && is_string($contentReleaseId) + && !in_array($contentReleaseId, $registeredReleaseIds, true) + ); + }, + )->getArray(); + usort($removedReleaseJobs, static fn(Job $a, Job $b): int => $b->getStart() <=> $a->getStart()); + + $contentReleaseIds = array_map( + static fn(Job $job): ContentReleaseIdentifier => ContentReleaseIdentifier::fromString( + $job->getVariables()['contentReleaseId'], + ), + $removedReleaseJobs, + ); + $metadata = $this->redisContentReleaseService->fetchMetadataForContentReleases( + $redisInstanceIdentifier, + ...$contentReleaseIds, + ); + $errorCounts = $this->redisRenderingErrorManager->countMultipleErrors( + $redisInstanceIdentifier, + ...$contentReleaseIds, + ); + + $result = []; + foreach ($removedReleaseJobs as $index => $job) { + $contentReleaseId = $contentReleaseIds[$index]; + $metadataForContentRelease = $metadata->getResultForContentRelease($contentReleaseId); + $errorCountForContentRelease = $errorCounts->getResultForContentRelease($contentReleaseId); + $result[] = new RemovedContentReleaseOverviewRow( + $contentReleaseId, + $job, + $metadataForContentRelease instanceof ContentReleaseMetadata ? $metadataForContentRelease : null, + is_int($errorCountForContentRelease) ? $errorCountForContentRelease : 0, + ); + } + + return $result; + } + public function loadDetailsData( ContentReleaseIdentifier $contentReleaseIdentifier, RedisInstanceIdentifier $redisInstanceIdentifier, @@ -170,6 +247,7 @@ function (string $item) { $contentReleaseIdentifier->equals($currentReleaseIdentifier), $manualTransferJobs, $contentReleaseMetadata->getContentReleaseSize(), + !$this->redisContentReleaseService->isRegistered($contentReleaseIdentifier, $redisInstanceIdentifier), ); } } diff --git a/Classes/BackendUi/Dto/ContentReleaseDetails.php b/Classes/BackendUi/Dto/ContentReleaseDetails.php index 52a9d21..fb02f50 100644 --- a/Classes/BackendUi/Dto/ContentReleaseDetails.php +++ b/Classes/BackendUi/Dto/ContentReleaseDetails.php @@ -20,6 +20,7 @@ class ContentReleaseDetails private int $renderingErrorCount; private bool $isActive; private ?float $contentReleaseSize; + private bool $isRemoved; /** * @var Job[] @@ -40,6 +41,7 @@ public function __construct( bool $isActive, array $manualTransferJobIds, ?float $contentReleaseSize = null, + bool $isRemoved = false, ) { $this->contentReleaseIdentifier = $contentReleaseIdentifier; $this->job = $job; @@ -49,6 +51,7 @@ public function __construct( $this->isActive = $isActive; $this->manualTransferJobs = $manualTransferJobIds; $this->contentReleaseSize = $contentReleaseSize; + $this->isRemoved = $isRemoved; } /** @@ -114,4 +117,13 @@ public function getManualTransferJobs(): array { return $this->manualTransferJobs; } + + /** + * A removed release is no longer registered, so it can neither be switched to nor transferred - only the keys it + * keeps after removal are left, and its enumeration is gone. + */ + public function isRemoved(): bool + { + return $this->isRemoved; + } } diff --git a/Classes/BackendUi/Dto/RemovedContentReleaseOverviewRow.php b/Classes/BackendUi/Dto/RemovedContentReleaseOverviewRow.php new file mode 100644 index 0000000..3941b36 --- /dev/null +++ b/Classes/BackendUi/Dto/RemovedContentReleaseOverviewRow.php @@ -0,0 +1,61 @@ +contentReleaseIdentifier; + } + + public function getJob(): Job + { + return $this->job; + } + + public function getMetadata(): ?ContentReleaseMetadata + { + return $this->metadata; + } + + public function getErrorCount(): int + { + return $this->errorCount; + } + + public function getStatus(): string + { + if ($this->metadata !== null) { + return $this->metadata->getStatus()->getDisplayName(); + } + if ($this->job->isCanceled()) { + return 'canceled'; + } + if ($this->job->isErrored()) { + return 'failed'; + } + return $this->job->isCompleted() ? 'done' : 'running'; + } +} diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index bba9d6c..00d990e 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -136,6 +136,10 @@ public function indexAction(?string $contentStore = null) $this->view->assign('contentStore', $contentStore->getIdentifier()); $this->view->assign('overviewData', $this->backendUiDataService->loadBackendOverviewData($contentStore)); + $this->view->assign( + 'removedReleases', + $this->backendUiDataService->loadRemovedReleasesOverviewData($contentStore), + ); $this->view->assign('redisContentStores', array_keys($this->redisContentStores)); $this->view->assign('storeSize', $storeSize); $this->view->assign('toggleFromConfigEpoch', $configEpochRedis); @@ -174,14 +178,24 @@ public function detailsAction( $this->view->assign('redisContentStores', array_keys($this->redisContentStores)); $this->view->assign('isPrimary', $contentStore->isPrimary()); + // once the keys a removed release keeps have expired, its prunner job is all that is left of it + $removedReleaseJob = null; + if (is_string($prunnerJobId) && $prunnerJobId !== '' && $detailsData === null) { + $removedReleaseJob = $this->prunnerApiService->loadJobDetail( + PrunnerJobId::fromString($prunnerJobId)->toJobId(), + ); + } + $this->view->assign('removedReleaseJob', $removedReleaseJob); + $job = $detailsData?->getJob() ?? $removedReleaseJob; + if ($detailTaskName !== '') { $this->view->assign('detailTaskName', $detailTaskName); $this->view->assign('jobLogs', $this->prunnerApiService->loadJobLogs( $prunnerJobId ? PrunnerJobId::fromString($prunnerJobId)->toJobId() : $detailsData->getJob()->getId(), $detailTaskName, )); - } elseif ($showAllRenderingErrors && $detailsData->getJob() !== null) { - $this->view->assign('workerErrorLogs', $this->workerErrorLogAggregator->aggregate($detailsData->getJob())); + } elseif ($showAllRenderingErrors && $job !== null) { + $this->view->assign('workerErrorLogs', $this->workerErrorLogAggregator->aggregate($job)); } } diff --git a/Classes/Core/Domain/ValueObject/AutomaticReleasePauseState.php b/Classes/Core/Domain/ValueObject/AutomaticReleasePauseState.php index fca6e2c..e2c4216 100644 --- a/Classes/Core/Domain/ValueObject/AutomaticReleasePauseState.php +++ b/Classes/Core/Domain/ValueObject/AutomaticReleasePauseState.php @@ -4,8 +4,8 @@ namespace Flowpack\DecoupledContentStore\Core\Domain\ValueObject; -use DateMalformedStringException; use DateTimeImmutable; +use Exception; use InvalidArgumentException; use Neos\Flow\Annotations as Flow; @@ -34,7 +34,7 @@ private function __construct(DateTimeImmutable $pausedAt, ?string $accountId, in * hide the inconsistency behind a banner claiming the pause started just now. * * @param array $redisHash - * @throws DateMalformedStringException + * @throws Exception */ public static function fromRedisHash(array $redisHash): self { diff --git a/Classes/Core/Infrastructure/RedisClientManager.php b/Classes/Core/Infrastructure/RedisClientManager.php index b13327f..bca642f 100644 --- a/Classes/Core/Infrastructure/RedisClientManager.php +++ b/Classes/Core/Infrastructure/RedisClientManager.php @@ -104,4 +104,13 @@ public function getRetentionCount(RedisInstanceIdentifier $redisInstanceIdentifi } return $this->configuration[$redisInstanceIdentifier->getIdentifier()]['contentReleaseRetentionCount']; } + + /** + * 0 when the content store keeps nothing of a removed release. + */ + public function getRemovedReleaseRetentionSeconds(RedisInstanceIdentifier $redisInstanceIdentifier): int + { + $instanceConfiguration = $this->configuration[$redisInstanceIdentifier->getIdentifier()]; + return (int) ($instanceConfiguration['removedReleaseRetentionSeconds'] ?? 0); + } } diff --git a/Classes/Core/RedisPruneService.php b/Classes/Core/RedisPruneService.php index 6d47347..0108606 100644 --- a/Classes/Core/RedisPruneService.php +++ b/Classes/Core/RedisPruneService.php @@ -15,7 +15,8 @@ class RedisPruneService { // go through all keys in the selected content store // check whether they are reserved keys or currently active or contain one of the currently registered releases ids - // if not: delete + // if not: delete - unless the key expires on its own, which is how a removed release keeps its details for the + // Backend UI (see removedReleaseRetentionSeconds) const PRUNE_LUA_SCRIPT = ' local contentStoreCurrent = redis.call("GET", "contentStore:current") local contentStoreAllKeys = redis.call("KEYS", "*") @@ -36,7 +37,8 @@ class RedisPruneService and contentStoreKey ~= "contentStore:registeredReleases" and contentStoreKey ~= "contentStore:configEpoch" and string.sub(contentStoreKey, 1, string.len(currentContentStoreStart)) ~= currentContentStoreStart - and not table_contains_value(contentStoreRegisteredReleases, contentStoreKey) then + and not table_contains_value(contentStoreRegisteredReleases, contentStoreKey) + and redis.call("TTL", contentStoreKey) < 0 then redis.call("DEL", contentStoreKey) end end diff --git a/Classes/PrepareContentRelease/Infrastructure/RedisContentReleaseService.php b/Classes/PrepareContentRelease/Infrastructure/RedisContentReleaseService.php index 1d31147..7106c6a 100644 --- a/Classes/PrepareContentRelease/Infrastructure/RedisContentReleaseService.php +++ b/Classes/PrepareContentRelease/Infrastructure/RedisContentReleaseService.php @@ -133,6 +133,18 @@ public function fetchAllReleaseIds(RedisInstanceIdentifier $redisInstanceIdentif return $result; } + public function isRegistered( + ContentReleaseIdentifier $contentReleaseIdentifier, + RedisInstanceIdentifier $redisInstanceIdentifier, + ): bool { + return ( + $this->redisClientManager->getRedis($redisInstanceIdentifier)->zScore( + 'contentStore:registeredReleases', + $contentReleaseIdentifier->getIdentifier(), + ) !== false + ); + } + public function fetchMetadataForContentRelease( ContentReleaseIdentifier $contentReleaseIdentifier, ?RedisInstanceIdentifier $redisInstanceIdentifier = null, diff --git a/Classes/Transfer/ContentReleaseCleaner.php b/Classes/Transfer/ContentReleaseCleaner.php index f7855c6..42365ac 100644 --- a/Classes/Transfer/ContentReleaseCleaner.php +++ b/Classes/Transfer/ContentReleaseCleaner.php @@ -155,12 +155,20 @@ public function removeRelease( } $redisKeyPostfixesForEachRelease = RedisKeyPostfixesForEachRelease::fromArray($this->redisKeyPostfixesForEachReleaseConfiguration); + $retentionSeconds = $this->redisClientManager->getRemovedReleaseRetentionSeconds($redisIdentifier); foreach ($redisKeyPostfixesForEachRelease->getRedisKeyPostfixes() as $redisKeyPostfix) { $redisKey = $this->redisKeyService->getRedisKeyForPostfix( $contentReleaseIdentifierToRemove, $redisKeyPostfix->getRedisKeyPostfix(), ); + // the release leaves registeredReleases below, so nothing can switch to it or build on it anymore - + // what is left only feeds the Backend UI, and Redis drops it once the retention is over + if ($retentionSeconds > 0 && $redisKeyPostfix->shouldKeepAfterRemoval()) { + $contentReleaseLogger->debug(' - Keeping ' . $redisKey . ' for ' . $retentionSeconds . 's'); + $redis->expire($redisKey, $retentionSeconds); + continue; + } $contentReleaseLogger->debug(' - Removing ' . $redisKey); $redis->del($redisKey); } diff --git a/Classes/Transfer/Dto/RedisKeyPostfixForEachRelease.php b/Classes/Transfer/Dto/RedisKeyPostfixForEachRelease.php index 5b652f2..1766ecf 100644 --- a/Classes/Transfer/Dto/RedisKeyPostfixForEachRelease.php +++ b/Classes/Transfer/Dto/RedisKeyPostfixForEachRelease.php @@ -21,6 +21,7 @@ final class RedisKeyPostfixForEachRelease protected string $transferMode; protected bool $isRequired; protected bool $copyOnQuickRelease; + protected bool $keepAfterRemoval; /** * @param string $redisKeyPostfix @@ -28,6 +29,7 @@ final class RedisKeyPostfixForEachRelease * @param string $transferMode * @param bool $isRequired * @param bool $copyOnQuickRelease + * @param bool $keepAfterRemoval */ private function __construct( string $redisKeyPostfix, @@ -35,6 +37,7 @@ private function __construct( string $transferMode, bool $isRequired, bool $copyOnQuickRelease, + bool $keepAfterRemoval, ) { if (!in_array($transferMode, [self::TRANSFER_MODE_HASH_INCREMENTAL, self::TRANSFER_MODE_DUMP])) { throw new \RuntimeException('TransferMode ' . $transferMode . ' not supported.'); @@ -52,6 +55,7 @@ private function __construct( $this->transferMode = $transferMode; $this->isRequired = $isRequired; $this->copyOnQuickRelease = $copyOnQuickRelease; + $this->keepAfterRemoval = $keepAfterRemoval; } public static function fromArray(array $in): self @@ -65,6 +69,8 @@ public static function fromArray(array $in): self // default: a key which should have been copied shows up as missing content, a key which should not have // been copied describes a different release $in['copyOnQuickRelease'] ?? false, + // a site package key can be of any size, so it only outlives its release when it opts in + $in['keepAfterRemoval'] ?? false, ); } @@ -92,6 +98,11 @@ public function shouldCopyOnQuickRelease(): bool return $this->copyOnQuickRelease; } + public function shouldKeepAfterRemoval(): bool + { + return $this->keepAfterRemoval; + } + public function getRedisKeyPostfix(): string { return $this->redisKeyPostfix; diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 2efb6d2..a3dd0d5 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -20,6 +20,16 @@ Flowpack: # pipelines.do_content_release.retention_count and pipelines.manually_transfer_content_release.retention_count). contentReleaseRetentionCount: 20 + # How long, in seconds, the keys flagged with `keepAfterRemoval` (see redisKeyPostfixesForEachRelease) outlive + # a removed release, so that the Backend UI can still show its status, rendering errors and statistics next to + # the prunner logs. Redis expires them on its own. 0 deletes them together with the rest of the release. + # + # Match this to the prunner `retention_period` of your release pipelines: the details page of a removed + # release is listed through its prunner job, so keys which outlive the job no longer show up in the overview. + # It is not set for additional content stores, which then delete everything - there is no rendering scratch + # space to look into on those, and they must not run out of memory. + removedReleaseRetentionSeconds: 432000 # 5 days + # you can add additional, arbitrarily named, content stores here - they appear in the UI. # If you do this, see the hints in the README "Copy Content Releases to a different Redis instance". @@ -120,6 +130,9 @@ Flowpack: # leave it off for keys which describe the build of a single release - a quick release enumerates and renders # only the documents which changed, so its own enumeration, queue and statistics have to stay its own. # It defaults to `false`, so keys registered by a site package are never copied unless they opt in. + # + # keepAfterRemoval keeps the key for `removedReleaseRetentionSeconds` once its release is removed, instead of + # deleting it right away. Set it only for small keys which describe the release - it defaults to `false`. data: redisKeyPostfix: 'data' transfer: true @@ -145,6 +158,7 @@ Flowpack: isRequired: true # written when the release is created, and it describes this release - never the one it was copied from copyOnQuickRelease: false + keepAfterRemoval: true metaUrls: redisKeyPostfix: 'meta:urls' transfer: true @@ -171,6 +185,7 @@ Flowpack: transferMode: 'dump' isRequired: false copyOnQuickRelease: false + keepAfterRemoval: true renderingJobQueue: redisKeyPostfix: 'renderingJobQueue' transfer: false @@ -195,6 +210,7 @@ Flowpack: transferMode: 'dump' isRequired: true copyOnQuickRelease: false + keepAfterRemoval: true statisticsEvents: redisKeyPostfix: 'statisticsEvents' transfer: false diff --git a/README.md b/README.md index 977ac38..378f231 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,21 @@ Where those numbers go is up to the site package: write a command which calls th whatever collects logs in your setup, then run it as a task at the end of your `pipelines.yml` and from its `on_error` hook, so an aborted release is measured too. +### Removed releases in the Backend UI + +Prunner keeps the logs of a release independently of Redis - its pipeline's `retention_count` and +`retention_period` decide how long. The Backend UI therefore lists, below the releases Redis still holds, every +started job of `do_content_release` and `do_quick_content_release` whose release has been removed, and links to its +logs. This only happens for the primary content store, the one releases are built in. + +To keep the status, author, rendering errors and rendering statistics next to those logs, the keys flagged with +`keepAfterRemoval` in `redisKeyPostfixesForEachRelease` (by default `meta:info`, `renderingErrors` and +`renderingStatistics`) are not deleted with their release. Redis expires them after `removedReleaseRetentionSeconds` +of the content store instead (5 days on the primary one, unset - so deleted right away - everywhere else). The release +itself leaves `contentStore:registeredReleases` as before, so it can neither be switched to nor built upon, and +*Prune content store* leaves keys alone which expire on their own. Set `removedReleaseRetentionSeconds` to about the +prunner `retention_period` of your release pipelines; once both have run out, the release is gone from the UI. + ## Quick Content Releases Rendering dominates the runtime of a content release: on a big site, a release which changes a single page still diff --git a/Resources/Private/BackendFusion/Integration/Backend.Details.fusion b/Resources/Private/BackendFusion/Integration/Backend.Details.fusion index bbf8494..a9c47ba 100644 --- a/Resources/Private/BackendFusion/Integration/Backend.Details.fusion +++ b/Resources/Private/BackendFusion/Integration/Backend.Details.fusion @@ -7,6 +7,7 @@ Flowpack.DecoupledContentStore.BackendController.details = Neos.Fusion:Component // - contentStore: string, currently active content store / redis instance // - redisContentStores: array of all configured content store identifiers // - isPrimary: bool + // - removedReleaseJob: Job, only for a removed release whose kept keys have already expired stdOutLines = ${Flowpack.DecoupledContentStore.formatStdOutput(jobLogs.stdout)} @@ -23,13 +24,25 @@ Flowpack.DecoupledContentStore.BackendController.details = Neos.Fusion:Component

Content Release {contentReleaseIdentifier.identifier}

- + {" "} {contentStore} + removed +

+ This release has been removed, and the details it kept in Redis have expired as well. Only its prunner job and logs are left. +

+
{props.stdOutLines}
-

+

No data exists for this release in Redis.

Start: {props.prunnerStart}, Transfer to: {props.manualSwitchTarget} @@ -238,13 +252,13 @@ prototype(Flowpack.DecoupledContentStore:ContentReleaseSteps) < prototype(Neos.F -
+
- {detailsData.enumeratedDocumentNodesCount} + {detailsData.removed ? '-' : detailsData.enumeratedDocumentNodesCount} @@ -489,18 +503,20 @@ prototype(Flowpack.DecoupledContentStore:DetailsFooter) < prototype(Neos.Fusion: Back - + Reload - - + + + - {isPrimary ? props._renderedSwitchButtons : ''} + {isPrimary ? props._renderedSwitchButtons : ''} + ` diff --git a/Resources/Private/BackendFusion/Integration/Backend.Index.fusion b/Resources/Private/BackendFusion/Integration/Backend.Index.fusion index dc20321..e48db6e 100644 --- a/Resources/Private/BackendFusion/Integration/Backend.Index.fusion +++ b/Resources/Private/BackendFusion/Integration/Backend.Index.fusion @@ -2,6 +2,7 @@ Flowpack.DecoupledContentStore.BackendController.index = Neos.Fusion:Component { // Context Variables: // - overviewData: list of ContentReleaseOverviewRow objects + // - removedReleases: list of RemovedContentReleaseOverviewRow objects, or NULL if prunner could not be reached // - contentStore: contains string content store identifier // - redisContentStores: array of all configured content store identifiers // - storeSize: string of content store size @@ -89,6 +90,8 @@ Flowpack.DecoupledContentStore.BackendController.index = Neos.Fusion:Component { {storeSize} + + @@ -101,6 +104,66 @@ Flowpack.DecoupledContentStore.BackendController.index = Neos.Fusion:Component { } } +// Releases which Redis has dropped, but whose prunner logs are still there. The keys a removed release keeps carry the +// status, author and errors for a while; once they have expired, the prunner job is all that is shown. +prototype(Flowpack.DecoupledContentStore:RemovedReleases) < prototype(Neos.Fusion:Component) { + removedReleases = ${removedReleases} + + _renderedTableBody = Neos.Fusion:Loop { + items = ${removedReleases} + itemRenderer = Neos.Fusion:Component { + // item instanceof RemovedContentReleaseOverviewRow + start = ${Date.format(item.job.start, 'd.m.Y H:i:s')} + end = ${item.job.end ? Date.format(item.job.end, 'd.m.Y H:i:s') : ''} + switch = ${item.metadata.switchTime ? Date.format(item.metadata.switchTime, 'd.m.Y H:i:s') : ''} + + renderer = afx` + + + + {item.contentReleaseIdentifier.identifier} + + {" "} + {item.status} + {item.errorCount} + + {item.job.pipeline} + {item.job.variables.accountId} + {props.start} + {props.end} + {props.switch} + {item.metadata.contentReleaseSize ? item.metadata.contentReleaseSize + " MB" : "-"} + + ` + } + } + + renderer = afx` +

+ Removed releases cannot be listed: the prunner API could not be reached. +

+ 0}> + Removed Releases (logs only) + + + + + + + + + + + + + + {props._renderedTableBody} + +
IdentifierPipelineAuthorStartStopSwitchRelease Size
+
+ ` +} + // Rendered whenever automatic releases are paused, for everybody who can see the module - the pause stops all editor // publishes from going live, so it must not be visible only to those allowed to lift it. prototype(Flowpack.DecoupledContentStore:AutomaticReleasesPausedBanner) < prototype(Neos.Fusion:Component) { diff --git a/Tests/Unit/BackendUi/BackendUiDataServiceTest.php b/Tests/Unit/BackendUi/BackendUiDataServiceTest.php new file mode 100644 index 0000000..8dcc731 --- /dev/null +++ b/Tests/Unit/BackendUi/BackendUiDataServiceTest.php @@ -0,0 +1,131 @@ +buildService([ + self::job('registered', 'do_content_release', '2026-10-01T10:00:00+00:00', '100'), + self::job('removed-full', 'do_content_release', '2026-10-01T08:00:00+00:00', '80'), + self::job('removed-quick', 'do_quick_content_release', '2026-10-01T09:00:00+00:00', '90'), + // replaced while waiting, so it never ran and wrote no log + self::job('replaced', 'do_content_release', null, '70'), + // refers to a release which exists, but describes a transfer rather than a build + self::job('transfer', 'manually_transfer_content_release', '2026-10-01T11:00:00+00:00', '80'), + ])->loadRemovedReleasesOverviewData(RedisInstanceIdentifier::primary()); + self::assertNotNull($rows); + + $jobIds = array_map( + static fn(RemovedContentReleaseOverviewRow $row): string => $row->getJob()->getId()->getId(), + $rows, + ); + self::assertSame(['removed-quick', 'removed-full'], $jobIds); + } + + public function testTheKeptMetadataIsShownWhileItHasNotExpired(): void + { + $metadata = ContentReleaseMetadata::create(PrunnerJobId::fromString('removed'), new \DateTimeImmutable()); + + $rows = $this->buildService( + [self::job('removed', 'do_content_release', '2026-10-01T08:00:00+00:00', '80')], + ['80' => $metadata], + )->loadRemovedReleasesOverviewData(RedisInstanceIdentifier::primary()); + + self::assertNotNull($rows); + self::assertSame($metadata, $rows[0]->getMetadata()); + } + + public function testNothingIsListedForAContentStoreWhichDoesNotBuildReleases(): void + { + // a release which was never transferred to this content store has no registered release there either + $rows = $this->buildService([self::job('removed', 'do_content_release', '2026-10-01T08:00:00+00:00', '80')]) + ->loadRemovedReleasesOverviewData(RedisInstanceIdentifier::fromString('target')); + + self::assertSame([], $rows); + } + + public function testAnUnreachablePrunnerIsReportedInsteadOfBreakingTheOverview(): void + { + $prunnerApiService = $this->createMock(PrunnerApiService::class); + $prunnerApiService->method('loadPipelinesAndJobs')->willThrowException(new \RuntimeException('down')); + + $service = $this->buildService([]); + $this->inject($service, 'prunnerApiService', $prunnerApiService); + + self::assertNull($service->loadRemovedReleasesOverviewData(RedisInstanceIdentifier::primary())); + } + + /** + * @param array> $jobs + * @param array $metadata + */ + private function buildService(array $jobs, array $metadata = []): BackendUiDataService + { + $prunnerApiService = $this->createMock(PrunnerApiService::class); + $prunnerApiService->method('loadPipelinesAndJobs') + ->willReturn( + PipelinesAndJobsResponse::fromJsonArray(['pipelines' => [], 'jobs' => $jobs]), + ); + + $redisContentReleaseService = $this->createMock(RedisContentReleaseService::class); + $redisContentReleaseService->method('fetchAllReleaseIds') + ->willReturn([ + ContentReleaseIdentifier::fromString('100'), + ]); + $redisContentReleaseService->method('fetchMetadataForContentReleases') + ->willReturn( + ContentReleaseBatchResult::createFromArray($metadata), + ); + + $redisRenderingErrorManager = $this->createMock(RedisRenderingErrorManager::class); + $redisRenderingErrorManager->method('countMultipleErrors') + ->willReturn( + ContentReleaseBatchResult::createFromArray([]), + ); + + $service = new BackendUiDataService(); + $this->inject($service, 'prunnerApiService', $prunnerApiService); + $this->inject($service, 'redisContentReleaseService', $redisContentReleaseService); + $this->inject($service, 'redisRenderingErrorManager', $redisRenderingErrorManager); + return $service; + } + + /** + * @return array + */ + private static function job(string $id, string $pipeline, ?string $start, string $contentReleaseId): array + { + return [ + 'id' => $id, + 'pipeline' => $pipeline, + 'tasks' => [], + 'completed' => $start !== null, + 'canceled' => $start === null, + 'errored' => false, + 'created' => '2026-10-01T07:00:00+00:00', + 'start' => $start, + 'variables' => ['contentReleaseId' => $contentReleaseId], + 'user' => 'cli', + ]; + } +} diff --git a/Tests/Unit/Metrics/Dto/ContentReleaseMetricsTest.php b/Tests/Unit/Metrics/Dto/ContentReleaseMetricsTest.php index 5de5763..0a5ee8e 100644 --- a/Tests/Unit/Metrics/Dto/ContentReleaseMetricsTest.php +++ b/Tests/Unit/Metrics/Dto/ContentReleaseMetricsTest.php @@ -149,6 +149,7 @@ public function testWorkerErrorCarriesTheNodeTheWorkerDiedOn(): void $metrics = $this->createMetrics([], [], null, [$workerErrorLog]); + self::assertNotNull($metrics->workerErrors); self::assertCount(1, $metrics->workerErrors); self::assertSame('render_3', $metrics->workerErrors[0]->workerName); self::assertFalse($metrics->workerErrors[0]->wasKilledByOrchestrator); @@ -163,6 +164,7 @@ public function testWorkerKilledByTheOrchestratorIsMarkedAsSuch(): void $metrics = $this->createMetrics([], [], null, [$workerErrorLog]); + self::assertNotNull($metrics->workerErrors); self::assertTrue($metrics->workerErrors[0]->wasKilledByOrchestrator); self::assertNull($metrics->workerErrors[0]->lastAttemptedNodeIdentifier); } @@ -183,6 +185,7 @@ public function testWorkerErrorListIsCappedAtTenEntries(): void $metrics = $this->createMetrics([], [], null, $workerErrorLogs); + self::assertNotNull($metrics->workerErrors); self::assertCount(10, $metrics->workerErrors); } diff --git a/Tests/Unit/Transfer/ContentReleaseCleanerTest.php b/Tests/Unit/Transfer/ContentReleaseCleanerTest.php new file mode 100644 index 0000000..24f2c6f --- /dev/null +++ b/Tests/Unit/Transfer/ContentReleaseCleanerTest.php @@ -0,0 +1,109 @@ + + */ + private array $expiringKeys = []; + + public function testTheFlaggedKeysExpireInsteadOfBeingDeleted(): void + { + $this->removeRelease(432_000); + + self::assertSame(['contentStore:5:data'], $this->deletedKeys); + self::assertSame(['contentStore:5:meta:info' => 432_000], $this->expiringKeys); + } + + public function testEverythingIsDeletedOnAContentStoreWithoutRetention(): void + { + $this->removeRelease(0); + + self::assertSame(['contentStore:5:data', 'contentStore:5:meta:info'], $this->deletedKeys); + self::assertSame([], $this->expiringKeys); + } + + private function removeRelease(int $retentionSeconds): void + { + $redis = $this->createMock(Redis::class); + $redis->method('del') + ->willReturnCallback(function (string $key): int { + $this->deletedKeys[] = $key; + return 1; + }); + $redis->method('expire') + ->willReturnCallback(function (string $key, int $seconds): bool { + $this->expiringKeys[$key] = $seconds; + return true; + }); + $redis->expects(self::once())->method('zRem')->with('contentStore:registeredReleases', '5'); + + $redisClientManager = $this->createMock(RedisClientManager::class); + $redisClientManager->method('getRedis')->willReturn($redis); + $redisClientManager->method('getRemovedReleaseRetentionSeconds')->willReturn($retentionSeconds); + + $redisReleaseSwitchService = $this->createMock(RedisReleaseSwitchService::class); + $redisReleaseSwitchService->method('getCurrentRelease')->willReturn(ContentReleaseIdentifier::fromString('6')); + + $redisKeyService = new RedisKeyService(); + $this->inject($redisKeyService, 'redisKeyPostfixesForEachReleaseConfiguration', self::keyConfiguration()); + + $cleaner = new ContentReleaseCleaner(); + $this->inject($cleaner, 'redisClientManager', $redisClientManager); + $this->inject($cleaner, 'redisKeyService', $redisKeyService); + $this->inject($cleaner, 'redisReleaseSwitchService', $redisReleaseSwitchService); + $this->inject($cleaner, 'redisKeyPostfixesForEachReleaseConfiguration', self::keyConfiguration()); + + $contentReleaseIdentifier = ContentReleaseIdentifier::fromString('5'); + $cleaner->removeRelease( + $contentReleaseIdentifier, + RedisInstanceIdentifier::primary(), + ContentReleaseLogger::fromSymfonyOutput(new BufferedOutput(), $contentReleaseIdentifier), + ); + } + + /** + * @return array> + */ + private static function keyConfiguration(): array + { + return [ + 'data' => [ + 'redisKeyPostfix' => 'data', + 'transfer' => true, + 'transferMode' => 'hash_incremental', + 'isRequired' => true, + ], + 'metainfo' => [ + 'redisKeyPostfix' => 'meta:info', + 'transfer' => true, + 'transferMode' => 'dump', + 'isRequired' => true, + 'keepAfterRemoval' => true, + ], + ]; + } +} diff --git a/Tests/Unit/Transfer/Dto/RedisKeyPostfixesForEachReleaseTest.php b/Tests/Unit/Transfer/Dto/RedisKeyPostfixesForEachReleaseTest.php index fc1bf37..2181f3e 100644 --- a/Tests/Unit/Transfer/Dto/RedisKeyPostfixesForEachReleaseTest.php +++ b/Tests/Unit/Transfer/Dto/RedisKeyPostfixesForEachReleaseTest.php @@ -8,7 +8,8 @@ use Neos\Flow\Tests\UnitTestCase; /** - * Tests which registered redis keys a quick content release takes over from the release it is built on. + * Tests which registered redis keys a quick content release takes over from the release it is built on, and which + * ones outlive their release once it is removed. */ final class RedisKeyPostfixesForEachReleaseTest extends UnitTestCase { @@ -36,6 +37,22 @@ public function testAKeyWhichDoesNotKnowAboutQuickReleasesIsNotCopied(): void self::assertSame([], self::copiedPostfixes($redisKeyPostfixes)); } + public function testAKeyIsOnlyKeptAfterRemovalWhenItOptsIn(): void + { + // a site package key can be of any size, and keeping it costs memory on the primary content store + $redisKeyPostfixes = RedisKeyPostfixesForEachRelease::fromArray([ + 'metaInfo' => ['keepAfterRemoval' => true] + self::keyConfiguration('meta:info', false), + 'data' => self::keyConfiguration('data', true), + ]); + + $keptAfterRemoval = []; + foreach ($redisKeyPostfixes->getRedisKeyPostfixes() as $redisKeyPostfix) { + $keptAfterRemoval[$redisKeyPostfix->getRedisKeyPostfix()] = $redisKeyPostfix->shouldKeepAfterRemoval(); + } + + self::assertSame(['meta:info' => true, 'data' => false], $keptAfterRemoval); + } + /** * @return array */ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9861488..ba94632 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -291,7 +291,7 @@ parameters: - message: '#^Cannot call method getJob\(\) on Flowpack\\DecoupledContentStore\\BackendUi\\Dto\\ContentReleaseDetails\|null\.$#' identifier: method.nonObject - count: 3 + count: 1 path: Classes/Controller/BackendController.php -