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 $useLatest = true; // boolean
30 protected $destSameAsSource = false; // boolean
31 protected $destAlreadyExists = false; // boolean
33 /* Object life-cycle */
35 const STATE_CHECKED
= 2;
36 const STATE_ATTEMPTED
= 3;
40 * Build a new file operation transaction
42 * @params $backend FileBackend
43 * @params $params Array
45 final public function __construct( FileBackendBase
$backend, array $params ) {
46 $this->backend
= $backend;
47 foreach ( $this->allowedParams() as $name ) {
48 if ( isset( $params[$name] ) ) {
49 $this->params
[$name] = $params[$name];
52 $this->params
= $params;
56 * Disable file backups for this operation
58 final protected function disableBackups() {
59 $this->useBackups
= false;
63 * Allow stale data for file reads and existence checks.
64 * If this is called, then disableBackups() should also be called
65 * unless the affected files are known to have not changed recently.
67 final protected function allowStaleReads() {
68 $this->useLatest
= false;
72 * Attempt a series of file operations.
73 * Callers are responsible for handling file locking.
75 * $opts is an array of options, including:
76 * 'force' : Errors that would normally cause a rollback do not.
77 * The remaining operations are still attempted if any fail.
78 * 'allowStale' : Don't require the latest available data.
79 * This can increase performance for non-critical writes.
80 * This has no effect unless the 'force' flag is set.
82 * @param $performOps Array List of FileOp operations
83 * @param $opts Array Batch operation options
86 final public static function attemptBatch( array $performOps, array $opts ) {
87 $status = Status
::newGood();
89 $allowStale = isset( $opts['allowStale'] ) && $opts['allowStale'];
90 $ignoreErrors = isset( $opts['force'] ) && $opts['force'];
91 $predicates = FileOp
::newPredicates(); // account for previous op in prechecks
92 // Do pre-checks for each operation; abort on failure...
93 foreach ( $performOps as $index => $fileOp ) {
95 $fileOp->allowStaleReads(); // allow potentially stale reads
97 $status->merge( $fileOp->precheck( $predicates ) );
98 if ( !$status->isOK() ) { // operation failed?
99 if ( $ignoreErrors ) {
100 ++
$status->failCount
;
101 $status->success
[$index] = false;
108 // Attempt each operation; abort on failure...
109 foreach ( $performOps as $index => $fileOp ) {
110 if ( $fileOp->failed() ) {
111 continue; // nothing to do
112 } elseif ( $ignoreErrors ) {
113 $fileOp->disableBackups(); // no chance of revert() calls
115 $status->merge( $fileOp->attempt() );
116 if ( !$status->isOK() ) { // operation failed?
117 if ( $ignoreErrors ) {
118 ++
$status->failCount
;
119 $status->success
[$index] = false;
121 // Revert everything done so far and abort.
122 // Do this by reverting each previous operation in reverse order.
123 $pos = $index - 1; // last one failed; no need to revert()
124 while ( $pos >= 0 ) {
125 if ( !$performOps[$pos]->failed() ) {
126 $status->merge( $performOps[$pos]->revert() );
135 $wasOk = $status->isOK();
136 // Finish each operation...
137 foreach ( $performOps as $index => $fileOp ) {
138 if ( $fileOp->failed() ) {
139 continue; // nothing to do
141 $subStatus = $fileOp->finish();
142 if ( $subStatus->isOK() ) {
143 ++
$status->successCount
;
144 $status->success
[$index] = true;
146 ++
$status->failCount
;
147 $status->success
[$index] = false;
149 $status->merge( $subStatus );
152 // Make sure status is OK, despite any finish() fatals
153 $status->setResult( $wasOk, $status->value
);
159 * Get the value of the parameter with the given name.
160 * Returns null if the parameter is not set.
162 * @param $name string
165 final public function getParam( $name ) {
166 return isset( $this->params
[$name] ) ?
$this->params
[$name] : null;
170 * Check if this operation failed precheck() or attempt()
173 final public function failed() {
174 return $this->failed
;
178 * Get a new empty predicates array for precheck()
182 final public static function newPredicates() {
183 return array( 'exists' => array() );
187 * Check preconditions of the operation without writing anything
189 * @param $predicates Array
192 final public function precheck( array &$predicates ) {
193 if ( $this->state
!== self
::STATE_NEW
) {
194 return Status
::newFatal( 'fileop-fail-state', self
::STATE_NEW
, $this->state
);
196 $this->state
= self
::STATE_CHECKED
;
197 $status = $this->doPrecheck( $predicates );
198 if ( !$status->isOK() ) {
199 $this->failed
= true;
205 * Attempt the operation, backing up files as needed; this must be reversible
209 final public function attempt() {
210 if ( $this->state
!== self
::STATE_CHECKED
) {
211 return Status
::newFatal( 'fileop-fail-state', self
::STATE_CHECKED
, $this->state
);
212 } elseif ( $this->failed
) { // failed precheck
213 return Status
::newFatal( 'fileop-fail-attempt-precheck' );
215 $this->state
= self
::STATE_ATTEMPTED
;
216 $status = $this->doAttempt();
217 if ( !$status->isOK() ) {
218 $this->failed
= true;
219 $this->logFailure( 'attempt' );
225 * Revert the operation; affected files are restored
229 final public function revert() {
230 if ( $this->state
!== self
::STATE_ATTEMPTED
) {
231 return Status
::newFatal( 'fileop-fail-state', self
::STATE_ATTEMPTED
, $this->state
);
233 $this->state
= self
::STATE_DONE
;
234 if ( $this->failed
) {
235 $status = Status
::newGood(); // nothing to revert
237 $status = $this->doRevert();
238 if ( !$status->isOK() ) {
239 $this->logFailure( 'revert' );
246 * Finish the operation; this may be irreversible
250 final public function finish() {
251 if ( $this->state
!== self
::STATE_ATTEMPTED
) {
252 return Status
::newFatal( 'fileop-fail-state', self
::STATE_ATTEMPTED
, $this->state
);
254 $this->state
= self
::STATE_DONE
;
255 if ( $this->failed
) {
256 $status = Status
::newGood(); // nothing to finish
258 $status = $this->doFinish();
264 * Get a list of storage paths read from for this operation
268 public function storagePathsRead() {
273 * Get a list of storage paths written to for this operation
277 public function storagePathsChanged() {
282 * @return Array List of allowed parameters
284 protected function allowedParams() {
291 protected function doPrecheck( array &$predicates ) {
292 return Status
::newGood();
298 abstract protected function doAttempt();
303 abstract protected function doRevert();
308 protected function doFinish() {
309 return Status
::newGood();
313 * Check if the destination file exists and update the
314 * destAlreadyExists member variable. A bad status will
315 * be returned if there is no chance it can be overwritten.
317 * @param $predicates Array
320 protected function precheckDestExistence( array $predicates ) {
321 $status = Status
::newGood();
322 if ( $this->fileExists( $this->params
['dst'], $predicates ) ) {
323 $this->destAlreadyExists
= true;
324 if ( !$this->getParam( 'overwriteDest' ) && !$this->getParam( 'overwriteSame' ) ) {
325 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
329 $this->destAlreadyExists
= false;
335 * Backup any file at the source to a temporary file
339 protected function backupSource() {
340 $status = Status
::newGood();
341 if ( $this->useBackups
) {
342 // Check if a file already exists at the source...
343 $params = array( 'src' => $this->params
['src'], 'latest' => $this->useLatest
);
344 if ( $this->backend
->fileExists( $params ) ) {
345 // Create a temporary backup copy...
346 $this->tmpSourcePath
= $this->backend
->getLocalCopy( $params );
347 if ( $this->tmpSourcePath
=== null ) {
348 $status->fatal( 'backend-fail-backup', $this->params
['src'] );
357 * Backup the file at the destination to a temporary file.
358 * Don't bother backing it up unless we might overwrite the file.
359 * This assumes that the destination is in the backend and that
360 * the source is either in the backend or on the file system.
361 * This also handles the 'overwriteSame' check logic and updates
362 * the destSameAsSource member variable.
366 protected function checkAndBackupDest() {
367 $status = Status
::newGood();
368 $this->destSameAsSource
= false;
370 if ( $this->getParam( 'overwriteDest' ) ) {
371 if ( $this->useBackups
) {
372 // Create a temporary backup copy...
373 $params = array( 'src' => $this->params
['dst'], 'latest' => $this->useLatest
);
374 $this->tmpDestFile
= $this->backend
->getLocalCopy( $params );
375 if ( !$this->tmpDestFile
) {
376 $status->fatal( 'backend-fail-backup', $this->params
['dst'] );
380 } elseif ( $this->getParam( 'overwriteSame' ) ) {
381 $shash = $this->getSourceSha1Base36();
382 // If there is a single source, then we can do some checks already.
383 // For things like concatenate(), we would need to build a temp file
384 // first and thus don't support 'overwriteSame' ($shash is null).
385 if ( $shash !== null ) {
386 $dhash = $this->getFileSha1Base36( $this->params
['dst'] );
387 if ( !strlen( $shash ) ||
!strlen( $dhash ) ) {
388 $status->fatal( 'backend-fail-hashes' );
389 } elseif ( $shash !== $dhash ) {
390 // Give an error if the files are not identical
391 $status->fatal( 'backend-fail-notsame', $this->params
['dst'] );
393 $this->destSameAsSource
= true;
395 return $status; // do nothing; either OK or bad status
398 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
406 * checkAndBackupDest() helper function to get the source file Sha1.
407 * Returns false on failure and null if there is no single source.
409 * @return string|false|null
411 protected function getSourceSha1Base36() {
416 * checkAndBackupDest() helper function to get the Sha1 of a file.
418 * @return string|false False on failure
420 protected function getFileSha1Base36( $path ) {
421 // Source file is in backend
422 if ( FileBackend
::isStoragePath( $path ) ) {
423 // For some backends (e.g. Swift, Azure) we can get
424 // standard hashes to use for this types of comparisons.
425 $params = array( 'src' => $path, 'latest' => $this->useLatest
);
426 $hash = $this->backend
->getFileSha1Base36( $params );
427 // Source file is on file system
429 wfSuppressWarnings();
430 $hash = sha1_file( $path );
432 if ( $hash !== false ) {
433 $hash = wfBaseConvert( $hash, 16, 36, 31 );
440 * Restore any temporary source backup file
444 protected function restoreSource() {
445 $status = Status
::newGood();
446 // Restore any file that was at the destination
447 if ( $this->tmpSourcePath
!== null ) {
449 'src' => $this->tmpSourcePath
,
450 'dst' => $this->params
['src'],
451 'overwriteDest' => true
453 $status = $this->backend
->storeInternal( $params );
454 if ( !$status->isOK() ) {
462 * Restore any temporary destination backup file
466 protected function restoreDest() {
467 $status = Status
::newGood();
468 // Restore any file that was at the destination
469 if ( $this->tmpDestFile
) {
471 'src' => $this->tmpDestFile
->getPath(),
472 'dst' => $this->params
['dst'],
473 'overwriteDest' => true
475 $status = $this->backend
->storeInternal( $params );
476 if ( !$status->isOK() ) {
484 * Check if a file will exist in storage when this operation is attempted
486 * @param $source string Storage path
487 * @param $predicates Array
490 final protected function fileExists( $source, array $predicates ) {
491 if ( isset( $predicates['exists'][$source] ) ) {
492 return $predicates['exists'][$source]; // previous op assures this
494 $params = array( 'src' => $source, 'latest' => $this->useLatest
);
495 return $this->backend
->fileExists( $params );
500 * Log a file operation failure and preserve any temp files
502 * @param $fileOp FileOp
504 final protected function logFailure( $action ) {
505 $params = $this->params
;
506 $params['failedAction'] = $action;
507 // Preserve backup files just in case (for recovery)
508 if ( $this->tmpSourceFile
) {
509 $this->tmpSourceFile
->preserve(); // don't purge
510 $params['srcBackupPath'] = $this->tmpSourceFile
->getPath();
512 if ( $this->tmpDestFile
) {
513 $this->tmpDestFile
->preserve(); // don't purge
514 $params['dstBackupPath'] = $this->tmpDestFile
->getPath();
517 wfDebugLog( 'FileOperation',
518 get_class( $this ) . ' failed:' . serialize( $params ) );
519 } catch ( Exception
$e ) {
520 // bad config? debug log error?
526 * Store a file into the backend from a file on the file system.
527 * Parameters similar to FileBackend::storeInternal(), which include:
528 * src : source path on file system
529 * dst : destination storage path
530 * overwriteDest : do nothing and pass if an identical file exists at destination
531 * overwriteSame : override any existing file at destination
533 class StoreFileOp
extends FileOp
{
534 protected function allowedParams() {
535 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
538 protected function doPrecheck( array &$predicates ) {
539 $status = Status
::newGood();
540 // Check if destination file exists
541 $status->merge( $this->precheckDestExistence( $predicates ) );
542 if ( !$status->isOK() ) {
545 // Check if the source file exists on the file system
546 if ( !is_file( $this->params
['src'] ) ) {
547 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
550 // Update file existence predicates
551 $predicates['exists'][$this->params
['dst']] = true;
555 protected function doAttempt() {
556 $status = Status
::newGood();
557 // Create a destination backup copy as needed
558 if ( $this->destAlreadyExists
) {
559 $status->merge( $this->checkAndBackupDest() );
560 if ( !$status->isOK() ) {
564 // Store the file at the destination
565 if ( !$this->destSameAsSource
) {
566 $status->merge( $this->backend
->storeInternal( $this->params
) );
571 protected function doRevert() {
572 $status = Status
::newGood();
573 if ( !$this->destSameAsSource
) {
574 // Restore any file that was at the destination,
575 // overwritting what was put there in attempt()
576 $status->merge( $this->restoreDest() );
581 protected function getSourceSha1Base36() {
582 return $this->getFileSha1Base36( $this->params
['src'] );
585 public function storagePathsChanged() {
586 return array( $this->params
['dst'] );
591 * Create a file in the backend with the given content.
592 * Parameters similar to FileBackend::create(), which include:
593 * content : a string of raw file contents
594 * dst : destination storage path
595 * overwriteDest : do nothing and pass if an identical file exists at destination
596 * overwriteSame : override any existing file at destination
598 class CreateFileOp
extends FileOp
{
599 protected function allowedParams() {
600 return array( 'content', 'dst', 'overwriteDest', 'overwriteSame' );
603 protected function doPrecheck( array &$predicates ) {
604 $status = Status
::newGood();
605 // Check if destination file exists
606 $status->merge( $this->precheckDestExistence( $predicates ) );
607 if ( !$status->isOK() ) {
610 // Update file existence predicates
611 $predicates['exists'][$this->params
['dst']] = true;
615 protected function doAttempt() {
616 $status = Status
::newGood();
617 // Create a destination backup copy as needed
618 if ( $this->destAlreadyExists
) {
619 $status->merge( $this->checkAndBackupDest() );
620 if ( !$status->isOK() ) {
624 // Create the file at the destination
625 if ( !$this->destSameAsSource
) {
626 $status->merge( $this->backend
->createInternal( $this->params
) );
631 protected function doRevert() {
632 $status = Status
::newGood();
633 if ( !$this->destSameAsSource
) {
634 // Restore any file that was at the destination,
635 // overwritting what was put there in attempt()
636 $status->merge( $this->restoreDest() );
641 protected function getSourceSha1Base36() {
642 return wfBaseConvert( sha1( $this->params
['content'] ), 16, 36, 31 );
645 public function storagePathsChanged() {
646 return array( $this->params
['dst'] );
651 * Copy a file from one storage path to another in the backend.
652 * Parameters similar to FileBackend::copy(), which include:
653 * src : source storage path
654 * dst : destination storage path
655 * overwriteDest : do nothing and pass if an identical file exists at destination
656 * overwriteSame : override any existing file at destination
658 class CopyFileOp
extends FileOp
{
659 protected function allowedParams() {
660 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
663 protected function doPrecheck( array &$predicates ) {
664 $status = Status
::newGood();
665 // Check if destination file exists
666 $status->merge( $this->precheckDestExistence( $predicates ) );
667 if ( !$status->isOK() ) {
670 // Check if the source file exists
671 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
672 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
675 // Update file existence predicates
676 $predicates['exists'][$this->params
['dst']] = true;
680 protected function doAttempt() {
681 $status = Status
::newGood();
682 // Create a destination backup copy as needed
683 if ( $this->destAlreadyExists
) {
684 $status->merge( $this->checkAndBackupDest() );
685 if ( !$status->isOK() ) {
689 // Copy the file into the destination
690 if ( !$this->destSameAsSource
) {
691 $status->merge( $this->backend
->copyInternal( $this->params
) );
696 protected function doRevert() {
697 $status = Status
::newGood();
698 if ( !$this->destSameAsSource
) {
699 // Restore any file that was at the destination,
700 // overwritting what was put there in attempt()
701 $status->merge( $this->restoreDest() );
706 protected function getSourceSha1Base36() {
707 return $this->getFileSha1Base36( $this->params
['src'] );
710 public function storagePathsRead() {
711 return array( $this->params
['src'] );
714 public function storagePathsChanged() {
715 return array( $this->params
['dst'] );
720 * Move a file from one storage path to another in the backend.
721 * Parameters similar to FileBackend::move(), which include:
722 * src : source storage path
723 * dst : destination storage path
724 * overwriteDest : do nothing and pass if an identical file exists at destination
725 * overwriteSame : override any existing file at destination
727 class MoveFileOp
extends FileOp
{
728 protected function allowedParams() {
729 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
732 protected function doPrecheck( array &$predicates ) {
733 $status = Status
::newGood();
734 // Check if destination file exists
735 $status->merge( $this->precheckDestExistence( $predicates ) );
736 if ( !$status->isOK() ) {
739 // Check if the source file exists
740 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
741 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
744 // Update file existence predicates
745 $predicates['exists'][$this->params
['src']] = false;
746 $predicates['exists'][$this->params
['dst']] = true;
750 protected function doAttempt() {
751 $status = Status
::newGood();
752 // Create a destination backup copy as needed
753 if ( $this->destAlreadyExists
) {
754 $status->merge( $this->checkAndBackupDest() );
755 if ( !$status->isOK() ) {
759 if ( !$this->destSameAsSource
) {
760 // Move the file into the destination
761 $status->merge( $this->backend
->moveInternal( $this->params
) );
763 // Create a source backup copy as needed
764 $status->merge( $this->backupSource() );
765 if ( !$status->isOK() ) {
768 // Just delete source as the destination needs no changes
769 $params = array( 'src' => $this->params
['src'] );
770 $status->merge( $this->backend
->deleteInternal( $params ) );
771 if ( !$status->isOK() ) {
778 protected function doRevert() {
779 $status = Status
::newGood();
780 if ( !$this->destSameAsSource
) {
781 // Move the file back to the source
783 'src' => $this->params
['dst'],
784 'dst' => $this->params
['src']
786 $status->merge( $this->backend
->moveInternal( $params ) );
787 if ( !$status->isOK() ) {
788 return $status; // also can't restore any dest file
790 // Restore any file that was at the destination
791 $status->merge( $this->restoreDest() );
793 // Restore any source file
794 return $this->restoreSource();
800 protected function getSourceSha1Base36() {
801 return $this->getFileSha1Base36( $this->params
['src'] );
804 public function storagePathsRead() {
805 return array( $this->params
['src'] );
808 public function storagePathsChanged() {
809 return array( $this->params
['dst'] );
814 * Delete a file at the storage path.
815 * Parameters similar to FileBackend::delete(), which include:
816 * src : source storage path
817 * ignoreMissingSource : don't return an error if the file does not exist
819 class DeleteFileOp
extends FileOp
{
820 protected $needsDelete = true;
822 protected function allowedParams() {
823 return array( 'src', 'ignoreMissingSource' );
826 protected function doPrecheck( array &$predicates ) {
827 $status = Status
::newGood();
828 // Check if the source file exists
829 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
830 if ( !$this->getParam( 'ignoreMissingSource' ) ) {
831 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
834 $this->needsDelete
= false;
836 // Update file existence predicates
837 $predicates['exists'][$this->params
['src']] = false;
841 protected function doAttempt() {
842 $status = Status
::newGood();
843 if ( $this->needsDelete
) {
844 // Create a source backup copy as needed
845 $status->merge( $this->backupSource() );
846 if ( !$status->isOK() ) {
849 // Delete the source file
850 $status->merge( $this->backend
->deleteInternal( $this->params
) );
851 if ( !$status->isOK() ) {
858 protected function doRevert() {
859 // Restore any source file that we deleted
860 return $this->restoreSource();
863 public function storagePathsChanged() {
864 return array( $this->params
['src'] );
869 * Placeholder operation that has no params and does nothing
871 class NullFileOp
extends FileOp
{
872 protected function doAttempt() {
873 return Status
::newGood();
876 protected function doRevert() {
877 return Status
::newGood();