Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/services/search/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,5 @@ services:

Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\ClassDefinition\ClassDefinitionReindexServiceInterface:
class: Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\ClassDefinition\ClassDefinitionReindexService

Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\UnusedIndexCleanupService: ~
31 changes: 31 additions & 0 deletions doc/02_Configuration/03_Index_Management.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ to power search and listing features in Pimcore.
| `generic-data-index:update:index -r` | Delete and recreate indices, then queue all elements |
| `generic-data-index:reindex` | Native search engine reindex (reorganizes data within existing indices, no database read) |
| `generic-data-index:deployment:reindex` | Update indices only for class definitions changed since the last deployment |
| `generic-data-index:cleanup:unused-indices` | Delete managed indices not referenced by any alias (`-odd`/`-even` suffixes only) |

## Index Prefix

Expand Down Expand Up @@ -190,3 +191,33 @@ bin/console generic-data-index:deployment:reindex

This updates the index structure for all class definitions modified since the last
deployment and reindexes data objects for affected classes.

### Cleaning Up Unused Indices

To clean up managed indices that are no longer referenced by any alias, run:

```bash
bin/console generic-data-index:cleanup:unused-indices
```
Comment thread
kingjia90 marked this conversation as resolved.

This only targets indices with the configured prefix and a `-odd` or `-even` suffix.
To preview deletions without making changes, use `--dry-run`:

```bash
bin/console generic-data-index:cleanup:unused-indices --dry-run
```

During a reindex the new `-odd`/`-even` index is created and populated before it is attached
to its alias, so for the duration of that window it carries the configured prefix and suffix
but is not referenced by any alias. To avoid deleting an index that is actively being built,
indices younger than `--min-age` seconds (default: `86400`, i.e. 24 hours) are never
considered unused. Indices whose creation date cannot be determined are also skipped.

```bash
bin/console generic-data-index:cleanup:unused-indices --min-age=3600
```

> **Warning:** Only lower `--min-age` or disable the guard entirely (`--min-age=0`) when you
> are sure no reindex (e.g. `generic-data-index:reindex` or
> `generic-data-index:deployment:reindex`) is currently running or expected to take longer
> than the chosen threshold. Run a `--dry-run` first to review what would be deleted.
130 changes: 130 additions & 0 deletions src/Command/CleanupUnusedIndicesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\GenericDataIndexBundle\Command;

use Exception;
use Pimcore\Bundle\GenericDataIndexBundle\Exception\CommandAlreadyRunningException;
use Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex\UnusedIndexCleanupService;
use Pimcore\Console\AbstractCommand;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
* @internal
*/
final class CleanupUnusedIndicesCommand extends AbstractCommand
{
use LockableTrait;

private const OPTION_DRY_RUN = 'dry-run';

private const OPTION_MIN_AGE = 'min-age';

public function __construct(
private readonly UnusedIndexCleanupService $unusedIndexCleanupService,
?string $name = null
) {
parent::__construct($name);
}
Comment thread
kingjia90 marked this conversation as resolved.

protected function configure(): void
{
$this
->setName('generic-data-index:cleanup:unused-indices')
->addOption(
self::OPTION_DRY_RUN,
null,
InputOption::VALUE_NONE,
'List unused indices without deleting them.'
)
->addOption(
self::OPTION_MIN_AGE,
null,
InputOption::VALUE_REQUIRED,
'Minimum age in seconds an index must have before it is considered unused. '
. 'Set to 0 to disable the age guard.',
(string) UnusedIndexCleanupService::DEFAULT_MIN_AGE_SECONDS
)
->setDescription(
'Deletes managed Generic Data Index indices with the configured index prefix '
. 'and a -odd/-even suffix that are not referenced by any alias.'
)
->setHelp(
'This command only targets managed Generic Data Index indices that use the '
. 'configured index prefix and end with -odd or -even. It does not consider other indices.'
. PHP_EOL
. 'A reindex creates and populates the new -odd/-even index before attaching it to its '
. 'alias, so during that window the new index is not referenced by any alias. To avoid '
. 'deleting an index that is actively being built, indices younger than --min-age seconds '
. '(default: 86400) are never deleted. Only lower this threshold or disable it '
. '(--min-age=0) when no reindex is or was recently running.'
);
}

/**
* @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);

$minAge = $input->getOption(self::OPTION_MIN_AGE);
if (!is_numeric($minAge) || (int) $minAge < 0) {
$output->writeln('<error>The --min-age option must be a non-negative number of seconds.</error>');

return self::FAILURE;
}

$unusedIndices = $this->unusedIndexCleanupService->cleanupUnusedIndices($dryRun, (int) $minAge);

if (empty($unusedIndices)) {
$output->writeln('<info>No unused indices found.</info>');

return self::SUCCESS;
}

$output->writeln('<info>Unused indices:</info>');
foreach ($unusedIndices as $indexName) {
$output->writeln(sprintf(' - %s', $indexName));
}

if ($dryRun) {
$output->writeln(
sprintf('<comment>Dry run: %d indices would be deleted.</comment>', count($unusedIndices))
);
} else {
$output->writeln(
sprintf('<info>Deleted %d unused indices.</info>', count($unusedIndices))
);
}
} catch (Exception $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
Comment thread
cancan101 marked this conversation as resolved.

return self::FAILURE;
} finally {
$this->release();
}

return self::SUCCESS;
}
Comment thread
kingjia90 marked this conversation as resolved.
}
5 changes: 5 additions & 0 deletions src/SearchIndexAdapter/DefaultSearch/DefaultSearchService.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,11 @@ public function getStats(string $indexName): array
return $this->client->getIndexStats(['index' => $indexName]);
}

public function getIndexSettings(string $indexName): array
{
return $this->client->getIndexSettings(['index' => $indexName]);
}

public function getCount(AdapterSearchInterface $search, string $indexName): int
{
$body = $search->toArray();
Expand Down
2 changes: 2 additions & 0 deletions src/SearchIndexAdapter/SearchIndexServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,7 @@ public function search(

public function getStats(string $indexName): array;

public function getIndexSettings(string $indexName): array;
Comment thread
kingjia90 marked this conversation as resolved.

public function getCount(AdapterSearchInterface $search, string $indexName): int;
}
151 changes: 151 additions & 0 deletions src/Service/SearchIndex/UnusedIndexCleanupService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\GenericDataIndexBundle\Service\SearchIndex;

use Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\DefaultSearch\DefaultSearchService;
use Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\IndexAliasServiceInterface;
use Pimcore\Bundle\GenericDataIndexBundle\SearchIndexAdapter\SearchIndexServiceInterface;

/**
* @internal
*/
final readonly class UnusedIndexCleanupService
{
public const DEFAULT_MIN_AGE_SECONDS = 86400;

private const INDEX_SUFFIX_PATTERN = '/-('
. DefaultSearchService::INDEX_VERSION_ODD
. '|'
. DefaultSearchService::INDEX_VERSION_EVEN
. ')$/';

public function __construct(
private SearchIndexServiceInterface $searchIndexService,
private IndexAliasServiceInterface $indexAliasService,
private SearchIndexConfigServiceInterface $searchIndexConfigService,
) {
}

/**
* @return string[]
*/
public function findUnusedIndices(int $minAgeSeconds = self::DEFAULT_MIN_AGE_SECONDS): array
{
$allManagedIndices = $this->getAllManagedIndices($minAgeSeconds);
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,
int $minAgeSeconds = self::DEFAULT_MIN_AGE_SECONDS
): array {
$unusedIndices = $this->findUnusedIndices($minAgeSeconds);

if ($dryRun) {
return $unusedIndices;
}

foreach ($unusedIndices as $indexName) {
$this->searchIndexService->deleteIndex($indexName);
}
Comment thread
kingjia90 marked this conversation as resolved.

return $unusedIndices;
}

/**
* @return string[]
*/
private function getAllManagedIndices(int $minAgeSeconds): array
{
$indexPrefix = $this->searchIndexConfigService->getIndexPrefix();
if ($indexPrefix === '') {
return [];
}

$settingsByIndex = $this->searchIndexService->getIndexSettings($indexPrefix . '*');

$indexNames = [];
foreach ($settingsByIndex as $indexName => $indexSettings) {
if (!is_string($indexName)
|| !str_starts_with($indexName, $indexPrefix)
|| preg_match(self::INDEX_SUFFIX_PATTERN, $indexName) !== 1
|| !$this->isOldEnough($indexSettings, $minAgeSeconds)
) {
continue;
}

$indexNames[] = $indexName;
}

return $indexNames;
}

/**
* A reindex creates and populates the new -odd/-even index before attaching it to its
* alias, so a recently created index without an alias may still be in that window.
* Indices with an unknown creation date never qualify for deletion unless the guard
* is disabled ($minAgeSeconds <= 0).
*/
private function isOldEnough(mixed $indexSettings, int $minAgeSeconds): bool
{
if ($minAgeSeconds <= 0) {
return true;
}

$creationDate = $indexSettings['settings']['index']['creation_date'] ?? null;
if (!is_numeric($creationDate)) {
return false;
}

$creationTimestamp = (int) ((float) $creationDate / 1000);

return time() - $creationTimestamp >= $minAgeSeconds;
}

/**
* @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);
}
}
Loading
Loading