Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.43% covered (warning)
89.43%
110 / 123
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryMembershipChangeJob
90.16% covered (success)
90.16%
110 / 122
57.14% covered (warning)
57.14%
4 / 7
30.86
0.00% covered (danger)
0.00%
0 / 1
 newSpec
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 run
85.71% covered (warning)
85.71%
48 / 56
0.00% covered (danger)
0.00%
0 / 1
7.14
 notifyUpdatesForRevision
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
13.19
 getExplicitCategoriesChanges
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getCategoriesAtRev
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 getDeduplicationInfo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\JobQueue\Jobs;
22
23use MediaWiki\JobQueue\Job;
24use MediaWiki\JobQueue\JobSpecification;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Page\PageIdentity;
28use MediaWiki\Page\WikiPage;
29use MediaWiki\RecentChanges\CategoryMembershipChange;
30use MediaWiki\RecentChanges\RecentChange;
31use MediaWiki\Revision\RevisionRecord;
32use MediaWiki\Revision\RevisionStoreRecord;
33use MediaWiki\Title\Title;
34use Wikimedia\Rdbms\IDBAccessObject;
35use Wikimedia\Rdbms\LBFactory;
36use Wikimedia\Rdbms\RawSQLExpression;
37use Wikimedia\Rdbms\SelectQueryBuilder;
38
39/**
40 * Job to add recent change entries mentioning category membership changes
41 *
42 * This allows users to easily scan categories for recent page membership changes
43 *
44 * Parameters include:
45 *   - pageId : page ID
46 *   - revTimestamp : timestamp of the triggering revision
47 *
48 * Category changes will be mentioned for revisions at/after the timestamp for this page
49 *
50 * @since 1.27
51 * @ingroup JobQueue
52 */
53class CategoryMembershipChangeJob extends Job {
54    /** @var int|null */
55    private $ticket;
56
57    private const ENQUEUE_FUDGE_SEC = 60;
58
59    /**
60     * @param PageIdentity $page the page for which to update category membership.
61     * @param string $revisionTimestamp The timestamp of the new revision that triggered the job.
62     * @param bool $forImport Whether the new revision that triggered the import was imported
63     * @return JobSpecification
64     */
65    public static function newSpec( PageIdentity $page, $revisionTimestamp, bool $forImport ) {
66        return new JobSpecification(
67            'categoryMembershipChange',
68            [
69                'pageId' => $page->getId(),
70                'revTimestamp' => $revisionTimestamp,
71                'forImport' => $forImport,
72            ],
73            [
74                'removeDuplicates' => true,
75                'removeDuplicatesIgnoreParams' => [ 'revTimestamp' ]
76            ],
77            $page
78        );
79    }
80
81    /**
82     * Constructor for use by the Job Queue infrastructure.
83     * @note Don't call this when queueing a new instance, use newSpec() instead.
84     * @param PageIdentity $page the categorized page.
85     * @param array $params Such latest revision instance of the categorized page.
86     */
87    public function __construct( PageIdentity $page, array $params ) {
88        parent::__construct( 'categoryMembershipChange', $page, $params );
89        // Only need one job per page. Note that ENQUEUE_FUDGE_SEC handles races where an
90        // older revision job gets inserted while the newer revision job is de-duplicated.
91        $this->removeDuplicates = true;
92    }
93
94    public function run() {
95        $services = MediaWikiServices::getInstance();
96        $lbFactory = $services->getDBLoadBalancerFactory();
97        $lb = $lbFactory->getMainLB();
98        $dbw = $lb->getConnection( DB_PRIMARY );
99
100        $this->ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
101
102        $page = $services->getWikiPageFactory()->newFromID( $this->params['pageId'], IDBAccessObject::READ_LATEST );
103        if ( !$page ) {
104            $this->setLastError( "Could not find page #{$this->params['pageId']}" );
105            return false; // deleted?
106        }
107
108        // Cut down on the time spent in waitForPrimaryPos() in the critical section
109        $dbr = $lb->getConnection( DB_REPLICA );
110        if ( !$lb->waitForPrimaryPos( $dbr ) ) {
111            $this->setLastError( "Timed out while pre-waiting for replica DB to catch up" );
112            return false;
113        }
114
115        // Use a named lock so that jobs for this page see each others' changes
116        $lockKey = "{$dbw->getDomainID()}:CategoryMembershipChange:{$page->getId()}"; // per-wiki
117        $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 3 );
118        if ( !$scopedLock ) {
119            $this->setLastError( "Could not acquire lock '$lockKey'" );
120            return false;
121        }
122
123        // Wait till replica DB is caught up so that jobs for this page see each others' changes
124        if ( !$lb->waitForPrimaryPos( $dbr ) ) {
125            $this->setLastError( "Timed out while waiting for replica DB to catch up" );
126            return false;
127        }
128        // Clear any stale REPEATABLE-READ snapshot
129        $dbr->flushSnapshot( __METHOD__ );
130
131        $cutoffUnix = wfTimestamp( TS_UNIX, $this->params['revTimestamp'] );
132        // Using ENQUEUE_FUDGE_SEC handles jobs inserted out of revision order due to the delay
133        // between COMMIT and actual enqueueing of the CategoryMembershipChangeJob job.
134        $cutoffUnix -= self::ENQUEUE_FUDGE_SEC;
135
136        // Get the newest page revision that has a SRC_CATEGORIZE row.
137        // Assume that category changes before it were already handled.
138        $subQuery = $dbr->newSelectQueryBuilder()
139            ->select( '1' )
140            ->from( 'recentchanges' )
141            ->where( 'rc_this_oldid = rev_id' )
142            ->andWhere( [ 'rc_source' => RecentChange::SRC_CATEGORIZE ] );
143        $row = $dbr->newSelectQueryBuilder()
144            ->select( [ 'rev_timestamp', 'rev_id' ] )
145            ->from( 'revision' )
146            ->where( [ 'rev_page' => $page->getId() ] )
147            ->andWhere( $dbr->expr( 'rev_timestamp', '>=', $dbr->timestamp( $cutoffUnix ) ) )
148            ->andWhere( new RawSQLExpression( 'EXISTS (' . $subQuery->getSQL() . ')' ) )
149            ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC )
150            ->caller( __METHOD__ )->fetchRow();
151
152        // Only consider revisions newer than any such revision
153        if ( $row ) {
154            $cutoffUnix = wfTimestamp( TS_UNIX, $row->rev_timestamp );
155            $lastRevId = (int)$row->rev_id;
156        } else {
157            $lastRevId = 0;
158        }
159
160        // Find revisions to this page made around and after this revision which lack category
161        // notifications in recent changes. This lets jobs pick up were the last one left off.
162        $revisionStore = $services->getRevisionStore();
163        $res = $revisionStore->newSelectQueryBuilder( $dbr )
164            ->joinComment()
165            ->where( [
166                'rev_page' => $page->getId(),
167                $dbr->buildComparison( '>', [
168                    'rev_timestamp' => $dbr->timestamp( $cutoffUnix ),
169                    'rev_id' => $lastRevId,
170                ] )
171            ] )
172            ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_ASC )
173            ->caller( __METHOD__ )->fetchResultSet();
174
175        // Apply all category updates in revision timestamp order
176        foreach ( $res as $row ) {
177            $this->notifyUpdatesForRevision( $lbFactory, $page, $revisionStore->newRevisionFromRow( $row ) );
178        }
179
180        return true;
181    }
182
183    /**
184     * @param LBFactory $lbFactory
185     * @param WikiPage $page
186     * @param RevisionRecord $newRev
187     */
188    protected function notifyUpdatesForRevision(
189        LBFactory $lbFactory, WikiPage $page, RevisionRecord $newRev
190    ) {
191        $title = $page->getTitle();
192
193        // Get the new revision
194        if ( $newRev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
195            return;
196        }
197
198        $services = MediaWikiServices::getInstance();
199        // Get the prior revision (the same for null edits)
200        if ( $newRev->getParentId() ) {
201            $oldRev = $services->getRevisionLookup()
202                ->getRevisionById( $newRev->getParentId(), IDBAccessObject::READ_LATEST );
203            if ( !$oldRev || $oldRev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
204                return;
205            }
206        } else {
207            $oldRev = null;
208        }
209
210        // Parse the new revision and get the categories
211        $categoryChanges = $this->getExplicitCategoriesChanges( $page, $newRev, $oldRev );
212        [ $categoryInserts, $categoryDeletes ] = $categoryChanges;
213        if ( !$categoryInserts && !$categoryDeletes ) {
214            return; // nothing to do
215        }
216
217        $blc = $services->getBacklinkCacheFactory()->getBacklinkCache( $title );
218        $catMembChange = new CategoryMembershipChange( $title, $blc, $newRev, $this->params['forImport'] ?? false );
219        $catMembChange->checkTemplateLinks();
220
221        $batchSize = $services->getMainConfig()->get( MainConfigNames::UpdateRowsPerQuery );
222        $insertCount = 0;
223
224        foreach ( $categoryInserts as $categoryName ) {
225            $categoryTitle = Title::makeTitle( NS_CATEGORY, $categoryName );
226            $catMembChange->triggerCategoryAddedNotification( $categoryTitle );
227            if ( $insertCount++ && ( $insertCount % $batchSize ) == 0 ) {
228                $lbFactory->commitAndWaitForReplication( __METHOD__, $this->ticket );
229            }
230        }
231
232        foreach ( $categoryDeletes as $categoryName ) {
233            $categoryTitle = Title::makeTitle( NS_CATEGORY, $categoryName );
234            $catMembChange->triggerCategoryRemovedNotification( $categoryTitle );
235            if ( $insertCount++ && ( $insertCount++ % $batchSize ) == 0 ) {
236                $lbFactory->commitAndWaitForReplication( __METHOD__, $this->ticket );
237            }
238        }
239    }
240
241    private function getExplicitCategoriesChanges(
242        WikiPage $page, RevisionRecord $newRev, ?RevisionRecord $oldRev = null
243    ): array {
244        // Inject the same timestamp for both revision parses to avoid seeing category changes
245        // due to time-based parser functions. Inject the same page title for the parses too.
246        // Note that REPEATABLE-READ makes template/file pages appear unchanged between parses.
247        $parseTimestamp = $newRev->getTimestamp();
248        // Parse the old rev and get the categories. Do not use link tables as that
249        // assumes these updates are perfectly FIFO and that link tables are always
250        // up to date, neither of which are true.
251        $oldCategories = $oldRev
252            ? $this->getCategoriesAtRev( $page, $oldRev, $parseTimestamp )
253            : [];
254        // Parse the new revision and get the categories
255        $newCategories = $this->getCategoriesAtRev( $page, $newRev, $parseTimestamp );
256
257        $categoryInserts = array_values( array_diff( $newCategories, $oldCategories ) );
258        $categoryDeletes = array_values( array_diff( $oldCategories, $newCategories ) );
259
260        return [ $categoryInserts, $categoryDeletes ];
261    }
262
263    /**
264     * @param WikiPage $page
265     * @param RevisionRecord $rev
266     * @param string $parseTimestamp TS_MW
267     *
268     * @return string[] category names
269     */
270    private function getCategoriesAtRev( WikiPage $page, RevisionRecord $rev, $parseTimestamp ) {
271        $services = MediaWikiServices::getInstance();
272        $options = $page->makeParserOptions( 'canonical' );
273        $options->setTimestamp( $parseTimestamp );
274        $options->setRenderReason( 'CategoryMembershipChangeJob' );
275
276        $output = $rev instanceof RevisionStoreRecord && $rev->isCurrent()
277            ? $services->getParserCache()->get( $page, $options )
278            : null;
279
280        if ( !$output || $output->getCacheRevisionId() !== $rev->getId() ) {
281            $output = $services->getRevisionRenderer()->getRenderedRevision( $rev, $options )
282                ->getRevisionParserOutput();
283        }
284
285        // array keys will cast numeric category names to ints;
286        // ::getCategoryNames() is careful to cast them back to strings
287        // to avoid breaking things!
288        return $output->getCategoryNames();
289    }
290
291    public function getDeduplicationInfo() {
292        $info = parent::getDeduplicationInfo();
293        unset( $info['params']['revTimestamp'] ); // first job wins
294
295        return $info;
296    }
297}
298
299/** @deprecated class alias since 1.44 */
300class_alias( CategoryMembershipChangeJob::class, 'CategoryMembershipChangeJob' );