summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@andamasov.com>2026-05-23 17:49:28 +0300
committerGitHub <noreply@github.com>2026-05-23 17:49:28 +0300
commit422681512d98175eb558271589cf433c1c9a1020 (patch)
tree00ad5372e564a0c332f7dc26abe9836681f9361e /src
parent49588447d6865c968143db7db1ab2b82b191e29e (diff)
parent5a47a44b717a912bd22d7e03921521cfec49074e (diff)
downloadphorge-elasticsearch-modern-production.tar.gz
phorge-elasticsearch-modern-production.zip
Merge pull request #16 from vyos/developmentHEADv0.1.0production
Release v0.1.0 — promote development to production
Diffstat (limited to 'src')
-rw-r--r--src/__phutil_library_init__.php3
-rw-r--r--src/__phutil_library_map__.php26
-rw-r--r--src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php280
-rw-r--r--src/__tests__/VyOSElasticModernHostTestCase.php66
-rw-r--r--src/engine/VyOSElasticModernFulltextStorageEngine.php596
-rw-r--r--src/host/VyOSElasticModernHost.php93
6 files changed, 1064 insertions, 0 deletions
diff --git a/src/__phutil_library_init__.php b/src/__phutil_library_init__.php
new file mode 100644
index 0000000..0b22869
--- /dev/null
+++ b/src/__phutil_library_init__.php
@@ -0,0 +1,3 @@
+<?php
+
+phutil_register_library('phorge-elasticsearch-modern', __FILE__);
diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
new file mode 100644
index 0000000..291fb03
--- /dev/null
+++ b/src/__phutil_library_map__.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * This file is automatically generated. Use 'arc liberate' to rebuild it.
+ *
+ * @generated
+ * @phutil-library-version 2
+ */
+phutil_register_library_map(array(
+ '__library_version__' => 2,
+ 'class' => array(
+ 'VyOSElasticModernFulltextStorageEngine' => 'engine/VyOSElasticModernFulltextStorageEngine.php',
+ 'VyOSElasticModernFulltextStorageEngineTestCase' => '__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php',
+ 'VyOSElasticModernFulltextStorageEngineTestDouble' => '__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php',
+ 'VyOSElasticModernHost' => 'host/VyOSElasticModernHost.php',
+ 'VyOSElasticModernHostTestCase' => '__tests__/VyOSElasticModernHostTestCase.php',
+ ),
+ 'function' => array(),
+ 'xmap' => array(
+ 'VyOSElasticModernFulltextStorageEngine' => 'PhabricatorFulltextStorageEngine',
+ 'VyOSElasticModernFulltextStorageEngineTestCase' => 'PhutilTestCase',
+ 'VyOSElasticModernFulltextStorageEngineTestDouble' => 'VyOSElasticModernFulltextStorageEngine',
+ 'VyOSElasticModernHost' => 'PhabricatorSearchHost',
+ 'VyOSElasticModernHostTestCase' => 'PhutilTestCase',
+ ),
+));
diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php
new file mode 100644
index 0000000..7e20fdc
--- /dev/null
+++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php
@@ -0,0 +1,280 @@
+<?php
+
+/**
+ * Test-double subclass of the concrete engine used by unit tests.
+ * Overrides host-dependent methods so tests run without a real
+ * PhabricatorSearchService; no actual Elasticsearch I/O occurs.
+ */
+final class VyOSElasticModernFulltextStorageEngineTestDouble
+ extends VyOSElasticModernFulltextStorageEngine {
+
+ public function reindexAbstractDocument(
+ PhabricatorSearchAbstractDocument $doc) {
+ // no-op in tests
+ }
+
+ public function executeSearch(PhabricatorSavedQuery $query) {
+ return array();
+ }
+
+ public function indexExists(?VyOSElasticModernHost $host = null) {
+ return false;
+ }
+
+ public function getIndexStats(?VyOSElasticModernHost $host = null) {
+ return array();
+ }
+
+}
+
+final class VyOSElasticModernFulltextStorageEngineTestCase
+ extends PhutilTestCase {
+
+ private function newEngine() {
+ return new VyOSElasticModernFulltextStorageEngineTestDouble();
+ }
+
+ public function testSetVersionAcceptsSevenAndAbove() {
+ foreach (array(7, 8, 9, 100) as $v) {
+ $engine = $this->newEngine();
+ $caught = null;
+ try {
+ $engine->setVersion($v);
+ } catch (Exception $e) {
+ $caught = $e;
+ }
+ $this->assertEqual(
+ null,
+ $caught,
+ pht('Expected no exception for version=%d.', $v));
+ $this->assertEqual($v, $engine->getVersion());
+ }
+ }
+
+ public function testSetVersionRejectsBelowSeven() {
+ foreach (array(0, 1, 2, 5, 6) as $v) {
+ $engine = $this->newEngine();
+ $caught = null;
+ try {
+ $engine->setVersion($v);
+ } catch (Exception $e) {
+ $caught = $e;
+ }
+ $this->assertTrue(
+ $caught !== null,
+ pht('Expected an exception for version=%d.', $v));
+ }
+ }
+
+ public function testGetDocumentUri() {
+ $engine = $this->newEngine()->setVersion(7);
+ $uri = $engine->getDocumentUri('TASK', 'PHID-TASK-abc');
+ $this->assertEqual('/_doc/PHID-TASK-abc', $uri);
+ }
+
+ public function testGetDocumentUriIgnoresType() {
+ // The typeless API does not encode the doc type in the URL.
+ $engine = $this->newEngine()->setVersion(7);
+ $uri_a = $engine->getDocumentUri('TASK', 'PHID-TASK-abc');
+ $uri_b = $engine->getDocumentUri('DREV', 'PHID-TASK-abc');
+ $this->assertEqual($uri_a, $uri_b);
+ }
+
+ public function testGetSearchUri() {
+ $engine = $this->newEngine()->setVersion(7);
+ $this->assertEqual(
+ '/_search',
+ $engine->getSearchUri(array('TASK', 'DREV')));
+ $this->assertEqual('/_search', $engine->getSearchUri(array('USER')));
+ $this->assertEqual('/_search', $engine->getSearchUri(array()));
+ }
+
+ public function testBuildTypeFilter() {
+ $engine = $this->newEngine()->setVersion(7);
+ $filter = $engine->buildTypeFilter(array('TASK', 'DREV'));
+ $expected = array(
+ 'terms' => array(
+ 'documentType' => array('TASK', 'DREV'),
+ ),
+ );
+ $this->assertEqual($expected, $filter);
+ }
+
+ public function testBuildTypeFilterEmpty() {
+ // Empty list still produces a filter; the caller is responsible
+ // for normalizing "no types specified" to "all indexable types"
+ // before calling this method. This matches the bundled engine's
+ // pattern (executeSearch() normalizes before URL construction).
+ $engine = $this->newEngine()->setVersion(7);
+ $filter = $engine->buildTypeFilter(array());
+ $this->assertEqual(
+ array('terms' => array('documentType' => array())),
+ $filter);
+ }
+
+ public function testBuildIndexMappingsShape() {
+ $engine = $this->newEngine()->setVersion(7);
+
+ $doc_types = array('TASK', 'DREV');
+ $fields = array('title', 'body', 'comment');
+ $relationships = array('authorPHID', 'projectPHID');
+ $mappings = $engine->buildIndexMappings(
+ $doc_types, $fields, $relationships, 'text');
+
+ // Single typeless mapping with one 'properties' block.
+ $this->assertTrue(isset($mappings['properties']));
+ $this->assertFalse(isset($mappings['TASK']));
+ $this->assertFalse(isset($mappings['DREV']));
+
+ // All three text fields have the multi-analyzer shape.
+ foreach (array('title', 'body', 'comment') as $field) {
+ $this->assertTrue(
+ isset($mappings['properties'][$field]),
+ pht('Field "%s" missing from mappings.', $field));
+ $this->assertEqual(
+ 'text',
+ $mappings['properties'][$field]['type'],
+ pht('Field "%s" should be type text.', $field));
+ $this->assertTrue(
+ isset($mappings['properties'][$field]['fields']['raw']),
+ pht('Field "%s" missing raw sub-field.', $field));
+ $this->assertTrue(
+ isset($mappings['properties'][$field]['fields']['keywords']),
+ pht('Field "%s" missing keywords sub-field.', $field));
+ $this->assertTrue(
+ isset($mappings['properties'][$field]['fields']['stems']),
+ pht('Field "%s" missing stems sub-field.', $field));
+ }
+
+ // Both relationships emit as keyword fields with doc_values:false.
+ foreach (array('authorPHID', 'projectPHID') as $rel) {
+ $this->assertEqual(
+ 'keyword',
+ $mappings['properties'][$rel]['type'],
+ pht('Relationship "%s" should be keyword type.', $rel));
+ $this->assertEqual(
+ false,
+ $mappings['properties'][$rel]['doc_values'],
+ pht('Relationship "%s" should have doc_values:false.', $rel));
+ $this->assertEqual(
+ 'date',
+ $mappings['properties'][$rel.'_ts']['type'],
+ pht('Relationship "%s" missing timestamp field.', $rel));
+ // No include_in_all anywhere.
+ $this->assertFalse(
+ isset($mappings['properties'][$rel]['include_in_all']),
+ pht('Relationship "%s" should not have include_in_all.', $rel));
+ }
+
+ // documentType is a keyword field inside properties.
+ $this->assertEqual(
+ 'keyword',
+ $mappings['properties']['documentType']['type']);
+
+ // Standard date fields present.
+ $this->assertEqual(
+ 'date',
+ $mappings['properties']['dateCreated']['type']);
+ $this->assertEqual(
+ 'date',
+ $mappings['properties']['lastModified']['type']);
+ }
+
+ public function testBuildIndexMappingsRejectsReservedFieldName() {
+ $engine = $this->newEngine()->setVersion(7);
+ $caught = null;
+ try {
+ $engine->buildIndexMappings(
+ array(), array('documentType'), array(), 'text');
+ } catch (Exception $e) {
+ $caught = $e;
+ }
+ $this->assertTrue(
+ $caught !== null,
+ pht('Expected exception when field name collides with reserved key.'));
+ }
+
+ public function testBuildIndexMappingsRejectsReservedRelationshipName() {
+ $engine = $this->newEngine()->setVersion(7);
+ $caught = null;
+ try {
+ $engine->buildIndexMappings(
+ array(), array(), array('lastModified'), 'text');
+ } catch (Exception $e) {
+ $caught = $e;
+ }
+ $this->assertTrue(
+ $caught !== null,
+ pht('Expected exception when relationship name collides with reserved key.'));
+ }
+
+ public function testBuildIndexMappingsRejectsFooTsClash() {
+ // A field named "foo_ts" would clash with the timestamp slot auto-generated
+ // for a relationship named "foo".
+ $engine = $this->newEngine()->setVersion(7);
+ $caught = null;
+ try {
+ $engine->buildIndexMappings(
+ array(), array('foo_ts'), array('foo'), 'text');
+ } catch (Exception $e) {
+ $caught = $e;
+ }
+ $this->assertTrue(
+ $caught !== null,
+ pht('Expected exception for field/relationship timestamp-slot collision.'));
+ }
+
+ public function testBuildIndexMappingsEmptyInputsYieldStandardFields() {
+ $engine = $this->newEngine()->setVersion(7);
+ $mappings = $engine->buildIndexMappings(array(), array(), array(), 'text');
+ $this->assertTrue(isset($mappings['properties']['documentType']));
+ $this->assertTrue(isset($mappings['properties']['dateCreated']));
+ $this->assertTrue(isset($mappings['properties']['lastModified']));
+ $this->assertEqual(
+ 'keyword', $mappings['properties']['documentType']['type']);
+ $this->assertEqual('date', $mappings['properties']['dateCreated']['type']);
+ $this->assertEqual('date', $mappings['properties']['lastModified']['type']);
+ }
+
+ public function testEngineIdentifier() {
+ $engine = $this->newEngine();
+ $this->assertEqual('elasticsearch-modern', $engine->getEngineIdentifier());
+ }
+
+ public function testHostType() {
+ $engine = $this->newEngine();
+ $host = $engine->getHostType();
+ $this->assertTrue($host instanceof VyOSElasticModernHost);
+ }
+
+ public function testGetTextFieldType() {
+ $engine = $this->newEngine()->setVersion(7);
+ $this->assertEqual('text', $engine->getTextFieldType());
+ }
+
+ public function testGetTimestampField() {
+ $engine = $this->newEngine()->setVersion(7);
+ $this->assertEqual('lastModified', $engine->getTimestampField());
+ }
+
+ public function testBuildSpecTreatsZeroQueryAsSearchTerm() {
+ // '0' is falsy in PHP but must be treated as a non-empty search term.
+ // strlen('0') == 1, so the query clause should be present and the
+ // default date-sorted path should NOT fire.
+ $engine = $this->newEngine()->setVersion(7);
+ $query = id(new PhabricatorSavedQuery())
+ ->setParameter('query', '0');
+
+ $method = new ReflectionMethod($engine, 'buildSpec');
+ $method->setAccessible(true);
+ $spec = $method->invoke($engine, $query, array('TASK'));
+
+ $this->assertFalse(isset($spec['sort']));
+ $this->assertEqual(
+ '0',
+ idxv(
+ $spec,
+ array('query', 'bool', 'must', 0, 'simple_query_string', 'query')));
+ }
+
+}
diff --git a/src/__tests__/VyOSElasticModernHostTestCase.php b/src/__tests__/VyOSElasticModernHostTestCase.php
new file mode 100644
index 0000000..8fcd9b6
--- /dev/null
+++ b/src/__tests__/VyOSElasticModernHostTestCase.php
@@ -0,0 +1,66 @@
+<?php
+
+final class VyOSElasticModernHostTestEngine
+ extends VyOSElasticModernFulltextStorageEngine {
+}
+
+final class VyOSElasticModernHostTestCase
+ extends PhutilTestCase {
+
+ public function testEngineIsConcreteForDispatch() {
+ // Must NOT be abstract: PhutilClassMapQuery → PhutilSymbolLoader::loadObjects()
+ // calls setConcreteOnly(true) which unsets abstract classes. A concrete (plain)
+ // class is required for cluster.search[].type:elasticsearch-modern dispatch.
+ $class = new ReflectionClass('VyOSElasticModernFulltextStorageEngine');
+ $this->assertFalse($class->isAbstract());
+ }
+
+ public function testDisplayName() {
+ $engine = new VyOSElasticModernHostTestEngine();
+ $host = new VyOSElasticModernHost($engine);
+ $this->assertEqual(
+ 'Elasticsearch (modern)',
+ (string)$host->getDisplayName());
+ }
+
+ public function testConfigDefaults() {
+ $engine = new VyOSElasticModernHostTestEngine();
+ $host = new VyOSElasticModernHost($engine);
+ $host->setConfig(array());
+ $this->assertEqual('http', $host->getProtocol());
+ $this->assertEqual('phabricator/', $host->getPath());
+ $this->assertEqual(7, $host->getVersion());
+ }
+
+ public function testConfigOverrides() {
+ $engine = new VyOSElasticModernHostTestEngine();
+ $host = new VyOSElasticModernHost($engine);
+ $host->setConfig(array(
+ 'host' => 'es.example.com',
+ 'port' => 9200,
+ 'protocol' => 'https',
+ 'path' => '/myphorge',
+ 'version' => 8,
+ ));
+ $this->assertEqual('es.example.com', $host->getHost());
+ $this->assertEqual(9200, $host->getPort());
+ $this->assertEqual('https', $host->getProtocol());
+ $this->assertEqual('/myphorge', $host->getPath());
+ $this->assertEqual(8, $host->getVersion());
+ }
+
+ public function testGetURI() {
+ $engine = new VyOSElasticModernHostTestEngine();
+ $host = new VyOSElasticModernHost($engine);
+ $host->setConfig(array(
+ 'host' => 'es.example.com',
+ 'port' => 9200,
+ 'protocol' => 'http',
+ 'path' => '/phabricator',
+ ));
+ $uri = (string)$host->getURI('/_doc/abc');
+ $this->assertTrue(strpos($uri, 'es.example.com') !== false);
+ $this->assertTrue(strpos($uri, '/_doc/abc') !== false);
+ }
+
+}
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;
+ }
+
+}
diff --git a/src/host/VyOSElasticModernHost.php b/src/host/VyOSElasticModernHost.php
new file mode 100644
index 0000000..ec86eb2
--- /dev/null
+++ b/src/host/VyOSElasticModernHost.php
@@ -0,0 +1,93 @@
+<?php
+
+/**
+ * Host subclass for the phorge-elasticsearch-modern extension.
+ *
+ * Mirrors PhabricatorElasticsearchHost's surface area; differs only in
+ * the display name and (in future versions) the auth config fields.
+ */
+final class VyOSElasticModernHost extends PhabricatorSearchHost {
+
+ private $version = 7;
+ private $path = 'phabricator/';
+ private $protocol = 'http';
+
+ const KEY_REFS = 'search.elasticmodern.refs';
+
+ public function setConfig($config) {
+ $this->setRoles(idx($config, 'roles', $this->getRoles()))
+ ->setHost(idx($config, 'host', $this->host))
+ ->setPort(idx($config, 'port', $this->port))
+ ->setProtocol(idx($config, 'protocol', $this->protocol))
+ ->setPath(idx($config, 'path', $this->path))
+ ->setVersion(idx($config, 'version', $this->version));
+ return $this;
+ }
+
+ public function getDisplayName() {
+ return pht('Elasticsearch (modern)');
+ }
+
+ public function getStatusViewColumns() {
+ return array(
+ pht('Protocol') => $this->getProtocol(),
+ pht('Host') => $this->getHost(),
+ pht('Port') => $this->getPort(),
+ pht('Index Path') => $this->getPath(),
+ pht('Version') => $this->getVersion(),
+ pht('Roles') => implode(', ', array_keys($this->getRoles())),
+ );
+ }
+
+ public function setProtocol($protocol) {
+ $this->protocol = $protocol;
+ return $this;
+ }
+
+ public function getProtocol() {
+ return $this->protocol;
+ }
+
+ public function setPath($path) {
+ $this->path = $path;
+ return $this;
+ }
+
+ public function getPath() {
+ return $this->path;
+ }
+
+ public function setVersion($version) {
+ $this->version = $version;
+ return $this;
+ }
+
+ public function getVersion() {
+ return $this->version;
+ }
+
+ /**
+ * @return PhutilURI
+ */
+ public function getURI($to_path = null) {
+ $uri = id(new PhutilURI('http://'.$this->getHost()))
+ ->setProtocol($this->getProtocol())
+ ->setPort($this->getPort())
+ ->setPath($this->getPath());
+
+ if ($to_path) {
+ $uri->appendPath($to_path);
+ }
+ return $uri;
+ }
+
+ public function getConnectionStatus() {
+ try {
+ $status = $this->getEngine()->indexIsSane($this);
+ return $status ? parent::STATUS_OKAY : parent::STATUS_FAIL;
+ } catch (Exception $e) {
+ return parent::STATUS_FAIL;
+ }
+ }
+
+}