Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.61% covered (success)
96.61%
114 / 118
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
FallbackRunner
96.61% covered (success)
96.61%
114 / 118
83.33% covered (warning)
83.33%
10 / 12
43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 noopRunner
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 create
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 createFromProfile
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
8.35
 acceptQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 run
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
9
 getElasticSuggesters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 attachSearchRequests
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 msearchKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 recordResponseMetrics
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getMetrics
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace CirrusSearch\Fallbacks;
4
5use CirrusSearch\CirrusSearchHookRunner;
6use CirrusSearch\InterwikiResolver;
7use CirrusSearch\Parser\NamespacePrefixParser;
8use CirrusSearch\Profile\SearchProfileException;
9use CirrusSearch\Profile\SearchProfileService;
10use CirrusSearch\Search\CirrusSearchResultSet;
11use CirrusSearch\Search\MSearchRequests;
12use CirrusSearch\Search\MSearchResponses;
13use CirrusSearch\Search\SearchMetricsProvider;
14use CirrusSearch\Search\SearchQuery;
15use Elastica\Client;
16use Wikimedia\Assert\Assert;
17
18class FallbackRunner implements SearchMetricsProvider {
19    /** @var self */
20    private static $NOOP_RUNNER = null;
21
22    /**
23     * @var FallbackMethod[] List of fallbacks to apply in order, keyed by
24     *  name of the fallback configuration.
25     */
26    private $fallbackMethods;
27
28    /**
29     * @var array[] Execution trace of fallback methods run and their result.
30     */
31    private $searchMetrics = [];
32
33    /**
34     * @var int[] Map from fallback name to number of hits returned
35     */
36    private $responseMetrics = [];
37
38    /**
39     * @param FallbackMethod[] $fallbackMethods List of fallbacks to apply in order,
40     *  keyed by name of the fallback configuration.
41     */
42    public function __construct( array $fallbackMethods ) {
43        $this->fallbackMethods = $fallbackMethods;
44    }
45
46    /**
47     * Noop fallback runner
48     * @return self
49     */
50    public static function noopRunner(): self {
51        self::$NOOP_RUNNER ??= new self( [] );
52        return self::$NOOP_RUNNER;
53    }
54
55    /**
56     * @param SearchQuery $query
57     * @param InterwikiResolver $interwikiResolver
58     * @param string $profileContext
59     * @param array $profileContextParam
60     * @return self
61     */
62    public static function create(
63        SearchQuery $query,
64        InterwikiResolver $interwikiResolver,
65        $profileContext = SearchProfileService::CONTEXT_DEFAULT,
66        $profileContextParam = []
67    ): self {
68        $profileService = $query->getSearchConfig()->getProfileService();
69        if ( !$profileService->supportsContext( SearchProfileService::FALLBACKS, $profileContext ) ) {
70            // This component is optional and we simply avoid building it if the $profileContext does
71            // not define any defaults for it.
72            return self::noopRunner();
73        }
74        return self::createFromProfile(
75            $query,
76            $profileService->loadProfile( SearchProfileService::FALLBACKS, $profileContext, null, $profileContextParam ),
77            $interwikiResolver
78        );
79    }
80
81    /**
82     * @param SearchQuery $query
83     * @param array $profile
84     * @param InterwikiResolver $interwikiResolver
85     * @return self
86     */
87    private static function createFromProfile( SearchQuery $query, array $profile, InterwikiResolver $interwikiResolver ): self {
88        $fallbackMethods = [];
89        $methodDefs = $profile['methods'] ?? [];
90        foreach ( $methodDefs as $name => $methodDef ) {
91            if ( !isset( $methodDef['class'] ) ) {
92                throw new SearchProfileException( "Invalid FallbackMethod: missing 'class' definition in profile" );
93            }
94            $clazz = $methodDef['class'];
95            $params = $methodDef['params'] ?? [];
96            if ( !class_exists( $clazz ) ) {
97                throw new SearchProfileException( "Invalid FallbackMethod: unknown class $clazz" );
98            }
99            if ( !is_subclass_of( $clazz, FallbackMethod::class ) ) {
100                throw new SearchProfileException( "Invalid FallbackMethod: $clazz must implement " . FallbackMethod::class );
101            }
102            if ( !self::acceptQuery( $methodDef, $query ) ) {
103                continue;
104            }
105            $method = $clazz::build( $query, $params, $interwikiResolver );
106            if ( $method !== null ) {
107                $fallbackMethods[$name] = $method;
108            }
109        }
110        return $fallbackMethods === [] ? self::noopRunner() : new self( $fallbackMethods );
111    }
112
113    private static function acceptQuery( array $methodDef, SearchQuery $query ): bool {
114        if ( $methodDef['default_ns_only'] ?? false ) {
115            return $query->isTargetingDefaultSearchedNamespaces();
116        }
117        return true;
118    }
119
120    /**
121     * @param SearcherFactory $factory
122     * @param CirrusSearchResultSet $initialResult
123     * @param MSearchResponses $responses
124     * @param NamespacePrefixParser $namespacePrefixParser
125     * @param CirrusSearchHookRunner $cirrusSearchHookRunner
126     * @return CirrusSearchResultSet
127     */
128    public function run(
129        SearcherFactory $factory,
130        CirrusSearchResultSet $initialResult,
131        MSearchResponses $responses,
132        NamespacePrefixParser $namespacePrefixParser,
133        CirrusSearchHookRunner $cirrusSearchHookRunner
134    ) {
135        $this->recordResponseMetrics( $responses );
136        $methods = [];
137        $position = 0;
138        $context = new FallbackRunnerContextImpl( $initialResult, $factory, $namespacePrefixParser, $cirrusSearchHookRunner );
139        foreach ( $this->fallbackMethods as $name => $fallback ) {
140            $position++;
141            $context->resetSuggestResponse();
142            if ( $fallback instanceof ElasticSearchRequestFallbackMethod ) {
143                $k = $this->msearchKey( $position );
144                if ( $responses->hasResultsFor( $k ) ) {
145                    $context->setSuggestResponse( $responses->getResultSet( $this->msearchKey( $position ) ) );
146                }
147            }
148            $score = $fallback->successApproximation( $context );
149            if ( $score >= 1.0 ) {
150                $status = $this->execute( $name, $fallback, $context );
151                return $status->apply( $context->getPreviousResultSet() );
152            }
153            if ( $score <= 0 ) {
154                continue;
155            }
156            $methods[] = [
157                'name' => $name,
158                'method' => $fallback,
159                'score' => $score,
160                'position' => $position
161            ];
162        }
163
164        usort( $methods, static function ( $a, $b ) {
165            return $b['score'] <=> $a['score'] ?: $a['position'] <=> $b['position'];
166        } );
167        foreach ( $methods as $fallbackArray ) {
168            $name = $fallbackArray['name'];
169            $fallback = $fallbackArray['method'];
170            $context->resetSuggestResponse();
171            if ( $fallback instanceof ElasticSearchRequestFallbackMethod ) {
172                $context->setSuggestResponse( $responses->getResultSet( $this->msearchKey( $fallbackArray['position'] ) ) );
173            }
174            $status = $this->execute( $name, $fallback, $context );
175            $context->setPreviousResultSet( $status->apply( $context->getPreviousResultSet() ) );
176        }
177        return $context->getPreviousResultSet();
178    }
179
180    public function getElasticSuggesters(): array {
181        $suggesters = [];
182        foreach ( $this->fallbackMethods as $method ) {
183            if ( $method instanceof ElasticSearchSuggestFallbackMethod ) {
184                $suggestQueries = $method->getSuggestQueries();
185                if ( $suggestQueries !== null ) {
186                    foreach ( $suggestQueries as $name => $suggestQ ) {
187                        Assert::precondition( !array_key_exists( $name, $suggesters ),
188                            get_class( $method ) . " is trying to add a suggester [$name] (duplicate)" );
189                        $suggesters[$name] = $suggestQ;
190                    }
191                }
192            }
193        }
194        return $suggesters;
195    }
196
197    public function attachSearchRequests( MSearchRequests $requests, Client $client ) {
198        $position = 0;
199        foreach ( $this->fallbackMethods as $method ) {
200            $position++;
201            if ( $method instanceof ElasticSearchRequestFallbackMethod ) {
202                $search = $method->getSearchRequest( $client );
203                if ( $search !== null ) {
204                    $requests->addRequest(
205                        $this->msearchKey( $position ),
206                        $search
207                    );
208                }
209            }
210        }
211    }
212
213    /**
214     * @param int $position
215     * @return string
216     */
217    private function msearchKey( $position ) {
218        return "fallback-$position";
219    }
220
221    /**
222     * @param string $name
223     * @param FallbackMethod $fallbackMethod
224     * @param FallbackRunnerContext $context
225     * @return FallbackStatus
226     */
227    private function execute( string $name, FallbackMethod $fallbackMethod, FallbackRunnerContext $context ): FallbackStatus {
228        $status = $fallbackMethod->rewrite( $context );
229        // Collecting metrics here isn't particularly simple, as methods have
230        // the ability to not only change the suggestion, but to replace the
231        // entire result set. Instead of figuring out what happened we only
232        // record that a method was run, depending on implementations to
233        // report what happened themselves.
234        $metrics = [ 'name' => $name, 'action' => $status->getAction() ];
235        if ( $fallbackMethod instanceof SearchMetricsProvider ) {
236            $metrics += $fallbackMethod->getMetrics() ?? [];
237        }
238        $this->searchMetrics[] = $metrics;
239        return $status;
240    }
241
242    private function recordResponseMetrics( MSearchResponses $responses ) {
243        // Report on which fallback methods returned results, even if they didn't get used.
244        $position = 0;
245        foreach ( $this->fallbackMethods as $name => $fallback ) {
246            $position++;
247            if ( !( $fallback instanceof ElasticSearchRequestFallbackMethod ) ) {
248                continue;
249            }
250            $k = $this->msearchKey( $position );
251            if ( !$responses->hasResultsFor( $k ) ) {
252                continue;
253            }
254            $resultSet = $responses->getResultSet( $k );
255            $this->responseMetrics[$name] = count( $resultSet );
256        }
257    }
258
259    /**
260     * @return array
261     */
262    public function getMetrics() {
263        // The metrics we have currently are useful for debugging or
264        // tracing, but are too detailed for passing on to the frontend
265        // to use as part of event reporting.  Generate a simplified
266        // version for that purpose.
267        $mainResults = [ 'name' => '__main__', 'action' => null ];
268        $querySuggestion = null;
269        foreach ( $this->searchMetrics as $metrics ) {
270            if ( $metrics['action'] == FallbackStatus::ACTION_SUGGEST_QUERY ) {
271                $querySuggestion = $metrics;
272            } elseif ( $metrics['action'] == FallbackStatus::ACTION_REPLACE_LOCAL_RESULTS ) {
273                $mainResults = $metrics;
274                $querySuggestion = $metrics;
275            }
276        }
277
278        return [
279            'wgCirrusSearchFallback' => [
280                // action that provided main search results
281                'mainResults' => $mainResults,
282                // action that made final query suggestion
283                'querySuggestion' => $querySuggestion,
284                // metrics about requests that were made
285                'responseMetrics' => $this->responseMetrics,
286            ],
287        ];
288    }
289}