MediaWiki master
LocalFileMoveBatch.php
Go to the documentation of this file.
1<?php
8
15use Psr\Log\LoggerInterface;
18use Wikimedia\ScopedCallback;
19
27 protected $file;
28
30 protected $target;
31
33 protected $cur;
34
36 protected $olds;
37
39 protected $oldCount;
40
42 protected $db;
43
45 protected $oldHash;
46
48 protected $newHash;
49
51 protected $oldName;
52
54 protected $newName;
55
57 protected $oldRel;
58
60 protected $newRel;
61
63 private $logger;
64
66 private $haveSourceLock = false;
67
69 private $haveTargetLock = false;
70
72 private $targetFile;
73
74 public function __construct( LocalFile $file, Title $target ) {
75 $this->file = $file;
76 $this->target = $target;
77 $this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
78 $this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
79 $this->oldName = $this->file->getName();
80 $this->newName = $this->file->repo->getNameFromTitle( $this->target );
81 $this->oldRel = $this->oldHash . $this->oldName;
82 $this->newRel = $this->newHash . $this->newName;
83 $this->db = $file->getRepo()->getPrimaryDB();
84
85 $this->logger = LoggerFactory::getInstance( 'imagemove' );
86 }
87
93 public function addCurrent() {
94 $status = $this->acquireSourceLock();
95 if ( $status->isOK() ) {
96 $this->cur = [ $this->oldRel, $this->newRel ];
97 }
98 return $status;
99 }
100
105 public function addOlds() {
106 $archiveBase = 'archive';
107 $this->olds = [];
108 $this->oldCount = 0;
109 $archiveNames = [];
110
111 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
113 );
114 if ( $migrationStage && SCHEMA_COMPAT_WRITE_OLD ) {
115 $result = $this->db->newSelectQueryBuilder()
116 ->select( [ 'oi_archive_name', 'oi_deleted' ] )
117 ->forUpdate() // ignore snapshot
118 ->from( 'oldimage' )
119 ->where( [ 'oi_name' => $this->oldName ] )
120 ->caller( __METHOD__ )->fetchResultSet();
121 } else {
122 $result = $this->db->newSelectQueryBuilder()
123 ->select( [
124 'oi_archive_name' => 'fr_archive_name',
125 'oi_deleted' => 'fr_deleted',
126 ] )
127 ->forUpdate() // ignore snapshot
128 ->from( 'filerevision' )
129 ->join( 'file', null, 'fr_file = file_id' )
130 ->where( [ 'file_name' => $this->oldName, 'file_deleted' => 0, 'file_latest != fr_id' ] )
131 ->caller( __METHOD__ )->fetchResultSet();
132 }
133
134 foreach ( $result as $row ) {
135 $archiveNames[] = $row->oi_archive_name;
136 $oldName = $row->oi_archive_name;
137 $bits = explode( '!', $oldName, 2 );
138
139 if ( count( $bits ) != 2 ) {
140 $this->logger->debug(
141 'Old file name missing !: {oldName}',
142 [ 'oldName' => $oldName ]
143 );
144 continue;
145 }
146
147 [ $timestamp, $filename ] = $bits;
148
149 if ( $this->oldName != $filename ) {
150 $this->logger->debug(
151 'Old file name does not match: {oldName}',
152 [ 'oldName' => $oldName ]
153 );
154 continue;
155 }
156
157 $this->oldCount++;
158
159 // Do we want to add those to oldCount?
160 if ( $row->oi_deleted & File::DELETED_FILE ) {
161 continue;
162 }
163
164 $this->olds[] = [
165 "{$archiveBase}/{$this->oldHash}{$oldName}",
166 "{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
167 ];
168 }
169
170 return $archiveNames;
171 }
172
178 protected function acquireSourceLock() {
179 if ( $this->haveSourceLock ) {
180 return Status::newGood();
181 }
182 $status = $this->file->acquireFileLock();
183 if ( $status->isOK() ) {
184 $this->haveSourceLock = true;
185 }
186 return $status;
187 }
188
194 protected function acquireTargetLock() {
195 if ( $this->haveTargetLock ) {
196 return Status::newGood();
197 }
198 $status = $this->getTargetFile()->acquireFileLock();
199 if ( $status->isOK() ) {
200 $this->haveTargetLock = true;
201 }
202 return $status;
203 }
204
208 protected function releaseLocks() {
209 if ( $this->haveSourceLock ) {
210 $this->file->releaseFileLock();
211 $this->haveSourceLock = false;
212 }
213 if ( $this->haveTargetLock ) {
214 $this->getTargetFile()->releaseFileLock();
215 $this->haveTargetLock = false;
216 }
217 }
218
224 protected function getTargetFile() {
225 if ( $this->targetFile === null ) {
226 $this->targetFile = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
227 ->newFile( $this->target );
228 }
229 return $this->targetFile;
230 }
231
236 public function execute() {
237 $repo = $this->file->repo;
238 $status = $repo->newGood();
239
240 $status->merge( $this->acquireSourceLock() );
241 if ( !$status->isOK() ) {
242 return $status;
243 }
244 $status->merge( $this->acquireTargetLock() );
245 if ( !$status->isOK() ) {
246 $this->releaseLocks();
247 return $status;
248 }
249 $unlockScope = new ScopedCallback( function () {
250 $this->releaseLocks();
251 } );
252
253 $triplets = $this->getMoveTriplets();
254 $checkStatus = $this->removeNonexistentFiles( $triplets );
255 if ( !$checkStatus->isGood() ) {
256 $status->merge( $checkStatus ); // couldn't talk to file backend
257 return $status;
258 }
259 $triplets = $checkStatus->value;
260
261 // Verify the file versions metadata in the DB.
262 $statusDb = $this->verifyDBUpdates();
263 if ( !$statusDb->isGood() ) {
264 $statusDb->setOK( false );
265
266 return $statusDb;
267 }
268
269 if ( !$repo->hasSha1Storage() ) {
270 // Copy the files into their new location.
271 // If a prior process fataled copying or cleaning up files we tolerate any
272 // of the existing files if they are identical to the ones being stored.
273 $statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
274
275 $this->logger->debug(
276 'Moved files for {fileName}: {successCount} successes, {failCount} failures',
277 [
278 'fileName' => $this->file->getName(),
279 'successCount' => $statusMove->successCount,
280 'failCount' => $statusMove->failCount,
281 ]
282 );
283
284 if ( !$statusMove->isGood() ) {
285 // Delete any files copied over (while the destination is still locked)
286 $this->cleanupTarget( $triplets );
287
288 $this->logger->debug(
289 'Error in moving files: {error}',
290 [ 'error' => $statusMove->getWikiText( false, false, 'en' ) ]
291 );
292
293 $statusMove->setOK( false );
294
295 return $statusMove;
296 }
297 $status->merge( $statusMove );
298 }
299
300 // Rename the file versions metadata in the DB.
301 $this->doDBUpdates();
302
303 $this->logger->debug(
304 'Renamed {fileName} in database: {successCount} successes, {failCount} failures',
305 [
306 'fileName' => $this->file->getName(),
307 'successCount' => $statusDb->successCount,
308 'failCount' => $statusDb->failCount,
309 ]
310 );
311
312 // Everything went ok, remove the source files
313 $this->cleanupSource( $triplets );
314
315 // Defer lock release until the transaction is committed.
316 if ( $this->db->trxLevel() ) {
317 ScopedCallback::cancel( $unlockScope );
318 $this->db->onTransactionResolution( function () {
319 $this->releaseLocks();
320 }, __METHOD__ );
321 } else {
322 ScopedCallback::consume( $unlockScope );
323 }
324
325 $status->merge( $statusDb );
326
327 return $status;
328 }
329
336 protected function verifyDBUpdates() {
337 $repo = $this->file->repo;
338 $status = $repo->newGood();
339 $dbw = $this->db;
340
341 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
343 );
344 if ( $migrationStage && SCHEMA_COMPAT_WRITE_OLD ) {
345 // Lock the image row
346 $hasCurrent = $dbw->newSelectQueryBuilder()
347 ->from( 'image' )
348 ->where( [ 'img_name' => $this->oldName ] )
349 ->forUpdate()
350 ->caller( __METHOD__ )
351 ->fetchRowCount();
352
353 // Lock the oldimage rows
354 $oldRowCount = $dbw->newSelectQueryBuilder()
355 ->from( 'oldimage' )
356 ->where( [ 'oi_name' => $this->oldName ] )
357 ->forUpdate()
358 ->caller( __METHOD__ )
359 ->fetchRowCount();
360 if ( $hasCurrent ) {
361 $status->successCount++;
362 } else {
363 $status->failCount++;
364 }
365 $status->successCount += $oldRowCount;
366 // T36934: oldCount is based on files that actually exist.
367 // There may be more DB rows than such files, in which case $affected
368 // can be greater than $total. We use max() to avoid negatives here.
369 $status->failCount += max( 0, $this->oldCount - $oldRowCount );
370 if ( $status->failCount ) {
371 $status->error( 'imageinvalidfilename' );
372 }
373 } else {
374 $status->successCount += $this->db->newSelectQueryBuilder()
375 ->forUpdate() // ignore snapshot
376 ->from( 'filerevision' )
377 ->join( 'file', null, 'fr_file = file_id' )
378 ->where( [ 'file_name' => $this->oldName, 'file_deleted' => 0 ] )
379 ->caller( __METHOD__ )->fetchRowCount();
380 }
381
382 return $status;
383 }
384
389 protected function doDBUpdates() {
390 $dbw = $this->db;
391
392 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
394 );
395 if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
396 $deleted = $dbw->newSelectQueryBuilder()
397 ->select( 'file_id' )
398 ->from( 'file' )
399 ->where( [ 'file_name' => $this->newName ] )
400 ->andWhere( [ 'file_deleted' => 1 ] )
401 ->caller( __METHOD__ )->fetchField();
402 if ( $deleted ) {
403 // Overwriting an existing file that was deleted.
404 // Once the file deletion storage refactor starts,
405 // this should change to update deleted revisions too.
406 $dbw->newDeleteQueryBuilder()
407 ->deleteFrom( 'file' )
408 ->where( [ 'file_name' => $this->newName ] )
409 ->andWhere( [ 'file_deleted' => 1 ] )
410 ->caller( __METHOD__ )->execute();
411 // Paranoia
412 $dbw->newUpdateQueryBuilder()
413 ->update( 'filerevision' )
414 ->set( [ 'fr_file' => $this->file->getFileIdFromName() ] )
415 ->where( [ 'fr_file' => $deleted ] )
416 ->caller( __METHOD__ )->execute();
417 }
418 $dbw->newUpdateQueryBuilder()
419 ->update( 'file' )
420 ->set( [ 'file_name' => $this->newName ] )
421 ->where( [ 'file_id' => $this->file->getFileIdFromName() ] )
422 ->caller( __METHOD__ )->execute();
423 }
424 if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
425 // Update current image
426 $dbw->newUpdateQueryBuilder()
427 ->update( 'image' )
428 ->set( [ 'img_name' => $this->newName ] )
429 ->where( [ 'img_name' => $this->oldName ] )
430 ->caller( __METHOD__ )->execute();
431
432 // Update old images
433 $dbw->newUpdateQueryBuilder()
434 ->update( 'oldimage' )
435 ->set( [
436 'oi_name' => $this->newName,
437 'oi_archive_name' => new RawSQLValue( $dbw->strreplace(
438 'oi_archive_name',
439 $dbw->addQuotes( $this->oldName ),
440 $dbw->addQuotes( $this->newName )
441 ) ),
442 ] )
443 ->where( [ 'oi_name' => $this->oldName ] )
444 ->caller( __METHOD__ )->execute();
445 }
446 }
447
452 protected function getMoveTriplets() {
453 $triplets = []; // The format is: (srcUrl, destZone, destUrl)
454
455 foreach ( [ $this->cur, ...$this->olds ] as $move ) {
456 // $move: (oldRelativePath, newRelativePath)
457 $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
458 $triplets[] = [ $srcUrl, 'public', $move[1] ];
459
460 $this->logger->debug(
461 'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
462 [
463 'fileName' => $this->file->getName(),
464 'srcUrl' => $srcUrl,
465 'move1' => $move[1],
466 ]
467 );
468 }
469
470 return $triplets;
471 }
472
478 protected function removeNonexistentFiles( $triplets ) {
479 $files = [];
480
481 foreach ( $triplets as $file ) {
482 $files[$file[0]] = $file[0];
483 }
484
485 $result = $this->file->repo->fileExistsBatch( $files );
486 if ( in_array( null, $result, true ) ) {
487 return Status::newFatal( 'backend-fail-internal',
488 $this->file->repo->getBackend()->getName() );
489 }
490
491 $filteredTriplets = [];
492 foreach ( $triplets as $file ) {
493 if ( $result[$file[0]] ) {
494 $filteredTriplets[] = $file;
495 } else {
496 $this->logger->debug(
497 'File {file} does not exist',
498 [ 'file' => $file[0] ]
499 );
500 }
501 }
502
503 return Status::newGood( $filteredTriplets );
504 }
505
511 protected function cleanupTarget( $triplets ) {
512 // Create dest pairs from the triplets
513 $pairs = [];
514 foreach ( $triplets as $triplet ) {
515 // $triplet: (old source virtual URL, dst zone, dest rel)
516 $pairs[] = [ $triplet[1], $triplet[2] ];
517 }
518
519 $this->file->repo->cleanupBatch( $pairs );
520 }
521
527 protected function cleanupSource( $triplets ) {
528 // Create source file names from the triplets
529 $files = [];
530 foreach ( $triplets as $triplet ) {
531 $files[] = $triplet[0];
532 }
533
534 $this->file->repo->cleanupBatch( $files );
535 }
536}
537
539class_alias( LocalFileMoveBatch::class, 'LocalFileMoveBatch' );
const SCHEMA_COMPAT_WRITE_OLD
Definition Defines.php:293
const SCHEMA_COMPAT_WRITE_NEW
Definition Defines.php:297
Base class for file repositories.
Definition FileRepo.php:51
removeNonexistentFiles( $triplets)
Removes non-existent files from move batch.
addOlds()
Add the old versions of the image to the batch.
addCurrent()
Add the current image to the batch.
verifyDBUpdates()
Verify the database updates and return a new Status indicating how many rows would be updated.
doDBUpdates()
Do the database updates and return a new Status indicating how many rows where updated.
acquireTargetLock()
Acquire the target file lock, if it has not been acquired already.
acquireSourceLock()
Acquire the source file lock, if it has not been acquired already.
cleanupSource( $triplets)
Cleanup a fully moved array of triplets by deleting the source files.
getMoveTriplets()
Generate triplets for FileRepo::storeBatch().
cleanupTarget( $triplets)
Cleanup a partially moved array of triplets by deleting the target files.
__construct(LocalFile $file, Title $target)
Local file in the wiki's own database.
Definition LocalFile.php:81
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const FileSchemaMigrationStage
Name constant for the FileSchemaMigrationStage setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Represents a title within MediaWiki.
Definition Title.php:69
Raw SQL value to be used in query builders.
Interface to a relational database.
Definition IDatabase.php:31
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.