9 * Helper class for representing operations with transaction support.
10 * Do not use this class from places outside FileBackend.
12 * Methods called from attemptBatch() should avoid throwing exceptions at all costs.
13 * FileOp objects should be lightweight in order to support large arrays in memory.
15 * @ingroup FileBackend
18 abstract class FileOp
{
20 protected $params = array();
21 /** @var FileBackendStore */
24 protected $state = self
::STATE_NEW
; // integer
25 protected $failed = false; // boolean
26 protected $useLatest = true; // boolean
27 protected $batchId; // string
29 protected $sourceSha1; // string
30 protected $destSameAsSource; // boolean
32 /* Object life-cycle */
34 const STATE_CHECKED
= 2;
35 const STATE_ATTEMPTED
= 3;
37 /* Timeout related parameters */
38 const MAX_BATCH_SIZE
= 1000;
39 const TIME_LIMIT_SEC
= 300; // 5 minutes
42 * Build a new file operation transaction
44 * @param $backend FileBackendStore
45 * @param $params Array
48 final public function __construct( FileBackendStore
$backend, array $params ) {
49 $this->backend
= $backend;
50 list( $required, $optional ) = $this->allowedParams();
51 foreach ( $required as $name ) {
52 if ( isset( $params[$name] ) ) {
53 $this->params
[$name] = $params[$name];
55 throw new MWException( "File operation missing parameter '$name'." );
58 foreach ( $optional as $name ) {
59 if ( isset( $params[$name] ) ) {
60 $this->params
[$name] = $params[$name];
63 $this->params
= $params;
67 * Set the batch UUID this operation belongs to
69 * @param $batchId string
72 final protected function setBatchId( $batchId ) {
73 $this->batchId
= $batchId;
77 * Whether to allow stale data for file reads and stat checks
79 * @param $allowStale bool
82 final protected function allowStaleReads( $allowStale ) {
83 $this->useLatest
= !$allowStale;
87 * Attempt to perform a series of file operations.
88 * Callers are responsible for handling file locking.
90 * $opts is an array of options, including:
91 * 'force' : Errors that would normally cause a rollback do not.
92 * The remaining operations are still attempted if any fail.
93 * 'allowStale' : Don't require the latest available data.
94 * This can increase performance for non-critical writes.
95 * This has no effect unless the 'force' flag is set.
96 * 'nonJournaled' : Don't log this operation batch in the file journal.
98 * The resulting Status will be "OK" unless:
99 * a) unexpected operation errors occurred (network partitions, disk full...)
100 * b) significant operation errors occured and 'force' was not set
102 * @param $performOps Array List of FileOp operations
103 * @param $opts Array Batch operation options
104 * @param $journal FileJournal Journal to log operations to
107 final public static function attemptBatch(
108 array $performOps, array $opts, FileJournal
$journal
110 $status = Status
::newGood();
112 $n = count( $performOps );
113 if ( $n > self
::MAX_BATCH_SIZE
) {
114 $status->fatal( 'backend-fail-batchsize', $n, self
::MAX_BATCH_SIZE
);
118 $batchId = $journal->getTimestampedUUID();
119 $allowStale = !empty( $opts['allowStale'] );
120 $ignoreErrors = !empty( $opts['force'] );
121 $journaled = empty( $opts['nonJournaled'] );
123 $entries = array(); // file journal entries
124 $predicates = FileOp
::newPredicates(); // account for previous op in prechecks
125 // Do pre-checks for each operation; abort on failure...
126 foreach ( $performOps as $index => $fileOp ) {
127 $fileOp->setBatchId( $batchId );
128 $fileOp->allowStaleReads( $allowStale );
129 $oldPredicates = $predicates;
130 $subStatus = $fileOp->precheck( $predicates ); // updates $predicates
131 $status->merge( $subStatus );
132 if ( $subStatus->isOK() ) {
133 if ( $journaled ) { // journal log entry
134 $entries = array_merge( $entries,
135 self
::getJournalEntries( $fileOp, $oldPredicates, $predicates ) );
137 } else { // operation failed?
138 $status->success
[$index] = false;
139 ++
$status->failCount
;
140 if ( !$ignoreErrors ) {
141 return $status; // abort
146 // Log the operations in file journal...
147 if ( count( $entries ) ) {
148 $subStatus = $journal->logChangeBatch( $entries, $batchId );
149 if ( !$subStatus->isOK() ) {
150 return $subStatus; // abort
154 if ( $ignoreErrors ) { // treat precheck() fatals as mere warnings
155 $status->setResult( true, $status->value
);
158 // Attempt each operation...
159 foreach ( $performOps as $index => $fileOp ) {
160 if ( $fileOp->failed() ) {
161 continue; // nothing to do
163 $subStatus = $fileOp->attempt();
164 $status->merge( $subStatus );
165 if ( $subStatus->isOK() ) {
166 $status->success
[$index] = true;
167 ++
$status->successCount
;
169 $status->success
[$index] = false;
170 ++
$status->failCount
;
171 // We can't continue (even with $ignoreErrors) as $predicates is wrong.
172 // Log the remaining ops as failed for recovery...
173 for ( $i = ($index +
1); $i < count( $performOps ); $i++
) {
174 $performOps[$i]->logFailure( 'attempt_aborted' );
176 return $status; // bail out
184 * Get the file journal entries for a single file operation
186 * @param $fileOp FileOp
187 * @param $oPredicates Array Pre-op information about files
188 * @param $nPredicates Array Post-op information about files
191 final protected static function getJournalEntries(
192 FileOp
$fileOp, array $oPredicates, array $nPredicates
194 $nullEntries = array();
195 $updateEntries = array();
196 $deleteEntries = array();
197 $pathsUsed = array_merge( $fileOp->storagePathsRead(), $fileOp->storagePathsChanged() );
198 foreach ( $pathsUsed as $path ) {
199 $nullEntries[] = array( // assertion for recovery
202 'newSha1' => $fileOp->fileSha1( $path, $oPredicates )
205 foreach ( $fileOp->storagePathsChanged() as $path ) {
206 if ( $nPredicates['sha1'][$path] === false ) { // deleted
207 $deleteEntries[] = array(
212 } else { // created/updated
213 $updateEntries[] = array(
214 'op' => $fileOp->fileExists( $path, $oPredicates ) ?
'update' : 'create',
216 'newSha1' => $nPredicates['sha1'][$path]
220 return array_merge( $nullEntries, $updateEntries, $deleteEntries );
224 * Get the value of the parameter with the given name
226 * @param $name string
227 * @return mixed Returns null if the parameter is not set
229 final public function getParam( $name ) {
230 return isset( $this->params
[$name] ) ?
$this->params
[$name] : null;
234 * Check if this operation failed precheck() or attempt()
238 final public function failed() {
239 return $this->failed
;
243 * Get a new empty predicates array for precheck()
247 final public static function newPredicates() {
248 return array( 'exists' => array(), 'sha1' => array() );
252 * Check preconditions of the operation without writing anything
254 * @param $predicates Array
257 final public function precheck( array &$predicates ) {
258 if ( $this->state
!== self
::STATE_NEW
) {
259 return Status
::newFatal( 'fileop-fail-state', self
::STATE_NEW
, $this->state
);
261 $this->state
= self
::STATE_CHECKED
;
262 $status = $this->doPrecheck( $predicates );
263 if ( !$status->isOK() ) {
264 $this->failed
= true;
270 * Attempt the operation, backing up files as needed; this must be reversible
274 final public function attempt() {
275 if ( $this->state
!== self
::STATE_CHECKED
) {
276 return Status
::newFatal( 'fileop-fail-state', self
::STATE_CHECKED
, $this->state
);
277 } elseif ( $this->failed
) { // failed precheck
278 return Status
::newFatal( 'fileop-fail-attempt-precheck' );
280 $this->state
= self
::STATE_ATTEMPTED
;
281 $status = $this->doAttempt();
282 if ( !$status->isOK() ) {
283 $this->failed
= true;
284 $this->logFailure( 'attempt' );
290 * Get the file operation parameters
292 * @return Array (required params list, optional params list)
294 protected function allowedParams() {
295 return array( array(), array() );
299 * Get a list of storage paths read from for this operation
303 public function storagePathsRead() {
308 * Get a list of storage paths written to for this operation
312 public function storagePathsChanged() {
319 protected function doPrecheck( array &$predicates ) {
320 return Status
::newGood();
326 protected function doAttempt() {
327 return Status
::newGood();
331 * Check for errors with regards to the destination file already existing.
332 * This also updates the destSameAsSource and sourceSha1 member variables.
333 * A bad status will be returned if there is no chance it can be overwritten.
335 * @param $predicates Array
338 protected function precheckDestExistence( array $predicates ) {
339 $status = Status
::newGood();
340 // Get hash of source file/string and the destination file
341 $this->sourceSha1
= $this->getSourceSha1Base36(); // FS file or data string
342 if ( $this->sourceSha1
=== null ) { // file in storage?
343 $this->sourceSha1
= $this->fileSha1( $this->params
['src'], $predicates );
345 $this->destSameAsSource
= false;
346 if ( $this->fileExists( $this->params
['dst'], $predicates ) ) {
347 if ( $this->getParam( 'overwrite' ) ) {
348 return $status; // OK
349 } elseif ( $this->getParam( 'overwriteSame' ) ) {
350 $dhash = $this->fileSha1( $this->params
['dst'], $predicates );
351 // Check if hashes are valid and match each other...
352 if ( !strlen( $this->sourceSha1
) ||
!strlen( $dhash ) ) {
353 $status->fatal( 'backend-fail-hashes' );
354 } elseif ( $this->sourceSha1
!== $dhash ) {
355 // Give an error if the files are not identical
356 $status->fatal( 'backend-fail-notsame', $this->params
['dst'] );
358 $this->destSameAsSource
= true; // OK
360 return $status; // do nothing; either OK or bad status
362 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
370 * precheckDestExistence() helper function to get the source file SHA-1.
371 * Subclasses should overwride this iff the source is not in storage.
373 * @return string|bool Returns false on failure
375 protected function getSourceSha1Base36() {
380 * Check if a file will exist in storage when this operation is attempted
382 * @param $source string Storage path
383 * @param $predicates Array
386 final protected function fileExists( $source, array $predicates ) {
387 if ( isset( $predicates['exists'][$source] ) ) {
388 return $predicates['exists'][$source]; // previous op assures this
390 $params = array( 'src' => $source, 'latest' => $this->useLatest
);
391 return $this->backend
->fileExists( $params );
396 * Get the SHA-1 of a file in storage when this operation is attempted
398 * @param $source string Storage path
399 * @param $predicates Array
400 * @return string|bool False on failure
402 final protected function fileSha1( $source, array $predicates ) {
403 if ( isset( $predicates['sha1'][$source] ) ) {
404 return $predicates['sha1'][$source]; // previous op assures this
406 $params = array( 'src' => $source, 'latest' => $this->useLatest
);
407 return $this->backend
->getFileSha1Base36( $params );
412 * Log a file operation failure and preserve any temp files
414 * @param $action string
417 final protected function logFailure( $action ) {
418 $params = $this->params
;
419 $params['failedAction'] = $action;
421 wfDebugLog( 'FileOperation', get_class( $this ) .
422 " failed (batch #{$this->batchId}): " . FormatJson
::encode( $params ) );
423 } catch ( Exception
$e ) {
424 // bad config? debug log error?
430 * Store a file into the backend from a file on the file system.
431 * Parameters similar to FileBackendStore::storeInternal(), which include:
432 * src : source path on file system
433 * dst : destination storage path
434 * overwrite : do nothing and pass if an identical file exists at destination
435 * overwriteSame : override any existing file at destination
437 class StoreFileOp
extends FileOp
{
438 protected function allowedParams() {
439 return array( array( 'src', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
442 protected function doPrecheck( array &$predicates ) {
443 $status = Status
::newGood();
444 // Check if the source file exists on the file system
445 if ( !is_file( $this->params
['src'] ) ) {
446 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
448 // Check if the source file is too big
449 } elseif ( filesize( $this->params
['src'] ) > $this->backend
->maxFileSizeInternal() ) {
450 $status->fatal( 'backend-fail-store', $this->params
['src'], $this->params
['dst'] );
452 // Check if a file can be placed at the destination
453 } elseif ( !$this->backend
->isPathUsableInternal( $this->params
['dst'] ) ) {
454 $status->fatal( 'backend-fail-store', $this->params
['src'], $this->params
['dst'] );
457 // Check if destination file exists
458 $status->merge( $this->precheckDestExistence( $predicates ) );
459 if ( $status->isOK() ) {
460 // Update file existence predicates
461 $predicates['exists'][$this->params
['dst']] = true;
462 $predicates['sha1'][$this->params
['dst']] = $this->sourceSha1
;
464 return $status; // safe to call attempt()
467 protected function doAttempt() {
468 $status = Status
::newGood();
469 // Store the file at the destination
470 if ( !$this->destSameAsSource
) {
471 $status->merge( $this->backend
->storeInternal( $this->params
) );
476 protected function getSourceSha1Base36() {
477 wfSuppressWarnings();
478 $hash = sha1_file( $this->params
['src'] );
480 if ( $hash !== false ) {
481 $hash = wfBaseConvert( $hash, 16, 36, 31 );
486 public function storagePathsChanged() {
487 return array( $this->params
['dst'] );
492 * Create a file in the backend with the given content.
493 * Parameters similar to FileBackendStore::createInternal(), which include:
494 * content : the raw file contents
495 * dst : destination storage path
496 * overwrite : do nothing and pass if an identical file exists at destination
497 * overwriteSame : override any existing file at destination
499 class CreateFileOp
extends FileOp
{
500 protected function allowedParams() {
501 return array( array( 'content', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
504 protected function doPrecheck( array &$predicates ) {
505 $status = Status
::newGood();
506 // Check if the source data is too big
507 if ( strlen( $this->getParam( 'content' ) ) > $this->backend
->maxFileSizeInternal() ) {
508 $status->fatal( 'backend-fail-create', $this->params
['dst'] );
510 // Check if a file can be placed at the destination
511 } elseif ( !$this->backend
->isPathUsableInternal( $this->params
['dst'] ) ) {
512 $status->fatal( 'backend-fail-create', $this->params
['dst'] );
515 // Check if destination file exists
516 $status->merge( $this->precheckDestExistence( $predicates ) );
517 if ( $status->isOK() ) {
518 // Update file existence predicates
519 $predicates['exists'][$this->params
['dst']] = true;
520 $predicates['sha1'][$this->params
['dst']] = $this->sourceSha1
;
522 return $status; // safe to call attempt()
525 protected function doAttempt() {
526 $status = Status
::newGood();
527 // Create the file at the destination
528 if ( !$this->destSameAsSource
) {
529 $status->merge( $this->backend
->createInternal( $this->params
) );
534 protected function getSourceSha1Base36() {
535 return wfBaseConvert( sha1( $this->params
['content'] ), 16, 36, 31 );
538 public function storagePathsChanged() {
539 return array( $this->params
['dst'] );
544 * Copy a file from one storage path to another in the backend.
545 * Parameters similar to FileBackendStore::copyInternal(), which include:
546 * src : source storage path
547 * dst : destination storage path
548 * overwrite : do nothing and pass if an identical file exists at destination
549 * overwriteSame : override any existing file at destination
551 class CopyFileOp
extends FileOp
{
552 protected function allowedParams() {
553 return array( array( 'src', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
556 protected function doPrecheck( array &$predicates ) {
557 $status = Status
::newGood();
558 // Check if the source file exists
559 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
560 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
562 // Check if a file can be placed at the destination
563 } elseif ( !$this->backend
->isPathUsableInternal( $this->params
['dst'] ) ) {
564 $status->fatal( 'backend-fail-copy', $this->params
['src'], $this->params
['dst'] );
567 // Check if destination file exists
568 $status->merge( $this->precheckDestExistence( $predicates ) );
569 if ( $status->isOK() ) {
570 // Update file existence predicates
571 $predicates['exists'][$this->params
['dst']] = true;
572 $predicates['sha1'][$this->params
['dst']] = $this->sourceSha1
;
574 return $status; // safe to call attempt()
577 protected function doAttempt() {
578 $status = Status
::newGood();
579 // Do nothing if the src/dst paths are the same
580 if ( $this->params
['src'] !== $this->params
['dst'] ) {
581 // Copy the file into the destination
582 if ( !$this->destSameAsSource
) {
583 $status->merge( $this->backend
->copyInternal( $this->params
) );
589 public function storagePathsRead() {
590 return array( $this->params
['src'] );
593 public function storagePathsChanged() {
594 return array( $this->params
['dst'] );
599 * Move a file from one storage path to another in the backend.
600 * Parameters similar to FileBackendStore::moveInternal(), which include:
601 * src : source storage path
602 * dst : destination storage path
603 * overwrite : do nothing and pass if an identical file exists at destination
604 * overwriteSame : override any existing file at destination
606 class MoveFileOp
extends FileOp
{
607 protected function allowedParams() {
608 return array( array( 'src', 'dst' ), array( 'overwrite', 'overwriteSame' ) );
611 protected function doPrecheck( array &$predicates ) {
612 $status = Status
::newGood();
613 // Check if the source file exists
614 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
615 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
617 // Check if a file can be placed at the destination
618 } elseif ( !$this->backend
->isPathUsableInternal( $this->params
['dst'] ) ) {
619 $status->fatal( 'backend-fail-move', $this->params
['src'], $this->params
['dst'] );
622 // Check if destination file exists
623 $status->merge( $this->precheckDestExistence( $predicates ) );
624 if ( $status->isOK() ) {
625 // Update file existence predicates
626 $predicates['exists'][$this->params
['src']] = false;
627 $predicates['sha1'][$this->params
['src']] = false;
628 $predicates['exists'][$this->params
['dst']] = true;
629 $predicates['sha1'][$this->params
['dst']] = $this->sourceSha1
;
631 return $status; // safe to call attempt()
634 protected function doAttempt() {
635 $status = Status
::newGood();
636 // Do nothing if the src/dst paths are the same
637 if ( $this->params
['src'] !== $this->params
['dst'] ) {
638 if ( !$this->destSameAsSource
) {
639 // Move the file into the destination
640 $status->merge( $this->backend
->moveInternal( $this->params
) );
642 // Just delete source as the destination needs no changes
643 $params = array( 'src' => $this->params
['src'] );
644 $status->merge( $this->backend
->deleteInternal( $params ) );
650 public function storagePathsRead() {
651 return array( $this->params
['src'] );
654 public function storagePathsChanged() {
655 return array( $this->params
['src'], $this->params
['dst'] );
660 * Delete a file at the given storage path from the backend.
661 * Parameters similar to FileBackendStore::deleteInternal(), which include:
662 * src : source storage path
663 * ignoreMissingSource : don't return an error if the file does not exist
665 class DeleteFileOp
extends FileOp
{
666 protected function allowedParams() {
667 return array( array( 'src' ), array( 'ignoreMissingSource' ) );
670 protected $needsDelete = true;
672 protected function doPrecheck( array &$predicates ) {
673 $status = Status
::newGood();
674 // Check if the source file exists
675 if ( !$this->fileExists( $this->params
['src'], $predicates ) ) {
676 if ( !$this->getParam( 'ignoreMissingSource' ) ) {
677 $status->fatal( 'backend-fail-notexists', $this->params
['src'] );
680 $this->needsDelete
= false;
682 // Update file existence predicates
683 $predicates['exists'][$this->params
['src']] = false;
684 $predicates['sha1'][$this->params
['src']] = false;
685 return $status; // safe to call attempt()
688 protected function doAttempt() {
689 $status = Status
::newGood();
690 if ( $this->needsDelete
) {
691 // Delete the source file
692 $status->merge( $this->backend
->deleteInternal( $this->params
) );
697 public function storagePathsChanged() {
698 return array( $this->params
['src'] );
703 * Placeholder operation that has no params and does nothing
705 class NullFileOp
extends FileOp
{}