Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PersistRevisionThreadItems
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 4
380
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 process
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
90
 processRow
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools\Maintenance;
4
5use IDatabase;
6use Language;
7use Maintenance;
8use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
9use MediaWiki\Extension\DiscussionTools\ThreadItemStore;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Revision\RevisionStore;
12use MediaWiki\Shell\Shell;
13use MWExceptionRenderer;
14use stdClass;
15use Throwable;
16use Title;
17use Wikimedia\Rdbms\SelectQueryBuilder;
18
19$IP = getenv( 'MW_INSTALL_PATH' );
20if ( $IP === false ) {
21    $IP = __DIR__ . '/../../..';
22}
23require_once "$IP/maintenance/Maintenance.php";
24
25class PersistRevisionThreadItems extends Maintenance {
26
27    private IDatabase $dbr;
28    private ThreadItemStore $itemStore;
29    private RevisionStore $revStore;
30    private Language $lang;
31
32    public function __construct() {
33        parent::__construct();
34        $this->requireExtension( 'DiscussionTools' );
35        $this->addDescription( 'Persist thread item information for the given pages/revisions' );
36        $this->addOption( 'rev', 'Revision ID to process', false, true, false, true );
37        $this->addOption( 'page', 'Page title to process', false, true, false, true );
38        $this->addOption( 'all', 'Process the whole wiki' );
39        $this->addOption( 'current', 'Process current revisions only' );
40        $this->addOption( 'start', 'Restart from this position (as printed by the script)', false, true );
41        $this->setBatchSize( 100 );
42    }
43
44    public function execute() {
45        $services = MediaWikiServices::getInstance();
46
47        $this->dbr = $this->getDB( DB_REPLICA );
48        $this->itemStore = $services->getService( 'DiscussionTools.ThreadItemStore' );
49        $this->revStore = $services->getRevisionStore();
50        $this->lang = $services->getLanguageFactory()->getLanguage( 'en' );
51
52        $qb = $this->dbr->newSelectQueryBuilder();
53
54        $qb->queryInfo( $this->revStore->getQueryInfo( [ 'page' ] ) );
55
56        if ( $this->getOption( 'all' ) ) {
57            // Do nothing
58
59        } elseif ( $this->getOption( 'page' ) ) {
60            $linkBatch = $services->getLinkBatchFactory()->newLinkBatch();
61            foreach ( $this->getOption( 'page' ) as $page ) {
62                $linkBatch->addObj( Title::newFromText( $page ) );
63            }
64            $pageIds = array_map( static function ( $page ) {
65                return $page->getId();
66            }, $linkBatch->getPageIdentities() );
67
68            $qb->where( [ 'rev_page' => $pageIds ] );
69
70        } elseif ( $this->getOption( 'rev' ) ) {
71            $qb->where( [ 'rev_id' => $this->getOption( 'rev' ) ] );
72        } else {
73            $this->error( "One of 'all', 'page', or 'rev' required" );
74            $this->maybeHelp( true );
75            return;
76        }
77
78        if ( $this->getOption( 'current' ) ) {
79            $qb->where( 'rev_id = page_latest' );
80            $index = [ 'page_id' ];
81        } else {
82            // Process in order by page and time to avoid confusing results while the script is running
83            $index = [ 'rev_page', 'rev_timestamp', 'rev_id' ];
84        }
85
86        $this->process( $qb, $index );
87    }
88
89    /**
90     * @param SelectQueryBuilder $qb
91     * @param array $index
92     */
93    private function process( SelectQueryBuilder $qb, array $index ): void {
94        $start = microtime( true );
95
96        $qb->caller( __METHOD__ );
97
98        // estimateRowCount() refuses to work when fields are set, so we can't just call it on $qb
99        $countQueryInfo = $qb->getQueryInfo();
100        $count = $qb->newSubquery()
101            ->rawTables( $countQueryInfo['tables'] )
102            ->where( $countQueryInfo['conds'] )
103            ->options( $countQueryInfo['options'] )
104            ->joinConds( $countQueryInfo['join_conds'] )
105            ->caller( __METHOD__ )
106            ->estimateRowCount();
107        $this->output( "Processing... (estimated $count rows)\n" );
108
109        $processed = 0;
110        $updated = 0;
111
112        $qb->orderBy( $index );
113        $batchSize = $this->getBatchSize();
114        $qb->limit( $batchSize );
115
116        $batchStart = null;
117        if ( $this->getOption( 'start' ) ) {
118            $batchStart = json_decode( $this->getOption( 'start' ) );
119            if ( !$batchStart ) {
120                $this->error( "Invalid 'start'" );
121            }
122        }
123
124        while ( true ) {
125            $qbForBatch = clone $qb;
126            if ( $batchStart ) {
127                $batchStartCond = $this->dbr->buildComparison( '>', array_combine( $index, $batchStart ) );
128                $qbForBatch->where( $batchStartCond );
129
130                $batchStartOutput = Shell::escape( json_encode( $batchStart ) );
131                $this->output( "--start $batchStartOutput\n" );
132            }
133
134            $res = $qbForBatch->fetchResultSet();
135            foreach ( $res as $row ) {
136                $updated += (int)$this->processRow( $row );
137            }
138            $processed += $res->numRows();
139
140            $this->output( "Processed $processed (updated $updated) of $count rows\n" );
141
142            $this->waitForReplication();
143
144            if ( $res->numRows() < $batchSize || !isset( $row ) ) {
145                // Done
146                break;
147            }
148
149            // Update the conditions to select the next batch.
150            $batchStart = [];
151            foreach ( $index as $field ) {
152                $batchStart[] = $row->$field;
153            }
154        }
155
156        $duration = microtime( true ) - $start;
157        $durationFormatted = $this->lang->formatTimePeriod( $duration );
158        $this->output( "Finished in $durationFormatted\n" );
159    }
160
161    /**
162     * @param stdClass $row Database table row
163     * @return bool
164     */
165    private function processRow( stdClass $row ): bool {
166        $changed = false;
167        try {
168            $rev = $this->revStore->newRevisionFromRow( $row );
169            $title = Title::newFromLinkTarget(
170                $rev->getPageAsLinkTarget()
171            );
172            if ( HookUtils::isAvailableForTitle( $title ) ) {
173                $threadItemSet = HookUtils::parseRevisionParsoidHtml( $rev );
174
175                // Store permalink data
176                $changed = $this->itemStore->insertThreadItems( $rev, $threadItemSet );
177            }
178        } catch ( Throwable $e ) {
179            $this->output( "Error while processing revid=$row->rev_id, pageid=$row->rev_page\n" );
180            MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW );
181        }
182        return $changed;
183    }
184}
185
186$maintClass = PersistRevisionThreadItems::class;
187require_once RUN_MAINTENANCE_IF_MAIN;