├── .gitignore ├── EnqueueElasticaBundle.php ├── Queue ├── Commands.php └── PopulateProcessor.php ├── Doctrine ├── Queue │ ├── Commands.php │ └── SyncIndexWithObjectChangeProcessor.php └── SyncIndexWithObjectChangeListener.php ├── composer.json ├── LICENSE ├── README.md ├── Persister ├── Listener │ └── PurgePopulateQueueListener.php └── QueuePagerPersister.php └── DependencyInjection ├── Configuration.php └── EnqueueElasticaExtension.php /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /composer.lock 3 | /composer.phar 4 | /phpunit.xml 5 | /vendor/ 6 | /.idea/ 7 | -------------------------------------------------------------------------------- /EnqueueElasticaBundle.php: -------------------------------------------------------------------------------- 1 | context = $context; 15 | } 16 | 17 | public function purgePopulateQueue(PrePersistEvent $event) 18 | { 19 | $options = $event->getOptions(); 20 | if (empty($options['purge_populate_queue'])) { 21 | return; 22 | } 23 | if (empty($options['populate_queue'])) { 24 | return; 25 | } 26 | 27 | if (method_exists($this->context, 'purge')) { 28 | $queue = $this->context->createQueue($options['populate_queue']); 29 | 30 | $this->context->purge($queue); 31 | } 32 | 33 | if (method_exists($this->context, 'purgeQueue')) { 34 | $queue = $this->context->createQueue($options['populate_queue']); 35 | 36 | $this->context->purgeQueue($queue); 37 | } 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public static function getSubscribedEvents() 44 | { 45 | return [ 46 | PrePersistEvent::class => 'purgePopulateQueue', 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 19 | } else { 20 | // BC layer for symfony/config 4.1 and older 21 | $rootNode = $tb->root('enqueue_elastica'); 22 | } 23 | $rootNode 24 | ->children() 25 | ->booleanNode('enabled')->defaultValue(true)->end() 26 | ->scalarNode('transport')->defaultValue('%enqueue.default_transport%')->cannotBeEmpty()->isRequired()->end() 27 | ->arrayNode('doctrine') 28 | ->children() 29 | ->scalarNode('driver')->defaultValue('orm')->cannotBeEmpty() 30 | ->validate()->ifNotInArray(['orm', 'mongodb'])->thenInvalid('Invalid driver') 31 | ->end()->end() 32 | ->arrayNode('queue_listeners') 33 | ->prototype('array') 34 | ->addDefaultsIfNotSet() 35 | ->children() 36 | ->booleanNode('insert')->defaultTrue()->end() 37 | ->booleanNode('update')->defaultTrue()->end() 38 | ->booleanNode('remove')->defaultTrue()->end() 39 | ->scalarNode('connection')->defaultValue('default')->cannotBeEmpty()->end() 40 | ->scalarNode('index_name')->isRequired()->cannotBeEmpty()->end() 41 | ->scalarNode('model_class')->isRequired()->cannotBeEmpty()->end() 42 | ->scalarNode('model_id')->defaultValue('id')->cannotBeEmpty()->end() 43 | ->scalarNode('repository_method')->defaultValue('find')->cannotBeEmpty()->end() 44 | ->end() 45 | ; 46 | 47 | return $tb; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Doctrine/SyncIndexWithObjectChangeListener.php: -------------------------------------------------------------------------------- 1 | context = $context; 34 | $this->modelClass = $modelClass; 35 | $this->config = $config; 36 | } 37 | 38 | public function postUpdate(LifecycleEventArgs $args) 39 | { 40 | if ($args->getObject() instanceof $this->modelClass) { 41 | $this->scheduledForUpdateIndex[] = [ 42 | 'action' => SyncProcessor::UPDATE_ACTION, 43 | 'id' => $this->extractId($args->getObject()) 44 | ]; 45 | } 46 | } 47 | 48 | public function postPersist(LifecycleEventArgs $args) 49 | { 50 | if ($args->getObject() instanceof $this->modelClass) { 51 | $this->scheduledForUpdateIndex[] = [ 52 | 'action' => SyncProcessor::INSERT_ACTION, 53 | 'id' => $this->extractId($args->getObject()) 54 | ]; 55 | } 56 | } 57 | 58 | public function preRemove(LifecycleEventArgs $args) 59 | { 60 | if ($args->getObject() instanceof $this->modelClass) { 61 | $this->scheduledForUpdateIndex[] = [ 62 | 'action' => SyncProcessor::REMOVE_ACTION, 63 | 'id' => $this->extractId($args->getObject()) 64 | ]; 65 | } 66 | } 67 | 68 | public function postFlush(PostFlushEventArgs $event) 69 | { 70 | if (count($this->scheduledForUpdateIndex)) { 71 | foreach ($this->scheduledForUpdateIndex as $updateIndex) { 72 | $this->sendUpdateIndexMessage($updateIndex['action'], $updateIndex['id']); 73 | } 74 | 75 | $this->scheduledForUpdateIndex = []; 76 | } 77 | } 78 | 79 | public function getSubscribedEvents() 80 | { 81 | return [ 82 | 'postPersist', 83 | 'postUpdate', 84 | 'preRemove', 85 | 'postFlush' 86 | ]; 87 | } 88 | 89 | /** 90 | * @param string $action 91 | * @param $id 92 | */ 93 | private function sendUpdateIndexMessage($action, $id) 94 | { 95 | $queue = $this->context->createQueue(Commands::SYNC_INDEX_WITH_OBJECT_CHANGE); 96 | 97 | $message = $this->context->createMessage(JSON::encode([ 98 | 'action' => $action, 99 | 'model_class' => $this->modelClass, 100 | 'model_id' => $this->config['model_id'], 101 | 'id' => $id, 102 | 'index_name' => $this->config['index_name'], 103 | 'repository_method' => $this->config['repository_method'], 104 | ])); 105 | 106 | $this->context->createProducer()->send($queue, $message); 107 | } 108 | 109 | /** 110 | * @param $object 111 | * @return mixed 112 | * @throws \ReflectionException 113 | */ 114 | private function extractId($object) 115 | { 116 | $rp = (new \ReflectionClass($this->modelClass))->getProperty($this->config['model_id']); 117 | $rp->setAccessible(true); 118 | $id = $rp->getValue($object); 119 | $rp->setAccessible(false); 120 | 121 | return $id; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Doctrine/Queue/SyncIndexWithObjectChangeProcessor.php: -------------------------------------------------------------------------------- 1 | persisterRegistry = $persisterRegistry; 32 | $this->indexable = $indexable; 33 | $this->doctrine = $doctrine; 34 | } 35 | 36 | public function process(Message $message, Context $context): Result 37 | { 38 | $data = JSON::decode($message->getBody()); 39 | 40 | if (false == isset($data['action'])) { 41 | return Result::reject('The message data misses action'); 42 | } 43 | if (false == isset($data['model_class'])) { 44 | return Result::reject('The message data misses model_class'); 45 | } 46 | if (false == isset($data['id'])) { 47 | return Result::reject('The message data misses id'); 48 | } 49 | if (false == isset($data['index_name'])) { 50 | return Result::reject('The message data misses index_name'); 51 | } 52 | if (false == isset($data['repository_method'])) { 53 | return Result::reject('The message data misses repository_method'); 54 | } 55 | 56 | $action = $data['action']; 57 | $modelClass = $data['model_class']; 58 | $id = $data['id']; 59 | $index = $data['index_name']; 60 | $repositoryMethod = $data['repository_method']; 61 | 62 | $repository = $this->doctrine->getManagerForClass($modelClass)->getRepository($modelClass); 63 | $persister = $this->persisterRegistry->getPersister($index); 64 | 65 | switch ($action) { 66 | case self::UPDATE_ACTION: 67 | if (false == $object = $repository->{$repositoryMethod}($id)) { 68 | $persister->deleteById($id); 69 | 70 | return Result::ack(sprintf('The object "%s" with id "%s" could not be found.', $modelClass, $id)); 71 | } 72 | 73 | if ($persister->handlesObject($object)) { 74 | if ($this->indexable->isObjectIndexable($index, $object)) { 75 | $persister->replaceOne($object); 76 | } else { 77 | $persister->deleteOne($object); 78 | } 79 | } 80 | 81 | return Result::ack(); 82 | case self::INSERT_ACTION: 83 | if (false == $object = $repository->{$repositoryMethod}($id)) { 84 | $persister->deleteById($id); 85 | 86 | return Result::ack(sprintf('The object "%s" with id "%s" could not be found.', $modelClass, $id)); 87 | } 88 | 89 | if ($persister->handlesObject($object) && $this->indexable->isObjectIndexable($index, $object)) { 90 | $persister->insertOne($object); 91 | } 92 | 93 | return Result::ack(); 94 | case self::REMOVE_ACTION: 95 | $persister->deleteById($id); 96 | 97 | return Result::ack(); 98 | default: 99 | return Result::reject(sprintf('The action "%s" is not supported', $action)); 100 | } 101 | } 102 | 103 | public static function getSubscribedCommand(): array 104 | { 105 | return [ 106 | 'command' => Commands::SYNC_INDEX_WITH_OBJECT_CHANGE, 107 | 'queue' => Commands::SYNC_INDEX_WITH_OBJECT_CHANGE, 108 | 'prefix_queue' => false, 109 | 'exclusive' => true, 110 | ]; 111 | } 112 | 113 | public static function getSubscribedQueues(): array 114 | { 115 | return [Commands::SYNC_INDEX_WITH_OBJECT_CHANGE]; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /DependencyInjection/EnqueueElasticaExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration(new Configuration(), $configs); 24 | 25 | if (!$config['enabled']) { 26 | return; 27 | } 28 | 29 | $transport = $container->getParameterBag()->resolveValue($config['transport']); 30 | 31 | $diUtils = new DiUtils(TransportFactory::MODULE, $transport); 32 | $container->setAlias('enqueue_elastica.context', $diUtils->format('context')); 33 | 34 | $container->register('enqueue_elastica.populate_processor', PopulateProcessor::class) 35 | ->addArgument(new Reference('fos_elastica.pager_provider_registry')) 36 | ->addArgument(new Reference('fos_elastica.pager_persister_registry')) 37 | ->addArgument(new Reference('fos_elastica.index_manager')) 38 | 39 | ->addTag('enqueue.command_subscriber', ['client' => $transport]) 40 | ->addTag('enqueue.transport.processor', ['transport' => $transport]) 41 | ; 42 | 43 | $container->register('enqueue_elastica.purge_populate_queue_listener', PurgePopulateQueueListener::class) 44 | ->addArgument(new Reference('enqueue_elastica.context')) 45 | 46 | ->addTag('kernel.event_subscriber') 47 | ; 48 | 49 | $container->register('enqueue_elastica.queue_pager_persister', QueuePagerPersister::class) 50 | ->addArgument(new Reference('enqueue_elastica.context')) 51 | ->addArgument(new Reference('fos_elastica.persister_registry')) 52 | ->addArgument(new Reference('event_dispatcher')) 53 | ->addArgument(new Reference('fos_elastica.index_manager')) 54 | 55 | ->addTag('fos_elastica.pager_persister', ['persisterName' => 'queue']) 56 | ->setPublic(true) 57 | ; 58 | 59 | if (false == empty($config['doctrine']['queue_listeners'])) { 60 | $doctrineDriver = $config['doctrine']['driver']; 61 | 62 | $container->register('enqueue_elastica.doctrine.sync_index_with_object_change_processor', SyncIndexWithObjectChangeProcessor::class) 63 | ->addArgument(new Reference($this->getManagerRegistry($doctrineDriver))) 64 | ->addArgument(new Reference('fos_elastica.persister_registry')) 65 | ->addArgument(new Reference('fos_elastica.indexable')) 66 | ->addTag('enqueue.command_subscriber', ['client' => $transport]) 67 | ->addTag('enqueue.transport.processor', ['transport' => $transport]) 68 | ; 69 | 70 | foreach ($config['doctrine']['queue_listeners'] as $listenerConfig) { 71 | $listenerId = sprintf( 72 | 'enqueue_elastica.doctrine_queue_listener.%s', 73 | $listenerConfig['index_name'] 74 | ); 75 | 76 | $container->register($listenerId, SyncIndexWithObjectChangeListener::class) 77 | ->setPublic(true) 78 | ->addArgument(new Reference('enqueue_elastica.context')) 79 | ->addArgument($listenerConfig['model_class']) 80 | ->addArgument($listenerConfig) 81 | ->addTag($this->getEventSubscriber($doctrineDriver), ['connection' => $listenerConfig['connection']]) 82 | ; 83 | } 84 | } 85 | } 86 | 87 | private function getManagerRegistry(string $driver): string 88 | { 89 | switch ($driver) { 90 | case 'mongodb': 91 | return 'doctrine_mongodb'; 92 | break; 93 | case 'orm': 94 | default: 95 | return 'doctrine'; 96 | } 97 | } 98 | 99 | private function getEventSubscriber(string $driver): string 100 | { 101 | switch ($driver) { 102 | case 'mongodb': 103 | return 'doctrine_mongodb.odm.event_subscriber'; 104 | break; 105 | case 'orm': 106 | default: 107 | return 'doctrine.event_subscriber'; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Queue/PopulateProcessor.php: -------------------------------------------------------------------------------- 1 | pagerPersisterRegistry = $pagerPersisterRegistry; 30 | $this->pagerProviderRegistry = $pagerProviderRegistry; 31 | $this->indexManager = $indexManager; 32 | } 33 | 34 | public function process(Message $message, Context $context): Result 35 | { 36 | if ($message->isRedelivered()) { 37 | $replyMessage = $this->createReplyMessage($context, $message, 0,'The message was redelivered. Chances are that something has gone wrong.'); 38 | 39 | return Result::reply($replyMessage, Result::REJECT); 40 | } 41 | 42 | $objectsCount = 0; 43 | 44 | try { 45 | $data = JSON::decode($message->getBody()); 46 | 47 | if (!isset($data['options'])) { 48 | return Result::reply($this->createReplyMessage($context, $message, 0,'The message is invalid. Missing options.')); 49 | } 50 | if (!isset($data['page'])) { 51 | return Result::reply($this->createReplyMessage($context, $message, 0,'The message is invalid. Missing page.')); 52 | } 53 | if (!isset($data['options']['indexName'])) { 54 | return Result::reply($this->createReplyMessage($context, $message, 0,'The message is invalid. Missing indexName option.')); 55 | } 56 | 57 | $options = $data['options']; 58 | $options['first_page'] = $data['page']; 59 | $options['last_page'] = $data['page']; 60 | 61 | if (isset($options['realIndexName'])) { 62 | $this->indexManager->getIndex($options['indexName'])->overrideName($options['realIndexName']); 63 | } 64 | 65 | $provider = $this->pagerProviderRegistry->getProvider($options['indexName']); 66 | $pager = $provider->provide($options); 67 | $pager->setMaxPerPage($options['max_per_page']); 68 | $pager->setCurrentPage($options['first_page']); 69 | 70 | $objectsCount = count($pager->getCurrentPageResults()); 71 | 72 | $pagerPersister = $this->pagerPersisterRegistry->getPagerPersister(InPlacePagerPersister::NAME); 73 | $pagerPersister->insert($pager, $options); 74 | 75 | return Result::reply($this->createReplyMessage($context, $message, $objectsCount)); 76 | } catch (\Throwable $e) { 77 | return Result::reply($this->createExceptionReplyMessage($context, $message, $objectsCount, $e), Result::REJECT); 78 | } 79 | } 80 | 81 | private function createExceptionReplyMessage(Context $context, Message $message, int $objectsCount, \Throwable $e): Message 82 | { 83 | $errorMessage = sprintf( 84 | 'The queue processor has failed to process the message with exception: %s: %s in file %s at line %s.', 85 | get_class($e), 86 | $e->getMessage(), 87 | $e->getFile(), 88 | $e->getLine() 89 | ); 90 | 91 | return $this->createReplyMessage($context, $message, $objectsCount, $errorMessage); 92 | } 93 | 94 | private function createReplyMessage(Context $context, Message $message, int $objectsCount, string $error = null): Message 95 | { 96 | $replyMessage = $context->createMessage($message->getBody(), $message->getProperties(), $message->getHeaders()); 97 | $replyMessage->setProperty('fos-populate-objects-count', $objectsCount); 98 | 99 | if ($error) { 100 | $replyMessage->setProperty('fos-populate-error', $error); 101 | } 102 | 103 | return $replyMessage; 104 | } 105 | 106 | public static function getSubscribedCommand(): array 107 | { 108 | return [ 109 | 'command' => Commands::POPULATE, 110 | 'queue' => Commands::POPULATE, 111 | 'prefix_queue' => false, 112 | 'exclusive' => true, 113 | ]; 114 | } 115 | 116 | public static function getSubscribedQueues(): array 117 | { 118 | return [Commands::POPULATE]; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Persister/QueuePagerPersister.php: -------------------------------------------------------------------------------- 1 | context = $context; 44 | $this->dispatcher = $dispatcher; 45 | $this->registry = $registry; 46 | $this->indexManager = $indexManager; 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | public function insert(PagerInterface $pager, array $options = array()) 53 | { 54 | $pager->setMaxPerPage(empty($options['max_per_page']) ? 100 : $options['max_per_page']); 55 | 56 | $defaultOptions = [ 57 | 'max_per_page' => $pager->getMaxPerPage(), 58 | 'first_page' => $pager->getCurrentPage(), 59 | 'last_page' => $pager->getNbPages(), 60 | 'populate_queue' => Commands::POPULATE, 61 | 'populate_reply_queue' => null, 62 | 'reply_receive_timeout' => 5000, // ms 63 | 'limit_overall_reply_time' => 180 // sec 64 | ]; 65 | $index = $this->indexManager->getIndex($options['indexName']); 66 | if ($index->getName() !== $index->getOriginalName()) { 67 | $defaultOptions['realIndexName'] = $index->getName(); 68 | } 69 | 70 | $options = array_replace($defaultOptions, $options); 71 | 72 | $pager->setCurrentPage($options['first_page']); 73 | 74 | $objectPersister = $this->registry->getPersister($options['indexName']); 75 | 76 | $event = new PrePersistEvent($pager, $objectPersister, $options); 77 | $this->dispatcher->dispatch($event); 78 | $pager = $event->getPager(); 79 | $options = $event->getOptions(); 80 | 81 | $queue = $this->context->createQueue($options['populate_queue']); 82 | $replyQueue = $options['populate_reply_queue'] ? 83 | $this->context->createQueue($options['populate_reply_queue']) : 84 | $this->context->createTemporaryQueue() 85 | ; 86 | $options['populate_reply_queue'] = $replyQueue->getQueueName(); 87 | 88 | $producer = $this->context->createProducer(); 89 | 90 | $lastPage = min($options['last_page'], $pager->getNbPages()); 91 | $page = $pager->getCurrentPage(); 92 | $sentCount = 0; 93 | do { 94 | $pager->setCurrentPage($page); 95 | 96 | $filteredOptions = $options; 97 | unset( 98 | $filteredOptions['first_page'], 99 | $filteredOptions['last_page'], 100 | $filteredOptions['populate_queue'], 101 | $filteredOptions['populate_reply_queue'], 102 | $filteredOptions['reply_receive_timeout'], 103 | $filteredOptions['limit_overall_reply_time'] 104 | ); 105 | 106 | $message = $this->context->createMessage(JSON::encode([ 107 | 'options' => $filteredOptions, 108 | 'page' => $page, 109 | ])); 110 | $message->setReplyTo($replyQueue->getQueueName()); 111 | 112 | // Because of https://github.com/php-enqueue/enqueue-dev/issues/907 113 | \usleep(10); 114 | 115 | $producer->send($queue, $message); 116 | 117 | $page++; 118 | $sentCount++; 119 | } while ($page <= $lastPage); 120 | 121 | $consumer = $this->context->createConsumer($replyQueue); 122 | $limitTime = microtime(true) + $options['limit_overall_reply_time']; 123 | while ($sentCount) { 124 | if ($message = $consumer->receive($options['reply_receive_timeout'])) { 125 | $sentCount--; 126 | 127 | $data = JSON::decode($message->getBody()); 128 | 129 | $errorMessage = $message->getProperty('fos-populate-error', false); 130 | $objectsCount = (int) $message->getProperty('fos-populate-objects-count', false); 131 | 132 | $pager->setCurrentPage($data['page']); 133 | 134 | $event = new PostAsyncInsertObjectsEvent( 135 | $pager, 136 | $objectPersister, 137 | $objectsCount, 138 | $errorMessage, 139 | $data['options'] 140 | ); 141 | $this->dispatcher->dispatch($event); 142 | } 143 | 144 | if (microtime(true) > $limitTime) { 145 | throw new \LogicException(sprintf('Overall reply time (%s seconds) has been exceeded.', $options['limit_overall_reply_time'])); 146 | } 147 | } 148 | 149 | $event = new PostPersistEvent($pager, $objectPersister, $options); 150 | $this->dispatcher->dispatch($event); 151 | } 152 | } 153 | --------------------------------------------------------------------------------