diff options
-rw-r--r-- | autoload.php | 1 | ||||
-rw-r--r-- | includes/libs/filebackend/FileOpBatch.php | 2 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/CopyFileOp.php | 29 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/CreateFileOp.php | 19 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/DeleteFileOp.php | 15 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/DescribeFileOp.php | 23 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/FileOp.php | 172 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/FileStatePredicates.php | 142 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/MoveFileOp.php | 33 | ||||
-rw-r--r-- | includes/libs/filebackend/fileop/StoreFileOp.php | 25 | ||||
-rw-r--r-- | tests/phpunit/includes/filebackend/FileBackendIntegrationTest.php | 8 |
11 files changed, 303 insertions, 166 deletions
diff --git a/autoload.php b/autoload.php index 378f6c5b5672..a6a5504f8af1 100644 --- a/autoload.php +++ b/autoload.php @@ -494,6 +494,7 @@ $wgAutoloadLocalClasses = [ 'FileOpBatch' => __DIR__ . '/includes/libs/filebackend/FileOpBatch.php', 'FileOpPerfTest' => __DIR__ . '/maintenance/fileOpPerfTest.php', 'FileRepo' => __DIR__ . '/includes/filerepo/FileRepo.php', + 'FileStatePredicates' => __DIR__ . '/includes/libs/filebackend/fileop/FileStatePredicates.php', 'FindBadBlobs' => __DIR__ . '/maintenance/findBadBlobs.php', 'FindClasses' => __DIR__ . '/maintenance/findClasses.php', 'FindDeprecated' => __DIR__ . '/maintenance/findDeprecated.php', diff --git a/includes/libs/filebackend/FileOpBatch.php b/includes/libs/filebackend/FileOpBatch.php index d28cbdfc5359..bcabf47099d7 100644 --- a/includes/libs/filebackend/FileOpBatch.php +++ b/includes/libs/filebackend/FileOpBatch.php @@ -64,7 +64,7 @@ class FileOpBatch { $ignoreErrors = !empty( $opts['force'] ); $maxConcurrency = $opts['concurrency'] ?? 1; - $predicates = FileOp::newPredicates(); // account for previous ops in prechecks + $predicates = new FileStatePredicates(); // account for previous ops in prechecks $curBatch = []; // concurrent FileOp sub-batch accumulation $curBatchDeps = FileOp::newDependencies(); // paths used in FileOp sub-batch $pPerformOps = []; // ordered list of concurrent FileOp sub-batches diff --git a/includes/libs/filebackend/fileop/CopyFileOp.php b/includes/libs/filebackend/fileop/CopyFileOp.php index 779fece9490a..cee3e7f7ea18 100644 --- a/includes/libs/filebackend/fileop/CopyFileOp.php +++ b/includes/libs/filebackend/fileop/CopyFileOp.php @@ -34,18 +34,19 @@ class CopyFileOp extends FileOp { ]; } - protected function doPrecheck( array &$predicates ) { + protected function doPrecheck( + FileStatePredicates $opPredicates, + FileStatePredicates $batchPredicates + ) { $status = StatusValue::newGood(); // Check source file existence - $srcExists = $this->fileExists( $this->params['src'], $predicates ); + $srcExists = $this->resolveFileExistence( $this->params['src'], $opPredicates ); if ( $srcExists === false ) { if ( $this->getParam( 'ignoreMissingSource' ) ) { $this->cancelled = true; // no-op // Update file existence predicates (cache 404s) - $predicates[self::ASSUMED_EXISTS][$this->params['src']] = false; - $predicates[self::ASSUMED_SIZE][$this->params['src']] = false; - $predicates[self::ASSUMED_SHA1][$this->params['src']] = false; + $batchPredicates->assumeFileDoesNotExist( $this->params['src'] ); return $status; // nothing to do } else { @@ -58,15 +59,23 @@ class CopyFileOp extends FileOp { return $status; } - // Check if destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); + // Check if an incompatible destination file exists + $srcSize = function () use ( $opPredicates ) { + static $size = null; + $size ??= $this->resolveFileSize( $this->params['src'], $opPredicates ); + return $size; + }; + $srcSha1 = function () use ( $opPredicates ) { + static $sha1 = null; + $sha1 ??= $this->resolveFileSha1Base36( $this->params['src'], $opPredicates ); + return $sha1; + }; + $status->merge( $this->precheckDestExistence( $opPredicates, $srcSize, $srcSha1 ) ); $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() // Update file existence predicates if the operation is expected to be allowed to run if ( $status->isOK() ) { - $predicates[self::ASSUMED_EXISTS][$this->params['dst']] = true; - $predicates[self::ASSUMED_SIZE][$this->params['dst']] = $this->sourceSize; - $predicates[self::ASSUMED_SHA1][$this->params['dst']] = $this->sourceSha1; + $batchPredicates->assumeFileExists( $this->params['dst'], $srcSize, $srcSha1 ); } return $status; // safe to call attempt() diff --git a/includes/libs/filebackend/fileop/CreateFileOp.php b/includes/libs/filebackend/fileop/CreateFileOp.php index ef825c6318f6..d1897a13a915 100644 --- a/includes/libs/filebackend/fileop/CreateFileOp.php +++ b/includes/libs/filebackend/fileop/CreateFileOp.php @@ -32,25 +32,28 @@ class CreateFileOp extends FileOp { ]; } - protected function doPrecheck( array &$predicates ) { + protected function doPrecheck( + FileStatePredicates $opPredicates, + FileStatePredicates $batchPredicates + ) { $status = StatusValue::newGood(); // Check if the source data is too big - $maxBytes = $this->backend->maxFileSizeInternal(); - if ( strlen( $this->getParam( 'content' ) ) > $maxBytes ) { - $status->fatal( 'backend-fail-maxsize', $this->params['dst'], $maxBytes ); + $sourceSize = $this->getSourceSize(); + $maxFileSize = $this->backend->maxFileSizeInternal(); + if ( $sourceSize > $maxFileSize ) { + $status->fatal( 'backend-fail-maxsize', $this->params['dst'], $maxFileSize ); return $status; } // Check if an incompatible destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); + $sourceSha1 = $this->getSourceSha1Base36(); + $status->merge( $this->precheckDestExistence( $opPredicates, $sourceSize, $sourceSha1 ) ); $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() // Update file existence predicates if the operation is expected to be allowed to run if ( $status->isOK() ) { - $predicates[self::ASSUMED_EXISTS][$this->params['dst']] = true; - $predicates[self::ASSUMED_SIZE][$this->params['dst']] = $this->sourceSize; - $predicates[self::ASSUMED_SHA1][$this->params['dst']] = $this->sourceSha1; + $batchPredicates->assumeFileExists( $this->params['dst'], $sourceSize, $sourceSha1 ); } return $status; // safe to call attempt() diff --git a/includes/libs/filebackend/fileop/DeleteFileOp.php b/includes/libs/filebackend/fileop/DeleteFileOp.php index 251b7bbc7419..7c7c39f850e0 100644 --- a/includes/libs/filebackend/fileop/DeleteFileOp.php +++ b/includes/libs/filebackend/fileop/DeleteFileOp.php @@ -30,18 +30,19 @@ class DeleteFileOp extends FileOp { return [ [ 'src' ], [ 'ignoreMissingSource' ], [ 'src' ] ]; } - protected function doPrecheck( array &$predicates ) { + protected function doPrecheck( + FileStatePredicates $opPredicates, + FileStatePredicates $batchPredicates + ) { $status = StatusValue::newGood(); // Check source file existence - $srcExists = $this->fileExists( $this->params['src'], $predicates ); + $srcExists = $this->resolveFileExistence( $this->params['src'], $opPredicates ); if ( $srcExists === false ) { if ( $this->getParam( 'ignoreMissingSource' ) ) { $this->cancelled = true; // no-op // Update file existence predicates (cache 404s) - $predicates[self::ASSUMED_EXISTS][$this->params['src']] = false; - $predicates[self::ASSUMED_SIZE][$this->params['src']] = false; - $predicates[self::ASSUMED_SHA1][$this->params['src']] = false; + $batchPredicates->assumeFileDoesNotExist( $this->params['src'] ); return $status; // nothing to do } else { @@ -56,9 +57,7 @@ class DeleteFileOp extends FileOp { } // Update file existence predicates since the operation is expected to be allowed to run - $predicates[self::ASSUMED_EXISTS][$this->params['src']] = false; - $predicates[self::ASSUMED_SIZE][$this->params['src']] = false; - $predicates[self::ASSUMED_SHA1][$this->params['src']] = false; + $batchPredicates->assumeFileDoesNotExist( $this->params['src'] ); return $status; // safe to call attempt() } diff --git a/includes/libs/filebackend/fileop/DescribeFileOp.php b/includes/libs/filebackend/fileop/DescribeFileOp.php index 029c45132a64..70411963e416 100644 --- a/includes/libs/filebackend/fileop/DescribeFileOp.php +++ b/includes/libs/filebackend/fileop/DescribeFileOp.php @@ -30,11 +30,14 @@ class DescribeFileOp extends FileOp { return [ [ 'src' ], [ 'headers' ], [ 'src' ] ]; } - protected function doPrecheck( array &$predicates ) { + protected function doPrecheck( + FileStatePredicates $opPredicates, + FileStatePredicates $batchPredicates + ) { $status = StatusValue::newGood(); // Check source file existence - $srcExists = $this->fileExists( $this->params['src'], $predicates ); + $srcExists = $this->resolveFileExistence( $this->params['src'], $opPredicates ); if ( $srcExists === false ) { $status->fatal( 'backend-fail-notexists', $this->params['src'] ); @@ -46,11 +49,17 @@ class DescribeFileOp extends FileOp { } // Update file existence predicates since the operation is expected to be allowed to run - $predicates[self::ASSUMED_EXISTS][$this->params['src']] = $srcExists; - $predicates[self::ASSUMED_SIZE][$this->params['src']] = - $this->fileSize( $this->params['src'], $predicates ); - $predicates[self::ASSUMED_SHA1][$this->params['src']] = - $this->fileSha1( $this->params['src'], $predicates ); + $srcSize = function () use ( $opPredicates ) { + static $size = null; + $size ??= $this->resolveFileSize( $this->params['src'], $opPredicates ); + return $size; + }; + $srcSha1 = function () use ( $opPredicates ) { + static $sha1 = null; + $sha1 ??= $this->resolveFileSha1Base36( $this->params['src'], $opPredicates ); + return $sha1; + }; + $batchPredicates->assumeFileExists( $this->params['src'], $srcSize, $srcSha1 ); return $status; // safe to call attempt() } diff --git a/includes/libs/filebackend/fileop/FileOp.php b/includes/libs/filebackend/fileop/FileOp.php index 68b4d276c0c7..3bfd362e5e91 100644 --- a/includes/libs/filebackend/fileop/FileOp.php +++ b/includes/libs/filebackend/fileop/FileOp.php @@ -52,15 +52,9 @@ abstract class FileOp { /** @var bool */ protected $cancelled = false; - /** @var int|bool */ - protected $sourceSize; - /** @var string|bool */ - protected $sourceSha1; - - /** @var bool */ + /** @var bool|null */ protected $overwriteSameCase; - - /** @var bool */ + /** @var bool|null */ protected $destExists; /* Object life-cycle */ @@ -68,10 +62,6 @@ abstract class FileOp { private const STATE_CHECKED = 2; private const STATE_ATTEMPTED = 3; - protected const ASSUMED_SHA1 = 'sha1'; - protected const ASSUMED_EXISTS = 'exists'; - protected const ASSUMED_SIZE = 'size'; - /** * Build a new batch file operation transaction * @@ -142,15 +132,6 @@ abstract class FileOp { } /** - * Get a new empty predicates array for precheck() - * - * @return array - */ - final public static function newPredicates() { - return [ self::ASSUMED_EXISTS => [], self::ASSUMED_SHA1 => [], self::ASSUMED_SIZE => [] ]; - } - - /** * Get a new empty dependency tracking array for paths read/written to * * @return array @@ -162,7 +143,7 @@ abstract class FileOp { /** * Update a dependency tracking array to account for this operation * - * @param array $deps Prior path reads/writes; format of FileOp::newPredicates() + * @param array $deps Prior path reads/writes; format of FileOp::newDependencies() * @return array */ final public function applyDependencies( array $deps ) { @@ -175,7 +156,7 @@ abstract class FileOp { /** * Check if this operation changes files listed in $paths * - * @param array $deps Prior path reads/writes; format of FileOp::newPredicates() + * @param array $deps Prior path reads/writes; format of FileOp::newDependencies() * @return bool */ final public function dependsOn( array $deps ) { @@ -194,14 +175,14 @@ abstract class FileOp { } /** - * Check preconditions of the operation without writing anything. - * This must update $predicates for each path that the op can change - * except when a failing StatusValue object is returned. + * Do a dry-run precondition check of the operation in the context of op batch + * + * Updates the batch predicates for all paths this op can change if an OK status is returned * - * @param array &$predicates + * @param FileStatePredicates $predicates Counterfactual file states for the op batch * @return StatusValue */ - final public function precheck( array &$predicates ) { + final public function precheck( FileStatePredicates $predicates ) { if ( $this->state !== self::STATE_NEW ) { return StatusValue::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state ); } @@ -217,7 +198,8 @@ abstract class FileOp { return $status; } - $status = $this->doPrecheck( $predicates ); + $opPredicates = $predicates->snapshot( $this->storagePathsReadOrChanged() ); + $status = $this->doPrecheck( $opPredicates, $predicates ); if ( !$status->isOK() ) { $this->failed = true; } @@ -226,10 +208,18 @@ abstract class FileOp { } /** - * @param array &$predicates + * Do a dry-run precondition check of the operation in the context of op batch + * + * Updates the batch predicates for all paths this op can change if an OK status is returned + * + * @param FileStatePredicates $opPredicates Counterfactual file states for op paths at op start + * @param FileStatePredicates $batchPredicates Counterfactual file states for the op batch * @return StatusValue */ - protected function doPrecheck( array &$predicates ) { + protected function doPrecheck( + FileStatePredicates $opPredicates, + FileStatePredicates $batchPredicates + ) { return StatusValue::newGood(); } @@ -349,25 +339,24 @@ abstract class FileOp { } /** - * Check for errors with regards to the destination file already existing. - * Also set destExists, overwriteSameCase, sourceSize, and sourceSha1 member variables. + * Check for errors with regards to the destination file already existing + * + * Also set the destExists and overwriteSameCase member variables. * A bad StatusValue will be returned if there is no chance it can be overwritten. * - * @param array $predicates + * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op + * @param int|false|Closure $sourceSize Source size or idempotent function yielding the size + * @param string|Closure $sourceSha1 Source hash, or, idempotent function yielding the hash * @return StatusValue */ - protected function precheckDestExistence( array $predicates ) { + protected function precheckDestExistence( + FileStatePredicates $opPredicates, + $sourceSize, + $sourceSha1 + ) { $status = StatusValue::newGood(); - // Record the size of source file/string - $this->sourceSize = $this->getSourceSize(); // FS file or data string - // file in storage? - $this->sourceSize ??= $this->fileSize( $this->params['src'], $predicates ); - // Record the hash of source file/string - $this->sourceSha1 = $this->getSourceSha1Base36(); // FS file or data string - // file in storage? - $this->sourceSha1 ??= $this->fileSha1( $this->params['src'], $predicates ); // Record the existence of destination file - $this->destExists = $this->fileExists( $this->params['dst'], $predicates ); + $this->destExists = $this->resolveFileExistence( $this->params['dst'], $opPredicates ); // Check if an incompatible file exists at the destination $this->overwriteSameCase = false; if ( $this->destExists ) { @@ -375,14 +364,16 @@ abstract class FileOp { return $status; // OK, no conflict } elseif ( $this->getParam( 'overwriteSame' ) ) { // Operation does nothing other than return an OK or bad status - $dhash = $this->fileSha1( $this->params['dst'], $predicates ); - $dsize = $this->fileSize( $this->params['dst'], $predicates ); + $sourceSize = ( $sourceSize instanceof Closure ) ? $sourceSize() : $sourceSize; + $sourceSha1 = ( $sourceSha1 instanceof Closure ) ? $sourceSha1() : $sourceSha1; + $dstSha1 = $this->resolveFileSha1Base36( $this->params['dst'], $opPredicates ); + $dstSize = $this->resolveFileSize( $this->params['dst'], $opPredicates ); // Check if hashes are valid and match each other... - if ( !strlen( $this->sourceSha1 ) || !strlen( $dhash ) ) { + if ( !strlen( $sourceSha1 ) || !strlen( $dstSha1 ) ) { $status->fatal( 'backend-fail-hashes' ); - } elseif ( !is_int( $this->sourceSize ) || !is_int( $dsize ) ) { + } elseif ( !is_int( $sourceSize ) || !is_int( $dstSize ) ) { $status->fatal( 'backend-fail-sizes' ); - } elseif ( $this->sourceSha1 !== $dhash || $this->sourceSize !== $dsize ) { + } elseif ( $sourceSha1 !== $dstSha1 || $sourceSize !== $dstSize ) { // Give an error if the files are not identical $status->fatal( 'backend-fail-notsame', $this->params['dst'] ); } else { @@ -399,43 +390,22 @@ abstract class FileOp { } /** - * precheckDestExistence() helper function to get the source file size. - * Subclasses should overwrite this if the source is not in storage. - * - * @return int|false|null Returns false on failure - */ - protected function getSourceSize() { - return null; // N/A - } - - /** - * precheckDestExistence() helper function to get the source file SHA-1. - * Subclasses should overwrite this if the source is not in storage. - * - * @return string|false|null Returns false on failure - */ - protected function getSourceSha1Base36() { - return null; // N/A - } - - /** * Check if a file will exist in storage when this operation is attempted * * Ideally, the file stat entry should already be preloaded via preloadFileStat(). * Otherwise, this will query the backend. * * @param string $source Storage path - * @param array $predicates + * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op * @return bool|null Whether the file will exist or null on error */ - final protected function fileExists( $source, array $predicates ) { - if ( isset( $predicates[self::ASSUMED_EXISTS][$source] ) ) { - return $predicates[self::ASSUMED_EXISTS][$source]; // previous op assures this - } else { - $params = [ 'src' => $source, 'latest' => true ]; - - return $this->backend->fileExists( $params ); - } + final protected function resolveFileExistence( $source, FileStatePredicates $opPredicates ) { + return $opPredicates->resolveFileExistence( + $source, + function ( $path ) { + return $this->backend->fileExists( [ 'src' => $path, 'latest' => true ] ); + } + ); } /** @@ -446,44 +416,32 @@ abstract class FileOp { * Get the size of a file in storage when this operation is attempted * * @param string $source Storage path - * @param array $predicates + * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op * @return int|false False on failure */ - final protected function fileSize( $source, array $predicates ) { - if ( isset( $predicates[self::ASSUMED_SIZE][$source] ) ) { - return $predicates[self::ASSUMED_SIZE][$source]; // previous op assures this - } elseif ( - isset( $predicates[self::ASSUMED_EXISTS][$source] ) && - !$predicates[self::ASSUMED_EXISTS][$source] - ) { - return false; // previous op assures this - } else { - $params = [ 'src' => $source, 'latest' => true ]; - - return $this->backend->getFileSize( $params ); - } + final protected function resolveFileSize( $source, FileStatePredicates $opPredicates ) { + return $opPredicates->resolveFileSize( + $source, + function ( $path ) { + return $this->backend->getFileSize( [ 'src' => $path, 'latest' => true ] ); + } + ); } /** * Get the SHA-1 of a file in storage when this operation is attempted * * @param string $source Storage path - * @param array $predicates - * @return string|bool The SHA-1 hash the file will have or false if non-existent or on error + * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op + * @return string|false The SHA-1 hash the file will have or false if non-existent or on error */ - final protected function fileSha1( $source, array $predicates ) { - if ( isset( $predicates[self::ASSUMED_SHA1][$source] ) ) { - return $predicates[self::ASSUMED_SHA1][$source]; // previous op assures this - } elseif ( - isset( $predicates[self::ASSUMED_EXISTS][$source] ) && - !$predicates[self::ASSUMED_EXISTS][$source] - ) { - return false; // previous op assures this - } else { - $params = [ 'src' => $source, 'latest' => true ]; - - return $this->backend->getFileSha1Base36( $params ); - } + final protected function resolveFileSha1Base36( $source, FileStatePredicates $opPredicates ) { + return $opPredicates->resolveFileSha1Base36( + $source, + function ( $path ) { + return $this->backend->getFileSha1Base36( [ 'src' => $path, 'latest' => true ] ); + } + ); } /** diff --git a/includes/libs/filebackend/fileop/FileStatePredicates.php b/includes/libs/filebackend/fileop/FileStatePredicates.php new file mode 100644 index 000000000000..a9a592751a28 --- /dev/null +++ b/includes/libs/filebackend/fileop/FileStatePredicates.php @@ -0,0 +1,142 @@ +<?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 + * @ingroup FileBackend + */ + +/** + * Helper class for tracking counterfactual file states when pre-checking file operation batches + * + * The file states are represented with (existence,size,sha1) triples. When combined with the + * current state of files in the backend, this can be used to simulate how a batch of operations + * would play out as a "dry run". This is used in FileBackend::doOperations() to bail out if any + * failure can be predicted before modifying any data. This includes file operation batches where + * the same file gets modified by different operations within the batch. + * + * @internal Only for use within FileBackend + */ +class FileStatePredicates { + protected const EXISTS = 'exists'; + protected const SIZE = 'size'; + protected const SHA1 = 'sha1'; + + /** @var array<string,array> Map of (storage path => file state map) */ + private $fileStateByPath; + + public function __construct() { + $this->fileStateByPath = []; + } + + /** + * Predicate that a file exists at the path + * + * @param string $path Storage path + * @param int|false|Closure $size File size or idempotent function yielding the size + * @param string|Closure $sha1Base36 File hash, or, idempotent function yielding the hash + */ + public function assumeFileExists( string $path, $size, $sha1Base36 ) { + $this->fileStateByPath[$path] = [ + self::EXISTS => true, + self::SIZE => $size, + self::SHA1 => $sha1Base36 + ]; + } + + /** + * Predicate that no file exists at the path + * + * @param string $path Storage path + */ + public function assumeFileDoesNotExist( string $path ) { + $this->fileStateByPath[$path] = [ + self::EXISTS => false, + self::SIZE => false, + self::SHA1 => false + ]; + } + + /** + * Get the hypothetical existance a file given predicated and current state of files + * + * @param string $path Storage path + * @param Closure $curExistenceFunc Function to compute the current existence for a given path + * @return bool|null Whether the file exists; null on error + */ + public function resolveFileExistence( string $path, $curExistenceFunc ) { + return self::resolveFileProperty( $path, self::EXISTS, $curExistenceFunc ); + } + + /** + * Get the hypothetical size of a file given predicated and current state of files + * + * @param string $path Storage path + * @param Closure $curSizeFunc Function to compute the current size for a given path + * @return int|false Bytes; false on error + */ + public function resolveFileSize( string $path, $curSizeFunc ) { + return self::resolveFileProperty( $path, self::SIZE, $curSizeFunc ); + } + + /** + * Get the hypothetical SHA-1 hash of a file given predicated and current state of files + * + * @param string $path Storage path + * @param Closure $curSha1Func Function to compute the current SHA-1 hash for a given path + * @return string|false Base 36 SHA-1 hash; false on error + */ + public function resolveFileSha1Base36( string $path, $curSha1Func ) { + return self::resolveFileProperty( $path, self::SHA1, $curSha1Func ); + } + + /** + * @param string $path Storage path + * @param string $property One of (self::EXISTS, self::SIZE, self::SHA1) + * @param Closure $curValueFunc Function to compute the current value for a given path + * @return mixed + */ + private function resolveFileProperty( $path, $property, $curValueFunc ) { + if ( isset( $this->fileStateByPath[$path] ) ) { + // File is predicated to have a counterfactual state + $value = $this->fileStateByPath[$path][$property]; + if ( $value instanceof Closure ) { + $value = $value(); + $this->fileStateByPath[$path][$property] = $value; + } + } else { + // File is not predicated to have a counterfactual state; use the current state + $value = $curValueFunc( $path ); + } + + return $value; + } + + /** + * @param string[] $paths List of storage paths + * @return self Clone predicates limited to the given paths + */ + public function snapshot( array $paths ) { + $snapshot = new self(); + foreach ( $paths as $path ) { + if ( isset( $this->fileStateByPath[$path] ) ) { + $snapshot->fileStateByPath[$path] = $this->fileStateByPath[$path]; + } + } + + return $snapshot; + } +} diff --git a/includes/libs/filebackend/fileop/MoveFileOp.php b/includes/libs/filebackend/fileop/MoveFileOp.php index 06855777aed9..708574b18393 100644 --- a/includes/libs/filebackend/fileop/MoveFileOp.php +++ b/includes/libs/filebackend/fileop/MoveFileOp.php @@ -34,18 +34,19 @@ class MoveFileOp extends FileOp { ]; } - protected function doPrecheck( array &$predicates ) { + protected function doPrecheck( + FileStatePredicates $opPredicates, + FileStatePredicates $batchPredicates + ) { $status = StatusValue::newGood(); // Check source file existence - $srcExists = $this->fileExists( $this->params['src'], $predicates ); + $srcExists = $this->resolveFileExistence( $this->params['src'], $opPredicates ); if ( $srcExists === false ) { if ( $this->getParam( 'ignoreMissingSource' ) ) { $this->cancelled = true; // no-op // Update file existence predicates (cache 404s) - $predicates[self::ASSUMED_EXISTS][$this->params['src']] = false; - $predicates[self::ASSUMED_SIZE][$this->params['src']] = false; - $predicates[self::ASSUMED_SHA1][$this->params['src']] = false; + $batchPredicates->assumeFileDoesNotExist( $this->params['src'] ); return $status; // nothing to do } else { @@ -59,17 +60,25 @@ class MoveFileOp extends FileOp { return $status; } // Check if an incompatible destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); + $srcSize = function () use ( $opPredicates ) { + static $size = null; + $size ??= $this->resolveFileSize( $this->params['src'], $opPredicates ); + return $size; + }; + $srcSha1 = function () use ( $opPredicates ) { + static $sha1 = null; + $sha1 ??= $this->resolveFileSha1Base36( $this->params['src'], $opPredicates ); + return $sha1; + }; + $status->merge( $this->precheckDestExistence( $opPredicates, $srcSize, $srcSha1 ) ); $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() // Update file existence predicates if the operation is expected to be allowed to run if ( $status->isOK() ) { - $predicates[self::ASSUMED_EXISTS][$this->params['src']] = false; - $predicates[self::ASSUMED_SIZE][$this->params['src']] = false; - $predicates[self::ASSUMED_SHA1][$this->params['src']] = false; - $predicates[self::ASSUMED_EXISTS][$this->params['dst']] = true; - $predicates[self::ASSUMED_SIZE][$this->params['dst']] = $this->sourceSize; - $predicates[self::ASSUMED_SHA1][$this->params['dst']] = $this->sourceSha1; + $batchPredicates->assumeFileExists( $this->params['dst'], $srcSize, $srcSha1 ); + if ( $this->params['src'] !== $this->params['dst'] ) { + $batchPredicates->assumeFileDoesNotExist( $this->params['src'] ); + } } return $status; // safe to call attempt() diff --git a/includes/libs/filebackend/fileop/StoreFileOp.php b/includes/libs/filebackend/fileop/StoreFileOp.php index 76cc4b6c494e..ef4974ff9c96 100644 --- a/includes/libs/filebackend/fileop/StoreFileOp.php +++ b/includes/libs/filebackend/fileop/StoreFileOp.php @@ -36,31 +36,38 @@ class StoreFileOp extends FileOp { ]; } - protected function doPrecheck( array &$predicates ) { + protected function doPrecheck( + FileStatePredicates $opPredicates, + FileStatePredicates $batchPredicates + ) { $status = StatusValue::newGood(); - // Check if the source file exists in the file system and is not too big + // Check if the source file exists in the file system if ( !is_file( $this->params['src'] ) ) { $status->fatal( 'backend-fail-notexists', $this->params['src'] ); return $status; } // Check if the source file is too big - $maxBytes = $this->backend->maxFileSizeInternal(); - if ( filesize( $this->params['src'] ) > $maxBytes ) { - $status->fatal( 'backend-fail-maxsize', $this->params['dst'], $maxBytes ); + $sourceSize = $this->getSourceSize(); + $maxFileSize = $this->backend->maxFileSizeInternal(); + if ( $sourceSize > $maxFileSize ) { + $status->fatal( 'backend-fail-maxsize', $this->params['dst'], $maxFileSize ); return $status; } // Check if an incompatible destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); + $sourceSha1 = function () { + static $sha1 = null; + $sha1 ??= $this->getSourceSha1Base36(); + return $sha1; + }; + $status->merge( $this->precheckDestExistence( $opPredicates, $sourceSize, $sourceSha1 ) ); $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() // Update file existence predicates if the operation is expected to be allowed to run if ( $status->isOK() ) { - $predicates[self::ASSUMED_EXISTS][$this->params['dst']] = true; - $predicates[self::ASSUMED_SIZE][$this->params['dst']] = $this->sourceSize; - $predicates[self::ASSUMED_SHA1][$this->params['dst']] = $this->sourceSha1; + $batchPredicates->assumeFileExists( $this->params['dst'], $sourceSize, $sourceSha1 ); } return $status; // safe to call attempt() diff --git a/tests/phpunit/includes/filebackend/FileBackendIntegrationTest.php b/tests/phpunit/includes/filebackend/FileBackendIntegrationTest.php index c292a8dd0c9f..ebb7659da5a4 100644 --- a/tests/phpunit/includes/filebackend/FileBackendIntegrationTest.php +++ b/tests/phpunit/includes/filebackend/FileBackendIntegrationTest.php @@ -1672,19 +1672,19 @@ class FileBackendIntegrationTest extends MediaWikiIntegrationTestCase { } } - public function testDoOperations() { + public function testDoOperationsSuccessful() { $this->backend = $this->singleBackend; $this->tearDownFiles(); - $this->doTestDoOperations(); + $this->doTestDoOperationsSuccessful(); $this->tearDownFiles(); $this->backend = $this->multiBackend; $this->tearDownFiles(); - $this->doTestDoOperations(); + $this->doTestDoOperationsSuccessful(); $this->tearDownFiles(); } - private function doTestDoOperations() { + private function doTestDoOperationsSuccessful() { $base = self::baseStorePath(); $fileA = "$base/unittest-cont1/e/a/b/fileA.txt"; |