MediaWiki  master
LocalFileMoveBatch.php
Go to the documentation of this file.
1 <?php
25 use Psr\Log\LoggerInterface;
27 use Wikimedia\ScopedCallback;
28 
36  protected $file;
37 
39  protected $target;
40 
41  protected $cur;
42 
43  protected $olds;
44 
45  protected $oldCount;
46 
47  protected $archive;
48 
50  protected $db;
51 
53  protected $oldHash;
54 
56  protected $newHash;
57 
59  protected $oldName;
60 
62  protected $newName;
63 
65  protected $oldRel;
66 
68  protected $newRel;
69 
71  private $logger;
72 
74  private $haveSourceLock = false;
75 
77  private $haveTargetLock = false;
78 
80  private $targetFile;
81 
86  public function __construct( LocalFile $file, Title $target ) {
87  $this->file = $file;
88  $this->target = $target;
89  $this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
90  $this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
91  $this->oldName = $this->file->getName();
92  $this->newName = $this->file->repo->getNameFromTitle( $this->target );
93  $this->oldRel = $this->oldHash . $this->oldName;
94  $this->newRel = $this->newHash . $this->newName;
95  $this->db = $file->getRepo()->getPrimaryDB();
96 
97  $this->logger = LoggerFactory::getInstance( 'imagemove' );
98  }
99 
105  public function addCurrent() {
106  $status = $this->acquireSourceLock();
107  if ( $status->isOK() ) {
108  $this->cur = [ $this->oldRel, $this->newRel ];
109  }
110  return $status;
111  }
112 
117  public function addOlds() {
118  $archiveBase = 'archive';
119  $this->olds = [];
120  $this->oldCount = 0;
121  $archiveNames = [];
122 
123  $result = $this->db->newSelectQueryBuilder()
124  ->select( [ 'oi_archive_name', 'oi_deleted' ] )
125  ->forUpdate() // ignore snapshot
126  ->from( 'oldimage' )
127  ->where( [ 'oi_name' => $this->oldName ] )
128  ->caller( __METHOD__ )->fetchResultSet();
129 
130  foreach ( $result as $row ) {
131  $archiveNames[] = $row->oi_archive_name;
132  $oldName = $row->oi_archive_name;
133  $bits = explode( '!', $oldName, 2 );
134 
135  if ( count( $bits ) != 2 ) {
136  $this->logger->debug(
137  'Old file name missing !: {oldName}',
138  [ 'oldName' => $oldName ]
139  );
140  continue;
141  }
142 
143  [ $timestamp, $filename ] = $bits;
144 
145  if ( $this->oldName != $filename ) {
146  $this->logger->debug(
147  'Old file name does not match: {oldName}',
148  [ 'oldName' => $oldName ]
149  );
150  continue;
151  }
152 
153  $this->oldCount++;
154 
155  // Do we want to add those to oldCount?
156  if ( $row->oi_deleted & File::DELETED_FILE ) {
157  continue;
158  }
159 
160  $this->olds[] = [
161  "{$archiveBase}/{$this->oldHash}{$oldName}",
162  "{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
163  ];
164  }
165 
166  return $archiveNames;
167  }
168 
174  protected function acquireSourceLock() {
175  if ( $this->haveSourceLock ) {
176  return Status::newGood();
177  }
178  $status = $this->file->acquireFileLock();
179  if ( $status->isOK() ) {
180  $this->haveSourceLock = true;
181  }
182  return $status;
183  }
184 
190  protected function acquireTargetLock() {
191  if ( $this->haveTargetLock ) {
192  return Status::newGood();
193  }
194  $status = $this->getTargetFile()->acquireFileLock();
195  if ( $status->isOK() ) {
196  $this->haveTargetLock = true;
197  }
198  return $status;
199  }
200 
204  protected function releaseLocks() {
205  if ( $this->haveSourceLock ) {
206  $this->file->releaseFileLock();
207  $this->haveSourceLock = false;
208  }
209  if ( $this->haveTargetLock ) {
210  $this->getTargetFile()->releaseFileLock();
211  $this->haveTargetLock = false;
212  }
213  }
214 
220  protected function getTargetFile() {
221  if ( $this->targetFile === null ) {
222  $this->targetFile = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
223  ->newFile( $this->target );
224  }
225  return $this->targetFile;
226  }
227 
232  public function execute() {
233  $repo = $this->file->repo;
234  $status = $repo->newGood();
235 
236  $status->merge( $this->acquireSourceLock() );
237  if ( !$status->isOK() ) {
238  return $status;
239  }
240  $status->merge( $this->acquireTargetLock() );
241  if ( !$status->isOK() ) {
242  $this->releaseLocks();
243  return $status;
244  }
245  $unlockScope = new ScopedCallback( function () {
246  $this->releaseLocks();
247  } );
248 
249  $triplets = $this->getMoveTriplets();
250  $checkStatus = $this->removeNonexistentFiles( $triplets );
251  if ( !$checkStatus->isGood() ) {
252  $status->merge( $checkStatus ); // couldn't talk to file backend
253  return $status;
254  }
255  $triplets = $checkStatus->value;
256 
257  // Verify the file versions metadata in the DB.
258  $statusDb = $this->verifyDBUpdates();
259  if ( !$statusDb->isGood() ) {
260  $statusDb->setOK( false );
261 
262  return $statusDb;
263  }
264 
265  if ( !$repo->hasSha1Storage() ) {
266  // Copy the files into their new location.
267  // If a prior process fataled copying or cleaning up files we tolerate any
268  // of the existing files if they are identical to the ones being stored.
269  $statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
270 
271  $this->logger->debug(
272  'Moved files for {fileName}: {successCount} successes, {failCount} failures',
273  [
274  'fileName' => $this->file->getName(),
275  'successCount' => $statusMove->successCount,
276  'failCount' => $statusMove->failCount,
277  ]
278  );
279 
280  if ( !$statusMove->isGood() ) {
281  // Delete any files copied over (while the destination is still locked)
282  $this->cleanupTarget( $triplets );
283 
284  $this->logger->debug(
285  'Error in moving files: {error}',
286  [ 'error' => $statusMove->getWikiText( false, false, 'en' ) ]
287  );
288 
289  $statusMove->setOK( false );
290 
291  return $statusMove;
292  }
293  $status->merge( $statusMove );
294  }
295 
296  // Rename the file versions metadata in the DB.
297  $this->doDBUpdates();
298 
299  $this->logger->debug(
300  'Renamed {fileName} in database: {successCount} successes, {failCount} failures',
301  [
302  'fileName' => $this->file->getName(),
303  'successCount' => $statusDb->successCount,
304  'failCount' => $statusDb->failCount,
305  ]
306  );
307 
308  // Everything went ok, remove the source files
309  $this->cleanupSource( $triplets );
310 
311  // Defer lock release until the transaction is committed.
312  if ( $this->db->trxLevel() ) {
313  $unlockScope->cancel();
314  $this->db->onTransactionResolution( function () {
315  $this->releaseLocks();
316  } );
317  } else {
318  ScopedCallback::consume( $unlockScope );
319  }
320 
321  $status->merge( $statusDb );
322 
323  return $status;
324  }
325 
332  protected function verifyDBUpdates() {
333  $repo = $this->file->repo;
334  $status = $repo->newGood();
335  $dbw = $this->db;
336 
337  // Lock the image row
338  $hasCurrent = $dbw->newSelectQueryBuilder()
339  ->from( 'image' )
340  ->where( [ 'img_name' => $this->oldName ] )
341  ->forUpdate()
342  ->caller( __METHOD__ )
343  ->fetchRowCount();
344 
345  // Lock the oldimage rows
346  $oldRowCount = $dbw->newSelectQueryBuilder()
347  ->from( 'oldimage' )
348  ->where( [ 'oi_name' => $this->oldName ] )
349  ->forUpdate()
350  ->caller( __METHOD__ )
351  ->fetchRowCount();
352 
353  if ( $hasCurrent ) {
354  $status->successCount++;
355  } else {
356  $status->failCount++;
357  }
358  $status->successCount += $oldRowCount;
359  // T36934: oldCount is based on files that actually exist.
360  // There may be more DB rows than such files, in which case $affected
361  // can be greater than $total. We use max() to avoid negatives here.
362  $status->failCount += max( 0, $this->oldCount - $oldRowCount );
363  if ( $status->failCount ) {
364  $status->error( 'imageinvalidfilename' );
365  }
366 
367  return $status;
368  }
369 
374  protected function doDBUpdates() {
375  $dbw = $this->db;
376 
377  // Update current image
378  $dbw->newUpdateQueryBuilder()
379  ->update( 'image' )
380  ->set( [ 'img_name' => $this->newName ] )
381  ->where( [ 'img_name' => $this->oldName ] )
382  ->caller( __METHOD__ )->execute();
383 
384  // Update old images
385  $dbw->newUpdateQueryBuilder()
386  ->update( 'oldimage' )
387  ->set( [
388  'oi_name' => $this->newName,
389  'oi_archive_name = ' . $dbw->strreplace(
390  'oi_archive_name',
391  $dbw->addQuotes( $this->oldName ),
392  $dbw->addQuotes( $this->newName )
393  ),
394  ] )
395  ->where( [ 'oi_name' => $this->oldName ] )
396  ->caller( __METHOD__ )->execute();
397  }
398 
403  protected function getMoveTriplets() {
404  $moves = array_merge( [ $this->cur ], $this->olds );
405  $triplets = []; // The format is: (srcUrl, destZone, destUrl)
406 
407  foreach ( $moves as $move ) {
408  // $move: (oldRelativePath, newRelativePath)
409  $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
410  $triplets[] = [ $srcUrl, 'public', $move[1] ];
411 
412  $this->logger->debug(
413  'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
414  [
415  'fileName' => $this->file->getName(),
416  'srcUrl' => $srcUrl,
417  'move1' => $move[1],
418  ]
419  );
420  }
421 
422  return $triplets;
423  }
424 
430  protected function removeNonexistentFiles( $triplets ) {
431  $files = [];
432 
433  foreach ( $triplets as $file ) {
434  $files[$file[0]] = $file[0];
435  }
436 
437  $result = $this->file->repo->fileExistsBatch( $files );
438  if ( in_array( null, $result, true ) ) {
439  return Status::newFatal( 'backend-fail-internal',
440  $this->file->repo->getBackend()->getName() );
441  }
442 
443  $filteredTriplets = [];
444  foreach ( $triplets as $file ) {
445  if ( $result[$file[0]] ) {
446  $filteredTriplets[] = $file;
447  } else {
448  $this->logger->debug(
449  'File {file} does not exist',
450  [ 'file' => $file[0] ]
451  );
452  }
453  }
454 
455  return Status::newGood( $filteredTriplets );
456  }
457 
463  protected function cleanupTarget( $triplets ) {
464  // Create dest pairs from the triplets
465  $pairs = [];
466  foreach ( $triplets as $triplet ) {
467  // $triplet: (old source virtual URL, dst zone, dest rel)
468  $pairs[] = [ $triplet[1], $triplet[2] ];
469  }
470 
471  $this->file->repo->cleanupBatch( $pairs );
472  }
473 
479  protected function cleanupSource( $triplets ) {
480  // Create source file names from the triplets
481  $files = [];
482  foreach ( $triplets as $triplet ) {
483  $files[] = $triplet[0];
484  }
485 
486  $this->file->repo->cleanupBatch( $files );
487  }
488 }
const OVERWRITE_SAME
Definition: FileRepo.php:53
const DELETED_FILE
Definition: File.php:74
Helper class for file movement.
getTargetFile()
Get the target file.
releaseLocks()
Release both file locks.
cleanupTarget( $triplets)
Cleanup a partially moved array of triplets by deleting the target files.
addOlds()
Add the old versions of the image to the batch.
doDBUpdates()
Do the database updates and return a new Status indicating how many rows where updated.
acquireSourceLock()
Acquire the source file lock, if it has not been acquired already.
getMoveTriplets()
Generate triplets for FileRepo::storeBatch().
execute()
Perform the move.
verifyDBUpdates()
Verify the database updates and return a new Status indicating how many rows would be updated.
removeNonexistentFiles( $triplets)
Removes non-existent files from move batch.
__construct(LocalFile $file, Title $target)
acquireTargetLock()
Acquire the target file lock, if it has not been acquired already.
addCurrent()
Add the current image to the batch.
cleanupSource( $triplets)
Cleanup a fully moved array of triplets by deleting the source files.
Local file in the wiki's own database.
Definition: LocalFile.php:64
PSR-3 logger instance factory.
Service locator for MediaWiki core services.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:58
Represents a title within MediaWiki.
Definition: Title.php:76
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:36
newUpdateQueryBuilder()
Get an UpdateQueryBuilder bound to this connection.
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.