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 * @license GPL-2.0-or-later
14 */
15class OtherIndexesUpdater extends Updater {
16    /** @var string Local site we're tracking */
17    private $localSite;
18
19    /**
20     * @param Connection $readConnection
21     * @param string|null $writeToClusterName
22     * @param string $localSite
23     */
24    public function __construct( Connection $readConnection, $writeToClusterName, $localSite ) {
25        parent::__construct( $readConnection, $writeToClusterName );
26        $this->localSite = $localSite;
27    }
28
29    /**
30     * @param SearchConfig $config
31     * @param string|null $cluster
32     * @param string $localSite
33     * @return self
34     */
35    public static function buildOtherIndexesUpdater( SearchConfig $config, $cluster, $localSite ): self {
36        $connection = Connection::getPool( $config, $cluster );
37        return new self( $connection, $cluster, $localSite );
38    }
39
40    /**
41     * Get the external index identifiers for title.
42     * @param SearchConfig $config
43     * @param Title $title
44     * @param string|null $cluster cluster (as in CirrusSearchWriteClusters) to filter on
45     * @return ExternalIndex[] array of external indices.
46     */
47    public static function getExternalIndexes( SearchConfig $config, Title $title, $cluster = null ) {
48        $namespace = $title->getNamespace();
49        $indices = [];
50        foreach ( $config->get( CirrusConfigNames::ExtraIndexes )[$namespace] ?? [] as $indexName ) {
51            $indices[] = new ExternalIndex( $config, $indexName );
52        }
53        return $indices;
54    }
55
56    /**
57     * Get any extra indexes to query, if any, based on namespaces
58     * @param SearchConfig $config
59     * @param int[] $namespaces An array of namespace ids
60     * @return ExternalIndex[] array of indexes
61     */
62    public static function getExtraIndexesForNamespaces( SearchConfig $config, array $namespaces ) {
63        $extraIndexes = [];
64        foreach ( $config->get( CirrusConfigNames::ExtraIndexes ) ?: [] as $namespace => $indexes ) {
65            if ( !in_array( $namespace, $namespaces ) ) {
66                continue;
67            }
68            foreach ( $indexes as $indexName ) {
69                $extraIndexes[] = new ExternalIndex( $config, $indexName );
70            }
71        }
72        return $extraIndexes;
73    }
74
75    /**
76     * Update the indexes for other wiki that also store information about $titles.
77     * @param Title[] $titles array of titles in other indexes to update
78     */
79    public function updateOtherIndex( $titles ) {
80        if ( !$this->connection->getConfig()->getElement( CirrusConfigNames::WikimediaExtraPlugin, 'super_detect_noop' ) ) {
81            $this->logFailure( $titles, 'super_detect_noop plugin not enabled' );
82            return;
83        }
84
85        $updates = [];
86
87        // Build multisearch to find ids to update
88        $findIdsMultiSearch = new MultiSearch( $this->connection->getClient() );
89        $findIdsClosures = [];
90        $readClusterName = $this->connection->getConfig()->getClusterAssignment()->getCrossClusterName();
91        foreach ( $titles as $title ) {
92            foreach ( self::getExternalIndexes( $this->connection->getConfig(), $title ) as $otherIndex ) {
93                $searchIndex = $otherIndex->getSearchIndex( $readClusterName );
94                $query = $this->queryForTitle( $title );
95                $search = $this->connection->getIndex( $searchIndex )->createSearch( $query );
96                $findIdsMultiSearch->addSearch( $search );
97                $findIdsClosures[] = static function ( $docId ) use ( $otherIndex, &$updates, $title ) {
98                    // The searchIndex, including the cluster specified, is needed
99                    // as this gets passed to the ExternalIndex constructor in
100                    // the created jobs.
101                    if ( !isset( $updates[spl_object_hash( $otherIndex )] ) ) {
102                        $updates[spl_object_hash( $otherIndex )] = [ $otherIndex, [] ];
103                    }
104                    $updates[spl_object_hash( $otherIndex )][1][] = [
105                        'docId' => $docId,
106                        'ns' => $title->getNamespace(),
107                        'dbKey' => $title->getDBkey(),
108                    ];
109                };
110            }
111        }
112        $findIdsClosuresCount = count( $findIdsClosures );
113        if ( $findIdsClosuresCount === 0 ) {
114            // No other indexes to check.
115            return;
116        }
117
118        // Look up the ids and run all closures to build the list of updates
119        $result = $this->runMSearch(
120            $findIdsMultiSearch,
121            new MultiSearchRequestLog(
122                $this->connection->getClient(),
123                'searching for {numIds} ids in other indexes',
124                'other_idx_lookup',
125                [ 'numIds' => $findIdsClosuresCount ]
126            )
127        );
128        if ( $result->isGood() ) {
129            /** @var ResultSet $findIdsMultiSearchResult */
130            $findIdsMultiSearchResult = $result->getValue();
131            foreach ( $findIdsClosures as $i => $closure ) {
132                $results = $findIdsMultiSearchResult[$i]->getResults();
133                if ( count( $results ) ) {
134                    $closure( $results[0]->getId() );
135                }
136            }
137            $this->runUpdates( reset( $titles ), $updates );
138        }
139    }
140
141    /**
142     * @param Title $title
143     * @param array $updates
144     * @return void
145     */
146    protected function runUpdates( Title $title, array $updates ): void {
147        // These are split into a job per index because the external indexes
148        // may be configured to write to different clusters. This maintains
149        // isolation of writes between clusters so one slow cluster doesn't
150        // drag down the others.
151        foreach ( $updates as [ $otherIndex, $actions ] ) {
152            $this->pushElasticaWriteJobs(
153                UpdateGroup::PAGE,
154                $actions,
155                function ( array $chunk, string $cluster ) use ( $otherIndex ) {
156                    // Name of the index to write to on whatever cluster is connected to
157                    $indexName = $otherIndex->getIndexName();
158                    // Index name and, potentially, a replica group identifier. Needed to
159                    // create an appropriate ExternalIndex instance in the job.
160                    $externalIndex = $otherIndex->getGroupAndIndexName();
161                    return Job\ElasticaWrite::build(
162                        $cluster,
163                        UpdateGroup::PAGE,
164                        'sendOtherIndexUpdates',
165                        [ $this->localSite, $indexName, $chunk ],
166                        [ 'external-index' => $externalIndex ],
167                    );
168                } );
169        }
170    }
171
172    /**
173     * @param Title[] $titles
174     * @param string $reason
175     */
176    private function logFailure( array $titles, $reason = '' ) {
177        $articleIDs = array_map( static function ( Title $title ) {
178            return $title->getArticleID();
179        }, $titles );
180        if ( $reason ) {
181            $reason = " ($reason)";
182        }
183        LoggerFactory::getInstance( LogChannel::CHANGE_FAILED )->info(
184            "Other Index$reason for article ids: " . implode( ',', $articleIDs ) );
185    }
186
187    /**
188     * @param Title $title
189     * @return \Elastica\Query
190     */
191    private function queryForTitle( Title $title ) {
192        $bool = new \Elastica\Query\BoolQuery();
193
194        // Note that we need to use the keyword indexing of title so the analyzer gets out of the way.
195        $bool->addFilter( new \Elastica\Query\Term( [ 'title.keyword' => $title->getText() ] ) );
196        $bool->addFilter( new \Elastica\Query\Term( [ 'namespace' => $title->getNamespace() ] ) );
197
198        $query = new \Elastica\Query( $bool );
199        $query->setStoredFields( [] ); // We only need the _id so don't load the _source
200        $query->setSize( 1 );
201
202        return $query;
203    }
204
205}