Completed
Branch master (d7c4e6)
by
unknown
29:20
created

SqlBagOStuff::handleWriteError()   B

Complexity

Conditions 6
Paths 7

Size

Total Lines 21
Code Lines 13

Duplication

Lines 7
Ratio 33.33 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 6
eloc 13
c 2
b 0
f 0
nc 7
nop 3
dl 7
loc 21
rs 8.7624
1
<?php
2
/**
3
 * Object caching using a SQL database.
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 Cache
22
 */
23
24
use \MediaWiki\MediaWikiServices;
25
26
/**
27
 * Class to store objects in the database
28
 *
29
 * @ingroup Cache
30
 */
31
class SqlBagOStuff extends BagOStuff {
32
	/** @var array[] (server index => server config) */
33
	protected $serverInfos;
34
	/** @var string[] (server index => tag/host name) */
35
	protected $serverTags;
36
	/** @var int */
37
	protected $numServers;
38
	/** @var int */
39
	protected $lastExpireAll = 0;
40
	/** @var int */
41
	protected $purgePeriod = 100;
42
	/** @var int */
43
	protected $shards = 1;
44
	/** @var string */
45
	protected $tableName = 'objectcache';
46
	/** @var bool */
47
	protected $slaveOnly = false;
48
	/** @var int */
49
	protected $syncTimeout = 3;
50
51
	/** @var array */
52
	protected $conns;
53
	/** @var array UNIX timestamps */
54
	protected $connFailureTimes = [];
55
	/** @var array Exceptions */
56
	protected $connFailureErrors = [];
57
58
	/**
59
	 * Constructor. Parameters are:
60
	 *   - server:      A server info structure in the format required by each
61
	 *                  element in $wgDBServers.
62
	 *
63
	 *   - servers:     An array of server info structures describing a set of database servers
64
	 *                  to distribute keys to. If this is specified, the "server" option will be
65
	 *                  ignored. If string keys are used, then they will be used for consistent
66
	 *                  hashing *instead* of the host name (from the server config). This is useful
67
	 *                  when a cluster is replicated to another site (with different host names)
68
	 *                  but each server has a corresponding replica in the other cluster.
69
	 *
70
	 *   - purgePeriod: The average number of object cache requests in between
71
	 *                  garbage collection operations, where expired entries
72
	 *                  are removed from the database. Or in other words, the
73
	 *                  reciprocal of the probability of purging on any given
74
	 *                  request. If this is set to zero, purging will never be
75
	 *                  done.
76
	 *
77
	 *   - tableName:   The table name to use, default is "objectcache".
78
	 *
79
	 *   - shards:      The number of tables to use for data storage on each server.
80
	 *                  If this is more than 1, table names will be formed in the style
81
	 *                  objectcacheNNN where NNN is the shard index, between 0 and
82
	 *                  shards-1. The number of digits will be the minimum number
83
	 *                  required to hold the largest shard index. Data will be
84
	 *                  distributed across all tables by key hash. This is for
85
	 *                  MySQL bugs 61735 and 61736.
86
	 *   - slaveOnly:   Whether to only use slave DBs and avoid triggering
87
	 *                  garbage collection logic of expired items. This only
88
	 *                  makes sense if the primary DB is used and only if get()
89
	 *                  calls will be used. This is used by ReplicatedBagOStuff.
90
	 *   - syncTimeout: Max seconds to wait for slaves to catch up for WRITE_SYNC.
91
	 *
92
	 * @param array $params
93
	 */
94
	public function __construct( $params ) {
95
		parent::__construct( $params );
96
97
		$this->attrMap[self::ATTR_EMULATION] = self::QOS_EMULATION_SQL;
98
99
		if ( isset( $params['servers'] ) ) {
100
			$this->serverInfos = [];
101
			$this->serverTags = [];
102
			$this->numServers = count( $params['servers'] );
103
			$index = 0;
104
			foreach ( $params['servers'] as $tag => $info ) {
105
				$this->serverInfos[$index] = $info;
106
				if ( is_string( $tag ) ) {
107
					$this->serverTags[$index] = $tag;
108
				} else {
109
					$this->serverTags[$index] = isset( $info['host'] ) ? $info['host'] : "#$index";
110
				}
111
				++$index;
112
			}
113
		} elseif ( isset( $params['server'] ) ) {
114
			$this->serverInfos = [ $params['server'] ];
115
			$this->numServers = count( $this->serverInfos );
116
		} else {
117
			$this->serverInfos = false;
0 ignored issues
show
Documentation Bug introduced by
It seems like false of type false is incompatible with the declared type array<integer,array> of property $serverInfos.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
118
			$this->numServers = 1;
119
		}
120
		if ( isset( $params['purgePeriod'] ) ) {
121
			$this->purgePeriod = intval( $params['purgePeriod'] );
122
		}
123
		if ( isset( $params['tableName'] ) ) {
124
			$this->tableName = $params['tableName'];
125
		}
126
		if ( isset( $params['shards'] ) ) {
127
			$this->shards = intval( $params['shards'] );
128
		}
129
		if ( isset( $params['syncTimeout'] ) ) {
130
			$this->syncTimeout = $params['syncTimeout'];
131
		}
132
		$this->slaveOnly = !empty( $params['slaveOnly'] );
133
	}
134
135
	/**
136
	 * Get a connection to the specified database
137
	 *
138
	 * @param int $serverIndex
139
	 * @return IDatabase
140
	 * @throws MWException
141
	 */
142
	protected function getDB( $serverIndex ) {
143
		if ( !isset( $this->conns[$serverIndex] ) ) {
144
			if ( $serverIndex >= $this->numServers ) {
145
				throw new MWException( __METHOD__ . ": Invalid server index \"$serverIndex\"" );
146
			}
147
148
			# Don't keep timing out trying to connect for each call if the DB is down
149
			if ( isset( $this->connFailureErrors[$serverIndex] )
150
				&& ( time() - $this->connFailureTimes[$serverIndex] ) < 60
151
			) {
152
				throw $this->connFailureErrors[$serverIndex];
153
			}
154
155
			# If server connection info was given, use that
156
			if ( $this->serverInfos ) {
157
				$info = $this->serverInfos[$serverIndex];
158
				$type = isset( $info['type'] ) ? $info['type'] : 'mysql';
159
				$host = isset( $info['host'] ) ? $info['host'] : '[unknown]';
160
				$this->logger->debug( __CLASS__ . ": connecting to $host" );
161
				// Use a blank trx profiler to ignore expections as this is a cache
162
				$info['trxProfiler'] = new TransactionProfiler();
163
				$db = DatabaseBase::factory( $type, $info );
164
				$db->clearFlag( DBO_TRX );
165
			} else {
166
				// We must keep a separate connection to MySQL in order to avoid deadlocks
167
				// However, SQLite has an opposite behavior. And PostgreSQL needs to know
168
				// if we are in transaction or not (@TODO: find some work-around).
169
				$index = $this->slaveOnly ? DB_SLAVE : DB_MASTER;
170
				if ( wfGetDB( $index )->getType() == 'mysql' ) {
171
					$lb = wfGetLBFactory()->newMainLB();
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLBFactory() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
172
					$db = $lb->getConnection( $index );
173
					$db->clearFlag( DBO_TRX ); // auto-commit mode
174
				} else {
175
					$db = wfGetDB( $index );
176
				}
177
			}
178
			$this->logger->debug( sprintf( "Connection %s will be used for SqlBagOStuff", $db ) );
179
			$this->conns[$serverIndex] = $db;
180
		}
181
182
		return $this->conns[$serverIndex];
183
	}
184
185
	/**
186
	 * Get the server index and table name for a given key
187
	 * @param string $key
188
	 * @return array Server index and table name
189
	 */
190
	protected function getTableByKey( $key ) {
191
		if ( $this->shards > 1 ) {
192
			$hash = hexdec( substr( md5( $key ), 0, 8 ) ) & 0x7fffffff;
193
			$tableIndex = $hash % $this->shards;
194
		} else {
195
			$tableIndex = 0;
196
		}
197
		if ( $this->numServers > 1 ) {
198
			$sortedServers = $this->serverTags;
199
			ArrayUtils::consistentHashSort( $sortedServers, $key );
200
			reset( $sortedServers );
201
			$serverIndex = key( $sortedServers );
202
		} else {
203
			$serverIndex = 0;
204
		}
205
		return [ $serverIndex, $this->getTableNameByShard( $tableIndex ) ];
206
	}
207
208
	/**
209
	 * Get the table name for a given shard index
210
	 * @param int $index
211
	 * @return string
212
	 */
213
	protected function getTableNameByShard( $index ) {
214
		if ( $this->shards > 1 ) {
215
			$decimals = strlen( $this->shards - 1 );
216
			return $this->tableName .
217
				sprintf( "%0{$decimals}d", $index );
218
		} else {
219
			return $this->tableName;
220
		}
221
	}
222
223
	protected function doGet( $key, $flags = 0 ) {
224
		$casToken = null;
225
226
		return $this->getWithToken( $key, $casToken, $flags );
227
	}
228
229
	protected function getWithToken( $key, &$casToken, $flags = 0 ) {
230
		$values = $this->getMulti( [ $key ] );
231
		if ( array_key_exists( $key, $values ) ) {
232
			$casToken = $values[$key];
233
			return $values[$key];
234
		}
235
		return false;
236
	}
237
238
	public function getMulti( array $keys, $flags = 0 ) {
239
		$values = []; // array of (key => value)
240
241
		$keysByTable = [];
242 View Code Duplication
		foreach ( $keys as $key ) {
243
			list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
244
			$keysByTable[$serverIndex][$tableName][] = $key;
245
		}
246
247
		$this->garbageCollect(); // expire old entries if any
248
249
		$dataRows = [];
250
		foreach ( $keysByTable as $serverIndex => $serverKeys ) {
251
			try {
252
				$db = $this->getDB( $serverIndex );
253
				foreach ( $serverKeys as $tableName => $tableKeys ) {
254
					$res = $db->select( $tableName,
255
						[ 'keyname', 'value', 'exptime' ],
256
						[ 'keyname' => $tableKeys ],
257
						__METHOD__,
258
						// Approximate write-on-the-fly BagOStuff API via blocking.
259
						// This approximation fails if a ROLLBACK happens (which is rare).
260
						// We do not want to flush the TRX as that can break callers.
261
						$db->trxLevel() ? [ 'LOCK IN SHARE MODE' ] : []
262
					);
263
					if ( $res === false ) {
264
						continue;
265
					}
266
					foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
267
						$row->serverIndex = $serverIndex;
268
						$row->tableName = $tableName;
269
						$dataRows[$row->keyname] = $row;
270
					}
271
				}
272
			} catch ( DBError $e ) {
273
				$this->handleReadError( $e, $serverIndex );
274
			}
275
		}
276
277
		foreach ( $keys as $key ) {
278
			if ( isset( $dataRows[$key] ) ) { // HIT?
279
				$row = $dataRows[$key];
280
				$this->debug( "get: retrieved data; expiry time is " . $row->exptime );
281
				$db = null;
282
				try {
283
					$db = $this->getDB( $row->serverIndex );
284
					if ( $this->isExpired( $db, $row->exptime ) ) { // MISS
285
						$this->debug( "get: key has expired" );
286
					} else { // HIT
287
						$values[$key] = $this->unserialize( $db->decodeBlob( $row->value ) );
288
					}
289
				} catch ( DBQueryError $e ) {
290
					$this->handleWriteError( $e, $db, $row->serverIndex );
291
				}
292
			} else { // MISS
293
				$this->debug( 'get: no matching rows' );
294
			}
295
		}
296
297
		return $values;
298
	}
299
300
	public function setMulti( array $data, $expiry = 0 ) {
301
		$keysByTable = [];
302 View Code Duplication
		foreach ( $data as $key => $value ) {
303
			list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
304
			$keysByTable[$serverIndex][$tableName][] = $key;
305
		}
306
307
		$this->garbageCollect(); // expire old entries if any
308
309
		$result = true;
310
		$exptime = (int)$expiry;
311
		foreach ( $keysByTable as $serverIndex => $serverKeys ) {
312
			$db = null;
313
			try {
314
				$db = $this->getDB( $serverIndex );
315
			} catch ( DBError $e ) {
316
				$this->handleWriteError( $e, $db, $serverIndex );
317
				$result = false;
318
				continue;
319
			}
320
321
			if ( $exptime < 0 ) {
322
				$exptime = 0;
323
			}
324
325 View Code Duplication
			if ( $exptime == 0 ) {
326
				$encExpiry = $this->getMaxDateTime( $db );
327
			} else {
328
				$exptime = $this->convertExpiry( $exptime );
329
				$encExpiry = $db->timestamp( $exptime );
330
			}
331
			foreach ( $serverKeys as $tableName => $tableKeys ) {
332
				$rows = [];
333
				foreach ( $tableKeys as $key ) {
334
					$rows[] = [
335
						'keyname' => $key,
336
						'value' => $db->encodeBlob( $this->serialize( $data[$key] ) ),
337
						'exptime' => $encExpiry,
338
					];
339
				}
340
341
				try {
342
					$db->replace(
343
						$tableName,
344
						[ 'keyname' ],
345
						$rows,
346
						__METHOD__
347
					);
348
				} catch ( DBError $e ) {
349
					$this->handleWriteError( $e, $db, $serverIndex );
350
					$result = false;
351
				}
352
353
			}
354
355
		}
356
357
		return $result;
358
	}
359
360 View Code Duplication
	public function set( $key, $value, $exptime = 0, $flags = 0 ) {
361
		$ok = $this->setMulti( [ $key => $value ], $exptime );
362
		if ( ( $flags & self::WRITE_SYNC ) == self::WRITE_SYNC ) {
363
			$ok = $ok && $this->waitForSlaves();
364
		}
365
366
		return $ok;
367
	}
368
369
	protected function cas( $casToken, $key, $value, $exptime = 0 ) {
370
		list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
371
		$db = null;
372
		try {
373
			$db = $this->getDB( $serverIndex );
374
			$exptime = intval( $exptime );
375
376
			if ( $exptime < 0 ) {
377
				$exptime = 0;
378
			}
379
380 View Code Duplication
			if ( $exptime == 0 ) {
381
				$encExpiry = $this->getMaxDateTime( $db );
382
			} else {
383
				$exptime = $this->convertExpiry( $exptime );
384
				$encExpiry = $db->timestamp( $exptime );
385
			}
386
			// (bug 24425) use a replace if the db supports it instead of
387
			// delete/insert to avoid clashes with conflicting keynames
388
			$db->update(
389
				$tableName,
390
				[
391
					'keyname' => $key,
392
					'value' => $db->encodeBlob( $this->serialize( $value ) ),
393
					'exptime' => $encExpiry
394
				],
395
				[
396
					'keyname' => $key,
397
					'value' => $db->encodeBlob( $this->serialize( $casToken ) )
398
				],
399
				__METHOD__
400
			);
401
		} catch ( DBQueryError $e ) {
402
			$this->handleWriteError( $e, $db, $serverIndex );
403
404
			return false;
405
		}
406
407
		return (bool)$db->affectedRows();
408
	}
409
410
	public function delete( $key ) {
411
		list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
412
		$db = null;
413
		try {
414
			$db = $this->getDB( $serverIndex );
415
			$db->delete(
416
				$tableName,
417
				[ 'keyname' => $key ],
418
				__METHOD__ );
419
		} catch ( DBError $e ) {
420
			$this->handleWriteError( $e, $db, $serverIndex );
421
			return false;
422
		}
423
424
		return true;
425
	}
426
427
	public function incr( $key, $step = 1 ) {
428
		list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
429
		$db = null;
430
		try {
431
			$db = $this->getDB( $serverIndex );
432
			$step = intval( $step );
433
			$row = $db->selectRow(
434
				$tableName,
435
				[ 'value', 'exptime' ],
436
				[ 'keyname' => $key ],
437
				__METHOD__,
438
				[ 'FOR UPDATE' ] );
439
			if ( $row === false ) {
440
				// Missing
441
442
				return null;
443
			}
444
			$db->delete( $tableName, [ 'keyname' => $key ], __METHOD__ );
445
			if ( $this->isExpired( $db, $row->exptime ) ) {
446
				// Expired, do not reinsert
447
448
				return null;
449
			}
450
451
			$oldValue = intval( $this->unserialize( $db->decodeBlob( $row->value ) ) );
452
			$newValue = $oldValue + $step;
453
			$db->insert( $tableName,
454
				[
455
					'keyname' => $key,
456
					'value' => $db->encodeBlob( $this->serialize( $newValue ) ),
457
					'exptime' => $row->exptime
458
				], __METHOD__, 'IGNORE' );
459
460
			if ( $db->affectedRows() == 0 ) {
461
				// Race condition. See bug 28611
462
				$newValue = null;
463
			}
464
		} catch ( DBError $e ) {
465
			$this->handleWriteError( $e, $db, $serverIndex );
466
			return null;
467
		}
468
469
		return $newValue;
470
	}
471
472 View Code Duplication
	public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
473
		$ok = $this->mergeViaCas( $key, $callback, $exptime, $attempts );
474
		if ( ( $flags & self::WRITE_SYNC ) == self::WRITE_SYNC ) {
475
			$ok = $ok && $this->waitForSlaves();
476
		}
477
478
		return $ok;
479
	}
480
481
	public function changeTTL( $key, $expiry = 0 ) {
482
		list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
483
		$db = null;
484
		try {
485
			$db = $this->getDB( $serverIndex );
486
			$db->update(
487
				$tableName,
488
				[ 'exptime' => $db->timestamp( $this->convertExpiry( $expiry ) ) ],
489
				[ 'keyname' => $key, 'exptime > ' . $db->addQuotes( $db->timestamp( time() ) ) ],
490
				__METHOD__
491
			);
492
			if ( $db->affectedRows() == 0 ) {
493
				return false;
494
			}
495
		} catch ( DBError $e ) {
496
			$this->handleWriteError( $e, $db, $serverIndex );
497
			return false;
498
		}
499
500
		return true;
501
	}
502
503
	/**
504
	 * @param IDatabase $db
505
	 * @param string $exptime
506
	 * @return bool
507
	 */
508
	protected function isExpired( $db, $exptime ) {
509
		return $exptime != $this->getMaxDateTime( $db ) && wfTimestamp( TS_UNIX, $exptime ) < time();
510
	}
511
512
	/**
513
	 * @param IDatabase $db
514
	 * @return string
515
	 */
516
	protected function getMaxDateTime( $db ) {
517
		if ( time() > 0x7fffffff ) {
518
			return $db->timestamp( 1 << 62 );
519
		} else {
520
			return $db->timestamp( 0x7fffffff );
521
		}
522
	}
523
524
	protected function garbageCollect() {
525
		if ( !$this->purgePeriod || $this->slaveOnly ) {
526
			// Disabled
527
			return;
528
		}
529
		// Only purge on one in every $this->purgePeriod requests.
530
		if ( $this->purgePeriod !== 1 && mt_rand( 0, $this->purgePeriod - 1 ) ) {
531
			return;
532
		}
533
		$now = time();
534
		// Avoid repeating the delete within a few seconds
535
		if ( $now > ( $this->lastExpireAll + 1 ) ) {
536
			$this->lastExpireAll = $now;
537
			$this->expireAll();
538
		}
539
	}
540
541
	public function expireAll() {
542
		$this->deleteObjectsExpiringBefore( wfTimestampNow() );
0 ignored issues
show
Security Bug introduced by
It seems like wfTimestampNow() can also be of type false; however, SqlBagOStuff::deleteObjectsExpiringBefore() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
543
	}
544
545
	/**
546
	 * Delete objects from the database which expire before a certain date.
547
	 * @param string $timestamp
548
	 * @param bool|callable $progressCallback
549
	 * @return bool
550
	 */
551
	public function deleteObjectsExpiringBefore( $timestamp, $progressCallback = false ) {
552
		for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
553
			$db = null;
554
			try {
555
				$db = $this->getDB( $serverIndex );
556
				$dbTimestamp = $db->timestamp( $timestamp );
557
				$totalSeconds = false;
558
				$baseConds = [ 'exptime < ' . $db->addQuotes( $dbTimestamp ) ];
559
				for ( $i = 0; $i < $this->shards; $i++ ) {
560
					$maxExpTime = false;
561
					while ( true ) {
562
						$conds = $baseConds;
563
						if ( $maxExpTime !== false ) {
564
							$conds[] = 'exptime > ' . $db->addQuotes( $maxExpTime );
565
						}
566
						$rows = $db->select(
567
							$this->getTableNameByShard( $i ),
568
							[ 'keyname', 'exptime' ],
569
							$conds,
570
							__METHOD__,
571
							[ 'LIMIT' => 100, 'ORDER BY' => 'exptime' ] );
572
						if ( $rows === false || !$rows->numRows() ) {
573
							break;
574
						}
575
						$keys = [];
576
						$row = $rows->current();
577
						$minExpTime = $row->exptime;
578
						if ( $totalSeconds === false ) {
579
							$totalSeconds = wfTimestamp( TS_UNIX, $timestamp )
580
								- wfTimestamp( TS_UNIX, $minExpTime );
581
						}
582
						foreach ( $rows as $row ) {
0 ignored issues
show
Bug introduced by
The expression $rows of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
583
							$keys[] = $row->keyname;
584
							$maxExpTime = $row->exptime;
585
						}
586
587
						$db->delete(
588
							$this->getTableNameByShard( $i ),
589
							[
590
								'exptime >= ' . $db->addQuotes( $minExpTime ),
591
								'exptime < ' . $db->addQuotes( $dbTimestamp ),
592
								'keyname' => $keys
593
							],
594
							__METHOD__ );
595
596
						if ( $progressCallback ) {
597
							if ( intval( $totalSeconds ) === 0 ) {
598
								$percent = 0;
599
							} else {
600
								$remainingSeconds = wfTimestamp( TS_UNIX, $timestamp )
601
									- wfTimestamp( TS_UNIX, $maxExpTime );
602
								if ( $remainingSeconds > $totalSeconds ) {
603
									$totalSeconds = $remainingSeconds;
604
								}
605
								$processedSeconds = $totalSeconds - $remainingSeconds;
606
								$percent = ( $i + $processedSeconds / $totalSeconds )
607
									/ $this->shards * 100;
608
							}
609
							$percent = ( $percent / $this->numServers )
610
								+ ( $serverIndex / $this->numServers * 100 );
611
							call_user_func( $progressCallback, $percent );
612
						}
613
					}
614
				}
615
			} catch ( DBError $e ) {
616
				$this->handleWriteError( $e, $db, $serverIndex );
617
				return false;
618
			}
619
		}
620
		return true;
621
	}
622
623
	/**
624
	 * Delete content of shard tables in every server.
625
	 * Return true if the operation is successful, false otherwise.
626
	 * @return bool
627
	 */
628
	public function deleteAll() {
629
		for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
630
			$db = null;
631
			try {
632
				$db = $this->getDB( $serverIndex );
633
				for ( $i = 0; $i < $this->shards; $i++ ) {
634
					$db->delete( $this->getTableNameByShard( $i ), '*', __METHOD__ );
635
				}
636
			} catch ( DBError $e ) {
637
				$this->handleWriteError( $e, $db, $serverIndex );
638
				return false;
639
			}
640
		}
641
		return true;
642
	}
643
644
	/**
645
	 * Serialize an object and, if possible, compress the representation.
646
	 * On typical message and page data, this can provide a 3X decrease
647
	 * in storage requirements.
648
	 *
649
	 * @param mixed $data
650
	 * @return string
651
	 */
652
	protected function serialize( &$data ) {
653
		$serial = serialize( $data );
654
655
		if ( function_exists( 'gzdeflate' ) ) {
656
			return gzdeflate( $serial );
657
		} else {
658
			return $serial;
659
		}
660
	}
661
662
	/**
663
	 * Unserialize and, if necessary, decompress an object.
664
	 * @param string $serial
665
	 * @return mixed
666
	 */
667
	protected function unserialize( $serial ) {
668
		if ( function_exists( 'gzinflate' ) ) {
669
			MediaWiki\suppressWarnings();
670
			$decomp = gzinflate( $serial );
671
			MediaWiki\restoreWarnings();
672
673
			if ( false !== $decomp ) {
674
				$serial = $decomp;
675
			}
676
		}
677
678
		$ret = unserialize( $serial );
679
680
		return $ret;
681
	}
682
683
	/**
684
	 * Handle a DBError which occurred during a read operation.
685
	 *
686
	 * @param DBError $exception
687
	 * @param int $serverIndex
688
	 */
689
	protected function handleReadError( DBError $exception, $serverIndex ) {
690
		if ( $exception instanceof DBConnectionError ) {
691
			$this->markServerDown( $exception, $serverIndex );
692
		}
693
		$this->logger->error( "DBError: {$exception->getMessage()}" );
694 View Code Duplication
		if ( $exception instanceof DBConnectionError ) {
695
			$this->setLastError( BagOStuff::ERR_UNREACHABLE );
696
			$this->logger->debug( __METHOD__ . ": ignoring connection error" );
697
		} else {
698
			$this->setLastError( BagOStuff::ERR_UNEXPECTED );
699
			$this->logger->debug( __METHOD__ . ": ignoring query error" );
700
		}
701
	}
702
703
	/**
704
	 * Handle a DBQueryError which occurred during a write operation.
705
	 *
706
	 * @param DBError $exception
707
	 * @param IDatabase|null $db DB handle or null if connection failed
708
	 * @param int $serverIndex
709
	 * @throws Exception
710
	 */
711
	protected function handleWriteError( DBError $exception, IDatabase $db = null, $serverIndex ) {
712
		if ( !$db ) {
713
			$this->markServerDown( $exception, $serverIndex );
714
		} elseif ( $db->wasReadOnlyError() ) {
715
			if ( $db->trxLevel() && $this->usesMainDB() ) {
716
				// Errors like deadlocks and connection drops already cause rollback.
717
				// For consistency, we have no choice but to throw an error and trigger
718
				// complete rollback if the main DB is also being used as the cache DB.
719
				throw $exception;
720
			}
721
		}
722
723
		$this->logger->error( "DBError: {$exception->getMessage()}" );
724 View Code Duplication
		if ( $exception instanceof DBConnectionError ) {
725
			$this->setLastError( BagOStuff::ERR_UNREACHABLE );
726
			$this->logger->debug( __METHOD__ . ": ignoring connection error" );
727
		} else {
728
			$this->setLastError( BagOStuff::ERR_UNEXPECTED );
729
			$this->logger->debug( __METHOD__ . ": ignoring query error" );
730
		}
731
	}
732
733
	/**
734
	 * Mark a server down due to a DBConnectionError exception
735
	 *
736
	 * @param DBError $exception
737
	 * @param int $serverIndex
738
	 */
739
	protected function markServerDown( DBError $exception, $serverIndex ) {
740
		unset( $this->conns[$serverIndex] ); // bug T103435
741
742
		if ( isset( $this->connFailureTimes[$serverIndex] ) ) {
743
			if ( time() - $this->connFailureTimes[$serverIndex] >= 60 ) {
744
				unset( $this->connFailureTimes[$serverIndex] );
745
				unset( $this->connFailureErrors[$serverIndex] );
746
			} else {
747
				$this->logger->debug( __METHOD__ . ": Server #$serverIndex already down" );
748
				return;
749
			}
750
		}
751
		$now = time();
752
		$this->logger->info( __METHOD__ . ": Server #$serverIndex down until " . ( $now + 60 ) );
753
		$this->connFailureTimes[$serverIndex] = $now;
754
		$this->connFailureErrors[$serverIndex] = $exception;
755
	}
756
757
	/**
758
	 * Create shard tables. For use from eval.php.
759
	 */
760
	public function createTables() {
761
		for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
762
			$db = $this->getDB( $serverIndex );
763
			if ( $db->getType() !== 'mysql' ) {
764
				throw new MWException( __METHOD__ . ' is not supported on this DB server' );
765
			}
766
767
			for ( $i = 0; $i < $this->shards; $i++ ) {
768
				$db->query(
769
					'CREATE TABLE ' . $db->tableName( $this->getTableNameByShard( $i ) ) .
770
					' LIKE ' . $db->tableName( 'objectcache' ),
771
					__METHOD__ );
772
			}
773
		}
774
	}
775
776
	/**
777
	 * @return bool Whether the main DB is used, e.g. wfGetDB( DB_MASTER )
778
	 */
779
	protected function usesMainDB() {
780
		return !$this->serverInfos;
781
	}
782
783
	protected function waitForSlaves() {
784
		if ( $this->usesMainDB() ) {
785
			// Main LB is used; wait for any slaves to catch up
786
			try {
787
				$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
788
				$lbFactory->waitForReplication( [ 'wiki' => wfWikiID() ] );
789
				return true;
790
			} catch ( DBReplicationWaitError $e ) {
791
				return false;
792
			}
793
		} else {
794
			// Custom DB server list; probably doesn't use replication
795
			return true;
796
		}
797
	}
798
}
799