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