wiki.techinc.nl/includes/filerepo/backend/FileOp.php

886 lines
24 KiB
PHP
Raw Normal View History

<?php
/**
* @file
* @ingroup FileBackend
* @author Aaron Schulz
*/
/**
* Helper class for representing operations with transaction support.
* FileBackend::doOperations() will require these classes for supported operations.
* Do not use this class from places outside FileBackend.
*
* Use of large fields should be avoided as we want to support
* potentially many FileOp classes in large arrays in memory.
*
* @ingroup FileBackend
* @since 1.19
*/
abstract class FileOp {
/** $var Array */
protected $params = array();
/** $var FileBackendBase */
protected $backend;
/** @var TempFSFile|null */
protected $tmpSourceFile, $tmpDestFile;
protected $state = self::STATE_NEW; // integer
protected $failed = false; // boolean
protected $useBackups = true; // boolean
protected $useLatest = true; // boolean
protected $destSameAsSource = false; // boolean
protected $destAlreadyExists = false; // boolean
/* Object life-cycle */
const STATE_NEW = 1;
const STATE_CHECKED = 2;
const STATE_ATTEMPTED = 3;
const STATE_DONE = 4;
/**
* Build a new file operation transaction
*
* @params $backend FileBackend
* @params $params Array
*/
final public function __construct( FileBackendBase $backend, array $params ) {
$this->backend = $backend;
foreach ( $this->allowedParams() as $name ) {
if ( isset( $params[$name] ) ) {
$this->params[$name] = $params[$name];
}
}
$this->params = $params;
}
/**
* Disable file backups for this operation
*
* @return void
*/
final protected function disableBackups() {
$this->useBackups = false;
}
/**
* Allow stale data for file reads and existence checks.
* If this is called, then disableBackups() should also be called
* unless the affected files are known to have not changed recently.
*
* @return void
*/
final protected function allowStaleReads() {
$this->useLatest = false;
}
/**
* Attempt a series of file operations.
* Callers are responsible for handling file locking.
*
* $opts is an array of options, including:
* 'force' : Errors that would normally cause a rollback do not.
* The remaining operations are still attempted if any fail.
* 'allowStale' : Don't require the latest available data.
* This can increase performance for non-critical writes.
* This has no effect unless the 'force' flag is set.
*
* @param $performOps Array List of FileOp operations
* @param $opts Array Batch operation options
* @return Status
*/
final public static function attemptBatch( array $performOps, array $opts ) {
$status = Status::newGood();
$allowStale = isset( $opts['allowStale'] ) && $opts['allowStale'];
$ignoreErrors = isset( $opts['force'] ) && $opts['force'];
$predicates = FileOp::newPredicates(); // account for previous op in prechecks
// Do pre-checks for each operation; abort on failure...
foreach ( $performOps as $index => $fileOp ) {
if ( $allowStale ) {
$fileOp->allowStaleReads(); // allow potentially stale reads
}
$status->merge( $fileOp->precheck( $predicates ) );
if ( !$status->isOK() ) { // operation failed?
if ( $ignoreErrors ) {
++$status->failCount;
$status->success[$index] = false;
} else {
return $status;
}
}
}
// Attempt each operation; abort on failure...
foreach ( $performOps as $index => $fileOp ) {
if ( $fileOp->failed() ) {
continue; // nothing to do
} elseif ( $ignoreErrors ) {
$fileOp->disableBackups(); // no chance of revert() calls
}
$status->merge( $fileOp->attempt() );
if ( !$status->isOK() ) { // operation failed?
if ( $ignoreErrors ) {
++$status->failCount;
$status->success[$index] = false;
} else {
// Revert everything done so far and abort.
// Do this by reverting each previous operation in reverse order.
$pos = $index - 1; // last one failed; no need to revert()
while ( $pos >= 0 ) {
if ( !$performOps[$pos]->failed() ) {
$status->merge( $performOps[$pos]->revert() );
}
$pos--;
}
return $status;
}
}
}
$wasOk = $status->isOK();
// Finish each operation...
foreach ( $performOps as $index => $fileOp ) {
if ( $fileOp->failed() ) {
continue; // nothing to do
}
$subStatus = $fileOp->finish();
if ( $subStatus->isOK() ) {
++$status->successCount;
$status->success[$index] = true;
} else {
++$status->failCount;
$status->success[$index] = false;
}
$status->merge( $subStatus );
}
// Make sure status is OK, despite any finish() fatals
$status->setResult( $wasOk, $status->value );
return $status;
}
/**
* Get the value of the parameter with the given name.
* Returns null if the parameter is not set.
*
* @param $name string
* @return mixed
*/
final public function getParam( $name ) {
return isset( $this->params[$name] ) ? $this->params[$name] : null;
}
/**
* Check if this operation failed precheck() or attempt()
* @return type
*/
final public function failed() {
return $this->failed;
}
/**
* Get a new empty predicates array for precheck()
*
* @return Array
*/
final public static function newPredicates() {
return array( 'exists' => array() );
}
/**
* Check preconditions of the operation without writing anything
*
* @param $predicates Array
* @return Status
*/
final public function precheck( array &$predicates ) {
if ( $this->state !== self::STATE_NEW ) {
return Status::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state );
}
$this->state = self::STATE_CHECKED;
$status = $this->doPrecheck( $predicates );
if ( !$status->isOK() ) {
$this->failed = true;
}
return $status;
}
/**
* Attempt the operation, backing up files as needed; this must be reversible
*
* @return Status
*/
final public function attempt() {
if ( $this->state !== self::STATE_CHECKED ) {
return Status::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state );
} elseif ( $this->failed ) { // failed precheck
return Status::newFatal( 'fileop-fail-attempt-precheck' );
}
$this->state = self::STATE_ATTEMPTED;
$status = $this->doAttempt();
if ( !$status->isOK() ) {
$this->failed = true;
$this->logFailure( 'attempt' );
}
return $status;
}
/**
* Revert the operation; affected files are restored
*
* @return Status
*/
final public function revert() {
if ( $this->state !== self::STATE_ATTEMPTED ) {
return Status::newFatal( 'fileop-fail-state', self::STATE_ATTEMPTED, $this->state );
}
$this->state = self::STATE_DONE;
if ( $this->failed ) {
$status = Status::newGood(); // nothing to revert
} else {
$status = $this->doRevert();
if ( !$status->isOK() ) {
$this->logFailure( 'revert' );
}
}
return $status;
}
/**
* Finish the operation; this may be irreversible
*
* @return Status
*/
final public function finish() {
if ( $this->state !== self::STATE_ATTEMPTED ) {
return Status::newFatal( 'fileop-fail-state', self::STATE_ATTEMPTED, $this->state );
}
$this->state = self::STATE_DONE;
if ( $this->failed ) {
$status = Status::newGood(); // nothing to finish
} else {
$status = $this->doFinish();
}
return $status;
}
/**
* Get a list of storage paths read from for this operation
*
* @return Array
*/
public function storagePathsRead() {
return array();
}
/**
* Get a list of storage paths written to for this operation
*
* @return Array
*/
public function storagePathsChanged() {
return array();
}
/**
* @return Array List of allowed parameters
*/
protected function allowedParams() {
return array();
}
/**
* @return Status
*/
protected function doPrecheck( array &$predicates ) {
return Status::newGood();
}
/**
* @return Status
*/
abstract protected function doAttempt();
/**
* @return Status
*/
abstract protected function doRevert();
/**
* @return Status
*/
protected function doFinish() {
return Status::newGood();
}
/**
* Check if the destination file exists and update the
* destAlreadyExists member variable. A bad status will
* be returned if there is no chance it can be overwritten.
*
* @param $predicates Array
* @return Status
*/
protected function precheckDestExistence( array $predicates ) {
$status = Status::newGood();
if ( $this->fileExists( $this->params['dst'], $predicates ) ) {
$this->destAlreadyExists = true;
if ( !$this->getParam( 'overwriteDest' ) && !$this->getParam( 'overwriteSame' ) ) {
$status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
return $status;
}
} else {
$this->destAlreadyExists = false;
}
return $status;
}
/**
* Backup any file at the source to a temporary file
*
* @return Status
*/
protected function backupSource() {
$status = Status::newGood();
if ( $this->useBackups ) {
// Check if a file already exists at the source...
$params = array( 'src' => $this->params['src'], 'latest' => $this->useLatest );
if ( $this->backend->fileExists( $params ) ) {
// Create a temporary backup copy...
$this->tmpSourcePath = $this->backend->getLocalCopy( $params );
if ( $this->tmpSourcePath === null ) {
$status->fatal( 'backend-fail-backup', $this->params['src'] );
return $status;
}
}
}
return $status;
}
/**
* Backup the file at the destination to a temporary file.
* Don't bother backing it up unless we might overwrite the file.
* This assumes that the destination is in the backend and that
* the source is either in the backend or on the file system.
* This also handles the 'overwriteSame' check logic and updates
* the destSameAsSource member variable.
*
* @return Status
*/
protected function checkAndBackupDest() {
$status = Status::newGood();
$this->destSameAsSource = false;
if ( $this->getParam( 'overwriteDest' ) ) {
if ( $this->useBackups ) {
// Create a temporary backup copy...
$params = array( 'src' => $this->params['dst'], 'latest' => $this->useLatest );
$this->tmpDestFile = $this->backend->getLocalCopy( $params );
if ( !$this->tmpDestFile ) {
$status->fatal( 'backend-fail-backup', $this->params['dst'] );
return $status;
}
}
} elseif ( $this->getParam( 'overwriteSame' ) ) {
$shash = $this->getSourceSha1Base36();
// If there is a single source, then we can do some checks already.
// For things like concatenate(), we would need to build a temp file
// first and thus don't support 'overwriteSame' ($shash is null).
if ( $shash !== null ) {
$dhash = $this->getFileSha1Base36( $this->params['dst'] );
if ( !strlen( $shash ) || !strlen( $dhash ) ) {
$status->fatal( 'backend-fail-hashes' );
} elseif ( $shash !== $dhash ) {
// Give an error if the files are not identical
$status->fatal( 'backend-fail-notsame', $this->params['dst'] );
} else {
$this->destSameAsSource = true;
}
return $status; // do nothing; either OK or bad status
}
} else {
$status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
return $status;
}
return $status;
}
/**
* checkAndBackupDest() helper function to get the source file Sha1.
* Returns false on failure and null if there is no single source.
*
* @return string|false|null
*/
protected function getSourceSha1Base36() {
return null; // N/A
}
/**
* checkAndBackupDest() helper function to get the Sha1 of a file.
*
* @return string|false False on failure
*/
protected function getFileSha1Base36( $path ) {
// Source file is in backend
if ( FileBackend::isStoragePath( $path ) ) {
// For some backends (e.g. Swift, Azure) we can get
// standard hashes to use for this types of comparisons.
$params = array( 'src' => $path, 'latest' => $this->useLatest );
$hash = $this->backend->getFileSha1Base36( $params );
// Source file is on file system
} else {
wfSuppressWarnings();
$hash = sha1_file( $path );
wfRestoreWarnings();
if ( $hash !== false ) {
$hash = wfBaseConvert( $hash, 16, 36, 31 );
}
}
return $hash;
}
/**
* Restore any temporary source backup file
*
* @return Status
*/
protected function restoreSource() {
$status = Status::newGood();
// Restore any file that was at the destination
if ( $this->tmpSourcePath !== null ) {
$params = array(
'src' => $this->tmpSourcePath,
'dst' => $this->params['src'],
'overwriteDest' => true
);
$status = $this->backend->storeInternal( $params );
if ( !$status->isOK() ) {
return $status;
}
}
return $status;
}
/**
* Restore any temporary destination backup file
*
* @return Status
*/
protected function restoreDest() {
$status = Status::newGood();
// Restore any file that was at the destination
if ( $this->tmpDestFile ) {
$params = array(
'src' => $this->tmpDestFile->getPath(),
'dst' => $this->params['dst'],
'overwriteDest' => true
);
$status = $this->backend->storeInternal( $params );
if ( !$status->isOK() ) {
return $status;
}
}
return $status;
}
/**
* Check if a file will exist in storage when this operation is attempted
*
* @param $source string Storage path
* @param $predicates Array
* @return bool
*/
final protected function fileExists( $source, array $predicates ) {
if ( isset( $predicates['exists'][$source] ) ) {
return $predicates['exists'][$source]; // previous op assures this
} else {
$params = array( 'src' => $source, 'latest' => $this->useLatest );
return $this->backend->fileExists( $params );
}
}
/**
* Log a file operation failure and preserve any temp files
*
* @param $fileOp FileOp
* @return void
*/
final protected function logFailure( $action ) {
$params = $this->params;
$params['failedAction'] = $action;
// Preserve backup files just in case (for recovery)
if ( $this->tmpSourceFile ) {
$this->tmpSourceFile->preserve(); // don't purge
$params['srcBackupPath'] = $this->tmpSourceFile->getPath();
}
if ( $this->tmpDestFile ) {
$this->tmpDestFile->preserve(); // don't purge
$params['dstBackupPath'] = $this->tmpDestFile->getPath();
}
try {
wfDebugLog( 'FileOperation',
get_class( $this ) . ' failed:' . serialize( $params ) );
} catch ( Exception $e ) {
// bad config? debug log error?
}
}
}
/**
* Store a file into the backend from a file on the file system.
* Parameters similar to FileBackend::storeInternal(), which include:
* src : source path on file system
* dst : destination storage path
* overwriteDest : do nothing and pass if an identical file exists at destination
* overwriteSame : override any existing file at destination
*/
class StoreFileOp extends FileOp {
protected function allowedParams() {
return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
}
protected function doPrecheck( array &$predicates ) {
$status = Status::newGood();
// Check if destination file exists
$status->merge( $this->precheckDestExistence( $predicates ) );
if ( !$status->isOK() ) {
return $status;
}
// Check if the source file exists on the file system
if ( !is_file( $this->params['src'] ) ) {
$status->fatal( 'backend-fail-notexists', $this->params['src'] );
return $status;
}
// Update file existence predicates
$predicates['exists'][$this->params['dst']] = true;
return $status;
}
protected function doAttempt() {
$status = Status::newGood();
// Create a destination backup copy as needed
if ( $this->destAlreadyExists ) {
$status->merge( $this->checkAndBackupDest() );
if ( !$status->isOK() ) {
return $status;
}
}
// Store the file at the destination
if ( !$this->destSameAsSource ) {
$status->merge( $this->backend->storeInternal( $this->params ) );
}
return $status;
}
protected function doRevert() {
$status = Status::newGood();
if ( !$this->destSameAsSource ) {
// Restore any file that was at the destination,
// overwritting what was put there in attempt()
$status->merge( $this->restoreDest() );
}
return $status;
}
protected function getSourceSha1Base36() {
return $this->getFileSha1Base36( $this->params['src'] );
}
public function storagePathsChanged() {
return array( $this->params['dst'] );
}
}
/**
* Create a file in the backend with the given content.
* Parameters similar to FileBackend::create(), which include:
* content : a string of raw file contents
* dst : destination storage path
* overwriteDest : do nothing and pass if an identical file exists at destination
* overwriteSame : override any existing file at destination
*/
class CreateFileOp extends FileOp {
protected function allowedParams() {
return array( 'content', 'dst', 'overwriteDest', 'overwriteSame' );
}
protected function doPrecheck( array &$predicates ) {
$status = Status::newGood();
// Check if destination file exists
$status->merge( $this->precheckDestExistence( $predicates ) );
if ( !$status->isOK() ) {
return $status;
}
// Update file existence predicates
$predicates['exists'][$this->params['dst']] = true;
return $status;
}
protected function doAttempt() {
$status = Status::newGood();
// Create a destination backup copy as needed
if ( $this->destAlreadyExists ) {
$status->merge( $this->checkAndBackupDest() );
if ( !$status->isOK() ) {
return $status;
}
}
// Create the file at the destination
if ( !$this->destSameAsSource ) {
$status->merge( $this->backend->createInternal( $this->params ) );
}
return $status;
}
protected function doRevert() {
$status = Status::newGood();
if ( !$this->destSameAsSource ) {
// Restore any file that was at the destination,
// overwritting what was put there in attempt()
$status->merge( $this->restoreDest() );
}
return $status;
}
protected function getSourceSha1Base36() {
return wfBaseConvert( sha1( $this->params['content'] ), 16, 36, 31 );
}
public function storagePathsChanged() {
return array( $this->params['dst'] );
}
}
/**
* Copy a file from one storage path to another in the backend.
* Parameters similar to FileBackend::copy(), which include:
* src : source storage path
* dst : destination storage path
* overwriteDest : do nothing and pass if an identical file exists at destination
* overwriteSame : override any existing file at destination
*/
class CopyFileOp extends FileOp {
protected function allowedParams() {
return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
}
protected function doPrecheck( array &$predicates ) {
$status = Status::newGood();
// Check if destination file exists
$status->merge( $this->precheckDestExistence( $predicates ) );
if ( !$status->isOK() ) {
return $status;
}
// Check if the source file exists
if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
$status->fatal( 'backend-fail-notexists', $this->params['src'] );
return $status;
}
// Update file existence predicates
$predicates['exists'][$this->params['dst']] = true;
return $status;
}
protected function doAttempt() {
$status = Status::newGood();
// Create a destination backup copy as needed
if ( $this->destAlreadyExists ) {
$status->merge( $this->checkAndBackupDest() );
if ( !$status->isOK() ) {
return $status;
}
}
// Copy the file into the destination
if ( !$this->destSameAsSource ) {
$status->merge( $this->backend->copyInternal( $this->params ) );
}
return $status;
}
protected function doRevert() {
$status = Status::newGood();
if ( !$this->destSameAsSource ) {
// Restore any file that was at the destination,
// overwritting what was put there in attempt()
$status->merge( $this->restoreDest() );
}
return $status;
}
protected function getSourceSha1Base36() {
return $this->getFileSha1Base36( $this->params['src'] );
}
public function storagePathsRead() {
return array( $this->params['src'] );
}
public function storagePathsChanged() {
return array( $this->params['dst'] );
}
}
/**
* Move a file from one storage path to another in the backend.
* Parameters similar to FileBackend::move(), which include:
* src : source storage path
* dst : destination storage path
* overwriteDest : do nothing and pass if an identical file exists at destination
* overwriteSame : override any existing file at destination
*/
class MoveFileOp extends FileOp {
protected function allowedParams() {
return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
}
protected function doPrecheck( array &$predicates ) {
$status = Status::newGood();
// Check if destination file exists
$status->merge( $this->precheckDestExistence( $predicates ) );
if ( !$status->isOK() ) {
return $status;
}
// Check if the source file exists
if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
$status->fatal( 'backend-fail-notexists', $this->params['src'] );
return $status;
}
// Update file existence predicates
$predicates['exists'][$this->params['src']] = false;
$predicates['exists'][$this->params['dst']] = true;
return $status;
}
protected function doAttempt() {
$status = Status::newGood();
// Create a destination backup copy as needed
if ( $this->destAlreadyExists ) {
$status->merge( $this->checkAndBackupDest() );
if ( !$status->isOK() ) {
return $status;
}
}
if ( !$this->destSameAsSource ) {
// Move the file into the destination
$status->merge( $this->backend->moveInternal( $this->params ) );
} else {
// Create a source backup copy as needed
$status->merge( $this->backupSource() );
if ( !$status->isOK() ) {
return $status;
}
// Just delete source as the destination needs no changes
$params = array( 'src' => $this->params['src'] );
$status->merge( $this->backend->deleteInternal( $params ) );
if ( !$status->isOK() ) {
return $status;
}
}
return $status;
}
protected function doRevert() {
$status = Status::newGood();
if ( !$this->destSameAsSource ) {
// Move the file back to the source
$params = array(
'src' => $this->params['dst'],
'dst' => $this->params['src']
);
$status->merge( $this->backend->moveInternal( $params ) );
if ( !$status->isOK() ) {
return $status; // also can't restore any dest file
}
// Restore any file that was at the destination
$status->merge( $this->restoreDest() );
} else {
// Restore any source file
return $this->restoreSource();
}
return $status;
}
protected function getSourceSha1Base36() {
return $this->getFileSha1Base36( $this->params['src'] );
}
public function storagePathsRead() {
return array( $this->params['src'] );
}
public function storagePathsChanged() {
return array( $this->params['dst'] );
}
}
/**
* Delete a file at the storage path.
* Parameters similar to FileBackend::delete(), which include:
* src : source storage path
* ignoreMissingSource : don't return an error if the file does not exist
*/
class DeleteFileOp extends FileOp {
protected $needsDelete = true;
protected function allowedParams() {
return array( 'src', 'ignoreMissingSource' );
}
protected function doPrecheck( array &$predicates ) {
$status = Status::newGood();
// Check if the source file exists
if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
if ( !$this->getParam( 'ignoreMissingSource' ) ) {
$status->fatal( 'backend-fail-notexists', $this->params['src'] );
return $status;
}
$this->needsDelete = false;
}
// Update file existence predicates
$predicates['exists'][$this->params['src']] = false;
return $status;
}
protected function doAttempt() {
$status = Status::newGood();
if ( $this->needsDelete ) {
// Create a source backup copy as needed
$status->merge( $this->backupSource() );
if ( !$status->isOK() ) {
return $status;
}
// Delete the source file
$status->merge( $this->backend->deleteInternal( $this->params ) );
if ( !$status->isOK() ) {
return $status;
}
}
return $status;
}
protected function doRevert() {
// Restore any source file that we deleted
return $this->restoreSource();
}
public function storagePathsChanged() {
return array( $this->params['src'] );
}
}
/**
* Placeholder operation that has no params and does nothing
*/
class NullFileOp extends FileOp {
protected function doAttempt() {
return Status::newGood();
}
protected function doRevert() {
return Status::newGood();
}
}