From 77fdd523554e18abc3a834bbd52527f015bc4aa8 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 10:43:48 +0300 Subject: Add VyOSElasticModernHost + placeholder engine stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host class mirrors PhabricatorElasticsearchHost's surface. Engine is a stub providing only getEngineIdentifier() and getHostType() so the host's tests can construct an engine instance; real engine implementation lands in subsequent commits. All six abstract methods of PhabricatorFulltextStorageEngine are implemented (four as throwing stubs to be replaced in E7/E8). .arcconfig gains "src/" in the load list so arc unit can discover the extension library; test case extends PhutilTestCase (no DB needed for pure-PHP host property assertions). 🤖 Generated by [robots](https://vyos.io) --- .../VyOSElasticModernFulltextStorageEngine.php | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/engine/VyOSElasticModernFulltextStorageEngine.php (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php new file mode 100644 index 0000000..d10bac7 --- /dev/null +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -0,0 +1,40 @@ + Date: Fri, 22 May 2026 10:52:57 +0300 Subject: H1: Apply code-quality fixes from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine class marked final (matches host class declaration; no design intent to subclass). - testConfigOverrides now also asserts host and port (these were the most-likely-to-be-misconfigured fields; previously set but unverified). 🤖 Generated by [robots](https://vyos.io) --- src/__tests__/VyOSElasticModernHostTestCase.php | 2 ++ src/engine/VyOSElasticModernFulltextStorageEngine.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernHostTestCase.php b/src/__tests__/VyOSElasticModernHostTestCase.php index 1c5ef95..80b82c2 100644 --- a/src/__tests__/VyOSElasticModernHostTestCase.php +++ b/src/__tests__/VyOSElasticModernHostTestCase.php @@ -30,6 +30,8 @@ final class VyOSElasticModernHostTestCase '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()); diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index d10bac7..755bc2d 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -4,7 +4,7 @@ * Placeholder for the engine class. Real implementation grows in Phase 3-4. * Tasks E1-E8 replace the throwing stubs below with working code. */ -class VyOSElasticModernFulltextStorageEngine +final class VyOSElasticModernFulltextStorageEngine extends PhabricatorFulltextStorageEngine { public function getEngineIdentifier() { -- cgit v1.2.3 From 5e97aa48c13f75537915018754339b2336f14c1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 09:59:41 +0000 Subject: H1: keep placeholder engine undiscoverable --- src/__tests__/VyOSElasticModernHostTestCase.php | 17 +++++++++++++---- src/engine/VyOSElasticModernFulltextStorageEngine.php | 6 +++--- 2 files changed, 16 insertions(+), 7 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernHostTestCase.php b/src/__tests__/VyOSElasticModernHostTestCase.php index 80b82c2..a727a94 100644 --- a/src/__tests__/VyOSElasticModernHostTestCase.php +++ b/src/__tests__/VyOSElasticModernHostTestCase.php @@ -1,10 +1,19 @@ assertTrue($class->isAbstract()); + } + public function testDisplayName() { - $engine = new VyOSElasticModernFulltextStorageEngine(); + $engine = new VyOSElasticModernHostTestEngine(); $host = new VyOSElasticModernHost($engine); $this->assertEqual( 'Elasticsearch (modern)', @@ -12,7 +21,7 @@ final class VyOSElasticModernHostTestCase } public function testConfigDefaults() { - $engine = new VyOSElasticModernFulltextStorageEngine(); + $engine = new VyOSElasticModernHostTestEngine(); $host = new VyOSElasticModernHost($engine); $host->setConfig(array()); $this->assertEqual('http', $host->getProtocol()); @@ -21,7 +30,7 @@ final class VyOSElasticModernHostTestCase } public function testConfigOverrides() { - $engine = new VyOSElasticModernFulltextStorageEngine(); + $engine = new VyOSElasticModernHostTestEngine(); $host = new VyOSElasticModernHost($engine); $host->setConfig(array( 'host' => 'es.example.com', @@ -38,7 +47,7 @@ final class VyOSElasticModernHostTestCase } public function testGetURI() { - $engine = new VyOSElasticModernFulltextStorageEngine(); + $engine = new VyOSElasticModernHostTestEngine(); $host = new VyOSElasticModernHost($engine); $host->setConfig(array( 'host' => 'es.example.com', diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 755bc2d..f11eeab 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -1,10 +1,10 @@ Date: Fri, 22 May 2026 10:56:29 +0300 Subject: Engine: Add setVersion() with strict validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine accepts only versions 7 and above (covering ES 7.x, 8.x, and OpenSearch 1.x/2.x/3.x via the typeless API). Anything below 7 raises with a clear message pointing at the bundled engine for ES 5 users. 🤖 Generated by [robots](https://vyos.io) --- src/__phutil_library_map__.php | 2 ++ ...SElasticModernFulltextStorageEngineTestCase.php | 38 ++++++++++++++++++++++ .../VyOSElasticModernFulltextStorageEngine.php | 21 ++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 298810e..d365d58 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -10,12 +10,14 @@ phutil_register_library_map(array( '__library_version__' => 2, 'class' => array( 'VyOSElasticModernFulltextStorageEngine' => 'engine/VyOSElasticModernFulltextStorageEngine.php', + 'VyOSElasticModernFulltextStorageEngineTestCase' => '__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php', 'VyOSElasticModernHost' => 'host/VyOSElasticModernHost.php', 'VyOSElasticModernHostTestCase' => '__tests__/VyOSElasticModernHostTestCase.php', ), 'function' => array(), 'xmap' => array( 'VyOSElasticModernFulltextStorageEngine' => 'PhabricatorFulltextStorageEngine', + 'VyOSElasticModernFulltextStorageEngineTestCase' => 'PhutilTestCase', 'VyOSElasticModernHost' => 'PhabricatorSearchHost', 'VyOSElasticModernHostTestCase' => 'PhutilTestCase', ), diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php new file mode 100644 index 0000000..7a41ca1 --- /dev/null +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -0,0 +1,38 @@ +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 = new VyOSElasticModernFulltextStorageEngine(); + $caught = null; + try { + $engine->setVersion($v); + } catch (Exception $e) { + $caught = $e; + } + $this->assertTrue( + $caught !== null, + pht('Expected an exception for version=%d.', $v)); + } + } + +} diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index f11eeab..60683b1 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -7,6 +7,27 @@ abstract class VyOSElasticModernFulltextStorageEngine extends PhabricatorFulltextStorageEngine { + private $version; + + 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() { + return $this->version; + } + public function getEngineIdentifier() { return 'elasticsearch-modern'; } -- cgit v1.2.3 From ed0dee0333acf8a1c79845bba6455506c8210bad Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 13:13:22 +0300 Subject: E1 fixup: initialize $version to null; getVersion() throws if unset; use TestDouble in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by [robots](https://vyos.io) --- src/__phutil_library_map__.php | 2 ++ ...SElasticModernFulltextStorageEngineTestCase.php | 35 ++++++++++++++++++++-- .../VyOSElasticModernFulltextStorageEngine.php | 6 +++- 3 files changed, 40 insertions(+), 3 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index d365d58..291fb03 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -11,6 +11,7 @@ phutil_register_library_map(array( 'class' => array( 'VyOSElasticModernFulltextStorageEngine' => 'engine/VyOSElasticModernFulltextStorageEngine.php', 'VyOSElasticModernFulltextStorageEngineTestCase' => '__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php', + 'VyOSElasticModernFulltextStorageEngineTestDouble' => '__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php', 'VyOSElasticModernHost' => 'host/VyOSElasticModernHost.php', 'VyOSElasticModernHostTestCase' => '__tests__/VyOSElasticModernHostTestCase.php', ), @@ -18,6 +19,7 @@ phutil_register_library_map(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 index 7a41ca1..7c0755c 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -1,11 +1,42 @@ newEngine(); $caught = null; try { $engine->setVersion($v); @@ -22,7 +53,7 @@ final class VyOSElasticModernFulltextStorageEngineTestCase public function testSetVersionRejectsBelowSeven() { foreach (array(0, 1, 2, 5, 6) as $v) { - $engine = new VyOSElasticModernFulltextStorageEngine(); + $engine = $this->newEngine(); $caught = null; try { $engine->setVersion($v); diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 60683b1..2570311 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -7,7 +7,7 @@ abstract class VyOSElasticModernFulltextStorageEngine extends PhabricatorFulltextStorageEngine { - private $version; + private $version = null; public function setVersion($version) { $version = (int)$version; @@ -25,6 +25,10 @@ abstract class VyOSElasticModernFulltextStorageEngine } public function getVersion() { + if ($this->version === null) { + throw new Exception( + pht('Version not configured; call setVersion() or setService() first.')); + } return $this->version; } -- cgit v1.2.3 From 249feeb0a6d43b8f98f1e4d48e5cc3f6edc19998 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 10:58:20 +0300 Subject: Engine: Add getDocumentUri() helper for typeless document URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by [robots](https://vyos.io) --- .../VyOSElasticModernFulltextStorageEngineTestCase.php | 16 ++++++++++++++++ src/engine/VyOSElasticModernFulltextStorageEngine.php | 4 ++++ 2 files changed, 20 insertions(+) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index 7c0755c..5ced477 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -66,4 +66,20 @@ final class VyOSElasticModernFulltextStorageEngineTestCase } } + public function testGetDocumentUri() { + $engine = id(new VyOSElasticModernFulltextStorageEngine()) + ->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 = id(new VyOSElasticModernFulltextStorageEngine()) + ->setVersion(7); + $uri_a = $engine->getDocumentUri('TASK', 'PHID-TASK-abc'); + $uri_b = $engine->getDocumentUri('DREV', 'PHID-TASK-abc'); + $this->assertEqual($uri_a, $uri_b); + } + } diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 2570311..cf9278b 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -32,6 +32,10 @@ abstract class VyOSElasticModernFulltextStorageEngine return $this->version; } + public function getDocumentUri($type, $phid) { + return '/_doc/'.$phid; + } + public function getEngineIdentifier() { return 'elasticsearch-modern'; } -- cgit v1.2.3 From 4a2e5781883f8963c36ee129cf76c43e887ac4dc Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 11:00:04 +0300 Subject: Engine: Add getSearchUri() helper (typeless search endpoint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by [robots](https://vyos.io) --- .../VyOSElasticModernFulltextStorageEngineTestCase.php | 10 ++++++++++ src/engine/VyOSElasticModernFulltextStorageEngine.php | 4 ++++ 2 files changed, 14 insertions(+) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index 3a96bac..e3cbef5 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -80,4 +80,14 @@ final class VyOSElasticModernFulltextStorageEngineTestCase $this->assertEqual($uri_a, $uri_b); } + public function testGetSearchUri() { + $engine = id(new VyOSElasticModernFulltextStorageEngine()) + ->setVersion(7); + $this->assertEqual( + '/_search', + $engine->getSearchUri(array('TASK', 'DREV'))); + $this->assertEqual('/_search', $engine->getSearchUri(array('USER'))); + $this->assertEqual('/_search', $engine->getSearchUri(array())); + } + } diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index cf9278b..e21021f 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -36,6 +36,10 @@ abstract class VyOSElasticModernFulltextStorageEngine return '/_doc/'.$phid; } + public function getSearchUri(array $types) { + return '/_search'; + } + public function getEngineIdentifier() { return 'elasticsearch-modern'; } -- cgit v1.2.3 From a529a40ec8569a7010df4965c4e7fff9e81eb125 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 11:38:59 +0300 Subject: Engine: Add buildTypeFilter() helper (body-level documentType filter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by [robots](https://vyos.io) --- ...SElasticModernFulltextStorageEngineTestCase.php | 25 ++++++++++++++++++++++ .../VyOSElasticModernFulltextStorageEngine.php | 8 +++++++ 2 files changed, 33 insertions(+) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index 1762d32..95558a1 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -89,4 +89,29 @@ final class VyOSElasticModernFulltextStorageEngineTestCase $this->assertEqual('/_search', $engine->getSearchUri(array())); } + public function testBuildTypeFilter() { + $engine = id(new VyOSElasticModernFulltextStorageEngine()) + ->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 = id(new VyOSElasticModernFulltextStorageEngine()) + ->setVersion(7); + $filter = $engine->buildTypeFilter(array()); + $this->assertEqual( + array('terms' => array('documentType' => array())), + $filter); + } + } diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index e21021f..316a02b 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -40,6 +40,14 @@ abstract class VyOSElasticModernFulltextStorageEngine return '/_search'; } + public function buildTypeFilter(array $types) { + return array( + 'terms' => array( + 'documentType' => array_values($types), + ), + ); + } + public function getEngineIdentifier() { return 'elasticsearch-modern'; } -- cgit v1.2.3 From fc0e71e28e49d9cdc84fadfda40692bdfc415a6c Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 11:40:40 +0300 Subject: Engine: Add buildIndexMappings() helper (single typeless mapping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single 'properties' block, documentType as a keyword field inside it, no include_in_all anywhere (the field was removed in ES 6). Relationships emit as keyword fields with doc_values: false, matching the bundled engine's ES-5-mode behavior. Field-data multi-analyzer shape (raw, keywords, stems) preserved. 🤖 Generated by [robots](https://vyos.io) --- ...SElasticModernFulltextStorageEngineTestCase.php | 54 ++++++++++++++++++++++ .../VyOSElasticModernFulltextStorageEngine.php | 47 +++++++++++++++++++ 2 files changed, 101 insertions(+) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index ff4ade8..ab6f5c6 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -112,4 +112,58 @@ final class VyOSElasticModernFulltextStorageEngineTestCase $filter); } + public function testBuildIndexMappingsShape() { + $engine = id(new VyOSElasticModernFulltextStorageEngine()) + ->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'])); + + // Field properties exist with the multi-analyzer shape. + $this->assertTrue(isset($mappings['properties']['title'])); + $this->assertEqual('text', $mappings['properties']['title']['type']); + $this->assertTrue( + isset($mappings['properties']['title']['fields']['raw'])); + $this->assertTrue( + isset($mappings['properties']['title']['fields']['keywords'])); + $this->assertTrue( + isset($mappings['properties']['title']['fields']['stems'])); + + // Relationships emit as keyword fields with doc_values:false. + $this->assertEqual( + 'keyword', + $mappings['properties']['authorPHID']['type']); + $this->assertEqual( + false, + $mappings['properties']['authorPHID']['doc_values']); + $this->assertEqual( + 'date', + $mappings['properties']['authorPHID_ts']['type']); + + // No include_in_all anywhere. + $this->assertFalse( + isset($mappings['properties']['authorPHID']['include_in_all'])); + + // 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']); + } + } diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 316a02b..16c519c 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -48,6 +48,53 @@ abstract class VyOSElasticModernFulltextStorageEngine ); } + public function buildIndexMappings( + array $doc_types, array $fields, array $relationships, $text_type) { + + $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'; } -- cgit v1.2.3 From b05490e9be22d8e9ac576ac62b646ddd9b5f3ffe Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 13:28:12 +0300 Subject: E5 fixup: guard buildIndexMappings() against reserved field name collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by [robots](https://vyos.io) --- src/engine/VyOSElasticModernFulltextStorageEngine.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 16c519c..f798e4b 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -51,6 +51,23 @@ abstract class VyOSElasticModernFulltextStorageEngine 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'); + + $all_caller_keys = array_merge( + $fields, + $relationships, + array_map(function($r) { return $r.'_ts'; }, $relationships)); + $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)))); + } + $properties = array(); foreach ($fields as $field) { -- cgit v1.2.3 From de83584f8b782bde516d6b39f9f1bc4214585058 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 13:31:46 +0300 Subject: E5 fixup: detect intra-key duplicates in buildIndexMappings(); add collision tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by [robots](https://vyos.io) --- ...SElasticModernFulltextStorageEngineTestCase.php | 56 ++++++++++++++++++++++ .../VyOSElasticModernFulltextStorageEngine.php | 21 ++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index 979c419..e3daab4 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -180,4 +180,60 @@ final class VyOSElasticModernFulltextStorageEngineTestCase $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']); + } + } diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index f798e4b..fe88adc 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -55,10 +55,10 @@ abstract class VyOSElasticModernFulltextStorageEngine // Caller-supplied $fields or $relationships must not shadow them. static $reserved = array('documentType', 'dateCreated', 'lastModified'); - $all_caller_keys = array_merge( - $fields, - $relationships, - array_map(function($r) { return $r.'_ts'; }, $relationships)); + $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( @@ -68,6 +68,19 @@ abstract class VyOSElasticModernFulltextStorageEngine 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) { -- cgit v1.2.3 From f945e302f79086c6ea99bd45d414fe6bccefb9d4 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 13:44:01 +0300 Subject: E6 fixup: use trim() + empty-string guard in setService(); use newEngine() in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - str_replace('/', '', \$index) → trim(\$index, '/') to preserve internal slashes in multi-segment index paths (e.g. 'phabricator/prod' must stay intact; only leading/trailing slashes are stripped). - Add empty-string guard: after trim, throw a clear exception if the result is '' so misconfigured paths fail loudly at setService() rather than silently at query time with a confusing endpoint URL. - Replace direct new VyOSElasticModernFulltextStorageEngine() in the four new test methods with \$this->newEngine() to avoid PHP fatal on abstract class instantiation. 🤖 Generated by [robots](https://vyos.io) --- ...SElasticModernFulltextStorageEngineTestCase.php | 21 ++++++++++++ .../VyOSElasticModernFulltextStorageEngine.php | 40 ++++++++++++++++++++++ 2 files changed, 61 insertions(+) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index e3daab4..9f08f8c 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -236,4 +236,25 @@ final class VyOSElasticModernFulltextStorageEngineTestCase $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()); + } + } diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index fe88adc..42e2fd6 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -8,6 +8,46 @@ abstract class VyOSElasticModernFulltextStorageEngine extends PhabricatorFulltextStorageEngine { private $version = null; + private $index; + + 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; -- cgit v1.2.3 From e0eb6dd6b21c129c775d8bb2bec203ae0d756d0f Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 11:48:30 +0300 Subject: Engine: Add reindexAbstractDocument() + executeRequest() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reindexAbstractDocument builds the document body with documentType as a real field (replacing the bundled engine's per-type URL segment), then PUTs to the typeless /_doc/{phid} URL via the new helper. executeRequest mirrors the bundled engine's HTTPSFuture pattern; it's private and not test-targeted directly (smoke tests in V1-V3 exercise the full wire path). 🤖 Generated by [robots](https://vyos.io) --- .../VyOSElasticModernFulltextStorageEngine.php | 104 ++++++++++++++++++++- 1 file changed, 101 insertions(+), 3 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 42e2fd6..211a196 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -9,6 +9,7 @@ abstract class VyOSElasticModernFulltextStorageEngine private $version = null; private $index; + private $timeout = 15; public function setService(PhabricatorSearchService $service) { $this->service = $service; // inherited protected property @@ -173,11 +174,108 @@ abstract class VyOSElasticModernFulltextStorageEngine return new VyOSElasticModernHost($this); } + public function setTimeout($timeout) { + $this->timeout = $timeout; + return $this; + } + + public function getTimeout() { + return $this->timeout; + } + public function reindexAbstractDocument( PhabricatorSearchAbstractDocument $doc) { - throw new Exception(pht( - 'reindexAbstractDocument() is not yet implemented on '. - 'VyOSElasticModernFulltextStorageEngine; lands in Task E7.')); + + $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) { + $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; + } + + if ($method !== 'GET') { + return null; + } + + try { + $decoded = phutil_json_decode($body); + $host->didHealthCheck(true); + return $decoded; + } catch (PhutilJSONParserException $ex) { + $host->didHealthCheck(false); + throw new Exception( + pht('Elasticsearch server returned invalid JSON.'), + 0, + $ex); + } } public function executeSearch(PhabricatorSavedQuery $query) { -- cgit v1.2.3 From 3a4580e35a0c7a6480aeee9ec4acf90d0fa85326 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 13:47:45 +0300 Subject: E7 fixup: move didHealthCheck(true) to HTTP-success boundary in executeRequest() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the success health-check was called inside the JSON-decode try block. This was wrong: a valid HTTP 200 response with non-JSON body (e.g. ES maintenance page) would mark the host as *unhealthy* because JSON parse failure triggered didHealthCheck(false). The host is demonstrably reachable and responding; only the application layer failed. Move didHealthCheck(true) to immediately after list($body) = resolvex() succeeds, before the GET/non-GET branch. Remove the now-unnecessary didHealthCheck(false) from the JSON catch block — only network/timeout failures belong in the health-check false path. 🤖 Generated by [robots](https://vyos.io) --- src/engine/VyOSElasticModernFulltextStorageEngine.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 211a196..e036082 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -261,16 +261,18 @@ abstract class VyOSElasticModernFulltextStorageEngine 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 { - $decoded = phutil_json_decode($body); - $host->didHealthCheck(true); - return $decoded; + return phutil_json_decode($body); } catch (PhutilJSONParserException $ex) { - $host->didHealthCheck(false); throw new Exception( pht('Elasticsearch server returned invalid JSON.'), 0, -- cgit v1.2.3 From 58ab9f6cacf8735adec2413e7404b34fc129f8a5 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 11:52:43 +0300 Subject: Engine: Wire executeSearch, index lifecycle, buildSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full public surface lands. executeSearch normalizes \$types before both URL building and body filter construction. buildSpec uses the bundled PhabricatorElasticsearchQueryBuilder (final, by composition) and emits bool.must_not.ids for the exclude parameter from day one. buildSpec also injects the body-level documentType filter via buildTypeFilter. indexExists/indexIsSane/initIndex/getIndexStats mirror the bundled engine but without the version-branching dead code. The private check() and normalizeConfigValue() helpers are copied verbatim from the bundled engine (no behavior difference). catch (Exception) widened to catch (Throwable) per XHP132. normalizeConfigValue() one-liners braced per XHP24. Lint clean. 🤖 Generated by [robots](https://vyos.io) --- .../VyOSElasticModernFulltextStorageEngine.php | 291 ++++++++++++++++++++- 1 file changed, 283 insertions(+), 8 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index e036082..199f2c8 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -281,18 +281,293 @@ abstract class VyOSElasticModernFulltextStorageEngine } public function executeSearch(PhabricatorSavedQuery $query) { - throw new Exception(pht( - 'executeSearch() is not yet implemented; lands in Task E8.')); + $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 (Throwable $e) { + $exceptions[] = $e; + } + } + throw new PhutilAggregateException( + pht('All Fulltext Search hosts failed:'), + $exceptions); } - public function indexExists() { - throw new Exception(pht( - 'indexExists() is not yet implemented; lands in Task E8.')); + private function buildSpec(PhabricatorSavedQuery $query, array $types) { + $q = new PhabricatorElasticsearchQueryBuilder(); + $query_string = $query->getParameter('query'); + if (strlen($query_string)) { + $fields = $this->getTypeConstants('PhabricatorSearchDocumentFieldType'); + $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. + $q->addMustNotClause(array( + 'ids' => 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(), + ), + ); + + if (!$query->getParameter('query')) { + $spec['sort'] = array( + array('dateCreated' => 'desc'), + ); + } + + $offset = (int)$query->getParameter('offset', 0); + $limit = (int)$query->getParameter('limit', 101); + 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 getIndexStats() { - throw new Exception(pht( - 'getIndexStats() is not yet implemented; lands in Task E8.')); + 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 (HTTPFutureHTTPResponseStatus $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()) { + $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()); + $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; } } -- cgit v1.2.3 From 51ceab16c95efc5527445788cb83f0289675d0bf Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 22 May 2026 12:00:28 +0300 Subject: E6-E8: Apply code-quality fixes from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead $fields assignment in buildSpec() (copy-paste artifact from bundled engine; never actually used). - Revert executeSearch catch from Throwable back to Exception. The earlier lint-driven widening was over-broad: Error subclasses (TypeError, etc.) should propagate immediately, not be aggregated into the per-host failover loop. Comment added explaining the intent. 🤖 Generated by [robots](https://vyos.io) --- src/engine/VyOSElasticModernFulltextStorageEngine.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 199f2c8..c49ff5e 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -296,7 +296,12 @@ abstract class VyOSElasticModernFulltextStorageEngine $response = $this->executeRequest($host, $uri, $spec); $phids = ipull($response['hits']['hits'], '_id'); return $phids; - } catch (Throwable $e) { + } 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; } } @@ -309,7 +314,6 @@ abstract class VyOSElasticModernFulltextStorageEngine $q = new PhabricatorElasticsearchQueryBuilder(); $query_string = $query->getParameter('query'); if (strlen($query_string)) { - $fields = $this->getTypeConstants('PhabricatorSearchDocumentFieldType'); $q->addMustClause(array( 'simple_query_string' => array( 'query' => $query_string, -- cgit v1.2.3 From 4d836601cb2f602c0089134eda1604f605fb39da Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 13:54:24 +0300 Subject: E8 fixup: four correctness fixes from Phase 0 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. strlen(\$query_string) → null-safe check \$query->getParameter('query') may return null; strlen(null) is deprecated in PHP 8.1+. Switch to explicit !== null && !== '' check. 2. ids.values nesting fix in buildSpec() 'values' => array(\$exclude) wraps an already-array \$exclude in a nested array, producing invalid Elasticsearch ids syntax. Cast to (array) then re-index with array_values() so both scalar PHID and array-of-PHIDs produce a flat list. 3. initIndex() host consistency: pass write host to indexExists() initIndex() called indexExists() without a host argument, which fell back to the read host, then deleted via getHostForWrite(). A read/write split lag window could cause false-positive "index doesn't exist" results. Pass the already-resolved write host. 4. getIndexStats() unchecked array access \$res['indices'][\$this->index] was accessed without verifying the key exists. If the index was just deleted or the name drifted, this throws an undefined-offset PHP notice. Add an explicit isset() guard with a clear exception message naming the index. 🤖 Generated by [robots](https://vyos.io) --- ...yOSElasticModernFulltextStorageEngineTestCase.php | 20 ++++++++++++++++++++ .../VyOSElasticModernFulltextStorageEngine.php | 14 +++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index 9f08f8c..8a8b29c 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -257,4 +257,24 @@ final class VyOSElasticModernFulltextStorageEngineTestCase $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/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index c49ff5e..70844f9 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -313,7 +313,7 @@ abstract class VyOSElasticModernFulltextStorageEngine private function buildSpec(PhabricatorSavedQuery $query, array $types) { $q = new PhabricatorElasticsearchQueryBuilder(); $query_string = $query->getParameter('query'); - if (strlen($query_string)) { + if ($query_string !== null && $query_string !== '') { $q->addMustClause(array( 'simple_query_string' => array( 'query' => $query_string, @@ -343,9 +343,11 @@ abstract class VyOSElasticModernFulltextStorageEngine $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($exclude), + 'values' => array_values((array)$exclude), ), )); } @@ -460,7 +462,7 @@ abstract class VyOSElasticModernFulltextStorageEngine public function initIndex() { $host = $this->getHostForWrite(); - if ($this->indexExists()) { + if ($this->indexExists($host)) { $this->executeRequest($host, '/', array(), 'DELETE'); } $data = $this->getIndexConfiguration(); @@ -472,6 +474,12 @@ abstract class VyOSElasticModernFulltextStorageEngine $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') => -- cgit v1.2.3 From e36bce7dee75eccf67ae4737452275a6a01d7bb9 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 15:08:18 +0300 Subject: fix: use correct HTTPFutureResponseStatus in indexExists() catch clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 CodeRabbit caught a typo — HTTPFutureHTTPResponseStatus does not exist in the libphutil type hierarchy; the correct class is HTTPFutureResponseStatus (consistent with line 257 in executeRequest()). 🤖 Generated by [robots](https://vyos.io) --- src/engine/VyOSElasticModernFulltextStorageEngine.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 70844f9..4c14ce0 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -436,7 +436,7 @@ abstract class VyOSElasticModernFulltextStorageEngine try { $res = $this->executeRequest($host, '/_stats/', array()); return isset($res['indices'][$this->index]); - } catch (HTTPFutureHTTPResponseStatus $e) { + } catch (HTTPFutureResponseStatus $e) { if ($e->getStatusCode() == 404) { return false; } -- cgit v1.2.3 From 4d27ac74aff1ca47db53510bf07e9d083635175b Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 15:14:02 +0300 Subject: fix: correct Phase-0 findings from promotion PR review (3 issues) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. TestDouble signatures: indexExists() and getIndexStats() in VyOSElasticModernFulltextStorageEngineTestDouble were missing the nullable host parameter added in E7/E8, causing a PHP fatal on 'arc unit --everything'. 2. buildSpec() zero-query sort bug: the falsy check (!$query->getParameter('query')) incorrectly treats the string '0' as empty (PHP falsy), enabling date-sorted path for a real search term. Changed to explicit null/empty-string checks to match the existing must-clause guard on line 316. All 22 unit tests now pass. 🤖 Generated by [robots](https://vyos.io) --- src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php | 4 ++-- src/engine/VyOSElasticModernFulltextStorageEngine.php | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php index 8a8b29c..0362f95 100644 --- a/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php +++ b/src/__tests__/VyOSElasticModernFulltextStorageEngineTestCase.php @@ -17,11 +17,11 @@ final class VyOSElasticModernFulltextStorageEngineTestDouble return array(); } - public function indexExists() { + public function indexExists(?VyOSElasticModernHost $host = null) { return false; } - public function getIndexStats() { + public function getIndexStats(?VyOSElasticModernHost $host = null) { return array(); } diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 4c14ce0..7fc7fd7 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -410,7 +410,8 @@ abstract class VyOSElasticModernFulltextStorageEngine ), ); - if (!$query->getParameter('query')) { + $sort_query_string = $query->getParameter('query'); + if ($sort_query_string === null || $sort_query_string === '') { $spec['sort'] = array( array('dateCreated' => 'desc'), ); -- cgit v1.2.3 From a19f5c077c0ed8ee4d88c0c8d704995b155d3dc2 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 15:35:17 +0300 Subject: Address PR 16 CR findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README cluster.search example uses 'phabricator/' to match host default (was '/phabricator'; both work for index name but the host uses path verbatim for the base URI). - Add VERIFICATION.md placeholder with the planned smoke-test matrix template; status notes v0.1.0 is pending IS-474. - Engine reindex: replace 'if ($time)' falsy check with 'if ($time !== null)' so a Unix epoch timestamp (0) is not silently dropped. 🤖 Generated by [robots](https://vyos.io) --- README.md | 2 +- VERIFICATION.md | 35 ++++++++++++++++++++++ .../VyOSElasticModernFulltextStorageEngine.php | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 VERIFICATION.md (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/README.md b/README.md index b14e528..a249325 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ v0.1.0 — initial release. No auth support yet (deferred to v0.2). See the [Lim } ], "version": 7, - "path": "/phabricator" + "path": "phabricator/" } ] } diff --git a/VERIFICATION.md b/VERIFICATION.md new file mode 100644 index 0000000..33f6734 --- /dev/null +++ b/VERIFICATION.md @@ -0,0 +1,35 @@ +# Verification Matrix — v0.1.0 + +The smoke-test matrix for v0.1.0 is **pending** execution. It will run as part of the vyos.dev cutover under [IS-474](https://vyos.atlassian.net/browse/IS-474), or as a follow-up if the operator stands up a dedicated local Phorge dev environment beforehand. + +## Planned procedure + +Each release will be smoke-tested against three backends in Docker: + +| Backend | Image | Purpose | +|---------|-------|---------| +| Elasticsearch | `docker.elastic.co/elasticsearch/elasticsearch:7.17.24` | Primary target for IS-474 | +| Elasticsearch | `docker.elastic.co/elasticsearch/elasticsearch:8.15.x` | Current ES major | +| OpenSearch | `opensearchproject/opensearch:2.17.x` | OpenSearch wire-compat | + +For each backend, the procedure is: + +1. Start container (with security disabled for ES 8.x / OpenSearch). +2. Configure Phorge `cluster.search` with `type: elasticsearch-modern`, the appropriate `version` (7 / 8 / 7), and explicit `hosts[].roles`. +3. `bin/search init` — verify the index is created with the typeless mapping shape (`mappings.properties`, `documentType` keyword field present, no `include_in_all`). +4. `bin/search index --all` — verify documents are indexed without errors. +5. Hit `/search/?query=test` in the web UI — verify hits and ranking. +6. Edit a task, save — verify the document is updated in ES (incremental indexing). +7. Exercise the `exclude` saved-query parameter via a one-off `scripts/verify-exclude.php` REPL script — confirm the wire-level outbound body contains `bool.must_not.ids` and no `not` key. + +## Results + +Will be populated per release tag once the matrix runs. The latest release with completed verification will list: + +- Date of run +- Phorge commit + extension commit tested against +- Per-backend PASS/FAIL with relevant evidence (logs, screenshots, mapping JSON) + +## Status: v0.1.0 + +Pending. See [IS-474](https://vyos.atlassian.net/browse/IS-474) for the cutover execution; results land here when complete. diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 7fc7fd7..f290513 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -225,7 +225,7 @@ abstract class VyOSElasticModernFulltextStorageEngine } else { $spec[$field_name][] = $related_phid; } - if ($time) { + if ($time !== null) { $spec[$field_name.'_ts'] = $time; } } -- cgit v1.2.3 From 011eec12f9176a6a8b8d8b9133bc00107a7855c0 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sat, 23 May 2026 15:48:18 +0300 Subject: Engine: revert abstract → plain class for production dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recent fixup cascade declared VyOSElasticModernFulltextStorageEngine as 'abstract class' to enable test-doubling subclasses (VyOSElasticModernHostTestEngine, VyOSElasticModernFulltextStorageEngineTestDouble). That change broke production dispatch: Phorge's PhutilClassMapQuery selects engines via PhutilSymbolLoader::loadObjects(), which calls setConcreteOnly(true) and unsets any class where reflection reports isAbstract(). The engine class would no longer be discoverable by cluster.search[].type:elasticsearch-modern, and the test-fixture subclasses live in src/__tests__/ which libphutil excludes from runtime class-map loading. Revert to plain 'class' (not 'final', not 'abstract'). Plain 'class' is discoverable by PhutilClassMapQuery; existing test fixtures keep working without rewrite; operators can subclass if they need to (harmless). Also update VyOSElasticModernHostTestCase::testPlaceholderEngineIsAbstract → testEngineIsConcreteForDispatch, inverting the assertion to document the correct invariant and its rationale (setConcreteOnly reference). All 22 tests pass. 🤖 Generated by [robots](https://vyos.io) --- src/__tests__/VyOSElasticModernHostTestCase.php | 7 +++++-- src/engine/VyOSElasticModernFulltextStorageEngine.php | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/__tests__/VyOSElasticModernHostTestCase.php b/src/__tests__/VyOSElasticModernHostTestCase.php index a727a94..8fcd9b6 100644 --- a/src/__tests__/VyOSElasticModernHostTestCase.php +++ b/src/__tests__/VyOSElasticModernHostTestCase.php @@ -7,9 +7,12 @@ final class VyOSElasticModernHostTestEngine final class VyOSElasticModernHostTestCase extends PhutilTestCase { - public function testPlaceholderEngineIsAbstract() { + 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->assertTrue($class->isAbstract()); + $this->assertFalse($class->isAbstract()); } public function testDisplayName() { diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index f290513..5c3d9a2 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -1,10 +1,11 @@ Date: Sat, 23 May 2026 17:16:51 +0300 Subject: Engine: fix negative pagination bounds and _ts accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness bugs surfaced by CR / Phase 0, both inherited from the bundled PhabricatorElasticFulltextStorageEngine pattern: 1. executeSearch() did not validate that $offset / $limit were non-negative. PHP's (int) cast on a negative string yields a negative int, which would be passed unchecked to Elasticsearch. Add an explicit non-negative check before the existing upper- bound (offset + limit > 10000) check. 2. reindexAbstractDocument()'s relationship-data loop assigned the '_ts' field unconditionally instead of accumulating. For multi- valued relationships (multiple authorPHID, projectPHID, etc. entries on the same doc), only the last timestamp survived. Change to array-append, parallel to the PHID array. Both fixes diverge from the bundled engine; bundled retains the faithful-but-broken behavior. Worth fixing in this extension since we control the wire format. 🤖 Generated by [robots](https://vyos.io) --- src/engine/VyOSElasticModernFulltextStorageEngine.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) (limited to 'src/engine/VyOSElasticModernFulltextStorageEngine.php') diff --git a/src/engine/VyOSElasticModernFulltextStorageEngine.php b/src/engine/VyOSElasticModernFulltextStorageEngine.php index 5c3d9a2..ef4a14d 100644 --- a/src/engine/VyOSElasticModernFulltextStorageEngine.php +++ b/src/engine/VyOSElasticModernFulltextStorageEngine.php @@ -227,7 +227,11 @@ class VyOSElasticModernFulltextStorageEngine $spec[$field_name][] = $related_phid; } if ($time !== null) { - $spec[$field_name.'_ts'] = $time; + if (!isset($spec[$field_name.'_ts'])) { + $spec[$field_name.'_ts'] = array($time); + } else { + $spec[$field_name.'_ts'][] = $time; + } } } @@ -420,6 +424,11 @@ class VyOSElasticModernFulltextStorageEngine $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)', -- cgit v1.2.3