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