MediaWiki master
cleanupBlocks.php
Go to the documentation of this file.
1<?php
2
4use Wikimedia\IPUtils;
5
6require_once __DIR__ . '/Maintenance.php';
7
9 private bool $dryRun = false;
10
11 public function __construct() {
12 parent::__construct();
13 $this->addDescription( 'Fix referential integrity issues in block and block_target tables' );
14 $this->addOption( 'dry-run', 'Just report, don\'t fix anything' );
15 }
16
17 public function execute() {
18 $this->dryRun = $this->hasOption( 'dry-run' );
19
20 $this->deleteOrphanBlockTargets();
21 $this->deleteTargetlessBlocks();
22 $this->normalizeAddresses();
23 $this->mergeDuplicateBlockTargets();
24 $this->fixTargetCounts();
25 }
26
30 private function deleteOrphanBlockTargets() {
31 $dbr = $this->getReplicaDB();
32 $badIds = $dbr->newSelectQueryBuilder()
33 ->select( 'bt_id' )
34 ->from( 'block_target' )
35 ->leftJoin( 'block', null, 'bt_id=bl_target' )
36 ->where( [ 'bl_target' => null ] )
37 ->caller( __METHOD__ )
38 ->fetchFieldValues();
39
40 foreach ( $badIds as $id ) {
41 $this->deleteOrphanBlockTarget( (int)$id );
42 }
43 }
44
49 private function deleteOrphanBlockTarget( int $id ) {
50 $this->output( "Deleting orphan bt_id=$id: " );
51 if ( $this->dryRun ) {
52 $this->output( "dry run\n" );
53 return;
54 }
55 $dbw = $this->getPrimaryDB();
56 $dbw->startAtomic( __METHOD__ );
57 $lockingUsage = $dbw->newSelectQueryBuilder()
58 ->from( 'block' )
59 ->where( [ 'bl_target' => $id ] )
60 ->forUpdate()
61 ->caller( __METHOD__ )
62 ->fetchRowCount();
63 if ( $lockingUsage ) {
64 $dbw->endAtomic( __METHOD__ );
65 $this->output( "primary usage count is non-zero\n" );
66 return;
67 }
68 $dbw->newDeleteQueryBuilder()
69 ->deleteFrom( 'block_target' )
70 ->where( [ 'bt_id' => $id ] )
71 ->caller( __METHOD__ )
72 ->execute();
73 $affected = $dbw->affectedRows();
74 $dbw->endAtomic( __METHOD__ );
75 $this->output( $affected ? "OK\n" : "no rows affected\n" );
76 }
77
81 private function deleteTargetlessBlocks() {
82 $dbr = $this->getReplicaDB();
83 $res = $dbr->newSelectQueryBuilder()
84 ->select( [ 'bl_id', 'bl_target' ] )
85 ->from( 'block' )
86 ->leftJoin( 'block_target', null, 'bt_id=bl_target' )
87 ->where( [ 'bt_id' => null ] )
88 ->caller( __METHOD__ )
89 ->fetchResultSet();
90 foreach ( $res as $row ) {
91 $this->deleteTargetlessBlock( (int)$row->bl_id, (int)$row->bl_target );
92 }
93 }
94
101 private function deleteTargetlessBlock( int $blockId, int $targetId ) {
102 $this->output( "Deleting block $blockId on non-existent target $targetId: " );
103 if ( $this->dryRun ) {
104 $this->output( "dry run\n" );
105 return;
106 }
107 $dbw = $this->getPrimaryDB();
108 $dbw->startAtomic( __METHOD__ );
109 $lockingTargetCount = $dbw->newSelectQueryBuilder()
110 ->from( 'block_target' )
111 ->where( [ 'bt_id' => $targetId ] )
112 ->forUpdate()
113 ->caller( __METHOD__ )
114 ->fetchRowCount();
115 if ( $lockingTargetCount ) {
116 $this->output( "target exists in primary\n" );
117 $dbw->endAtomic( __METHOD__ );
118 return;
119 }
120 $dbw->newDeleteQueryBuilder()
121 ->deleteFrom( 'block' )
122 ->where( [ 'bl_id' => $blockId, 'bl_target' => $targetId ] )
123 ->caller( __METHOD__ )
124 ->execute();
125 $affected = $dbw->affectedRows();
126 $dbw->endAtomic( __METHOD__ );
127 $this->output( $affected ? "OK\n" : "no rows affected\n" );
128 }
129
136 private function normalizeAddresses() {
137 $dbr = $this->getReplicaDB();
138 $dbType = $dbr->getType();
139 if ( $dbType !== 'mysql' ) {
140 $this->output( "Skipping IP address normalization: not implemented on $dbType\n" );
141 return;
142 }
143 $res = $dbr->newSelectQueryBuilder()
144 ->select( [ 'bt_id', 'bt_address' ] )
145 ->from( 'block_target' )
146 ->where( [ 'bt_user' => null ] )
147 ->andWhere( 'bt_range_start IS NOT NULL OR ' .
148 'bt_address RLIKE \'(^|[.:])0[0-9]|[a-f]|::\'' )
149 ->caller( __METHOD__ )
150 ->fetchResultSet();
151 $writeDone = false;
152 foreach ( $res as $row ) {
153 $addr = $row->bt_address;
154 if ( IPUtils::isValid( $addr ) ) {
155 $norm = IPUtils::sanitizeIP( $addr );
156 } elseif ( IPUtils::isValidRange( $addr ) ) {
157 $norm = IPUtils::sanitizeRange( $addr );
158 } else {
159 continue;
160 }
161 if ( $addr !== $norm && is_string( $norm ) ) {
162 $this->normalizeAddress( (int)$row->bt_id, $addr, $norm );
163 $writeDone = true;
164 }
165 }
166 if ( $writeDone ) {
167 // Ensure that mergeDuplicateBlockTargets() sees our changes
168 $this->waitForReplication();
169 }
170 }
171
179 private function normalizeAddress( int $targetId, string $address, string $normalizedAddress ) {
180 $this->output( "Normalizing bt_id=$targetId $address -> $normalizedAddress: " );
181 if ( $this->dryRun ) {
182 $this->output( "dry run\n" );
183 return;
184 }
185 $dbw = $this->getPrimaryDB();
186 $dbw->startAtomic( __METHOD__ );
187 $primaryAddr = $dbw->newSelectQueryBuilder()
188 ->select( 'bt_address' )
189 ->from( 'block_target' )
190 ->where( [ 'bt_id' => $targetId ] )
191 ->forUpdate()
192 ->caller( __METHOD__ )
193 ->fetchField();
194 if ( $primaryAddr === false ) {
195 $this->output( "missing in primary\n" );
196 return;
197 }
198 if ( $primaryAddr !== $address ) {
199 $this->output( "changed in primary\n" );
200 return;
201 }
202 $dbw->newUpdateQueryBuilder()
203 ->update( 'block_target' )
204 ->set( [ 'bt_address' => $normalizedAddress ] )
205 ->where( [ 'bt_id' => $targetId ] )
206 ->caller( __METHOD__ )
207 ->execute();
208 $dbw->endAtomic( __METHOD__ );
209 $this->output( "done\n" );
210 }
211
215 private function mergeDuplicateBlockTargets() {
216 $dbr = $this->getReplicaDB();
217 $rawGroups = $this->getReplicaDB()->newSelectQueryBuilder()
218 ->select( $dbr->buildGroupConcat( 'bt_id', ',' ) )
219 ->from( 'block_target' )
220 ->where( $dbr->expr( 'bt_user', '!=', null ) )
221 ->groupBy( 'bt_user' )
222 ->having( 'COUNT(*) > 1' )
223 ->caller( __METHOD__ )
224 ->fetchFieldValues();
225 $this->processIdGroups( $rawGroups );
226
227 $rawGroups = $this->getReplicaDB()->newSelectQueryBuilder()
228 ->select( $dbr->buildGroupConcat( 'bt_id', ',' ) )
229 ->from( 'block_target' )
230 ->where( $dbr->expr( 'bt_address', '!=', null ) )
231 ->groupBy( [ 'bt_auto', 'bt_address' ] )
232 ->having( 'COUNT(*) > 1' )
233 ->caller( __METHOD__ )
234 ->fetchFieldValues();
235 $this->processIdGroups( $rawGroups );
236 }
237
242 private function processIdGroups( $rawGroups ) {
243 foreach ( $rawGroups as $blob ) {
244 $group = array_map( 'intval', explode( ',', $blob ) );
245 sort( $group );
246 $main = array_shift( $group );
247 $this->mergeGroup( $main, $group );
248 }
249 }
250
256 private function mergeGroup( int $mainId, array $badIds ) {
257 $this->output( 'Merging bt_id ' . implode( ',', $badIds ) . " into $mainId: " );
258 if ( $this->dryRun ) {
259 $this->output( "dry run\n" );
260 return;
261 }
262
263 $dbw = $this->getPrimaryDB();
264 $dbw->startAtomic( __METHOD__ );
265
266 // Check that the targets are identical in the primary
267 $fieldsToTest = [ 'bt_address', 'bt_user', 'bt_user_text', 'bt_auto' ];
268 $mainRow = $dbw->newSelectQueryBuilder()
269 ->select( $fieldsToTest )
270 ->from( 'block_target' )
271 ->where( [ 'bt_id' => $mainId ] )
272 ->forUpdate()
273 ->caller( __METHOD__ )
274 ->fetchRow();
275 $badRows = $dbw->newSelectQueryBuilder()
276 ->select( $fieldsToTest )
277 ->select( 'bt_id' )
278 ->from( 'block_target' )
279 ->where( [ 'bt_id' => $badIds ] )
280 ->forUpdate()
281 ->caller( __METHOD__ )
282 ->fetchResultSet();
283
284 if ( $badRows->numRows() !== count( $badIds ) ) {
285 $this->output( "some IDs are not present in the primary\n" );
286 $dbw->endAtomic( __METHOD__ );
287 return;
288 }
289
290 foreach ( $badRows as $badRow ) {
291 foreach ( $fieldsToTest as $field ) {
292 if ( $mainRow->$field !== $badRow->$field ) {
293 $this->output( "mismatch in $field for bt_id={$badRow->bt_id}\n" );
294 $dbw->endAtomic( __METHOD__ );
295 return;
296 }
297 }
298 }
299
300 // Update the block rows for the targets to be deleted
301 $dbw->newUpdateQueryBuilder()
302 ->update( 'block' )
303 ->set( [ 'bl_target' => $mainId ] )
304 ->where( [ 'bl_target' => $badIds ] )
305 ->caller( __METHOD__ )
306 ->execute();
307 $blockCount = $dbw->affectedRows();
308
309 // Delete the bad targets
310 $dbw->newDeleteQueryBuilder()
311 ->delete( 'block_target' )
312 ->where( [ 'bt_id' => $badIds ] )
313 ->caller( __METHOD__ )
314 ->execute();
315
316 // Update bt_count for the remaining target
317 $dbw->newUpdateQueryBuilder()
318 ->update( 'block_target' )
319 ->set( 'bt_count=bt_count + ' . $blockCount )
320 ->where( [ 'bt_id' => $mainId ] )
321 ->caller( __METHOD__ )
322 ->execute();
323
324 $dbw->endAtomic( __METHOD__ );
325 $this->output( "done\n" );
326 }
327
331 private function fixTargetCounts() {
332 $dbr = $this->getReplicaDB();
333 $res = $dbr->newSelectQueryBuilder()
334 ->select( [ 'bt_id', 'bt_count', 'real_count' => 'COUNT(*)' ] )
335 ->from( 'block' )
336 ->join( 'block_target', null, 'bt_id=bl_target' )
337 ->groupBy( [ 'bt_id', 'bt_count' ] )
338 ->having( 'COUNT(*) != bt_count' )
339 ->caller( __METHOD__ )
340 ->fetchResultSet();
341
342 foreach ( $res as $row ) {
343 $this->fixTargetCount( (int)$row->bt_id, (int)$row->bt_count, (int)$row->real_count );
344 }
345 }
346
354 private function fixTargetCount( int $targetId, int $badCount, int $replicaCount ) {
355 $this->output( "Fixing bt_id=$targetId count $badCount -> $replicaCount: " );
356 if ( $this->dryRun ) {
357 $this->output( "dry run\n" );
358 return;
359 }
360
361 $dbw = $this->getPrimaryDB();
362 $dbw->startAtomic( __METHOD__ );
363 $primaryCount = $dbw->newSelectQueryBuilder()
364 ->from( 'block' )
365 ->where( [ 'bl_target' => $targetId ] )
366 ->forUpdate()
367 ->caller( __METHOD__ )
368 ->fetchRowCount();
369
370 if ( $primaryCount !== $replicaCount ) {
371 $dbw->endAtomic( __METHOD__ );
372 $this->output( "changed in primary, skipping\n" );
373 return;
374 }
375
376 $dbw->newUpdateQueryBuilder()
377 ->update( 'block_target' )
378 ->set( [ 'bt_count' => $primaryCount ] )
379 ->where( [
380 'bt_id' => $targetId,
381 'bt_count' => $badCount
382 ] )
383 ->caller( __METHOD__ )
384 ->execute();
385 $affected = $dbw->affectedRows();
386 $dbw->endAtomic( __METHOD__ );
387 $this->output( $affected ? "OK\n" : "no rows affected\n" );
388 }
389}
390
391$maintClass = CleanupBlocks::class;
392require_once RUN_MAINTENANCE_IF_MAIN;
execute()
Do the actual work.
__construct()
Default constructor.
Abstract maintenance class for quickly writing and churning out maintenance scripts with minimal effo...
output( $out, $channel=null)
Throw some output to the user.
addOption( $name, $description, $required=false, $withArg=false, $shortName=false, $multiOccurrence=false)
Add a parameter to the script.
waitForReplication()
Wait for replica DB servers to catch up.
hasOption( $name)
Checks to see if a particular option was set.
getReplicaDB(string|false $virtualDomain=false)
getPrimaryDB(string|false $virtualDomain=false)
addDescription( $text)
Set the description text.
$maintClass