MediaWiki master
LocalFileMoveBatch.php
Go to the documentation of this file.
1<?php
25use Psr\Log\LoggerInterface;
27use 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
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:54
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:68
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
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.