Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.86% covered (warning)
68.86%
199 / 289
74.36% covered (warning)
74.36%
29 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileBackendMultiWrite
69.10% covered (warning)
69.10%
199 / 288
74.36% covered (warning)
74.36%
29 / 39
388.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
10.36
 doOperationsInternal
70.00% covered (warning)
70.00%
28 / 40
0.00% covered (danger)
0.00%
0 / 1
11.19
 resyncFiles
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
272
 fileStoragePathsForOps
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 substOpBatchPaths
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 substOpPaths
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 substPaths
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 unsubstPaths
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 hasVolatileSources
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 doQuickOperationsInternal
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 doPrepare
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doSecure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doPublish
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doClean
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doDirectoryOp
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
4.49
 concatenate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fileExists
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFileTimestamp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFileSize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFileStat
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFileXAttributes
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getFileContentsMulti
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getFileSha1Base36
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFileProps
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 streamFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getLocalReferenceMulti
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getLocalCopyMulti
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getFileHttpUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addShellboxInputFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 directoryExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDirectoryList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFileList
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getFileListForWrite
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getFeatures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearCache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 preloadCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 preloadFileStat
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getScopedLocksForOps
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getReadIndexFromParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Proxy backend that mirrors writes to several internal backends.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup FileBackend
8 */
9
10namespace Wikimedia\FileBackend;
11
12use InvalidArgumentException;
13use LogicException;
14use Shellbox\Command\BoxedCommand;
15use StatusValue;
16use Wikimedia\LockManager\LockManager;
17use Wikimedia\StringUtils\StringUtils;
18
19/**
20 * @brief Proxy backend that mirrors writes to several internal backends.
21 *
22 * This class defines a multi-write backend. Multiple backends can be
23 * registered to this proxy backend and it will act as a single backend.
24 * Use this when all access to those backends is through this proxy backend.
25 * At least one of the backends must be declared the "master" backend.
26 *
27 * Only use this class when transitioning from one storage system to another.
28 *
29 * Read operations are only done on the 'master' backend for consistency.
30 * Except on getting list of thumbnails for write operations.
31 * Write operations are performed on all backends, starting with the master.
32 * This makes a best-effort to have transactional semantics.
33 *
34 * @ingroup FileBackend
35 * @since 1.19
36 */
37class FileBackendMultiWrite extends FileBackend {
38    /** @var FileBackendStore[] Prioritized list of FileBackendStore objects */
39    protected $backends = [];
40
41    /** @var int Index of master backend */
42    protected $masterIndex = -1;
43    /** @var int Index of read affinity backend */
44    protected $readIndex = -1;
45
46    /** @var bool */
47    protected $asyncWrites = false;
48
49    /**
50     * Construct a proxy backend that consists of several internal backends.
51     * Locking and read-only checks are handled by the proxy backend.
52     *
53     * Additional $config params include:
54     *   - backends       : Array of backend config and multi-backend settings.
55     *                      Each value is the config used in the constructor of a
56     *                      FileBackendStore class, but with these additional settings:
57     *                        - class         : The name of the backend class
58     *                        - isMultiMaster : This must be set for one backend.
59     *                        - readAffinity  : Use this for reads without 'latest' set.
60     *   - replication    : Set to 'async' to defer file operations on the non-master backends.
61     *                      This will apply such updates post-send for web requests.
62     */
63    public function __construct( array $config ) {
64        parent::__construct( $config );
65        $this->asyncWrites = isset( $config['replication'] ) && $config['replication'] === 'async';
66        // Construct backends here rather than via registration
67        // to keep these backends hidden from outside the proxy.
68        $namesUsed = [];
69        foreach ( $config['backends'] as $index => $beConfig ) {
70            $name = $beConfig['name'];
71            if ( isset( $namesUsed[$name] ) ) { // don't break FileOp predicates
72                throw new LogicException( "Two or more backends defined with the name $name." );
73            }
74            $namesUsed[$name] = 1;
75            // Alter certain sub-backend settings
76            unset( $beConfig['readOnly'] ); // use proxy backend setting
77            unset( $beConfig['lockManager'] ); // lock under proxy backend
78            $beConfig['domainId'] = $this->domainId; // use the proxy backend wiki ID
79            $beConfig['logger'] = $this->logger; // use the proxy backend logger
80            if ( !empty( $beConfig['isMultiMaster'] ) ) {
81                if ( $this->masterIndex >= 0 ) {
82                    throw new LogicException( 'More than one master backend defined.' );
83                }
84                $this->masterIndex = $index; // this is the "master"
85            }
86            if ( !empty( $beConfig['readAffinity'] ) ) {
87                $this->readIndex = $index; // prefer this for reads
88            }
89            // Create sub-backend object
90            if ( !isset( $beConfig['class'] ) ) {
91                throw new InvalidArgumentException( 'No class given for a backend config.' );
92            }
93            $class = $beConfig['class'];
94            $this->backends[$index] = new $class( $beConfig );
95        }
96        if ( $this->masterIndex < 0 ) { // need backends and must have a master
97            throw new LogicException( 'No master backend defined.' );
98        }
99        if ( $this->readIndex < 0 ) {
100            $this->readIndex = $this->masterIndex; // default
101        }
102    }
103
104    /** @inheritDoc */
105    final protected function doOperationsInternal( array $ops, array $opts ) {
106        $status = $this->newStatus();
107
108        $fname = __METHOD__;
109        $mbe = $this->backends[$this->masterIndex]; // convenience
110
111        // Acquire any locks as needed
112        $scopeLock = null;
113        if ( empty( $opts['nonLocking'] ) ) {
114            $scopeLock = $this->getScopedLocksForOps( $ops, $status );
115            if ( !$status->isOK() ) {
116                return $status; // abort
117            }
118        }
119        // Get the list of paths to read/write
120        $relevantPaths = $this->fileStoragePathsForOps( $ops );
121        // Clear any cache entries (after locks acquired)
122        $this->clearCache( $relevantPaths );
123        $opts['preserveCache'] = true;
124        // Actually attempt the operation batch on the master backend
125        $realOps = $this->substOpBatchPaths( $ops, $mbe );
126        $masterStatus = $mbe->doOperations( $realOps, $opts );
127        $status->merge( $masterStatus );
128        // Propagate the operations to the clone backends if there were no unexpected errors
129        // and everything didn't fail due to predicted errors. If $ops only had one operation,
130        // this might avoid backend sync inconsistencies.
131        if ( $masterStatus->isOK() && $masterStatus->successCount > 0 ) {
132            foreach ( $this->backends as $index => $backend ) {
133                if ( $index === $this->masterIndex ) {
134                    continue; // done already
135                }
136
137                $realOps = $this->substOpBatchPaths( $ops, $backend );
138                if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) {
139                    // Bind $scopeLock to the callback to preserve locks
140                    $this->callNowOrLater(
141                        function () use (
142                            // @phan-suppress-next-line PhanUnusedClosureUseVariable
143                            $backend, $realOps, $opts, $scopeLock, $relevantPaths, $fname
144                        ) {
145                            $this->logger->debug(
146                                "$fname: '{$backend->getName()}' async replication; paths: " .
147                                implode( ', ', $relevantPaths )
148                            );
149                            $backend->doOperations( $realOps, $opts );
150                        }
151                    );
152                } else {
153                    $this->logger->debug(
154                        "$fname: '{$backend->getName()}' sync replication; paths: " .
155                        implode( ', ', $relevantPaths )
156                    );
157                    $status->merge( $backend->doOperations( $realOps, $opts ) );
158                }
159            }
160        }
161        // Make 'success', 'successCount', and 'failCount' fields reflect
162        // the overall operation, rather than all the batches for each backend.
163        // Do this by only using success values from the master backend's batch.
164        $status->success = $masterStatus->success;
165        $status->successCount = $masterStatus->successCount;
166        $status->failCount = $masterStatus->failCount;
167
168        return $status;
169    }
170
171    /**
172     * Check that a set of files are consistent across all internal backends
173     * and re-synchronize those files against the "multi master" if needed.
174     *
175     * This method should only be called if the files are locked
176     *
177     * @param string[] $paths List of storage paths
178     * @param string|bool $resyncMode False, True, or "conservative"; see __construct()
179     * @return StatusValue
180     */
181    public function resyncFiles( array $paths, $resyncMode = true ) {
182        $status = $this->newStatus();
183
184        $fname = __METHOD__;
185        foreach ( $paths as $path ) {
186            $params = [ 'src' => $path, 'latest' => true ];
187            // Get the state of the file on the master backend
188            $masterBackend = $this->backends[$this->masterIndex];
189            $masterParams = $this->substOpPaths( $params, $masterBackend );
190            $masterPath = $masterParams['src'];
191            $masterStat = $masterBackend->getFileStat( $masterParams );
192            if ( $masterStat === self::STAT_ERROR ) {
193                $status->fatal( 'backend-fail-stat', $path );
194                $this->logger->error( "$fname: file '$masterPath' is not available" );
195                continue;
196            }
197            $masterSha1 = $masterBackend->getFileSha1Base36( $masterParams );
198            if ( ( $masterSha1 !== false ) !== (bool)$masterStat ) {
199                $status->fatal( 'backend-fail-hash', $path );
200                $this->logger->error( "$fname: file '$masterPath' hash does not match stat" );
201                continue;
202            }
203
204            // Check of all clone backends agree with the master...
205            foreach ( $this->backends as $index => $cloneBackend ) {
206                if ( $index === $this->masterIndex ) {
207                    continue; // master
208                }
209
210                // Get the state of the file on the clone backend
211                $cloneParams = $this->substOpPaths( $params, $cloneBackend );
212                $clonePath = $cloneParams['src'];
213                $cloneStat = $cloneBackend->getFileStat( $cloneParams );
214                if ( $cloneStat === self::STAT_ERROR ) {
215                    $status->fatal( 'backend-fail-stat', $path );
216                    $this->logger->error( "$fname: file '$clonePath' is not available" );
217                    continue;
218                }
219                $cloneSha1 = $cloneBackend->getFileSha1Base36( $cloneParams );
220                if ( ( $cloneSha1 !== false ) !== (bool)$cloneStat ) {
221                    $status->fatal( 'backend-fail-hash', $path );
222                    $this->logger->error( "$fname: file '$clonePath' hash does not match stat" );
223                    continue;
224                }
225
226                if ( $masterSha1 === $cloneSha1 ) {
227                    // File is either the same in both backends or absent from both backends
228                    $this->logger->debug( "$fname: file '$clonePath' matches '$masterPath'" );
229                } elseif ( $masterSha1 !== false ) {
230                    // File is either missing from or different in the clone backend
231                    if (
232                        $resyncMode === 'conservative' &&
233                        $cloneStat &&
234                        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
235                        $cloneStat['mtime'] > $masterStat['mtime']
236                    ) {
237                        // Do not replace files with older ones; reduces the risk of data loss
238                        $status->fatal( 'backend-fail-synced', $path );
239                    } else {
240                        // Copy the master backend file to the clone backend in overwrite mode
241                        $fsFile = $masterBackend->getLocalReference( $masterParams );
242                        $status->merge( $cloneBackend->quickStore( [
243                            'src' => $fsFile,
244                            'dst' => $clonePath
245                        ] ) );
246                    }
247                } elseif ( $masterStat === false ) {
248                    // Stray file exists in the clone backend
249                    if ( $resyncMode === 'conservative' ) {
250                        // Do not delete stray files; reduces the risk of data loss
251                        $status->fatal( 'backend-fail-synced', $path );
252                        $this->logger->error( "$fname: not allowed to delete file '$clonePath'" );
253                    } else {
254                        // Delete the stay file from the clone backend
255                        $status->merge( $cloneBackend->quickDelete( [ 'src' => $clonePath ] ) );
256                    }
257                }
258            }
259        }
260
261        if ( !$status->isOK() ) {
262            $this->logger->error( "$fname: failed to resync: " . implode( ', ', $paths ) );
263        }
264
265        return $status;
266    }
267
268    /**
269     * Get a list of file storage paths to read or write for a list of operations
270     *
271     * @param array $ops Same format as doOperations()
272     * @return array List of storage paths to files (does not include directories)
273     */
274    protected function fileStoragePathsForOps( array $ops ) {
275        $paths = [];
276        foreach ( $ops as $op ) {
277            if ( isset( $op['src'] ) ) {
278                $paths[] = $op['src'];
279            }
280            if ( isset( $op['srcs'] ) ) {
281                $paths = array_merge( $paths, $op['srcs'] );
282            }
283            if ( isset( $op['dst'] ) ) {
284                $paths[] = $op['dst'];
285            }
286        }
287
288        return array_values( array_unique( array_filter(
289            $paths,
290            FileBackend::isStoragePath( ... )
291        ) ) );
292    }
293
294    /**
295     * Substitute the backend name in storage path parameters
296     * for a set of operations with that of a given internal backend.
297     *
298     * @param array $ops List of file operation arrays
299     * @param FileBackendStore $backend
300     * @return array
301     */
302    protected function substOpBatchPaths( array $ops, FileBackendStore $backend ) {
303        $newOps = []; // operations
304        foreach ( $ops as $op ) {
305            $newOp = $op; // operation
306            foreach ( [ 'src', 'srcs', 'dst', 'dir' ] as $par ) {
307                if ( isset( $newOp[$par] ) ) { // string or array
308                    $newOp[$par] = $this->substPaths( $newOp[$par], $backend );
309                }
310            }
311            $newOps[] = $newOp;
312        }
313
314        return $newOps;
315    }
316
317    /**
318     * Same as substOpBatchPaths() but for a single operation
319     *
320     * @param array $ops File operation array
321     * @param FileBackendStore $backend
322     * @return array
323     */
324    protected function substOpPaths( array $ops, FileBackendStore $backend ) {
325        $newOps = $this->substOpBatchPaths( [ $ops ], $backend );
326
327        return $newOps[0];
328    }
329
330    /**
331     * Substitute the backend of storage paths with an internal backend's name
332     *
333     * @param string[]|string $paths List of paths or single string path
334     * @param FileBackendStore $backend
335     * @return string[]|string
336     */
337    protected function substPaths( $paths, FileBackendStore $backend ) {
338        return preg_replace(
339            '!^mwstore://' . preg_quote( $this->name, '!' ) . '/!',
340            StringUtils::escapeRegexReplacement( "mwstore://{$backend->getName()}/" ),
341            $paths // string or array
342        );
343    }
344
345    /**
346     * Substitute the backend of internal storage paths with the proxy backend's name
347     *
348     * @param string[]|string $paths List of paths or single string path
349     * @param FileBackendStore $backend internal storage backend
350     * @return string[]|string
351     */
352    protected function unsubstPaths( $paths, FileBackendStore $backend ) {
353        return preg_replace(
354            '!^mwstore://' . preg_quote( $backend->getName(), '!' ) . '/!',
355            StringUtils::escapeRegexReplacement( "mwstore://{$this->name}/" ),
356            $paths // string or array
357        );
358    }
359
360    /**
361     * @param array[] $ops File operations for FileBackend::doOperations()
362     * @return bool Whether there are file path sources with outside lifetime/ownership
363     */
364    protected function hasVolatileSources( array $ops ) {
365        foreach ( $ops as $op ) {
366            if ( $op['op'] === 'store' && !isset( $op['srcRef'] ) ) {
367                return true; // source file might be deleted anytime after do*Operations()
368            }
369        }
370
371        return false;
372    }
373
374    /** @inheritDoc */
375    protected function doQuickOperationsInternal( array $ops, array $opts ) {
376        $status = $this->newStatus();
377        // Do the operations on the master backend; setting StatusValue fields
378        $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] );
379        $masterStatus = $this->backends[$this->masterIndex]->doQuickOperations( $realOps );
380        $status->merge( $masterStatus );
381        // Propagate the operations to the clone backends...
382        foreach ( $this->backends as $index => $backend ) {
383            if ( $index === $this->masterIndex ) {
384                continue; // done already
385            }
386
387            $realOps = $this->substOpBatchPaths( $ops, $backend );
388            if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) {
389                $this->callNowOrLater(
390                    static function () use ( $backend, $realOps ) {
391                        $backend->doQuickOperations( $realOps );
392                    }
393                );
394            } else {
395                $status->merge( $backend->doQuickOperations( $realOps ) );
396            }
397        }
398        // Make 'success', 'successCount', and 'failCount' fields reflect
399        // the overall operation, rather than all the batches for each backend.
400        // Do this by only using success values from the master backend's batch.
401        $status->success = $masterStatus->success;
402        $status->successCount = $masterStatus->successCount;
403        $status->failCount = $masterStatus->failCount;
404
405        return $status;
406    }
407
408    /** @inheritDoc */
409    protected function doPrepare( array $params ) {
410        return $this->doDirectoryOp( 'prepare', $params );
411    }
412
413    /** @inheritDoc */
414    protected function doSecure( array $params ) {
415        return $this->doDirectoryOp( 'secure', $params );
416    }
417
418    /** @inheritDoc */
419    protected function doPublish( array $params ) {
420        return $this->doDirectoryOp( 'publish', $params );
421    }
422
423    /** @inheritDoc */
424    protected function doClean( array $params ) {
425        return $this->doDirectoryOp( 'clean', $params );
426    }
427
428    /**
429     * @param string $method One of (doPrepare,doSecure,doPublish,doClean)
430     * @param array $params Method arguments
431     * @return StatusValue
432     */
433    protected function doDirectoryOp( $method, array $params ) {
434        $status = $this->newStatus();
435
436        $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
437        $masterStatus = $this->backends[$this->masterIndex]->$method( $realParams );
438        $status->merge( $masterStatus );
439
440        foreach ( $this->backends as $index => $backend ) {
441            if ( $index === $this->masterIndex ) {
442                continue; // already done
443            }
444
445            $realParams = $this->substOpPaths( $params, $backend );
446            if ( $this->asyncWrites ) {
447                $this->callNowOrLater(
448                    static function () use ( $backend, $method, $realParams ) {
449                        $backend->$method( $realParams );
450                    }
451                );
452            } else {
453                $status->merge( $backend->$method( $realParams ) );
454            }
455        }
456
457        return $status;
458    }
459
460    /** @inheritDoc */
461    public function concatenate( array $params ) {
462        $status = $this->newStatus();
463        // We are writing to an FS file, so we don't need to do this per-backend
464        $index = $this->getReadIndexFromParams( $params );
465        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
466
467        $status->merge( $this->backends[$index]->concatenate( $realParams ) );
468
469        return $status;
470    }
471
472    /** @inheritDoc */
473    public function fileExists( array $params ) {
474        $index = $this->getReadIndexFromParams( $params );
475        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
476
477        return $this->backends[$index]->fileExists( $realParams );
478    }
479
480    /** @inheritDoc */
481    public function getFileTimestamp( array $params ) {
482        $index = $this->getReadIndexFromParams( $params );
483        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
484
485        return $this->backends[$index]->getFileTimestamp( $realParams );
486    }
487
488    /** @inheritDoc */
489    public function getFileSize( array $params ) {
490        $index = $this->getReadIndexFromParams( $params );
491        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
492
493        return $this->backends[$index]->getFileSize( $realParams );
494    }
495
496    /** @inheritDoc */
497    public function getFileStat( array $params ) {
498        $index = $this->getReadIndexFromParams( $params );
499        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
500
501        return $this->backends[$index]->getFileStat( $realParams );
502    }
503
504    /** @inheritDoc */
505    public function getFileXAttributes( array $params ) {
506        $index = $this->getReadIndexFromParams( $params );
507        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
508
509        return $this->backends[$index]->getFileXAttributes( $realParams );
510    }
511
512    /** @inheritDoc */
513    public function getFileContentsMulti( array $params ) {
514        $index = $this->getReadIndexFromParams( $params );
515        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
516
517        $contentsM = $this->backends[$index]->getFileContentsMulti( $realParams );
518
519        $contents = []; // (path => FSFile) mapping using the proxy backend's name
520        foreach ( $contentsM as $path => $data ) {
521            $contents[$this->unsubstPaths( $path, $this->backends[$index] )] = $data;
522        }
523
524        return $contents;
525    }
526
527    /** @inheritDoc */
528    public function getFileSha1Base36( array $params ) {
529        $index = $this->getReadIndexFromParams( $params );
530        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
531
532        return $this->backends[$index]->getFileSha1Base36( $realParams );
533    }
534
535    /** @inheritDoc */
536    public function getFileProps( array $params ) {
537        $index = $this->getReadIndexFromParams( $params );
538        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
539
540        return $this->backends[$index]->getFileProps( $realParams );
541    }
542
543    /** @inheritDoc */
544    public function streamFile( array $params ) {
545        $index = $this->getReadIndexFromParams( $params );
546        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
547
548        return $this->backends[$index]->streamFile( $realParams );
549    }
550
551    /** @inheritDoc */
552    public function getLocalReferenceMulti( array $params ) {
553        $index = $this->getReadIndexFromParams( $params );
554        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
555
556        $fsFilesM = $this->backends[$index]->getLocalReferenceMulti( $realParams );
557
558        $fsFiles = []; // (path => FSFile) mapping using the proxy backend's name
559        foreach ( $fsFilesM as $path => $fsFile ) {
560            $fsFiles[$this->unsubstPaths( $path, $this->backends[$index] )] = $fsFile;
561        }
562
563        return $fsFiles;
564    }
565
566    /** @inheritDoc */
567    public function getLocalCopyMulti( array $params ) {
568        $index = $this->getReadIndexFromParams( $params );
569        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
570
571        $tempFilesM = $this->backends[$index]->getLocalCopyMulti( $realParams );
572
573        $tempFiles = []; // (path => TempFSFile) mapping using the proxy backend's name
574        foreach ( $tempFilesM as $path => $tempFile ) {
575            $tempFiles[$this->unsubstPaths( $path, $this->backends[$index] )] = $tempFile;
576        }
577
578        return $tempFiles;
579    }
580
581    /** @inheritDoc */
582    public function getFileHttpUrl( array $params ) {
583        $index = $this->getReadIndexFromParams( $params );
584        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
585
586        return $this->backends[$index]->getFileHttpUrl( $realParams );
587    }
588
589    /** @inheritDoc */
590    public function addShellboxInputFile( BoxedCommand $command, string $boxedName,
591        array $params
592    ) {
593        $index = $this->getReadIndexFromParams( $params );
594        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
595        return $this->backends[$index]->addShellboxInputFile( $command, $boxedName, $realParams );
596    }
597
598    /** @inheritDoc */
599    public function directoryExists( array $params ) {
600        $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
601
602        return $this->backends[$this->masterIndex]->directoryExists( $realParams );
603    }
604
605    /** @inheritDoc */
606    public function getDirectoryList( array $params ) {
607        $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
608
609        return $this->backends[$this->masterIndex]->getDirectoryList( $realParams );
610    }
611
612    /** @inheritDoc */
613    public function getFileList( array $params ) {
614        if ( isset( $params['forWrite'] ) && $params['forWrite'] ) {
615            return $this->getFileListForWrite( $params );
616        }
617
618        $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
619        return $this->backends[$this->masterIndex]->getFileList( $realParams );
620    }
621
622    private function getFileListForWrite( array $params ): array {
623        $files = [];
624        // Get the list of thumbnails from all backends to allow
625        // deleting all of them. Otherwise, old thumbnails existing on
626        // one backend only won't get updated in reupload (T331138).
627        foreach ( $this->backends as $backend ) {
628            $realParams = $this->substOpPaths( $params, $backend );
629            $iterator = $backend->getFileList( $realParams );
630            if ( $iterator !== null ) {
631                foreach ( $iterator as $file ) {
632                    $files[] = $file;
633                }
634            }
635        }
636
637        return array_unique( $files );
638    }
639
640    /** @inheritDoc */
641    public function getFeatures() {
642        return $this->backends[$this->masterIndex]->getFeatures();
643    }
644
645    /** @inheritDoc */
646    public function clearCache( ?array $paths = null ) {
647        foreach ( $this->backends as $backend ) {
648            $realPaths = is_array( $paths ) ? $this->substPaths( $paths, $backend ) : null;
649            $backend->clearCache( $realPaths );
650        }
651    }
652
653    /** @inheritDoc */
654    public function preloadCache( array $paths ) {
655        $realPaths = $this->substPaths( $paths, $this->backends[$this->readIndex] );
656        $this->backends[$this->readIndex]->preloadCache( $realPaths );
657    }
658
659    /** @inheritDoc */
660    public function preloadFileStat( array $params ) {
661        $index = $this->getReadIndexFromParams( $params );
662        $realParams = $this->substOpPaths( $params, $this->backends[$index] );
663
664        return $this->backends[$index]->preloadFileStat( $realParams );
665    }
666
667    /** @inheritDoc */
668    public function getScopedLocksForOps( array $ops, StatusValue $status ) {
669        $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] );
670        $fileOps = $this->backends[$this->masterIndex]->getOperationsInternal( $realOps );
671        // Get the paths to lock from the master backend
672        $paths = $this->backends[$this->masterIndex]->getPathsToLockForOpsInternal( $fileOps );
673        // Get the paths under the proxy backend's name
674        $pbPaths = [
675            LockManager::LOCK_UW => $this->unsubstPaths(
676                $paths[LockManager::LOCK_UW],
677                $this->backends[$this->masterIndex]
678            ),
679            LockManager::LOCK_EX => $this->unsubstPaths(
680                $paths[LockManager::LOCK_EX],
681                $this->backends[$this->masterIndex]
682            )
683        ];
684
685        // Actually acquire the locks
686        return $this->getScopedFileLocks( $pbPaths, 'mixed', $status );
687    }
688
689    /**
690     * @param array $params
691     * @return int The master or read affinity backend index, based on $params['latest']
692     */
693    protected function getReadIndexFromParams( array $params ) {
694        return !empty( $params['latest'] ) ? $this->masterIndex : $this->readIndex;
695    }
696}
697
698/** @deprecated class alias since 1.43 */
699class_alias( FileBackendMultiWrite::class, 'FileBackendMultiWrite' );