wiki.techinc.nl/includes/filerepo/file/LocalFileMoveBatch.php
Amir Sarabadani f4e68e055f Reorg: Move Status to MediaWiki\Status\
This class is used heavily basically everywhere, moving it to Utils
wouldn't make much sense. Also with this change, we can move
StatusValue to MediaWiki\Status as well.

Bug: T321882
Depends-On: I5f89ecf27ce1471a74f31c6018806461781213c3
Change-Id: I04c1dcf5129df437589149f0f3e284974d7c98fa
2023-08-25 15:44:17 +02:00

488 lines
12 KiB
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 );
}
}