Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.99% covered (warning)
80.99%
98 / 121
68.00% covered (warning)
68.00%
17 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileOp
80.99% covered (warning)
80.99%
98 / 121
68.00% covered (warning)
68.00%
17 / 25
84.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 normalizeIfValidStoragePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 failed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newDependencies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyDependencies
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 dependsOn
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 precheck
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
 doPrecheck
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attempt
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
5.93
 doAttempt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attemptAsync
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 attemptQuick
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 attemptAsyncQuick
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 allowedParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFlags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storagePathsRead
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storagePathsChanged
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storagePathsReadOrChanged
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 precheckDestExistence
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
13.43
 resolveFileExistence
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 resolveFileSize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 resolveFileSha1Base36
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getBackend
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logFailure
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Helper class for representing operations with transaction support.
4 *
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.
9 *
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.
14 *
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
19 *
20 * @file
21 * @ingroup FileBackend
22 */
23
24use MediaWiki\Json\FormatJson;
25use Psr\Log\LoggerInterface;
26use Wikimedia\FileBackend\FileBackend;
27use Wikimedia\RequestTimeout\TimeoutException;
28
29/**
30 * FileBackend helper class for representing operations.
31 * Do not use this class from places outside FileBackend.
32 *
33 * Methods called from FileOpBatch::attempt() should avoid throwing
34 * exceptions at all costs. FileOp objects should be lightweight in order
35 * to support large arrays in memory and serialization.
36 *
37 * @ingroup FileBackend
38 * @since 1.19
39 */
40abstract class FileOp {
41    /** @var FileBackendStore */
42    protected $backend;
43    /** @var LoggerInterface */
44    protected $logger;
45
46    /** @var array */
47    protected $params = [];
48
49    /** @var int Stage in the operation life-cycle */
50    protected $state = self::STATE_NEW;
51    /** @var bool Whether the operation pre-check or attempt stage failed */
52    protected $failed = false;
53    /** @var bool Whether the operation is part of a concurrent sub-batch of operation */
54    protected $async = false;
55    /** @var bool Whether the operation pre-check stage marked the attempt stage as a no-op */
56    protected $noOp = false;
57
58    /** @var bool|null */
59    protected $overwriteSameCase;
60    /** @var bool|null */
61    protected $destExists;
62
63    /** Operation has not yet been pre-checked nor run */
64    private const STATE_NEW = 1;
65    /** Operation has been pre-checked but not yet attempted */
66    private const STATE_CHECKED = 2;
67    /** Operation has been attempted */
68    private const STATE_ATTEMPTED = 3;
69
70    /**
71     * Build a new batch file operation transaction
72     *
73     * @param FileBackendStore $backend
74     * @param array $params
75     * @param LoggerInterface $logger PSR logger instance
76     * @throws InvalidArgumentException
77     */
78    final public function __construct(
79        FileBackendStore $backend, array $params, LoggerInterface $logger
80    ) {
81        $this->backend = $backend;
82        $this->logger = $logger;
83        [ $required, $optional, $paths ] = $this->allowedParams();
84        foreach ( $required as $name ) {
85            if ( isset( $params[$name] ) ) {
86                $this->params[$name] = $params[$name];
87            } else {
88                throw new InvalidArgumentException( "File operation missing parameter '$name'." );
89            }
90        }
91        foreach ( $optional as $name ) {
92            if ( isset( $params[$name] ) ) {
93                $this->params[$name] = $params[$name];
94            }
95        }
96        foreach ( $paths as $name ) {
97            if ( isset( $this->params[$name] ) ) {
98                // Normalize paths so the paths to the same file have the same string
99                $this->params[$name] = self::normalizeIfValidStoragePath( $this->params[$name] );
100            }
101        }
102    }
103
104    /**
105     * Normalize a string if it is a valid storage path
106     *
107     * @param string $path
108     * @return string
109     */
110    protected static function normalizeIfValidStoragePath( $path ) {
111        if ( FileBackend::isStoragePath( $path ) ) {
112            $res = FileBackend::normalizeStoragePath( $path );
113
114            return $res ?? $path;
115        }
116
117        return $path;
118    }
119
120    /**
121     * Get the value of the parameter with the given name
122     *
123     * @param string $name
124     * @return mixed Returns null if the parameter is not set
125     */
126    final public function getParam( $name ) {
127        return $this->params[$name] ?? null;
128    }
129
130    /**
131     * Check if this operation failed precheck() or attempt()
132     *
133     * @return bool
134     */
135    final public function failed() {
136        return $this->failed;
137    }
138
139    /**
140     * Get a new empty dependency tracking array for paths read/written to
141     *
142     * @return array
143     */
144    final public static function newDependencies() {
145        return [ 'read' => [], 'write' => [] ];
146    }
147
148    /**
149     * Update a dependency tracking array to account for this operation
150     *
151     * @param array $deps Prior path reads/writes; format of FileOp::newDependencies()
152     * @return array
153     */
154    final public function applyDependencies( array $deps ) {
155        $deps['read'] += array_fill_keys( $this->storagePathsRead(), 1 );
156        $deps['write'] += array_fill_keys( $this->storagePathsChanged(), 1 );
157
158        return $deps;
159    }
160
161    /**
162     * Check if this operation changes files listed in $paths
163     *
164     * @param array $deps Prior path reads/writes; format of FileOp::newDependencies()
165     * @return bool
166     */
167    final public function dependsOn( array $deps ) {
168        foreach ( $this->storagePathsChanged() as $path ) {
169            if ( isset( $deps['read'][$path] ) || isset( $deps['write'][$path] ) ) {
170                return true; // "output" or "anti" dependency
171            }
172        }
173        foreach ( $this->storagePathsRead() as $path ) {
174            if ( isset( $deps['write'][$path] ) ) {
175                return true; // "flow" dependency
176            }
177        }
178
179        return false;
180    }
181
182    /**
183     * Do a dry-run precondition check of the operation in the context of op batch
184     *
185     * Updates the batch predicates for all paths this op can change if an OK status is returned
186     *
187     * @param FileStatePredicates $predicates Counterfactual file states for the op batch
188     * @return StatusValue
189     */
190    final public function precheck( FileStatePredicates $predicates ) {
191        if ( $this->state !== self::STATE_NEW ) {
192            return StatusValue::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state );
193        }
194        $this->state = self::STATE_CHECKED;
195
196        $status = StatusValue::newGood();
197        foreach ( $this->storagePathsReadOrChanged() as $path ) {
198            if ( !$this->backend->isPathUsableInternal( $path ) ) {
199                $status->fatal( 'backend-fail-usable', $path );
200            }
201        }
202        if ( !$status->isOK() ) {
203            return $status;
204        }
205
206        $opPredicates = $predicates->snapshot( $this->storagePathsReadOrChanged() );
207        $status = $this->doPrecheck( $opPredicates, $predicates );
208        if ( !$status->isOK() ) {
209            $this->failed = true;
210        }
211
212        return $status;
213    }
214
215    /**
216     * Do a dry-run precondition check of the operation in the context of op batch
217     *
218     * Updates the batch predicates for all paths this op can change if an OK status is returned
219     *
220     * @param FileStatePredicates $opPredicates Counterfactual file states for op paths at op start
221     * @param FileStatePredicates $batchPredicates Counterfactual file states for the op batch
222     * @return StatusValue
223     */
224    protected function doPrecheck(
225        FileStatePredicates $opPredicates,
226        FileStatePredicates $batchPredicates
227    ) {
228        return StatusValue::newGood();
229    }
230
231    /**
232     * Attempt the operation
233     *
234     * @return StatusValue
235     */
236    final public function attempt() {
237        if ( $this->state !== self::STATE_CHECKED ) {
238            return StatusValue::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state );
239        } elseif ( $this->failed ) { // failed precheck
240            return StatusValue::newFatal( 'fileop-fail-attempt-precheck' );
241        }
242        $this->state = self::STATE_ATTEMPTED;
243        if ( $this->noOp ) {
244            $status = StatusValue::newGood(); // no-op
245        } else {
246            $status = $this->doAttempt();
247            if ( !$status->isOK() ) {
248                $this->failed = true;
249                $this->logFailure( 'attempt' );
250            }
251        }
252
253        return $status;
254    }
255
256    /**
257     * @return StatusValue
258     */
259    protected function doAttempt() {
260        return StatusValue::newGood();
261    }
262
263    /**
264     * Attempt the operation in the background
265     *
266     * @return StatusValue
267     */
268    final public function attemptAsync() {
269        $this->async = true;
270        $result = $this->attempt();
271        $this->async = false;
272
273        return $result;
274    }
275
276    /**
277     * Attempt the operation without regards to prechecks
278     *
279     * @return StatusValue
280     */
281    final public function attemptQuick() {
282        $this->state = self::STATE_CHECKED; // bypassed
283
284        return $this->attempt();
285    }
286
287    /**
288     * Attempt the operation in the background without regards to prechecks
289     *
290     * @return StatusValue
291     */
292    final public function attemptAsyncQuick() {
293        $this->state = self::STATE_CHECKED; // bypassed
294
295        return $this->attemptAsync();
296    }
297
298    /**
299     * Get the file operation parameters
300     *
301     * @return array (required params list, optional params list, list of params that are paths)
302     */
303    protected function allowedParams() {
304        return [ [], [], [] ];
305    }
306
307    /**
308     * Adjust params to FileBackendStore internal file calls
309     *
310     * @param array $params
311     * @return array (required params list, optional params list)
312     */
313    protected function setFlags( array $params ) {
314        return [ 'async' => $this->async ] + $params;
315    }
316
317    /**
318     * Get a list of storage paths read from for this operation
319     *
320     * @return array
321     */
322    public function storagePathsRead() {
323        return [];
324    }
325
326    /**
327     * Get a list of storage paths written to for this operation
328     *
329     * @return array
330     */
331    public function storagePathsChanged() {
332        return [];
333    }
334
335    /**
336     * Get a list of storage paths read from or written to for this operation
337     *
338     * @return array
339     */
340    final public function storagePathsReadOrChanged() {
341        return array_values( array_unique(
342            array_merge( $this->storagePathsRead(), $this->storagePathsChanged() )
343        ) );
344    }
345
346    /**
347     * Check for errors with regards to the destination file already existing
348     *
349     * Also set the destExists and overwriteSameCase member variables.
350     * A bad StatusValue will be returned if there is no chance it can be overwritten.
351     *
352     * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
353     * @param int|false|Closure $sourceSize Source size or idempotent function yielding the size
354     * @param string|Closure $sourceSha1 Source hash, or, idempotent function yielding the hash
355     * @return StatusValue
356     */
357    protected function precheckDestExistence(
358        FileStatePredicates $opPredicates,
359        $sourceSize,
360        $sourceSha1
361    ) {
362        $status = StatusValue::newGood();
363        // Record the existence of destination file
364        $this->destExists = $this->resolveFileExistence( $this->params['dst'], $opPredicates );
365        // Check if an incompatible file exists at the destination
366        $this->overwriteSameCase = false;
367        if ( $this->destExists ) {
368            if ( $this->getParam( 'overwrite' ) ) {
369                return $status; // OK, no conflict
370            } elseif ( $this->getParam( 'overwriteSame' ) ) {
371                // Operation does nothing other than return an OK or bad status
372                $sourceSize = ( $sourceSize instanceof Closure ) ? $sourceSize() : $sourceSize;
373                $sourceSha1 = ( $sourceSha1 instanceof Closure ) ? $sourceSha1() : $sourceSha1;
374                $dstSha1 = $this->resolveFileSha1Base36( $this->params['dst'], $opPredicates );
375                $dstSize = $this->resolveFileSize( $this->params['dst'], $opPredicates );
376                // Check if hashes are valid and match each other...
377                if ( !strlen( $sourceSha1 ) || !strlen( $dstSha1 ) ) {
378                    $status->fatal( 'backend-fail-hashes' );
379                } elseif ( !is_int( $sourceSize ) || !is_int( $dstSize ) ) {
380                    $status->fatal( 'backend-fail-sizes' );
381                } elseif ( $sourceSha1 !== $dstSha1 || $sourceSize !== $dstSize ) {
382                    // Give an error if the files are not identical
383                    $status->fatal( 'backend-fail-notsame', $this->params['dst'] );
384                } else {
385                    $this->overwriteSameCase = true; // OK
386                }
387            } else {
388                $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
389            }
390        } elseif ( $this->destExists === FileBackend::EXISTENCE_ERROR ) {
391            $status->fatal( 'backend-fail-stat', $this->params['dst'] );
392        }
393
394        return $status;
395    }
396
397    /**
398     * Check if a file will exist in storage when this operation is attempted
399     *
400     * Ideally, the file stat entry should already be preloaded via preloadFileStat().
401     * Otherwise, this will query the backend.
402     *
403     * @param string $source Storage path
404     * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
405     * @return bool|null Whether the file will exist or null on error
406     */
407    final protected function resolveFileExistence( $source, FileStatePredicates $opPredicates ) {
408        return $opPredicates->resolveFileExistence(
409            $source,
410            function ( $path ) {
411                return $this->backend->fileExists( [ 'src' => $path, 'latest' => true ] );
412            }
413        );
414    }
415
416    /**
417     * Get the size a file in storage will have when this operation is attempted
418     *
419     * Ideally, file the stat entry should already be preloaded via preloadFileStat() and
420     * the backend tracks hashes as extended attributes. Otherwise, this will query the backend.
421     * Get the size of a file in storage when this operation is attempted
422     *
423     * @param string $source Storage path
424     * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
425     * @return int|false False on failure
426     */
427    final protected function resolveFileSize( $source, FileStatePredicates $opPredicates ) {
428        return $opPredicates->resolveFileSize(
429            $source,
430            function ( $path ) {
431                return $this->backend->getFileSize( [ 'src' => $path, 'latest' => true ] );
432            }
433        );
434    }
435
436    /**
437     * Get the SHA-1 of a file in storage when this operation is attempted
438     *
439     * @param string $source Storage path
440     * @param FileStatePredicates $opPredicates Counterfactual storage path states for this op
441     * @return string|false The SHA-1 hash the file will have or false if non-existent or on error
442     */
443    final protected function resolveFileSha1Base36( $source, FileStatePredicates $opPredicates ) {
444        return $opPredicates->resolveFileSha1Base36(
445            $source,
446            function ( $path ) {
447                return $this->backend->getFileSha1Base36( [ 'src' => $path, 'latest' => true ] );
448            }
449        );
450    }
451
452    /**
453     * Get the backend this operation is for
454     *
455     * @return FileBackendStore
456     */
457    public function getBackend() {
458        return $this->backend;
459    }
460
461    /**
462     * Log a file operation failure and preserve any temp files
463     *
464     * @param string $action
465     */
466    final public function logFailure( $action ) {
467        $params = $this->params;
468        $params['failedAction'] = $action;
469        try {
470            $this->logger->error( static::class .
471                " failed: " . FormatJson::encode( $params ) );
472        } catch ( TimeoutException $e ) {
473            throw $e;
474        } catch ( Exception $e ) {
475            // bad config? debug log error?
476        }
477    }
478}