diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 867f572..4b8327c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -17,7 +17,6 @@ jobs: php: [ '8.1', '8.2' ] pimcore: [ '^11.0' ] stability: [ prefer-lowest, prefer-stable ] - elastica: [ '^7.1', '8.x-dev#0f6b04b' ] steps: - name: Checkout code @@ -34,7 +33,7 @@ jobs: - name: Install dependencies run: | - composer require "pimcore/pimcore:${{ matrix.pimcore }}" "ruflin/elastica:${{ matrix.elastica }}" --no-interaction --no-update + composer require "pimcore/pimcore:${{ matrix.pimcore }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: List installed dependencies diff --git a/README.md b/README.md index 535dc66..c25da2f 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ The only job of the bundle is to store Pimcore elements (assets, documents, data 1. `composer require valantic/pimcore-elastica-bridge` 1. Edit `config/bundles.php` and add `\Valantic\ElasticaBridgeBundle\ValanticElasticaBridgeBundle::class => ['all' => true],` -1. Configure the connection to your Elasticsearch cluster as seen in [`example/app/config/config.yml`](example/app/config/config.yml) -1. Don't forget to register your newly created services (implementing `IndexInterface` etc.) in your `services.yml` +1. Configure the connection to your Elasticsearch cluster as seen in [`example/app/config/config.yaml`](example/app/config/config.yaml) +1. Don't forget to register your newly created services (implementing `IndexInterface` etc.) in your `services.yaml` ```yml App\Elasticsearch\: resource: '../../Elasticsearch' @@ -53,11 +53,25 @@ See the [`ProductIndexDocument` provided in the example](docs/example/src/Elasti ```yaml valantic_elastica_bridge: client: - host: localhost - port: 9200 - addSentryBreadcrumbs: false + + # The DSN to connect to the Elasticsearch cluster. + dsn: 'http://localhost:9200' + + # If true, breadcrumbs are added to Sentry for every request made to Elasticsearch via Elastica. + should_add_sentry_breadcrumbs: false + indexing: + + # To prevent overlapping indexing jobs. Set to a value higher than the slowest index. Value is specified in seconds. + lock_timeout: 300 + + # If true, when a document fails to be indexed, it will be skipped and indexing continue with the next document. If false, indexing that index will be aborted. + should_skip_failing_documents: false ``` +## Queue + +[Set up a worker](https://symfony.com/doc/current/messenger.html#consuming-messages-running-the-worker) to process `elastica_bridge_index`. Alternatively you can route the transport to use the `sync` handler: `framework.messenger.transports.elastica_bridge_index: 'sync'`. + ## Indexing ### Bulk @@ -86,6 +100,8 @@ The bridge automatically listens to Pimcore events and updates documents as need This can be globally disabled by calling `\Valantic\ElasticaBridgeBundle\EventListener\Pimcore\ChangeListener::disableListener();`. +You can also dispatch a `Valantic\ElasticaBridgeBundle\Messenger\Message\RefreshElement` message to handle updates to related objects which are not triggered by the `ChangeListener`. + ## Status ``` diff --git a/UPGRADE.md b/UPGRADE.md index 657db9f..46d3091 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,14 +1,26 @@ -# Upgrade from v1 to v2 +# UPGRADE +## Upgrade from v3 to v4 -## Migration +- Remove deprecated options `valantic_elastica_bridge.client.host` and `valantic_elastica_bridge.client.port`. Use `valantic_elastica_bridge.client.dsn` instead, e.g. `http://localhost:9200` +- Renamed `valantic_elastica_bridge.client.addSentryBreadcrumbs` to `valantic_elastica_bridge.client.should_add_sentry_breadcrumbs` +- See [README#Queue](./README#queue) to set up the **required** Symfony Messenger workers. + +## Upgrade from v2 to v3 + +- no code changes necessary + +## Upgrade from v1 to v2 + + +### Migration - `IndexDocumentInterface` implementations should now extend `\Valantic\ElasticaBridgeBundle\Document\AbstractDocument`. `getType()` should now return one of `\Valantic\ElasticaBridgeBundle\Enum\DocumentType` - `Valantic\ElasticaBridgeBundle\DocumentType\Index\ListingTrait` was removed, remove any references to it [#30](https://github.com/valantic/pimcore-elastica-bridge/issues/30) - Update references to renamed classes and interfaces (see next section) - see also the example in [`docs/example/`](./docs/example/) -## Breaking Changes +### Breaking Changes - PHP 8.1+ [#26](https://github.com/valantic/pimcore-elastica-bridge/issues/26) - `\Valantic\ElasticaBridgeBundle\EventListener\Pimcore\AbstractListener` was renamed to `\Valantic\ElasticaBridgeBundle\EventListener\Pimcore\ChangeListener` @@ -23,14 +35,14 @@ - `Valantic\ElasticaBridgeBundle\Document\TenantAwareTrait` has been replaced by `Valantic\ElasticaBridgeBundle\Document\AbstractTenantAwareDocument` - `Valantic\ElasticaBridgeBundle\DocumentType\Index\DataObjectNormalizerTrait` was moved to `Valantic\ElasticaBridgeBundle\Document\DataObjectNormalizerTrait` -## New Features +### New Features - PHPStan generics annotations for `\Valantic\ElasticaBridgeBundle\Document\DocumentInterface` and related helper traits [#32](https://github.com/valantic/pimcore-elastica-bridge/issues/32) - Added `\Valantic\ElasticaBridgeBundle\Service\PropagateChanges::handle` to programmatically update an element in all indices [#33](https://github.com/valantic/pimcore-elastica-bridge/issues/33) - Added support for assets [#34](https://github.com/valantic/pimcore-elastica-bridge/issues/34) - Allow `\Valantic\ElasticaBridgeBundle\Document\DocumentInterface::getSubType` to return `null` for generic, element-level indices [#42](https://github.com/valantic/pimcore-elastica-bridge/issues/42) -## Other changes +### Other changes - `:cleanup` now defaults to only cleaning up bundle indices [#27](https://github.com/valantic/pimcore-elastica-bridge/issues/27) - Removed `--check` from `:index` [#41](https://github.com/valantic/pimcore-elastica-bridge/issues/41) diff --git a/composer.json b/composer.json index bd3533f..d417634 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "ext-json": "*", "pimcore/pimcore": "^11.0", "psr/log": "^3.0", - "ruflin/elastica": "^7.1 || 8.x-dev#0f6b04b", + "ruflin/elastica": "8.x-dev", "symfony/console": "^6.2", "symfony/lock": "^6.2" }, @@ -18,7 +18,7 @@ "phpstan/phpstan": "^1.10.58", "phpstan/phpstan-deprecation-rules": "^1.1.4", "phpstan/phpstan-strict-rules": "^1.5.2", - "rector/rector": "^0.18.13", + "rector/rector": "^1.0.1", "roave/security-advisories": "dev-latest", "sentry/sentry": "^3.22.1", "symfony/http-client": "^6.4.3" diff --git a/docs/example/config/config.yaml b/docs/example/config/config.yaml new file mode 100644 index 0000000..7f91c51 --- /dev/null +++ b/docs/example/config/config.yaml @@ -0,0 +1,3 @@ +valantic_elastica_bridge: + client: + dsn: 'http://localhost:9200' diff --git a/docs/example/config/config.yml b/docs/example/config/config.yml deleted file mode 100644 index ff5b19a..0000000 --- a/docs/example/config/config.yml +++ /dev/null @@ -1,4 +0,0 @@ -valantic_elastica_bridge: - client: - host: 'localhost' - port: 9200 diff --git a/rector.php b/rector.php index c7ab87e..d60fd3e 100644 --- a/rector.php +++ b/rector.php @@ -2,30 +2,39 @@ declare(strict_types=1); +use Rector\CodeQuality\Rector\Foreach_\SimplifyForeachToCoalescingRector; +use Rector\CodeQuality\Rector\FuncCall\SingleInArrayToCompareRector; use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\Config\RectorConfig; use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; use Rector\Php80\Rector\FunctionLike\MixedTypeRector; -use Rector\Set\ValueObject\LevelSetList; -use Rector\Set\ValueObject\SetList; -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->paths([ +return RectorConfig::configure() + ->withPhpSets() + ->withPreparedSets( + codeQuality: true, + ) + ->withAttributesSets( + symfony: true, + doctrine: true, + ) + ->withPaths([ __DIR__ . '/src', - ]); - - $rectorConfig->skip([ + ]) + ->withSkip([ CountArrayToEmptyArrayComparisonRector::class, StringClassNameToClassConstantRector::class => [ 'src/Elastica/Client/ElasticsearchClientFactory.php', ], MixedTypeRector::class => [ 'src/Document/DataObjectNormalizerTrait.php', + 'src/Index/IndexInterface.php', ], - ]); - - $rectorConfig->sets([ - LevelSetList::UP_TO_PHP_81, - SetList::CODE_QUALITY, - ]); -}; + SingleInArrayToCompareRector::class => [ + 'src/Command/NonBundleIndexTrait.php', + ], + SimplifyForeachToCoalescingRector::class => [ + 'src/Repository/IndexRepository.php', + ], + ]) + ->withRootFiles(); diff --git a/src/Command/Cleanup.php b/src/Command/Cleanup.php index b327f02..1d55788 100644 --- a/src/Command/Cleanup.php +++ b/src/Command/Cleanup.php @@ -4,7 +4,7 @@ namespace Valantic\ElasticaBridgeBundle\Command; -use Elastica\Exception\ResponseException; +use Elastic\Elasticsearch\Exception\ElasticsearchException; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -15,6 +15,7 @@ class Cleanup extends BaseCommand { + use NonBundleIndexTrait; private const OPTION_ALL_IN_CLUSTER = 'all'; public function __construct( @@ -54,15 +55,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int $indices = $this->getIndices(); foreach ($indices as $index) { + if (!$this->shouldProcessNonBundleIndex($index)) { + continue; + } + $client = $this->esClient->getIndex($index); + if ($client->getSettings()->getBool('hidden')) { + continue; + } + foreach ($client->getAliases() as $alias) { $client->removeAlias($alias); } try { $client->delete(); - } catch (ResponseException $e) { + } catch (ElasticsearchException $e) { $this->output->writeln(sprintf('%s', $e->getMessage())); } } @@ -81,7 +90,7 @@ private function getIndices(): array $indices = []; - foreach ($this->indexRepository->flattened() as $indexConfig) { + foreach ($this->indexRepository->flattenedAll() as $indexConfig) { if ($indexConfig->usesBlueGreenIndices()) { $indices[] = $indexConfig->getBlueGreenActiveElasticaIndex()->getName(); $indices[] = $indexConfig->getBlueGreenInactiveElasticaIndex()->getName(); diff --git a/src/Command/Index.php b/src/Command/Index.php index 023e27f..157829c 100644 --- a/src/Command/Index.php +++ b/src/Command/Index.php @@ -5,22 +5,20 @@ namespace Valantic\ElasticaBridgeBundle\Command; use Elastica\Index as ElasticaIndex; -use Elastica\Request; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\HttpKernel\KernelInterface; -use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\Process; use Valantic\ElasticaBridgeBundle\Elastica\Client\ElasticsearchClient; use Valantic\ElasticaBridgeBundle\Enum\IndexBlueGreenSuffix; use Valantic\ElasticaBridgeBundle\Exception\Index\BlueGreenIndicesIncorrectlySetupException; use Valantic\ElasticaBridgeBundle\Index\IndexInterface; -use Valantic\ElasticaBridgeBundle\Repository\ConfigurationRepository; use Valantic\ElasticaBridgeBundle\Repository\IndexRepository; +use Valantic\ElasticaBridgeBundle\Service\LockService; +use Valantic\ElasticaBridgeBundle\Util\ElasticsearchResponse; class Index extends BaseCommand { @@ -34,8 +32,7 @@ public function __construct( private readonly IndexRepository $indexRepository, private readonly ElasticsearchClient $esClient, private readonly KernelInterface $kernel, - private readonly LockFactory $lockFactory, - private readonly ConfigurationRepository $configurationRepository, + private readonly LockService $lockService, ) { parent::__construct(); } @@ -73,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $skippedIndices = []; - foreach ($this->indexRepository->flattened() as $indexConfig) { + foreach ($this->indexRepository->flattenedAll() as $indexConfig) { if ( is_array($this->input->getArgument(self::ARGUMENT_INDEX)) && count($this->input->getArgument(self::ARGUMENT_INDEX)) > 0 @@ -84,7 +81,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } - $lock = $this->getLock($indexConfig); + $lock = $this->lockService->getIndexingLock($indexConfig); if (!$lock->acquire()) { if ($this->input->getOption(self::OPTION_LOCK_RELEASE) === true) { @@ -229,7 +226,7 @@ private function ensureCorrectBlueGreenIndexSetup(IndexInterface $indexConfig): // In case an index with the same name as the blue/green alias exists, delete it if ( $nonAliasIndex->exists() - && !$this->esClient->request('_alias/' . $indexConfig->getName(), Request::HEAD)->isOk() + && !ElasticsearchResponse::getResponse($this->esClient->indices()->existsAlias(['name' => $indexConfig->getName()]))->asBool() ) { $nonAliasIndex->delete(); $this->output->writeln('-> Deleted non-blue/green index to prepare for blue/green usage'); @@ -259,13 +256,4 @@ private function ensureCorrectBlueGreenIndexSetup(IndexInterface $indexConfig): $this->output->writeln('-> Ensured indices are correctly set up with alias'); } - - private function getLock(mixed $indexConfig): LockInterface - { - return $this->lockFactory - ->createLock( - __METHOD__ . '->' . $indexConfig->getName(), - ttl: $this->configurationRepository->getIndexingLockTimeout() - ); - } } diff --git a/src/Command/NonBundleIndexTrait.php b/src/Command/NonBundleIndexTrait.php new file mode 100644 index 0000000..47f94f4 --- /dev/null +++ b/src/Command/NonBundleIndexTrait.php @@ -0,0 +1,13 @@ +indexRepository->flattened() as $indexConfig) { + foreach ($this->indexRepository->flattenedAll() as $indexConfig) { if ($indexConfig->getName() === $this->input->getOption(self::OPTION_CONFIG)) { return $indexConfig; } @@ -92,13 +95,21 @@ private function populateIndex(IndexInterface $indexConfig, ElasticaIndex $esInd $listing->setLimit($indexConfig->getBatchSize()); foreach ($listing->getData() ?? [] as $dataObject) { - $progressBar->advance(); + try { + $progressBar->advance(); - if (!$documentInstance->shouldIndex($dataObject)) { - continue; - } + if (!$documentInstance->shouldIndex($dataObject)) { + continue; + } + + $esDocuments[] = $this->documentHelper->elementToDocument($documentInstance, $dataObject); + } catch (\Throwable $throwable) { + $this->displayDocumentError($indexConfig, $document, $dataObject, $throwable); - $esDocuments[] = $this->documentHelper->elementToDocument($documentInstance, $dataObject); + if (!$this->configurationRepository->shouldSkipFailingDocuments()) { + throw new DocumentFailedException($throwable); + } + } } if (count($esDocuments) > 0) { @@ -116,22 +127,7 @@ private function populateIndex(IndexInterface $indexConfig, ElasticaIndex $esInd } } } catch (\Throwable $throwable) { - $this->output->writeln(''); - $this->output->writeln(sprintf( - 'Error while populating index %s, processing documents of type %s, last processed element ID %s.', - $indexConfig::class, - $document ?? '(N/A)', - isset($dataObject) && $dataObject instanceof AbstractElement ? $dataObject->getId() : '(N/A)' - )); - $this->output->writeln(''); - $this->output->writeln(sprintf('In %s line %d', $throwable->getFile(), $throwable->getLine())); - $this->output->writeln(''); - - $this->output->writeln($throwable->getMessage()); - $this->output->writeln(''); - - $this->output->writeln($throwable->getTraceAsString()); - $this->output->writeln(''); + $this->displayIndexError($indexConfig, $throwable); throw new IndexingFailedException($throwable); } finally { @@ -143,4 +139,44 @@ private function populateIndex(IndexInterface $indexConfig, ElasticaIndex $esInd $progressBar->finish(); $this->output->writeln(''); } + + private function displayDocumentError( + IndexInterface $indexConfig, + string $document, + AbstractElement $dataObject, + \Throwable $throwable, + ): void { + $this->output->writeln(''); + $this->output->writeln(sprintf( + 'Error while populating index %s, processing documents of type %s, last processed element ID %s.', + $indexConfig::class, + $document, + $dataObject->getId() + )); + $this->displayThrowable($throwable); + } + + private function displayIndexError(IndexInterface $indexConfig, \Throwable $throwable): void + { + $this->output->writeln(''); + $this->output->writeln(sprintf( + 'Error while populating index %s.', + $indexConfig::class, + )); + + $this->displayThrowable($throwable); + } + + private function displayThrowable(\Throwable $throwable): void + { + $this->output->writeln(''); + $this->output->writeln(sprintf('In %s line %d', $throwable->getFile(), $throwable->getLine())); + $this->output->writeln(''); + + $this->output->writeln($throwable->getMessage()); + $this->output->writeln(''); + + $this->output->writeln($throwable->getTraceAsString()); + $this->output->writeln(''); + } } diff --git a/src/Command/Status.php b/src/Command/Status.php index af616fb..6d66c96 100644 --- a/src/Command/Status.php +++ b/src/Command/Status.php @@ -14,6 +14,7 @@ class Status extends BaseCommand { + use NonBundleIndexTrait; /** * @var array> */ @@ -55,7 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->output->writeln(''); - foreach ($this->indexRepository->flattened() as $indexConfig) { + foreach ($this->indexRepository->flattenedAll() as $indexConfig) { $this->processBundleIndex($indexConfig); } @@ -66,11 +67,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->setHeaderTitle('Indices (managed by this bundle)'); $table->render(); + $otherIndexNames = []; + foreach ($this->esClient->getCluster()->getIndexNames() as $indexName) { - if (in_array($indexName, $this->skipOtherIndices, true)) { + if (in_array($indexName, $this->skipOtherIndices, true) || !$this->shouldProcessNonBundleIndex($indexName)) { continue; } - $this->processOtherIndex($indexName); + $otherIndexNames[] = $indexName; + } + + sort($otherIndexNames); + + foreach ($otherIndexNames as $otherIndexName) { + $this->processOtherIndex($otherIndexName); } $this->output->writeln(''); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 00bacd7..d3598df 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -7,16 +7,8 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -/** - * This is the class that validates and merges configuration from your app/config files. - * - * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/configuration.html} - */ class Configuration implements ConfigurationInterface { - /** - * {@inheritDoc} - */ public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder('valantic_elastica_bridge'); @@ -24,16 +16,15 @@ public function getConfigTreeBuilder() ->children() ->arrayNode('client') ->children() - ->scalarNode('host')->defaultValue('localhost')->setDeprecated('valantic/pimcore-elastica-bridge', '3.1.0', 'Use the "dsn" option instead, e.g. http://username:password@localhost:9200')->end() - ->integerNode('port')->defaultValue(9200)->setDeprecated('valantic/pimcore-elastica-bridge', '3.1.0', 'Use the "dsn" option instead, e.g. http://username:password@localhost:9200')->end() - ->scalarNode('dsn')->defaultNull()->end() - ->booleanNode('addSentryBreadcrumbs')->defaultValue(false)->end() + ->scalarNode('dsn')->defaultValue('http://localhost:9200')->info('The DSN to connect to the Elasticsearch cluster.')->end() + ->booleanNode('should_add_sentry_breadcrumbs')->defaultFalse()->info('If true, breadcrumbs are added to Sentry for every request made to Elasticsearch via Elastica.')->end() ->end() ->end() ->arrayNode('indexing') ->addDefaultsIfNotSet() ->children() ->integerNode('lock_timeout')->defaultValue(5 * 60)->info('To prevent overlapping indexing jobs. Set to a value higher than the slowest index. Value is specified in seconds.')->end() + ->booleanNode('should_skip_failing_documents')->defaultFalse()->info('If true, when a document fails to be indexed, it will be skipped and indexing continue with the next document. If false, indexing that index will be aborted.')->end() ->end() ->end() ->end() diff --git a/src/DependencyInjection/ValanticElasticaBridgeExtension.php b/src/DependencyInjection/ValanticElasticaBridgeExtension.php index 3e02a4e..655a846 100644 --- a/src/DependencyInjection/ValanticElasticaBridgeExtension.php +++ b/src/DependencyInjection/ValanticElasticaBridgeExtension.php @@ -37,7 +37,7 @@ public function load(array $configs, ContainerBuilder $container): void $config = $this->processConfiguration($configuration, $configs); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('services.yml'); + $loader->load('services.yaml'); $container->setParameter('valantic_elastica_bridge', $config); } diff --git a/src/Document/AbstractDocument.php b/src/Document/AbstractDocument.php index eb674b5..a125b3e 100644 --- a/src/Document/AbstractDocument.php +++ b/src/Document/AbstractDocument.php @@ -5,11 +5,8 @@ namespace Valantic\ElasticaBridgeBundle\Document; use Pimcore\Model\Asset; -use Pimcore\Model\Asset\Listing as AssetListing; use Pimcore\Model\DataObject; -use Pimcore\Model\DataObject\Listing as DataObjectListing; use Pimcore\Model\Document as PimcoreDocument; -use Pimcore\Model\Document\Listing as DocumentListing; use Pimcore\Model\Element\AbstractElement; use Pimcore\Model\Listing\AbstractListing; use Valantic\ElasticaBridgeBundle\Enum\DocumentType; @@ -42,12 +39,12 @@ public function getListingInstance(IndexInterface $index): AbstractListing } if (in_array($this->getType(), DocumentType::casesPublishedState(), true)) { - /** @var DocumentListing|DataObjectListing $listingInstance */ + /** @var PimcoreDocument\Listing|DataObject\Listing $listingInstance */ $listingInstance->setUnpublished($this->includeUnpublishedElementsInListing()); } if ($this->getType() === DocumentType::DATA_OBJECT) { - /** @var DataObjectListing $listingInstance */ + /** @var DataObject\Listing $listingInstance */ if ($this->treatObjectVariantsAsDocuments()) { $listingInstance->setObjectTypes([ DataObject\AbstractObject::OBJECT_TYPE_OBJECT, @@ -137,8 +134,8 @@ protected function getListingClass(): string { try { return match ($this->getType()) { - DocumentType::ASSET => AssetListing::class, - DocumentType::DOCUMENT => DocumentListing::class, + DocumentType::ASSET => Asset\Listing::class, + DocumentType::DOCUMENT => PimcoreDocument\Listing::class, DocumentType::DATA_OBJECT, DocumentType::VARIANT => $this->getDataObjectListingClass(), }; } catch (\UnhandledMatchError) { @@ -214,7 +211,7 @@ private function getDataObjectListingClass(): string $subType = $this->getSubType(); if ($subType === null) { - return DataObjectListing::class; + return DataObject\Listing::class; } $className = $subType . '\Listing'; diff --git a/src/Document/DataObjectNormalizerTrait.php b/src/Document/DataObjectNormalizerTrait.php index c51149a..16ab6df 100644 --- a/src/Document/DataObjectNormalizerTrait.php +++ b/src/Document/DataObjectNormalizerTrait.php @@ -9,6 +9,7 @@ use Pimcore\Model\DataObject\Concrete; use Pimcore\Model\DataObject\Localizedfield; use Pimcore\Tool; +use Symfony\Contracts\Service\Attribute\Required; /** * Collection of helpers for normalizing a DataObject. @@ -19,11 +20,7 @@ trait DataObjectNormalizerTrait { protected LocaleService $localeService; - /** - * Injects the LocaleService using Symfony's DI. - * - * @required - */ + #[Required] public function setLocaleService(LocaleService $localeService): void { $this->localeService = $localeService; diff --git a/src/Document/DocumentInterface.php b/src/Document/DocumentInterface.php index 2dc0c1f..84fce99 100644 --- a/src/Document/DocumentInterface.php +++ b/src/Document/DocumentInterface.php @@ -63,7 +63,7 @@ public function getType(): DocumentType; * * Returning null will result in all elements of getType() being included. * - * @return ?class-string + * @return ?class-string */ public function getSubType(): ?string; diff --git a/src/Document/DocumentNormalizerTrait.php b/src/Document/DocumentNormalizerTrait.php index 84508e8..b0a5d8b 100644 --- a/src/Document/DocumentNormalizerTrait.php +++ b/src/Document/DocumentNormalizerTrait.php @@ -37,10 +37,10 @@ protected function editables(Document\Page $document): array $data = []; $editableNames = array_merge( array_map(fn (Document\Editable $editable): string => $editable->getName(), $document->getEditables()), - $document->getContentMasterDocument() instanceof Document\PageSnippet + $document->getContentMainDocument() instanceof Document\PageSnippet ? array_map( fn (Document\Editable $editable): string => $editable->getName(), - $document->getContentMasterDocument()->getEditables() + $document->getContentMainDocument()->getEditables() ) : [] ); @@ -84,7 +84,9 @@ protected function editableRelation( [$contents->getId()], array_map( fn (Concrete $obj): int => $obj->getId(), - $contents->getChildren([AbstractObject::OBJECT_TYPE_OBJECT]) + $contents + ->getChildren([AbstractObject::OBJECT_TYPE_OBJECT]) + ->getData() ?? [] ) ); diff --git a/src/Elastica/Client/ElasticsearchClientFactory.php b/src/Elastica/Client/ElasticsearchClientFactory.php index 85cda05..adcc074 100644 --- a/src/Elastica/Client/ElasticsearchClientFactory.php +++ b/src/Elastica/Client/ElasticsearchClientFactory.php @@ -15,9 +15,9 @@ public function __construct( public function __invoke( ): ElasticsearchClient { - $esClient = new ElasticsearchClient($this->configurationRepository->getClient()); + $esClient = new ElasticsearchClient($this->configurationRepository->getClientDsn()); - if ($this->configurationRepository->getAddSentryBreadcrumbs() && class_exists('\Sentry\Breadcrumb')) { + if ($this->configurationRepository->shouldAddSentryBreadcrumbs() && class_exists('\Sentry\Breadcrumb')) { $esClient->setLogger(new SentryBreadcrumbLogger()); } diff --git a/src/EventListener/Pimcore/ChangeListener.php b/src/EventListener/Pimcore/ChangeListener.php index 8ff61fa..0489a26 100644 --- a/src/EventListener/Pimcore/ChangeListener.php +++ b/src/EventListener/Pimcore/ChangeListener.php @@ -15,8 +15,9 @@ use Pimcore\Model\Document; use Pimcore\Model\Element\AbstractElement; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Messenger\MessageBusInterface; use Valantic\ElasticaBridgeBundle\Exception\EventListener\PimcoreElementNotFoundException; -use Valantic\ElasticaBridgeBundle\Service\PropagateChanges; +use Valantic\ElasticaBridgeBundle\Messenger\Message\RefreshElement; /** * An abstract listener for DataObject and Document listeners. @@ -28,7 +29,7 @@ class ChangeListener implements EventSubscriberInterface private static bool $isEnabled = true; public function __construct( - private readonly PropagateChanges $propagateChanges, + private readonly MessageBusInterface $messageBus, ) {} public function handle(AssetEvent|DataObjectEvent|DocumentEvent $event): void @@ -45,7 +46,7 @@ public function handle(AssetEvent|DataObjectEvent|DocumentEvent $event): void return; } - $this->propagateChanges->handle($this->getFreshElement($element)); + $this->messageBus->dispatch(new RefreshElement($this->getFreshElement($element))); } public static function enableListener(): void diff --git a/src/Exception/Command/DocumentFailedException.php b/src/Exception/Command/DocumentFailedException.php new file mode 100644 index 0000000..9870def --- /dev/null +++ b/src/Exception/Command/DocumentFailedException.php @@ -0,0 +1,15 @@ + [ + DocumentInterface::META_ID => [ + 'type' => 'keyword', + ], + DocumentInterface::META_TYPE => [ + 'type' => 'keyword', + ], + DocumentInterface::META_SUB_TYPE => [ + 'type' => 'keyword', + ], + ], + ]; } public function getSettings(): array @@ -112,10 +126,14 @@ final public function getBlueGreenActiveSuffix(): IndexBlueGreenSuffix throw new BlueGreenIndicesIncorrectlySetupException(); } - $aliases = array_filter( - $this->client->request('_aliases')->getData(), - fn (array $datum): bool => array_key_exists($this->getName(), $datum['aliases']) - ); + try { + $aliases = array_filter( + ElasticsearchResponse::getResponse($this->client->indices()->getAlias(['name' => $this->getName()]))->asArray(), + fn (array $datum): bool => array_key_exists($this->getName(), $datum['aliases']) + ); + } catch (ClientResponseException) { + throw new BlueGreenIndicesIncorrectlySetupException(); + } if (count($aliases) !== 1) { throw new BlueGreenIndicesIncorrectlySetupException(); diff --git a/src/Index/IndexInterface.php b/src/Index/IndexInterface.php index 96f1d37..774aef7 100644 --- a/src/Index/IndexInterface.php +++ b/src/Index/IndexInterface.php @@ -35,7 +35,7 @@ public function getBatchSize(): int; * Defines the mapping to be used for this index. * Passed 1:1 to Elasticsearch. * - * @return array> + * @return array{properties:array} */ public function getMapping(): array; @@ -57,7 +57,7 @@ public function getCreateArguments(): array; /** * Defines the types of documents found in this index. Array of classes implementing DocumentInterface. * - * @return string[] Class names of DocumentInterface classes + * @return class-string[] Class names of DocumentInterface classes * * @see DocumentInterface */ @@ -66,7 +66,7 @@ public function getAllowedDocuments(): array; /** * The documents this index subscribes to i.e. the documents which are updated using event listeners. * - * @return string[] Class names of DocumentInterface instances + * @return class-string[] Class names of DocumentInterface instances * * @see DocumentInterface */ diff --git a/src/Messenger/Handler/AbstractRefreshHandler.php b/src/Messenger/Handler/AbstractRefreshHandler.php new file mode 100644 index 0000000..471ba44 --- /dev/null +++ b/src/Messenger/Handler/AbstractRefreshHandler.php @@ -0,0 +1,45 @@ + $className */ + $className = $message->className; + + try { + $element = $className::getById($message->id); + } catch (\Throwable) { + throw new PimcoreElementNotFoundException($message->id, $message->className); + } + + if (!$element instanceof AbstractElement) { + // The element in question was deleted so we need a skeleton. + /** @var TModel $element */ + $element = new ($className)(); + $element->setId($message->id); + + if ($element instanceof Concrete) { + $element->setPublished(false); + } + } + + if ($element === null) { + throw new PimcoreElementNotFoundException($message->id, $message->className); + } + + return $element; + } +} diff --git a/src/Messenger/Handler/RefreshElementHandler.php b/src/Messenger/Handler/RefreshElementHandler.php new file mode 100644 index 0000000..db251c7 --- /dev/null +++ b/src/Messenger/Handler/RefreshElementHandler.php @@ -0,0 +1,28 @@ + + */ +class RefreshElementHandler extends AbstractRefreshHandler +{ + public function __construct( + private readonly PropagateChanges $propagateChanges, + ) {} + + public function __invoke(RefreshElement $message): void + { + $element = $this->resolveElement($message); + + $this->propagateChanges->handle($element); + } +} diff --git a/src/Messenger/Handler/RefreshElementInIndexHandler.php b/src/Messenger/Handler/RefreshElementInIndexHandler.php new file mode 100644 index 0000000..1e715fd --- /dev/null +++ b/src/Messenger/Handler/RefreshElementInIndexHandler.php @@ -0,0 +1,37 @@ + + */ +class RefreshElementInIndexHandler extends AbstractRefreshHandler +{ + public function __construct( + private readonly PropagateChanges $propagateChanges, + private readonly LockService $lockService, + private readonly IndexRepository $indexRepository, + ) {} + + public function __invoke(RefreshElementInIndex $message): void + { + $index = $this->indexRepository->flattenedGet($message->index); + $element = $this->resolveElement($message); + + if ($index->usesBlueGreenIndices() && !$this->lockService->getIndexingLock($index)->acquire()) { + $this->propagateChanges->handleIndex($element, $index, $index->getBlueGreenInactiveElasticaIndex()); + } + + $this->propagateChanges->handleIndex($element, $index); + } +} diff --git a/src/Messenger/Message/AbstractRefresh.php b/src/Messenger/Message/AbstractRefresh.php new file mode 100644 index 0000000..1c9d33e --- /dev/null +++ b/src/Messenger/Message/AbstractRefresh.php @@ -0,0 +1,20 @@ + */ + public string $className; + public int $id; + + protected function setElement(ElementInterface $element): void + { + $this->className = $element::class; + $this->id = $element->getId() ?? throw new \InvalidArgumentException('Pimcore ID is null.'); + } +} diff --git a/src/Messenger/Message/RefreshElement.php b/src/Messenger/Message/RefreshElement.php new file mode 100644 index 0000000..b4785d0 --- /dev/null +++ b/src/Messenger/Message/RefreshElement.php @@ -0,0 +1,15 @@ +setElement($element); + } +} diff --git a/src/Messenger/Message/RefreshElementInIndex.php b/src/Messenger/Message/RefreshElementInIndex.php new file mode 100644 index 0000000..ebdcdd0 --- /dev/null +++ b/src/Messenger/Message/RefreshElementInIndex.php @@ -0,0 +1,17 @@ +setElement($element); + } +} diff --git a/src/Repository/AbstractRepository.php b/src/Repository/AbstractRepository.php index 3cad617..c9d6358 100644 --- a/src/Repository/AbstractRepository.php +++ b/src/Repository/AbstractRepository.php @@ -8,6 +8,8 @@ /** * @template TItem + * + * @internal */ abstract class AbstractRepository { diff --git a/src/Repository/ConfigurationRepository.php b/src/Repository/ConfigurationRepository.php index 112a76c..d08e277 100644 --- a/src/Repository/ConfigurationRepository.php +++ b/src/Repository/ConfigurationRepository.php @@ -6,34 +6,32 @@ use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface; +/** + * @internal + */ class ConfigurationRepository { public function __construct( private readonly ContainerBagInterface $containerBag, ) {} - /** - * @return array{host:string,port:int}|string - */ - public function getClient(): array|string + public function getClientDsn(): string { - $config = $this->containerBag->get('valantic_elastica_bridge')['client']; - - return $config['dsn'] !== null && $config['dsn'] !== '' - ? $config['dsn'] - : [ - 'host' => $config['host'], - 'port' => $config['port'], - ]; + return $this->containerBag->get('valantic_elastica_bridge')['client']['dsn']; } - public function getAddSentryBreadcrumbs(): bool + public function shouldAddSentryBreadcrumbs(): bool { - return $this->containerBag->get('valantic_elastica_bridge')['client']['addSentryBreadcrumbs']; + return $this->containerBag->get('valantic_elastica_bridge')['client']['should_add_sentry_breadcrumbs']; } public function getIndexingLockTimeout(): int { return $this->containerBag->get('valantic_elastica_bridge')['indexing']['lock_timeout']; } + + public function shouldSkipFailingDocuments(): bool + { + return $this->containerBag->get('valantic_elastica_bridge')['indexing']['should_skip_failing_documents']; + } } diff --git a/src/Repository/DocumentRepository.php b/src/Repository/DocumentRepository.php index 8427d6c..9c664ab 100644 --- a/src/Repository/DocumentRepository.php +++ b/src/Repository/DocumentRepository.php @@ -6,5 +6,9 @@ use Valantic\ElasticaBridgeBundle\Document\DocumentInterface; -/** @extends AbstractRepository */ +/** + * @extends AbstractRepository + * + * @internal + */ class DocumentRepository extends AbstractRepository {} diff --git a/src/Repository/IndexRepository.php b/src/Repository/IndexRepository.php index 99da9ff..5ad406f 100644 --- a/src/Repository/IndexRepository.php +++ b/src/Repository/IndexRepository.php @@ -4,16 +4,21 @@ namespace Valantic\ElasticaBridgeBundle\Repository; +use Valantic\ElasticaBridgeBundle\Exception\Repository\ItemNotFoundInRepositoryException; use Valantic\ElasticaBridgeBundle\Index\IndexInterface; use Valantic\ElasticaBridgeBundle\Index\TenantAwareInterface; -/** @extends AbstractRepository */ +/** + * @extends AbstractRepository + * + * @internal + */ class IndexRepository extends AbstractRepository { /** * @return \Generator */ - public function flattened(): \Generator + public function flattenedAll(): \Generator { foreach ($this->all() as $indexConfig) { if ($indexConfig instanceof TenantAwareInterface) { @@ -28,4 +33,15 @@ public function flattened(): \Generator } } } + + public function flattenedGet(string $key): IndexInterface + { + foreach ($this->flattenedAll() as $candidateKey => $index) { + if ($candidateKey === $key) { + return $index; + } + } + + throw new ItemNotFoundInRepositoryException($key); + } } diff --git a/src/Resources/config/pimcore/config.yaml b/src/Resources/config/pimcore/config.yaml new file mode 100644 index 0000000..53b51b1 --- /dev/null +++ b/src/Resources/config/pimcore/config.yaml @@ -0,0 +1,2 @@ +imports: + - { resource: messenger.yaml } diff --git a/src/Resources/config/pimcore/messenger.yaml b/src/Resources/config/pimcore/messenger.yaml new file mode 100644 index 0000000..66393aa --- /dev/null +++ b/src/Resources/config/pimcore/messenger.yaml @@ -0,0 +1,8 @@ +framework: + messenger: + enabled: true + transports: + elastica_bridge_index: 'doctrine://default?queue_name=elastica_bridge_index' + routing: + Valantic\ElasticaBridgeBundle\Messenger\Message\RefreshElement: elastica_bridge_index + Valantic\ElasticaBridgeBundle\Messenger\Message\RefreshElementInIndex: elastica_bridge_index diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yaml similarity index 86% rename from src/Resources/config/services.yml rename to src/Resources/config/services.yaml index acdf8b6..0de954b 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yaml @@ -28,3 +28,8 @@ services: Valantic\ElasticaBridgeBundle\Repository\DocumentRepository: arguments: - !tagged_iterator valantic.elastica_bridge.document + + Valantic\ElasticaBridgeBundle\Messenger\Handler\: + resource: '../../Messenger/Handler/*' + tags: + - { name: messenger.message_handler } diff --git a/src/Service/LockService.php b/src/Service/LockService.php new file mode 100644 index 0000000..425832b --- /dev/null +++ b/src/Service/LockService.php @@ -0,0 +1,29 @@ +lockFactory + ->createLock( + sprintf('%s:indexing:%s', self::LOCK_PREFIX, $indexConfig->getName()), + ttl: $this->configurationRepository->getIndexingLockTimeout() + ); + } +} diff --git a/src/Service/PropagateChanges.php b/src/Service/PropagateChanges.php index 24b97ae..a5fcb54 100644 --- a/src/Service/PropagateChanges.php +++ b/src/Service/PropagateChanges.php @@ -5,10 +5,13 @@ namespace Valantic\ElasticaBridgeBundle\Service; use Elastica\Exception\NotFoundException; +use Elastica\Index; use Pimcore\Model\DataObject\AbstractObject; use Pimcore\Model\Element\AbstractElement; +use Symfony\Component\Messenger\MessageBusInterface; use Valantic\ElasticaBridgeBundle\Document\DocumentInterface; use Valantic\ElasticaBridgeBundle\Index\IndexInterface; +use Valantic\ElasticaBridgeBundle\Messenger\Message\RefreshElementInIndex; use Valantic\ElasticaBridgeBundle\Repository\IndexRepository; class PropagateChanges @@ -16,6 +19,7 @@ class PropagateChanges public function __construct( private readonly IndexRepository $indexRepository, private readonly DocumentHelper $documentHelper, + private readonly MessageBusInterface $messageBus, ) {} /** @@ -26,15 +30,27 @@ public function __construct( */ public function handle(AbstractElement $element): void { - $indices = $this->matchingIndicesForElement($this->indexRepository->flattened(), $element); + $indices = $this->matchingIndicesForElement($this->indexRepository->flattenedAll(), $element); foreach ($indices as $index) { - $this->handleIndex($element, $index); + $this->messageBus->dispatch(new RefreshElementInIndex($element, $index->getName())); } } - private function handleIndex(AbstractElement $element, IndexInterface $index): void - { + public function handleIndex( + AbstractElement $element, + IndexInterface $index, + ?Index $elasticaIndex = null, + ): void { + $this->doHandleIndex($element, $index, $elasticaIndex ?? $index->getElasticaIndex()); + } + + private function doHandleIndex( + AbstractElement $element, + IndexInterface $index, + Index $elasticaIndex, + ): void { + // TODO: actually use $elasticaIndex $document = $index->findDocumentInstanceByPimcore($element); if (!$document instanceof DocumentInterface) { @@ -52,20 +68,20 @@ private function handleIndex(AbstractElement $element, IndexInterface $index): v return; } - $isPresent = $this->isIdInIndex($document::getElasticsearchId($element), $index); + $isPresent = $this->isIdInIndex($document::getElasticsearchId($element), $elasticaIndex); if ($document->shouldIndex($element)) { if ($isPresent) { - $this->updateElementInIndex($element, $index, $document); + $this->updateElementInIndex($element, $elasticaIndex, $document); } if (!$isPresent) { - $this->addElementToIndex($element, $index, $document); + $this->addElementToIndex($element, $elasticaIndex, $document); } } if ($isPresent && !$document->shouldIndex($element)) { - $this->deleteElementFromIndex($element, $index, $document); + $this->deleteElementFromIndex($element, $elasticaIndex, $document); } $this->documentHelper->resetTenantIfNeeded($document, $index); @@ -76,11 +92,11 @@ private function handleIndex(AbstractElement $element, IndexInterface $index): v */ private function addElementToIndex( AbstractElement $element, - IndexInterface $index, + Index $index, DocumentInterface $document, ): void { $document = $this->documentHelper->elementToDocument($document, $element); - $index->getElasticaIndex()->addDocument($document); + $index->addDocument($document); } /** @@ -88,12 +104,12 @@ private function addElementToIndex( */ private function updateElementInIndex( AbstractElement $element, - IndexInterface $index, + Index $index, DocumentInterface $document, ): void { $document = $this->documentHelper->elementToDocument($document, $element); // updateDocument() allows partial updates, hence the full replace here - $index->getElasticaIndex()->addDocument($document); + $index->addDocument($document); } /** @@ -101,11 +117,11 @@ private function updateElementInIndex( */ private function deleteElementFromIndex( AbstractElement $element, - IndexInterface $index, + Index $index, DocumentInterface $document, ): void { $elasticsearchId = $document::getElasticsearchId($element); - $index->getElasticaIndex()->deleteById($elasticsearchId); + $index->deleteById($elasticsearchId); } /** @@ -134,10 +150,10 @@ private function matchingIndicesForElement( /** * Checks whether a given ID is in an index. */ - private function isIdInIndex(string $id, IndexInterface $index): bool + private function isIdInIndex(string $id, Index $index): bool { try { - $index->getElasticaIndex()->getDocument($id); + $index->getDocument($id); } catch (NotFoundException) { return false; } diff --git a/src/Util/ElasticsearchResponse.php b/src/Util/ElasticsearchResponse.php new file mode 100644 index 0000000..92a1d14 --- /dev/null +++ b/src/Util/ElasticsearchResponse.php @@ -0,0 +1,28 @@ +