diff options
Diffstat (limited to 'src/engine')
| -rw-r--r-- | src/engine/VyOSElasticModernFulltextStorageEngine.php | 596 |
1 files changed, 596 insertions, 0 deletions
diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php new file mode 100644 index 0000000..ef4a14d --- /dev/null +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -0,0 +1,596 @@ +<?php + +/** + * Full-text storage engine for Elasticsearch 7/8 and compatible OpenSearch + * clusters. Discoverable by PhutilClassMapQuery (plain class, not abstract) + * so Phorge's cluster.search dispatch can find and instantiate it. + */ +class VyOSElasticModernFulltextStorageEngine + extends PhabricatorFulltextStorageEngine { + + private $version = null; + private $index; + private $timeout = 15; + + public function setService(PhabricatorSearchService $service) { + $this->service = $service; // inherited protected property + $config = $service->getConfig(); + $index = idx($config, 'path', '/phabricator'); + $normalized = trim($index, '/'); + if ($normalized === '') { + throw new Exception( + pht( + 'Invalid index path "%s" in cluster.search config: '. + 'path must contain at least one non-slash character.', + $index)); + } + $this->index = $normalized; + $this->setVersion(idx($config, 'version', 7)); + return $this; + } + + public function getTimestampField() { + return 'lastModified'; + } + + public function getTextFieldType() { + return 'text'; + } + + public function getHostForRead() { + return $this->getService()->getAnyHostForRole('read'); + } + + public function getHostForWrite() { + return $this->getService()->getAnyHostForRole('write'); + } + + public function getTypeConstants($class) { + $relationship_class = new ReflectionClass($class); + $typeconstants = $relationship_class->getConstants(); + return array_unique(array_values($typeconstants)); + } + + public function setVersion($version) { + $version = (int)$version; + if ($version < 7) { + throw new Exception( + pht( + 'Unsupported Elasticsearch version "%d" for the '. + '"elasticsearch-modern" engine. This engine supports version 7 '. + 'and above (Elasticsearch 7.x, 8.x, or OpenSearch 1.x/2.x/3.x). '. + 'For ES 5.x, use the bundled "elasticsearch" engine instead.', + $version)); + } + $this->version = $version; + return $this; + } + + public function getVersion() { + if ($this->version === null) { + throw new Exception( + pht('Version not configured; call setVersion() or setService() first.')); + } + return $this->version; + } + + public function getDocumentUri($type, $phid) { + return '/_doc/'.$phid; + } + + public function getSearchUri(array $types) { + return '/_search'; + } + + public function buildTypeFilter(array $types) { + return array( + 'terms' => array( + 'documentType' => array_values($types), + ), + ); + } + + public function buildIndexMappings( + array $doc_types, array $fields, array $relationships, $text_type) { + + // These are emitted as fixed standard fields at the end of the mapping. + // Caller-supplied $fields or $relationships must not shadow them. + static $reserved = array('documentType', 'dateCreated', 'lastModified'); + + $rel_ts_keys = array_map(function($r) { return $r.'_ts'; }, $relationships); + $all_caller_keys = array_merge($fields, $relationships, $rel_ts_keys); + + // Check for caller-supplied names that shadow reserved fields. + $collisions = array_intersect($all_caller_keys, $reserved); + if ($collisions) { + throw new Exception( + pht( + 'buildIndexMappings(): caller-supplied field(s) "%s" collide with '. + 'reserved mapping keys.', + implode('", "', array_values($collisions)))); + } + + // Check for duplicates within caller-supplied keys themselves + // (e.g. a field named "foo_ts" that would clash with relationship "foo"'s + // implicit timestamp slot). + $counts = array_count_values($all_caller_keys); + $duplicates = array_keys(array_filter($counts, function($c) { return $c > 1; })); + if ($duplicates) { + throw new Exception( + pht( + 'buildIndexMappings(): caller-supplied key(s) "%s" appear more '. + 'than once (check for field/relationship/timestamp-slot collisions).', + implode('", "', $duplicates))); + } + + $properties = array(); + + foreach ($fields as $field) { + $properties[$field] = array( + 'type' => $text_type, + 'fields' => array( + 'raw' => array( + 'type' => $text_type, + 'analyzer' => 'english_exact', + 'search_analyzer' => 'english', + 'search_quote_analyzer' => 'english_exact', + ), + 'keywords' => array( + 'type' => $text_type, + 'analyzer' => 'letter_stop', + ), + 'stems' => array( + 'type' => $text_type, + 'analyzer' => 'english_stem', + ), + ), + ); + } + + foreach ($relationships as $rel) { + $properties[$rel] = array( + 'type' => 'keyword', + 'doc_values' => false, + ); + $properties[$rel.'_ts'] = array( + 'type' => 'date', + ); + } + + $properties['documentType'] = array('type' => 'keyword'); + $properties['dateCreated'] = array('type' => 'date'); + $properties['lastModified'] = array('type' => 'date'); + + // The $doc_types parameter is part of the signature for symmetry + // with the bundled engine's per-type loop, but the typeless API + // emits one mapping shared across all doc types. + return array('properties' => $properties); + } + + public function getEngineIdentifier() { + return 'elasticsearch-modern'; + } + + public function getHostType() { + return new VyOSElasticModernHost($this); + } + + public function setTimeout($timeout) { + $this->timeout = $timeout; + return $this; + } + + public function getTimeout() { + return $this->timeout; + } + + public function reindexAbstractDocument( + PhabricatorSearchAbstractDocument $doc) { + + $host = $this->getHostForWrite(); + + $type = $doc->getDocumentType(); + $phid = $doc->getPHID(); + + // The handle query mirrors the bundled engine's pattern; it + // populates the handle cache so subsequent field-data lookups + // don't issue redundant queries. + $handle = id(new PhabricatorHandleQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withPHIDs(array($phid)) + ->executeOne(); + + $spec = array( + 'title' => $doc->getDocumentTitle(), + 'dateCreated' => $doc->getDocumentCreated(), + 'lastModified' => $doc->getDocumentModified(), + 'documentType' => $type, + ); + + foreach ($doc->getFieldData() as $field) { + list($field_name, $corpus, $aux) = $field; + if (!isset($spec[$field_name])) { + $spec[$field_name] = array($corpus); + } else { + $spec[$field_name][] = $corpus; + } + if ($aux !== null) { + $spec[$field_name][] = $aux; + } + } + + foreach ($doc->getRelationshipData() as $field) { + list($field_name, $related_phid, $rtype, $time) = $field; + if (!isset($spec[$field_name])) { + $spec[$field_name] = array($related_phid); + } else { + $spec[$field_name][] = $related_phid; + } + if ($time !== null) { + if (!isset($spec[$field_name.'_ts'])) { + $spec[$field_name.'_ts'] = array($time); + } else { + $spec[$field_name.'_ts'][] = $time; + } + } + } + + $this->executeRequest( + $host, + $this->getDocumentUri($type, $phid), + $spec, + 'PUT'); + } + + private function executeRequest( + VyOSElasticModernHost $host, $path, array $data, $method = 'GET') { + + $uri = $host->getURI($path); + $data = phutil_json_encode($data); + $future = new HTTPSFuture($uri, $data); + $future->addHeader('Content-Type', 'application/json'); + + if ($method !== 'GET') { + $future->setMethod($method); + } + if ($this->getTimeout()) { + $future->setTimeout($this->getTimeout()); + } + + try { + list($body) = $future->resolvex(); + } catch (HTTPFutureResponseStatus $ex) { + if ($ex->isTimeout() || (int)$ex->getStatusCode() > 499) { + $host->didHealthCheck(false); + } + throw $ex; + } + + // HTTP request succeeded — mark the host as healthy regardless of whether + // the response body is valid JSON. JSON parse failure is an application + // error, not a host-connectivity failure. + $host->didHealthCheck(true); + + if ($method !== 'GET') { + return null; + } + + try { + return phutil_json_decode($body); + } catch (PhutilJSONParserException $ex) { + throw new Exception( + pht('Elasticsearch server returned invalid JSON.'), + 0, + $ex); + } + } + + public function executeSearch(PhabricatorSavedQuery $query) { + $types = $query->getParameter('types'); + if (!$types) { + $types = array_keys( + PhabricatorSearchApplicationSearchEngine::getIndexableDocumentTypes()); + } + + $uri = $this->getSearchUri($types); + $spec = $this->buildSpec($query, $types); + + $exceptions = array(); + foreach ($this->service->getAllHostsForRole('read') as $host) { + try { + $response = $this->executeRequest($host, $uri, $spec); + $phids = ipull($response['hits']['hits'], '_id'); + return $phids; + } catch (Exception $e) { + // Catches HTTPFutureResponseStatus and other network/HTTP exceptions + // raised by executeRequest(). PHP Error subclasses (TypeError, etc.) + // are intentionally NOT caught -- they indicate programming bugs that + // should propagate immediately, not get aggregated into the per-host + // failover. + $exceptions[] = $e; + } + } + throw new PhutilAggregateException( + pht('All Fulltext Search hosts failed:'), + $exceptions); + } + + private function buildSpec(PhabricatorSavedQuery $query, array $types) { + $q = new PhabricatorElasticsearchQueryBuilder(); + $query_string = $query->getParameter('query'); + if ($query_string !== null && $query_string !== '') { + $q->addMustClause(array( + 'simple_query_string' => array( + 'query' => $query_string, + 'fields' => array( + PhabricatorSearchDocumentFieldType::FIELD_TITLE.'.*', + PhabricatorSearchDocumentFieldType::FIELD_BODY.'.*', + PhabricatorSearchDocumentFieldType::FIELD_COMMENT.'.*', + ), + 'default_operator' => 'AND', + ), + )); + $q->addShouldClause(array( + 'simple_query_string' => array( + 'query' => $query_string, + 'fields' => array( + '*.raw', + PhabricatorSearchDocumentFieldType::FIELD_TITLE.'^4', + PhabricatorSearchDocumentFieldType::FIELD_BODY.'^3', + PhabricatorSearchDocumentFieldType::FIELD_COMMENT.'^1.2', + ), + 'analyzer' => 'english_exact', + 'default_operator' => 'and', + ), + )); + } + + $exclude = $query->getParameter('exclude'); + if ($exclude) { + // Correct from day one: bool.must_not, not the obsolete 'not' clause. + // Cast to array so a single PHID scalar and an already-array of PHIDs + // both produce a flat list for the Elasticsearch ids.values field. + $q->addMustNotClause(array( + 'ids' => array( + 'values' => array_values((array)$exclude), + ), + )); + } + + $relationship_map = array( + PhabricatorSearchRelationship::RELATIONSHIP_AUTHOR => + $query->getParameter('authorPHIDs', array()), + PhabricatorSearchRelationship::RELATIONSHIP_SUBSCRIBER => + $query->getParameter('subscriberPHIDs', array()), + PhabricatorSearchRelationship::RELATIONSHIP_PROJECT => + $query->getParameter('projectPHIDs', array()), + PhabricatorSearchRelationship::RELATIONSHIP_REPOSITORY => + $query->getParameter('repositoryPHIDs', array()), + ); + + $statuses = $query->getParameter('statuses', array()); + $statuses = array_fuse($statuses); + $rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN; + $rel_closed = PhabricatorSearchRelationship::RELATIONSHIP_CLOSED; + $rel_unowned = PhabricatorSearchRelationship::RELATIONSHIP_UNOWNED; + $include_open = !empty($statuses[$rel_open]); + $include_closed = !empty($statuses[$rel_closed]); + if ($include_open && !$include_closed) { + $q->addExistsClause($rel_open); + } else if (!$include_open && $include_closed) { + $q->addExistsClause($rel_closed); + } + + if ($query->getParameter('withUnowned')) { + $q->addExistsClause($rel_unowned); + } + + $rel_owner = PhabricatorSearchRelationship::RELATIONSHIP_OWNER; + if ($query->getParameter('withAnyOwner')) { + $q->addExistsClause($rel_owner); + } else { + $owner_phids = $query->getParameter('ownerPHIDs', array()); + if (count($owner_phids)) { + $q->addTermsClause($rel_owner, $owner_phids); + } + } + + foreach ($relationship_map as $field => $phids) { + if (is_array($phids) && !empty($phids)) { + $q->addTermsClause($field, $phids); + } + } + + // Body-level type filter (typeless API has no per-type URL segment). + $q->addFilterClause($this->buildTypeFilter($types)); + + if (!$q->getClauseCount('must')) { + $q->addMustClause(array('match_all' => array('boost' => 1))); + } + + $spec = array( + '_source' => false, + 'query' => array( + 'bool' => $q->toArray(), + ), + ); + + $sort_query_string = $query->getParameter('query'); + if ($sort_query_string === null || $sort_query_string === '') { + $spec['sort'] = array( + array('dateCreated' => 'desc'), + ); + } + + $offset = (int)$query->getParameter('offset', 0); + $limit = (int)$query->getParameter('limit', 101); + if ($offset < 0 || $limit < 0) { + throw new Exception(pht( + 'Query offset and limit must be non-negative. offset=%d limit=%d', + $offset, $limit)); + } + if ($offset + $limit > 10000) { + throw new Exception(pht( + 'Query offset is too large. offset+limit=%s (max=%s)', + $offset + $limit, 10000)); + } + $spec['from'] = $offset; + $spec['size'] = $limit; + + return $spec; + } + + public function indexExists(?VyOSElasticModernHost $host = null) { + if (!$host) { + $host = $this->getHostForRead(); + } + try { + $res = $this->executeRequest($host, '/_stats/', array()); + return isset($res['indices'][$this->index]); + } catch (HTTPFutureResponseStatus $e) { + if ($e->getStatusCode() == 404) { + return false; + } + throw $e; + } + } + + public function indexIsSane(?VyOSElasticModernHost $host = null) { + if (!$host) { + $host = $this->getHostForRead(); + } + if (!$this->indexExists($host)) { + return false; + } + $cur_mapping = $this->executeRequest($host, '/_mapping/', array()); + $cur_settings = $this->executeRequest($host, '/_settings/', array()); + $actual = array_merge( + $cur_settings[$this->index], + $cur_mapping[$this->index]); + + return $this->check($actual, $this->getIndexConfiguration()); + } + + public function initIndex() { + $host = $this->getHostForWrite(); + if ($this->indexExists($host)) { + $this->executeRequest($host, '/', array(), 'DELETE'); + } + $data = $this->getIndexConfiguration(); + $this->executeRequest($host, '/', $data, 'PUT'); + } + + public function getIndexStats(?VyOSElasticModernHost $host = null) { + if (!$host) { + $host = $this->getHostForRead(); + } + $res = $this->executeRequest($host, '/_stats/', array()); + if (!isset($res['indices'][$this->index])) { + throw new Exception( + pht( + 'Index "%s" not found in Elasticsearch _stats response.', + $this->index)); + } + $stats = $res['indices'][$this->index]; + return array( + pht('Queries') => + idxv($stats, array('primaries', 'search', 'query_total')), + pht('Documents') => + idxv($stats, array('total', 'docs', 'count')), + pht('Deleted') => + idxv($stats, array('total', 'docs', 'deleted')), + pht('Storage Used') => + phutil_format_bytes( + idxv($stats, array('total', 'store', 'size_in_bytes'))), + ); + } + + private function getIndexConfiguration() { + $data = array(); + $data['settings'] = array( + 'index' => array( + 'auto_expand_replicas' => '0-2', + 'analysis' => array( + 'filter' => array( + 'english_stop' => array( + 'type' => 'stop', + 'stopwords' => '_english_', + ), + 'english_stemmer' => array( + 'type' => 'stemmer', + 'language' => 'english', + ), + 'english_possessive_stemmer' => array( + 'type' => 'stemmer', + 'language' => 'possessive_english', + ), + ), + 'analyzer' => array( + 'english_exact' => array( + 'tokenizer' => 'standard', + 'filter' => array('lowercase'), + ), + 'letter_stop' => array( + 'tokenizer' => 'letter', + 'filter' => array('lowercase', 'english_stop'), + ), + 'english_stem' => array( + 'tokenizer' => 'standard', + 'filter' => array( + 'english_possessive_stemmer', + 'lowercase', + 'english_stop', + 'english_stemmer', + ), + ), + ), + ), + ), + ); + + $fields = $this->getTypeConstants('PhabricatorSearchDocumentFieldType'); + $relationships = $this->getTypeConstants('PhabricatorSearchRelationship'); + $doc_types = array_keys( + PhabricatorSearchApplicationSearchEngine::getIndexableDocumentTypes()); + $text_type = $this->getTextFieldType(); + + $data['mappings'] = $this->buildIndexMappings( + $doc_types, $fields, $relationships, $text_type); + + return $data; + } + + private function check($actual, $required, $path = '') { + foreach ($required as $key => $value) { + if (!array_key_exists($key, $actual)) { + return false; + } + if (is_array($value)) { + if (!is_array($actual[$key])) { + return false; + } + if (!$this->check($actual[$key], $value, $path.'.'.$key)) { + return false; + } + continue; + } + $actual[$key] = self::normalizeConfigValue($actual[$key]); + $value = self::normalizeConfigValue($value); + if ($actual[$key] != $value) { + return false; + } + } + return true; + } + + private static function normalizeConfigValue($value) { + if ($value === true) { + return 'true'; + } + if ($value === false) { + return 'false'; + } + return $value; + } + +} |
