Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
69.14% |
242 / 350 |
|
70.00% |
28 / 40 |
CRAP | |
0.00% |
0 / 1 |
FileBackendMultiWrite | |
69.14% |
242 / 350 |
|
70.00% |
28 / 40 |
609.38 | |
0.00% |
0 / 1 |
__construct | |
85.71% |
24 / 28 |
|
0.00% |
0 / 1 |
10.29 | |||
doOperationsInternal | |
61.54% |
32 / 52 |
|
0.00% |
0 / 1 |
22.62 | |||
consistencyCheck | |
79.07% |
34 / 43 |
|
0.00% |
0 / 1 |
22.31 | |||
accessibilityCheck | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
5.27 | |||
resyncFiles | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
272 | |||
fileStoragePathsForOps | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
7.04 | |||
substOpBatchPaths | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
substOpPaths | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
substPaths | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
unsubstPaths | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
hasVolatileSources | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 | |||
doQuickOperationsInternal | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
5 | |||
doPrepare | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doSecure | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doPublish | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doClean | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doDirectoryOp | |
68.75% |
11 / 16 |
|
0.00% |
0 / 1 |
4.49 | |||
concatenate | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
fileExists | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFileTimestamp | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFileSize | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFileStat | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFileXAttributes | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getFileContentsMulti | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getFileSha1Base36 | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFileProps | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
streamFile | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getLocalReferenceMulti | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getLocalCopyMulti | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getFileHttpUrl | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
directoryExists | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getDirectoryList | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getFileList | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
getFileListForWrite | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
getFeatures | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
clearCache | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
preloadCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
preloadFileStat | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getScopedLocksForOps | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
getReadIndexFromParams | |
100.00% |
1 / 1 |
|
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 | |
24 | use MediaWiki\Deferred\DeferredUpdates; |
25 | use 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 | */ |
47 | class 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 | } |