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