9 private bool $dryRun =
false;
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' );
18 $this->dryRun = $this->
hasOption(
'dry-run' );
20 $this->deleteOrphanBlockTargets();
21 $this->deleteTargetlessBlocks();
22 $this->normalizeAddresses();
23 $this->mergeDuplicateBlockTargets();
24 $this->fixTargetCounts();
30 private function deleteOrphanBlockTargets() {
32 $badIds = $dbr->newSelectQueryBuilder()
34 ->from(
'block_target' )
35 ->leftJoin(
'block',
null,
'bt_id=bl_target' )
36 ->where( [
'bl_target' =>
null ] )
37 ->caller( __METHOD__ )
40 foreach ( $badIds as $id ) {
41 $this->deleteOrphanBlockTarget( (
int)$id );
49 private function deleteOrphanBlockTarget(
int $id ) {
50 $this->
output(
"Deleting orphan bt_id=$id: " );
51 if ( $this->dryRun ) {
52 $this->
output(
"dry run\n" );
56 $dbw->startAtomic( __METHOD__ );
57 $lockingUsage = $dbw->newSelectQueryBuilder()
59 ->where( [
'bl_target' => $id ] )
61 ->caller( __METHOD__ )
63 if ( $lockingUsage ) {
64 $dbw->endAtomic( __METHOD__ );
65 $this->
output(
"primary usage count is non-zero\n" );
68 $dbw->newDeleteQueryBuilder()
69 ->deleteFrom(
'block_target' )
70 ->where( [
'bt_id' => $id ] )
71 ->caller( __METHOD__ )
73 $affected = $dbw->affectedRows();
74 $dbw->endAtomic( __METHOD__ );
75 $this->
output( $affected ?
"OK\n" :
"no rows affected\n" );
81 private function deleteTargetlessBlocks() {
83 $res = $dbr->newSelectQueryBuilder()
84 ->select( [
'bl_id',
'bl_target' ] )
86 ->leftJoin(
'block_target',
null,
'bt_id=bl_target' )
87 ->where( [
'bt_id' =>
null ] )
88 ->caller( __METHOD__ )
90 foreach ( $res as $row ) {
91 $this->deleteTargetlessBlock( (
int)$row->bl_id, (
int)$row->bl_target );
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" );
108 $dbw->startAtomic( __METHOD__ );
109 $lockingTargetCount = $dbw->newSelectQueryBuilder()
110 ->from(
'block_target' )
111 ->where( [
'bt_id' => $targetId ] )
113 ->caller( __METHOD__ )
115 if ( $lockingTargetCount ) {
116 $this->
output(
"target exists in primary\n" );
117 $dbw->endAtomic( __METHOD__ );
120 $dbw->newDeleteQueryBuilder()
121 ->deleteFrom(
'block' )
122 ->where( [
'bl_id' => $blockId,
'bl_target' => $targetId ] )
123 ->caller( __METHOD__ )
125 $affected = $dbw->affectedRows();
126 $dbw->endAtomic( __METHOD__ );
127 $this->
output( $affected ?
"OK\n" :
"no rows affected\n" );
136 private function normalizeAddresses() {
138 $dbType = $dbr->getType();
139 if ( $dbType !==
'mysql' ) {
140 $this->
output(
"Skipping IP address normalization: not implemented on $dbType\n" );
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__ )
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 );
161 if ( $addr !== $norm && is_string( $norm ) ) {
162 $this->normalizeAddress( (
int)$row->bt_id, $addr, $norm );
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" );
186 $dbw->startAtomic( __METHOD__ );
187 $primaryAddr = $dbw->newSelectQueryBuilder()
188 ->select(
'bt_address' )
189 ->from(
'block_target' )
190 ->where( [
'bt_id' => $targetId ] )
192 ->caller( __METHOD__ )
194 if ( $primaryAddr ===
false ) {
195 $this->
output(
"missing in primary\n" );
198 if ( $primaryAddr !== $address ) {
199 $this->
output(
"changed in primary\n" );
202 $dbw->newUpdateQueryBuilder()
203 ->update(
'block_target' )
204 ->set( [
'bt_address' => $normalizedAddress ] )
205 ->where( [
'bt_id' => $targetId ] )
206 ->caller( __METHOD__ )
208 $dbw->endAtomic( __METHOD__ );
209 $this->
output(
"done\n" );
215 private function mergeDuplicateBlockTargets() {
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 );
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 );
242 private function processIdGroups( $rawGroups ) {
243 foreach ( $rawGroups as $blob ) {
244 $group = array_map(
'intval', explode(
',', $blob ) );
246 $main = array_shift( $group );
247 $this->mergeGroup( $main, $group );
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" );
264 $dbw->startAtomic( __METHOD__ );
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 ] )
273 ->caller( __METHOD__ )
275 $badRows = $dbw->newSelectQueryBuilder()
276 ->select( $fieldsToTest )
278 ->from(
'block_target' )
279 ->where( [
'bt_id' => $badIds ] )
281 ->caller( __METHOD__ )
284 if ( $badRows->numRows() !== count( $badIds ) ) {
285 $this->
output(
"some IDs are not present in the primary\n" );
286 $dbw->endAtomic( __METHOD__ );
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__ );
301 $dbw->newUpdateQueryBuilder()
303 ->set( [
'bl_target' => $mainId ] )
304 ->where( [
'bl_target' => $badIds ] )
305 ->caller( __METHOD__ )
307 $blockCount = $dbw->affectedRows();
310 $dbw->newDeleteQueryBuilder()
311 ->delete(
'block_target' )
312 ->where( [
'bt_id' => $badIds ] )
313 ->caller( __METHOD__ )
317 $dbw->newUpdateQueryBuilder()
318 ->update(
'block_target' )
319 ->set(
'bt_count=bt_count + ' . $blockCount )
320 ->where( [
'bt_id' => $mainId ] )
321 ->caller( __METHOD__ )
324 $dbw->endAtomic( __METHOD__ );
325 $this->
output(
"done\n" );
331 private function fixTargetCounts() {
333 $res = $dbr->newSelectQueryBuilder()
334 ->select( [
'bt_id',
'bt_count',
'real_count' =>
'COUNT(*)' ] )
336 ->join(
'block_target',
null,
'bt_id=bl_target' )
337 ->groupBy( [
'bt_id',
'bt_count' ] )
338 ->having(
'COUNT(*) != bt_count' )
339 ->caller( __METHOD__ )
342 foreach ( $res as $row ) {
343 $this->fixTargetCount( (
int)$row->bt_id, (
int)$row->bt_count, (
int)$row->real_count );
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" );
362 $dbw->startAtomic( __METHOD__ );
363 $primaryCount = $dbw->newSelectQueryBuilder()
365 ->where( [
'bl_target' => $targetId ] )
367 ->caller( __METHOD__ )
370 if ( $primaryCount !== $replicaCount ) {
371 $dbw->endAtomic( __METHOD__ );
372 $this->
output(
"changed in primary, skipping\n" );
376 $dbw->newUpdateQueryBuilder()
377 ->update(
'block_target' )
378 ->set( [
'bt_count' => $primaryCount ] )
380 'bt_id' => $targetId,
381 'bt_count' => $badCount
383 ->caller( __METHOD__ )
385 $affected = $dbw->affectedRows();
386 $dbw->endAtomic( __METHOD__ );
387 $this->
output( $affected ?
"OK\n" :
"no rows affected\n" );