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