9 * Helper class for representing operations with transaction support.
10 * FileBackend::doOperations() will require these classes for supported operations.
11 * Do not use this class from places outside FileBackend.
13 * Use of large fields should be avoided as we want to support
14 * potentially many FileOp classes in large arrays in memory.
16 * @ingroup FileBackend
19 abstract class FileOp
{
21 protected $params = array();
22 /** $var FileBackendBase */
24 /** @var TempFSFile|null */
25 protected $tmpSourceFile, $tmpDestFile;
27 protected $state = self
::STATE_NEW
; // integer
28 protected $failed = false; // boolean
29 protected $useBackups = true; // boolean
30 protected $useLatest = true; // boolean
31 protected $destSameAsSource = false; // boolean
32 protected $destAlreadyExists = false; // boolean
34 /* Object life-cycle */
36 const STATE_CHECKED
= 2;
37 const STATE_ATTEMPTED
= 3;
41 * Build a new file operation transaction
43 * @params $backend FileBackend
44 * @params $params Array
46 final public function __construct( FileBackendBase
$backend, array $params ) {
47 $this->backend
= $backend;
48 foreach ( $this->allowedParams() as $name ) {
49 if ( isset( $params[$name] ) ) {
50 $this->params
[$name] = $params[$name];
53 $this->params
= $params;
57 * Disable file backups for this operation
61 final protected function disableBackups() {
62 $this->useBackups
= false;
66 * Allow stale data for file reads and existence checks.
67 * If this is called, then disableBackups() should also be called
68 * unless the affected files are known to have not changed recently.
72 final protected function allowStaleReads() {
73 $this->useLatest
= false;
77 * Attempt a series of file operations.
78 * Callers are responsible for handling file locking.
80 * $opts is an array of options, including:
81 * 'force' : Errors that would normally cause a rollback do not.
82 * The remaining operations are still attempted if any fail.
83 * 'allowStale' : Don't require the latest available data.
84 * This can increase performance for non-critical writes.
85 * This has no effect unless the 'force' flag is set.
87 * @param $performOps Array List of FileOp operations
88 * @param $opts Array Batch operation options
91 final public static function attemptBatch( array $performOps, array $opts ) {
92 $status = Status
::newGood();
94 $allowStale = isset( $opts['allowStale'] ) && $opts['allowStale'];
95 $ignoreErrors = isset( $opts['force'] ) && $opts['force'];
96 $predicates = FileOp
::newPredicates(); // account for previous op in prechecks
97 // Do pre-checks for each operation; abort on failure...
98 foreach ( $performOps as $index => $fileOp ) {
100 $fileOp->allowStaleReads(); // allow potentially stale reads
102 $status->merge( $fileOp->precheck( $predicates ) );
103 if ( !$status->isOK() ) { // operation failed?
104 if ( $ignoreErrors ) {
105 ++
$status->failCount
;
106 $status->success
[$index] = false;
113 // Attempt each operation; abort on failure...
114 foreach ( $performOps as $index => $fileOp ) {
115 if ( $fileOp->failed() ) {
116 continue; // nothing to do
117 } elseif ( $ignoreErrors ) {
118 $fileOp->disableBackups(); // no chance of revert() calls
120 $status->merge( $fileOp->attempt() );
121 if ( !$status->isOK() ) { // operation failed?
122 if ( $ignoreErrors ) {
123 ++
$status->failCount
;
124 $status->success
[$index] = false;
126 // Revert everything done so far and abort.
127 // Do this by reverting each previous operation in reverse order.
128 $pos = $index - 1; // last one failed; no need to revert()
129 while ( $pos >= 0 ) {
130 if ( !$performOps[$pos]->failed() ) {
131 $status->merge( $performOps[$pos]->revert() );
140 $wasOk = $status->isOK();
141 // Finish each operation...
142 foreach ( $performOps as $index => $fileOp ) {
143 if ( $fileOp->failed() ) {
144 continue; // nothing to do
146 $subStatus = $fileOp->finish();
147 if ( $subStatus->isOK() ) {
148 ++
$status->successCount
;
149 $status->success
[$index] = true;
151 ++
$status->failCount
;
152 $status->success
[$index] = false;
154 $status->merge( $subStatus );
157 // Make sure status is OK, despite any finish() fatals
158 $status->setResult( $wasOk, $status->value
);
164 * Get the value of the parameter with the given name.
165 * Returns null if the parameter is not set.
167 * @param $name string
170 final public function getParam( $name ) {
171 return isset( $this->params
[$name] ) ?
$this->params
[$name] : null;
175 * Check if this operation failed precheck() or attempt()
178 final public function failed() {
179 return $this->failed
;
183 * Get a new empty predicates array for precheck()
187 final public static function newPredicates() {
188 return array( 'exists' => array() );
192 * Check preconditions of the operation without writing anything
194 * @param $predicates Array
197 final public function precheck( array &$predicates ) {
198 if ( $this->state
!== self
::STATE_NEW
) {
199 return Status
::newFatal( 'fileop-fail-state', self
::STATE_NEW
, $this->state
);
201 $this->state
= self
::STATE_CHECKED
;
202 $status = $this->doPrecheck( $predicates );
203 if ( !$status->isOK() ) {
204 $this->failed
= true;
210 * Attempt the operation, backing up files as needed; this must be reversible
214 final public function attempt() {
215 if ( $this->state
!== self
::STATE_CHECKED
) {
216 return Status
::newFatal( 'fileop-fail-state', self
::STATE_CHECKED
, $this->state
);
217 } elseif ( $this->failed
) { // failed precheck
218 return Status
::newFatal( 'fileop-fail-attempt-precheck' );
220 $this->state
= self
::STATE_ATTEMPTED
;
221 $status = $this->doAttempt();
222 if ( !$status->isOK() ) {
223 $this->failed
= true;
224 $this->logFailure( 'attempt' );
230 * Revert the operation; affected files are restored
234 final public function revert() {
235 if ( $this->state
!== self
::STATE_ATTEMPTED
) {
236 return Status
::newFatal( 'fileop-fail-state', self
::STATE_ATTEMPTED
, $this->state
);
238 $this->state
= self
::STATE_DONE
;
239 if ( $this->failed
) {
240 $status = Status
::newGood(); // nothing to revert
242 $status = $this->doRevert();
243 if ( !$status->isOK() ) {
244 $this->logFailure( 'revert' );
251 * Finish the operation; this may be irreversible
255 final public function finish() {
256 if ( $this->state
!== self
::STATE_ATTEMPTED
) {
257 return Status
::newFatal( 'fileop-fail-state', self
::STATE_ATTEMPTED
, $this->state
);
259 $this->state
= self
::STATE_DONE
;
260 if ( $this->failed
) {
261 $status = Status
::newGood(); // nothing to finish
263 $status = $this->doFinish();
269 * Get a list of storage paths read from for this operation
273 public function storagePathsRead() {
278 * Get a list of storage paths written to for this operation
282 public function storagePathsChanged() {
287 * @return Array List of allowed parameters
289 protected function allowedParams() {
296 protected function doPrecheck( array &$predicates ) {
297 return Status
::newGood();
303 abstract protected function doAttempt();
308 abstract protected function doRevert();
313 protected function doFinish() {
314 return Status
::newGood();
318 * Check if the destination file exists and update the
319 * destAlreadyExists member variable. A bad status will
320 * be returned if there is no chance it can be overwritten.
322 * @param $predicates Array
325 protected function precheckDestExistence( array $predicates ) {
326 $status = Status
::newGood();
327 if ( $this->fileExists( $this->params
['dst'], $predicates ) ) {
328 $this->destAlreadyExists
= true;
329 if ( !$this->getParam( 'overwriteDest' ) && !$this->getParam( 'overwriteSame' ) ) {
330 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
334 $this->destAlreadyExists
= false;
340 * Backup any file at the source to a temporary file
344 protected function backupSource() {
345 $status = Status
::newGood();
346 if ( $this->useBackups
) {
347 // Check if a file already exists at the source...
348 $params = array( 'src' => $this->params
['src'], 'latest' => $this->useLatest
);
349 if ( $this->backend
->fileExists( $params ) ) {
350 // Create a temporary backup copy...
351 $this->tmpSourcePath
= $this->backend
->getLocalCopy( $params );
352 if ( $this->tmpSourcePath
=== null ) {
353 $status->fatal( 'backend-fail-backup', $this->params
['src'] );
362 * Backup the file at the destination to a temporary file.
363 * Don't bother backing it up unless we might overwrite the file.
364 * This assumes that the destination is in the backend and that
365 * the source is either in the backend or on the file system.
366 * This also handles the 'overwriteSame' check logic and updates
367 * the destSameAsSource member variable.
371 protected function checkAndBackupDest() {
372 $status = Status
::newGood();
373 $this->destSameAsSource
= false;
375 if ( $this->getParam( 'overwriteDest' ) ) {
376 if ( $this->useBackups
) {
377 // Create a temporary backup copy...
378 $params = array( 'src' => $this->params
['dst'], 'latest' => $this->useLatest
);
379 $this->tmpDestFile
= $this->backend
->getLocalCopy( $params );
380 if ( !$this->tmpDestFile
) {
381 $status->fatal( 'backend-fail-backup', $this->params
['dst'] );
385 } elseif ( $this->getParam( 'overwriteSame' ) ) {
386 $shash = $this->getSourceSha1Base36();
387 // If there is a single source, then we can do some checks already.
388 // For things like concatenate(), we would need to build a temp file
389 // first and thus don't support 'overwriteSame' ($shash is null).
390 if ( $shash !== null ) {
391 $dhash = $this->getFileSha1Base36( $this->params
['dst'] );
392 if ( !strlen( $shash ) ||
!strlen( $dhash ) ) {
393 $status->fatal( 'backend-fail-hashes' );
394 } elseif ( $shash !== $dhash ) {
395 // Give an error if the files are not identical
396 $status->fatal( 'backend-fail-notsame', $this->params
['dst'] );
398 $this->destSameAsSource
= true;
400 return $status; // do nothing; either OK or bad status
403 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
411 * checkAndBackupDest() helper function to get the source file Sha1.
412 * Returns false on failure and null if there is no single source.
414 * @return string|false|null
416 protected function getSourceSha1Base36() {
421 * checkAndBackupDest() helper function to get the Sha1 of a file.
423 * @return string|false False on failure
425 protected function getFileSha1Base36( $path ) {
426 // Source file is in backend
427 if ( FileBackend
::isStoragePath( $path ) ) {
428 // For some backends (e.g. Swift, Azure) we can get
429 // standard hashes to use for this types of comparisons.
430 $params = array( 'src' => $path, 'latest' => $this->useLatest
);
431 $hash = $this->backend
->getFileSha1Base36( $params );
432 // Source file is on file system
434 wfSuppressWarnings();
435 $hash = sha1_file( $path );
437 if ( $hash !== false ) {
438 $hash = wfBaseConvert( $hash, 16, 36, 31 );
445 * Restore any temporary source backup file
449 protected function restoreSource() {
450 $status = Status
::newGood();
451 // Restore any file that was at the destination
452 if ( $this->tmpSourcePath
!== null ) {
454 'src' => $this->tmpSourcePath
,
455 'dst' => $this->params
['src'],
456 'overwriteDest' => true
458 $status = $this->backend
->storeInternal( $params );
459 if ( !$status->isOK() ) {
467 * Restore any temporary destination backup file
471 protected function restoreDest() {
472 $status = Status
::newGood();
473 // Restore any file that was at the destination
474 if ( $this->tmpDestFile
) {
476 'src' => $this->tmpDestFile
->getPath(),
477 'dst' => $this->params
['dst'],
478 'overwriteDest' => true
480 $status = $this->backend
->storeInternal( $params );
481 if ( !$status->isOK() ) {
489 * Check if a file will exist in storage when this operation is attempted
491 * @param $source string Storage path
492 * @param $predicates Array
495 final protected function fileExists( $source, array $predicates ) {
496 if ( isset( $predicates['exists'][$source] ) ) {
497 return $predicates['exists'][$source]; // previous op assures this
499 $params = array( 'src' => $source, 'latest' => $this->useLatest
);
500 return $this->backend
->fileExists( $params );
505 * Log a file operation failure and preserve any temp files
507 * @param $fileOp FileOp
510 final protected function logFailure( $action ) {
511 $params = $this->params
;
512 $params['failedAction'] = $action;
513 // Preserve backup files just in case (for recovery)
514 if ( $this->tmpSourceFile
) {
515 $this->tmpSourceFile
->preserve(); // don't purge
516 $params['srcBackupPath'] = $this->tmpSourceFile
->getPath();
518 if ( $this->tmpDestFile
) {
519 $this->tmpDestFile
->preserve(); // don't purge
520 $params['dstBackupPath'] = $this->tmpDestFile
->getPath();
523 wfDebugLog( 'FileOperation',
524 get_class( $this ) . ' failed:' . serialize( $params ) );
525 } catch ( Exception
$e ) {
526 // bad config? debug log error?
532 * Store a file into the backend from a file on the file system.
533 * Parameters similar to FileBackend::storeInternal(), which include:
534 * src : source path on file system
535 * dst : destination storage path
536 * overwriteDest : do nothing and pass if an identical file exists at destination
537 * overwriteSame : override any existing file at destination
539 class StoreFileOp
extends FileOp
{
540 protected function allowedParams() {
541 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
544 protected function doPrecheck( array &$predicates ) {
545 $status = Status
::newGood();
546 // Check if destination file exists
547 $status->merge( $this->precheckDestExistence( $predicates ) );
548 if ( !$status->isOK() ) {
551 // Check if the source file exists on the file system
552 if ( !is_file( $this->params
['src'] ) ) {
553 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
556 // Update file existence predicates
557 $predicates['exists'][$this->params
['dst']] = true;
561 protected function doAttempt() {
562 $status = Status
::newGood();
563 // Create a destination backup copy as needed
564 if ( $this->destAlreadyExists
) {
565 $status->merge( $this->checkAndBackupDest() );
566 if ( !$status->isOK() ) {
570 // Store the file at the destination
571 if ( !$this->destSameAsSource
) {
572 $status->merge( $this->backend
->storeInternal( $this->params
) );
577 protected function doRevert() {
578 $status = Status
::newGood();
579 if ( !$this->destSameAsSource
) {
580 // Restore any file that was at the destination,
581 // overwritting what was put there in attempt()
582 $status->merge( $this->restoreDest() );
587 protected function getSourceSha1Base36() {
588 return $this->getFileSha1Base36( $this->params
['src'] );
591 public function storagePathsChanged() {
592 return array( $this->params
['dst'] );
597 * Create a file in the backend with the given content.
598 * Parameters similar to FileBackend::create(), which include:
599 * content : a string of raw file contents
600 * dst : destination storage path
601 * overwriteDest : do nothing and pass if an identical file exists at destination
602 * overwriteSame : override any existing file at destination
604 class CreateFileOp
extends FileOp
{
605 protected function allowedParams() {
606 return array( 'content', 'dst', 'overwriteDest', 'overwriteSame' );
609 protected function doPrecheck( array &$predicates ) {
610 $status = Status
::newGood();
611 // Check if destination file exists
612 $status->merge( $this->precheckDestExistence( $predicates ) );
613 if ( !$status->isOK() ) {
616 // Update file existence predicates
617 $predicates['exists'][$this->params
['dst']] = true;
621 protected function doAttempt() {
622 $status = Status
::newGood();
623 // Create a destination backup copy as needed
624 if ( $this->destAlreadyExists
) {
625 $status->merge( $this->checkAndBackupDest() );
626 if ( !$status->isOK() ) {
630 // Create the file at the destination
631 if ( !$this->destSameAsSource
) {
632 $status->merge( $this->backend
->createInternal( $this->params
) );
637 protected function doRevert() {
638 $status = Status
::newGood();
639 if ( !$this->destSameAsSource
) {
640 // Restore any file that was at the destination,
641 // overwritting what was put there in attempt()
642 $status->merge( $this->restoreDest() );
647 protected function getSourceSha1Base36() {
648 return wfBaseConvert( sha1( $this->params
['content'] ), 16, 36, 31 );
651 public function storagePathsChanged() {
652 return array( $this->params
['dst'] );
657 * Copy a file from one storage path to another in the backend.
658 * Parameters similar to FileBackend::copy(), which include:
659 * src : source storage path
660 * dst : destination storage path
661 * overwriteDest : do nothing and pass if an identical file exists at destination
662 * overwriteSame : override any existing file at destination
664 class CopyFileOp
extends FileOp
{
665 protected function allowedParams() {
666 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
669 protected function doPrecheck( array &$predicates ) {
670 $status = Status
::newGood();
671 // Check if destination file exists
672 $status->merge( $this->precheckDestExistence( $predicates ) );
673 if ( !$status->isOK() ) {
676 // Check if the source file exists
677 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
678 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
681 // Update file existence predicates
682 $predicates['exists'][$this->params
['dst']] = true;
686 protected function doAttempt() {
687 $status = Status
::newGood();
688 // Create a destination backup copy as needed
689 if ( $this->destAlreadyExists
) {
690 $status->merge( $this->checkAndBackupDest() );
691 if ( !$status->isOK() ) {
695 // Copy the file into the destination
696 if ( !$this->destSameAsSource
) {
697 $status->merge( $this->backend
->copyInternal( $this->params
) );
702 protected function doRevert() {
703 $status = Status
::newGood();
704 if ( !$this->destSameAsSource
) {
705 // Restore any file that was at the destination,
706 // overwritting what was put there in attempt()
707 $status->merge( $this->restoreDest() );
712 protected function getSourceSha1Base36() {
713 return $this->getFileSha1Base36( $this->params
['src'] );
716 public function storagePathsRead() {
717 return array( $this->params
['src'] );
720 public function storagePathsChanged() {
721 return array( $this->params
['dst'] );
726 * Move a file from one storage path to another in the backend.
727 * Parameters similar to FileBackend::move(), which include:
728 * src : source storage path
729 * dst : destination storage path
730 * overwriteDest : do nothing and pass if an identical file exists at destination
731 * overwriteSame : override any existing file at destination
733 class MoveFileOp
extends FileOp
{
734 protected function allowedParams() {
735 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
738 protected function doPrecheck( array &$predicates ) {
739 $status = Status
::newGood();
740 // Check if destination file exists
741 $status->merge( $this->precheckDestExistence( $predicates ) );
742 if ( !$status->isOK() ) {
745 // Check if the source file exists
746 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
747 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
750 // Update file existence predicates
751 $predicates['exists'][$this->params
['src']] = false;
752 $predicates['exists'][$this->params
['dst']] = true;
756 protected function doAttempt() {
757 $status = Status
::newGood();
758 // Create a destination backup copy as needed
759 if ( $this->destAlreadyExists
) {
760 $status->merge( $this->checkAndBackupDest() );
761 if ( !$status->isOK() ) {
765 if ( !$this->destSameAsSource
) {
766 // Move the file into the destination
767 $status->merge( $this->backend
->moveInternal( $this->params
) );
769 // Create a source backup copy as needed
770 $status->merge( $this->backupSource() );
771 if ( !$status->isOK() ) {
774 // Just delete source as the destination needs no changes
775 $params = array( 'src' => $this->params
['src'] );
776 $status->merge( $this->backend
->deleteInternal( $params ) );
777 if ( !$status->isOK() ) {
784 protected function doRevert() {
785 $status = Status
::newGood();
786 if ( !$this->destSameAsSource
) {
787 // Move the file back to the source
789 'src' => $this->params
['dst'],
790 'dst' => $this->params
['src']
792 $status->merge( $this->backend
->moveInternal( $params ) );
793 if ( !$status->isOK() ) {
794 return $status; // also can't restore any dest file
796 // Restore any file that was at the destination
797 $status->merge( $this->restoreDest() );
799 // Restore any source file
800 return $this->restoreSource();
806 protected function getSourceSha1Base36() {
807 return $this->getFileSha1Base36( $this->params
['src'] );
810 public function storagePathsRead() {
811 return array( $this->params
['src'] );
814 public function storagePathsChanged() {
815 return array( $this->params
['dst'] );
820 * Delete a file at the storage path.
821 * Parameters similar to FileBackend::delete(), which include:
822 * src : source storage path
823 * ignoreMissingSource : don't return an error if the file does not exist
825 class DeleteFileOp
extends FileOp
{
826 protected $needsDelete = true;
828 protected function allowedParams() {
829 return array( 'src', 'ignoreMissingSource' );
832 protected function doPrecheck( array &$predicates ) {
833 $status = Status
::newGood();
834 // Check if the source file exists
835 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
836 if ( !$this->getParam( 'ignoreMissingSource' ) ) {
837 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
840 $this->needsDelete
= false;
842 // Update file existence predicates
843 $predicates['exists'][$this->params
['src']] = false;
847 protected function doAttempt() {
848 $status = Status
::newGood();
849 if ( $this->needsDelete
) {
850 // Create a source backup copy as needed
851 $status->merge( $this->backupSource() );
852 if ( !$status->isOK() ) {
855 // Delete the source file
856 $status->merge( $this->backend
->deleteInternal( $this->params
) );
857 if ( !$status->isOK() ) {
864 protected function doRevert() {
865 // Restore any source file that we deleted
866 return $this->restoreSource();
869 public function storagePathsChanged() {
870 return array( $this->params
['src'] );
875 * Placeholder operation that has no params and does nothing
877 class NullFileOp
extends FileOp
{
878 protected function doAttempt() {
879 return Status
::newGood();
882 protected function doRevert() {
883 return Status
::newGood();