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