includes/filerepo/file/LocalFileMoveBatch.php
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use Psr\Log\LoggerInterface;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\ScopedCallback;
/**
* Helper class for file movement
*
* @ingroup FileAbstraction
*/
class LocalFileMoveBatch {
/** @var LocalFile */
protected $file;
/** @var Title */
protected $target;
protected $cur;
protected $olds;
protected $oldCount;
protected $archive;
/** @var IDatabase */
protected $db;
/** @var string */
protected $oldHash;
/** @var string */
protected $newHash;
/** @var string */
protected $oldName;
/** @var string */
protected $newName;
/** @var string */
protected $oldRel;
/** @var string */
protected $newRel;
/** @var LoggerInterface */
private $logger;
/** @var bool */
private $haveSourceLock = false;
/** @var bool */
private $haveTargetLock = false;
/** @var LocalFile|null */
private $targetFile;
/**
* @param LocalFile $file
* @param Title $target
*/
public function __construct( LocalFile $file, Title $target ) {
$this->file = $file;
$this->target = $target;
$this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
$this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
$this->oldName = $this->file->getName();
$this->newName = $this->file->repo->getNameFromTitle( $this->target );
$this->oldRel = $this->oldHash . $this->oldName;
$this->newRel = $this->newHash . $this->newName;
$this->db = $file->getRepo()->getPrimaryDB();
$this->logger = LoggerFactory::getInstance( 'imagemove' );
}
/**
* Add the current image to the batch
*
* @return Status
*/
public function addCurrent() {
$status = $this->acquireSourceLock();
if ( $status->isOK() ) {
$this->cur = [ $this->oldRel, $this->newRel ];
}
return $status;
}
/**
* Add the old versions of the image to the batch
* @return string[] List of archive names from old versions
*/
public function addOlds() {
$archiveBase = 'archive';
$this->olds = [];
$this->oldCount = 0;
$archiveNames = [];
$result = $this->db->newSelectQueryBuilder()
->select( [ 'oi_archive_name', 'oi_deleted' ] )
->forUpdate() // ignore snapshot
->from( 'oldimage' )
->where( [ 'oi_name' => $this->oldName ] )
->caller( __METHOD__ )->fetchResultSet();
foreach ( $result as $row ) {
$archiveNames[] = $row->oi_archive_name;
$oldName = $row->oi_archive_name;
$bits = explode( '!', $oldName, 2 );
if ( count( $bits ) != 2 ) {
$this->logger->debug(
'Old file name missing !: {oldName}',
[ 'oldName' => $oldName ]
);
continue;
}
[ $timestamp, $filename ] = $bits;
if ( $this->oldName != $filename ) {
$this->logger->debug(
'Old file name does not match: {oldName}',
[ 'oldName' => $oldName ]
);
continue;
}
$this->oldCount++;
// Do we want to add those to oldCount?
if ( $row->oi_deleted & File::DELETED_FILE ) {
continue;
}
$this->olds[] = [
"{$archiveBase}/{$this->oldHash}{$oldName}",
"{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
];
}
return $archiveNames;
}
/**
* Acquire the source file lock, if it has not been acquired already
*
* @return Status
*/
protected function acquireSourceLock() {
if ( $this->haveSourceLock ) {
return Status::newGood();
}
$status = $this->file->acquireFileLock();
if ( $status->isOK() ) {
$this->haveSourceLock = true;
}
return $status;
}
/**
* Acquire the target file lock, if it has not been acquired already
*
* @return Status
*/
protected function acquireTargetLock() {
if ( $this->haveTargetLock ) {
return Status::newGood();
}
$status = $this->getTargetFile()->acquireFileLock();
if ( $status->isOK() ) {
$this->haveTargetLock = true;
}
return $status;
}
/**
* Release both file locks
*/
protected function releaseLocks() {
if ( $this->haveSourceLock ) {
$this->file->releaseFileLock();
$this->haveSourceLock = false;
}
if ( $this->haveTargetLock ) {
$this->getTargetFile()->releaseFileLock();
$this->haveTargetLock = false;
}
}
/**
* Get the target file
*
* @return LocalFile
*/
protected function getTargetFile() {
if ( $this->targetFile === null ) {
$this->targetFile = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
->newFile( $this->target );
}
return $this->targetFile;
}
/**
* Perform the move.
* @return Status
*/
public function execute() {
$repo = $this->file->repo;
$status = $repo->newGood();
$status->merge( $this->acquireSourceLock() );
if ( !$status->isOK() ) {
return $status;
}
$status->merge( $this->acquireTargetLock() );
if ( !$status->isOK() ) {
$this->releaseLocks();
return $status;
}
$unlockScope = new ScopedCallback( function () {
$this->releaseLocks();
} );
$triplets = $this->getMoveTriplets();
$checkStatus = $this->removeNonexistentFiles( $triplets );
if ( !$checkStatus->isGood() ) {
$status->merge( $checkStatus ); // couldn't talk to file backend
return $status;
}
$triplets = $checkStatus->value;
// Verify the file versions metadata in the DB.
$statusDb = $this->verifyDBUpdates();
if ( !$statusDb->isGood() ) {
$statusDb->setOK( false );
return $statusDb;
}
if ( !$repo->hasSha1Storage() ) {
// Copy the files into their new location.
// If a prior process fataled copying or cleaning up files we tolerate any
// of the existing files if they are identical to the ones being stored.
$statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
$this->logger->debug(
'Moved files for {fileName}: {successCount} successes, {failCount} failures',
[
'fileName' => $this->file->getName(),
'successCount' => $statusMove->successCount,
'failCount' => $statusMove->failCount,
]
);
if ( !$statusMove->isGood() ) {
// Delete any files copied over (while the destination is still locked)
$this->cleanupTarget( $triplets );
$this->logger->debug(
'Error in moving files: {error}',
[ 'error' => $statusMove->getWikiText( false, false, 'en' ) ]
);
$statusMove->setOK( false );
return $statusMove;
}
$status->merge( $statusMove );
}
// Rename the file versions metadata in the DB.
$this->doDBUpdates();
$this->logger->debug(
'Renamed {fileName} in database: {successCount} successes, {failCount} failures',
[
'fileName' => $this->file->getName(),
'successCount' => $statusDb->successCount,
'failCount' => $statusDb->failCount,
]
);
// Everything went ok, remove the source files
$this->cleanupSource( $triplets );
// Defer lock release until the transaction is committed.
if ( $this->db->trxLevel() ) {
$unlockScope->cancel();
$this->db->onTransactionResolution( function () {
$this->releaseLocks();
} );
} else {
ScopedCallback::consume( $unlockScope );
}
$status->merge( $statusDb );
return $status;
}
/**
* Verify the database updates and return a new Status indicating how
* many rows would be updated.
*
* @return Status
*/
protected function verifyDBUpdates() {
$repo = $this->file->repo;
$status = $repo->newGood();
$dbw = $this->db;
// Lock the image row
$hasCurrent = $dbw->newSelectQueryBuilder()
->from( 'image' )
->where( [ 'img_name' => $this->oldName ] )
->forUpdate()
->caller( __METHOD__ )
->fetchRowCount();
// Lock the oldimage rows
$oldRowCount = $dbw->newSelectQueryBuilder()
->from( 'oldimage' )
->where( [ 'oi_name' => $this->oldName ] )
->forUpdate()
->caller( __METHOD__ )
->fetchRowCount();
if ( $hasCurrent ) {
$status->successCount++;
} else {
$status->failCount++;
}
$status->successCount += $oldRowCount;
// T36934: oldCount is based on files that actually exist.
// There may be more DB rows than such files, in which case $affected
// can be greater than $total. We use max() to avoid negatives here.
$status->failCount += max( 0, $this->oldCount - $oldRowCount );
if ( $status->failCount ) {
$status->error( 'imageinvalidfilename' );
}
return $status;
}
/**
* Do the database updates and return a new Status indicating how
* many rows where updated.
*/
protected function doDBUpdates() {
$dbw = $this->db;
// Update current image
$dbw->newUpdateQueryBuilder()
->update( 'image' )
->set( [ 'img_name' => $this->newName ] )
->where( [ 'img_name' => $this->oldName ] )
->caller( __METHOD__ )->execute();
// Update old images
$dbw->newUpdateQueryBuilder()
->update( 'oldimage' )
->set( [
'oi_name' => $this->newName,
'oi_archive_name = ' . $dbw->strreplace(
'oi_archive_name',
$dbw->addQuotes( $this->oldName ),
$dbw->addQuotes( $this->newName )
),
] )
->where( [ 'oi_name' => $this->oldName ] )
->caller( __METHOD__ )->execute();
}
/**
* Generate triplets for FileRepo::storeBatch().
* @return array[]
*/
protected function getMoveTriplets() {
$moves = array_merge( [ $this->cur ], $this->olds );
$triplets = []; // The format is: (srcUrl, destZone, destUrl)
foreach ( $moves as $move ) {
// $move: (oldRelativePath, newRelativePath)
$srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
$triplets[] = [ $srcUrl, 'public', $move[1] ];
$this->logger->debug(
'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
[
'fileName' => $this->file->getName(),
'srcUrl' => $srcUrl,
'move1' => $move[1],
]
);
}
return $triplets;
}
/**
* Removes non-existent files from move batch.
* @param array[] $triplets
* @return Status
*/
protected function removeNonexistentFiles( $triplets ) {
$files = [];
foreach ( $triplets as $file ) {
$files[$file[0]] = $file[0];
}
$result = $this->file->repo->fileExistsBatch( $files );
if ( in_array( null, $result, true ) ) {
return Status::newFatal( 'backend-fail-internal',
$this->file->repo->getBackend()->getName() );
}
$filteredTriplets = [];
foreach ( $triplets as $file ) {
if ( $result[$file[0]] ) {
$filteredTriplets[] = $file;
} else {
$this->logger->debug(
'File {file} does not exist',
[ 'file' => $file[0] ]
);
}
}
return Status::newGood( $filteredTriplets );
}
/**
* Cleanup a partially moved array of triplets by deleting the target
* files. Called if something went wrong half way.
* @param array[] $triplets
*/
protected function cleanupTarget( $triplets ) {
// Create dest pairs from the triplets
$pairs = [];
foreach ( $triplets as $triplet ) {
// $triplet: (old source virtual URL, dst zone, dest rel)
$pairs[] = [ $triplet[1], $triplet[2] ];
}
$this->file->repo->cleanupBatch( $pairs );
}
/**
* Cleanup a fully moved array of triplets by deleting the source files.
* Called at the end of the move process if everything else went ok.
* @param array[] $triplets
*/
protected function cleanupSource( $triplets ) {
// Create source file names from the triplets
$files = [];
foreach ( $triplets as $triplet ) {
$files[] = $triplet[0];
}
$this->file->repo->cleanupBatch( $files );
}
}