9 * Helper class for representing operations with transaction support.
10 * FileBackend::doOperations() will require these classes for supported operations.
12 * Use of large fields should be avoided as we want to be able to support
13 * potentially many FileOp classes in large arrays in memory.
15 * @ingroup FileBackend
18 abstract class FileOp
{
20 protected $params = array();
21 /** $var FileBackendBase */
23 /** @var TempFSFile|null */
24 protected $tmpSourceFile, $tmpDestFile;
26 protected $state = self
::STATE_NEW
; // integer
27 protected $failed = false; // boolean
28 protected $useBackups = true; // boolean
29 protected $destSameAsSource = false; // boolean
30 protected $destAlreadyExists = false; // boolean
32 /* Object life-cycle */
34 const STATE_CHECKED
= 2;
35 const STATE_ATTEMPTED
= 3;
39 * Build a new file operation transaction
41 * @params $backend FileBackend
42 * @params $params Array
44 final public function __construct( FileBackendBase
$backend, array $params ) {
45 $this->backend
= $backend;
46 foreach ( $this->allowedParams() as $name ) {
47 if ( isset( $params[$name] ) ) {
48 $this->params
[$name] = $params[$name];
51 $this->params
= $params;
55 * Disable file backups for this operation
59 final protected function disableBackups() {
60 $this->useBackups
= false;
64 * Attempt a series of file operations.
65 * Callers are responsible for handling file locking.
67 * @param $performOps Array List of FileOp operations
68 * @param $opts Array Batch operation options
71 final public static function attemptBatch( array $performOps, array $opts ) {
72 $status = Status
::newGood();
74 $ignoreErrors = isset( $opts['ignoreErrors'] ) && $opts['ignoreErrors'];
75 $predicates = FileOp
::newPredicates(); // account for previous op in prechecks
76 // Do pre-checks for each operation; abort on failure...
77 foreach ( $performOps as $index => $fileOp ) {
78 $status->merge( $fileOp->precheck( $predicates ) );
79 if ( !$status->isOK() ) { // operation failed?
80 if ( $ignoreErrors ) {
82 $status->success
[$index] = false;
89 // Attempt each operation; abort on failure...
90 foreach ( $performOps as $index => $fileOp ) {
91 if ( $fileOp->failed() ) {
92 continue; // nothing to do
93 } elseif ( $ignoreErrors ) {
94 $fileOp->disableBackups(); // no chance of revert() calls
96 $status->merge( $fileOp->attempt() );
97 if ( !$status->isOK() ) { // operation failed?
98 if ( $ignoreErrors ) {
100 $status->success
[$index] = false;
102 // Revert everything done so far and abort.
103 // Do this by reverting each previous operation in reverse order.
104 $pos = $index - 1; // last one failed; no need to revert()
105 while ( $pos >= 0 ) {
106 if ( !$performOps[$pos]->failed() ) {
107 $status->merge( $performOps[$pos]->revert() );
116 // Finish each operation...
117 foreach ( $performOps as $index => $fileOp ) {
118 if ( $fileOp->failed() ) {
119 continue; // nothing to do
121 $subStatus = $fileOp->finish();
122 if ( $subStatus->isOK() ) {
123 ++
$status->successCount
;
124 $status->success
[$index] = true;
126 ++
$status->failCount
;
127 $status->success
[$index] = false;
129 $status->merge( $subStatus );
132 // Make sure status is OK, despite any finish() fatals
133 $status->setResult( true, $status->value
);
139 * Get the value of the parameter with the given name.
140 * Returns null if the parameter is not set.
142 * @param $name string
145 final public function getParam( $name ) {
146 return isset( $this->params
[$name] ) ?
$this->params
[$name] : null;
150 * Check if this operation failed precheck() or attempt()
153 final public function failed() {
154 return $this->failed
;
158 * Get a new empty predicates array for precheck()
162 final public static function newPredicates() {
163 return array( 'exists' => array() );
167 * Check preconditions of the operation without writing anything
169 * @param $predicates Array
172 final public function precheck( array &$predicates ) {
173 if ( $this->state
!== self
::STATE_NEW
) {
174 return Status
::newFatal( 'fileop-fail-state', self
::STATE_NEW
, $this->state
);
176 $this->state
= self
::STATE_CHECKED
;
177 $status = $this->doPrecheck( $predicates );
178 if ( !$status->isOK() ) {
179 $this->failed
= true;
185 * Attempt the operation, backing up files as needed; this must be reversible
189 final public function attempt() {
190 if ( $this->state
!== self
::STATE_CHECKED
) {
191 return Status
::newFatal( 'fileop-fail-state', self
::STATE_CHECKED
, $this->state
);
192 } elseif ( $this->failed
) { // failed precheck
193 return Status
::newFatal( 'fileop-fail-attempt-precheck' );
195 $this->state
= self
::STATE_ATTEMPTED
;
196 $status = $this->doAttempt();
197 if ( !$status->isOK() ) {
198 $this->failed
= true;
199 $this->logFailure( 'attempt' );
205 * Revert the operation; affected files are restored
209 final public function revert() {
210 if ( $this->state
!== self
::STATE_ATTEMPTED
) {
211 return Status
::newFatal( 'fileop-fail-state', self
::STATE_ATTEMPTED
, $this->state
);
213 $this->state
= self
::STATE_DONE
;
214 if ( $this->failed
) {
215 $status = Status
::newGood(); // nothing to revert
217 $status = $this->doRevert();
218 if ( !$status->isOK() ) {
219 $this->logFailure( 'revert' );
226 * Finish the operation; this may be irreversible
230 final public function finish() {
231 if ( $this->state
!== self
::STATE_ATTEMPTED
) {
232 return Status
::newFatal( 'fileop-fail-state', self
::STATE_ATTEMPTED
, $this->state
);
234 $this->state
= self
::STATE_DONE
;
235 if ( $this->failed
) {
236 $status = Status
::newGood(); // nothing to finish
238 $status = $this->doFinish();
244 * Get a list of storage paths read from for this operation
248 public function storagePathsRead() {
253 * Get a list of storage paths written to for this operation
257 public function storagePathsChanged() {
262 * @return Array List of allowed parameters
264 protected function allowedParams() {
271 protected function doPrecheck( array &$predicates ) {
272 return Status
::newGood();
278 abstract protected function doAttempt();
283 abstract protected function doRevert();
288 protected function doFinish() {
289 return Status
::newGood();
293 * Check if the destination file exists and update the
294 * destAlreadyExists member variable. A bad status will
295 * be returned if there is no chance it can be overwritten.
297 * @param $predicates Array
300 protected function precheckDestExistence( array $predicates ) {
301 $status = Status
::newGood();
302 if ( $this->fileExists( $this->params
['dst'], $predicates ) ) {
303 $this->destAlreadyExists
= true;
304 if ( !$this->getParam( 'overwriteDest' ) && !$this->getParam( 'overwriteSame' ) ) {
305 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
309 $this->destAlreadyExists
= false;
315 * Backup any file at the source to a temporary file
319 protected function backupSource() {
320 $status = Status
::newGood();
321 if ( $this->useBackups
) {
322 // Check if a file already exists at the source...
323 $params = array( 'src' => $this->params
['src'] );
324 if ( $this->backend
->fileExists( $params ) ) {
325 // Create a temporary backup copy...
326 $this->tmpSourcePath
= $this->backend
->getLocalCopy( $params );
327 if ( $this->tmpSourcePath
=== null ) {
328 $status->fatal( 'backend-fail-backup', $this->params
['src'] );
337 * Backup the file at the destination to a temporary file.
338 * Don't bother backing it up unless we might overwrite the file.
339 * This assumes that the destination is in the backend and that
340 * the source is either in the backend or on the file system.
341 * This also handles the 'overwriteSame' check logic and updates
342 * the destSameAsSource member variable.
346 protected function checkAndBackupDest() {
347 $status = Status
::newGood();
348 $this->destSameAsSource
= false;
350 if ( $this->getParam( 'overwriteDest' ) ) {
351 if ( $this->useBackups
) {
352 // Create a temporary backup copy...
353 $params = array( 'src' => $this->params
['dst'] );
354 $this->tmpDestFile
= $this->backend
->getLocalCopy( $params );
355 if ( !$this->tmpDestFile
) {
356 $status->fatal( 'backend-fail-backup', $this->params
['dst'] );
360 } elseif ( $this->getParam( 'overwriteSame' ) ) {
361 $shash = $this->getSourceSha1Base36();
362 // If there is a single source, then we can do some checks already.
363 // For things like concatenate(), we would need to build a temp file
364 // first and thus don't support 'overwriteSame' ($shash is null).
365 if ( $shash !== null ) {
366 $dhash = $this->getFileSha1Base36( $this->params
['dst'] );
367 if ( !strlen( $shash ) ||
!strlen( $dhash ) ) {
368 $status->fatal( 'backend-fail-hashes' );
369 } elseif ( $shash !== $dhash ) {
370 // Give an error if the files are not identical
371 $status->fatal( 'backend-fail-notsame', $this->params
['dst'] );
373 $this->destSameAsSource
= true;
375 return $status; // do nothing; either OK or bad status
378 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
386 * checkAndBackupDest() helper function to get the source file Sha1.
387 * Returns false on failure and null if there is no single source.
389 * @return string|false|null
391 protected function getSourceSha1Base36() {
396 * checkAndBackupDest() helper function to get the Sha1 of a file.
398 * @return string|false False on failure
400 protected function getFileSha1Base36( $path ) {
401 // Source file is in backend
402 if ( FileBackend
::isStoragePath( $path ) ) {
403 // For some backends (e.g. Swift, Azure) we can get
404 // standard hashes to use for this types of comparisons.
405 $hash = $this->backend
->getFileSha1Base36( array( 'src' => $path ) );
406 // Source file is on file system
408 wfSuppressWarnings();
409 $hash = sha1_file( $path );
411 if ( $hash !== false ) {
412 $hash = wfBaseConvert( $hash, 16, 36, 31 );
419 * Restore any temporary source backup file
423 protected function restoreSource() {
424 $status = Status
::newGood();
425 // Restore any file that was at the destination
426 if ( $this->tmpSourcePath
!== null ) {
428 'src' => $this->tmpSourcePath
,
429 'dst' => $this->params
['src'],
430 'overwriteDest' => true
432 $status = $this->backend
->store( $params );
433 if ( !$status->isOK() ) {
441 * Restore any temporary destination backup file
445 protected function restoreDest() {
446 $status = Status
::newGood();
447 // Restore any file that was at the destination
448 if ( $this->tmpDestFile
) {
450 'src' => $this->tmpDestFile
->getPath(),
451 'dst' => $this->params
['dst'],
452 'overwriteDest' => true
454 $status = $this->backend
->store( $params );
455 if ( !$status->isOK() ) {
463 * Check if a file will exist in storage when this operation is attempted
465 * @param $source string Storage path
466 * @param $predicates Array
469 final protected function fileExists( $source, array $predicates ) {
470 if ( isset( $predicates['exists'][$source] ) ) {
471 return $predicates['exists'][$source]; // previous op assures this
473 return $this->backend
->fileExists( array( 'src' => $source ) );
478 * Log a file operation failure and preserve any temp files
480 * @param $fileOp FileOp
483 final protected function logFailure( $action ) {
484 $params = $this->params
;
485 $params['failedAction'] = $action;
486 // Preserve backup files just in case (for recovery)
487 if ( $this->tmpSourceFile
) {
488 $this->tmpSourceFile
->preserve(); // don't purge
489 $params['srcBackupPath'] = $this->tmpSourceFile
->getPath();
491 if ( $this->tmpDestFile
) {
492 $this->tmpDestFile
->preserve(); // don't purge
493 $params['dstBackupPath'] = $this->tmpDestFile
->getPath();
496 wfDebugLog( 'FileOperation',
497 get_class( $this ) . ' failed:' . serialize( $params ) );
498 } catch ( Exception
$e ) {
499 // bad config? debug log error?
505 * Store a file into the backend from a file on the file system.
506 * Parameters similar to FileBackend::store(), which include:
507 * src : source path on file system
508 * dst : destination storage path
509 * overwriteDest : do nothing and pass if an identical file exists at destination
510 * overwriteSame : override any existing file at destination
512 class StoreFileOp
extends FileOp
{
513 protected function allowedParams() {
514 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
517 protected function doPrecheck( array &$predicates ) {
518 $status = Status
::newGood();
519 // Check if destination file exists
520 $status->merge( $this->precheckDestExistence( $predicates ) );
521 if ( !$status->isOK() ) {
524 // Check if the source file exists on the file system
525 if ( !is_file( $this->params
['src'] ) ) {
526 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
529 // Update file existence predicates
530 $predicates['exists'][$this->params
['dst']] = true;
534 protected function doAttempt() {
535 $status = Status
::newGood();
536 // Create a destination backup copy as needed
537 if ( $this->destAlreadyExists
) {
538 $status->merge( $this->checkAndBackupDest() );
539 if ( !$status->isOK() ) {
543 // Store the file at the destination
544 if ( !$this->destSameAsSource
) {
545 $status->merge( $this->backend
->store( $this->params
) );
550 protected function doRevert() {
551 $status = Status
::newGood();
552 if ( !$this->destSameAsSource
) {
553 // Restore any file that was at the destination,
554 // overwritting what was put there in attempt()
555 $status->merge( $this->restoreDest() );
560 protected function getSourceSha1Base36() {
561 return $this->getFileSha1Base36( $this->params
['src'] );
564 public function storagePathsChanged() {
565 return array( $this->params
['dst'] );
570 * Create a file in the backend with the given content.
571 * Parameters similar to FileBackend::create(), which include:
572 * content : a string of raw file contents
573 * dst : destination storage path
574 * overwriteDest : do nothing and pass if an identical file exists at destination
575 * overwriteSame : override any existing file at destination
577 class CreateFileOp
extends FileOp
{
578 protected function allowedParams() {
579 return array( 'content', 'dst', 'overwriteDest', 'overwriteSame' );
582 protected function doPrecheck( array &$predicates ) {
583 $status = Status
::newGood();
584 // Check if destination file exists
585 $status->merge( $this->precheckDestExistence( $predicates ) );
586 if ( !$status->isOK() ) {
589 // Update file existence predicates
590 $predicates['exists'][$this->params
['dst']] = true;
594 protected function doAttempt() {
595 $status = Status
::newGood();
596 // Create a destination backup copy as needed
597 if ( $this->destAlreadyExists
) {
598 $status->merge( $this->checkAndBackupDest() );
599 if ( !$status->isOK() ) {
603 // Create the file at the destination
604 if ( !$this->destSameAsSource
) {
605 $status->merge( $this->backend
->create( $this->params
) );
610 protected function doRevert() {
611 $status = Status
::newGood();
612 if ( !$this->destSameAsSource
) {
613 // Restore any file that was at the destination,
614 // overwritting what was put there in attempt()
615 $status->merge( $this->restoreDest() );
620 protected function getSourceSha1Base36() {
621 return wfBaseConvert( sha1( $this->params
['content'] ), 16, 36, 31 );
624 public function storagePathsChanged() {
625 return array( $this->params
['dst'] );
630 * Copy a file from one storage path to another in the backend.
631 * Parameters similar to FileBackend::copy(), which include:
632 * src : source storage path
633 * dst : destination storage path
634 * overwriteDest : do nothing and pass if an identical file exists at destination
635 * overwriteSame : override any existing file at destination
637 class CopyFileOp
extends FileOp
{
638 protected function allowedParams() {
639 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
642 protected function doPrecheck( array &$predicates ) {
643 $status = Status
::newGood();
644 // Check if destination file exists
645 $status->merge( $this->precheckDestExistence( $predicates ) );
646 if ( !$status->isOK() ) {
649 // Check if the source file exists
650 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
651 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
654 // Update file existence predicates
655 $predicates['exists'][$this->params
['dst']] = true;
659 protected function doAttempt() {
660 $status = Status
::newGood();
661 // Create a destination backup copy as needed
662 if ( $this->destAlreadyExists
) {
663 $status->merge( $this->checkAndBackupDest() );
664 if ( !$status->isOK() ) {
668 // Copy the file into the destination
669 if ( !$this->destSameAsSource
) {
670 $status->merge( $this->backend
->copy( $this->params
) );
675 protected function doRevert() {
676 $status = Status
::newGood();
677 if ( !$this->destSameAsSource
) {
678 // Restore any file that was at the destination,
679 // overwritting what was put there in attempt()
680 $status->merge( $this->restoreDest() );
685 protected function getSourceSha1Base36() {
686 return $this->getFileSha1Base36( $this->params
['src'] );
689 public function storagePathsRead() {
690 return array( $this->params
['src'] );
693 public function storagePathsChanged() {
694 return array( $this->params
['dst'] );
699 * Move a file from one storage path to another in the backend.
700 * Parameters similar to FileBackend::move(), which include:
701 * src : source storage path
702 * dst : destination storage path
703 * overwriteDest : do nothing and pass if an identical file exists at destination
704 * overwriteSame : override any existing file at destination
706 class MoveFileOp
extends FileOp
{
707 protected function allowedParams() {
708 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
711 protected function doPrecheck( array &$predicates ) {
712 $status = Status
::newGood();
713 // Check if destination file exists
714 $status->merge( $this->precheckDestExistence( $predicates ) );
715 if ( !$status->isOK() ) {
718 // Check if the source file exists
719 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
720 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
723 // Update file existence predicates
724 $predicates['exists'][$this->params
['src']] = false;
725 $predicates['exists'][$this->params
['dst']] = true;
729 protected function doAttempt() {
730 $status = Status
::newGood();
731 // Create a destination backup copy as needed
732 if ( $this->destAlreadyExists
) {
733 $status->merge( $this->checkAndBackupDest() );
734 if ( !$status->isOK() ) {
738 if ( !$this->destSameAsSource
) {
739 // Move the file into the destination
740 $status->merge( $this->backend
->move( $this->params
) );
742 // Create a source backup copy as needed
743 $status->merge( $this->backupSource() );
744 if ( !$status->isOK() ) {
747 // Just delete source as the destination needs no changes
748 $params = array( 'src' => $this->params
['src'] );
749 $status->merge( $this->backend
->delete( $params ) );
750 if ( !$status->isOK() ) {
757 protected function doRevert() {
758 $status = Status
::newGood();
759 if ( !$this->destSameAsSource
) {
760 // Move the file back to the source
762 'src' => $this->params
['dst'],
763 'dst' => $this->params
['src']
765 $status->merge( $this->backend
->move( $params ) );
766 if ( !$status->isOK() ) {
767 return $status; // also can't restore any dest file
769 // Restore any file that was at the destination
770 $status->merge( $this->restoreDest() );
772 // Restore any source file
773 return $this->restoreSource();
779 protected function getSourceSha1Base36() {
780 return $this->getFileSha1Base36( $this->params
['src'] );
783 public function storagePathsRead() {
784 return array( $this->params
['src'] );
787 public function storagePathsChanged() {
788 return array( $this->params
['dst'] );
793 * Combines files from severals storage paths into a new file in the backend.
794 * Parameters similar to FileBackend::concatenate(), which include:
795 * srcs : ordered source storage paths (e.g. chunk1, chunk2, ...)
796 * dst : destination storage path
797 * overwriteDest : do nothing and pass if an identical file exists at destination
799 class ConcatenateFileOp
extends FileOp
{
800 protected function allowedParams() {
801 return array( 'srcs', 'dst', 'overwriteDest' );
804 protected function doPrecheck( array &$predicates ) {
805 $status = Status
::newGood();
806 // Check if destination file exists
807 $status->merge( $this->precheckDestExistence( $predicates ) );
808 if ( !$status->isOK() ) {
811 // Check that source files exists
812 foreach ( $this->params
['srcs'] as $source ) {
813 if ( !$this->fileExists( $source, $predicates ) ) {
814 $status->fatal( 'backend-fail-notexists', $source );
818 // Update file existence predicates
819 $predicates['exists'][$this->params
['dst']] = true;
823 protected function doAttempt() {
824 $status = Status
::newGood();
825 // Create a destination backup copy as needed
826 if ( $this->destAlreadyExists
) {
827 $status->merge( $this->checkAndBackupDest() );
828 if ( !$status->isOK() ) {
832 // Concatenate the file at the destination
833 $status->merge( $this->backend
->concatenate( $this->params
) );
837 protected function doRevert() {
838 // Restore any file that was at the destination,
839 // overwritting what was put there in attempt()
840 return $this->restoreDest();
843 protected function getSourceSha1Base36() {
844 return null; // defer this until we finish building the new file
847 public function storagePathsRead() {
848 return $this->params
['srcs'];
851 public function storagePathsChanged() {
852 return array( $this->params
['dst'] );
857 * Delete a file at the storage path.
858 * Parameters similar to FileBackend::delete(), which include:
859 * src : source storage path
860 * ignoreMissingSource : don't return an error if the file does not exist
862 class DeleteFileOp
extends FileOp
{
863 protected $needsDelete = true;
865 protected function allowedParams() {
866 return array( 'src', 'ignoreMissingSource' );
869 protected function doPrecheck( array &$predicates ) {
870 $status = Status
::newGood();
871 // Check if the source file exists
872 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
873 if ( !$this->getParam( 'ignoreMissingSource' ) ) {
874 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
877 $this->needsDelete
= false;
879 // Update file existence predicates
880 $predicates['exists'][$this->params
['src']] = false;
884 protected function doAttempt() {
885 $status = Status
::newGood();
886 if ( $this->needsDelete
) {
887 // Create a source backup copy as needed
888 $status->merge( $this->backupSource() );
889 if ( !$status->isOK() ) {
892 // Delete the source file
893 $status->merge( $this->backend
->delete( $this->params
) );
894 if ( !$status->isOK() ) {
901 protected function doRevert() {
902 // Restore any source file that we deleted
903 return $this->restoreSource();
906 public function storagePathsChanged() {
907 return array( $this->params
['src'] );
912 * Placeholder operation that has no params and does nothing
914 class NullFileOp
extends FileOp
{
915 protected function doAttempt() {
916 return Status
::newGood();
919 protected function doRevert() {
920 return Status
::newGood();