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