From d8eebb183a9ca34fee42b8af1479dc73c7bd691d Mon Sep 17 00:00:00 2001 From: Bart van Bragt Date: Tue, 14 Apr 2026 16:05:59 +0200 Subject: [PATCH] fix: prevent ES CPU saturation from has_child scoring all posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without minimum_should_match=1 on the inner post bool query, Elasticsearch defaults MSM to 0 whenever a filter clause is present. This caused has_child to score every non-hidden post on every search request, saturating CPU on shared ES nodes with large corpora (3.7M+ posts). - Add BoolQuery subclass with create() override and minimumShouldMatch() (spatie/elasticsearch-query-builder 1.x uses `new self()` in create(), so subclassing requires overriding it) - Set minimumShouldMatch(1) on the inner post query after adding the is_hidden filter, so only genuinely matching posts are scored - Remove the operator('or') clause from buildShouldClauses() — with Turkish min_ngram=2, 'or' generates 2-gram tokens that match nearly every post, causing near-total index scans - Add ES client timeouts (connect: 2s, query: 10s) to prevent Apache mod_php worker saturation when ES is slow or unreachable --- src/Api/Controllers/SearchController.php | 7 ++-- src/Elasticsearch/BoolQuery.php | 41 ++++++++++++++++++++++++ src/Provider.php | 8 ++++- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 src/Elasticsearch/BoolQuery.php diff --git a/src/Api/Controllers/SearchController.php b/src/Api/Controllers/SearchController.php index 9d5f7e7..893b9d8 100644 --- a/src/Api/Controllers/SearchController.php +++ b/src/Api/Controllers/SearchController.php @@ -37,7 +37,7 @@ use Illuminate\Support\Str; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Spatie\ElasticsearchQueryBuilder\Builder; -use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery; +use Blomstra\Search\Elasticsearch\BoolQuery; use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery; use Spatie\ElasticsearchQueryBuilder\Sorts\Sort; use Tobscure\JsonApi\Document; @@ -217,6 +217,10 @@ class SearchController extends ListDiscussionsController $postQuery->add(TermQuery::create('is_hidden', 'false'), 'filter'); } + // Without minimum_should_match, ES default MSM is 0 when a filter clause is present, + // causing has_child to score every non-hidden post instead of only matching ones. + $postQuery->minimumShouldMatch(1); + $textQuery->add( HasChildQuery::create('post', $postQuery)->withInnerHits(), 'should' @@ -235,7 +239,6 @@ class SearchController extends ListDiscussionsController } if ($this->matchWords) { $should->add((new MatchQuery('content', $search))->operator('and')->boost(1.8 * $boost), 'should'); - $should->add((new MatchQuery('content', $search))->operator('or')->boost(0.8 * $boost), 'should'); } return $should; diff --git a/src/Elasticsearch/BoolQuery.php b/src/Elasticsearch/BoolQuery.php new file mode 100644 index 0000000..ac900e5 --- /dev/null +++ b/src/Elasticsearch/BoolQuery.php @@ -0,0 +1,41 @@ +minimumShouldMatch = $minimum; + + return $this; + } + + public function toArray(): array + { + $array = parent::toArray(); + + if ($this->minimumShouldMatch !== null) { + $array['bool']['minimum_should_match'] = $this->minimumShouldMatch; + } + + return $array; + } +} diff --git a/src/Provider.php b/src/Provider.php index dcf5163..2cd5ad4 100644 --- a/src/Provider.php +++ b/src/Provider.php @@ -47,7 +47,13 @@ class Provider extends AbstractServiceProvider $this->container->singleton(Elastic::class, function (Container $container) use ($settings, $config) { $builder = ClientBuilder::create() - ->setHosts([$settings->get('blomstra-search.elastic-endpoint')]); + ->setHosts([$settings->get('blomstra-search.elastic-endpoint')]) + ->setConnectionParams([ + 'client' => [ + 'connect_timeout' => 2, // fail fast if ES is unreachable + 'timeout' => 10, // allow time for complex queries + ], + ]); if ($config->inDebugMode()) { $builder->setLogger($container->make(LoggerInterface::class));