Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.60% covered (warning)
68.60%
59 / 86
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
OtherIndexesUpdater
68.60% covered (warning)
68.60%
59 / 86
50.00% covered (danger)
50.00%
4 / 8
39.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 buildOtherIndexesUpdater
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getExternalIndexes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getExtraIndexesForNamespaces
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 updateOtherIndex
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
9.03
 runUpdates
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 logFailure
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 queryForTitle
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CirrusSearch;
4
5use Elastica\Multi\ResultSet;
6use Elastica\Multi\Search as MultiSearch;
7use MediaWiki\Logger\LoggerFactory;
8use MediaWiki\Title\Title;
9
10/**
11 * Tracks whether a Title is known on other indexes.
12 *
13 * This program is free software; you can redistribute it and/or modify
14 * it under the terms of the GNU General Public License as published by
15 * the Free Software Foundation; either version 2 of the License, or
16 * (at your option) any later version.
17 *
18 * This program is distributed in the hope that it will be useful,
19 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 * GNU General Public License for more details.
22 *
23 * You should have received a copy of the GNU General Public License along
24 * with this program; if not, write to the Free Software Foundation, Inc.,
25 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26 * http://www.gnu.org/copyleft/gpl.html
27 */
28class OtherIndexesUpdater extends Updater {
29    /** @var string Local site we're tracking */
30    private $localSite;
31
32    /**
33     * @param Connection $readConnection
34     * @param string|null $writeToClusterName
35     * @param string $localSite
36     */
37    public function __construct( Connection $readConnection, $writeToClusterName, $localSite ) {
38        parent::__construct( $readConnection, $writeToClusterName );
39        $this->localSite = $localSite;
40    }
41
42    /**
43     * @param SearchConfig $config
44     * @param string|null $cluster
45     * @param string $localSite
46     * @return OtherIndexesUpdater
47     */
48    public static function buildOtherIndexesUpdater( SearchConfig $config, $cluster, $localSite ): OtherIndexesUpdater {
49        $connection = Connection::getPool( $config, $cluster );
50        return new self( $connection, $cluster, $localSite );
51    }
52
53    /**
54     * Get the external index identifiers for title.
55     * @param SearchConfig $config
56     * @param Title $title
57     * @param string|null $cluster cluster (as in CirrusSearchWriteClusters) to filter on
58     * @return ExternalIndex[] array of external indices.
59     */
60    public static function getExternalIndexes( SearchConfig $config, Title $title, $cluster = null ) {
61        $namespace = $title->getNamespace();
62        $indices = [];
63        foreach ( $config->get( 'CirrusSearchExtraIndexes' )[$namespace] ?? [] as $indexName ) {
64            $indices[] = new ExternalIndex( $config, $indexName );
65        }
66        return $indices;
67    }
68
69    /**
70     * Get any extra indexes to query, if any, based on namespaces
71     * @param SearchConfig $config
72     * @param int[] $namespaces An array of namespace ids
73     * @return ExternalIndex[] array of indexes
74     */
75    public static function getExtraIndexesForNamespaces( SearchConfig $config, array $namespaces ) {
76        $extraIndexes = [];
77        foreach ( $config->get( 'CirrusSearchExtraIndexes' ) ?: [] as $namespace => $indexes ) {
78            if ( !in_array( $namespace, $namespaces ) ) {
79                continue;
80            }
81            foreach ( $indexes as $indexName ) {
82                $extraIndexes[] = new ExternalIndex( $config, $indexName );
83            }
84        }
85        return $extraIndexes;
86    }
87
88    /**
89     * Update the indexes for other wiki that also store information about $titles.
90     * @param Title[] $titles array of titles in other indexes to update
91     */
92    public function updateOtherIndex( $titles ) {
93        if ( !$this->connection->getConfig()->getElement( 'CirrusSearchWikimediaExtraPlugin', 'super_detect_noop' ) ) {
94            $this->logFailure( $titles, 'super_detect_noop plugin not enabled' );
95            return;
96        }
97
98        $updates = [];
99
100        // Build multisearch to find ids to update
101        $findIdsMultiSearch = new MultiSearch( $this->connection->getClient() );
102        $findIdsClosures = [];
103        $readClusterName = $this->connection->getConfig()->getClusterAssignment()->getCrossClusterName();
104        foreach ( $titles as $title ) {
105            foreach ( self::getExternalIndexes( $this->connection->getConfig(), $title ) as $otherIndex ) {
106                $searchIndex = $otherIndex->getSearchIndex( $readClusterName );
107                $query = $this->queryForTitle( $title );
108                $search = $this->connection->getIndex( $searchIndex )->createSearch( $query );
109                $findIdsMultiSearch->addSearch( $search );
110                $findIdsClosures[] = static function ( $docId ) use ( $otherIndex, &$updates, $title ) {
111                    // The searchIndex, including the cluster specified, is needed
112                    // as this gets passed to the ExternalIndex constructor in
113                    // the created jobs.
114                    if ( !isset( $updates[spl_object_hash( $otherIndex )] ) ) {
115                        $updates[spl_object_hash( $otherIndex )] = [ $otherIndex, [] ];
116                    }
117                    $updates[spl_object_hash( $otherIndex )][1][] = [
118                        'docId' => $docId,
119                        'ns' => $title->getNamespace(),
120                        'dbKey' => $title->getDBkey(),
121                    ];
122                };
123            }
124        }
125        $findIdsClosuresCount = count( $findIdsClosures );
126        if ( $findIdsClosuresCount === 0 ) {
127            // No other indexes to check.
128            return;
129        }
130
131        // Look up the ids and run all closures to build the list of updates
132        $result = $this->runMSearch(
133            $findIdsMultiSearch,
134            new MultiSearchRequestLog(
135                $this->connection->getClient(),
136                'searching for {numIds} ids in other indexes',
137                'other_idx_lookup',
138                [ 'numIds' => $findIdsClosuresCount ]
139            )
140        );
141        if ( $result->isGood() ) {
142            /** @var ResultSet $findIdsMultiSearchResult */
143            $findIdsMultiSearchResult = $result->getValue();
144            foreach ( $findIdsClosures as $i => $closure ) {
145                $results = $findIdsMultiSearchResult[$i]->getResults();
146                if ( count( $results ) ) {
147                    $closure( $results[0]->getId() );
148                }
149            }
150            $this->runUpdates( reset( $titles ), $updates );
151        }
152    }
153
154    protected function runUpdates( Title $title, array $updates ) {
155        // These are split into a job per index because the external indexes
156        // may be configured to write to different clusters. This maintains
157        // isolation of writes between clusters so one slow cluster doesn't
158        // drag down the others.
159        foreach ( $updates as [ $otherIndex, $actions ] ) {
160            $this->pushElasticaWriteJobs(
161                UpdateGroup::PAGE,
162                $actions,
163                function ( array $chunk, ClusterSettings $cluster ) use ( $otherIndex ) {
164                    // Name of the index to write to on whatever cluster is connected to
165                    $indexName = $otherIndex->getIndexName();
166                    // Index name and, potentially, a replica group identifier. Needed to
167                    // create an appropriate ExternalIndex instance in the job.
168                    $externalIndex = $otherIndex->getGroupAndIndexName();
169                    return Job\ElasticaWrite::build(
170                        $cluster,
171                        UpdateGroup::PAGE,
172                        'sendOtherIndexUpdates',
173                        [ $this->localSite, $indexName, $chunk ],
174                        [ 'external-index' => $externalIndex ],
175                    );
176                } );
177        }
178    }
179
180    /**
181     * @param Title[] $titles
182     * @param string $reason
183     */
184    private function logFailure( array $titles, $reason = '' ) {
185        $articleIDs = array_map( static function ( Title $title ) {
186            return $title->getArticleID();
187        }, $titles );
188        if ( $reason ) {
189            $reason = " ($reason)";
190        }
191        LoggerFactory::getInstance( 'CirrusSearchChangeFailed' )->info(
192            "Other Index$reason for article ids: " . implode( ',', $articleIDs ) );
193    }
194
195    /**
196     * @param Title $title
197     * @return \Elastica\Query
198     */
199    private function queryForTitle( Title $title ) {
200        $bool = new \Elastica\Query\BoolQuery();
201
202        // Note that we need to use the keyword indexing of title so the analyzer gets out of the way.
203        $bool->addFilter( new \Elastica\Query\Term( [ 'title.keyword' => $title->getText() ] ) );
204        $bool->addFilter( new \Elastica\Query\Term( [ 'namespace' => $title->getNamespace() ] ) );
205
206        $query = new \Elastica\Query( $bool );
207        $query->setStoredFields( [] ); // We only need the _id so don't load the _source
208        $query->setSize( 1 );
209
210        return $query;
211    }
212
213}