Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.20% covered (warning)
56.20%
68 / 121
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeListener
56.20% covered (warning)
56.20%
68 / 121
38.46% covered (danger)
38.46%
5 / 13
159.35
0.00% covered (danger)
0.00%
0 / 1
 create
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 isEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleRevisionVisibilitySet
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onLinksUpdateComplete
72.22% covered (warning)
72.22%
26 / 36
0.00% covered (danger)
0.00%
0 / 1
6.77
 onUploadComplete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 onPageDelete
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 onPageDeleteComplete
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 onTitleMove
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onPageMoveComplete
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 preparePageReferencesForLinksUpdate
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 pickFromArray
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getConnection
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace CirrusSearch;
4
5use CirrusSearch\Job\CirrusTitleJob;
6use CirrusSearch\Job\DeletePages;
7use CirrusSearch\Job\LinksUpdate;
8use JobQueueGroup;
9use ManualLogEntry;
10use MediaWiki\Config\ConfigFactory;
11use MediaWiki\Deferred\DeferredUpdates;
12use MediaWiki\Deferred\LinksUpdate\LinksTable;
13use MediaWiki\Hook\ArticleRevisionVisibilitySetHook;
14use MediaWiki\Hook\LinksUpdateCompleteHook;
15use MediaWiki\Hook\PageMoveCompleteHook;
16use MediaWiki\Hook\TitleMoveHook;
17use MediaWiki\Hook\UploadCompleteHook;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\Page\Hook\PageDeleteCompleteHook;
20use MediaWiki\Page\Hook\PageDeleteHook;
21use MediaWiki\Page\PageReference;
22use MediaWiki\Page\ProperPageIdentity;
23use MediaWiki\Page\RedirectLookup;
24use MediaWiki\Permissions\Authority;
25use MediaWiki\Revision\RevisionRecord;
26use MediaWiki\Status\Status;
27use MediaWiki\Title\Title;
28use MediaWiki\User\User;
29use MediaWiki\Utils\MWTimestamp;
30use Wikimedia\Assert\Assert;
31use Wikimedia\Rdbms\IConnectionProvider;
32
33/**
34 * Implementation to all the hooks that CirrusSearch needs to listen in order to keep its index
35 * in sync with main SQL database.
36 */
37class ChangeListener extends PageChangeTracker implements
38    LinksUpdateCompleteHook,
39    TitleMoveHook,
40    PageMoveCompleteHook,
41    UploadCompleteHook,
42    ArticleRevisionVisibilitySetHook,
43    PageDeleteHook,
44    PageDeleteCompleteHook
45{
46    private JobQueueGroup $jobQueue;
47    private SearchConfig $searchConfig;
48    private IConnectionProvider $dbProvider;
49    private RedirectLookup $redirectLookup;
50
51    /** @var Connection */
52    private $connection;
53
54    /** @var array state holding the titles being moved */
55    private $movingTitles = [];
56
57    public static function create(
58        JobQueueGroup $jobQueue,
59        ConfigFactory $configFactory,
60        IConnectionProvider $dbProvider,
61        RedirectLookup $redirectLookup
62    ): ChangeListener {
63        /** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType $config is actually a SearchConfig */
64        return new self( $jobQueue, $configFactory->makeConfig( "CirrusSearch" ), $dbProvider, $redirectLookup );
65    }
66
67    public function __construct(
68        JobQueueGroup $jobQueue,
69        SearchConfig $searchConfig,
70        IConnectionProvider $dbProvider,
71        RedirectLookup $redirectLookup
72    ) {
73        parent::__construct();
74        $this->jobQueue = $jobQueue;
75        $this->searchConfig = $searchConfig;
76        $this->dbProvider = $dbProvider;
77        $this->redirectLookup = $redirectLookup;
78    }
79
80    /**
81     * Check whether at least one cluster is writeable or not.
82     * If not there are no reasons to schedule a job.
83     *
84     * @return bool true if at least one cluster is writeable
85     */
86    private function isEnabled(): bool {
87        return $this->searchConfig
88            ->getClusterAssignment()
89            ->getWritableClusters( UpdateGroup::PAGE ) != [];
90    }
91
92    /**
93     * Called when a revision is deleted. In theory, we shouldn't need to to this since
94     * you can't delete the current text of a page (so we should've already updated when
95     * the page was updated last). But we're paranoid, because deleted revisions absolutely
96     * should not be in the index.
97     *
98     * @param Title $title The page title we've had a revision deleted on
99     * @param int[] $ids IDs to set the visibility for
100     * @param array $visibilityChangeMap Map of revision ID to oldBits and newBits.
101     *   This array can be examined to determine exactly what visibility bits
102     *   have changed for each revision. This array is of the form:
103     *   [id => ['oldBits' => $oldBits, 'newBits' => $newBits], ... ]
104     */
105    public function onArticleRevisionVisibilitySet( $title, $ids, $visibilityChangeMap ) {
106        if ( !$this->isEnabled() ) {
107            return;
108        }
109        $this->jobQueue->lazyPush( LinksUpdate::newPastRevisionVisibilityChange( $title ) );
110    }
111
112    /**
113     * Hooked to update the search index when pages change directly or when templates that
114     * they include change.
115     * @param \MediaWiki\Deferred\LinksUpdate\LinksUpdate $linksUpdate
116     * @param mixed $ticket Prior result of LBFactory::getEmptyTransactionTicket()
117     */
118    public function onLinksUpdateComplete( $linksUpdate, $ticket ) {
119        if ( !$this->isEnabled() ) {
120            return;
121        }
122        // defer processing the LinksUpdateComplete hook until other hooks tagged in PageChangeTracker
123        // have a chance to run. Reason is that we want to detect what are the links updates triggered
124        // by a "page change". The definition of a "page change" we use is the one used by EventBus
125        // PageChangeHooks.
126        DeferredUpdates::addCallableUpdate( function () use ( $linksUpdate ) {
127            $linkedArticlesToUpdate = $this->searchConfig->get( 'CirrusSearchLinkedArticlesToUpdate' );
128            $unLinkedArticlesToUpdate = $this->searchConfig->get( 'CirrusSearchUnlinkedArticlesToUpdate' );
129            $updateDelay = $this->searchConfig->get( 'CirrusSearchUpdateDelay' );
130
131            // Titles that are created by a move don't need their own job.
132            if ( in_array( $linksUpdate->getTitle()->getPrefixedDBkey(), $this->movingTitles ) ) {
133                return;
134            }
135
136            $params = [];
137            if ( $this->searchConfig->get( 'CirrusSearchEnableIncomingLinkCounting' ) ) {
138                $params['addedLinks'] = self::preparePageReferencesForLinksUpdate(
139                    $linksUpdate->getPageReferenceArray( 'pagelinks', LinksTable::INSERTED ),
140                    $linkedArticlesToUpdate
141                );
142                // We exclude links that contains invalid UTF-8 sequences, reason is that page created
143                // before T13143 was fixed might sill have bad links the pagelinks table
144                // and thus will cause LinksUpdate to believe that these links are removed.
145                $params['removedLinks'] = self::preparePageReferencesForLinksUpdate(
146                    $linksUpdate->getPageReferenceArray( 'pagelinks', LinksTable::DELETED ),
147                    $unLinkedArticlesToUpdate,
148                    true
149                );
150            }
151
152            if ( $this->isPageChange( $linksUpdate->getPageId() ) ) {
153                $jobParams = $params + LinksUpdate::buildJobDelayOptions( LinksUpdate::class,
154                        $updateDelay['prioritized'], $this->jobQueue );
155                $job = LinksUpdate::newPageChangeUpdate( $linksUpdate->getTitle(),
156                    $linksUpdate->getRevisionRecord(), $jobParams );
157                if ( ( MWTimestamp::time() - $job->params[CirrusTitleJob::ROOT_EVENT_TIME] ) > ( 3600 * 24 ) ) {
158                    LoggerFactory::getInstance( 'CirrusSearch' )->debug(
159                        "Scheduled a page-change-update for {title} on a revision created more than 24hours ago, " .
160                        "the cause is {causeAction}",
161                        [
162                            'title' => $linksUpdate->getTitle()->getPrefixedDBkey(),
163                            'causeAction' => $linksUpdate->getCauseAction()
164                        ] );
165                }
166            } else {
167                $job = LinksUpdate::newPageRefreshUpdate( $linksUpdate->getTitle(),
168                    $params + LinksUpdate::buildJobDelayOptions( LinksUpdate::class, $updateDelay['default'], $this->jobQueue ) );
169            }
170
171            $this->jobQueue->lazyPush( $job );
172        } );
173    }
174
175    /**
176     * Hook into UploadComplete, because overwritten files mistakenly do not trigger
177     * LinksUpdateComplete (T344285). Since files do contain indexed metadata
178     * we need to refresh the search index when a file is overwritten on an
179     * existing title.
180     *
181     * @param \UploadBase $uploadBase
182     */
183    public function onUploadComplete( $uploadBase ) {
184        if ( !$this->isEnabled() ) {
185            return;
186        }
187        if ( $uploadBase->getTitle()->exists() ) {
188            $this->jobQueue->lazyPush( LinksUpdate::newPageChangeUpdate( $uploadBase->getTitle(), null, [] ) );
189        }
190    }
191
192    /**
193     * This hook is called before a page is deleted.
194     *
195     * @since 1.37
196     *
197     * @param ProperPageIdentity $page Page being deleted.
198     * @param Authority $deleter Who is deleting the page
199     * @param string $reason Reason the page is being deleted
200     * @param \StatusValue $status Add any error here
201     * @param bool $suppress Whether this is a suppression deletion or not
202     * @return bool|void True or no return value to continue; false to abort, which also requires adding
203     * a fatal error to $status.
204     */
205    public function onPageDelete(
206        ProperPageIdentity $page,
207        Authority $deleter,
208        string $reason,
209        \StatusValue $status,
210        bool $suppress
211    ) {
212        if ( !$this->isEnabled() ) {
213            return;
214        }
215        parent::onPageDelete( $page, $deleter, $reason, $status, $suppress );
216        // We use this to pick up redirects so we can update their targets.
217        // Can't re-use PageDeleteComplete because the page info's
218        // already gone
219        // If we abort or fail deletion it's no big deal because this will
220        // end up being a no-op when it executes.
221        $targetLink = $this->redirectLookup->getRedirectTarget( $page );
222        $target = null;
223        if ( $targetLink != null ) {
224            $target = Title::castFromLinkTarget( $targetLink );
225        }
226        if ( $target ) {
227            $this->jobQueue->lazyPush( new Job\LinksUpdate( $target, [] ) );
228        }
229    }
230
231    /**
232     * @param ProperPageIdentity $page
233     * @param Authority $deleter
234     * @param string $reason
235     * @param int $pageID
236     * @param RevisionRecord $deletedRev
237     * @param ManualLogEntry $logEntry
238     * @param int $archivedRevisionCount
239     * @return void
240     */
241    public function onPageDeleteComplete( ProperPageIdentity $page, Authority $deleter,
242        string $reason, int $pageID, RevisionRecord $deletedRev, ManualLogEntry $logEntry,
243        int $archivedRevisionCount
244    ) {
245        if ( !$this->isEnabled() ) {
246            return;
247        }
248        parent::onPageDeleteComplete( $page, $deleter, $reason, $pageID, $deletedRev, $logEntry, 1 );
249        // Note that we must use the article id provided or it'll be lost in the ether.  The job can't
250        // load it from the title because the page row has already been deleted.
251        $title = Title::castFromPageIdentity( $page );
252        Assert::postcondition( $title !== null, '$page can be cast to a Title' );
253        $this->jobQueue->lazyPush(
254            DeletePages::build(
255                $title,
256                $this->searchConfig->makeId( $pageID ),
257                $logEntry->getTimestamp() !== false ? MWTimestamp::convert( TS_UNIX, $logEntry->getTimestamp() ) : MWTimestamp::time()
258            )
259        );
260    }
261
262    /**
263     * Before we've moved a title from $title to $newTitle.
264     *
265     * @param Title $old Old title
266     * @param Title $nt New title
267     * @param User $user User who does the move
268     * @param string $reason Reason provided by the user
269     * @param Status &$status To abort the move, add a fatal error to this object
270     *       (i.e. call $status->fatal())
271     * @return bool|void True or no return value to continue or false to abort
272     */
273    public function onTitleMove( Title $old, Title $nt, User $user, $reason, Status &$status ) {
274        if ( !$this->isEnabled() ) {
275            return;
276        }
277        $this->movingTitles[] = $old->getPrefixedDBkey();
278    }
279
280    /**
281     * When we've moved a Title from A to B.
282     * @param \MediaWiki\Linker\LinkTarget $old Old title
283     * @param \MediaWiki\Linker\LinkTarget $new New title
284     * @param \MediaWiki\User\UserIdentity $user User who did the move
285     * @param int $pageid Database ID of the page that's been moved
286     * @param int $redirid Database ID of the created redirect
287     * @param string $reason Reason for the move
288     * @param \MediaWiki\Revision\RevisionRecord $revision RevisionRecord created by the move
289     * @return bool|void True or no return value to continue or false stop other hook handlers,
290     *     doesn't abort the move itself
291     */
292    public function onPageMoveComplete(
293        $old, $new, $user, $pageid, $redirid,
294        $reason, $revision
295    ) {
296        if ( !$this->isEnabled() ) {
297            return;
298        }
299        parent::onPageMoveComplete( $old, $new, $user, $pageid, $redirid, $reason, $revision );
300        // When a page is moved the update and delete hooks are good enough to catch
301        // almost everything.  The only thing they miss is if a page moves from one
302        // index to another.  That only happens if it switches namespace.
303        if ( $old->getNamespace() === $new->getNamespace() ) {
304            return;
305        }
306
307        $conn = $this->getConnection();
308        $oldIndexSuffix = $conn->getIndexSuffixForNamespace( $old->getNamespace() );
309        $newIndexSuffix = $conn->getIndexSuffixForNamespace( $new->getNamespace() );
310        if ( $oldIndexSuffix !== $newIndexSuffix ) {
311            $title = Title::newFromLinkTarget( $old );
312            $job = new Job\DeletePages( $title, [
313                'indexSuffix' => $oldIndexSuffix,
314                'docId' => $this->searchConfig->makeId( $pageid )
315            ] );
316            // Push the job after DB commit but cancel on rollback
317            $this->dbProvider->getPrimaryDatabase()->onTransactionCommitOrIdle( function () use ( $job ) {
318                $this->jobQueue->lazyPush( $job );
319            }, __METHOD__ );
320        }
321    }
322
323    /**
324     * Take a list of titles either linked or unlinked and prepare them for Job\LinksUpdate.
325     * This includes limiting them to $max titles.
326     * @param PageReference[] $pageReferences titles to prepare
327     * @param int $max maximum number of titles to return
328     * @param bool $excludeBadUTF exclude links that contains invalid UTF sequences
329     * @return array
330     */
331    public static function preparePageReferencesForLinksUpdate( $pageReferences, int $max, $excludeBadUTF = false ) {
332        $pageReferences = self::pickFromArray( $pageReferences, $max );
333        $dBKeys = [];
334        foreach ( $pageReferences as $pageReference ) {
335            $title = Title::newFromPageReference( $pageReference );
336            $key = $title->getPrefixedDBkey();
337            if ( $excludeBadUTF ) {
338                $fixedKey = mb_convert_encoding( $key, 'UTF-8', 'UTF-8' );
339                if ( $fixedKey !== $key ) {
340                    LoggerFactory::getInstance( 'CirrusSearch' )
341                        ->warning( "Ignoring title {title} with invalid UTF-8 sequences.",
342                            [ 'title' => $fixedKey ] );
343                    continue;
344                }
345            }
346            $dBKeys[] = $title->getPrefixedDBkey();
347        }
348        return $dBKeys;
349    }
350
351    /**
352     * Pick $num random entries from $array.
353     * @param array $array Array to pick from
354     * @param int $num Number of entries to pick
355     * @return array of entries from $array
356     */
357    private static function pickFromArray( $array, $num ) {
358        if ( $num > count( $array ) ) {
359            return $array;
360        }
361        if ( $num < 1 ) {
362            return [];
363        }
364        $chosen = array_rand( $array, $num );
365        // If $num === 1 then array_rand will return a key rather than an array of keys.
366        if ( !is_array( $chosen ) ) {
367            return [ $array[ $chosen ] ];
368        }
369        $result = [];
370        foreach ( $chosen as $key ) {
371            $result[] = $array[ $key ];
372        }
373        return $result;
374    }
375
376    private function getConnection(): Connection {
377        if ( $this->connection === null ) {
378            $this->connection = new Connection( $this->searchConfig );
379        }
380        return $this->connection;
381    }
382}