Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / libs / filebackend / fileop / FileOp.php
blob8b45ad8b9f64a29a082aad572ecfff675dda9d17
1 <?php
2 /**
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
20 * @file
21 * @ingroup FileBackend
24 namespace Wikimedia\FileBackend\FileOps;
26 use Closure;
27 use Exception;
28 use InvalidArgumentException;
29 use MediaWiki\Json\FormatJson;
30 use Psr\Log\LoggerInterface;
31 use StatusValue;
32 use Wikimedia\FileBackend\FileBackend;
33 use Wikimedia\FileBackend\FileBackendStore;
34 use Wikimedia\RequestTimeout\TimeoutException;
36 /**
37 * FileBackend helper class for representing operations.
38 * Do not use this class from places outside FileBackend.
40 * Methods called from FileOpBatch::attempt() should avoid throwing
41 * exceptions at all costs. FileOp objects should be lightweight in order
42 * to support large arrays in memory and serialization.
44 * @ingroup FileBackend
45 * @since 1.19
47 abstract class FileOp {
48 /** @var FileBackendStore */
49 protected $backend;
50 /** @var LoggerInterface */
51 protected $logger;
53 /** @var array */
54 protected $params = [];
56 /** @var int Stage in the operation life-cycle */
57 protected $state = self::STATE_NEW;
58 /** @var bool Whether the operation pre-check or attempt stage failed */
59 protected $failed = false;
60 /** @var bool Whether the operation is part of a concurrent sub-batch of operation */
61 protected $async = false;
62 /** @var bool Whether the operation pre-check stage marked the attempt stage as a no-op */
63 protected $noOp = false;
65 /** @var bool|null */
66 protected $overwriteSameCase;
67 /** @var bool|null */
68 protected $destExists;
70 /** Operation has not yet been pre-checked nor run */
71 private const STATE_NEW = 1;
72 /** Operation has been pre-checked but not yet attempted */
73 private const STATE_CHECKED = 2;
74 /** Operation has been attempted */
75 private const STATE_ATTEMPTED = 3;
77 /**
78 * Build a new batch file operation transaction
80 * @param FileBackendStore $backend
81 * @param array $params
82 * @param LoggerInterface $logger PSR logger instance
84 final public function __construct(
85 FileBackendStore $backend, array $params, LoggerInterface $logger
86 ) {
87 $this->backend = $backend;
88 $this->logger = $logger;
89 [ $required, $optional, $paths ] = $this->allowedParams();
90 foreach ( $required as $name ) {
91 if ( isset( $params[$name] ) ) {
92 $this->params[$name] = $params[$name];
93 } else {
94 throw new InvalidArgumentException( "File operation missing parameter '$name'." );
97 foreach ( $optional as $name ) {
98 if ( isset( $params[$name] ) ) {
99 $this->params[$name] = $params[$name];
102 foreach ( $paths as $name ) {
103 if ( isset( $this->params[$name] ) ) {
104 // Normalize paths so the paths to the same file have the same string
105 $this->params[$name] = self::normalizeIfValidStoragePath( $this->params[$name] );
111 * Normalize a string if it is a valid storage path
113 * @param string $path
114 * @return string
116 protected static function normalizeIfValidStoragePath( $path ) {
117 if ( FileBackend::isStoragePath( $path ) ) {
118 $res = FileBackend::normalizeStoragePath( $path );
120 return $res ?? $path;
123 return $path;
127 * Get the value of the parameter with the given name
129 * @param string $name
130 * @return mixed Returns null if the parameter is not set
132 final public function getParam( $name ) {
133 return $this->params[$name] ?? null;
137 * Check if this operation failed precheck() or attempt()
139 * @return bool
141 final public function failed() {
142 return $this->failed;
146 * Get a new empty dependency tracking array for paths read/written to
148 * @return array
150 final public static function newDependencies() {
151 return [ 'read' => [], 'write' => [] ];
155 * Update a dependency tracking array to account for this operation
157 * @param array $deps Prior path reads/writes; format of FileOp::newDependencies()
158 * @return array
160 final public function applyDependencies( array $deps ) {
161 $deps['read'] += array_fill_keys( $this->storagePathsRead(), 1 );
162 $deps['write'] += array_fill_keys( $this->storagePathsChanged(), 1 );
164 return $deps;
168 * Check if this operation changes files listed in $paths
170 * @param array $deps Prior path reads/writes; format of FileOp::newDependencies()
171 * @return bool
173 final public function dependsOn( array $deps ) {
174 foreach ( $this->storagePathsChanged() as $path ) {
175 if ( isset( $deps['read'][$path] ) || isset( $deps['write'][$path] ) ) {
176 return true; // "output" or "anti" dependency
179 foreach ( $this->storagePathsRead() as $path ) {
180 if ( isset( $deps['write'][$path] ) ) {
181 return true; // "flow" dependency
185 return false;
189 * Do a dry-run precondition check of the operation in the context of op batch
191 * Updates the batch predicates for all paths this op can change if an OK status is returned
193 * @param FileStatePredicates $predicates Counterfactual file states for the op batch
194 * @return StatusValue
196 final public function precheck( FileStatePredicates $predicates ) {
197 if ( $this->state !== self::STATE_NEW ) {
198 return StatusValue::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state );
200 $this->state = self::STATE_CHECKED;
202 $status = StatusValue::newGood();
203 foreach ( $this->storagePathsReadOrChanged() as $path ) {
204 if ( !$this->backend->isPathUsableInternal( $path ) ) {
205 $status->fatal( 'backend-fail-usable', $path );
208 if ( !$status->isOK() ) {
209 return $status;
212 $opPredicates = $predicates->snapshot( $this->storagePathsReadOrChanged() );
213 $status = $this->doPrecheck( $opPredicates, $predicates );
214 if ( !$status->isOK() ) {
215 $this->failed = true;
218 return $status;
222 * Do a dry-run precondition check of the operation in the context of op batch
224 * Updates the batch predicates for all paths this op can change if an OK status is returned
226 * @param FileStatePredicates $opPredicates Counterfactual file states for op paths at op start
227 * @param FileStatePredicates $batchPredicates Counterfactual file states for the op batch
228 * @return StatusValue
230 protected function doPrecheck(
231 FileStatePredicates $opPredicates,
232 FileStatePredicates $batchPredicates
234 return StatusValue::newGood();
238 * Attempt the operation
240 * @return StatusValue
242 final public function attempt() {
243 if ( $this->state !== self::STATE_CHECKED ) {
244 return StatusValue::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state );
245 } elseif ( $this->failed ) { // failed precheck
246 return StatusValue::newFatal( 'fileop-fail-attempt-precheck' );
248 $this->state = self::STATE_ATTEMPTED;
249 if ( $this->noOp ) {
250 $status = StatusValue::newGood(); // no-op
251 } else {
252 $status = $this->doAttempt();
253 if ( !$status->isOK() ) {
254 $this->failed = true;
255 $this->logFailure( 'attempt' );
259 return $status;
263 * @return StatusValue
265 protected function doAttempt() {
266 return StatusValue::newGood();
270 * Attempt the operation in the background
272 * @return StatusValue
274 final public function attemptAsync() {
275 $this->async = true;
276 $result = $this->attempt();
277 $this->async = false;
279 return $result;
283 * Attempt the operation without regards to prechecks
285 * @return StatusValue
287 final public function attemptQuick() {
288 $this->state = self::STATE_CHECKED; // bypassed
290 return $this->attempt();
294 * Attempt the operation in the background without regards to prechecks
296 * @return StatusValue
298 final public function attemptAsyncQuick() {
299 $this->state = self::STATE_CHECKED; // bypassed
301 return $this->attemptAsync();
305 * Get the file operation parameters
307 * @return array (required params list, optional params list, list of params that are paths)
309 protected function allowedParams() {
310 return [ [], [], [] ];
314 * Adjust params to FileBackendStore internal file calls
316 * @param array $params
317 * @return array (required params list, optional params list)
319 protected function setFlags( array $params ) {
320 return [ 'async' => $this->async ] + $params;
324 * Get a list of storage paths read from for this operation
326 * @return array
328 public function storagePathsRead() {
329 return [];
333 * Get a list of storage paths written to for this operation
335 * @return array
337 public function storagePathsChanged() {
338 return [];
342 * Get a list of storage paths read from or written to for this operation
344 * @return array
346 final public function storagePathsReadOrChanged() {
347 return array_values( array_unique(
348 array_merge( $this->storagePathsRead(), $this->storagePathsChanged() )
349 ) );
353 * Check for errors with regards to the destination file already existing
355 * Also set the destExists and overwriteSameCase member variables.
356 * A bad StatusValue will be returned if there is no chance it can be overwritten.
358 * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
359 * @param int|false|Closure $sourceSize Source size or idempotent function yielding the size
360 * @param string|Closure $sourceSha1 Source hash, or, idempotent function yielding the hash
361 * @return StatusValue
363 protected function precheckDestExistence(
364 FileStatePredicates $opPredicates,
365 $sourceSize,
366 $sourceSha1
368 $status = StatusValue::newGood();
369 // Record the existence of destination file
370 $this->destExists = $this->resolveFileExistence( $this->params['dst'], $opPredicates );
371 // Check if an incompatible file exists at the destination
372 $this->overwriteSameCase = false;
373 if ( $this->destExists ) {
374 if ( $this->getParam( 'overwrite' ) ) {
375 return $status; // OK, no conflict
376 } elseif ( $this->getParam( 'overwriteSame' ) ) {
377 // Operation does nothing other than return an OK or bad status
378 $sourceSize = ( $sourceSize instanceof Closure ) ? $sourceSize() : $sourceSize;
379 $sourceSha1 = ( $sourceSha1 instanceof Closure ) ? $sourceSha1() : $sourceSha1;
380 $dstSha1 = $this->resolveFileSha1Base36( $this->params['dst'], $opPredicates );
381 $dstSize = $this->resolveFileSize( $this->params['dst'], $opPredicates );
382 // Check if hashes are valid and match each other...
383 if ( !strlen( $sourceSha1 ) || !strlen( $dstSha1 ) ) {
384 $status->fatal( 'backend-fail-hashes' );
385 } elseif ( !is_int( $sourceSize ) || !is_int( $dstSize ) ) {
386 $status->fatal( 'backend-fail-sizes' );
387 } elseif ( $sourceSha1 !== $dstSha1 || $sourceSize !== $dstSize ) {
388 // Give an error if the files are not identical
389 $status->fatal( 'backend-fail-notsame', $this->params['dst'] );
390 } else {
391 $this->overwriteSameCase = true; // OK
393 } else {
394 $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
396 } elseif ( $this->destExists === FileBackend::EXISTENCE_ERROR ) {
397 $status->fatal( 'backend-fail-stat', $this->params['dst'] );
400 return $status;
404 * Check if a file will exist in storage when this operation is attempted
406 * Ideally, the file stat entry should already be preloaded via preloadFileStat().
407 * Otherwise, this will query the backend.
409 * @param string $source Storage path
410 * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
411 * @return bool|null Whether the file will exist or null on error
413 final protected function resolveFileExistence( $source, FileStatePredicates $opPredicates ) {
414 return $opPredicates->resolveFileExistence(
415 $source,
416 function ( $path ) {
417 return $this->backend->fileExists( [ 'src' => $path, 'latest' => true ] );
423 * Get the size a file in storage will have when this operation is attempted
425 * Ideally, file the stat entry should already be preloaded via preloadFileStat() and
426 * the backend tracks hashes as extended attributes. Otherwise, this will query the backend.
427 * Get the size of a file in storage when this operation is attempted
429 * @param string $source Storage path
430 * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
431 * @return int|false False on failure
433 final protected function resolveFileSize( $source, FileStatePredicates $opPredicates ) {
434 return $opPredicates->resolveFileSize(
435 $source,
436 function ( $path ) {
437 return $this->backend->getFileSize( [ 'src' => $path, 'latest' => true ] );
443 * Get the SHA-1 of a file in storage when this operation is attempted
445 * @param string $source Storage path
446 * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
447 * @return string|false The SHA-1 hash the file will have or false if non-existent or on error
449 final protected function resolveFileSha1Base36( $source, FileStatePredicates $opPredicates ) {
450 return $opPredicates->resolveFileSha1Base36(
451 $source,
452 function ( $path ) {
453 return $this->backend->getFileSha1Base36( [ 'src' => $path, 'latest' => true ] );
459 * Get the backend this operation is for
461 * @return FileBackendStore
463 public function getBackend() {
464 return $this->backend;
468 * Log a file operation failure and preserve any temp files
470 * @param string $action
472 final public function logFailure( $action ) {
473 $params = $this->params;
474 $params['failedAction'] = $action;
475 try {
476 $this->logger->error( static::class .
477 " failed: " . FormatJson::encode( $params ) );
478 } catch ( TimeoutException $e ) {
479 throw $e;
480 } catch ( Exception $e ) {
481 // bad config? debug log error?
486 /** @deprecated class alias since 1.43 */
487 class_alias( FileOp::class, 'FileOp' );