Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.06% covered (warning)
68.06%
98 / 144
54.84% covered (warning)
54.84%
17 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
LinksUpdate
68.06% covered (warning)
68.06%
98 / 144
54.84% covered (warning)
54.84%
17 / 31
123.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 setTransactionTicket
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMoveDetails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doUpdate
62.07% covered (warning)
62.07%
18 / 29
0.00% covered (danger)
0.00%
0 / 1
7.96
 acquirePageLock
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
2.86
 doIncrementalUpdate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 queueRecursiveJobs
54.55% covered (warning)
54.55%
12 / 22
0.00% covered (danger)
0.00%
0 / 1
5.50
 queueRecursiveJobsForTable
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
3.06
 setStrictTestMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getParserOutput
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getImages
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setRevisionRecord
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionRecord
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTriggeringUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTriggeringUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageLinksTable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExternalLinksTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPagePropsTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAddedLinks
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRemovedLinks
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAddedExternalLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRemovedExternalLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAddedProperties
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRemovedProperties
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageReferenceIterator
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 getPageReferenceArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateLinksTimestamp
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getDB
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isRecursive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Updater for link tracking tables after a page edit.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Deferred\LinksUpdate;
10
11use InvalidArgumentException;
12use MediaWiki\Cache\BacklinkCache;
13use MediaWiki\Deferred\AutoCommitUpdate;
14use MediaWiki\Deferred\DataUpdate;
15use MediaWiki\Deferred\DeferredUpdates;
16use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
17use MediaWiki\JobQueue\Job;
18use MediaWiki\JobQueue\Jobs\RefreshLinksJob;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MainConfigNames;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\Page\PageIdentity;
23use MediaWiki\Page\PageReference;
24use MediaWiki\Page\PageReferenceValue;
25use MediaWiki\Parser\ParserOutput;
26use MediaWiki\Revision\RevisionRecord;
27use MediaWiki\Title\Title;
28use MediaWiki\User\UserIdentity;
29use RuntimeException;
30use Wikimedia\Rdbms\IConnectionProvider;
31use Wikimedia\Rdbms\IDatabase;
32use Wikimedia\Rdbms\IDBAccessObject;
33use Wikimedia\ScopedCallback;
34
35/**
36 * Class the manages updates of *_link tables as well as similar extension-managed tables
37 *
38 * @note LinksUpdate is managed by DeferredUpdates::execute(). Do not run this in a transaction.
39 *
40 * See docs/deferred.txt
41 */
42class LinksUpdate extends DataUpdate {
43    use ProtectedHookAccessorTrait;
44
45    /** @var int Page ID of the article linked from */
46    protected $mId;
47
48    /** @var Title Title object of the article linked from */
49    protected $mTitle;
50
51    /** @var ParserOutput */
52    protected $mParserOutput;
53
54    /** @var bool Whether to queue jobs for recursive updates */
55    protected $mRecursive;
56
57    /** @var bool Whether the page's redirect target may have changed in the latest revision */
58    protected $mMaybeRedirectChanged;
59
60    /** @var RevisionRecord Revision for which this update has been triggered */
61    private $mRevisionRecord;
62
63    /**
64     * @var UserIdentity|null
65     */
66    private $user;
67
68    /** @var IDatabase */
69    private $db;
70
71    /** @var LinksTableGroup */
72    private $tableFactory;
73
74    private IConnectionProvider $dbProvider;
75
76    /**
77     * @param PageIdentity $page The page we're updating
78     * @param ParserOutput $parserOutput Output from a full parse of this page
79     * @param bool $recursive Queue jobs for recursive updates?
80     * @param bool $maybeRedirectChanged True if the page's redirect target may have changed in the
81     *   latest revision. If false, this is used as a hint to skip some unnecessary updates.
82     */
83    public function __construct(
84        PageIdentity $page,
85        ParserOutput $parserOutput,
86        $recursive = true,
87        $maybeRedirectChanged = true
88    ) {
89        parent::__construct();
90
91        $this->mTitle = Title::newFromPageIdentity( $page );
92        $this->mParserOutput = $parserOutput;
93        $this->mRecursive = $recursive;
94        $this->mMaybeRedirectChanged = $maybeRedirectChanged;
95
96        $services = MediaWikiServices::getInstance();
97        $config = $services->getMainConfig();
98        $this->tableFactory = new LinksTableGroup(
99            $services->getObjectFactory(),
100            $services->getDBLoadBalancerFactory(),
101            $services->getCollationFactory(),
102            $page,
103            $services->getLinkTargetLookup(),
104            $config->get( MainConfigNames::UpdateRowsPerQuery ),
105            $config->get( MainConfigNames::TempCategoryCollations )
106        );
107        // TODO: this does not have to be called in LinksDeletionUpdate
108        $this->tableFactory->setParserOutput( $parserOutput );
109        $this->dbProvider = $services->getDBLoadBalancerFactory();
110    }
111
112    /** @inheritDoc */
113    public function setTransactionTicket( $ticket ) {
114        parent::setTransactionTicket( $ticket );
115        $this->tableFactory->setTransactionTicket( $ticket );
116    }
117
118    /**
119     * Notify LinksUpdate that a move has just been completed and set the
120     * original title
121     */
122    public function setMoveDetails( PageReference $oldPage ) {
123        $this->tableFactory->setMoveDetails( $oldPage );
124    }
125
126    /**
127     * Update link tables with outgoing links from an updated article
128     *
129     * @note this is managed by DeferredUpdates::execute(). Do not run this in a transaction.
130     */
131    public function doUpdate() {
132        if ( !$this->mId ) {
133            // NOTE: subclasses may initialize mId directly!
134            $this->mId = $this->mTitle->getArticleID( IDBAccessObject::READ_LATEST );
135        }
136
137        if ( !$this->mId ) {
138            // Probably due to concurrent deletion or renaming of the page
139            $logger = LoggerFactory::getInstance( 'SecondaryDataUpdate' );
140            $logger->warning(
141                'LinksUpdate: The Title object yields no ID. Perhaps the page was deleted?',
142                [
143                    'page_title' => $this->mTitle->getPrefixedDBkey(),
144                    'cause_action' => $this->getCauseAction(),
145                    'cause_agent' => $this->getCauseAgent()
146                ]
147            );
148
149            // nothing to do
150            return;
151        }
152
153        // Do any setup that needs to be done prior to acquiring the lock
154        // Calling getAll() here has the side-effect of calling
155        // LinksUpdateBatch::setParserOutput() on all subclasses, allowing
156        // those methods to also do pre-lock operations.
157        foreach ( $this->tableFactory->getAll() as $table ) {
158            $table->beforeLock();
159        }
160
161        if ( $this->ticket ) {
162            // Make sure all links update threads see the changes of each other.
163            // This handles the case when updates have to batched into several COMMITs.
164            $scopedLock = self::acquirePageLock( $this->getDB(), $this->mId );
165            if ( !$scopedLock ) {
166                throw new RuntimeException( "Could not acquire lock for page ID '{$this->mId}'." );
167            }
168        }
169
170        $this->getHookRunner()->onLinksUpdate( $this );
171        $this->doIncrementalUpdate();
172
173        // Commit and release the lock (if set)
174        ScopedCallback::consume( $scopedLock );
175        // Run post-commit hook handlers without DBO_TRX
176        DeferredUpdates::addUpdate( new AutoCommitUpdate(
177            $this->getDB(),
178            __METHOD__,
179            function () {
180                $this->getHookRunner()->onLinksUpdateComplete( $this, $this->ticket );
181            }
182        ) );
183    }
184
185    /**
186     * Acquire a session-level lock for performing link table updates for a page on a DB
187     *
188     * @param IDatabase $dbw
189     * @param int $pageId
190     * @param string $why One of (job, atomicity)
191     * @return ScopedCallback|null
192     * @since 1.27
193     */
194    public static function acquirePageLock( IDatabase $dbw, $pageId, $why = 'atomicity' ) {
195        $key = "{$dbw->getDomainID()}:LinksUpdate:$why:pageid:$pageId"; // per-wiki
196        $scopedLock = $dbw->getScopedLockAndFlush( $key, __METHOD__, 1 );
197        if ( !$scopedLock ) {
198            $logger = LoggerFactory::getInstance( 'SecondaryDataUpdate' );
199            $logger->info( "Could not acquire lock '{key}' for page ID '{page_id}'.", [
200                'key' => $key,
201                'page_id' => $pageId,
202            ] );
203            return null;
204        }
205
206        return $scopedLock;
207    }
208
209    protected function doIncrementalUpdate() {
210        foreach ( $this->tableFactory->getAll() as $table ) {
211            $table->update();
212        }
213
214        # Refresh links of all pages including this page
215        # This will be in a separate transaction
216        if ( $this->mRecursive ) {
217            $this->queueRecursiveJobs();
218        }
219
220        # Update the links table freshness for this title
221        $this->updateLinksTimestamp();
222    }
223
224    /**
225     * Queue recursive jobs for this page
226     *
227     * Which means do LinksUpdate on all pages that include the current page,
228     * using the job queue.
229     */
230    protected function queueRecursiveJobs() {
231        $services = MediaWikiServices::getInstance();
232        $backlinkCache = $services->getBacklinkCacheFactory()
233            ->getBacklinkCache( $this->mTitle );
234        $action = $this->getCauseAction();
235        $agent = $this->getCauseAgent();
236
237        self::queueRecursiveJobsForTable(
238            $this->mTitle, 'templatelinks', $action, $agent, $backlinkCache
239        );
240        if ( $this->mMaybeRedirectChanged && $this->mTitle->getNamespace() === NS_FILE ) {
241            // Process imagelinks in case the redirect target has changed
242            self::queueRecursiveJobsForTable(
243                $this->mTitle, 'imagelinks', $action, $agent, $backlinkCache
244            );
245        }
246
247        // Get jobs for cascade-protected backlinks for a high priority queue.
248        // If meta-templates change to using a new template, the new template
249        // should be implicitly protected as soon as possible, if applicable.
250        // These jobs duplicate a subset of the above ones, but can run sooner.
251        // Which ever runs first generally no-ops the other one.
252        $jobs = [];
253        foreach ( $backlinkCache->getCascadeProtectedLinkPages() as $page ) {
254            $jobs[] = RefreshLinksJob::newPrioritized(
255                $page,
256                [
257                    'causeAction' => $action,
258                    'causeAgent' => $agent
259                ]
260            );
261        }
262        $services->getJobQueueGroup()->push( $jobs );
263    }
264
265    /**
266     * Queue a RefreshLinks job for any table.
267     *
268     * @param PageIdentity $page Page to do job for
269     * @param string $table Table to use (e.g. 'templatelinks')
270     * @param string $action Triggering action
271     * @param string $userName Triggering user name
272     * @param BacklinkCache|null $backlinkCache
273     */
274    public static function queueRecursiveJobsForTable(
275        PageIdentity $page, $table, $action = 'LinksUpdate', $userName = 'unknown', ?BacklinkCache $backlinkCache = null
276    ) {
277        $title = Title::newFromPageIdentity( $page );
278        if ( !$backlinkCache ) {
279            wfDeprecatedMsg( __METHOD__ . " needs a BacklinkCache object, null passed", '1.37' );
280            $backlinkCache = MediaWikiServices::getInstance()->getBacklinkCacheFactory()
281                ->getBacklinkCache( $title );
282        }
283        if ( $backlinkCache->hasLinks( $table ) ) {
284            $job = new RefreshLinksJob(
285                $title,
286                [
287                    'table' => $table,
288                    'recursive' => true,
289                ] + Job::newRootJobParams( // "overall" refresh links job info
290                    "refreshlinks:{$table}:{$title->getPrefixedText()}"
291                ) + [ 'causeAction' => $action, 'causeAgent' => $userName ]
292            );
293
294            MediaWikiServices::getInstance()->getJobQueueGroup()->push( $job );
295        }
296    }
297
298    /**
299     * Omit conflict resolution options from the insert query so that testing
300     * can confirm that the incremental update logic was correct.
301     *
302     * @param bool $mode
303     */
304    public function setStrictTestMode( $mode = true ) {
305        $this->tableFactory->setStrictTestMode( $mode );
306    }
307
308    /**
309     * Return the title object of the page being updated
310     * @return Title
311     */
312    public function getTitle() {
313        return $this->mTitle;
314    }
315
316    /**
317     * Get the page_id of the page being updated
318     *
319     * @since 1.38
320     * @return int
321     */
322    public function getPageId() {
323        if ( $this->mId ) {
324            return $this->mId;
325        } else {
326            return $this->mTitle->getArticleID();
327        }
328    }
329
330    /**
331     * Returns parser output
332     * @since 1.19
333     * @return ParserOutput
334     */
335    public function getParserOutput() {
336        return $this->mParserOutput;
337    }
338
339    /**
340     * Return the list of images used as generated by the parser
341     * @return array
342     * @deprecated since 1.44
343     */
344    public function getImages() {
345        wfDeprecated( __METHOD__, '1.44' ); // unused
346        return $this->getParserOutput()->getImages();
347    }
348
349    /**
350     * Set the RevisionRecord corresponding to this LinksUpdate
351     *
352     * @since 1.35
353     * @param RevisionRecord $revisionRecord
354     */
355    public function setRevisionRecord( RevisionRecord $revisionRecord ) {
356        $this->mRevisionRecord = $revisionRecord;
357        $this->tableFactory->setRevision( $revisionRecord );
358    }
359
360    /**
361     * @since 1.35
362     * @return RevisionRecord|null
363     */
364    public function getRevisionRecord() {
365        return $this->mRevisionRecord;
366    }
367
368    /**
369     * Set the user who triggered this LinksUpdate
370     *
371     * @since 1.27
372     * @param UserIdentity $user
373     */
374    public function setTriggeringUser( UserIdentity $user ) {
375        $this->user = $user;
376    }
377
378    /**
379     * Get the user who triggered this LinksUpdate
380     *
381     * @since 1.27
382     * @return UserIdentity|null
383     */
384    public function getTriggeringUser(): ?UserIdentity {
385        return $this->user;
386    }
387
388    protected function getPageLinksTable(): PageLinksTable {
389        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
390        return $this->tableFactory->get( 'pagelinks' );
391    }
392
393    protected function getExternalLinksTable(): ExternalLinksTable {
394        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
395        return $this->tableFactory->get( 'externallinks' );
396    }
397
398    protected function getPagePropsTable(): PagePropsTable {
399        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
400        return $this->tableFactory->get( 'page_props' );
401    }
402
403    /**
404     * Fetch page links added by this LinksUpdate.  Only available after the update is complete.
405     *
406     * @since 1.22
407     * @deprecated since 1.38 use getPageReferenceIterator() or getPageReferenceArray(), hard-deprecated since 1.43
408     * @return Title[] Array of Titles
409     */
410    public function getAddedLinks() {
411        wfDeprecated( __METHOD__, '1.43' );
412        return $this->getPageLinksTable()->getTitleArray( LinksTable::INSERTED );
413    }
414
415    /**
416     * Fetch page links removed by this LinksUpdate.  Only available after the update is complete.
417     *
418     * @since 1.22
419     * @deprecated since 1.38 use getPageReferenceIterator() or getPageReferenceArray(), hard-deprecated since 1.43
420     * @return Title[] Array of Titles
421     */
422    public function getRemovedLinks() {
423        wfDeprecated( __METHOD__, '1.43' );
424        return $this->getPageLinksTable()->getTitleArray( LinksTable::DELETED );
425    }
426
427    /**
428     * Fetch external links added by this LinksUpdate. Only available after
429     * the update is complete.
430     * @since 1.33
431     * @return null|array Array of Strings
432     */
433    public function getAddedExternalLinks() {
434        return $this->getExternalLinksTable()->getStringArray( LinksTable::INSERTED );
435    }
436
437    /**
438     * Fetch external links removed by this LinksUpdate. Only available after
439     * the update is complete.
440     * @since 1.33
441     * @return null|string[]
442     */
443    public function getRemovedExternalLinks() {
444        return $this->getExternalLinksTable()->getStringArray( LinksTable::DELETED );
445    }
446
447    /**
448     * Fetch page properties added by this LinksUpdate.
449     * Only available after the update is complete.
450     * @since 1.28
451     * @return null|array
452     */
453    public function getAddedProperties() {
454        return $this->getPagePropsTable()->getAssocArray( LinksTable::INSERTED );
455    }
456
457    /**
458     * Fetch page properties removed by this LinksUpdate.
459     * Only available after the update is complete.
460     * @since 1.28
461     * @return null|array
462     */
463    public function getRemovedProperties() {
464        return $this->getPagePropsTable()->getAssocArray( LinksTable::DELETED );
465    }
466
467    /**
468     * Get an iterator over PageReferenceValue objects corresponding to a given set
469     * type in a given table.
470     *
471     * @since 1.38
472     * @param string $tableName The name of any table that links to local titles
473     * @param int $setType One of:
474     *    - LinksTable::INSERTED: The inserted links
475     *    - LinksTable::DELETED: The deleted links
476     *    - LinksTable::CHANGED: Both the inserted and deleted links
477     *    - LinksTable::OLD: The old set of links, loaded before the update
478     *    - LinksTable::NEW: The new set of links from the ParserOutput
479     * @return iterable<PageReferenceValue>
480     * @phan-return \Traversable
481     */
482    public function getPageReferenceIterator( $tableName, $setType ) {
483        $table = $this->tableFactory->get( $tableName );
484        if ( $table instanceof TitleLinksTable ) {
485            return $table->getPageReferenceIterator( $setType );
486        } else {
487            throw new InvalidArgumentException(
488                __METHOD__ . "$tableName does not have a list of titles" );
489        }
490    }
491
492    /**
493     * Same as getPageReferenceIterator() but converted to an array for convenience
494     * (at the expense of additional time and memory usage)
495     *
496     * @since 1.38
497     * @param string $tableName
498     * @param int $setType
499     * @return PageReferenceValue[]
500     */
501    public function getPageReferenceArray( $tableName, $setType ) {
502        return iterator_to_array( $this->getPageReferenceIterator( $tableName, $setType ) );
503    }
504
505    /**
506     * Update links table freshness
507     */
508    protected function updateLinksTimestamp() {
509        if ( $this->mId ) {
510            // The link updates made here only reflect the freshness of the parser output
511            $timestamp = $this->mParserOutput->getCacheTime();
512            $this->getDB()->newUpdateQueryBuilder()
513                ->update( 'page' )
514                ->set( [ 'page_links_updated' => $this->getDB()->timestamp( $timestamp ) ] )
515                ->where( [ 'page_id' => $this->mId ] )
516                ->caller( __METHOD__ )->execute();
517        }
518    }
519
520    /**
521     * @return IDatabase
522     */
523    protected function getDB() {
524        if ( !$this->db ) {
525            $this->db = $this->dbProvider->getPrimaryDatabase();
526        }
527
528        return $this->db;
529    }
530
531    /**
532     * Whether or not this LinksUpdate will also update pages which transclude the
533     * current page or otherwise depend on it.
534     *
535     * @return bool
536     */
537    public function isRecursive() {
538        return $this->mRecursive;
539    }
540}