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