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