3 * Helper class for representing operations with transaction support.
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
21 * @ingroup FileBackend
23 use Psr\Log\LoggerInterface
;
26 * FileBackend helper class for representing operations.
27 * Do not use this class from places outside FileBackend.
29 * Methods called from FileOpBatch::attempt() should avoid throwing
30 * exceptions at all costs. FileOp objects should be lightweight in order
31 * to support large arrays in memory and serialization.
33 * @ingroup FileBackend
36 abstract class FileOp
{
38 protected $params = [];
40 /** @var FileBackendStore */
42 /** @var LoggerInterface */
46 protected $state = self
::STATE_NEW
;
49 protected $failed = false;
52 protected $async = false;
57 /** @var bool Operation is not a no-op */
58 protected $doOperation = true;
61 protected $sourceSha1;
64 protected $overwriteSameCase;
67 protected $destExists;
69 /* Object life-cycle */
71 const STATE_CHECKED
= 2;
72 const STATE_ATTEMPTED
= 3;
75 * Build a new batch file operation transaction
77 * @param FileBackendStore $backend
78 * @param array $params
79 * @param LoggerInterface $logger PSR logger instance
80 * @throws FileBackendError
82 final public function __construct(
83 FileBackendStore
$backend, array $params, LoggerInterface
$logger
85 $this->backend
= $backend;
86 $this->logger
= $logger;
87 list( $required, $optional, $paths ) = $this->allowedParams();
88 foreach ( $required as $name ) {
89 if ( isset( $params[$name] ) ) {
90 $this->params
[$name] = $params[$name];
92 throw new InvalidArgumentException( "File operation missing parameter '$name'." );
95 foreach ( $optional as $name ) {
96 if ( isset( $params[$name] ) ) {
97 $this->params
[$name] = $params[$name];
100 foreach ( $paths as $name ) {
101 if ( isset( $this->params
[$name] ) ) {
102 // Normalize paths so the paths to the same file have the same string
103 $this->params
[$name] = self
::normalizeIfValidStoragePath( $this->params
[$name] );
109 * Normalize a string if it is a valid storage path
111 * @param string $path
114 protected static function normalizeIfValidStoragePath( $path ) {
115 if ( FileBackend
::isStoragePath( $path ) ) {
116 $res = FileBackend
::normalizeStoragePath( $path );
118 return ( $res !== null ) ?
$res : $path;
125 * Set the batch UUID this operation belongs to
127 * @param string $batchId
129 final public function setBatchId( $batchId ) {
130 $this->batchId
= $batchId;
134 * Get the value of the parameter with the given name
136 * @param string $name
137 * @return mixed Returns null if the parameter is not set
139 final public function getParam( $name ) {
140 return isset( $this->params
[$name] ) ?
$this->params
[$name] : null;
144 * Check if this operation failed precheck() or attempt()
148 final public function failed() {
149 return $this->failed
;
153 * Get a new empty predicates array for precheck()
157 final public static function newPredicates() {
158 return [ 'exists' => [], 'sha1' => [] ];
162 * Get a new empty dependency tracking array for paths read/written to
166 final public static function newDependencies() {
167 return [ 'read' => [], 'write' => [] ];
171 * Update a dependency tracking array to account for this operation
173 * @param array $deps Prior path reads/writes; format of FileOp::newPredicates()
176 final public function applyDependencies( array $deps ) {
177 $deps['read'] +
= array_fill_keys( $this->storagePathsRead(), 1 );
178 $deps['write'] +
= array_fill_keys( $this->storagePathsChanged(), 1 );
184 * Check if this operation changes files listed in $paths
186 * @param array $deps Prior path reads/writes; format of FileOp::newPredicates()
189 final public function dependsOn( array $deps ) {
190 foreach ( $this->storagePathsChanged() as $path ) {
191 if ( isset( $deps['read'][$path] ) ||
isset( $deps['write'][$path] ) ) {
192 return true; // "output" or "anti" dependency
195 foreach ( $this->storagePathsRead() as $path ) {
196 if ( isset( $deps['write'][$path] ) ) {
197 return true; // "flow" dependency
205 * Get the file journal entries for this file operation
207 * @param array $oPredicates Pre-op info about files (format of FileOp::newPredicates)
208 * @param array $nPredicates Post-op info about files (format of FileOp::newPredicates)
211 final public function getJournalEntries( array $oPredicates, array $nPredicates ) {
212 if ( !$this->doOperation
) {
213 return []; // this is a no-op
218 $pathsUsed = array_merge( $this->storagePathsRead(), $this->storagePathsChanged() );
219 foreach ( array_unique( $pathsUsed ) as $path ) {
220 $nullEntries[] = [ // assertion for recovery
223 'newSha1' => $this->fileSha1( $path, $oPredicates )
226 foreach ( $this->storagePathsChanged() as $path ) {
227 if ( $nPredicates['sha1'][$path] === false ) { // deleted
233 } else { // created/updated
235 'op' => $this->fileExists( $path, $oPredicates ) ?
'update' : 'create',
237 'newSha1' => $nPredicates['sha1'][$path]
242 return array_merge( $nullEntries, $updateEntries, $deleteEntries );
246 * Check preconditions of the operation without writing anything.
247 * This must update $predicates for each path that the op can change
248 * except when a failing StatusValue object is returned.
250 * @param array $predicates
251 * @return StatusValue
253 final public function precheck( array &$predicates ) {
254 if ( $this->state
!== self
::STATE_NEW
) {
255 return StatusValue
::newFatal( 'fileop-fail-state', self
::STATE_NEW
, $this->state
);
257 $this->state
= self
::STATE_CHECKED
;
258 $status = $this->doPrecheck( $predicates );
259 if ( !$status->isOK() ) {
260 $this->failed
= true;
267 * @param array $predicates
268 * @return StatusValue
270 protected function doPrecheck( array &$predicates ) {
271 return StatusValue
::newGood();
275 * Attempt the operation
277 * @return StatusValue
279 final public function attempt() {
280 if ( $this->state
!== self
::STATE_CHECKED
) {
281 return StatusValue
::newFatal( 'fileop-fail-state', self
::STATE_CHECKED
, $this->state
);
282 } elseif ( $this->failed
) { // failed precheck
283 return StatusValue
::newFatal( 'fileop-fail-attempt-precheck' );
285 $this->state
= self
::STATE_ATTEMPTED
;
286 if ( $this->doOperation
) {
287 $status = $this->doAttempt();
288 if ( !$status->isOK() ) {
289 $this->failed
= true;
290 $this->logFailure( 'attempt' );
293 $status = StatusValue
::newGood();
300 * @return StatusValue
302 protected function doAttempt() {
303 return StatusValue
::newGood();
307 * Attempt the operation in the background
309 * @return StatusValue
311 final public function attemptAsync() {
313 $result = $this->attempt();
314 $this->async
= false;
320 * Get the file operation parameters
322 * @return array (required params list, optional params list, list of params that are paths)
324 protected function allowedParams() {
325 return [ [], [], [] ];
329 * Adjust params to FileBackendStore internal file calls
331 * @param array $params
332 * @return array (required params list, optional params list)
334 protected function setFlags( array $params ) {
335 return [ 'async' => $this->async
] +
$params;
339 * Get a list of storage paths read from for this operation
343 public function storagePathsRead() {
348 * Get a list of storage paths written to for this operation
352 public function storagePathsChanged() {
357 * Check for errors with regards to the destination file already existing.
358 * Also set the destExists, overwriteSameCase and sourceSha1 member variables.
359 * A bad StatusValue will be returned if there is no chance it can be overwritten.
361 * @param array $predicates
362 * @return StatusValue
364 protected function precheckDestExistence( array $predicates ) {
365 $status = StatusValue
::newGood();
366 // Get hash of source file/string and the destination file
367 $this->sourceSha1
= $this->getSourceSha1Base36(); // FS file or data string
368 if ( $this->sourceSha1
=== null ) { // file in storage?
369 $this->sourceSha1
= $this->fileSha1( $this->params
['src'], $predicates );
371 $this->overwriteSameCase
= false;
372 $this->destExists
= $this->fileExists( $this->params
['dst'], $predicates );
373 if ( $this->destExists
) {
374 if ( $this->getParam( 'overwrite' ) ) {
375 return $status; // OK
376 } elseif ( $this->getParam( 'overwriteSame' ) ) {
377 $dhash = $this->fileSha1( $this->params
['dst'], $predicates );
378 // Check if hashes are valid and match each other...
379 if ( !strlen( $this->sourceSha1
) ||
!strlen( $dhash ) ) {
380 $status->fatal( 'backend-fail-hashes' );
381 } elseif ( $this->sourceSha1
!== $dhash ) {
382 // Give an error if the files are not identical
383 $status->fatal( 'backend-fail-notsame', $this->params
['dst'] );
385 $this->overwriteSameCase
= true; // OK
388 return $status; // do nothing; either OK or bad status
390 $status->fatal( 'backend-fail-alreadyexists', $this->params
['dst'] );
400 * precheckDestExistence() helper function to get the source file SHA-1.
401 * Subclasses should overwride this if the source is not in storage.
403 * @return string|bool Returns false on failure
405 protected function getSourceSha1Base36() {
410 * Check if a file will exist in storage when this operation is attempted
412 * @param string $source Storage path
413 * @param array $predicates
416 final protected function fileExists( $source, array $predicates ) {
417 if ( isset( $predicates['exists'][$source] ) ) {
418 return $predicates['exists'][$source]; // previous op assures this
420 $params = [ 'src' => $source, 'latest' => true ];
422 return $this->backend
->fileExists( $params );
427 * Get the SHA-1 of a file in storage when this operation is attempted
429 * @param string $source Storage path
430 * @param array $predicates
431 * @return string|bool False on failure
433 final protected function fileSha1( $source, array $predicates ) {
434 if ( isset( $predicates['sha1'][$source] ) ) {
435 return $predicates['sha1'][$source]; // previous op assures this
436 } elseif ( isset( $predicates['exists'][$source] ) && !$predicates['exists'][$source] ) {
437 return false; // previous op assures this
439 $params = [ 'src' => $source, 'latest' => true ];
441 return $this->backend
->getFileSha1Base36( $params );
446 * Get the backend this operation is for
448 * @return FileBackendStore
450 public function getBackend() {
451 return $this->backend
;
455 * Log a file operation failure and preserve any temp files
457 * @param string $action
459 final public function logFailure( $action ) {
460 $params = $this->params
;
461 $params['failedAction'] = $action;
463 $this->logger
->error( static::class .
464 " failed (batch #{$this->batchId}): " . FormatJson
::encode( $params ) );
465 } catch ( Exception
$e ) {
466 // bad config? debug log error?