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