diff --git a/doc/02_Configuration/03_Index_Management.md b/doc/02_Configuration/03_Index_Management.md index 83741e3d..f8070c6c 100644 --- a/doc/02_Configuration/03_Index_Management.md +++ b/doc/02_Configuration/03_Index_Management.md @@ -180,3 +180,17 @@ php bin/console generic-data-index:deployment:reindex ``` This command will update the index structure for all data object classes which were created/updated since the last deployment and reindex all data objects for relevant classes. + +### Cleaning Up Unused Indices + +To clean up old indices that are not referenced by any alias, use the following command: + +``` +php bin/console generic-data-index:cleanup:unused-indices +``` + +To preview what would be deleted without performing any changes, run: + +``` +php bin/console generic-data-index:cleanup:unused-indices --dry-run +``` diff --git a/src/Command/CleanupUnusedIndicesCommand.php b/src/Command/CleanupUnusedIndicesCommand.php new file mode 100644 index 00000000..cbb4162e --- /dev/null +++ b/src/Command/CleanupUnusedIndicesCommand.php @@ -0,0 +1,99 @@ +setName('generic-data-index:cleanup:unused-indices') + ->addOption( + self::OPTION_DRY_RUN, + null, + InputOption::VALUE_NONE, + 'List unused indices without deleting them.' + ) + ->setDescription( + 'Deletes Generic Data Index indices that are not referenced by any alias.' + ); + } + + /** + * @throws CommandAlreadyRunningException + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->lock()) { + throw new CommandAlreadyRunningException( + 'The command is already running in another process.' + ); + } + + try { + $dryRun = (bool) $input->getOption(self::OPTION_DRY_RUN); + $unusedIndices = $this->unusedIndexCleanupService->cleanupUnusedIndices($dryRun); + + if (empty($unusedIndices)) { + $output->writeln('No unused indices found.'); + + return self::SUCCESS; + } + + $output->writeln('Unused indices:'); + foreach ($unusedIndices as $indexName) { + $output->writeln(sprintf(' - %s', $indexName)); + } + + if ($dryRun) { + $output->writeln( + sprintf('Dry run: %d indices would be deleted.', count($unusedIndices)) + ); + } else { + $output->writeln( + sprintf('Deleted %d unused indices.', count($unusedIndices)) + ); + } + } catch (Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + } finally { + $this->release(); + } + + return self::SUCCESS; + } +} diff --git a/src/Service/SearchIndex/UnusedIndexCleanupService.php b/src/Service/SearchIndex/UnusedIndexCleanupService.php new file mode 100644 index 00000000..5755ce9d --- /dev/null +++ b/src/Service/SearchIndex/UnusedIndexCleanupService.php @@ -0,0 +1,120 @@ +getAllManagedIndices(); + if (empty($allManagedIndices)) { + return []; + } + + $aliasedIndices = $this->getAliasedIndices(); + $unusedIndices = array_values(array_diff($allManagedIndices, $aliasedIndices)); + sort($unusedIndices); + + return $unusedIndices; + } + + /** + * @return string[] + */ + public function cleanupUnusedIndices(bool $dryRun = false): array + { + $unusedIndices = $this->findUnusedIndices(); + + if ($dryRun) { + return $unusedIndices; + } + + foreach ($unusedIndices as $indexName) { + $this->searchIndexService->deleteIndex($indexName); + } + + return $unusedIndices; + } + + /** + * @return string[] + */ + private function getAllManagedIndices(): array + { + $indexPrefix = $this->searchIndexConfigService->getIndexPrefix(); + + try { + $stats = $this->searchIndexService->getStats($indexPrefix . '*'); + } catch (Exception) { + return []; + } + + $indices = $stats['indices'] ?? null; + if (!is_array($indices)) { + return []; + } + + $indexNames = array_keys($indices); + + return array_values(array_filter( + $indexNames, + static fn (string $indexName): bool => str_starts_with($indexName, $indexPrefix) + && preg_match(self::INDEX_SUFFIX_PATTERN, $indexName) === 1 + )); + } + + /** + * @return string[] + */ + private function getAliasedIndices(): array + { + $indexPrefix = $this->searchIndexConfigService->getIndexPrefix(); + $aliases = $this->indexAliasService->getAllAliases(); + + $aliasedIndexMap = []; + foreach ($aliases as $aliasData) { + if (!is_array($aliasData)) { + continue; + } + + $indexName = $aliasData['index'] ?? null; + if (!is_string($indexName) || !str_starts_with($indexName, $indexPrefix)) { + continue; + } + + $aliasedIndexMap[$indexName] = true; + } + + return array_keys($aliasedIndexMap); + } +} diff --git a/tests/Unit/Service/SearchIndex/UnusedIndexCleanupServiceTest.php b/tests/Unit/Service/SearchIndex/UnusedIndexCleanupServiceTest.php new file mode 100644 index 00000000..295df8cc --- /dev/null +++ b/tests/Unit/Service/SearchIndex/UnusedIndexCleanupServiceTest.php @@ -0,0 +1,146 @@ +createMock(SearchIndexServiceInterface::class); + $searchIndexService + ->method('getStats') + ->with('pimcore_*') + ->willReturn([ + 'indices' => [ + 'pimcore_asset-odd' => [], + 'pimcore_asset-even' => [], + 'pimcore_document-even' => [], + 'pimcore_custom' => [], + 'other_prefix_asset-odd' => [], + ], + ]) + ; + $searchIndexService->expects($this->never())->method('deleteIndex'); + + $indexAliasService = $this->createMock(IndexAliasServiceInterface::class); + $indexAliasService + ->method('getAllAliases') + ->willReturn([ + ['alias' => 'pimcore_asset', 'index' => 'pimcore_asset-even'], + ['alias' => 'pimcore_document', 'index' => 'pimcore_document-even'], + ]) + ; + + $searchIndexConfigService = $this->createMock(SearchIndexConfigServiceInterface::class); + $searchIndexConfigService + ->method('getIndexPrefix') + ->willReturn('pimcore_') + ; + + $service = new UnusedIndexCleanupService( + $searchIndexService, + $indexAliasService, + $searchIndexConfigService + ); + + $this->assertSame(['pimcore_asset-odd'], $service->findUnusedIndices()); + } + + public function testDryRunDoesNotDeleteIndices(): void + { + $searchIndexService = $this->createMock(SearchIndexServiceInterface::class); + $searchIndexService + ->method('getStats') + ->willReturn([ + 'indices' => [ + 'pimcore_asset-odd' => [], + 'pimcore_asset-even' => [], + ], + ]) + ; + $searchIndexService->expects($this->never())->method('deleteIndex'); + + $indexAliasService = $this->createMock(IndexAliasServiceInterface::class); + $indexAliasService + ->method('getAllAliases') + ->willReturn([ + ['alias' => 'pimcore_asset', 'index' => 'pimcore_asset-even'], + ]) + ; + + $searchIndexConfigService = $this->createMock(SearchIndexConfigServiceInterface::class); + $searchIndexConfigService + ->method('getIndexPrefix') + ->willReturn('pimcore_') + ; + + $service = new UnusedIndexCleanupService( + $searchIndexService, + $indexAliasService, + $searchIndexConfigService + ); + + $this->assertSame(['pimcore_asset-odd'], $service->cleanupUnusedIndices(true)); + } + + public function testExecuteDeletesUnusedIndices(): void + { + $searchIndexService = $this->createMock(SearchIndexServiceInterface::class); + $searchIndexService + ->method('getStats') + ->willReturn([ + 'indices' => [ + 'pimcore_asset-odd' => [], + 'pimcore_asset-even' => [], + ], + ]) + ; + $searchIndexService + ->expects($this->once()) + ->method('deleteIndex') + ->with('pimcore_asset-odd') + ; + + $indexAliasService = $this->createMock(IndexAliasServiceInterface::class); + $indexAliasService + ->method('getAllAliases') + ->willReturn([ + ['alias' => 'pimcore_asset', 'index' => 'pimcore_asset-even'], + ]) + ; + + $searchIndexConfigService = $this->createMock(SearchIndexConfigServiceInterface::class); + $searchIndexConfigService + ->method('getIndexPrefix') + ->willReturn('pimcore_') + ; + + $service = new UnusedIndexCleanupService( + $searchIndexService, + $indexAliasService, + $searchIndexConfigService + ); + + $this->assertSame(['pimcore_asset-odd'], $service->cleanupUnusedIndices()); + } +}