Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 121
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 / 115
0.00% covered (danger)
0.00%
0 / 4
506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
90
 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 / 12
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools\Maintenance;
4
5use Language;
6use Maintenance;
7use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
8use MediaWiki\Extension\DiscussionTools\ThreadItemStore;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\Revision\RevisionStore;
11use MediaWiki\Shell\Shell;
12use MediaWiki\Title\Title;
13use MWExceptionRenderer;
14use stdClass;
15use Throwable;
16use Wikimedia\Rdbms\IReadableDatabase;
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 IReadableDatabase $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( 'namespace', 'Namespace number to process', false, true, false, true );
39        $this->addOption( 'all', 'Process the whole wiki' );
40        $this->addOption( 'current', 'Process current revisions only' );
41        $this->addOption( 'start', 'Restart from this position (as printed by the script)', false, true );
42        $this->addOption( 'touched-after', 'Only process pages touched after this timestamp', false, true );
43        $this->addOption( 'touched-before', 'Only process pages touched before this timestamp', false, true );
44        $this->setBatchSize( 100 );
45    }
46
47    public function execute() {
48        $services = MediaWikiServices::getInstance();
49
50        $this->dbr = $dbr = $this->getDB( DB_REPLICA );
51        $this->itemStore = $services->getService( 'DiscussionTools.ThreadItemStore' );
52        $this->revStore = $services->getRevisionStore();
53        $this->lang = $services->getLanguageFactory()->getLanguage( 'en' );
54
55        $qb = $dbr->newSelectQueryBuilder();
56
57        $qb->queryInfo( $this->revStore->getQueryInfo( [ 'page' ] ) );
58
59        if ( $this->getOption( 'all' ) ) {
60            // Do nothing
61
62        } elseif ( $this->getOption( 'namespace' ) ) {
63            $qb->where( [ 'page_namespace' => $this->getOption( 'namespace' ) ] );
64
65        } elseif ( $this->getOption( 'page' ) ) {
66            $linkBatch = $services->getLinkBatchFactory()->newLinkBatch();
67            foreach ( $this->getOption( 'page' ) as $page ) {
68                $linkBatch->addObj( Title::newFromText( $page ) );
69            }
70            $pageIds = array_map( static function ( $page ) {
71                return $page->getId();
72            }, $linkBatch->getPageIdentities() );
73
74            $qb->where( [ 'rev_page' => $pageIds ] );
75
76        } elseif ( $this->getOption( 'rev' ) ) {
77            $qb->where( [ 'rev_id' => $this->getOption( 'rev' ) ] );
78        } else {
79            $this->error( "One of 'all', 'page', or 'rev' required" );
80            $this->maybeHelp( true );
81            return;
82        }
83
84        // Add conditions from HookUtils::isAvailableForTitle().
85        // Keep this in sync with that method.
86        $nsInfo = $services->getNamespaceInfo();
87        $signatureNamespaces = array_values( array_filter(
88            $nsInfo->getValidNamespaces(),
89            [ $nsInfo, 'wantSignatures' ]
90        ) );
91        $qb->leftJoin( 'page_props', null, [
92            'pp_propname' => 'newsectionlink',
93            'pp_page = page_id',
94        ] );
95        $qb->where(
96            $dbr->expr( 'page_namespace', '=', $signatureNamespaces )
97                ->or( 'pp_propname', '!=', null )
98        );
99
100        if ( $this->getOption( 'current' ) ) {
101            $qb->where( 'rev_id = page_latest' );
102            $index = [ 'page_id' ];
103
104            if ( $this->getOption( 'touched-after' ) ) {
105                $qb->where( $dbr->expr(
106                    'page_touched', '>', $dbr->timestamp( $this->getOption( 'touched-after' ) )
107                ) );
108            }
109            if ( $this->getOption( 'touched-before' ) ) {
110                $qb->where( $dbr->expr(
111                    'page_touched', '<', $dbr->timestamp( $this->getOption( 'touched-before' ) )
112                ) );
113            }
114
115        } else {
116            // Process in order by page and time to avoid confusing results while the script is running
117            $index = [ 'rev_page', 'rev_timestamp', 'rev_id' ];
118        }
119
120        $this->process( $qb, $index );
121    }
122
123    private function process( SelectQueryBuilder $qb, array $index ): void {
124        $start = microtime( true );
125
126        $qb->caller( __METHOD__ );
127
128        // estimateRowCount() refuses to work when fields are set, so we can't just call it on $qb
129        $countQueryInfo = $qb->getQueryInfo();
130        $count = $qb->newSubquery()
131            ->rawTables( $countQueryInfo['tables'] )
132            ->where( $countQueryInfo['conds'] )
133            ->options( $countQueryInfo['options'] )
134            ->joinConds( $countQueryInfo['join_conds'] )
135            ->caller( __METHOD__ )
136            ->estimateRowCount();
137        $this->output( "Processing... (estimated $count rows)\n" );
138
139        $processed = 0;
140        $updated = 0;
141
142        $qb->orderBy( $index );
143        $batchSize = $this->getBatchSize();
144        $qb->limit( $batchSize );
145
146        $batchStart = null;
147        if ( $this->getOption( 'start' ) ) {
148            $batchStart = json_decode( $this->getOption( 'start' ) );
149            if ( !$batchStart ) {
150                $this->error( "Invalid 'start'" );
151            }
152        }
153
154        while ( true ) {
155            $qbForBatch = clone $qb;
156            if ( $batchStart ) {
157                $batchStartCond = $this->dbr->buildComparison( '>', array_combine( $index, $batchStart ) );
158                $qbForBatch->where( $batchStartCond );
159
160                $batchStartOutput = Shell::escape( json_encode( $batchStart ) );
161                $this->output( "--start $batchStartOutput\n" );
162            }
163
164            $res = $qbForBatch->fetchResultSet();
165            foreach ( $res as $row ) {
166                $updated += (int)$this->processRow( $row );
167            }
168            $processed += $res->numRows();
169
170            $this->output( "Processed $processed (updated $updated) of $count rows\n" );
171
172            $this->waitForReplication();
173
174            if ( $res->numRows() < $batchSize || !isset( $row ) ) {
175                // Done
176                break;
177            }
178
179            // Update the conditions to select the next batch.
180            $batchStart = [];
181            foreach ( $index as $field ) {
182                $batchStart[] = $row->$field;
183            }
184        }
185
186        $duration = microtime( true ) - $start;
187        $durationFormatted = $this->lang->formatTimePeriod( $duration );
188        $this->output( "Finished in $durationFormatted\n" );
189    }
190
191    /**
192     * @param stdClass $row Database table row
193     * @return bool
194     */
195    private function processRow( stdClass $row ): bool {
196        $changed = false;
197        try {
198            $rev = $this->revStore->newRevisionFromRow( $row );
199            $title = Title::newFromLinkTarget(
200                $rev->getPageAsLinkTarget()
201            );
202            if ( HookUtils::isAvailableForTitle( $title ) ) {
203                $threadItemSet = HookUtils::parseRevisionParsoidHtml( $rev, false );
204
205                // Store permalink data (even when store is disabled - T334258)
206                $changed = $this->itemStore->insertThreadItems( $rev, $threadItemSet );
207            }
208        } catch ( Throwable $e ) {
209            $this->output( "Error while processing revid=$row->rev_id, pageid=$row->rev_page\n" );
210            MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW );
211        }
212        return $changed;
213    }
214}
215
216$maintClass = PersistRevisionThreadItems::class;
217require_once RUN_MAINTENANCE_IF_MAIN;