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