MediaWiki  REL1_19
FileOp.php
Go to the documentation of this file.
00001 <?php
00018 abstract class FileOp {
00020         protected $params = array();
00022         protected $backend;
00023 
00024         protected $state = self::STATE_NEW; // integer
00025         protected $failed = false; // boolean
00026         protected $useLatest = true; // boolean
00027 
00028         protected $sourceSha1; // string
00029         protected $destSameAsSource; // boolean
00030 
00031         /* Object life-cycle */
00032         const STATE_NEW = 1;
00033         const STATE_CHECKED = 2;
00034         const STATE_ATTEMPTED = 3;
00035 
00036         /* Timeout related parameters */
00037         const MAX_BATCH_SIZE = 1000;
00038         const TIME_LIMIT_SEC = 300; // 5 minutes
00039 
00047         final public function __construct( FileBackendStore $backend, array $params ) {
00048                 $this->backend = $backend;
00049                 list( $required, $optional ) = $this->allowedParams();
00050                 foreach ( $required as $name ) {
00051                         if ( isset( $params[$name] ) ) {
00052                                 $this->params[$name] = $params[$name];
00053                         } else {
00054                                 throw new MWException( "File operation missing parameter '$name'." );
00055                         }
00056                 }
00057                 foreach ( $optional as $name ) {
00058                         if ( isset( $params[$name] ) ) {
00059                                 $this->params[$name] = $params[$name];
00060                         }
00061                 }
00062                 $this->params = $params;
00063         }
00064 
00070         final protected function allowStaleReads() {
00071                 $this->useLatest = false;
00072         }
00073 
00093         final public static function attemptBatch( array $performOps, array $opts ) {
00094                 $status = Status::newGood();
00095 
00096                 $allowStale = !empty( $opts['allowStale'] );
00097                 $ignoreErrors = !empty( $opts['force'] );
00098 
00099                 $n = count( $performOps );
00100                 if ( $n > self::MAX_BATCH_SIZE ) {
00101                         $status->fatal( 'backend-fail-batchsize', $n, self::MAX_BATCH_SIZE );
00102                         return $status;
00103                 }
00104 
00105                 $predicates = FileOp::newPredicates(); // account for previous op in prechecks
00106                 // Do pre-checks for each operation; abort on failure...
00107                 foreach ( $performOps as $index => $fileOp ) {
00108                         if ( $allowStale ) {
00109                                 $fileOp->allowStaleReads(); // allow potentially stale reads
00110                         }
00111                         $subStatus = $fileOp->precheck( $predicates );
00112                         $status->merge( $subStatus );
00113                         if ( !$subStatus->isOK() ) { // operation failed?
00114                                 $status->success[$index] = false;
00115                                 ++$status->failCount;
00116                                 if ( !$ignoreErrors ) {
00117                                         return $status; // abort
00118                                 }
00119                         }
00120                 }
00121 
00122                 if ( $ignoreErrors ) {
00123                         # Treat all precheck() fatals as merely warnings
00124                         $status->setResult( true, $status->value );
00125                 }
00126 
00127                 // Restart PHP's execution timer and set the timeout to safe amount.
00128                 // This handles cases where the operations take a long time or where we are
00129                 // already running low on time left. The old timeout is restored afterwards.
00130                 # @TODO: re-enable this for when the number of batches is high.
00131                 #$scopedTimeLimit = new FileOpScopedPHPTimeout( self::TIME_LIMIT_SEC );
00132 
00133                 // Attempt each operation...
00134                 foreach ( $performOps as $index => $fileOp ) {
00135                         if ( $fileOp->failed() ) {
00136                                 continue; // nothing to do
00137                         }
00138                         $subStatus = $fileOp->attempt();
00139                         $status->merge( $subStatus );
00140                         if ( $subStatus->isOK() ) {
00141                                 $status->success[$index] = true;
00142                                 ++$status->successCount;
00143                         } else {
00144                                 $status->success[$index] = false;
00145                                 ++$status->failCount;
00146                                 // We can't continue (even with $ignoreErrors) as $predicates is wrong.
00147                                 // Log the remaining ops as failed for recovery...
00148                                 for ( $i = ($index + 1); $i < count( $performOps ); $i++ ) {
00149                                         $performOps[$i]->logFailure( 'attempt_aborted' );
00150                                 }
00151                                 return $status; // bail out
00152                         }
00153                 }
00154 
00155                 return $status;
00156         }
00157 
00164         final public function getParam( $name ) {
00165                 return isset( $this->params[$name] ) ? $this->params[$name] : null;
00166         }
00167 
00173         final public function failed() {
00174                 return $this->failed;
00175         }
00176 
00182         final public static function newPredicates() {
00183                 return array( 'exists' => array(), 'sha1' => array() );
00184         }
00185 
00192         final public function precheck( array &$predicates ) {
00193                 if ( $this->state !== self::STATE_NEW ) {
00194                         return Status::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state );
00195                 }
00196                 $this->state = self::STATE_CHECKED;
00197                 $status = $this->doPrecheck( $predicates );
00198                 if ( !$status->isOK() ) {
00199                         $this->failed = true;
00200                 }
00201                 return $status;
00202         }
00203 
00209         final public function attempt() {
00210                 if ( $this->state !== self::STATE_CHECKED ) {
00211                         return Status::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state );
00212                 } elseif ( $this->failed ) { // failed precheck
00213                         return Status::newFatal( 'fileop-fail-attempt-precheck' );
00214                 }
00215                 $this->state = self::STATE_ATTEMPTED;
00216                 $status = $this->doAttempt();
00217                 if ( !$status->isOK() ) {
00218                         $this->failed = true;
00219                         $this->logFailure( 'attempt' );
00220                 }
00221                 return $status;
00222         }
00223 
00229         protected function allowedParams() {
00230                 return array( array(), array() );
00231         }
00232 
00238         public function storagePathsRead() {
00239                 return array();
00240         }
00241 
00247         public function storagePathsChanged() {
00248                 return array();
00249         }
00250 
00254         protected function doPrecheck( array &$predicates ) {
00255                 return Status::newGood();
00256         }
00257 
00261         protected function doAttempt() {
00262                 return Status::newGood();
00263         }
00264 
00273         protected function precheckDestExistence( array $predicates ) {
00274                 $status = Status::newGood();
00275                 // Get hash of source file/string and the destination file
00276                 $this->sourceSha1 = $this->getSourceSha1Base36(); // FS file or data string
00277                 if ( $this->sourceSha1 === null ) { // file in storage?
00278                         $this->sourceSha1 = $this->fileSha1( $this->params['src'], $predicates );
00279                 }
00280                 $this->destSameAsSource = false;
00281                 if ( $this->fileExists( $this->params['dst'], $predicates ) ) {
00282                         if ( $this->getParam( 'overwrite' ) ) {
00283                                 return $status; // OK
00284                         } elseif ( $this->getParam( 'overwriteSame' ) ) {
00285                                 $dhash = $this->fileSha1( $this->params['dst'], $predicates );
00286                                 // Check if hashes are valid and match each other...
00287                                 if ( !strlen( $this->sourceSha1 ) || !strlen( $dhash ) ) {
00288                                         $status->fatal( 'backend-fail-hashes' );
00289                                 } elseif ( $this->sourceSha1 !== $dhash ) {
00290                                         // Give an error if the files are not identical
00291                                         $status->fatal( 'backend-fail-notsame', $this->params['dst'] );
00292                                 } else {
00293                                         $this->destSameAsSource = true; // OK
00294                                 }
00295                                 return $status; // do nothing; either OK or bad status
00296                         } else {
00297                                 $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
00298                                 return $status;
00299                         }
00300                 }
00301                 return $status;
00302         }
00303 
00310         protected function getSourceSha1Base36() {
00311                 return null; // N/A
00312         }
00313 
00321         final protected function fileExists( $source, array $predicates ) {
00322                 if ( isset( $predicates['exists'][$source] ) ) {
00323                         return $predicates['exists'][$source]; // previous op assures this
00324                 } else {
00325                         $params = array( 'src' => $source, 'latest' => $this->useLatest );
00326                         return $this->backend->fileExists( $params );
00327                 }
00328         }
00329 
00337         final protected function fileSha1( $source, array $predicates ) {
00338                 if ( isset( $predicates['sha1'][$source] ) ) {
00339                         return $predicates['sha1'][$source]; // previous op assures this
00340                 } else {
00341                         $params = array( 'src' => $source, 'latest' => $this->useLatest );
00342                         return $this->backend->getFileSha1Base36( $params );
00343                 }
00344         }
00345 
00352         final protected function logFailure( $action ) {
00353                 $params = $this->params;
00354                 $params['failedAction'] = $action;
00355                 try {
00356                         wfDebugLog( 'FileOperation',
00357                                 get_class( $this ) . ' failed:' . serialize( $params ) );
00358                 } catch ( Exception $e ) {
00359                         // bad config? debug log error?
00360                 }
00361         }
00362 }
00363 
00370 class FileOpScopedPHPTimeout {
00371         protected $startTime; // float; seconds
00372         protected $oldTimeout; // integer; seconds
00373 
00374         protected static $stackDepth = 0; // integer
00375         protected static $totalCalls = 0; // integer
00376         protected static $totalElapsed = 0; // float; seconds
00377 
00378         /* Prevent callers in infinite loops from running forever */
00379         const MAX_TOTAL_CALLS = 1000000;
00380         const MAX_TOTAL_TIME = 300; // seconds
00381 
00385         public function __construct( $seconds ) {
00386                 if ( ini_get( 'max_execution_time' ) > 0 ) { // CLI uses 0
00387                         if ( self::$totalCalls >= self::MAX_TOTAL_CALLS ) {
00388                                 trigger_error( "Maximum invocations of " . __CLASS__ . " exceeded." );
00389                         } elseif ( self::$totalElapsed >= self::MAX_TOTAL_TIME ) {
00390                                 trigger_error( "Time limit within invocations of " . __CLASS__ . " exceeded." );
00391                         } elseif ( self::$stackDepth > 0 ) { // recursion guard
00392                                 trigger_error( "Resursive invocation of " . __CLASS__ . " attempted." );
00393                         } else {
00394                                 $this->oldTimeout = ini_set( 'max_execution_time', $seconds );
00395                                 $this->startTime = microtime( true );
00396                                 ++self::$stackDepth;
00397                                 ++self::$totalCalls; // proof against < 1us scopes
00398                         }
00399                 }
00400         }
00401 
00406         public function __destruct() {
00407                 if ( $this->oldTimeout ) {
00408                         $elapsed = microtime( true ) - $this->startTime;
00409                         // Note: a limit of 0 is treated as "forever"
00410                         set_time_limit( max( 1, $this->oldTimeout - (int)$elapsed ) );
00411                         // If each scoped timeout is for less than one second, we end up
00412                         // restoring the original timeout without any decrease in value.
00413                         // Thus web scripts in an infinite loop can run forever unless we 
00414                         // take some measures to prevent this. Track total time and calls.
00415                         self::$totalElapsed += $elapsed;
00416                         --self::$stackDepth;
00417                 }
00418         }
00419 }
00420 
00429 class StoreFileOp extends FileOp {
00430         protected function allowedParams() {
00431                 return array( array( 'src', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
00432         }
00433 
00434         protected function doPrecheck( array &$predicates ) {
00435                 $status = Status::newGood();
00436                 // Check if the source file exists on the file system
00437                 if ( !is_file( $this->params['src'] ) ) {
00438                         $status->fatal( 'backend-fail-notexists', $this->params['src'] );
00439                         return $status;
00440                 // Check if the source file is too big
00441                 } elseif ( filesize( $this->params['src'] ) > $this->backend->maxFileSizeInternal() ) {
00442                         $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] );
00443                         return $status;
00444                 // Check if a file can be placed at the destination
00445                 } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
00446                         $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] );
00447                         return $status;
00448                 }
00449                 // Check if destination file exists
00450                 $status->merge( $this->precheckDestExistence( $predicates ) );
00451                 if ( $status->isOK() ) {
00452                         // Update file existence predicates
00453                         $predicates['exists'][$this->params['dst']] = true;
00454                         $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
00455                 }
00456                 return $status; // safe to call attempt()
00457         }
00458 
00459         protected function doAttempt() {
00460                 $status = Status::newGood();
00461                 // Store the file at the destination
00462                 if ( !$this->destSameAsSource ) {
00463                         $status->merge( $this->backend->storeInternal( $this->params ) );
00464                 }
00465                 return $status;
00466         }
00467 
00468         protected function getSourceSha1Base36() {
00469                 wfSuppressWarnings();
00470                 $hash = sha1_file( $this->params['src'] );
00471                 wfRestoreWarnings();
00472                 if ( $hash !== false ) {
00473                         $hash = wfBaseConvert( $hash, 16, 36, 31 );
00474                 }
00475                 return $hash;
00476         }
00477 
00478         public function storagePathsChanged() {
00479                 return array( $this->params['dst'] );
00480         }
00481 }
00482 
00491 class CreateFileOp extends FileOp {
00492         protected function allowedParams() {
00493                 return array( array( 'content', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
00494         }
00495 
00496         protected function doPrecheck( array &$predicates ) {
00497                 $status = Status::newGood();
00498                 // Check if the source data is too big
00499                 if ( strlen( $this->getParam( 'content' ) ) > $this->backend->maxFileSizeInternal() ) {
00500                         $status->fatal( 'backend-fail-create', $this->params['dst'] );
00501                         return $status;
00502                 // Check if a file can be placed at the destination
00503                 } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
00504                         $status->fatal( 'backend-fail-create', $this->params['dst'] );
00505                         return $status;
00506                 }
00507                 // Check if destination file exists
00508                 $status->merge( $this->precheckDestExistence( $predicates ) );
00509                 if ( $status->isOK() ) {
00510                         // Update file existence predicates
00511                         $predicates['exists'][$this->params['dst']] = true;
00512                         $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
00513                 }
00514                 return $status; // safe to call attempt()
00515         }
00516 
00517         protected function doAttempt() {
00518                 $status = Status::newGood();
00519                 // Create the file at the destination
00520                 if ( !$this->destSameAsSource ) {
00521                         $status->merge( $this->backend->createInternal( $this->params ) );
00522                 }
00523                 return $status;
00524         }
00525 
00526         protected function getSourceSha1Base36() {
00527                 return wfBaseConvert( sha1( $this->params['content'] ), 16, 36, 31 );
00528         }
00529 
00530         public function storagePathsChanged() {
00531                 return array( $this->params['dst'] );
00532         }
00533 }
00534 
00543 class CopyFileOp extends FileOp {
00544         protected function allowedParams() {
00545                 return array( array( 'src', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
00546         }
00547 
00548         protected function doPrecheck( array &$predicates ) {
00549                 $status = Status::newGood();
00550                 // Check if the source file exists
00551                 if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
00552                         $status->fatal( 'backend-fail-notexists', $this->params['src'] );
00553                         return $status;
00554                 // Check if a file can be placed at the destination
00555                 } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
00556                         $status->fatal( 'backend-fail-copy', $this->params['src'], $this->params['dst'] );
00557                         return $status;
00558                 }
00559                 // Check if destination file exists
00560                 $status->merge( $this->precheckDestExistence( $predicates ) );
00561                 if ( $status->isOK() ) {
00562                         // Update file existence predicates
00563                         $predicates['exists'][$this->params['dst']] = true;
00564                         $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
00565                 }
00566                 return $status; // safe to call attempt()
00567         }
00568 
00569         protected function doAttempt() {
00570                 $status = Status::newGood();
00571                 // Do nothing if the src/dst paths are the same
00572                 if ( $this->params['src'] !== $this->params['dst'] ) {
00573                         // Copy the file into the destination
00574                         if ( !$this->destSameAsSource ) {
00575                                 $status->merge( $this->backend->copyInternal( $this->params ) );
00576                         }
00577                 }
00578                 return $status;
00579         }
00580 
00581         public function storagePathsRead() {
00582                 return array( $this->params['src'] );
00583         }
00584 
00585         public function storagePathsChanged() {
00586                 return array( $this->params['dst'] );
00587         }
00588 }
00589 
00598 class MoveFileOp extends FileOp {
00599         protected function allowedParams() {
00600                 return array( array( 'src', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
00601         }
00602 
00603         protected function doPrecheck( array &$predicates ) {
00604                 $status = Status::newGood();
00605                 // Check if the source file exists
00606                 if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
00607                         $status->fatal( 'backend-fail-notexists', $this->params['src'] );
00608                         return $status;
00609                 // Check if a file can be placed at the destination
00610                 } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
00611                         $status->fatal( 'backend-fail-move', $this->params['src'], $this->params['dst'] );
00612                         return $status;
00613                 }
00614                 // Check if destination file exists
00615                 $status->merge( $this->precheckDestExistence( $predicates ) );
00616                 if ( $status->isOK() ) {
00617                         // Update file existence predicates
00618                         $predicates['exists'][$this->params['src']] = false;
00619                         $predicates['sha1'][$this->params['src']] = false;
00620                         $predicates['exists'][$this->params['dst']] = true;
00621                         $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
00622                 }
00623                 return $status; // safe to call attempt()
00624         }
00625 
00626         protected function doAttempt() {
00627                 $status = Status::newGood();
00628                 // Do nothing if the src/dst paths are the same
00629                 if ( $this->params['src'] !== $this->params['dst'] ) {
00630                         if ( !$this->destSameAsSource ) {
00631                                 // Move the file into the destination
00632                                 $status->merge( $this->backend->moveInternal( $this->params ) );
00633                         } else {
00634                                 // Just delete source as the destination needs no changes
00635                                 $params = array( 'src' => $this->params['src'] );
00636                                 $status->merge( $this->backend->deleteInternal( $params ) );
00637                         }
00638                 }
00639                 return $status;
00640         }
00641 
00642         public function storagePathsRead() {
00643                 return array( $this->params['src'] );
00644         }
00645 
00646         public function storagePathsChanged() {
00647                 return array( $this->params['dst'] );
00648         }
00649 }
00650 
00657 class DeleteFileOp extends FileOp {
00658         protected function allowedParams() {
00659                 return array( array( 'src' ), array( 'ignoreMissingSource' ) );
00660         }
00661 
00662         protected $needsDelete = true;
00663 
00664         protected function doPrecheck( array &$predicates ) {
00665                 $status = Status::newGood();
00666                 // Check if the source file exists
00667                 if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
00668                         if ( !$this->getParam( 'ignoreMissingSource' ) ) {
00669                                 $status->fatal( 'backend-fail-notexists', $this->params['src'] );
00670                                 return $status;
00671                         }
00672                         $this->needsDelete = false;
00673                 }
00674                 // Update file existence predicates
00675                 $predicates['exists'][$this->params['src']] = false;
00676                 $predicates['sha1'][$this->params['src']] = false;
00677                 return $status; // safe to call attempt()
00678         }
00679 
00680         protected function doAttempt() {
00681                 $status = Status::newGood();
00682                 if ( $this->needsDelete ) {
00683                         // Delete the source file
00684                         $status->merge( $this->backend->deleteInternal( $this->params ) );
00685                 }
00686                 return $status;
00687         }
00688 
00689         public function storagePathsChanged() {
00690                 return array( $this->params['src'] );
00691         }
00692 }
00693 
00697 class NullFileOp extends FileOp {}