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 Wikimedia\Timestamp\ConvertibleTimestamp;
25
26/**
27 * @brief Proxy backend that mirrors writes to several internal backends.
28 *
29 * This class defines a multi-write backend. Multiple backends can be
30 * registered to this proxy backend and it will act as a single backend.
31 * Use this when all access to those backends is through this proxy backend.
32 * At least one of the backends must be declared the "master" backend.
33 *
34 * Only use this class when transitioning from one storage system to another.
35 *
36 * Read operations are only done on the 'master' backend for consistency.
37 * Write operations are performed on all backends, starting with the master.
38 * This makes a best-effort to have transactional semantics, but since requests
39 * may sometimes fail, the use of "autoResync" or background scripts to fix
40 * inconsistencies is important.
41 *
42 * @ingroup FileBackend
43 * @since 1.19
44 */
45class FileBackendMultiWrite extends FileBackend {
46	/** @var FileBackendStore[] Prioritized list of FileBackendStore objects */
47	protected $backends = [];
48
49	/** @var int Index of master backend */
50	protected $masterIndex = -1;
51	/** @var int Index of read affinity backend */
52	protected $readIndex = -1;
53
54	/** @var int Bitfield */
55	protected $syncChecks = 0;
56	/** @var string|bool */
57	protected $autoResync = false;
58
59	/** @var bool */
60	protected $asyncWrites = false;
61
62	/** @var int Compare file sizes among backends */
63	private const CHECK_SIZE = 1;
64	/** @var int Compare file mtimes among backends */
65	private const CHECK_TIME = 2;
66	/** @var int Compare file hashes among backends */
67	private const CHECK_SHA1 = 4;
68
69	/**
70	 * Construct a proxy backend that consists of several internal backends.
71	 * Locking, journaling, and read-only checks are handled by the proxy backend.
72	 *
73	 * Additional $config params include:
74	 *   - backends       : Array of backend config and multi-backend settings.
75	 *                      Each value is the config used in the constructor of a
76	 *                      FileBackendStore class, but with these additional settings:
77	 *                        - class         : The name of the backend class
78	 *                        - isMultiMaster : This must be set for one backend.
79	 *                        - readAffinity  : Use this for reads without 'latest' set.
80	 *   - syncChecks     : Integer bitfield of internal backend sync checks to perform.
81	 *                      Possible bits include the FileBackendMultiWrite::CHECK_* constants.
82	 *                      There are constants for SIZE, TIME, and SHA1.
83	 *                      The checks are done before allowing any file operations.
84	 *   - autoResync     : Automatically resync the clone backends to the master backend
85	 *                      when pre-operation sync checks fail. This should only be used
86	 *                      if the master backend is stable and not missing any files.
87	 *                      Use "conservative" to limit resyncing to copying newer master
88	 *                      backend files over older (or non-existing) clone backend files.
89	 *                      Cases that cannot be handled will result in operation abortion.
90	 *   - replication    : Set to 'async' to defer file operations on the non-master backends.
91	 *                      This will apply such updates post-send for web requests. Note that
92	 *                      any checks from "syncChecks" are still synchronous.
93	 *
94	 * @param array $config
95	 * @throws LogicException
96	 */
97	public function __construct( array $config ) {
98		parent::__construct( $config );
99		$this->syncChecks = $config['syncChecks'] ?? self::CHECK_SIZE;
100		$this->autoResync = $config['autoResync'] ?? false;
101		$this->asyncWrites = isset( $config['replication'] ) && $config['replication'] === 'async';
102		// Construct backends here rather than via registration
103		// to keep these backends hidden from outside the proxy.
104		$namesUsed = [];
105		foreach ( $config['backends'] as $index => $beConfig ) {
106			$name = $beConfig['name'];
107			if ( isset( $namesUsed[$name] ) ) { // don't break FileOp predicates
108				throw new LogicException( "Two or more backends defined with the name $name." );
109			}
110			$namesUsed[$name] = 1;
111			// Alter certain sub-backend settings for sanity
112			unset( $beConfig['readOnly'] ); // use proxy backend setting
113			unset( $beConfig['fileJournal'] ); // use proxy backend journal
114			unset( $beConfig['lockManager'] ); // lock under proxy backend
115			$beConfig['domainId'] = $this->domainId; // use the proxy backend wiki ID
116			$beConfig['logger'] = $this->logger; // use the proxy backend logger
117			if ( !empty( $beConfig['isMultiMaster'] ) ) {
118				if ( $this->masterIndex >= 0 ) {
119					throw new LogicException( 'More than one master backend defined.' );
120				}
121				$this->masterIndex = $index; // this is the "master"
122				$beConfig['fileJournal'] = $this->fileJournal; // log under proxy backend
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							ConvertibleTimestamp::convert( TS_UNIX, $masterStat['mtime'] ) -
294							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		$realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
760
761		return $this->backends[$this->masterIndex]->getFileList( $realParams );
762	}
763
764	public function getFeatures() {
765		return $this->backends[$this->masterIndex]->getFeatures();
766	}
767
768	public function clearCache( array $paths = null ) {
769		foreach ( $this->backends as $backend ) {
770			$realPaths = is_array( $paths ) ? $this->substPaths( $paths, $backend ) : null;
771			$backend->clearCache( $realPaths );
772		}
773	}
774
775	public function preloadCache( array $paths ) {
776		$realPaths = $this->substPaths( $paths, $this->backends[$this->readIndex] );
777		$this->backends[$this->readIndex]->preloadCache( $realPaths );
778	}
779
780	public function preloadFileStat( array $params ) {
781		$index = $this->getReadIndexFromParams( $params );
782		$realParams = $this->substOpPaths( $params, $this->backends[$index] );
783
784		return $this->backends[$index]->preloadFileStat( $realParams );
785	}
786
787	public function getScopedLocksForOps( array $ops, StatusValue $status ) {
788		$realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] );
789		$fileOps = $this->backends[$this->masterIndex]->getOperationsInternal( $realOps );
790		// Get the paths to lock from the master backend
791		$paths = $this->backends[$this->masterIndex]->getPathsToLockForOpsInternal( $fileOps );
792		// Get the paths under the proxy backend's name
793		$pbPaths = [
794			LockManager::LOCK_UW => $this->unsubstPaths(
795				$paths[LockManager::LOCK_UW],
796				$this->backends[$this->masterIndex]
797			),
798			LockManager::LOCK_EX => $this->unsubstPaths(
799				$paths[LockManager::LOCK_EX],
800				$this->backends[$this->masterIndex]
801			)
802		];
803
804		// Actually acquire the locks
805		return $this->getScopedFileLocks( $pbPaths, 'mixed', $status );
806	}
807
808	/**
809	 * @param array $params
810	 * @return int The master or read affinity backend index, based on $params['latest']
811	 */
812	protected function getReadIndexFromParams( array $params ) {
813		return !empty( $params['latest'] ) ? $this->masterIndex : $this->readIndex;
814	}
815}
816