MediaWiki master
LocalFileMoveBatch.php
Go to the documentation of this file.
1<?php
22
29use Psr\Log\LoggerInterface;
32use Wikimedia\ScopedCallback;
33
41 protected $file;
42
44 protected $target;
45
47 protected $cur;
48
50 protected $olds;
51
53 protected $oldCount;
54
56 protected $db;
57
59 protected $oldHash;
60
62 protected $newHash;
63
65 protected $oldName;
66
68 protected $newName;
69
71 protected $oldRel;
72
74 protected $newRel;
75
77 private $logger;
78
80 private $haveSourceLock = false;
81
83 private $haveTargetLock = false;
84
86 private $targetFile;
87
88 public function __construct( LocalFile $file, Title $target ) {
89 $this->file = $file;
90 $this->target = $target;
91 $this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
92 $this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
93 $this->oldName = $this->file->getName();
94 $this->newName = $this->file->repo->getNameFromTitle( $this->target );
95 $this->oldRel = $this->oldHash . $this->oldName;
96 $this->newRel = $this->newHash . $this->newName;
97 $this->db = $file->getRepo()->getPrimaryDB();
98
99 $this->logger = LoggerFactory::getInstance( 'imagemove' );
100 }
101
107 public function addCurrent() {
108 $status = $this->acquireSourceLock();
109 if ( $status->isOK() ) {
110 $this->cur = [ $this->oldRel, $this->newRel ];
111 }
112 return $status;
113 }
114
119 public function addOlds() {
120 $archiveBase = 'archive';
121 $this->olds = [];
122 $this->oldCount = 0;
123 $archiveNames = [];
124
125 $result = $this->db->newSelectQueryBuilder()
126 ->select( [ 'oi_archive_name', 'oi_deleted' ] )
127 ->forUpdate() // ignore snapshot
128 ->from( 'oldimage' )
129 ->where( [ 'oi_name' => $this->oldName ] )
130 ->caller( __METHOD__ )->fetchResultSet();
131
132 foreach ( $result as $row ) {
133 $archiveNames[] = $row->oi_archive_name;
134 $oldName = $row->oi_archive_name;
135 $bits = explode( '!', $oldName, 2 );
136
137 if ( count( $bits ) != 2 ) {
138 $this->logger->debug(
139 'Old file name missing !: {oldName}',
140 [ 'oldName' => $oldName ]
141 );
142 continue;
143 }
144
145 [ $timestamp, $filename ] = $bits;
146
147 if ( $this->oldName != $filename ) {
148 $this->logger->debug(
149 'Old file name does not match: {oldName}',
150 [ 'oldName' => $oldName ]
151 );
152 continue;
153 }
154
155 $this->oldCount++;
156
157 // Do we want to add those to oldCount?
158 if ( $row->oi_deleted & File::DELETED_FILE ) {
159 continue;
160 }
161
162 $this->olds[] = [
163 "{$archiveBase}/{$this->oldHash}{$oldName}",
164 "{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
165 ];
166 }
167
168 return $archiveNames;
169 }
170
176 protected function acquireSourceLock() {
177 if ( $this->haveSourceLock ) {
178 return Status::newGood();
179 }
180 $status = $this->file->acquireFileLock();
181 if ( $status->isOK() ) {
182 $this->haveSourceLock = true;
183 }
184 return $status;
185 }
186
192 protected function acquireTargetLock() {
193 if ( $this->haveTargetLock ) {
194 return Status::newGood();
195 }
196 $status = $this->getTargetFile()->acquireFileLock();
197 if ( $status->isOK() ) {
198 $this->haveTargetLock = true;
199 }
200 return $status;
201 }
202
206 protected function releaseLocks() {
207 if ( $this->haveSourceLock ) {
208 $this->file->releaseFileLock();
209 $this->haveSourceLock = false;
210 }
211 if ( $this->haveTargetLock ) {
212 $this->getTargetFile()->releaseFileLock();
213 $this->haveTargetLock = false;
214 }
215 }
216
222 protected function getTargetFile() {
223 if ( $this->targetFile === null ) {
224 $this->targetFile = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
225 ->newFile( $this->target );
226 }
227 return $this->targetFile;
228 }
229
234 public function execute() {
235 $repo = $this->file->repo;
236 $status = $repo->newGood();
237
238 $status->merge( $this->acquireSourceLock() );
239 if ( !$status->isOK() ) {
240 return $status;
241 }
242 $status->merge( $this->acquireTargetLock() );
243 if ( !$status->isOK() ) {
244 $this->releaseLocks();
245 return $status;
246 }
247 $unlockScope = new ScopedCallback( function () {
248 $this->releaseLocks();
249 } );
250
251 $triplets = $this->getMoveTriplets();
252 $checkStatus = $this->removeNonexistentFiles( $triplets );
253 if ( !$checkStatus->isGood() ) {
254 $status->merge( $checkStatus ); // couldn't talk to file backend
255 return $status;
256 }
257 $triplets = $checkStatus->value;
258
259 // Verify the file versions metadata in the DB.
260 $statusDb = $this->verifyDBUpdates();
261 if ( !$statusDb->isGood() ) {
262 $statusDb->setOK( false );
263
264 return $statusDb;
265 }
266
267 if ( !$repo->hasSha1Storage() ) {
268 // Copy the files into their new location.
269 // If a prior process fataled copying or cleaning up files we tolerate any
270 // of the existing files if they are identical to the ones being stored.
271 $statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
272
273 $this->logger->debug(
274 'Moved files for {fileName}: {successCount} successes, {failCount} failures',
275 [
276 'fileName' => $this->file->getName(),
277 'successCount' => $statusMove->successCount,
278 'failCount' => $statusMove->failCount,
279 ]
280 );
281
282 if ( !$statusMove->isGood() ) {
283 // Delete any files copied over (while the destination is still locked)
284 $this->cleanupTarget( $triplets );
285
286 $this->logger->debug(
287 'Error in moving files: {error}',
288 [ 'error' => $statusMove->getWikiText( false, false, 'en' ) ]
289 );
290
291 $statusMove->setOK( false );
292
293 return $statusMove;
294 }
295 $status->merge( $statusMove );
296 }
297
298 // Rename the file versions metadata in the DB.
299 $this->doDBUpdates();
300
301 $this->logger->debug(
302 'Renamed {fileName} in database: {successCount} successes, {failCount} failures',
303 [
304 'fileName' => $this->file->getName(),
305 'successCount' => $statusDb->successCount,
306 'failCount' => $statusDb->failCount,
307 ]
308 );
309
310 // Everything went ok, remove the source files
311 $this->cleanupSource( $triplets );
312
313 // Defer lock release until the transaction is committed.
314 if ( $this->db->trxLevel() ) {
315 ScopedCallback::cancel( $unlockScope );
316 $this->db->onTransactionResolution( function () {
317 $this->releaseLocks();
318 }, __METHOD__ );
319 } else {
320 ScopedCallback::consume( $unlockScope );
321 }
322
323 $status->merge( $statusDb );
324
325 return $status;
326 }
327
334 protected function verifyDBUpdates() {
335 $repo = $this->file->repo;
336 $status = $repo->newGood();
337 $dbw = $this->db;
338
339 // Lock the image row
340 $hasCurrent = $dbw->newSelectQueryBuilder()
341 ->from( 'image' )
342 ->where( [ 'img_name' => $this->oldName ] )
343 ->forUpdate()
344 ->caller( __METHOD__ )
345 ->fetchRowCount();
346
347 // Lock the oldimage rows
348 $oldRowCount = $dbw->newSelectQueryBuilder()
349 ->from( 'oldimage' )
350 ->where( [ 'oi_name' => $this->oldName ] )
351 ->forUpdate()
352 ->caller( __METHOD__ )
353 ->fetchRowCount();
354
355 if ( $hasCurrent ) {
356 $status->successCount++;
357 } else {
358 $status->failCount++;
359 }
360 $status->successCount += $oldRowCount;
361 // T36934: oldCount is based on files that actually exist.
362 // There may be more DB rows than such files, in which case $affected
363 // can be greater than $total. We use max() to avoid negatives here.
364 $status->failCount += max( 0, $this->oldCount - $oldRowCount );
365 if ( $status->failCount ) {
366 $status->error( 'imageinvalidfilename' );
367 }
368
369 return $status;
370 }
371
376 protected function doDBUpdates() {
377 $dbw = $this->db;
378
379 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
381 );
382 if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
383 $deleted = $dbw->newSelectQueryBuilder()
384 ->select( 'file_id' )
385 ->from( 'file' )
386 ->where( [ 'file_name' => $this->newName ] )
387 ->andWhere( [ 'file_deleted' => 1 ] )
388 ->caller( __METHOD__ )->fetchField();
389 if ( $deleted ) {
390 // Overwriting an existing file that was deleted.
391 // Once the file deletion storage refactor starts,
392 // this should change to update deleted revisions too.
393 $dbw->newDeleteQueryBuilder()
394 ->deleteFrom( 'file' )
395 ->where( [ 'file_name' => $this->newName ] )
396 ->andWhere( [ 'file_deleted' => 1 ] )
397 ->caller( __METHOD__ )->execute();
398 // Paranoia
399 $dbw->newUpdateQueryBuilder()
400 ->update( 'filerevision' )
401 ->set( [ 'fr_file' => $this->file->getFileIdFromName() ] )
402 ->where( [ 'fr_file' => $deleted ] )
403 ->caller( __METHOD__ )->execute();
404 }
405 $dbw->newUpdateQueryBuilder()
406 ->update( 'file' )
407 ->set( [ 'file_name' => $this->newName ] )
408 ->where( [ 'file_id' => $this->file->getFileIdFromName() ] )
409 ->caller( __METHOD__ )->execute();
410 }
411 // Update current image
412 $dbw->newUpdateQueryBuilder()
413 ->update( 'image' )
414 ->set( [ 'img_name' => $this->newName ] )
415 ->where( [ 'img_name' => $this->oldName ] )
416 ->caller( __METHOD__ )->execute();
417
418 // Update old images
419 $dbw->newUpdateQueryBuilder()
420 ->update( 'oldimage' )
421 ->set( [
422 'oi_name' => $this->newName,
423 'oi_archive_name' => new RawSQLValue( $dbw->strreplace(
424 'oi_archive_name',
425 $dbw->addQuotes( $this->oldName ),
426 $dbw->addQuotes( $this->newName )
427 ) ),
428 ] )
429 ->where( [ 'oi_name' => $this->oldName ] )
430 ->caller( __METHOD__ )->execute();
431 }
432
437 protected function getMoveTriplets() {
438 $moves = array_merge( [ $this->cur ], $this->olds );
439 $triplets = []; // The format is: (srcUrl, destZone, destUrl)
440
441 foreach ( $moves as $move ) {
442 // $move: (oldRelativePath, newRelativePath)
443 $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
444 $triplets[] = [ $srcUrl, 'public', $move[1] ];
445
446 $this->logger->debug(
447 'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
448 [
449 'fileName' => $this->file->getName(),
450 'srcUrl' => $srcUrl,
451 'move1' => $move[1],
452 ]
453 );
454 }
455
456 return $triplets;
457 }
458
464 protected function removeNonexistentFiles( $triplets ) {
465 $files = [];
466
467 foreach ( $triplets as $file ) {
468 $files[$file[0]] = $file[0];
469 }
470
471 $result = $this->file->repo->fileExistsBatch( $files );
472 if ( in_array( null, $result, true ) ) {
473 return Status::newFatal( 'backend-fail-internal',
474 $this->file->repo->getBackend()->getName() );
475 }
476
477 $filteredTriplets = [];
478 foreach ( $triplets as $file ) {
479 if ( $result[$file[0]] ) {
480 $filteredTriplets[] = $file;
481 } else {
482 $this->logger->debug(
483 'File {file} does not exist',
484 [ 'file' => $file[0] ]
485 );
486 }
487 }
488
489 return Status::newGood( $filteredTriplets );
490 }
491
497 protected function cleanupTarget( $triplets ) {
498 // Create dest pairs from the triplets
499 $pairs = [];
500 foreach ( $triplets as $triplet ) {
501 // $triplet: (old source virtual URL, dst zone, dest rel)
502 $pairs[] = [ $triplet[1], $triplet[2] ];
503 }
504
505 $this->file->repo->cleanupBatch( $pairs );
506 }
507
513 protected function cleanupSource( $triplets ) {
514 // Create source file names from the triplets
515 $files = [];
516 foreach ( $triplets as $triplet ) {
517 $files[] = $triplet[0];
518 }
519
520 $this->file->repo->cleanupBatch( $files );
521 }
522}
523
525class_alias( LocalFileMoveBatch::class, 'LocalFileMoveBatch' );
const SCHEMA_COMPAT_WRITE_NEW
Definition Defines.php:307
Base class for file repositories.
Definition FileRepo.php:68
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:93
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:54
Represents a title within MediaWiki.
Definition Title.php:78
Raw SQL value to be used in query builders.
Interface to a relational database.
Definition IDatabase.php:45
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.