wikimedia/mediawiki-core

View on GitHub
includes/filerepo/file/LocalFileMoveBatch.php

Summary

Maintainability
C
1 day
Test Coverage
<?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 );
    }
}