In SwiftFileBackend:
[lhc/web/wiklou.git] / includes / filerepo / backend / FileOp.php
1 <?php
2 /**
3 * @file
4 * @ingroup FileBackend
5 * @author Aaron Schulz
6 */
7
8 /**
9 * Helper class for representing operations with transaction support.
10 * FileBackend::doOperations() will require these classes for supported operations.
11 * Do not use this class from places outside FileBackend.
12 *
13 * Use of large fields should be avoided as we want to support
14 * potentially many FileOp classes in large arrays in memory.
15 *
16 * @ingroup FileBackend
17 * @since 1.19
18 */
19 abstract class FileOp {
20 /** @var Array */
21 protected $params = array();
22 /** @var FileBackendBase */
23 protected $backend;
24
25 protected $state = self::STATE_NEW; // integer
26 protected $failed = false; // boolean
27 protected $useLatest = true; // boolean
28
29 protected $sourceSha1; // string
30 protected $destSameAsSource; // boolean
31
32 /* Object life-cycle */
33 const STATE_NEW = 1;
34 const STATE_CHECKED = 2;
35 const STATE_ATTEMPTED = 3;
36
37 /**
38 * Build a new file operation transaction
39 *
40 * @params $backend FileBackend
41 * @params $params Array
42 */
43 final public function __construct( FileBackendBase $backend, array $params ) {
44 $this->backend = $backend;
45 foreach ( $this->allowedParams() as $name ) {
46 if ( isset( $params[$name] ) ) {
47 $this->params[$name] = $params[$name];
48 }
49 }
50 $this->params = $params;
51 }
52
53 /**
54 * Allow stale data for file reads and existence checks.
55 *
56 * @return void
57 */
58 final protected function allowStaleReads() {
59 $this->useLatest = false;
60 }
61
62 /**
63 * Attempt a series of file operations.
64 * Callers are responsible for handling file locking.
65 *
66 * $opts is an array of options, including:
67 * 'force' : Errors that would normally cause a rollback do not.
68 * The remaining operations are still attempted if any fail.
69 * 'allowStale' : Don't require the latest available data.
70 * This can increase performance for non-critical writes.
71 * This has no effect unless the 'force' flag is set.
72 *
73 * @param $performOps Array List of FileOp operations
74 * @param $opts Array Batch operation options
75 * @return Status
76 */
77 final public static function attemptBatch( array $performOps, array $opts ) {
78 $status = Status::newGood();
79
80 $allowStale = !empty( $opts['allowStale'] );
81 $ignoreErrors = !empty( $opts['force'] );
82
83 $predicates = FileOp::newPredicates(); // account for previous op in prechecks
84 // Do pre-checks for each operation; abort on failure...
85 foreach ( $performOps as $index => $fileOp ) {
86 if ( $allowStale ) {
87 $fileOp->allowStaleReads(); // allow potentially stale reads
88 }
89 $subStatus = $fileOp->precheck( $predicates );
90 $status->merge( $subStatus );
91 if ( !$subStatus->isOK() ) { // operation failed?
92 $status->success[$index] = false;
93 ++$status->failCount;
94 if ( !$ignoreErrors ) {
95 return $status; // abort
96 }
97 }
98 }
99
100 // Attempt each operation...
101 foreach ( $performOps as $index => $fileOp ) {
102 if ( $fileOp->failed() ) {
103 continue; // nothing to do
104 }
105 $subStatus = $fileOp->attempt();
106 $status->merge( $subStatus );
107 if ( $subStatus->isOK() ) {
108 $status->success[$index] = true;
109 ++$status->successCount;
110 } else {
111 $status->success[$index] = false;
112 ++$status->failCount;
113 if ( !$ignoreErrors ) {
114 // Log remaining ops as failed for recovery...
115 for ( $i = ($index + 1); $i < count( $performOps ); $i++ ) {
116 $performOps[$i]->logFailure( 'attempt_aborted' );
117 }
118 return $status; // bail out
119 }
120 }
121 }
122
123 return $status;
124 }
125
126 /**
127 * Get the value of the parameter with the given name
128 *
129 * @param $name string
130 * @return mixed Returns null if the parameter is not set
131 */
132 final public function getParam( $name ) {
133 return isset( $this->params[$name] ) ? $this->params[$name] : null;
134 }
135
136 /**
137 * Check if this operation failed precheck() or attempt()
138 *
139 * @return type
140 */
141 final public function failed() {
142 return $this->failed;
143 }
144
145 /**
146 * Get a new empty predicates array for precheck()
147 *
148 * @return Array
149 */
150 final public static function newPredicates() {
151 return array( 'exists' => array(), 'sha1' => array() );
152 }
153
154 /**
155 * Check preconditions of the operation without writing anything
156 *
157 * @param $predicates Array
158 * @return Status
159 */
160 final public function precheck( array &$predicates ) {
161 if ( $this->state !== self::STATE_NEW ) {
162 return Status::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state );
163 }
164 $this->state = self::STATE_CHECKED;
165 $status = $this->doPrecheck( $predicates );
166 if ( !$status->isOK() ) {
167 $this->failed = true;
168 }
169 return $status;
170 }
171
172 /**
173 * Attempt the operation, backing up files as needed; this must be reversible
174 *
175 * @return Status
176 */
177 final public function attempt() {
178 if ( $this->state !== self::STATE_CHECKED ) {
179 return Status::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state );
180 } elseif ( $this->failed ) { // failed precheck
181 return Status::newFatal( 'fileop-fail-attempt-precheck' );
182 }
183 $this->state = self::STATE_ATTEMPTED;
184 $status = $this->doAttempt();
185 if ( !$status->isOK() ) {
186 $this->failed = true;
187 $this->logFailure( 'attempt' );
188 }
189 return $status;
190 }
191
192 /**
193 * Get a list of storage paths read from for this operation
194 *
195 * @return Array
196 */
197 public function storagePathsRead() {
198 return array();
199 }
200
201 /**
202 * Get a list of storage paths written to for this operation
203 *
204 * @return Array
205 */
206 public function storagePathsChanged() {
207 return array();
208 }
209
210 /**
211 * @return Array List of allowed parameters
212 */
213 protected function allowedParams() {
214 return array();
215 }
216
217 /**
218 * @return Status
219 */
220 protected function doPrecheck( array &$predicates ) {
221 return Status::newGood();
222 }
223
224 /**
225 * @return Status
226 */
227 abstract protected function doAttempt();
228
229 /**
230 * Check for errors with regards to the destination file already existing.
231 * This also updates the destSameAsSource and sourceSha1 member variables.
232 * A bad status will be returned if there is no chance it can be overwritten.
233 *
234 * @param $predicates Array
235 * @return Status
236 */
237 protected function precheckDestExistence( array $predicates ) {
238 $status = Status::newGood();
239 // Get hash of source file/string and the destination file
240 $this->sourceSha1 = $this->getSourceSha1Base36(); // FS file or data string
241 if ( $this->sourceSha1 === null ) { // file in storage?
242 $this->sourceSha1 = $this->fileSha1( $this->params['src'], $predicates );
243 }
244 $this->destSameAsSource = false;
245 if ( $this->fileExists( $this->params['dst'], $predicates ) ) {
246 if ( $this->getParam( 'overwriteDest' ) ) {
247 return $status; // OK
248 } elseif ( $this->getParam( 'overwriteSame' ) ) {
249 $dhash = $this->fileSha1( $this->params['dst'], $predicates );
250 // Check if hashes are valid and match each other...
251 if ( !strlen( $this->sourceSha1 ) || !strlen( $dhash ) ) {
252 $status->fatal( 'backend-fail-hashes' );
253 } elseif ( $this->sourceSha1 !== $dhash ) {
254 // Give an error if the files are not identical
255 $status->fatal( 'backend-fail-notsame', $this->params['dst'] );
256 } else {
257 $this->destSameAsSource = true; // OK
258 }
259 return $status; // do nothing; either OK or bad status
260 } else {
261 $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
262 return $status;
263 }
264 }
265 return $status;
266 }
267
268 /**
269 * precheckDestExistence() helper function to get the source file SHA-1.
270 * Subclasses should overwride this iff the source is not in storage.
271 *
272 * @return string|false Returns false on failure
273 */
274 protected function getSourceSha1Base36() {
275 return null; // N/A
276 }
277
278 /**
279 * Check if a file will exist in storage when this operation is attempted
280 *
281 * @param $source string Storage path
282 * @param $predicates Array
283 * @return bool
284 */
285 final protected function fileExists( $source, array $predicates ) {
286 if ( isset( $predicates['exists'][$source] ) ) {
287 return $predicates['exists'][$source]; // previous op assures this
288 } else {
289 $params = array( 'src' => $source, 'latest' => $this->useLatest );
290 return $this->backend->fileExists( $params );
291 }
292 }
293
294 /**
295 * Get the SHA-1 of a file in storage when this operation is attempted
296 *
297 * @param $source string Storage path
298 * @param $predicates Array
299 * @return string|false
300 */
301 final protected function fileSha1( $source, array $predicates ) {
302 if ( isset( $predicates['sha1'][$source] ) ) {
303 return $predicates['sha1'][$source]; // previous op assures this
304 } else {
305 $params = array( 'src' => $source, 'latest' => $this->useLatest );
306 return $this->backend->getFileSha1Base36( $params );
307 }
308 }
309
310 /**
311 * Log a file operation failure and preserve any temp files
312 *
313 * @param $action string
314 * @return void
315 */
316 final protected function logFailure( $action ) {
317 $params = $this->params;
318 $params['failedAction'] = $action;
319 try {
320 wfDebugLog( 'FileOperation',
321 get_class( $this ) . ' failed:' . serialize( $params ) );
322 } catch ( Exception $e ) {
323 // bad config? debug log error?
324 }
325 }
326 }
327
328 /**
329 * Store a file into the backend from a file on the file system.
330 * Parameters similar to FileBackend::storeInternal(), which include:
331 * src : source path on file system
332 * dst : destination storage path
333 * overwriteDest : do nothing and pass if an identical file exists at destination
334 * overwriteSame : override any existing file at destination
335 */
336 class StoreFileOp extends FileOp {
337 protected function allowedParams() {
338 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
339 }
340
341 protected function doPrecheck( array &$predicates ) {
342 $status = Status::newGood();
343 // Check if the source file exists on the file system
344 if ( !is_file( $this->params['src'] ) ) {
345 $status->fatal( 'backend-fail-notexists', $this->params['src'] );
346 return $status;
347 }
348 // Check if destination file exists
349 $status->merge( $this->precheckDestExistence( $predicates ) );
350 if ( !$status->isOK() ) {
351 return $status;
352 }
353 // Update file existence predicates
354 $predicates['exists'][$this->params['dst']] = true;
355 $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
356 return $status; // safe to call attempt()
357 }
358
359 protected function doAttempt() {
360 $status = Status::newGood();
361 // Store the file at the destination
362 if ( !$this->destSameAsSource ) {
363 $status->merge( $this->backend->storeInternal( $this->params ) );
364 }
365 return $status;
366 }
367
368 protected function getSourceSha1Base36() {
369 wfSuppressWarnings();
370 $hash = sha1_file( $this->params['src'] );
371 wfRestoreWarnings();
372 if ( $hash !== false ) {
373 $hash = wfBaseConvert( $hash, 16, 36, 31 );
374 }
375 return $hash;
376 }
377
378 public function storagePathsChanged() {
379 return array( $this->params['dst'] );
380 }
381 }
382
383 /**
384 * Create a file in the backend with the given content.
385 * Parameters similar to FileBackend::createInternal(), which include:
386 * content : a string of raw file contents
387 * dst : destination storage path
388 * overwriteDest : do nothing and pass if an identical file exists at destination
389 * overwriteSame : override any existing file at destination
390 */
391 class CreateFileOp extends FileOp {
392 protected function allowedParams() {
393 return array( 'content', 'dst', 'overwriteDest', 'overwriteSame' );
394 }
395
396 protected function doPrecheck( array &$predicates ) {
397 $status = Status::newGood();
398 // Check if destination file exists
399 $status->merge( $this->precheckDestExistence( $predicates ) );
400 if ( !$status->isOK() ) {
401 return $status;
402 }
403 // Update file existence predicates
404 $predicates['exists'][$this->params['dst']] = true;
405 $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
406 return $status; // safe to call attempt()
407 }
408
409 protected function doAttempt() {
410 $status = Status::newGood();
411 // Create the file at the destination
412 if ( !$this->destSameAsSource ) {
413 $status->merge( $this->backend->createInternal( $this->params ) );
414 }
415 return $status;
416 }
417
418 protected function getSourceSha1Base36() {
419 return wfBaseConvert( sha1( $this->params['content'] ), 16, 36, 31 );
420 }
421
422 public function storagePathsChanged() {
423 return array( $this->params['dst'] );
424 }
425 }
426
427 /**
428 * Copy a file from one storage path to another in the backend.
429 * Parameters similar to FileBackend::copyInternal(), which include:
430 * src : source storage path
431 * dst : destination storage path
432 * overwriteDest : do nothing and pass if an identical file exists at destination
433 * overwriteSame : override any existing file at destination
434 */
435 class CopyFileOp extends FileOp {
436 protected function allowedParams() {
437 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
438 }
439
440 protected function doPrecheck( array &$predicates ) {
441 $status = Status::newGood();
442 // Check if the source file exists
443 if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
444 $status->fatal( 'backend-fail-notexists', $this->params['src'] );
445 return $status;
446 }
447 // Check if destination file exists
448 $status->merge( $this->precheckDestExistence( $predicates ) );
449 if ( !$status->isOK() ) {
450 return $status;
451 }
452 // Update file existence predicates
453 $predicates['exists'][$this->params['dst']] = true;
454 $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
455 return $status; // safe to call attempt()
456 }
457
458 protected function doAttempt() {
459 $status = Status::newGood();
460 // Do nothing if the src/dst paths are the same
461 if ( $this->params['src'] !== $this->params['dst'] ) {
462 // Copy the file into the destination
463 if ( !$this->destSameAsSource ) {
464 $status->merge( $this->backend->copyInternal( $this->params ) );
465 }
466 }
467 return $status;
468 }
469
470 public function storagePathsRead() {
471 return array( $this->params['src'] );
472 }
473
474 public function storagePathsChanged() {
475 return array( $this->params['dst'] );
476 }
477 }
478
479 /**
480 * Move a file from one storage path to another in the backend.
481 * Parameters similar to FileBackend::moveInternal(), which include:
482 * src : source storage path
483 * dst : destination storage path
484 * overwriteDest : do nothing and pass if an identical file exists at destination
485 * overwriteSame : override any existing file at destination
486 */
487 class MoveFileOp extends FileOp {
488 protected function allowedParams() {
489 return array( 'src', 'dst', 'overwriteDest', 'overwriteSame' );
490 }
491
492 protected function doPrecheck( array &$predicates ) {
493 $status = Status::newGood();
494 // Check if the source file exists
495 if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
496 $status->fatal( 'backend-fail-notexists', $this->params['src'] );
497 return $status;
498 }
499 // Check if destination file exists
500 $status->merge( $this->precheckDestExistence( $predicates ) );
501 if ( !$status->isOK() ) {
502 return $status;
503 }
504 // Update file existence predicates
505 $predicates['exists'][$this->params['src']] = false;
506 $predicates['sha1'][$this->params['src']] = false;
507 $predicates['exists'][$this->params['dst']] = true;
508 $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
509 return $status; // safe to call attempt()
510 }
511
512 protected function doAttempt() {
513 $status = Status::newGood();
514 // Do nothing if the src/dst paths are the same
515 if ( $this->params['src'] !== $this->params['dst'] ) {
516 if ( !$this->destSameAsSource ) {
517 // Move the file into the destination
518 $status->merge( $this->backend->moveInternal( $this->params ) );
519 } else {
520 // Just delete source as the destination needs no changes
521 $params = array( 'src' => $this->params['src'] );
522 $status->merge( $this->backend->deleteInternal( $params ) );
523 if ( !$status->isOK() ) {
524 return $status;
525 }
526 }
527 }
528 return $status;
529 }
530
531 public function storagePathsRead() {
532 return array( $this->params['src'] );
533 }
534
535 public function storagePathsChanged() {
536 return array( $this->params['dst'] );
537 }
538 }
539
540 /**
541 * Delete a file at the storage path.
542 * Parameters similar to FileBackend::deleteInternal(), which include:
543 * src : source storage path
544 * ignoreMissingSource : don't return an error if the file does not exist
545 */
546 class DeleteFileOp extends FileOp {
547 protected $needsDelete = true;
548
549 protected function allowedParams() {
550 return array( 'src', 'ignoreMissingSource' );
551 }
552
553 protected function doPrecheck( array &$predicates ) {
554 $status = Status::newGood();
555 // Check if the source file exists
556 if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
557 if ( !$this->getParam( 'ignoreMissingSource' ) ) {
558 $status->fatal( 'backend-fail-notexists', $this->params['src'] );
559 return $status;
560 }
561 $this->needsDelete = false;
562 }
563 // Update file existence predicates
564 $predicates['exists'][$this->params['src']] = false;
565 $predicates['sha1'][$this->params['src']] = false;
566 return $status; // safe to call attempt()
567 }
568
569 protected function doAttempt() {
570 $status = Status::newGood();
571 if ( $this->needsDelete ) {
572 // Delete the source file
573 $status->merge( $this->backend->deleteInternal( $this->params ) );
574 if ( !$status->isOK() ) {
575 return $status;
576 }
577 }
578 return $status;
579 }
580
581 public function storagePathsChanged() {
582 return array( $this->params['src'] );
583 }
584 }
585
586 /**
587 * Placeholder operation that has no params and does nothing
588 */
589 class NullFileOp extends FileOp {
590 protected function doAttempt() {
591 return Status::newGood();
592 }
593 }