DoctrineSolrBundle


Добрый день, хочу представить свой symfony 2 бандл для автоматической синхронизации Doctrine entity в Solr и последующим поиском. Бандл предназначен для работы с Solr на уровне Doctrine entity и позволяет избежать написания низкоуровневых запросов в solr. Процесс установки и подробную документацию можно посмотреть на github.

Возможности


Реализованы основные (не все) возможности поиска стандартного парсера запросов Solr:


Также реализована поддержка SuggestComponent

Пример конфигурации


После установки для начала работы требуется настроить бандл в config.yml. Пример минимальной конфигурации бандла:

mdiyakov_doctrine_solr:
    indexed_entities:
        page:
            class:  AppBundle\Entity\Page
            schema: page
            config:
                - { name: type, value: page }
    schemes:
        page:
            document_unique_field: { name: 'uid' }
            config_entity_fields:
                - {  config_field_name: 'type', document_field_name: 'type', discriminator: true }
            fields:
                - {  entity_field_name: 'id', document_field_name: 'entity_id', field_type: int, entity_primary_key: true }
                - {  entity_field_name: 'textField', document_field_name: 'text', priority: 100, suggester: true }

Как результат после каждого создания, обновления «AppBundle\Entity\Page» сущности, будут прондексированы поля «id» и «textField», а также конфигурационное поле «type». В случае удаления экземпляра сущности соответствущий solr документ будет удален.

В «schemes» секции описываютс схемы индексации. В схему индексации входит описание полей сущности (fields), конфигурационных полей (config_entity_fields) которые должны быть проиндексированы в solr. А также «document_unique_field» указывающее уникальное поле для schemes.xml в solr. Помимо этого есть необязательное поле client в случае если надо использовать несколько solr core для разных схем индексации (подробнее об этом тут). По сути каждая схема в schemes отражает конкретное solr core.

Конфигурационное поле это поле которое задается в секции «indexed_entities» в рамках конфига сущности. Его можно использовать для индексации параметров заданных в parameters.yml, например:

....            
class:  AppBundle\Entity\Page
schema: page
config:
        - { name: app_version, value: %app_version% }
        - { name: host, value: %host% }
       ...
....

Для каждой индексируемой сущности должно быть задано как минимум одно конфигурационное поле с уникальным значением относительно всех индексируемых сущностей обозначенное как discriminator: true в schemes. Например:

mdiyakov_doctrine_solr:
    indexed_entities:
        page:
            class:  AppBundle\Entity\Article
            schema: page
            config:
                - { name: type, value: article }
	news:
            class:  AppBundle\Entity\News
            schema: page
            config:
                - { name: type, value: news }
  schemes:         
        page:
            ...
            config_entity_fields:
                - {  config_field_name: 'type', document_field_name: 'discriminator', discriminator: true }

Т.к. и AppBundle\Entity\Article и AppBundle\Entity\News использует одну и ту же схему «page» то соотв. уникальность их primary key теряется т.к. могут существовать News и Article с одинаковым id. Чтобы избежать неопределенности задается конфигурационное поле используемое как дискриминатор значение которого добавляется к primary key сущности и результат записывается в уникальное поле документа.

Также есть возможность задать фильтры для индексируемых сущностей применяемых перед тем как сущность будет проиндексирована в solr. В зависимости от результата фильтра сущность может быть проиндексирована, удалена или пропущена во время индексации. Пример:

indexed_entities:
    page:
        class:  AppBundle\Entity\Page
        ...
        filters: [ big_id, published, ... ]
    news:
        class:  AppBundle\Entity\News
        ...
        filters: [ published, ... ]        
schemes:
       ....
filters:    
    fields:
        big_id: { entity_field_name: "id", entity_field_value: 3, operator: ">=" }
        published: { entity_field_name: "published", entity_field_value: true, operator: "=" }

Соотв. если после того как Page или News были созданы и например поле «published» = false то индексация будет пропущена и в solr ничего не будет записано.

Если же Page или News были обновлены и например поле «published» = false то solr документ соответствующий этому экземпляру сущности будет удален в solr

То же самое и для фильтра big_id в случае если значение поле id < 3. «big_id» и «published» это произвольные названия для фильтров, могут быть какими угодно. Также есть возможность задачть symfony service как фильтр который применяется к экземпляру сущности а не к отдельному полю, подробности тут
Для успешной индексации должны быть выполнены условия для всех фильтров.

Индексация


Индексация сущности запускается каждый раз когда выполняется $em->flush и реализуется через symfony.com/doc/current/bundles/DoctrineBundle/entity-listeners.html

В случае когда надо выполнить первичную индексацию для всех сущностей в базе, реализована консольная команда:

app/console doctrine-solr:index

Подробности ее работы и аргументы можно найти на github.

Пример поиска:


После того как конфигурация задана и индексация выполнена можно искать как в рамках отдельной сущности так и в рамках схемы. Пример:

// MyController
//...
// @var \Mdiyakov\DoctrineSolrBundle\Finder\ClassFinder $finder 
$finder = $this->get('ds.finder')->getClassFinder(Article::class);

/** @var Article[] $searchResults */
$searchResults = $finder->findSearchTermByFields($searchTerm, ['title']);

Результатом будет массив состоящий только из Article::class.

Если схема используется несколькими сущностями, например:

  indexed_entities:
        page:
            class:  AppBundle\Entity\Article
            schema: page
           ...
	news:
            class:  AppBundle\Entity\News
            schema: page
            ...
...

то можно искать по всем сущностям использующим эту схему:

$schemaFinder = $this->get('ds.finder')->getSchemaFinder('page');
$schemaFinder->addSelectClass(Article::class);
$schemaFinder->addSelectClass(News::class);

/** @var object[] **/
$result = $schemaFinder->findSearchTermByFields($searchTerm, ['title', 'category']);

Результатом будет массив состоящий из Article и News экземпляров отсортированных в соотв. с релевантностью.

Подробнее про методы поиска тут

Заключение


Это вводная статься не описывающая полностью все возможности. Если вас заинтересовал бандл привожу пару ссылок для быстрого перехода по остальным возможностям бандла:

Комментарии (10)


  1. Anton_Zh
    01.04.2018 10:04

    А как обеспечивается согласованность между Реляционной БД и индексом? Допустим, если транзакция в БД закоммитилась, а Solr оказался недоступен по каким-то причинам?


    1. KoloBango Автор
      01.04.2018 14:11

      Это обеспечивается доктриной, тем как вызываются EntityListener. Если взглянуть на \Doctrine\ORM\UnitOfWork::commit:

              ...
              try {
       	    ...
                  $this->executeInserts($class);
      	    ...
      
                  $this->executeUpdates($class);
                  ...
      
                  $conn->commit();
              } catch (Exception $e) {
                  $this->em->close();
                  $conn->rollback();
      
                  throw $e;
              }
      

      в рамках вызова executeInserts и executeUpdates происходит вызов EntityListener который запускает индексацию. В случае если solr недоступен будет выброшен эксепшн "\Solarium\Exception\HttpException" и выполнен код в секции catch и изменения в базе откатятся через rollback


      1. Anton_Zh
        02.04.2018 01:43

        Хорошо, а если в Solr все запишется удачно, а транзакция откатится, например будет выброшено исключение в commit()?


        1. KoloBango Автор
          02.04.2018 20:00

          Да это может случиться, предлагаю заглянуть в \Doctrine\DBAL\Connection::commit (у меня версия doctrine/dbal = v2.5.13):

               /*
               * @throws \Doctrine\DBAL\ConnectionException If the commit failed due to no active transaction or
               *                                            because the transaction was marked for rollback only.
               */
              public function commit()
              {
          	...
          	if ($this->_transactionNestingLevel == 0) {
          	    throw ConnectionException::noActiveTransaction();
          	}
          	if ($this->_isRollbackOnly) {
          	    throw ConnectionException::commitFailedRollbackOnly();
          	}
          	...
               }
          


          Соотв. эксепшн может быть в ситуации когда «this->_transactionNestingLevel == 0» это возможно если не был вызван $connection->beginTransaction() перед $connection->commit(). Но т.к. индексация происходит в рамках $em->flush() то за вызов "$connection->beginTransaction()" отвечает UnitOfWork, соответственно тут мы в безопасности.

          Второй случай когда может возникнуть эксепшн это "$this->_isRollbackOnly == true". Выполнение этого условия возможно в след. ситуациях (не тестировал, исхожу из чтения кода):
          1. Когда был вызван явно \Doctrine\DBAL\Connection::setRollbackOnly для текущей транзакции до \Doctrine\DBAL\Connection::commit
          2. Либо был вызван \Doctrine\DBAL\Connection::rollBack для вложенной транзакции до \Doctrine\DBAL\Connection::commit

          В обоих случаях если работа идет стандартым способом через $em->flush() оба вышеописанных случая исключены. Имхо эти эксепшены могут возникнуть если разработчик явно использует beginTranscation и endTransaction и допускает ошибку например, забыв где то вызвать beginTranscation что в принципе отслеживается практически сразу либо использует вложенные транзакции, но тут уж он должен понимать тогда что он делает и к чему это приведет.

          Более опасный источник десинхронизации на самом деле не в \Doctrine\DBAL\Connection::commit а в других EntityListener которые могут вызываться для индексируемой сущности. Если после успешной индексации в каком-либо другом EntityListener будет неотлавливаемый эксепшн то получим кейс что индексация в солр прошла но изменения в базу не записались. Тут могу только порекомендовать разработчику иметь это ввиду и не злоупотреблять EntityListener либо корректно обрабатывать ошибки в других EntityListener.

          В итоге рекомендую на каждую измененную сущность делать свой $em->flush($entity) чтобы избежать десинхронизации и не злоупотреблять EntityListener. Как и любой фреймворк/библиотека DoctrineSolrBundle может использоваться неправильно или неоптимально что может привести к ошибке. Если у вас реализуется сложная логика в EntityListener или вы используется вложенные, «ручные» или еще как то усложненные транзакции то возможно DoctrineSolrBundle не будет вам удобен. Возможно в будущей версии я сделаю чтобы можно было отключить автоиндексацию при каждом flush и возможность вызвать индексацию вручную.


          1. Anton_Zh
            03.04.2018 01:49

            Еще рассинхронизация может произойти при останове/перезапуске веб-сервера, отключении питания, сбоях в работе сети (плдключения к БД, не удалось запрос COMMIT отравить). Во всеъ этих случаях подход, используемый в бандле может привести к рассинхронизации. И дело даже не в том, что что-то не так запрограммировано — дело в принципе, положенном в основу бандла. Для отказоустойчивой синхронизации между БД и индексом нужен двухфаный коммит.


            1. KoloBango Автор
              03.04.2018 12:28

              Как вы себе представляете двухфазный коммит между postgresql и solr? Т.е. вы можете сделать например двухфазный коммит между двумя postgresql базами данных. Солр тут надо рассматривать как third-party api. Между такими разными продуктами это имхо надо городить такой свой огород что неизвестно что дешевле и лучше, пофиксить неконсистентность раз в полгода (что по сути просто пересохранить сущность) или поддерживать свой двухфазный велосипед для солр и рсубд. К тому же это разные продукты с разными целями и внутренним устройством.

              Помимо того коммит в солр это не коммит в смысле реляционной субд, коммит в солр пушит набор изменений в индексе с последнего коммита на диск и эти изменения могут включать в себя запросы от разных пользователей по разным документам. Т.е. rollback в solr откатит все изменения сделанные с последнего коммита, а т.к. это процедура выполняемая (в зависимости от настроек) раз в какой то период (в случае если это не hard commit), то мы можем откатить и измения сделанные другими пользователями, но еще не закомиченные в индекс (поправьте меня если я неправ). Соотв. вместо роллбэка нам остается только переиндексировать сущность в исходном состоянии до того как она была изменена.

              Это можно сделать след образом (протестировано):

              $entityManagerName = 'doctrine.orm.default_entity_manager';
              $page = $this->get($entityManagerName)->getRepository('AppBundle\Entity\Page')
                          ->find(3);
              
              $page->setText($someText);
              
              try {
                  $this->get($entityManagerName)->flush($page);
              } catch(\Exception $e) {
                  if (!$em->isOpen()) {
                      $this->container->get('doctrine')->resetManager($entityManagerName);
                  }
                  $page = $em->getRepository(get_class($page))->find($page->getId());
                  $this->get('mdiyakov_doctrine_solr.manager.index_process_manager')->reindex($page);
              }
              

              При таком подходе даже если в солр были сделаны изменения они откатятся к прежнему состянию.

              И соотв. если сущность создается:
              $entityManagerName = 'doctrine.orm.default_entity_manager';
              $page = new AppBundle\Entity\Page();
              ...
              $page->setText($someText);
              
              try {
                  $this->get($entityManagerName)->flush($page);
              } catch(\Exception $e) {
                  (как в примере выше)...
                  $this->get('mdiyakov_doctrine_solr.manager.index_process_manager')->remove($page);
              }
              


              Так что подход в бандле абсолютно нормальный. Имхо возможно имеет смысл сделать враппер EntityManager для перехвата эксепшна и его обработки как показано выше. Но двухфазный коммит тут не при чем. Вот еще ссылку в пример что не я один так думаю.

              Как вариант вынести индексацию в очередь, но опять же это не гарант того что изменения будут приняты в солр например и не придется руками обрабатывать ошибку. В DoctrineSolrBundle предусмотрена валидация конфига индексируемых сущностей во время инициализации основных сервисов это поможет снизить возможные ошибки во время индексации.


              1. Anton_Zh
                04.04.2018 03:30

                Прекрасно представляю. Вместо того, чтобы сразу писать в Solr пишем необходимые для индексирования данные в отдельную таблицу, назовем ее log в той же РСУБД в той же транзакции. Получится что-то вроде журнала событий. Можно в json или xml положить, будет гибко. В другом процессе (cron, например) читаем необработанные события из log индексируем в solr. Если при обработке события произойдет сбой, событие не отметится как обработанное и процедура повторится. Изменения в индексах типа Solr как правило идемпотентны и могут быть повторены (если например фейл произошел при пометке события обработанным). Вот вам и 2PC.
                А с подходом в бандле сколько не смотри на исключения, не читай код доктрины, не пиши тестов — все равно может произойти рассинхронизация. Его нельзя назвать отказоустойчивым. Аварийный останов веб-сервера тут никак не обработать, а его нужно учитывать.
                То что вы с SO привели — я с этим не совсем согласен. Eventual Consistency можно сделать. В MySQL можно вообще бинлог читать и на его основе выполнять индексирование. В Postgres тоже вроде что-то добавили в последних версиях.


  1. Anton_Zh
    02.04.2018 01:43

    deleted


  1. Fid
    02.04.2018 20:08

    Что за лохи ещё пользуются Symfony 2?


    1. KoloBango Автор
      02.04.2018 20:10

      «Лохи» у которых есть приоритетный скоуп из бизнес задач. Имхо сифони2 (2.8) еще актуальна на многих проектах и с большим легаси гнаться за последней версией симофни не целесообразно с точки зрения бизнеса. SensionLab даже выпустила критические патчи в конце ноября прошлого года для 2ой версии — habrahabr.ru/post/342782

      А вообще быковать и «лошить» это вам в другое место идти надо, а не на хабр.