@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Support multiple fulltext search clusters with 'cluster.search' config

Summary:
The goal is to make fulltext search back-ends more extensible, configurable and robust.

When this is finished it will be possible to have multiple search storage back-ends and
potentially multiple instances of each.

Individual instances can be configured with roles such as 'read', 'write' which control
which hosts will receive writes to the index and which hosts will respond to queries.

These two roles make it possible to have any combination of:

* read-only
* write-only
* read-write
* disabled

This 'roles' mechanism is extensible to add new roles should that be needed in the future.

In addition to supporting multiple elasticsearch and mysql search instances, this refactors
the connection health monitoring infrastructure from PhabricatorDatabaseHealthRecord and
utilizes the same system for monitoring the health of elasticsearch nodes. This will
allow Wikimedia's phabricator to be redundant across data centers (mysql already is,
elasticsearch should be as well).

The real-world use-case I have in mind here is writing to two indexes (two elasticsearch clusters
in different data centers) but reading from only one. Then toggling the 'read' property when
we want to migrate to the other data center (and when we migrate from elasticsearch 2.x to 5.x)

Hopefully this is useful in the upstream as well.

Remaining TODO:

* test cases
* documentation

Test Plan:
(WARNING) This will most likely require the elasticsearch index to be deleted and re-created due to schema changes.

Tested with elasticsearch versions 2.4 and 5.2 using the following config:

```lang=json
"cluster.search": [
{
"type": "elasticsearch",
"hosts": [
{
"host": "localhost",
"roles": { "read": true, "write": true }
}
],
"port": 9200,
"protocol": "http",
"path": "/phabricator",
"version": 5
},
{
"type": "mysql",
"roles": { "write": true }
}
]

Also deployed the same changes to Wikimedia's production Phabricator instance without any issues whatsoever.
```

Reviewers: epriestley, #blessed_reviewers

Reviewed By: epriestley, #blessed_reviewers

Subscribers: Korvin, epriestley

Tags: #elasticsearch, #clusters, #wikimedia

Differential Revision: https://secure.phabricator.com/D17384

authored by

Mukunda Modell and committed by
20after4
e41c25de a41d1584

+1408 -375
+10 -2
resources/sql/autopatches/20161130.search.02.rebuild.php
··· 1 1 <?php 2 2 3 - $search_engine = PhabricatorFulltextStorageEngine::loadEngine(); 4 - $use_mysql = ($search_engine instanceof PhabricatorMySQLFulltextStorageEngine); 3 + 4 + $use_mysql = false; 5 + 6 + $services = PhabricatorSearchService::getAllServices(); 7 + foreach ($services as $service) { 8 + $engine = $service->getEngine(); 9 + if ($engine instanceof PhabricatorMySQLFulltextStorageEngine) { 10 + $use_mysql = true; 11 + } 12 + } 5 13 6 14 if ($use_mysql) { 7 15 $field = new PhabricatorSearchDocumentField();
+23 -10
src/__phutil_library_map__.php
··· 2259 2259 'PhabricatorChatLogQuery' => 'applications/chatlog/query/PhabricatorChatLogQuery.php', 2260 2260 'PhabricatorChunkedFileStorageEngine' => 'applications/files/engine/PhabricatorChunkedFileStorageEngine.php', 2261 2261 'PhabricatorClusterConfigOptions' => 'applications/config/option/PhabricatorClusterConfigOptions.php', 2262 - 'PhabricatorClusterDatabasesConfigOptionType' => 'infrastructure/cluster/PhabricatorClusterDatabasesConfigOptionType.php', 2263 - 'PhabricatorClusterException' => 'infrastructure/cluster/PhabricatorClusterException.php', 2264 - 'PhabricatorClusterExceptionHandler' => 'infrastructure/cluster/PhabricatorClusterExceptionHandler.php', 2265 - 'PhabricatorClusterImpossibleWriteException' => 'infrastructure/cluster/PhabricatorClusterImpossibleWriteException.php', 2266 - 'PhabricatorClusterImproperWriteException' => 'infrastructure/cluster/PhabricatorClusterImproperWriteException.php', 2267 - 'PhabricatorClusterStrandedException' => 'infrastructure/cluster/PhabricatorClusterStrandedException.php', 2262 + 'PhabricatorClusterDatabasesConfigOptionType' => 'infrastructure/cluster/config/PhabricatorClusterDatabasesConfigOptionType.php', 2263 + 'PhabricatorClusterException' => 'infrastructure/cluster/exception/PhabricatorClusterException.php', 2264 + 'PhabricatorClusterExceptionHandler' => 'infrastructure/cluster/exception/PhabricatorClusterExceptionHandler.php', 2265 + 'PhabricatorClusterImpossibleWriteException' => 'infrastructure/cluster/exception/PhabricatorClusterImpossibleWriteException.php', 2266 + 'PhabricatorClusterImproperWriteException' => 'infrastructure/cluster/exception/PhabricatorClusterImproperWriteException.php', 2267 + 'PhabricatorClusterNoHostForRoleException' => 'infrastructure/cluster/exception/PhabricatorClusterNoHostForRoleException.php', 2268 + 'PhabricatorClusterSearchConfigOptionType' => 'infrastructure/cluster/config/PhabricatorClusterSearchConfigOptionType.php', 2269 + 'PhabricatorClusterServiceHealthRecord' => 'infrastructure/cluster/PhabricatorClusterServiceHealthRecord.php', 2270 + 'PhabricatorClusterStrandedException' => 'infrastructure/cluster/exception/PhabricatorClusterStrandedException.php', 2268 2271 'PhabricatorColumnProxyInterface' => 'applications/project/interface/PhabricatorColumnProxyInterface.php', 2269 2272 'PhabricatorColumnsEditField' => 'applications/transactions/editfield/PhabricatorColumnsEditField.php', 2270 2273 'PhabricatorCommentEditEngineExtension' => 'applications/transactions/engineextension/PhabricatorCommentEditEngineExtension.php', ··· 2310 2313 'PhabricatorConfigClusterDatabasesController' => 'applications/config/controller/PhabricatorConfigClusterDatabasesController.php', 2311 2314 'PhabricatorConfigClusterNotificationsController' => 'applications/config/controller/PhabricatorConfigClusterNotificationsController.php', 2312 2315 'PhabricatorConfigClusterRepositoriesController' => 'applications/config/controller/PhabricatorConfigClusterRepositoriesController.php', 2316 + 'PhabricatorConfigClusterSearchController' => 'applications/config/controller/PhabricatorConfigClusterSearchController.php', 2313 2317 'PhabricatorConfigCollectorsModule' => 'applications/config/module/PhabricatorConfigCollectorsModule.php', 2314 2318 'PhabricatorConfigColumnSchema' => 'applications/config/schema/PhabricatorConfigColumnSchema.php', 2315 2319 'PhabricatorConfigConfigPHIDType' => 'applications/config/phid/PhabricatorConfigConfigPHIDType.php', ··· 2543 2547 'PhabricatorDashboardViewController' => 'applications/dashboard/controller/PhabricatorDashboardViewController.php', 2544 2548 'PhabricatorDataCacheSpec' => 'applications/cache/spec/PhabricatorDataCacheSpec.php', 2545 2549 'PhabricatorDataNotAttachedException' => 'infrastructure/storage/lisk/PhabricatorDataNotAttachedException.php', 2546 - 'PhabricatorDatabaseHealthRecord' => 'infrastructure/cluster/PhabricatorDatabaseHealthRecord.php', 2547 2550 'PhabricatorDatabaseRef' => 'infrastructure/cluster/PhabricatorDatabaseRef.php', 2548 2551 'PhabricatorDatabaseRefParser' => 'infrastructure/cluster/PhabricatorDatabaseRefParser.php', 2549 2552 'PhabricatorDatabaseSetupCheck' => 'applications/config/check/PhabricatorDatabaseSetupCheck.php', ··· 2651 2654 'PhabricatorEditorMultipleSetting' => 'applications/settings/setting/PhabricatorEditorMultipleSetting.php', 2652 2655 'PhabricatorEditorSetting' => 'applications/settings/setting/PhabricatorEditorSetting.php', 2653 2656 'PhabricatorElasticFulltextStorageEngine' => 'applications/search/fulltextstorage/PhabricatorElasticFulltextStorageEngine.php', 2657 + 'PhabricatorElasticSearchHost' => 'infrastructure/cluster/search/PhabricatorElasticSearchHost.php', 2658 + 'PhabricatorElasticSearchQueryBuilder' => 'applications/search/fulltextstorage/PhabricatorElasticSearchQueryBuilder.php', 2654 2659 'PhabricatorElasticSearchSetupCheck' => 'applications/config/check/PhabricatorElasticSearchSetupCheck.php', 2655 2660 'PhabricatorEmailAddressesSettingsPanel' => 'applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php', 2656 2661 'PhabricatorEmailContentSource' => 'applications/metamta/contentsource/PhabricatorEmailContentSource.php', ··· 3073 3078 'PhabricatorMySQLConfigOptions' => 'applications/config/option/PhabricatorMySQLConfigOptions.php', 3074 3079 'PhabricatorMySQLFileStorageEngine' => 'applications/files/engine/PhabricatorMySQLFileStorageEngine.php', 3075 3080 'PhabricatorMySQLFulltextStorageEngine' => 'applications/search/fulltextstorage/PhabricatorMySQLFulltextStorageEngine.php', 3081 + 'PhabricatorMySQLSearchHost' => 'infrastructure/cluster/search/PhabricatorMySQLSearchHost.php', 3076 3082 'PhabricatorMySQLSetupCheck' => 'applications/config/check/PhabricatorMySQLSetupCheck.php', 3077 3083 'PhabricatorNamedQuery' => 'applications/search/storage/PhabricatorNamedQuery.php', 3078 3084 'PhabricatorNamedQueryQuery' => 'applications/search/query/PhabricatorNamedQueryQuery.php', ··· 3762 3768 'PhabricatorSearchApplicationStorageEnginePanel' => 'applications/search/applicationpanel/PhabricatorSearchApplicationStorageEnginePanel.php', 3763 3769 'PhabricatorSearchBaseController' => 'applications/search/controller/PhabricatorSearchBaseController.php', 3764 3770 'PhabricatorSearchCheckboxesField' => 'applications/search/field/PhabricatorSearchCheckboxesField.php', 3765 - 'PhabricatorSearchConfigOptions' => 'applications/search/config/PhabricatorSearchConfigOptions.php', 3766 3771 'PhabricatorSearchConstraintException' => 'applications/search/exception/PhabricatorSearchConstraintException.php', 3767 3772 'PhabricatorSearchController' => 'applications/search/controller/PhabricatorSearchController.php', 3768 3773 'PhabricatorSearchCustomFieldProxyField' => 'applications/search/field/PhabricatorSearchCustomFieldProxyField.php', ··· 3785 3790 'PhabricatorSearchEngineExtensionModule' => 'applications/search/engineextension/PhabricatorSearchEngineExtensionModule.php', 3786 3791 'PhabricatorSearchEngineTestCase' => 'applications/search/engine/__tests__/PhabricatorSearchEngineTestCase.php', 3787 3792 'PhabricatorSearchField' => 'applications/search/field/PhabricatorSearchField.php', 3793 + 'PhabricatorSearchHost' => 'infrastructure/cluster/search/PhabricatorSearchHost.php', 3788 3794 'PhabricatorSearchHovercardController' => 'applications/search/controller/PhabricatorSearchHovercardController.php', 3789 3795 'PhabricatorSearchIndexVersion' => 'applications/search/storage/PhabricatorSearchIndexVersion.php', 3790 3796 'PhabricatorSearchIndexVersionDestructionEngineExtension' => 'applications/search/engineextension/PhabricatorSearchIndexVersionDestructionEngineExtension.php', ··· 3804 3810 'PhabricatorSearchSchemaSpec' => 'applications/search/storage/PhabricatorSearchSchemaSpec.php', 3805 3811 'PhabricatorSearchScopeSetting' => 'applications/settings/setting/PhabricatorSearchScopeSetting.php', 3806 3812 'PhabricatorSearchSelectField' => 'applications/search/field/PhabricatorSearchSelectField.php', 3813 + 'PhabricatorSearchService' => 'infrastructure/cluster/search/PhabricatorSearchService.php', 3807 3814 'PhabricatorSearchStringListField' => 'applications/search/field/PhabricatorSearchStringListField.php', 3808 3815 'PhabricatorSearchSubscribersField' => 'applications/search/field/PhabricatorSearchSubscribersField.php', 3809 3816 'PhabricatorSearchTextField' => 'applications/search/field/PhabricatorSearchTextField.php', ··· 7303 7310 'PhabricatorClusterExceptionHandler' => 'PhabricatorRequestExceptionHandler', 7304 7311 'PhabricatorClusterImpossibleWriteException' => 'PhabricatorClusterException', 7305 7312 'PhabricatorClusterImproperWriteException' => 'PhabricatorClusterException', 7313 + 'PhabricatorClusterNoHostForRoleException' => 'Exception', 7314 + 'PhabricatorClusterSearchConfigOptionType' => 'PhabricatorConfigJSONOptionType', 7315 + 'PhabricatorClusterServiceHealthRecord' => 'Phobject', 7306 7316 'PhabricatorClusterStrandedException' => 'PhabricatorClusterException', 7307 7317 'PhabricatorColumnsEditField' => 'PhabricatorPHIDListEditField', 7308 7318 'PhabricatorCommentEditEngineExtension' => 'PhabricatorEditEngineExtension', ··· 7354 7364 'PhabricatorConfigClusterDatabasesController' => 'PhabricatorConfigController', 7355 7365 'PhabricatorConfigClusterNotificationsController' => 'PhabricatorConfigController', 7356 7366 'PhabricatorConfigClusterRepositoriesController' => 'PhabricatorConfigController', 7367 + 'PhabricatorConfigClusterSearchController' => 'PhabricatorConfigController', 7357 7368 'PhabricatorConfigCollectorsModule' => 'PhabricatorConfigModule', 7358 7369 'PhabricatorConfigColumnSchema' => 'PhabricatorConfigStorageSchema', 7359 7370 'PhabricatorConfigConfigPHIDType' => 'PhabricatorPHIDType', ··· 7624 7635 'PhabricatorDashboardViewController' => 'PhabricatorDashboardProfileController', 7625 7636 'PhabricatorDataCacheSpec' => 'PhabricatorCacheSpec', 7626 7637 'PhabricatorDataNotAttachedException' => 'Exception', 7627 - 'PhabricatorDatabaseHealthRecord' => 'Phobject', 7628 7638 'PhabricatorDatabaseRef' => 'Phobject', 7629 7639 'PhabricatorDatabaseRefParser' => 'Phobject', 7630 7640 'PhabricatorDatabaseSetupCheck' => 'PhabricatorSetupCheck', ··· 7738 7748 'PhabricatorEditorMultipleSetting' => 'PhabricatorSelectSetting', 7739 7749 'PhabricatorEditorSetting' => 'PhabricatorStringSetting', 7740 7750 'PhabricatorElasticFulltextStorageEngine' => 'PhabricatorFulltextStorageEngine', 7751 + 'PhabricatorElasticSearchHost' => 'PhabricatorSearchHost', 7741 7752 'PhabricatorElasticSearchSetupCheck' => 'PhabricatorSetupCheck', 7742 7753 'PhabricatorEmailAddressesSettingsPanel' => 'PhabricatorSettingsPanel', 7743 7754 'PhabricatorEmailContentSource' => 'PhabricatorContentSource', ··· 8208 8219 'PhabricatorMySQLConfigOptions' => 'PhabricatorApplicationConfigOptions', 8209 8220 'PhabricatorMySQLFileStorageEngine' => 'PhabricatorFileStorageEngine', 8210 8221 'PhabricatorMySQLFulltextStorageEngine' => 'PhabricatorFulltextStorageEngine', 8222 + 'PhabricatorMySQLSearchHost' => 'PhabricatorSearchHost', 8211 8223 'PhabricatorMySQLSetupCheck' => 'PhabricatorSetupCheck', 8212 8224 'PhabricatorNamedQuery' => array( 8213 8225 'PhabricatorSearchDAO', ··· 9074 9086 'PhabricatorSearchApplicationStorageEnginePanel' => 'PhabricatorApplicationConfigurationPanel', 9075 9087 'PhabricatorSearchBaseController' => 'PhabricatorController', 9076 9088 'PhabricatorSearchCheckboxesField' => 'PhabricatorSearchField', 9077 - 'PhabricatorSearchConfigOptions' => 'PhabricatorApplicationConfigOptions', 9078 9089 'PhabricatorSearchConstraintException' => 'Exception', 9079 9090 'PhabricatorSearchController' => 'PhabricatorSearchBaseController', 9080 9091 'PhabricatorSearchCustomFieldProxyField' => 'PhabricatorSearchField', ··· 9097 9108 'PhabricatorSearchEngineExtensionModule' => 'PhabricatorConfigModule', 9098 9109 'PhabricatorSearchEngineTestCase' => 'PhabricatorTestCase', 9099 9110 'PhabricatorSearchField' => 'Phobject', 9111 + 'PhabricatorSearchHost' => 'Phobject', 9100 9112 'PhabricatorSearchHovercardController' => 'PhabricatorSearchBaseController', 9101 9113 'PhabricatorSearchIndexVersion' => 'PhabricatorSearchDAO', 9102 9114 'PhabricatorSearchIndexVersionDestructionEngineExtension' => 'PhabricatorDestructionEngineExtension', ··· 9116 9128 'PhabricatorSearchSchemaSpec' => 'PhabricatorConfigSchemaSpec', 9117 9129 'PhabricatorSearchScopeSetting' => 'PhabricatorInternalSetting', 9118 9130 'PhabricatorSearchSelectField' => 'PhabricatorSearchField', 9131 + 'PhabricatorSearchService' => 'Phobject', 9119 9132 'PhabricatorSearchStringListField' => 'PhabricatorSearchField', 9120 9133 'PhabricatorSearchSubscribersField' => 'PhabricatorSearchTokenizerField', 9121 9134 'PhabricatorSearchTextField' => 'PhabricatorSearchField',
+1
src/applications/config/application/PhabricatorConfigApplication.php
··· 69 69 'databases/' => 'PhabricatorConfigClusterDatabasesController', 70 70 'notifications/' => 'PhabricatorConfigClusterNotificationsController', 71 71 'repositories/' => 'PhabricatorConfigClusterRepositoriesController', 72 + 'search/' => 'PhabricatorConfigClusterSearchController', 72 73 ), 73 74 ), 74 75 );
+58 -55
src/applications/config/check/PhabricatorElasticSearchSetupCheck.php
··· 7 7 } 8 8 9 9 protected function executeChecks() { 10 - if (!$this->shouldUseElasticSearchEngine()) { 11 - return; 12 - } 10 + $services = PhabricatorSearchService::getAllServices(); 13 11 14 - $engine = new PhabricatorElasticFulltextStorageEngine(); 15 - 16 - $index_exists = null; 17 - $index_sane = null; 18 - try { 19 - $index_exists = $engine->indexExists(); 20 - if ($index_exists) { 21 - $index_sane = $engine->indexIsSane(); 12 + foreach ($services as $service) { 13 + try { 14 + $host = $service->getAnyHostForRole('read'); 15 + } catch (PhabricatorClusterNoHostForRoleException $e) { 16 + // ignore the error 17 + continue; 22 18 } 23 - } catch (Exception $ex) { 24 - $summary = pht('Elasticsearch is not reachable as configured.'); 25 - $message = pht( 26 - 'Elasticsearch is configured (with the %s setting) but Phabricator '. 27 - 'encountered an exception when trying to test the index.'. 28 - "\n\n". 29 - '%s', 30 - phutil_tag('tt', array(), 'search.elastic.host'), 31 - phutil_tag('pre', array(), $ex->getMessage())); 19 + if ($host instanceof PhabricatorElasticSearchHost) { 20 + $index_exists = null; 21 + $index_sane = null; 22 + try { 23 + $engine = $host->getEngine(); 24 + $index_exists = $engine->indexExists(); 25 + if ($index_exists) { 26 + $index_sane = $engine->indexIsSane(); 27 + } 28 + } catch (Exception $ex) { 29 + $summary = pht('Elasticsearch is not reachable as configured.'); 30 + $message = pht( 31 + 'Elasticsearch is configured (with the %s setting) but Phabricator'. 32 + ' encountered an exception when trying to test the index.'. 33 + "\n\n". 34 + '%s', 35 + phutil_tag('tt', array(), 'cluster.search'), 36 + phutil_tag('pre', array(), $ex->getMessage())); 32 37 33 - $this->newIssue('elastic.misconfigured') 34 - ->setName(pht('Elasticsearch Misconfigured')) 35 - ->setSummary($summary) 36 - ->setMessage($message) 37 - ->addRelatedPhabricatorConfig('search.elastic.host'); 38 - return; 39 - } 38 + $this->newIssue('elastic.misconfigured') 39 + ->setName(pht('Elasticsearch Misconfigured')) 40 + ->setSummary($summary) 41 + ->setMessage($message) 42 + ->addRelatedPhabricatorConfig('cluster.search'); 43 + return; 44 + } 40 45 41 - if (!$index_exists) { 42 - $summary = pht( 43 - 'You enabled Elasticsearch but the index does not exist.'); 46 + if (!$index_exists) { 47 + $summary = pht( 48 + 'You enabled Elasticsearch but the index does not exist.'); 44 49 45 - $message = pht( 46 - 'You likely enabled search.elastic.host without creating the '. 47 - 'index. Run `./bin/search init` to correct the index.'); 50 + $message = pht( 51 + 'You likely enabled cluster.search without creating the '. 52 + 'index. Run `./bin/search init` to correct the index.'); 48 53 49 - $this 50 - ->newIssue('elastic.missing-index') 51 - ->setName(pht('Elasticsearch index Not Found')) 52 - ->setSummary($summary) 53 - ->setMessage($message) 54 - ->addRelatedPhabricatorConfig('search.elastic.host'); 55 - } else if (!$index_sane) { 56 - $summary = pht( 57 - 'Elasticsearch index exists but needs correction.'); 54 + $this 55 + ->newIssue('elastic.missing-index') 56 + ->setName(pht('Elasticsearch index Not Found')) 57 + ->setSummary($summary) 58 + ->setMessage($message) 59 + ->addRelatedPhabricatorConfig('cluster.search'); 60 + } else if (!$index_sane) { 61 + $summary = pht( 62 + 'Elasticsearch index exists but needs correction.'); 58 63 59 - $message = pht( 60 - 'Either the Phabricator schema for Elasticsearch has changed '. 61 - 'or Elasticsearch created the index automatically. Run '. 62 - '`./bin/search init` to correct the index.'); 64 + $message = pht( 65 + 'Either the Phabricator schema for Elasticsearch has changed '. 66 + 'or Elasticsearch created the index automatically. Run '. 67 + '`./bin/search init` to correct the index.'); 63 68 64 - $this 65 - ->newIssue('elastic.broken-index') 66 - ->setName(pht('Elasticsearch index Incorrect')) 67 - ->setSummary($summary) 68 - ->setMessage($message); 69 + $this 70 + ->newIssue('elastic.broken-index') 71 + ->setName(pht('Elasticsearch index Incorrect')) 72 + ->setSummary($summary) 73 + ->setMessage($message); 74 + } 75 + } 69 76 } 70 77 } 71 78 72 - protected function shouldUseElasticSearchEngine() { 73 - $search_engine = PhabricatorFulltextStorageEngine::loadEngine(); 74 - return ($search_engine instanceof PhabricatorElasticFulltextStorageEngine); 75 - } 76 79 77 80 }
+8
src/applications/config/check/PhabricatorExtraConfigSetupCheck.php
··· 198 198 'This option has been removed, you can use Dashboards to provide '. 199 199 'homepage customization. See T11533 for more details.'); 200 200 201 + $elastic_reason = pht( 202 + 'Elasticsearch is now configured with "%s".', 203 + 'cluster.search'); 204 + 201 205 $ancient_config += array( 202 206 'phid.external-loaders' => 203 207 pht( ··· 348 352 'mysql.configuration-provider' => pht( 349 353 'Phabricator now has application-level management of partitioning '. 350 354 'and replicas.'), 355 + 356 + 'search.elastic.host' => $elastic_reason, 357 + 'search.elastic.namespace' => $elastic_reason, 358 + 351 359 ); 352 360 353 361 return $ancient_config;
+7 -2
src/applications/config/check/PhabricatorMySQLSetupCheck.php
··· 379 379 } 380 380 381 381 protected function shouldUseMySQLSearchEngine() { 382 - $search_engine = PhabricatorFulltextStorageEngine::loadEngine(); 383 - return ($search_engine instanceof PhabricatorMySQLFulltextStorageEngine); 382 + $services = PhabricatorSearchService::getAllServices(); 383 + foreach ($services as $service) { 384 + if ($service instanceof PhabricatorMySQLSearchHost) { 385 + return true; 386 + } 387 + } 388 + return false; 384 389 } 385 390 386 391 }
+129
src/applications/config/controller/PhabricatorConfigClusterSearchController.php
··· 1 + <?php 2 + 3 + final class PhabricatorConfigClusterSearchController 4 + extends PhabricatorConfigController { 5 + 6 + public function handleRequest(AphrontRequest $request) { 7 + $nav = $this->buildSideNavView(); 8 + $nav->selectFilter('cluster/search/'); 9 + 10 + $title = pht('Cluster Search'); 11 + $doc_href = PhabricatorEnv::getDoclink('Cluster: Search'); 12 + 13 + $header = id(new PHUIHeaderView()) 14 + ->setHeader($title) 15 + ->setProfileHeader(true) 16 + ->addActionLink( 17 + id(new PHUIButtonView()) 18 + ->setIcon('fa-book') 19 + ->setHref($doc_href) 20 + ->setTag('a') 21 + ->setText(pht('Documentation'))); 22 + 23 + $crumbs = $this 24 + ->buildApplicationCrumbs($nav) 25 + ->addTextCrumb($title) 26 + ->setBorder(true); 27 + 28 + $search_status = $this->buildClusterSearchStatus(); 29 + 30 + $content = id(new PhabricatorConfigPageView()) 31 + ->setHeader($header) 32 + ->setContent($search_status); 33 + 34 + return $this->newPage() 35 + ->setTitle($title) 36 + ->setCrumbs($crumbs) 37 + ->setNavigation($nav) 38 + ->appendChild($content) 39 + ->addClass('white-background'); 40 + } 41 + 42 + private function buildClusterSearchStatus() { 43 + $viewer = $this->getViewer(); 44 + 45 + $services = PhabricatorSearchService::getAllServices(); 46 + Javelin::initBehavior('phabricator-tooltips'); 47 + 48 + $view = array(); 49 + foreach ($services as $service) { 50 + $view[] = $this->renderStatusView($service); 51 + } 52 + return $view; 53 + } 54 + 55 + private function renderStatusView($service) { 56 + $head = array_merge( 57 + array(pht('Type')), 58 + array_keys($service->getStatusViewColumns()), 59 + array(pht('Status'))); 60 + 61 + $rows = array(); 62 + 63 + $status_map = PhabricatorSearchService::getConnectionStatusMap(); 64 + $stats = false; 65 + $stats_view = false; 66 + 67 + foreach ($service->getHosts() as $host) { 68 + try { 69 + $status = $host->getConnectionStatus(); 70 + $status = idx($status_map, $status, array()); 71 + } catch (Exception $ex) { 72 + $status['icon'] = 'fa-times'; 73 + $status['label'] = pht('Connection Error'); 74 + $status['color'] = 'red'; 75 + $host->didHealthCheck(false); 76 + } 77 + 78 + if (!$stats_view) { 79 + try { 80 + $stats = $host->getEngine()->getIndexStats($host); 81 + $stats_view = $this->renderIndexStats($stats); 82 + } catch (Exception $e) { 83 + $stats_view = false; 84 + } 85 + } 86 + 87 + $type_icon = 'fa-search sky'; 88 + $type_tip = $host->getDisplayName(); 89 + 90 + $type_icon = id(new PHUIIconView()) 91 + ->setIcon($type_icon); 92 + $status_view = array( 93 + id(new PHUIIconView())->setIcon($status['icon'].' '.$status['color']), 94 + ' ', 95 + $status['label'], 96 + ); 97 + $row = array(array($type_icon, ' ', $type_tip)); 98 + $row = array_merge($row, array_values( 99 + $host->getStatusViewColumns())); 100 + $row[] = $status_view; 101 + $rows[] = $row; 102 + } 103 + 104 + $table = id(new AphrontTableView($rows)) 105 + ->setNoDataString(pht('No search servers are configured.')) 106 + ->setHeaders($head); 107 + 108 + $view = id(new PHUIObjectBoxView()) 109 + ->setHeaderText($service->getDisplayName()) 110 + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 111 + ->setTable($table); 112 + 113 + if ($stats_view) { 114 + $view->addPropertyList($stats_view); 115 + } 116 + return $view; 117 + } 118 + 119 + private function renderIndexStats($stats) { 120 + $view = id(new PHUIPropertyListView()); 121 + if ($stats !== false) { 122 + foreach ($stats as $label => $val) { 123 + $view->addProperty($label, $val); 124 + } 125 + } 126 + return $view; 127 + } 128 + 129 + }
+3
src/applications/config/controller/PhabricatorConfigController.php
··· 42 42 pht('Notification Servers'), null, 'fa-bell-o'); 43 43 $nav->addFilter('cluster/repositories/', 44 44 pht('Repository Servers'), null, 'fa-code'); 45 + $nav->addFilter('cluster/search/', 46 + pht('Search Servers'), null, 'fa-search'); 45 47 $nav->addLabel(pht('Modules')); 48 + 46 49 47 50 $modules = PhabricatorConfigModule::getAllModules(); 48 51 foreach ($modules as $key => $module) {
+26
src/applications/config/option/PhabricatorClusterConfigOptions.php
··· 38 38 $intro_href = PhabricatorEnv::getDoclink('Clustering Introduction'); 39 39 $intro_name = pht('Clustering Introduction'); 40 40 41 + $search_type = 'custom:PhabricatorClusterSearchConfigOptionType'; 42 + $search_help = $this->deformat(pht(<<<EOTEXT 43 + Define one or more fulltext storage services. Here you can configure which 44 + hosts will handle fulltext search queries and indexing. For help with 45 + configuring fulltext search clusters, see **[[ %s | %s ]]** in the 46 + documentation. 47 + EOTEXT 48 + , 49 + PhabricatorEnv::getDoclink('Cluster: Search'), 50 + pht('Cluster: Search'))); 51 + 41 52 return array( 42 53 $this->newOption('cluster.addresses', 'list<string>', array()) 43 54 ->setLocked(true) ··· 114 125 ->setSummary( 115 126 pht('Configure database read replicas.')) 116 127 ->setDescription($databases_help), 128 + $this->newOption('cluster.search', $search_type, array()) 129 + ->setLocked(true) 130 + ->setSummary( 131 + pht('Configure full-text search services.')) 132 + ->setDescription($search_help) 133 + ->setDefault( 134 + array( 135 + array( 136 + 'type' => 'mysql', 137 + 'roles' => array( 138 + 'read' => true, 139 + 'write' => true, 140 + ), 141 + ), 142 + )), 117 143 ); 118 144 } 119 145
+4 -4
src/applications/maniphest/query/ManiphestTaskQuery.php
··· 513 513 ->setEngineClassName('PhabricatorSearchApplicationSearchEngine') 514 514 ->setParameter('query', $this->fullTextSearch); 515 515 516 - // NOTE: Setting this to something larger than 2^53 will raise errors in 516 + // NOTE: Setting this to something larger than 10,000 will raise errors in 517 517 // ElasticSearch, and billions of results won't fit in memory anyway. 518 - $fulltext_query->setParameter('limit', 100000); 518 + $fulltext_query->setParameter('limit', 10000); 519 519 $fulltext_query->setParameter('types', 520 520 array(ManiphestTaskPHIDType::TYPECONST)); 521 521 522 - $engine = PhabricatorFulltextStorageEngine::loadEngine(); 523 - $fulltext_results = $engine->executeSearch($fulltext_query); 522 + $fulltext_results = PhabricatorSearchService::executeSearch( 523 + $fulltext_query); 524 524 525 525 if (empty($fulltext_results)) { 526 526 $fulltext_results = array(null);
+9 -1
src/applications/project/search/PhabricatorProjectFulltextEngine.php
··· 10 10 $project = $object; 11 11 $project->updateDatasourceTokens(); 12 12 13 - $document->setDocumentTitle($project->getName()); 13 + $document->setDocumentTitle($project->getDisplayName()); 14 + $document->addField(PhabricatorSearchDocumentFieldType::FIELD_KEYWORDS, 15 + $project->getPrimarySlug()); 16 + try { 17 + $slugs = $project->getSlugs(); 18 + foreach ($slugs as $slug) {} 19 + } catch (PhabricatorDataNotAttachedException $e) { 20 + // ignore 21 + } 14 22 15 23 $document->addRelationship( 16 24 $project->isArchived()
-35
src/applications/search/config/PhabricatorSearchConfigOptions.php
··· 1 - <?php 2 - 3 - final class PhabricatorSearchConfigOptions 4 - extends PhabricatorApplicationConfigOptions { 5 - 6 - public function getName() { 7 - return pht('Search'); 8 - } 9 - 10 - public function getDescription() { 11 - return pht('Options relating to Search.'); 12 - } 13 - 14 - public function getIcon() { 15 - return 'fa-search'; 16 - } 17 - 18 - public function getGroup() { 19 - return 'apps'; 20 - } 21 - 22 - public function getOptions() { 23 - return array( 24 - $this->newOption('search.elastic.host', 'string', null) 25 - ->setLocked(true) 26 - ->setDescription(pht('Elastic Search host.')) 27 - ->addExample('http://elastic.example.com:9200/', pht('Valid Setting')), 28 - $this->newOption('search.elastic.namespace', 'string', 'phabricator') 29 - ->setLocked(true) 30 - ->setDescription(pht('Elastic Search index.')) 31 - ->addExample('phabricator2', pht('Valid Setting')), 32 - ); 33 - } 34 - 35 - }
+1
src/applications/search/constants/PhabricatorSearchDocumentFieldType.php
··· 5 5 const FIELD_TITLE = 'titl'; 6 6 const FIELD_BODY = 'body'; 7 7 const FIELD_COMMENT = 'cmnt'; 8 + const FIELD_KEYWORDS = 'kwrd'; 8 9 9 10 }
+2 -2
src/applications/search/engine/__tests__/PhabricatorSearchEngineTestCase.php
··· 3 3 final class PhabricatorSearchEngineTestCase extends PhabricatorTestCase { 4 4 5 5 public function testLoadAllEngines() { 6 - PhabricatorFulltextStorageEngine::loadAllEngines(); 7 - $this->assertTrue(true); 6 + $services = PhabricatorSearchService::getAllServices(); 7 + $this->assertTrue(!empty($services)); 8 8 } 9 9 10 10 }
+256 -155
src/applications/search/fulltextstorage/PhabricatorElasticFulltextStorageEngine.php
··· 1 1 <?php 2 2 3 - final class PhabricatorElasticFulltextStorageEngine 3 + class PhabricatorElasticFulltextStorageEngine 4 4 extends PhabricatorFulltextStorageEngine { 5 5 6 - private $uri; 7 6 private $index; 8 7 private $timeout; 8 + private $version; 9 9 10 - public function __construct() { 11 - $this->uri = PhabricatorEnv::getEnvConfig('search.elastic.host'); 12 - $this->index = PhabricatorEnv::getEnvConfig('search.elastic.namespace'); 10 + public function setService(PhabricatorSearchService $service) { 11 + $this->service = $service; 12 + $config = $service->getConfig(); 13 + $index = idx($config, 'path', '/phabricator'); 14 + $this->index = str_replace('/', '', $index); 15 + $this->timeout = idx($config, 'timeout', 15); 16 + $this->version = (int)idx($config, 'version', 5); 17 + return $this; 13 18 } 14 19 15 20 public function getEngineIdentifier() { 16 21 return 'elasticsearch'; 17 22 } 18 23 19 - public function getEnginePriority() { 20 - return 10; 24 + public function getTimestampField() { 25 + return $this->version < 2 ? 26 + '_timestamp' : 'lastModified'; 21 27 } 22 28 23 - public function isEnabled() { 24 - return (bool)$this->uri; 29 + public function getTextFieldType() { 30 + return $this->version >= 5 31 + ? 'text' : 'string'; 25 32 } 26 33 27 - public function setURI($uri) { 28 - $this->uri = $uri; 29 - return $this; 34 + public function getHostType() { 35 + return new PhabricatorElasticSearchHost($this); 30 36 } 31 37 32 - public function setIndex($index) { 33 - $this->index = $index; 34 - return $this; 38 + /** 39 + * @return PhabricatorElasticSearchHost 40 + */ 41 + public function getHostForRead() { 42 + return $this->getService()->getAnyHostForRole('read'); 43 + } 44 + 45 + /** 46 + * @return PhabricatorElasticSearchHost 47 + */ 48 + public function getHostForWrite() { 49 + return $this->getService()->getAnyHostForRole('write'); 35 50 } 36 51 37 52 public function setTimeout($timeout) { ··· 39 54 return $this; 40 55 } 41 56 42 - public function getURI() { 43 - return $this->uri; 57 + public function getTimeout() { 58 + return $this->timeout; 44 59 } 45 60 46 - public function getIndex() { 47 - return $this->index; 48 - } 49 - 50 - public function getTimeout() { 51 - return $this->timeout; 61 + public function getTypeConstants($class) { 62 + $relationship_class = new ReflectionClass($class); 63 + $typeconstants = $relationship_class->getConstants(); 64 + return array_unique(array_values($typeconstants)); 52 65 } 53 66 54 67 public function reindexAbstractDocument( 55 68 PhabricatorSearchAbstractDocument $doc) { 56 69 70 + $host = $this->getHostForWrite(); 71 + 57 72 $type = $doc->getDocumentType(); 58 73 $phid = $doc->getPHID(); 59 74 $handle = id(new PhabricatorHandleQuery()) ··· 61 76 ->withPHIDs(array($phid)) 62 77 ->executeOne(); 63 78 79 + $timestamp_key = $this->getTimestampField(); 80 + 64 81 // URL is not used internally but it can be useful externally. 65 82 $spec = array( 66 83 'title' => $doc->getDocumentTitle(), 67 84 'url' => PhabricatorEnv::getProductionURI($handle->getURI()), 68 85 'dateCreated' => $doc->getDocumentCreated(), 69 - '_timestamp' => $doc->getDocumentModified(), 70 - 'field' => array(), 71 - 'relationship' => array(), 86 + $timestamp_key => $doc->getDocumentModified(), 72 87 ); 73 88 74 89 foreach ($doc->getFieldData() as $field) { 75 - $spec['field'][] = array_combine(array('type', 'corpus', 'aux'), $field); 90 + list($field_name, $corpus, $aux) = $field; 91 + if (!isset($spec[$field_name])) { 92 + $spec[$field_name] = array($corpus); 93 + } else { 94 + $spec[$field_name][] = $corpus; 95 + } 96 + if ($aux != null) { 97 + $spec[$field_name][] = $aux; 98 + } 76 99 } 77 100 78 - foreach ($doc->getRelationshipData() as $relationship) { 79 - list($rtype, $to_phid, $to_type, $time) = $relationship; 80 - $spec['relationship'][$rtype][] = array( 81 - 'phid' => $to_phid, 82 - 'phidType' => $to_type, 83 - 'when' => (int)$time, 84 - ); 101 + foreach ($doc->getRelationshipData() as $field) { 102 + list($field_name, $related_phid, $rtype, $time) = $field; 103 + if (!isset($spec[$field_name])) { 104 + $spec[$field_name] = array($related_phid); 105 + } else { 106 + $spec[$field_name][] = $related_phid; 107 + } 108 + if ($time) { 109 + $spec[$field_name.'_ts'] = $time; 110 + } 85 111 } 86 112 87 - $this->executeRequest("/{$type}/{$phid}/", $spec, 'PUT'); 113 + $this->executeRequest($host, "/{$type}/{$phid}/", $spec, 'PUT'); 88 114 } 89 115 90 116 public function reconstructDocument($phid) { 91 117 $type = phid_get_type($phid); 92 - 93 - $response = $this->executeRequest("/{$type}/{$phid}", array()); 118 + $host = $this->getHostForRead(); 119 + $response = $this->executeRequest($host, "/{$type}/{$phid}", array()); 94 120 95 121 if (empty($response['exists'])) { 96 122 return null; ··· 103 129 $doc->setDocumentType($response['_type']); 104 130 $doc->setDocumentTitle($hit['title']); 105 131 $doc->setDocumentCreated($hit['dateCreated']); 106 - $doc->setDocumentModified($hit['_timestamp']); 132 + $doc->setDocumentModified($hit[$this->getTimestampField()]); 107 133 108 134 foreach ($hit['field'] as $fdef) { 109 - $doc->addField($fdef['type'], $fdef['corpus'], $fdef['aux']); 135 + $field_type = $fdef['type']; 136 + $doc->addField($field_type, $hit[$field_type], $fdef['aux']); 110 137 } 111 138 112 139 foreach ($hit['relationship'] as $rtype => $rships) { ··· 123 150 } 124 151 125 152 private function buildSpec(PhabricatorSavedQuery $query) { 126 - $spec = array(); 127 - $filter = array(); 128 - $title_spec = array(); 153 + $q = new PhabricatorElasticSearchQueryBuilder('bool'); 154 + $query_string = $query->getParameter('query'); 155 + if (strlen($query_string)) { 156 + $fields = $this->getTypeConstants('PhabricatorSearchDocumentFieldType'); 129 157 130 - if (strlen($query->getParameter('query'))) { 131 - $spec[] = array( 158 + // Build a simple_query_string query over all fields that must match all 159 + // of the words in the search string. 160 + $q->addMustClause(array( 132 161 'simple_query_string' => array( 133 - 'query' => $query->getParameter('query'), 134 - 'fields' => array('field.corpus'), 162 + 'query' => $query_string, 163 + 'fields' => array( 164 + '_all', 165 + ), 166 + 'default_operator' => 'OR', 135 167 ), 136 - ); 168 + )); 137 169 138 - $title_spec = array( 170 + // This second query clause is "SHOULD' so it only affects ranking of 171 + // documents which already matched the Must clause. This amplifies the 172 + // score of documents which have an exact match on title, body 173 + // or comments. 174 + $q->addShouldClause(array( 139 175 'simple_query_string' => array( 140 - 'query' => $query->getParameter('query'), 141 - 'fields' => array('title'), 176 + 'query' => $query_string, 177 + 'fields' => array( 178 + PhabricatorSearchDocumentFieldType::FIELD_TITLE.'^4', 179 + PhabricatorSearchDocumentFieldType::FIELD_BODY.'^3', 180 + PhabricatorSearchDocumentFieldType::FIELD_COMMENT.'^1.2', 181 + ), 182 + 'analyzer' => 'english_exact', 183 + 'default_operator' => 'and', 142 184 ), 143 - ); 185 + )); 186 + 144 187 } 145 188 146 189 $exclude = $query->getParameter('exclude'); 147 190 if ($exclude) { 148 - $filter[] = array( 191 + $q->addFilterClause(array( 149 192 'not' => array( 150 193 'ids' => array( 151 194 'values' => array($exclude), 152 195 ), 153 196 ), 154 - ); 197 + )); 155 198 } 156 199 157 200 $relationship_map = array( ··· 176 219 $include_closed = !empty($statuses[$rel_closed]); 177 220 178 221 if ($include_open && !$include_closed) { 179 - $relationship_map[$rel_open] = true; 222 + $q->addExistsClause($rel_open); 180 223 } else if (!$include_open && $include_closed) { 181 - $relationship_map[$rel_closed] = true; 224 + $q->addExistsClause($rel_closed); 182 225 } 183 226 184 227 if ($query->getParameter('withUnowned')) { 185 - $relationship_map[$rel_unowned] = true; 228 + $q->addExistsClause($rel_unowned); 186 229 } 187 230 188 231 $rel_owner = PhabricatorSearchRelationship::RELATIONSHIP_OWNER; 189 232 if ($query->getParameter('withAnyOwner')) { 190 - $relationship_map[$rel_owner] = true; 233 + $q->addExistsClause($rel_owner); 191 234 } else { 192 235 $owner_phids = $query->getParameter('ownerPHIDs', array()); 193 - $relationship_map[$rel_owner] = $owner_phids; 236 + if (count($owner_phids)) { 237 + $q->addTermsClause($rel_owner, $owner_phids); 238 + } 194 239 } 195 240 196 - foreach ($relationship_map as $field => $param) { 197 - if (is_array($param) && $param) { 198 - $should = array(); 199 - foreach ($param as $val) { 200 - $should[] = array( 201 - 'match' => array( 202 - "relationship.{$field}.phid" => array( 203 - 'query' => $val, 204 - 'type' => 'phrase', 205 - ), 206 - ), 207 - ); 208 - } 209 - // We couldn't solve it by minimum_number_should_match because it can 210 - // match multiple owners without matching author. 211 - $spec[] = array('bool' => array('should' => $should)); 212 - } else if ($param) { 213 - $filter[] = array( 214 - 'exists' => array( 215 - 'field' => "relationship.{$field}.phid", 216 - ), 217 - ); 241 + foreach ($relationship_map as $field => $phids) { 242 + if (is_array($phids) && !empty($phids)) { 243 + $q->addTermsClause($field, $phids); 218 244 } 219 245 } 220 246 221 - if ($spec) { 222 - $spec = array('query' => array('bool' => array('must' => $spec))); 223 - if ($title_spec) { 224 - $spec['query']['bool']['should'] = $title_spec; 225 - } 247 + if (!$q->getClauseCount('must')) { 248 + $q->addMustClause(array('match_all' => array('boost' => 1 ))); 226 249 } 227 250 228 - if ($filter) { 229 - $filter = array('filter' => array('and' => $filter)); 230 - if (!$spec) { 231 - $spec = array('query' => array('match_all' => new stdClass())); 232 - } 233 - $spec = array( 234 - 'query' => array( 235 - 'filtered' => $spec + $filter, 236 - ), 237 - ); 238 - } 251 + $spec = array( 252 + '_source' => false, 253 + 'query' => array( 254 + 'bool' => $q->toArray(), 255 + ), 256 + ); 257 + 239 258 240 259 if (!$query->getParameter('query')) { 241 260 $spec['sort'] = array( ··· 243 262 ); 244 263 } 245 264 246 - $spec['from'] = (int)$query->getParameter('offset', 0); 247 - $spec['size'] = (int)$query->getParameter('limit', 25); 265 + $offset = (int)$query->getParameter('offset', 0); 266 + $limit = (int)$query->getParameter('limit', 101); 267 + if ($offset + $limit > 10000) { 268 + throw new Exception(pht( 269 + 'Query offset is too large. offset+limit=%s (max=%s)', 270 + $offset + $limit, 271 + 10000)); 272 + } 273 + $spec['from'] = $offset; 274 + $spec['size'] = $limit; 248 275 249 276 return $spec; 250 277 } ··· 261 288 // some bigger index). Use '/$types/_search' instead. 262 289 $uri = '/'.implode(',', $types).'/_search'; 263 290 264 - try { 265 - $response = $this->executeRequest($uri, $this->buildSpec($query)); 266 - } catch (HTTPFutureHTTPResponseStatus $ex) { 267 - // elasticsearch probably uses Lucene query syntax: 268 - // http://lucene.apache.org/core/3_6_1/queryparsersyntax.html 269 - // Try literal search if operator search fails. 270 - if (!strlen($query->getParameter('query'))) { 271 - throw $ex; 291 + $spec = $this->buildSpec($query); 292 + $exceptions = array(); 293 + 294 + foreach ($this->service->getAllHostsForRole('read') as $host) { 295 + try { 296 + $response = $this->executeRequest($host, $uri, $spec); 297 + $phids = ipull($response['hits']['hits'], '_id'); 298 + return $phids; 299 + } catch (Exception $e) { 300 + $exceptions[] = $e; 272 301 } 273 - $query = clone $query; 274 - $query->setParameter( 275 - 'query', 276 - addcslashes( 277 - $query->getParameter('query'), '+-&|!(){}[]^"~*?:\\')); 278 - $response = $this->executeRequest($uri, $this->buildSpec($query)); 279 302 } 280 - 281 - $phids = ipull($response['hits']['hits'], '_id'); 282 - return $phids; 303 + throw new PhutilAggregateException('All search hosts failed:', $exceptions); 283 304 } 284 305 285 - public function indexExists() { 306 + public function indexExists(PhabricatorElasticSearchHost $host = null) { 307 + if (!$host) { 308 + $host = $this->getHostForRead(); 309 + } 286 310 try { 287 - return (bool)$this->executeRequest('/_status/', array()); 311 + if ($this->version >= 5) { 312 + $uri = '/_stats/'; 313 + $res = $this->executeRequest($host, $uri, array()); 314 + return isset($res['indices']['phabricator']); 315 + } else if ($this->version >= 2) { 316 + $uri = ''; 317 + } else { 318 + $uri = '/_status/'; 319 + } 320 + return (bool)$this->executeRequest($host, $uri, array()); 288 321 } catch (HTTPFutureHTTPResponseStatus $e) { 289 322 if ($e->getStatusCode() == 404) { 290 323 return false; ··· 299 332 'index' => array( 300 333 'auto_expand_replicas' => '0-2', 301 334 'analysis' => array( 302 - 'filter' => array( 303 - 'trigrams_filter' => array( 304 - 'min_gram' => 3, 305 - 'type' => 'ngram', 306 - 'max_gram' => 3, 307 - ), 308 - ), 309 335 'analyzer' => array( 310 - 'custom_trigrams' => array( 311 - 'type' => 'custom', 312 - 'filter' => array( 313 - 'lowercase', 314 - 'kstem', 315 - 'trigrams_filter', 316 - ), 336 + 'english_exact' => array( 317 337 'tokenizer' => 'standard', 338 + 'filter' => array('lowercase'), 318 339 ), 319 340 ), 320 341 ), 321 342 ), 322 343 ); 323 344 324 - $types = array_keys( 345 + $fields = $this->getTypeConstants('PhabricatorSearchDocumentFieldType'); 346 + $relationships = $this->getTypeConstants('PhabricatorSearchRelationship'); 347 + 348 + $doc_types = array_keys( 325 349 PhabricatorSearchApplicationSearchEngine::getIndexableDocumentTypes()); 326 - foreach ($types as $type) { 327 - // Use the custom trigram analyzer for the corpus of text 328 - $data['mappings'][$type]['properties']['field']['properties']['corpus'] = 329 - array('type' => 'string', 'analyzer' => 'custom_trigrams'); 350 + 351 + $text_type = $this->getTextFieldType(); 352 + 353 + foreach ($doc_types as $type) { 354 + $properties = array(); 355 + foreach ($fields as $field) { 356 + // Use the custom analyzer for the corpus of text 357 + $properties[$field] = array( 358 + 'type' => $text_type, 359 + 'analyzer' => 'english_exact', 360 + 'search_analyzer' => 'english', 361 + 'search_quote_analyzer' => 'english_exact', 362 + ); 363 + } 364 + 365 + if ($this->version < 5) { 366 + foreach ($relationships as $rel) { 367 + $properties[$rel] = array( 368 + 'type' => 'string', 369 + 'index' => 'not_analyzed', 370 + 'include_in_all' => false, 371 + ); 372 + $properties[$rel.'_ts'] = array( 373 + 'type' => 'date', 374 + 'include_in_all' => false, 375 + ); 376 + } 377 + } else { 378 + foreach ($relationships as $rel) { 379 + $properties[$rel] = array( 380 + 'type' => 'keyword', 381 + 'include_in_all' => false, 382 + 'doc_values' => false, 383 + ); 384 + $properties[$rel.'_ts'] = array( 385 + 'type' => 'date', 386 + 'include_in_all' => false, 387 + ); 388 + } 389 + } 330 390 331 391 // Ensure we have dateCreated since the default query requires it 332 - $data['mappings'][$type]['properties']['dateCreated']['type'] = 'string'; 392 + $properties['dateCreated']['type'] = 'date'; 393 + $properties['lastModified']['type'] = 'date'; 394 + 395 + $data['mappings'][$type]['properties'] = $properties; 333 396 } 334 - 335 397 return $data; 336 398 } 337 399 338 - public function indexIsSane() { 339 - if (!$this->indexExists()) { 400 + public function indexIsSane(PhabricatorElasticSearchHost $host = null) { 401 + if (!$host) { 402 + $host = $this->getHostForRead(); 403 + } 404 + if (!$this->indexExists($host)) { 340 405 return false; 341 406 } 342 - 343 - $cur_mapping = $this->executeRequest('/_mapping/', array()); 344 - $cur_settings = $this->executeRequest('/_settings/', array()); 407 + $cur_mapping = $this->executeRequest($host, '/_mapping/', array()); 408 + $cur_settings = $this->executeRequest($host, '/_settings/', array()); 345 409 $actual = array_merge($cur_settings[$this->index], 346 410 $cur_mapping[$this->index]); 347 411 348 - return $this->check($actual, $this->getIndexConfiguration()); 412 + $res = $this->check($actual, $this->getIndexConfiguration()); 413 + return $res; 349 414 } 350 415 351 416 /** ··· 355 420 * @param $required array 356 421 * @return bool 357 422 */ 358 - private function check($actual, $required) { 423 + private function check($actual, $required, $path = '') { 359 424 foreach ($required as $key => $value) { 360 425 if (!array_key_exists($key, $actual)) { 361 426 if ($key === '_all') { ··· 369 434 if (!is_array($actual[$key])) { 370 435 return false; 371 436 } 372 - if (!$this->check($actual[$key], $value)) { 437 + if (!$this->check($actual[$key], $value, $path.'.'.$key)) { 373 438 return false; 374 439 } 375 440 continue; ··· 403 468 } 404 469 405 470 public function initIndex() { 471 + $host = $this->getHostForWrite(); 406 472 if ($this->indexExists()) { 407 - $this->executeRequest('/', array(), 'DELETE'); 473 + $this->executeRequest($host, '/', array(), 'DELETE'); 408 474 } 409 475 $data = $this->getIndexConfiguration(); 410 - $this->executeRequest('/', $data, 'PUT'); 476 + $this->executeRequest($host, '/', $data, 'PUT'); 477 + } 478 + 479 + public function getIndexStats(PhabricatorElasticSearchHost $host = null) { 480 + if ($this->version < 2) { 481 + return false; 482 + } 483 + if (!$host) { 484 + $host = $this->getHostForRead(); 485 + } 486 + $uri = '/_stats/'; 487 + $host = $this->getHostForRead(); 488 + 489 + $res = $this->executeRequest($host, $uri, array()); 490 + $stats = $res['indices'][$this->index]; 491 + return array( 492 + pht('Queries') => 493 + idxv($stats, array('primaries', 'search', 'query_total')), 494 + pht('Documents') => 495 + idxv($stats, array('total', 'docs', 'count')), 496 + pht('Deleted') => 497 + idxv($stats, array('total', 'docs', 'deleted')), 498 + pht('Storage Used') => 499 + phutil_format_bytes(idxv($stats, 500 + array('total', 'store', 'size_in_bytes'))), 501 + ); 411 502 } 412 503 413 - private function executeRequest($path, array $data, $method = 'GET') { 414 - $uri = new PhutilURI($this->uri); 415 - $uri->setPath($this->index); 416 - $uri->appendPath($path); 504 + private function executeRequest(PhabricatorElasticSearchHost $host, $path, 505 + array $data, $method = 'GET') { 506 + 507 + $uri = $host->getURI($path); 417 508 $data = json_encode($data); 418 - 419 509 $future = new HTTPSFuture($uri, $data); 420 510 if ($method != 'GET') { 421 511 $future->setMethod($method); ··· 423 513 if ($this->getTimeout()) { 424 514 $future->setTimeout($this->getTimeout()); 425 515 } 426 - list($body) = $future->resolvex(); 516 + try { 517 + list($body) = $future->resolvex(); 518 + } catch (HTTPFutureResponseStatus $ex) { 519 + if ($ex->isTimeout() || (int)$ex->getStatusCode() > 499) { 520 + $host->didHealthCheck(false); 521 + } 522 + throw $ex; 523 + } 427 524 428 525 if ($method != 'GET') { 429 526 return null; 430 527 } 431 528 432 529 try { 433 - return phutil_json_decode($body); 530 + $data = phutil_json_decode($body); 531 + $host->didHealthCheck(true); 532 + return $data; 434 533 } catch (PhutilJSONParserException $ex) { 534 + $host->didHealthCheck(false); 435 535 throw new PhutilProxyException( 436 536 pht('ElasticSearch server returned invalid JSON!'), 437 537 $ex); 438 538 } 539 + 439 540 } 440 541 441 542 }
+78
src/applications/search/fulltextstorage/PhabricatorElasticSearchQueryBuilder.php
··· 1 + <?php 2 + 3 + class PhabricatorElasticSearchQueryBuilder { 4 + protected $name; 5 + protected $clauses = array(); 6 + 7 + 8 + public function getClauses($termkey = null) { 9 + $clauses = $this->clauses; 10 + if ($termkey == null) { 11 + return $clauses; 12 + } 13 + if (isset($clauses[$termkey])) { 14 + return $clauses[$termkey]; 15 + } 16 + return array(); 17 + } 18 + 19 + public function getClauseCount($clausekey) { 20 + if (isset($this->clauses[$clausekey])) { 21 + return count($this->clauses[$clausekey]); 22 + } else { 23 + return 0; 24 + } 25 + } 26 + 27 + public function addExistsClause($field) { 28 + return $this->addClause('filter', array( 29 + 'exists' => array( 30 + 'field' => $field, 31 + ), 32 + )); 33 + } 34 + 35 + public function addTermsClause($field, $values) { 36 + return $this->addClause('filter', array( 37 + 'terms' => array( 38 + $field => array_values($values), 39 + ), 40 + )); 41 + } 42 + 43 + public function addMustClause($clause) { 44 + return $this->addClause('must', $clause); 45 + } 46 + 47 + public function addFilterClause($clause) { 48 + return $this->addClause('filter', $clause); 49 + } 50 + 51 + public function addShouldClause($clause) { 52 + return $this->addClause('should', $clause); 53 + } 54 + 55 + public function addMustNotClause($clause) { 56 + return $this->addClause('must_not', $clause); 57 + } 58 + 59 + public function addClause($clause, $terms) { 60 + $this->clauses[$clause][] = $terms; 61 + return $this; 62 + } 63 + 64 + public function toArray() { 65 + $clauses = $this->getClauses(); 66 + return $clauses; 67 + $cleaned = array(); 68 + foreach ($clauses as $clause => $subclauses) { 69 + if (is_array($subclauses) && count($subclauses) == 1) { 70 + $cleaned[$clause] = array_shift($subclauses); 71 + } else { 72 + $cleaned[$clause] = $subclauses; 73 + } 74 + } 75 + return $cleaned; 76 + } 77 + 78 + }
+30 -64
src/applications/search/fulltextstorage/PhabricatorFulltextStorageEngine.php
··· 7 7 */ 8 8 abstract class PhabricatorFulltextStorageEngine extends Phobject { 9 9 10 - /* -( Engine Metadata )---------------------------------------------------- */ 10 + protected $service; 11 + 12 + public function getHosts() { 13 + return $this->service->getHosts(); 14 + } 15 + 16 + public function setService(PhabricatorSearchService $service) { 17 + $this->service = $service; 18 + return $this; 19 + } 11 20 12 21 /** 13 - * Return a unique, nonempty string which identifies this storage engine. 14 - * 15 - * @return string Unique string for this engine, max length 32. 16 - * @task meta 22 + * @return PhabricatorSearchService 17 23 */ 18 - abstract public function getEngineIdentifier(); 24 + public function getService() { 25 + return $this->service; 26 + } 19 27 20 28 /** 21 - * Prioritize this engine relative to other engines. 22 - * 23 - * Engines with a smaller priority number get an opportunity to write files 24 - * first. Generally, lower-latency filestores should have lower priority 25 - * numbers, and higher-latency filestores should have higher priority 26 - * numbers. Setting priority to approximately the number of milliseconds of 27 - * read latency will generally produce reasonable results. 28 - * 29 - * In conjunction with filesize limits, the goal is to store small files like 30 - * profile images, thumbnails, and text snippets in lower-latency engines, 31 - * and store large files in higher-capacity engines. 32 - * 33 - * @return float Engine priority. 34 - * @task meta 29 + * Implementations must return a prototype host instance which is cloned 30 + * by the PhabricatorSearchService infrastructure to configure each engine. 31 + * @return PhabricatorSearchHost 35 32 */ 36 - abstract public function getEnginePriority(); 33 + abstract public function getHostType(); 34 + 35 + /* -( Engine Metadata )---------------------------------------------------- */ 37 36 38 37 /** 39 - * Return `true` if the engine is currently writable. 40 - * 41 - * Engines that are disabled or missing configuration should return `false` 42 - * to prevent new writes. If writes were made with this engine in the past, 43 - * the application may still try to perform reads. 38 + * Return a unique, nonempty string which identifies this storage engine. 44 39 * 45 - * @return bool True if this engine can support new writes. 40 + * @return string Unique string for this engine, max length 32. 46 41 * @task meta 47 42 */ 48 - abstract public function isEnabled(); 49 - 43 + abstract public function getEngineIdentifier(); 50 44 51 45 /* -( Managing Documents )------------------------------------------------- */ 52 46 ··· 84 78 abstract public function indexExists(); 85 79 86 80 /** 81 + * Implementations should override this method to return a dictionary of 82 + * stats which are suitable for display in the admin UI. 83 + */ 84 + abstract public function getIndexStats(); 85 + 86 + 87 + /** 87 88 * Is the index in a usable state? 88 89 * 89 90 * @return bool ··· 99 100 */ 100 101 public function initIndex() {} 101 102 102 - 103 - /* -( Loading Storage Engines )-------------------------------------------- */ 104 - 105 - /** 106 - * @task load 107 - */ 108 - public static function loadAllEngines() { 109 - return id(new PhutilClassMapQuery()) 110 - ->setAncestorClass(__CLASS__) 111 - ->setUniqueMethod('getEngineIdentifier') 112 - ->setSortMethod('getEnginePriority') 113 - ->execute(); 114 - } 115 - 116 - /** 117 - * @task load 118 - */ 119 - public static function loadActiveEngines() { 120 - $engines = self::loadAllEngines(); 121 - 122 - $active = array(); 123 - foreach ($engines as $key => $engine) { 124 - if (!$engine->isEnabled()) { 125 - continue; 126 - } 127 - 128 - $active[$key] = $engine; 129 - } 130 - 131 - return $active; 132 - } 133 - 134 - public static function loadEngine() { 135 - return head(self::loadActiveEngines()); 136 - } 137 103 138 104 }
+7 -6
src/applications/search/fulltextstorage/PhabricatorMySQLFulltextStorageEngine.php
··· 7 7 return 'mysql'; 8 8 } 9 9 10 - public function getEnginePriority() { 11 - return 100; 12 - } 13 - 14 - public function isEnabled() { 15 - return true; 10 + public function getHostType() { 11 + return new PhabricatorMySQLSearchHost($this); 16 12 } 17 13 18 14 public function reindexAbstractDocument( ··· 415 411 public function indexExists() { 416 412 return true; 417 413 } 414 + 415 + public function getIndexStats() { 416 + return false; 417 + } 418 + 418 419 }
+1 -2
src/applications/search/index/PhabricatorFulltextEngine.php
··· 40 40 $extension->indexFulltextObject($object, $document); 41 41 } 42 42 43 - $storage_engine = PhabricatorFulltextStorageEngine::loadEngine(); 44 - $storage_engine->reindexAbstractDocument($document); 43 + PhabricatorSearchService::reindexAbstractDocument($document); 45 44 } 46 45 47 46 protected function newAbstractDocument($object) {
+32 -18
src/applications/search/management/PhabricatorSearchManagementInitWorkflow.php
··· 13 13 public function execute(PhutilArgumentParser $args) { 14 14 $console = PhutilConsole::getConsole(); 15 15 16 - $engine = PhabricatorFulltextStorageEngine::loadEngine(); 17 - 18 16 $work_done = false; 19 - if (!$engine->indexExists()) { 20 - $console->writeOut( 21 - '%s', 22 - pht('Index does not exist, creating...')); 23 - $engine->initIndex(); 17 + foreach (PhabricatorSearchService::getAllServices() as $service) { 24 18 $console->writeOut( 25 19 "%s\n", 26 - pht('done.')); 27 - $work_done = true; 28 - } else if (!$engine->indexIsSane()) { 29 - $console->writeOut( 30 - '%s', 31 - pht('Index exists but is incorrect, fixing...')); 32 - $engine->initIndex(); 33 - $console->writeOut( 34 - "%s\n", 35 - pht('done.')); 36 - $work_done = true; 20 + pht('Initializing search service "%s"', $service->getDisplayName())); 21 + 22 + try { 23 + $host = $service->getAnyHostForRole('write'); 24 + } catch (PhabricatorClusterNoHostForRoleException $e) { 25 + // If there are no writable hosts for a given cluster, skip it 26 + $console->writeOut("%s\n", $e->getMessage()); 27 + continue; 28 + } 29 + 30 + $engine = $host->getEngine(); 31 + 32 + if (!$engine->indexExists()) { 33 + $console->writeOut( 34 + '%s', 35 + pht('Index does not exist, creating...')); 36 + $engine->initIndex(); 37 + $console->writeOut( 38 + "%s\n", 39 + pht('done.')); 40 + $work_done = true; 41 + } else if (!$engine->indexIsSane()) { 42 + $console->writeOut( 43 + '%s', 44 + pht('Index exists but is incorrect, fixing...')); 45 + $engine->initIndex(); 46 + $console->writeOut( 47 + "%s\n", 48 + pht('done.')); 49 + $work_done = true; 50 + } 37 51 } 38 52 39 53 if ($work_done) {
+1 -4
src/applications/search/query/PhabricatorSearchDocumentQuery.php
··· 73 73 $query = id(clone($this->savedQuery)) 74 74 ->setParameter('offset', $this->getOffset()) 75 75 ->setParameter('limit', $this->getRawResultLimit()); 76 - 77 - $engine = PhabricatorFulltextStorageEngine::loadEngine(); 78 - 79 - return $engine->executeSearch($query); 76 + return PhabricatorSearchService::executeSearch($query); 80 77 } 81 78 82 79 public function getQueryApplicationClass() {
+76
src/docs/user/cluster/cluster_search.diviner
··· 1 + @title Cluster: Search 2 + @group cluster 3 + 4 + Overview 5 + ======== 6 + 7 + You can configure phabricator to connect to one or more fulltext search clusters 8 + running either Elasticsearch or MySQL. By default and without further 9 + configuration, Phabricator will use MySQL for fulltext search. This will be 10 + adequate for the vast majority of users. Installs with a very large number of 11 + objects or specialized search needs can consider enabling Elasticsearch for 12 + better scalability and potentially better search results. 13 + 14 + Configuring Search Services 15 + =========================== 16 + 17 + To configure an Elasticsearch service, use the `cluster.search` configuration 18 + option. A typical Elasticsearch configuration will probably look similar to 19 + the following example: 20 + 21 + ```lang=json 22 + { 23 + "cluster.search": [ 24 + { 25 + "type": "elasticsearch", 26 + "hosts": [ 27 + { 28 + "host": "127.0.0.1", 29 + "roles": { "write": true, "read": true } 30 + } 31 + ], 32 + "port": 9200, 33 + "protocol": "http", 34 + "path": "/phabricator", 35 + "version": 5 36 + }, 37 + ], 38 + } 39 + ``` 40 + 41 + Supported Options 42 + ----------------- 43 + | Key | Type |Comments| 44 + |`type` | String |Engine type. Currently, 'elasticsearch' or 'mysql'| 45 + |`protocol`| String |Either 'http' or 'https'| 46 + |`port`| Int |The TCP port that Elasticsearch is bound to| 47 + |`path`| String |The path portion of the url for phabricator's index.| 48 + |`version`| Int |The version of Elasticsearch server. Supports either 2 or 5.| 49 + |`hosts`| List |A list of one or more Elasticsearch host names / addresses.| 50 + 51 + Host Configuration 52 + ------------------ 53 + Each search service must have one or more hosts associated with it. Each host 54 + entry consists of a `host` key, a dictionary of roles and can optionally 55 + override any of the options that are valid at the service level (see above). 56 + 57 + Currently supported roles are `read` and `write`. These can be individually 58 + enabled or disabled on a per-host basis. A typical setup might include two 59 + elasticsearch clusters in two separate datacenters. You can configure one 60 + cluster for reads and both for writes. When one cluster is down for maintenance 61 + you can simply swap the read role over to the backup cluster and then proceed 62 + with maintenance without any service interruption. 63 + 64 + Monitoring Search Services 65 + ========================== 66 + 67 + You can monitor fulltext search in {nav Config > Search Servers}. This interface 68 + shows you a quick overview of services and their health. 69 + 70 + The table on this page shows some basic stats for each configured service, 71 + followed by the configuration and current status of each host. 72 + 73 + NOTE: This page runs its diagnostics //from the web server that is serving the 74 + request//. If you are recovering from a disaster, the view this page shows 75 + may be partial or misleading, and two requests served by different servers may 76 + see different views of the cluster.
src/infrastructure/cluster/PhabricatorClusterDatabasesConfigOptionType.php src/infrastructure/cluster/config/PhabricatorClusterDatabasesConfigOptionType.php
src/infrastructure/cluster/PhabricatorClusterException.php src/infrastructure/cluster/exception/PhabricatorClusterException.php
src/infrastructure/cluster/PhabricatorClusterExceptionHandler.php src/infrastructure/cluster/exception/PhabricatorClusterExceptionHandler.php
src/infrastructure/cluster/PhabricatorClusterImpossibleWriteException.php src/infrastructure/cluster/exception/PhabricatorClusterImpossibleWriteException.php
src/infrastructure/cluster/PhabricatorClusterImproperWriteException.php src/infrastructure/cluster/exception/PhabricatorClusterImproperWriteException.php
src/infrastructure/cluster/PhabricatorClusterStrandedException.php src/infrastructure/cluster/exception/PhabricatorClusterStrandedException.php
+8 -14
src/infrastructure/cluster/PhabricatorDatabaseHealthRecord.php src/infrastructure/cluster/PhabricatorClusterServiceHealthRecord.php
··· 1 1 <?php 2 2 3 - final class PhabricatorDatabaseHealthRecord 3 + class PhabricatorClusterServiceHealthRecord 4 4 extends Phobject { 5 5 6 - private $ref; 6 + private $cacheKey; 7 7 private $shouldCheck; 8 8 private $isHealthy; 9 9 private $upEventCount; 10 10 private $downEventCount; 11 11 12 - public function __construct(PhabricatorDatabaseRef $ref) { 13 - $this->ref = $ref; 12 + public function __construct($cache_key) { 13 + $this->cacheKey = $cache_key; 14 14 $this->readState(); 15 15 } 16 - 17 16 18 17 /** 19 18 * Is the database currently healthy? ··· 153 152 } 154 153 } 155 154 156 - private function getHealthRecordCacheKey() { 157 - $ref = $this->ref; 158 - 159 - $host = $ref->getHost(); 160 - $port = $ref->getPort(); 161 - 162 - return "cluster.db.health({$host}, {$port})"; 155 + public function getCacheKey() { 156 + return $this->cacheKey; 163 157 } 164 158 165 159 private function readHealthRecord() { 166 160 $cache = PhabricatorCaches::getSetupCache(); 167 - $cache_key = $this->getHealthRecordCacheKey(); 161 + $cache_key = $this->getCacheKey(); 168 162 $health_record = $cache->getKey($cache_key); 169 163 170 164 if (!is_array($health_record)) { ··· 180 174 181 175 private function writeHealthRecord(array $record) { 182 176 $cache = PhabricatorCaches::getSetupCache(); 183 - $cache_key = $this->getHealthRecordCacheKey(); 177 + $cache_key = $this->getCacheKey(); 184 178 $cache->setKey($cache_key, $record); 185 179 } 186 180
+11 -1
src/infrastructure/cluster/PhabricatorDatabaseRef.php
··· 14 14 const REPLICATION_SLOW = 'replica-slow'; 15 15 const REPLICATION_NOT_REPLICATING = 'not-replicating'; 16 16 17 + const KEY_HEALTH = 'cluster.db.health'; 17 18 const KEY_REFS = 'cluster.db.refs'; 18 19 const KEY_INDIVIDUAL = 'cluster.db.individual'; 19 20 ··· 489 490 return $this; 490 491 } 491 492 493 + private function getHealthRecordCacheKey() { 494 + $host = $this->getHost(); 495 + $port = $this->getPort(); 496 + $key = self::KEY_HEALTH; 497 + 498 + return "{$key}({$host}, {$port})"; 499 + } 500 + 492 501 public function getHealthRecord() { 493 502 if (!$this->healthRecord) { 494 - $this->healthRecord = new PhabricatorDatabaseHealthRecord($this); 503 + $this->healthRecord = new PhabricatorClusterServiceHealthRecord( 504 + $this->getHealthRecordCacheKey()); 495 505 } 496 506 return $this->healthRecord; 497 507 }
+79
src/infrastructure/cluster/config/PhabricatorClusterSearchConfigOptionType.php
··· 1 + <?php 2 + 3 + final class PhabricatorClusterSearchConfigOptionType 4 + extends PhabricatorConfigJSONOptionType { 5 + 6 + public function validateOption(PhabricatorConfigOption $option, $value) { 7 + if (!is_array($value)) { 8 + throw new Exception( 9 + pht( 10 + 'Search cluster configuration is not valid: value must be a '. 11 + 'list of search hosts.')); 12 + } 13 + 14 + $engines = PhabricatorSearchService::loadAllFulltextStorageEngines(); 15 + 16 + foreach ($value as $index => $spec) { 17 + if (!is_array($spec)) { 18 + throw new Exception( 19 + pht( 20 + 'Search cluster configuration is not valid: each entry in the '. 21 + 'list must be a dictionary describing a search service, but '. 22 + 'the value with index "%s" is not a dictionary.', 23 + $index)); 24 + } 25 + 26 + try { 27 + PhutilTypeSpec::checkMap( 28 + $spec, 29 + array( 30 + 'type' => 'string', 31 + 'hosts' => 'optional list<map<string, wild>>', 32 + 'roles' => 'optional map<string, wild>', 33 + 'port' => 'optional int', 34 + 'protocol' => 'optional string', 35 + 'path' => 'optional string', 36 + 'version' => 'optional int', 37 + )); 38 + } catch (Exception $ex) { 39 + throw new Exception( 40 + pht( 41 + 'Search engine configuration has an invalid service '. 42 + 'specification (at index "%s"): %s.', 43 + $index, 44 + $ex->getMessage())); 45 + } 46 + 47 + if (!array_key_exists($spec['type'], $engines)) { 48 + throw new Exception( 49 + pht('Invalid search engine type: %s. Valid types include: %s', 50 + $spec['type'], 51 + implode(', ', array_keys($engines)))); 52 + } 53 + 54 + if (isset($spec['hosts'])) { 55 + foreach ($spec['hosts'] as $hostindex => $host) { 56 + try { 57 + PhutilTypeSpec::checkMap( 58 + $host, 59 + array( 60 + 'host' => 'string', 61 + 'roles' => 'optional map<string, wild>', 62 + 'port' => 'optional int', 63 + 'protocol' => 'optional string', 64 + 'path' => 'optional string', 65 + 'version' => 'optional int', 66 + )); 67 + } catch (Exception $ex) { 68 + throw new Exception( 69 + pht( 70 + 'Search cluster configuration has an invalid host '. 71 + 'specification (at index "%s"): %s.', 72 + $hostindex, 73 + $ex->getMessage())); 74 + } 75 + } 76 + } 77 + } 78 + } 79 + }
+10
src/infrastructure/cluster/exception/PhabricatorClusterNoHostForRoleException.php
··· 1 + <?php 2 + 3 + final class PhabricatorClusterNoHostForRoleException 4 + extends Exception { 5 + 6 + public function __construct($role) { 7 + parent::__construct(pht('Search cluster has no hosts for role "%s".', 8 + $role)); 9 + } 10 + }
+82
src/infrastructure/cluster/search/PhabricatorElasticSearchHost.php
··· 1 + <?php 2 + 3 + final class PhabricatorElasticSearchHost 4 + extends PhabricatorSearchHost { 5 + 6 + private $version = 5; 7 + private $path = 'phabricator/'; 8 + private $protocol = 'http'; 9 + 10 + const KEY_REFS = 'search.elastic.refs'; 11 + 12 + 13 + public function setConfig($config) { 14 + $this->setRoles(idx($config, 'roles', $this->getRoles())) 15 + ->setHost(idx($config, 'host', $this->host)) 16 + ->setPort(idx($config, 'port', $this->port)) 17 + ->setProtocol(idx($config, 'protocol', $this->protocol)) 18 + ->setPath(idx($config, 'path', $this->path)) 19 + ->setVersion(idx($config, 'version', $this->version)); 20 + return $this; 21 + } 22 + 23 + public function getDisplayName() { 24 + return pht('ElasticSearch'); 25 + } 26 + 27 + public function getStatusViewColumns() { 28 + return array( 29 + pht('Protocol') => $this->getProtocol(), 30 + pht('Host') => $this->getHost(), 31 + pht('Port') => $this->getPort(), 32 + pht('Index Path') => $this->getPath(), 33 + pht('Elastic Version') => $this->getVersion(), 34 + pht('Roles') => implode(', ', array_keys($this->getRoles())), 35 + ); 36 + } 37 + 38 + public function setProtocol($protocol) { 39 + $this->protocol = $protocol; 40 + return $this; 41 + } 42 + 43 + public function getProtocol() { 44 + return $this->protocol; 45 + } 46 + 47 + public function setPath($path) { 48 + $this->path = $path; 49 + return $this; 50 + } 51 + 52 + public function getPath() { 53 + return $this->path; 54 + } 55 + 56 + public function setVersion($version) { 57 + $this->version = $version; 58 + return $this; 59 + } 60 + 61 + public function getVersion() { 62 + return $this->version; 63 + } 64 + 65 + public function getURI($to_path = null) { 66 + $uri = id(new PhutilURI('http://'.$this->getHost())) 67 + ->setProtocol($this->getProtocol()) 68 + ->setPort($this->getPort()) 69 + ->setPath($this->getPath()); 70 + 71 + if ($to_path) { 72 + $uri->appendPath($to_path); 73 + } 74 + return $uri; 75 + } 76 + 77 + public function getConnectionStatus() { 78 + $status = $this->getEngine()->indexIsSane($this); 79 + return $status ? parent::STATUS_OKAY : parent::STATUS_FAIL; 80 + } 81 + 82 + }
+34
src/infrastructure/cluster/search/PhabricatorMySQLSearchHost.php
··· 1 + <?php 2 + 3 + final class PhabricatorMySQLSearchHost 4 + extends PhabricatorSearchHost { 5 + 6 + public function setConfig($config) { 7 + $this->setRoles(idx($config, 'roles', 8 + array('read' => true, 'write' => true))); 9 + return $this; 10 + } 11 + 12 + public function getDisplayName() { 13 + return 'MySQL'; 14 + } 15 + 16 + public function getStatusViewColumns() { 17 + return array( 18 + pht('Protocol') => 'mysql', 19 + pht('Roles') => implode(', ', array_keys($this->getRoles())), 20 + ); 21 + } 22 + 23 + public function getProtocol() { 24 + return 'mysql'; 25 + } 26 + 27 + public function getConnectionStatus() { 28 + PhabricatorDatabaseRef::queryAll(); 29 + $ref = PhabricatorDatabaseRef::getMasterDatabaseRefForApplication('search'); 30 + $status = $ref->getConnectionStatus(); 31 + return $status; 32 + } 33 + 34 + }
+163
src/infrastructure/cluster/search/PhabricatorSearchHost.php
··· 1 + <?php 2 + 3 + abstract class PhabricatorSearchHost 4 + extends Phobject { 5 + 6 + const KEY_REFS = 'cluster.search.refs'; 7 + const KEY_HEALTH = 'cluster.search.health'; 8 + 9 + protected $engine; 10 + protected $healthRecord; 11 + protected $roles = array(); 12 + 13 + protected $disabled; 14 + protected $host; 15 + protected $port; 16 + protected $hostRefs = array(); 17 + 18 + const STATUS_OKAY = 'okay'; 19 + const STATUS_FAIL = 'fail'; 20 + 21 + public function __construct(PhabricatorFulltextStorageEngine $engine) { 22 + $this->engine = $engine; 23 + } 24 + 25 + public function setDisabled($disabled) { 26 + $this->disabled = $disabled; 27 + return $this; 28 + } 29 + 30 + public function getDisabled() { 31 + return $this->disabled; 32 + } 33 + 34 + /** 35 + * @return PhabricatorFulltextStorageEngine 36 + */ 37 + public function getEngine() { 38 + return $this->engine; 39 + } 40 + 41 + public function isWritable() { 42 + return $this->hasRole('write'); 43 + } 44 + 45 + public function isReadable() { 46 + return $this->hasRole('read'); 47 + } 48 + 49 + public function hasRole($role) { 50 + return isset($this->roles[$role]) && $this->roles[$role] === true; 51 + } 52 + 53 + public function setRoles(array $roles) { 54 + foreach ($roles as $role => $val) { 55 + $this->roles[$role] = $val; 56 + } 57 + return $this; 58 + } 59 + 60 + public function getRoles() { 61 + $roles = array(); 62 + foreach ($this->roles as $key => $val) { 63 + if ($val) { 64 + $roles[$key] = $val; 65 + } 66 + } 67 + return $roles; 68 + } 69 + 70 + public function setPort($value) { 71 + $this->port = $value; 72 + return $this; 73 + } 74 + 75 + public function getPort() { 76 + return $this->port; 77 + } 78 + 79 + public function setHost($value) { 80 + $this->host = $value; 81 + return $this; 82 + } 83 + 84 + public function getHost() { 85 + return $this->host; 86 + } 87 + 88 + 89 + public function getHealthRecordCacheKey() { 90 + $host = $this->getHost(); 91 + $port = $this->getPort(); 92 + $key = self::KEY_HEALTH; 93 + 94 + return "{$key}({$host}, {$port})"; 95 + } 96 + 97 + /** 98 + * @return PhabricatorClusterServiceHealthRecord 99 + */ 100 + public function getHealthRecord() { 101 + if (!$this->healthRecord) { 102 + $this->healthRecord = new PhabricatorClusterServiceHealthRecord( 103 + $this->getHealthRecordCacheKey()); 104 + } 105 + return $this->healthRecord; 106 + } 107 + 108 + public function didHealthCheck($reachable) { 109 + $record = $this->getHealthRecord(); 110 + $should_check = $record->getShouldCheck(); 111 + 112 + if ($should_check) { 113 + $record->didHealthCheck($reachable); 114 + } 115 + } 116 + 117 + /** 118 + * @return string[] Get a list of fields to show in the status overview UI 119 + */ 120 + abstract public function getStatusViewColumns(); 121 + 122 + abstract public function getConnectionStatus(); 123 + 124 + public static function reindexAbstractDocument( 125 + PhabricatorSearchAbstractDocument $doc) { 126 + 127 + $services = self::getAllServices(); 128 + $indexed = 0; 129 + foreach (self::getWritableHostForEachService() as $host) { 130 + $host->getEngine()->reindexAbstractDocument($doc); 131 + $indexed++; 132 + } 133 + if ($indexed == 0) { 134 + throw new PhabricatorClusterNoHostForRoleException('write'); 135 + } 136 + } 137 + 138 + public static function executeSearch(PhabricatorSavedQuery $query) { 139 + $services = self::getAllServices(); 140 + foreach ($services as $service) { 141 + $hosts = $service->getAllHostsForRole('read'); 142 + // try all hosts until one succeeds 143 + foreach ($hosts as $host) { 144 + $last_exception = null; 145 + try { 146 + $res = $host->getEngine()->executeSearch($query); 147 + // return immediately if we get results without an exception 148 + $host->didHealthCheck(true); 149 + return $res; 150 + } catch (Exception $ex) { 151 + // try each server in turn, only throw if none succeed 152 + $last_exception = $ex; 153 + $host->didHealthCheck(false); 154 + } 155 + } 156 + } 157 + if ($last_exception) { 158 + throw $last_exception; 159 + } 160 + return $res; 161 + } 162 + 163 + }
+259
src/infrastructure/cluster/search/PhabricatorSearchService.php
··· 1 + <?php 2 + 3 + class PhabricatorSearchService 4 + extends Phobject { 5 + 6 + const KEY_REFS = 'cluster.search.refs'; 7 + 8 + protected $config; 9 + protected $disabled; 10 + protected $engine; 11 + protected $hosts = array(); 12 + protected $hostsConfig; 13 + protected $hostType; 14 + protected $roles = array(); 15 + 16 + const STATUS_OKAY = 'okay'; 17 + const STATUS_FAIL = 'fail'; 18 + 19 + public function __construct(PhabricatorFulltextStorageEngine $engine) { 20 + $this->engine = $engine; 21 + $this->hostType = $engine->getHostType(); 22 + } 23 + 24 + /** 25 + * @throws Exception 26 + */ 27 + public function newHost($config) { 28 + $host = clone($this->hostType); 29 + $host_config = $this->config + $config; 30 + $host->setConfig($host_config); 31 + $this->hosts[] = $host; 32 + return $host; 33 + } 34 + 35 + public function getEngine() { 36 + return $this->engine; 37 + } 38 + 39 + public function getDisplayName() { 40 + return $this->hostType->getDisplayName(); 41 + } 42 + 43 + public function getStatusViewColumns() { 44 + return $this->hostType->getStatusViewColumns(); 45 + } 46 + 47 + public function setConfig($config) { 48 + $this->config = $config; 49 + 50 + if (!isset($config['hosts'])) { 51 + $config['hosts'] = array( 52 + array( 53 + 'host' => idx($config, 'host'), 54 + 'port' => idx($config, 'port'), 55 + 'protocol' => idx($config, 'protocol'), 56 + 'roles' => idx($config, 'roles'), 57 + ), 58 + ); 59 + } 60 + foreach ($config['hosts'] as $host) { 61 + $this->newHost($host); 62 + } 63 + 64 + } 65 + 66 + public function getConfig() { 67 + return $this->config; 68 + } 69 + 70 + public function setDisabled($disabled) { 71 + $this->disabled = $disabled; 72 + return $this; 73 + } 74 + 75 + public function getDisabled() { 76 + return $this->disabled; 77 + } 78 + 79 + public static function getConnectionStatusMap() { 80 + return array( 81 + self::STATUS_OKAY => array( 82 + 'icon' => 'fa-exchange', 83 + 'color' => 'green', 84 + 'label' => pht('Okay'), 85 + ), 86 + self::STATUS_FAIL => array( 87 + 'icon' => 'fa-times', 88 + 'color' => 'red', 89 + 'label' => pht('Failed'), 90 + ), 91 + ); 92 + } 93 + 94 + public function isWritable() { 95 + return $this->hasRole('write'); 96 + } 97 + 98 + public function isReadable() { 99 + return $this->hasRole('read'); 100 + } 101 + 102 + public function hasRole($role) { 103 + return isset($this->roles[$role]) && $this->roles[$role] === true; 104 + } 105 + 106 + public function setRoles(array $roles) { 107 + foreach ($roles as $role => $val) { 108 + if ($val === false && isset($this->roles[$role])) { 109 + unset($this->roles[$role]); 110 + } else { 111 + $this->roles[$role] = $val; 112 + } 113 + } 114 + return $this; 115 + } 116 + 117 + public function getRoles() { 118 + return $this->roles; 119 + } 120 + 121 + public function getPort() { 122 + return idx($this->config, 'port'); 123 + } 124 + 125 + public function getProtocol() { 126 + return idx($this->config, 'protocol'); 127 + } 128 + 129 + 130 + public function getVersion() { 131 + return idx($this->config, 'version'); 132 + } 133 + 134 + public function getHosts() { 135 + return $this->hosts; 136 + } 137 + 138 + 139 + /** 140 + * Get a random host reference with the specified role, skipping hosts which 141 + * failed recent health checks. 142 + * @throws PhabricatorClusterNoHostForRoleException if no healthy hosts match. 143 + * @return PhabricatorSearchHost 144 + */ 145 + public function getAnyHostForRole($role) { 146 + $hosts = $this->getAllHostsForRole($role); 147 + shuffle($hosts); 148 + foreach ($hosts as $host) { 149 + $health = $host->getHealthRecord(); 150 + if ($health->getIsHealthy()) { 151 + return $host; 152 + } 153 + } 154 + throw new PhabricatorClusterNoHostForRoleException($role); 155 + } 156 + 157 + 158 + /** 159 + * Get all configured hosts for this service which have the specified role. 160 + * @return PhabricatorSearchHost[] 161 + */ 162 + public function getAllHostsForRole($role) { 163 + $hosts = array(); 164 + foreach ($this->hosts as $host) { 165 + if ($host->hasRole($role)) { 166 + $hosts[] = $host; 167 + } 168 + } 169 + return $hosts; 170 + } 171 + 172 + /** 173 + * Get a reference to all configured fulltext search cluster services 174 + * @return PhabricatorSearchService[] 175 + */ 176 + public static function getAllServices() { 177 + $cache = PhabricatorCaches::getRequestCache(); 178 + 179 + $refs = $cache->getKey(self::KEY_REFS); 180 + if (!$refs) { 181 + $refs = self::newRefs(); 182 + $cache->setKey(self::KEY_REFS, $refs); 183 + } 184 + 185 + return $refs; 186 + } 187 + 188 + /** 189 + * Load all valid PhabricatorFulltextStorageEngine subclasses 190 + */ 191 + public static function loadAllFulltextStorageEngines() { 192 + return id(new PhutilClassMapQuery()) 193 + ->setAncestorClass('PhabricatorFulltextStorageEngine') 194 + ->setUniqueMethod('getEngineIdentifier') 195 + ->execute(); 196 + } 197 + 198 + /** 199 + * Create instances of PhabricatorSearchService based on configuration 200 + * @return PhabricatorSearchService[] 201 + */ 202 + public static function newRefs() { 203 + $services = PhabricatorEnv::getEnvConfig('cluster.search'); 204 + $engines = self::loadAllFulltextStorageEngines(); 205 + $refs = array(); 206 + 207 + foreach ($services as $config) { 208 + $engine = $engines[$config['type']]; 209 + $cluster = new self($engine); 210 + $cluster->setConfig($config); 211 + $engine->setService($cluster); 212 + $refs[] = $cluster; 213 + } 214 + 215 + return $refs; 216 + } 217 + 218 + 219 + /** 220 + * (re)index the document: attempt to pass the document to all writable 221 + * fulltext search hosts 222 + * @throws PhabricatorClusterNoHostForRoleException 223 + */ 224 + public static function reindexAbstractDocument( 225 + PhabricatorSearchAbstractDocument $doc) { 226 + $indexed = 0; 227 + foreach (self::getAllServices() as $service) { 228 + $service->getEngine()->reindexAbstractDocument($doc); 229 + $indexed++; 230 + } 231 + if ($indexed == 0) { 232 + throw new PhabricatorClusterNoHostForRoleException('write'); 233 + } 234 + } 235 + 236 + /** 237 + * Execute a full-text query and return a list of PHIDs of matching objects. 238 + * @return string[] 239 + * @throws PhutilAggregateException 240 + */ 241 + public static function executeSearch(PhabricatorSavedQuery $query) { 242 + $services = self::getAllServices(); 243 + $exceptions = array(); 244 + foreach ($services as $service) { 245 + $engine = $service->getEngine(); 246 + // try all hosts until one succeeds 247 + try { 248 + $res = $engine->executeSearch($query); 249 + // return immediately if we get results without an exception 250 + return $res; 251 + } catch (Exception $ex) { 252 + $exceptions[] = $ex; 253 + } 254 + } 255 + throw new PhutilAggregateException('All search engines failed:', 256 + $exceptions); 257 + } 258 + 259 + }