MediaWiki  master
LocalFileMoveBatch.php
Go to the documentation of this file.
1 <?php
23 use Psr\Log\LoggerInterface;
25 use Wikimedia\ScopedCallback;
26 
34  protected $file;
35 
37  protected $target;
38 
39  protected $cur;
40 
41  protected $olds;
42 
43  protected $oldCount;
44 
45  protected $archive;
46 
48  protected $db;
49 
51  protected $oldHash;
52 
54  protected $newHash;
55 
57  protected $oldName;
58 
60  protected $newName;
61 
63  protected $oldRel;
64 
66  protected $newRel;
67 
69  private $logger;
70 
72  private $haveSourceLock = false;
73 
75  private $haveTargetLock = false;
76 
78  private $targetFile;
79 
84  public function __construct( LocalFile $file, Title $target ) {
85  $this->file = $file;
86  $this->target = $target;
87  $this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
88  $this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
89  $this->oldName = $this->file->getName();
90  $this->newName = $this->file->repo->getNameFromTitle( $this->target );
91  $this->oldRel = $this->oldHash . $this->oldName;
92  $this->newRel = $this->newHash . $this->newName;
93  $this->db = $file->getRepo()->getPrimaryDB();
94 
95  $this->logger = LoggerFactory::getInstance( 'imagemove' );
96  }
97 
103  public function addCurrent() {
104  $status = $this->acquireSourceLock();
105  if ( $status->isOK() ) {
106  $this->cur = [ $this->oldRel, $this->newRel ];
107  }
108  return $status;
109  }
110 
115  public function addOlds() {
116  $archiveBase = 'archive';
117  $this->olds = [];
118  $this->oldCount = 0;
119  $archiveNames = [];
120 
121  $result = $this->db->select( 'oldimage',
122  [ 'oi_archive_name', 'oi_deleted' ],
123  [ 'oi_name' => $this->oldName ],
124  __METHOD__,
125  [ 'FOR UPDATE' ] // ignore snapshot
126  );
127 
128  foreach ( $result as $row ) {
129  $archiveNames[] = $row->oi_archive_name;
130  $oldName = $row->oi_archive_name;
131  $bits = explode( '!', $oldName, 2 );
132 
133  if ( count( $bits ) != 2 ) {
134  $this->logger->debug(
135  'Old file name missing !: {oldName}',
136  [ 'oldName' => $oldName ]
137  );
138  continue;
139  }
140 
141  list( $timestamp, $filename ) = $bits;
142 
143  if ( $this->oldName != $filename ) {
144  $this->logger->debug(
145  'Old file name does not match: {oldName}',
146  [ 'oldName' => $oldName ]
147  );
148  continue;
149  }
150 
151  $this->oldCount++;
152 
153  // Do we want to add those to oldCount?
154  if ( $row->oi_deleted & File::DELETED_FILE ) {
155  continue;
156  }
157 
158  $this->olds[] = [
159  "{$archiveBase}/{$this->oldHash}{$oldName}",
160  "{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
161  ];
162  }
163 
164  return $archiveNames;
165  }
166 
172  protected function acquireSourceLock() {
173  if ( $this->haveSourceLock ) {
174  return Status::newGood();
175  }
176  $status = $this->file->acquireFileLock();
177  if ( $status->isOK() ) {
178  $this->haveSourceLock = true;
179  }
180  return $status;
181  }
182 
188  protected function acquireTargetLock() {
189  if ( $this->haveTargetLock ) {
190  return Status::newGood();
191  }
192  $status = $this->getTargetFile()->acquireFileLock();
193  if ( $status->isOK() ) {
194  $this->haveTargetLock = true;
195  }
196  return $status;
197  }
198 
202  protected function releaseLocks() {
203  if ( $this->haveSourceLock ) {
204  $this->file->releaseFileLock();
205  $this->haveSourceLock = false;
206  }
207  if ( $this->haveTargetLock ) {
208  $this->getTargetFile()->releaseFileLock();
209  $this->haveTargetLock = false;
210  }
211  }
212 
218  protected function getTargetFile() {
219  if ( $this->targetFile === null ) {
220  $this->targetFile = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
221  ->newFile( $this->target );
222  }
223  return $this->targetFile;
224  }
225 
230  public function execute() {
231  $repo = $this->file->repo;
232  $status = $repo->newGood();
233 
234  $status->merge( $this->acquireSourceLock() );
235  if ( !$status->isOK() ) {
236  return $status;
237  }
238  $status->merge( $this->acquireTargetLock() );
239  if ( !$status->isOK() ) {
240  $this->releaseLocks();
241  return $status;
242  }
243  $unlockScope = new ScopedCallback( function () {
244  $this->releaseLocks();
245  } );
246 
247  $triplets = $this->getMoveTriplets();
248  $checkStatus = $this->removeNonexistentFiles( $triplets );
249  if ( !$checkStatus->isGood() ) {
250  $status->merge( $checkStatus ); // couldn't talk to file backend
251  return $status;
252  }
253  $triplets = $checkStatus->value;
254 
255  // Verify the file versions metadata in the DB.
256  $statusDb = $this->verifyDBUpdates();
257  if ( !$statusDb->isGood() ) {
258  $statusDb->setOK( false );
259 
260  return $statusDb;
261  }
262 
263  if ( !$repo->hasSha1Storage() ) {
264  // Copy the files into their new location.
265  // If a prior process fataled copying or cleaning up files we tolerate any
266  // of the existing files if they are identical to the ones being stored.
267  $statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
268 
269  $this->logger->debug(
270  'Moved files for {fileName}: {successCount} successes, {failCount} failures',
271  [
272  'fileName' => $this->file->getName(),
273  'successCount' => $statusMove->successCount,
274  'failCount' => $statusMove->failCount,
275  ]
276  );
277 
278  if ( !$statusMove->isGood() ) {
279  // Delete any files copied over (while the destination is still locked)
280  $this->cleanupTarget( $triplets );
281 
282  $this->logger->debug(
283  'Error in moving files: {error}',
284  [ 'error' => $statusMove->getWikiText( false, false, 'en' ) ]
285  );
286 
287  $statusMove->setOK( false );
288 
289  return $statusMove;
290  }
291  $status->merge( $statusMove );
292  }
293 
294  // Rename the file versions metadata in the DB.
295  $this->doDBUpdates();
296 
297  $this->logger->debug(
298  'Renamed {fileName} in database: {successCount} successes, {failCount} failures',
299  [
300  'fileName' => $this->file->getName(),
301  'successCount' => $statusDb->successCount,
302  'failCount' => $statusDb->failCount,
303  ]
304  );
305 
306  // Everything went ok, remove the source files
307  $this->cleanupSource( $triplets );
308 
309  // Defer lock release until the transaction is committed.
310  if ( $this->db->trxLevel() ) {
311  $unlockScope->cancel();
312  $this->db->onTransactionResolution( function () {
313  $this->releaseLocks();
314  } );
315  } else {
316  ScopedCallback::consume( $unlockScope );
317  }
318 
319  $status->merge( $statusDb );
320 
321  return $status;
322  }
323 
330  protected function verifyDBUpdates() {
331  $repo = $this->file->repo;
332  $status = $repo->newGood();
333  $dbw = $this->db;
334 
335  $hasCurrent = $dbw->lockForUpdate(
336  'image',
337  [ 'img_name' => $this->oldName ],
338  __METHOD__
339  );
340  $oldRowCount = $dbw->lockForUpdate(
341  'oldimage',
342  [ 'oi_name' => $this->oldName ],
343  __METHOD__
344  );
345 
346  if ( $hasCurrent ) {
347  $status->successCount++;
348  } else {
349  $status->failCount++;
350  }
351  $status->successCount += $oldRowCount;
352  // T36934: oldCount is based on files that actually exist.
353  // There may be more DB rows than such files, in which case $affected
354  // can be greater than $total. We use max() to avoid negatives here.
355  $status->failCount += max( 0, $this->oldCount - $oldRowCount );
356  if ( $status->failCount ) {
357  $status->error( 'imageinvalidfilename' );
358  }
359 
360  return $status;
361  }
362 
367  protected function doDBUpdates() {
368  $dbw = $this->db;
369 
370  // Update current image
371  $dbw->update(
372  'image',
373  [ 'img_name' => $this->newName ],
374  [ 'img_name' => $this->oldName ],
375  __METHOD__
376  );
377 
378  // Update old images
379  $dbw->update(
380  'oldimage',
381  [
382  'oi_name' => $this->newName,
383  'oi_archive_name = ' . $dbw->strreplace( 'oi_archive_name',
384  $dbw->addQuotes( $this->oldName ), $dbw->addQuotes( $this->newName ) ),
385  ],
386  [ 'oi_name' => $this->oldName ],
387  __METHOD__
388  );
389  }
390 
395  protected function getMoveTriplets() {
396  $moves = array_merge( [ $this->cur ], $this->olds );
397  $triplets = []; // The format is: (srcUrl, destZone, destUrl)
398 
399  foreach ( $moves as $move ) {
400  // $move: (oldRelativePath, newRelativePath)
401  $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
402  $triplets[] = [ $srcUrl, 'public', $move[1] ];
403 
404  $this->logger->debug(
405  'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
406  [
407  'fileName' => $this->file->getName(),
408  'srcUrl' => $srcUrl,
409  'move1' => $move[1],
410  ]
411  );
412  }
413 
414  return $triplets;
415  }
416 
422  protected function removeNonexistentFiles( $triplets ) {
423  $files = [];
424 
425  foreach ( $triplets as $file ) {
426  $files[$file[0]] = $file[0];
427  }
428 
429  $result = $this->file->repo->fileExistsBatch( $files );
430  if ( in_array( null, $result, true ) ) {
431  return Status::newFatal( 'backend-fail-internal',
432  $this->file->repo->getBackend()->getName() );
433  }
434 
435  $filteredTriplets = [];
436  foreach ( $triplets as $file ) {
437  if ( $result[$file[0]] ) {
438  $filteredTriplets[] = $file;
439  } else {
440  $this->logger->debug(
441  'File {file} does not exist',
442  [ 'file' => $file[0] ]
443  );
444  }
445  }
446 
447  return Status::newGood( $filteredTriplets );
448  }
449 
455  protected function cleanupTarget( $triplets ) {
456  // Create dest pairs from the triplets
457  $pairs = [];
458  foreach ( $triplets as $triplet ) {
459  // $triplet: (old source virtual URL, dst zone, dest rel)
460  $pairs[] = [ $triplet[1], $triplet[2] ];
461  }
462 
463  $this->file->repo->cleanupBatch( $pairs );
464  }
465 
471  protected function cleanupSource( $triplets ) {
472  // Create source file names from the triplets
473  $files = [];
474  foreach ( $triplets as $triplet ) {
475  $files[] = $triplet[0];
476  }
477 
478  $this->file->repo->cleanupBatch( $files );
479  }
480 }
const OVERWRITE_SAME
Definition: FileRepo.php:50
const DELETED_FILE
Definition: File.php:72
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.
LocalFile null $targetFile
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.
LoggerInterface $logger
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:60
PSR-3 logger instance factory.
Service locator for MediaWiki core services.
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:82
Represents a title within MediaWiki.
Definition: Title.php:49
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:39
lockForUpdate( $table, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Lock all rows meeting the given conditions/options FOR UPDATE.
update( $table, $set, $conds, $fname=__METHOD__, $options=[])
Update all rows in a table that match a given condition.