Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
69.21% |
245 / 354 |
|
70.73% |
29 / 41 |
CRAP | |
0.00% |
0 / 1 |
FileBackendMultiWrite | |
69.41% |
245 / 353 |
|
70.73% |
29 / 41 |
605.57 | |
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 | |||
addShellboxInputFile | |
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 | namespace Wikimedia\FileBackend; |
25 | |
26 | use InvalidArgumentException; |
27 | use LockManager; |
28 | use LogicException; |
29 | use Shellbox\Command\BoxedCommand; |
30 | use StatusValue; |
31 | use StringUtils; |
32 | use 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 | */ |
54 | class 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 */ |
851 | class_alias( FileBackendMultiWrite::class, 'FileBackendMultiWrite' ); |