Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 220
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FindBadBlobs
0.00% covered (danger)
0.00%
0 / 217
0.00% covered (danger)
0.00%
0 / 14
2450
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getStartTimestamp
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getRevisionIds
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 execute
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
72
 scanRevisionsByTimestamp
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
110
 loadRevisionsByTimestamp
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 loadArchiveByRevisionId
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getNextRevision
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 scanRevisionsById
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 loadRevisionsById
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 checkRevision
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 checkSlot
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 markBlob
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 handleStatus
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
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 * @ingroup Maintenance
20 */
21
22use MediaWiki\Revision\RevisionArchiveRecord;
23use MediaWiki\Revision\RevisionRecord;
24use MediaWiki\Revision\RevisionStore;
25use MediaWiki\Revision\RevisionStoreRecord;
26use MediaWiki\Revision\SlotRecord;
27use MediaWiki\Storage\BlobStore;
28
29require_once __DIR__ . '/Maintenance.php';
30
31/**
32 * Maintenance script for finding and marking bad content blobs.
33 *
34 * @ingroup Maintenance
35 */
36class FindBadBlobs extends Maintenance {
37
38    private RevisionStore $revisionStore;
39    private BlobStore $blobStore;
40
41    public function __construct() {
42        parent::__construct();
43
44        $this->setBatchSize( 1000 );
45        $this->addDescription( 'Find and mark bad content blobs. Marked blobs will be read as empty. '
46            . 'Use --scan-from to find revisions with bad blobs, use --mark to mark them.' );
47        $this->addOption( 'scan-from', 'Start scanning revisions at the given date. '
48            . 'Format: Anything supported by MediaWiki, e.g. YYYYMMDDHHMMSS or YYYY-MM-DDTHH:MM:SS',
49            false, true );
50        $this->addOption( 'revisions', 'A list of revision IDs to process, separated by comma or '
51            . 'colon or whitespace. Revisions belonging to deleted pages will work. '
52            . 'If set to "-" IDs are read from stdin, one per line.', false, true );
53        $this->addOption( 'limit', 'Maximum number of revisions for --scan-from to scan. '
54            . 'Default: 1000', false, true );
55        $this->addOption( 'mark', 'Mark the blob as "known bad", to avoid errors when '
56            . 'attempting to read it. The value given is the reason for marking the blob as bad, '
57            . 'typically a ticket ID. Requires --revisions to also be set.', false, true );
58    }
59
60    /**
61     * @return string
62     */
63    private function getStartTimestamp() {
64        $tsOpt = $this->getOption( 'scan-from' );
65        if ( strlen( $tsOpt ) < 14 ) {
66            $this->fatalError( 'Bad timestamp: ' . $tsOpt
67                . ', please provide time and date down to the second.' );
68        }
69
70        $ts = wfTimestamp( TS_MW, $tsOpt );
71        if ( !$ts ) {
72            $this->fatalError( 'Bad timestamp: ' . $tsOpt );
73        }
74
75        return $ts;
76    }
77
78    /**
79     * @return int[]
80     */
81    private function getRevisionIds() {
82        $opt = $this->getOption( 'revisions' );
83
84        if ( $opt === '-' ) {
85            $opt = stream_get_contents( STDIN );
86
87            if ( !$opt ) {
88                return [];
89            }
90        }
91
92        return $this->parseIntList( $opt );
93    }
94
95    /**
96     * @inheritDoc
97     */
98    public function execute() {
99        $services = $this->getServiceContainer();
100        $this->revisionStore = $services->getRevisionStore();
101        $this->blobStore = $services->getBlobStore();
102        $this->setDBProvider( $services->getConnectionProvider() );
103
104        if ( $this->hasOption( 'revisions' ) ) {
105            if ( $this->hasOption( 'scan-from' ) ) {
106                $this->fatalError( 'Cannot use --revisions together with --scan-from' );
107            }
108
109            $ids = $this->getRevisionIds();
110
111            $count = $this->scanRevisionsById( $ids );
112        } elseif ( $this->hasOption( 'scan-from' ) ) {
113            if ( $this->hasOption( 'mark' ) ) {
114                $this->fatalError( 'Cannot use --mark with --scan-from, '
115                    . 'use --revisions to specify revisions to mark.' );
116            }
117
118            $fromTimestamp = $this->getStartTimestamp();
119            $total = $this->getOption( 'limit', 1000 );
120
121            $count = $this->scanRevisionsByTimestamp( $fromTimestamp, $total );
122
123            $this->output( "The range of archive rows scanned is based on the range of revision IDs "
124                . "scanned in the revision table.\n" );
125        } else {
126            if ( $this->hasOption( 'mark' ) ) {
127                $this->fatalError( 'The --mark must be used together with --revisions' );
128            } else {
129                $this->fatalError( 'Must specify one of --revisions or --scan-from' );
130            }
131        }
132
133        if ( $this->hasOption( 'mark' ) ) {
134            $this->output( "Marked $count bad revisions.\n" );
135        } else {
136            $this->output( "Found $count bad revisions.\n" );
137
138            if ( $count > 0 ) {
139                $this->output( "On a unix/linux environment, you can use grep and cut to list of IDs\n" );
140                $this->output( "that can then be used with the --revisions option. E.g.\n" );
141                $this->output( "  grep '! Found bad blob' | cut -s -f 3\n" );
142            }
143        }
144    }
145
146    /**
147     * @param string $fromTimestamp
148     * @param int $total
149     *
150     * @return int
151     */
152    private function scanRevisionsByTimestamp( $fromTimestamp, $total ) {
153        $count = 0;
154        $lastRevId = 0;
155        $firstRevId = 0;
156        $lastTimestamp = $fromTimestamp;
157        $revisionRowsScanned = 0;
158        $archiveRowsScanned = 0;
159
160        $this->output( "Scanning revisions table, "
161            . "$total rows starting at rev_timestamp $fromTimestamp\n" );
162
163        while ( $revisionRowsScanned < $total ) {
164            $batchSize = min( $total - $revisionRowsScanned, $this->getBatchSize() );
165            $revisions = $this->loadRevisionsByTimestamp( $lastRevId, $lastTimestamp, $batchSize );
166            if ( !$revisions ) {
167                break;
168            }
169
170            foreach ( $revisions as $rev ) {
171                // we are sorting by timestamp, so we may encounter revision IDs out of sequence
172                $firstRevId = $firstRevId ? min( $firstRevId, $rev->getId() ) : $rev->getId();
173                $lastRevId = max( $lastRevId, $rev->getId() );
174
175                $count += $this->checkRevision( $rev );
176            }
177
178            $lastTimestamp = $rev->getTimestamp();
179            $batchSize = count( $revisions );
180            $revisionRowsScanned += $batchSize;
181            $this->output(
182                "\t- Scanned a batch of $batchSize revisions, "
183                . "up to revision $lastRevId ($lastTimestamp)\n"
184            );
185
186            $this->waitForReplication();
187        }
188
189        // NOTE: the archive table isn't indexed by timestamp, so the best we can do is use the
190        // revision ID just before the first revision ID we found above as the starting point
191        // of the scan, and scan up to on revision after the last revision ID we found above.
192        // If $firstRevId is 0, the loop body above didn't execute,
193        // so we should skip the one below as well.
194        $fromArchived = $this->getNextRevision( $firstRevId, '<', 'DESC' );
195        $maxArchived = $this->getNextRevision( $lastRevId, '>', 'ASC' );
196        $maxArchived = $maxArchived ?: PHP_INT_MAX;
197
198        $this->output( "Scanning archive table by ar_rev_id, $fromArchived to $maxArchived\n" );
199        while ( $firstRevId > 0 && $fromArchived < $maxArchived ) {
200            $batchSize = min( $total - $archiveRowsScanned, $this->getBatchSize() );
201            $revisions = $this->loadArchiveByRevisionId( $fromArchived, $maxArchived, $batchSize );
202            if ( !$revisions ) {
203                break;
204            }
205            /** @var RevisionRecord $rev */
206            foreach ( $revisions as $rev ) {
207                $count += $this->checkRevision( $rev );
208            }
209            $fromArchived = $rev->getId();
210            $batchSize = count( $revisions );
211            $archiveRowsScanned += $batchSize;
212            $this->output(
213                "\t- Scanned a batch of $batchSize archived revisions, "
214                . "up to revision $fromArchived ($lastTimestamp)\n"
215            );
216
217            $this->waitForReplication();
218        }
219
220        return $count;
221    }
222
223    /**
224     * @param int $afterId
225     * @param string $fromTimestamp
226     * @param int $batchSize
227     *
228     * @return RevisionStoreRecord[]
229     */
230    private function loadRevisionsByTimestamp( int $afterId, string $fromTimestamp, $batchSize ) {
231        $db = $this->getReplicaDB();
232        $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $db );
233        $rows = $queryBuilder->joinComment()
234            ->where( $db->buildComparison( '>', [
235                'rev_timestamp' => $fromTimestamp,
236                'rev_id' => $afterId,
237            ] ) )
238            ->useIndex( [ 'revision' => 'rev_timestamp' ] )
239            ->orderBy( [ 'rev_timestamp', 'rev_id' ] )
240            ->limit( $batchSize )
241            ->caller( __METHOD__ )->fetchResultSet();
242        $result = $this->revisionStore->newRevisionsFromBatch( $rows, [ 'slots' => true ] );
243        $this->handleStatus( $result );
244
245        $records = array_filter( $result->value );
246
247        '@phan-var RevisionStoreRecord[] $records';
248        return $records;
249    }
250
251    /**
252     * @param int $afterId
253     * @param int $uptoId
254     * @param int $batchSize
255     *
256     * @return RevisionArchiveRecord[]
257     */
258    private function loadArchiveByRevisionId( int $afterId, int $uptoId, $batchSize ) {
259        $db = $this->getReplicaDB();
260        $rows = $this->revisionStore->newArchiveSelectQueryBuilder( $db )
261            ->joinComment()
262            ->where( [ "ar_rev_id > $afterId", "ar_rev_id <= $uptoId" ] )
263            ->orderBy( 'ar_rev_id' )
264            ->limit( $batchSize )
265            ->caller( __METHOD__ )->fetchResultSet();
266        $result = $this->revisionStore->newRevisionsFromBatch(
267            $rows,
268            [ 'archive' => true, 'slots' => true ]
269        );
270        $this->handleStatus( $result );
271
272        $records = array_filter( $result->value );
273
274        '@phan-var RevisionArchiveRecord[] $records';
275        return $records;
276    }
277
278    /**
279     * Returns the revision ID next to $revId, according to $comp and $dir
280     *
281     * @param int $revId
282     * @param string $comp the comparator, either '<' or '>', to go with $dir
283     * @param string $dir the sort direction to go with $comp, either 'ARC' or 'DESC'
284     *
285     * @return int
286     */
287    private function getNextRevision( int $revId, string $comp, string $dir ) {
288        $db = $this->getReplicaDB();
289        $next = $db->newSelectQueryBuilder()
290            ->select( 'rev_id' )
291            ->from( 'revision' )
292            ->where( "rev_id $comp $revId" )
293            ->orderBy( [ "rev_id" ], $dir )
294            ->caller( __METHOD__ )
295            ->fetchField();
296        return (int)$next;
297    }
298
299    /**
300     * @param array $ids
301     *
302     * @return int
303     */
304    private function scanRevisionsById( array $ids ) {
305        $count = 0;
306        $total = count( $ids );
307
308        $this->output( "Scanning $total ids\n" );
309
310        foreach ( array_chunk( $ids, $this->getBatchSize() ) as $batch ) {
311            $revisions = $this->loadRevisionsById( $batch );
312
313            if ( !$revisions ) {
314                continue;
315            }
316
317            /** @var RevisionRecord $rev */
318            foreach ( $revisions as $rev ) {
319                $count += $this->checkRevision( $rev );
320            }
321
322            $batchSize = count( $revisions );
323            $this->output( "\t- Scanned a batch of $batchSize revisions\n" );
324        }
325
326        return $count;
327    }
328
329    /**
330     * @param int[] $ids
331     *
332     * @return RevisionRecord[]
333     */
334    private function loadRevisionsById( array $ids ) {
335        $db = $this->getReplicaDB();
336        $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $db );
337
338        $rows = $queryBuilder
339            ->joinComment()
340            ->where( [ 'rev_id' => $ids ] )
341            ->caller( __METHOD__ )->fetchResultSet();
342
343        $result = $this->revisionStore->newRevisionsFromBatch( $rows, [ 'slots' => true ] );
344
345        $this->handleStatus( $result );
346
347        $revisions = array_filter( $result->value );
348        '@phan-var RevisionArchiveRecord[] $revisions';
349
350        // if not all revisions were found, check the archive table.
351        if ( count( $revisions ) < count( $ids ) ) {
352            $rows = $this->revisionStore->newArchiveSelectQueryBuilder( $db )
353                ->joinComment()
354                ->where( [ 'ar_rev_id' => array_diff( $ids, array_keys( $revisions ) ) ] )
355                ->caller( __METHOD__ )->fetchResultSet();
356
357            $archiveResult = $this->revisionStore->newRevisionsFromBatch(
358                $rows,
359                [ 'slots' => true, 'archive' => true ]
360            );
361
362            $this->handleStatus( $archiveResult );
363
364            // don't use array_merge, since it will re-index
365            $revisions += array_filter( $archiveResult->value );
366        }
367
368        return $revisions;
369    }
370
371    /**
372     * @param RevisionRecord $rev
373     *
374     * @return int
375     */
376    private function checkRevision( RevisionRecord $rev ) {
377        $count = 0;
378        foreach ( $rev->getSlots()->getSlots() as $slot ) {
379            $count += $this->checkSlot( $rev, $slot );
380        }
381
382        if ( $count === 0 && $this->hasOption( 'mark' ) ) {
383            $this->output( "\t# No bad blob found on revision {$rev->getId()}, skipped!\n" );
384        }
385
386        return $count;
387    }
388
389    /**
390     * @param RevisionRecord $rev
391     * @param SlotRecord $slot
392     *
393     * @return int
394     */
395    private function checkSlot( RevisionRecord $rev, SlotRecord $slot ) {
396        $address = $slot->getAddress();
397
398        try {
399            $this->blobStore->getBlob( $address );
400            // nothing to do
401            return 0;
402        } catch ( Exception $ex ) {
403            $error = $ex->getMessage();
404            $type = get_class( $ex );
405        }
406
407        // NOTE: output the revision ID again at the end in a separate column for easy processing
408        // via the "cut" shell command.
409        $this->output( "\t! Found bad blob on revision {$rev->getId()} "
410            . "from {$rev->getTimestamp()} ({$slot->getRole()} slot): "
411            . "content_id={$slot->getContentId()}, address=<{$slot->getAddress()}>, "
412            . "error='$error', type='$type'. ID:\t{$rev->getId()}\n" );
413
414        if ( $this->hasOption( 'mark' ) ) {
415            $newAddress = $this->markBlob( $slot, $error );
416            $this->output( "\tChanged address to <$newAddress>\n" );
417        }
418
419        return 1;
420    }
421
422    /**
423     * @param SlotRecord $slot
424     * @param string|null $error
425     *
426     * @return false|string
427     */
428    private function markBlob( SlotRecord $slot, string $error = null ) {
429        $args = [];
430
431        if ( $this->hasOption( 'mark' ) ) {
432            $args['reason'] = $this->getOption( 'mark' );
433        }
434
435        if ( $error ) {
436            $args['error'] = $error;
437        }
438
439        $address = $slot->getAddress() ?: 'empty';
440        $badAddress = 'bad:' . urlencode( $address );
441
442        if ( $args ) {
443            $badAddress .= '?' . wfArrayToCgi( $args );
444        }
445
446        $badAddress = substr( $badAddress, 0, 255 );
447
448        $dbw = $this->getPrimaryDB();
449        $dbw->newUpdateQueryBuilder()
450            ->update( 'content' )
451            ->set( [ 'content_address' => $badAddress ] )
452            ->where( [ 'content_id' => $slot->getContentId() ] )
453            ->caller( __METHOD__ )->execute();
454
455        return $badAddress;
456    }
457
458    private function handleStatus( StatusValue $status ) {
459        if ( !$status->isOK() ) {
460            $this->fatalError( $status );
461        }
462        if ( !$status->isGood() ) {
463            $this->error( $status );
464        }
465    }
466
467}
468
469$maintClass = FindBadBlobs::class;
470require_once RUN_MAINTENANCE_IF_MAIN;