MediaWiki master
LocalFileMoveBatch.php
Go to the documentation of this file.
1<?php
25use Psr\Log\LoggerInterface;
28use Wikimedia\ScopedCallback;
29
37 protected $file;
38
40 protected $target;
41
43 protected $cur;
44
46 protected $olds;
47
49 protected $oldCount;
50
52 protected $db;
53
55 protected $oldHash;
56
58 protected $newHash;
59
61 protected $oldName;
62
64 protected $newName;
65
67 protected $oldRel;
68
70 protected $newRel;
71
73 private $logger;
74
76 private $haveSourceLock = false;
77
79 private $haveTargetLock = false;
80
82 private $targetFile;
83
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 $unlockScope->cancel();
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 // Update current image
381 ->update( 'image' )
382 ->set( [ 'img_name' => $this->newName ] )
383 ->where( [ 'img_name' => $this->oldName ] )
384 ->caller( __METHOD__ )->execute();
385
386 // Update old images
387 $dbw->newUpdateQueryBuilder()
388 ->update( 'oldimage' )
389 ->set( [
390 'oi_name' => $this->newName,
391 'oi_archive_name' => new RawSQLValue( $dbw->strreplace(
392 'oi_archive_name',
393 $dbw->addQuotes( $this->oldName ),
394 $dbw->addQuotes( $this->newName )
395 ) ),
396 ] )
397 ->where( [ 'oi_name' => $this->oldName ] )
398 ->caller( __METHOD__ )->execute();
399 }
400
405 protected function getMoveTriplets() {
406 $moves = array_merge( [ $this->cur ], $this->olds );
407 $triplets = []; // The format is: (srcUrl, destZone, destUrl)
408
409 foreach ( $moves as $move ) {
410 // $move: (oldRelativePath, newRelativePath)
411 $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
412 $triplets[] = [ $srcUrl, 'public', $move[1] ];
413
414 $this->logger->debug(
415 'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
416 [
417 'fileName' => $this->file->getName(),
418 'srcUrl' => $srcUrl,
419 'move1' => $move[1],
420 ]
421 );
422 }
423
424 return $triplets;
425 }
426
432 protected function removeNonexistentFiles( $triplets ) {
433 $files = [];
434
435 foreach ( $triplets as $file ) {
436 $files[$file[0]] = $file[0];
437 }
438
439 $result = $this->file->repo->fileExistsBatch( $files );
440 if ( in_array( null, $result, true ) ) {
441 return Status::newFatal( 'backend-fail-internal',
442 $this->file->repo->getBackend()->getName() );
443 }
444
445 $filteredTriplets = [];
446 foreach ( $triplets as $file ) {
447 if ( $result[$file[0]] ) {
448 $filteredTriplets[] = $file;
449 } else {
450 $this->logger->debug(
451 'File {file} does not exist',
452 [ 'file' => $file[0] ]
453 );
454 }
455 }
456
457 return Status::newGood( $filteredTriplets );
458 }
459
465 protected function cleanupTarget( $triplets ) {
466 // Create dest pairs from the triplets
467 $pairs = [];
468 foreach ( $triplets as $triplet ) {
469 // $triplet: (old source virtual URL, dst zone, dest rel)
470 $pairs[] = [ $triplet[1], $triplet[2] ];
471 }
472
473 $this->file->repo->cleanupBatch( $pairs );
474 }
475
481 protected function cleanupSource( $triplets ) {
482 // Create source file names from the triplets
483 $files = [];
484 foreach ( $triplets as $triplet ) {
485 $files[] = $triplet[0];
486 }
487
488 $this->file->repo->cleanupBatch( $files );
489 }
490}
const OVERWRITE_SAME
Definition FileRepo.php:60
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:75
Create PSR-3 logger objects.
Service locator for MediaWiki core services.
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:48
newUpdateQueryBuilder()
Get an UpdateQueryBuilder bound to this connection.
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.