Completed
Branch master (e9bc15)
by
unknown
32:19
created

LoadBalancer::clearLagTimeCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Database load balancing manager
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 Database
22
 */
23
use Psr\Log\LoggerInterface;
24
25
/**
26
 * Database connection, tracking, load balancing, and transaction manager for a cluster
27
 *
28
 * @ingroup Database
29
 */
30
class LoadBalancer implements ILoadBalancer {
31
	/** @var array[] Map of (server index => server config array) */
32
	private $mServers;
33
	/** @var array[] Map of (local/foreignUsed/foreignFree => server index => IDatabase array) */
34
	private $mConns;
35
	/** @var float[] Map of (server index => weight) */
36
	private $mLoads;
37
	/** @var array[] Map of (group => server index => weight) */
38
	private $mGroupLoads;
39
	/** @var bool Whether to disregard replica DB lag as a factor in replica DB selection */
40
	private $mAllowLagged;
41
	/** @var integer Seconds to spend waiting on replica DB lag to resolve */
42
	private $mWaitTimeout;
43
	/** @var array The LoadMonitor configuration */
44
	private $loadMonitorConfig;
45
	/** @var array[] $aliases Map of (table => (dbname, schema, prefix) map) */
46
	private $tableAliases = [];
47
48
	/** @var ILoadMonitor */
49
	private $loadMonitor;
50
	/** @var BagOStuff */
51
	private $srvCache;
52
	/** @var BagOStuff */
53
	private $memCache;
54
	/** @var WANObjectCache */
55
	private $wanCache;
56
	/** @var object|string Class name or object With profileIn/profileOut methods */
57
	protected $profiler;
58
	/** @var TransactionProfiler */
59
	protected $trxProfiler;
60
	/** @var LoggerInterface */
61
	protected $replLogger;
62
	/** @var LoggerInterface */
63
	protected $connLogger;
64
	/** @var LoggerInterface */
65
	protected $queryLogger;
66
	/** @var LoggerInterface */
67
	protected $perfLogger;
68
69
	/** @var bool|IDatabase Database connection that caused a problem */
70
	private $mErrorConnection;
71
	/** @var integer The generic (not query grouped) replica DB index (of $mServers) */
72
	private $mReadIndex;
73
	/** @var bool|DBMasterPos False if not set */
74
	private $mWaitForPos;
75
	/** @var bool Whether the generic reader fell back to a lagged replica DB */
76
	private $laggedReplicaMode = false;
77
	/** @var bool Whether the generic reader fell back to a lagged replica DB */
78
	private $allReplicasDownMode = false;
79
	/** @var string The last DB selection or connection error */
80
	private $mLastError = 'Unknown error';
81
	/** @var string|bool Reason the LB is read-only or false if not */
82
	private $readOnlyReason = false;
83
	/** @var integer Total connections opened */
84
	private $connsOpened = 0;
85
	/** @var string|bool String if a requested DBO_TRX transaction round is active */
86
	private $trxRoundId = false;
87
	/** @var array[] Map of (name => callable) */
88
	private $trxRecurringCallbacks = [];
89
	/** @var DatabaseDomain Local Domain ID and default for selectDB() calls */
90
	private $localDomain;
91
	/** @var string Alternate ID string for the domain instead of DatabaseDomain::getId() */
92
	private $localDomainIdAlias;
93
	/** @var string Current server name */
94
	private $host;
95
	/** @var bool Whether this PHP instance is for a CLI script */
96
	protected $cliMode;
97
	/** @var string Agent name for query profiling */
98
	protected $agent;
99
100
	/** @var callable Exception logger */
101
	private $errorLogger;
102
103
	/** @var boolean */
104
	private $disabled = false;
105
106
	/** @var integer Warn when this many connection are held */
107
	const CONN_HELD_WARN_THRESHOLD = 10;
108
	/** @var integer Default 'max lag' when unspecified */
109
	const MAX_LAG_DEFAULT = 10;
110
	/** @var integer Max time to wait for a replica DB to catch up (e.g. ChronologyProtector) */
111
	const POS_WAIT_TIMEOUT = 10;
112
	/** @var integer Seconds to cache master server read-only status */
113
	const TTL_CACHE_READONLY = 5;
114
115
	public function __construct( array $params ) {
116
		if ( !isset( $params['servers'] ) ) {
117
			throw new InvalidArgumentException( __CLASS__ . ': missing servers parameter' );
118
		}
119
		$this->mServers = $params['servers'];
120
121
		$this->localDomain = isset( $params['localDomain'] )
122
			? DatabaseDomain::newFromId( $params['localDomain'] )
123
			: DatabaseDomain::newUnspecified();
124
		// In case a caller assumes that the domain ID is simply <db>-<prefix>, which is almost
125
		// always true, gracefully handle the case when they fail to account for escaping.
126
		if ( $this->localDomain->getTablePrefix() != '' ) {
127
			$this->localDomainIdAlias =
128
				$this->localDomain->getDatabase() . '-' . $this->localDomain->getTablePrefix();
129
		} else {
130
			$this->localDomainIdAlias = $this->localDomain->getDatabase();
131
		}
132
133
		$this->mWaitTimeout = isset( $params['waitTimeout'] )
134
			? $params['waitTimeout']
135
			: self::POS_WAIT_TIMEOUT;
136
137
		$this->mReadIndex = -1;
138
		$this->mConns = [
0 ignored issues
show
Documentation Bug introduced by
It seems like array('local' => array()...oreignFree' => array()) of type array<string,array,{"loc..."foreignFree":"array"}> is incompatible with the declared type array<integer,array> of property $mConns.

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...
139
			'local'       => [],
140
			'foreignUsed' => [],
141
			'foreignFree' => []
142
		];
143
		$this->mLoads = [];
144
		$this->mWaitForPos = false;
145
		$this->mErrorConnection = false;
146
		$this->mAllowLagged = false;
147
148 View Code Duplication
		if ( isset( $params['readOnlyReason'] ) && is_string( $params['readOnlyReason'] ) ) {
149
			$this->readOnlyReason = $params['readOnlyReason'];
150
		}
151
152
		if ( isset( $params['loadMonitor'] ) ) {
153
			$this->loadMonitorConfig = $params['loadMonitor'];
154
		} else {
155
			$this->loadMonitorConfig = [ 'class' => 'LoadMonitorNull' ];
156
		}
157
158
		foreach ( $params['servers'] as $i => $server ) {
159
			$this->mLoads[$i] = $server['load'];
160
			if ( isset( $server['groupLoads'] ) ) {
161
				foreach ( $server['groupLoads'] as $group => $ratio ) {
162
					if ( !isset( $this->mGroupLoads[$group] ) ) {
163
						$this->mGroupLoads[$group] = [];
164
					}
165
					$this->mGroupLoads[$group][$i] = $ratio;
166
				}
167
			}
168
		}
169
170
		if ( isset( $params['srvCache'] ) ) {
171
			$this->srvCache = $params['srvCache'];
172
		} else {
173
			$this->srvCache = new EmptyBagOStuff();
174
		}
175
		if ( isset( $params['memCache'] ) ) {
176
			$this->memCache = $params['memCache'];
177
		} else {
178
			$this->memCache = new EmptyBagOStuff();
179
		}
180
		if ( isset( $params['wanCache'] ) ) {
181
			$this->wanCache = $params['wanCache'];
182
		} else {
183
			$this->wanCache = WANObjectCache::newEmpty();
184
		}
185
		$this->profiler = isset( $params['profiler'] ) ? $params['profiler'] : null;
186
		if ( isset( $params['trxProfiler'] ) ) {
187
			$this->trxProfiler = $params['trxProfiler'];
188
		} else {
189
			$this->trxProfiler = new TransactionProfiler();
190
		}
191
192
		$this->errorLogger = isset( $params['errorLogger'] )
193
			? $params['errorLogger']
194
			: function ( Exception $e ) {
195
				trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_WARNING );
196
			};
197
198
		foreach ( [ 'replLogger', 'connLogger', 'queryLogger', 'perfLogger' ] as $key ) {
199
			$this->$key = isset( $params[$key] ) ? $params[$key] : new \Psr\Log\NullLogger();
200
		}
201
202
		$this->host = isset( $params['hostname'] )
203
			? $params['hostname']
204
			: ( gethostname() ?: 'unknown' );
205
		$this->cliMode = isset( $params['cliMode'] ) ? $params['cliMode'] : PHP_SAPI === 'cli';
206
		$this->agent = isset( $params['agent'] ) ? $params['agent'] : '';
207
	}
208
209
	/**
210
	 * Get a LoadMonitor instance
211
	 *
212
	 * @return ILoadMonitor
213
	 */
214
	private function getLoadMonitor() {
215
		if ( !isset( $this->loadMonitor ) ) {
216
			$class = $this->loadMonitorConfig['class'];
217
			$this->loadMonitor = new $class(
218
				$this, $this->srvCache, $this->memCache, $this->loadMonitorConfig );
219
			$this->loadMonitor->setLogger( $this->replLogger );
220
		}
221
222
		return $this->loadMonitor;
223
	}
224
225
	/**
226
	 * @param array $loads
227
	 * @param bool|string $domain Domain to get non-lagged for
228
	 * @param int $maxLag Restrict the maximum allowed lag to this many seconds
229
	 * @return bool|int|string
230
	 */
231
	private function getRandomNonLagged( array $loads, $domain = false, $maxLag = INF ) {
232
		$lags = $this->getLagTimes( $domain );
0 ignored issues
show
Bug introduced by
It seems like $domain defined by parameter $domain on line 231 can also be of type string; however, LoadBalancer::getLagTimes() does only seem to accept boolean, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
233
234
		# Unset excessively lagged servers
235
		foreach ( $lags as $i => $lag ) {
236
			if ( $i != 0 ) {
237
				# How much lag this server nominally is allowed to have
238
				$maxServerLag = isset( $this->mServers[$i]['max lag'] )
239
					? $this->mServers[$i]['max lag']
240
					: self::MAX_LAG_DEFAULT; // default
241
				# Constrain that futher by $maxLag argument
242
				$maxServerLag = min( $maxServerLag, $maxLag );
243
244
				$host = $this->getServerName( $i );
245
				if ( $lag === false && !is_infinite( $maxServerLag ) ) {
246
					$this->replLogger->error( "Server $host (#$i) is not replicating?" );
247
					unset( $loads[$i] );
248
				} elseif ( $lag > $maxServerLag ) {
249
					$this->replLogger->warning( "Server $host (#$i) has >= $lag seconds of lag" );
250
					unset( $loads[$i] );
251
				}
252
			}
253
		}
254
255
		# Find out if all the replica DBs with non-zero load are lagged
256
		$sum = 0;
257
		foreach ( $loads as $load ) {
258
			$sum += $load;
259
		}
260
		if ( $sum == 0 ) {
261
			# No appropriate DB servers except maybe the master and some replica DBs with zero load
262
			# Do NOT use the master
263
			# Instead, this function will return false, triggering read-only mode,
264
			# and a lagged replica DB will be used instead.
265
			return false;
266
		}
267
268
		if ( count( $loads ) == 0 ) {
269
			return false;
270
		}
271
272
		# Return a random representative of the remainder
273
		return ArrayUtils::pickRandom( $loads );
274
	}
275
276
	public function getReaderIndex( $group = false, $domain = false ) {
277
		if ( count( $this->mServers ) == 1 ) {
278
			# Skip the load balancing if there's only one server
279
			return $this->getWriterIndex();
280
		} elseif ( $group === false && $this->mReadIndex >= 0 ) {
281
			# Shortcut if generic reader exists already
282
			return $this->mReadIndex;
283
		}
284
285
		# Find the relevant load array
286
		if ( $group !== false ) {
287
			if ( isset( $this->mGroupLoads[$group] ) ) {
288
				$nonErrorLoads = $this->mGroupLoads[$group];
289
			} else {
290
				# No loads for this group, return false and the caller can use some other group
291
				$this->connLogger->info( __METHOD__ . ": no loads for group $group" );
292
293
				return false;
294
			}
295
		} else {
296
			$nonErrorLoads = $this->mLoads;
297
		}
298
299
		if ( !count( $nonErrorLoads ) ) {
300
			throw new InvalidArgumentException( "Empty server array given to LoadBalancer" );
301
		}
302
303
		# Scale the configured load ratios according to the dynamic load if supported
304
		$this->getLoadMonitor()->scaleLoads( $nonErrorLoads, $domain );
305
306
		$laggedReplicaMode = false;
307
308
		# No server found yet
309
		$i = false;
310
		# First try quickly looking through the available servers for a server that
311
		# meets our criteria
312
		$currentLoads = $nonErrorLoads;
313
		while ( count( $currentLoads ) ) {
314
			if ( $this->mAllowLagged || $laggedReplicaMode ) {
315
				$i = ArrayUtils::pickRandom( $currentLoads );
316
			} else {
317
				$i = false;
318
				if ( $this->mWaitForPos && $this->mWaitForPos->asOfTime() ) {
319
					# ChronologyProtecter causes mWaitForPos to be set via sessions.
320
					# This triggers doWait() after connect, so it's especially good to
321
					# avoid lagged servers so as to avoid just blocking in that method.
322
					$ago = microtime( true ) - $this->mWaitForPos->asOfTime();
323
					# Aim for <= 1 second of waiting (being too picky can backfire)
324
					$i = $this->getRandomNonLagged( $currentLoads, $domain, $ago + 1 );
325
				}
326
				if ( $i === false ) {
327
					# Any server with less lag than it's 'max lag' param is preferable
328
					$i = $this->getRandomNonLagged( $currentLoads, $domain );
329
				}
330
				if ( $i === false && count( $currentLoads ) != 0 ) {
331
					# All replica DBs lagged. Switch to read-only mode
332
					$this->replLogger->error( "All replica DBs lagged. Switch to read-only mode" );
333
					$i = ArrayUtils::pickRandom( $currentLoads );
334
					$laggedReplicaMode = true;
335
				}
336
			}
337
338
			if ( $i === false ) {
339
				# pickRandom() returned false
340
				# This is permanent and means the configuration or the load monitor
341
				# wants us to return false.
342
				$this->connLogger->debug( __METHOD__ . ": pickRandom() returned false" );
343
344
				return false;
345
			}
346
347
			$serverName = $this->getServerName( $i );
348
			$this->connLogger->debug( __METHOD__ . ": Using reader #$i: $serverName..." );
349
350
			$conn = $this->openConnection( $i, $domain );
351
			if ( !$conn ) {
352
				$this->connLogger->warning( __METHOD__ . ": Failed connecting to $i/$domain" );
353
				unset( $nonErrorLoads[$i] );
354
				unset( $currentLoads[$i] );
355
				$i = false;
356
				continue;
357
			}
358
359
			// Decrement reference counter, we are finished with this connection.
360
			// It will be incremented for the caller later.
361
			if ( $domain !== false ) {
362
				$this->reuseConnection( $conn );
363
			}
364
365
			# Return this server
366
			break;
367
		}
368
369
		# If all servers were down, quit now
370
		if ( !count( $nonErrorLoads ) ) {
371
			$this->connLogger->error( "All servers down" );
372
		}
373
374
		if ( $i !== false ) {
375
			# Replica DB connection successful.
376
			# Wait for the session master pos for a short time.
377
			if ( $this->mWaitForPos && $i > 0 ) {
378
				$this->doWait( $i );
379
			}
380
			if ( $this->mReadIndex <= 0 && $this->mLoads[$i] > 0 && $group === false ) {
381
				$this->mReadIndex = $i;
0 ignored issues
show
Documentation Bug introduced by
It seems like $i can also be of type string. However, the property $mReadIndex is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
382
				# Record if the generic reader index is in "lagged replica DB" mode
383
				if ( $laggedReplicaMode ) {
384
					$this->laggedReplicaMode = true;
385
				}
386
			}
387
			$serverName = $this->getServerName( $i );
388
			$this->connLogger->debug(
389
				__METHOD__ . ": using server $serverName for group '$group'" );
390
		}
391
392
		return $i;
393
	}
394
395
	public function waitFor( $pos ) {
396
		$this->mWaitForPos = $pos;
397
		$i = $this->mReadIndex;
398
399
		if ( $i > 0 ) {
400
			if ( !$this->doWait( $i ) ) {
401
				$this->laggedReplicaMode = true;
402
			}
403
		}
404
	}
405
406
	public function waitForOne( $pos, $timeout = null ) {
407
		$this->mWaitForPos = $pos;
408
409
		$i = $this->mReadIndex;
410
		if ( $i <= 0 ) {
411
			// Pick a generic replica DB if there isn't one yet
412
			$readLoads = $this->mLoads;
413
			unset( $readLoads[$this->getWriterIndex()] ); // replica DBs only
414
			$readLoads = array_filter( $readLoads ); // with non-zero load
415
			$i = ArrayUtils::pickRandom( $readLoads );
416
		}
417
418 View Code Duplication
		if ( $i > 0 ) {
419
			$ok = $this->doWait( $i, true, $timeout );
420
		} else {
421
			$ok = true; // no applicable loads
422
		}
423
424
		return $ok;
425
	}
426
427
	public function waitForAll( $pos, $timeout = null ) {
428
		$this->mWaitForPos = $pos;
429
		$serverCount = count( $this->mServers );
430
431
		$ok = true;
432
		for ( $i = 1; $i < $serverCount; $i++ ) {
433 View Code Duplication
			if ( $this->mLoads[$i] > 0 ) {
434
				$ok = $this->doWait( $i, true, $timeout ) && $ok;
435
			}
436
		}
437
438
		return $ok;
439
	}
440
441
	public function getAnyOpenConnection( $i ) {
442
		foreach ( $this->mConns as $connsByServer ) {
443
			if ( !empty( $connsByServer[$i] ) ) {
444
				return reset( $connsByServer[$i] );
445
			}
446
		}
447
448
		return false;
449
	}
450
451
	/**
452
	 * Wait for a given replica DB to catch up to the master pos stored in $this
453
	 * @param int $index Server index
454
	 * @param bool $open Check the server even if a new connection has to be made
455
	 * @param int $timeout Max seconds to wait; default is mWaitTimeout
456
	 * @return bool
457
	 */
458
	protected function doWait( $index, $open = false, $timeout = null ) {
459
		$close = false; // close the connection afterwards
460
461
		// Check if we already know that the DB has reached this point
462
		$server = $this->getServerName( $index );
463
		$key = $this->srvCache->makeGlobalKey( __CLASS__, 'last-known-pos', $server );
464
		/** @var DBMasterPos $knownReachedPos */
465
		$knownReachedPos = $this->srvCache->get( $key );
466
		if ( $knownReachedPos && $knownReachedPos->hasReached( $this->mWaitForPos ) ) {
0 ignored issues
show
Bug introduced by
It seems like $this->mWaitForPos can also be of type boolean; however, DBMasterPos::hasReached() does only seem to accept object<DBMasterPos>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
467
			$this->replLogger->debug( __METHOD__ .
468
				": replica DB $server known to be caught up (pos >= $knownReachedPos)." );
469
			return true;
470
		}
471
472
		// Find a connection to wait on, creating one if needed and allowed
473
		$conn = $this->getAnyOpenConnection( $index );
474
		if ( !$conn ) {
475
			if ( !$open ) {
476
				$this->replLogger->debug( __METHOD__ . ": no connection open for $server" );
477
478
				return false;
479
			} else {
480
				$conn = $this->openConnection( $index, '' );
481
				if ( !$conn ) {
482
					$this->replLogger->warning( __METHOD__ . ": failed to connect to $server" );
483
484
					return false;
485
				}
486
				// Avoid connection spam in waitForAll() when connections
487
				// are made just for the sake of doing this lag check.
488
				$close = true;
489
			}
490
		}
491
492
		$this->replLogger->info( __METHOD__ . ": Waiting for replica DB $server to catch up..." );
493
		$timeout = $timeout ?: $this->mWaitTimeout;
494
		$result = $conn->masterPosWait( $this->mWaitForPos, $timeout );
0 ignored issues
show
Bug introduced by
It seems like $this->mWaitForPos can also be of type boolean; however, IDatabase::masterPosWait() does only seem to accept object<DBMasterPos>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
495
496
		if ( $result == -1 || is_null( $result ) ) {
497
			// Timed out waiting for replica DB, use master instead
498
			$msg = __METHOD__ . ": Timed out waiting on $server pos {$this->mWaitForPos}";
499
			$this->replLogger->warning( "$msg" );
500
			$ok = false;
501
		} else {
502
			$this->replLogger->info( __METHOD__ . ": Done" );
503
			$ok = true;
504
			// Remember that the DB reached this point
505
			$this->srvCache->set( $key, $this->mWaitForPos, BagOStuff::TTL_DAY );
506
		}
507
508
		if ( $close ) {
509
			$this->closeConnection( $conn );
0 ignored issues
show
Bug introduced by
It seems like $conn can also be of type boolean; however, LoadBalancer::closeConnection() does only seem to accept object<IDatabase>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
510
		}
511
512
		return $ok;
513
	}
514
515
	/**
516
	 * @see ILoadBalancer::getConnection()
517
	 *
518
	 * @param int $i
519
	 * @param array $groups
520
	 * @param bool $domain
521
	 * @return Database
522
	 * @throws DBConnectionError
523
	 */
524
	public function getConnection( $i, $groups = [], $domain = false ) {
525
		if ( $i === null || $i === false ) {
526
			throw new InvalidArgumentException( 'Attempt to call ' . __METHOD__ .
527
				' with invalid server index' );
528
		}
529
530
		if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) {
531
			$domain = false; // local connection requested
532
		}
533
534
		$groups = ( $groups === false || $groups === [] )
535
			? [ false ] // check one "group": the generic pool
536
			: (array)$groups;
537
538
		$masterOnly = ( $i == self::DB_MASTER || $i == $this->getWriterIndex() );
539
		$oldConnsOpened = $this->connsOpened; // connections open now
540
541
		if ( $i == self::DB_MASTER ) {
542
			$i = $this->getWriterIndex();
543
		} else {
544
			# Try to find an available server in any the query groups (in order)
545
			foreach ( $groups as $group ) {
546
				$groupIndex = $this->getReaderIndex( $group, $domain );
547
				if ( $groupIndex !== false ) {
548
					$i = $groupIndex;
549
					break;
550
				}
551
			}
552
		}
553
554
		# Operation-based index
555
		if ( $i == self::DB_REPLICA ) {
556
			$this->mLastError = 'Unknown error'; // reset error string
557
			# Try the general server pool if $groups are unavailable.
558
			$i = in_array( false, $groups, true )
559
				? false // don't bother with this if that is what was tried above
560
				: $this->getReaderIndex( false, $domain );
561
			# Couldn't find a working server in getReaderIndex()?
562
			if ( $i === false ) {
563
				$this->mLastError = 'No working replica DB server: ' . $this->mLastError;
564
				// Throw an exception
565
				$this->reportConnectionError();
566
				return null; // not reached
567
			}
568
		}
569
570
		# Now we have an explicit index into the servers array
571
		$conn = $this->openConnection( $i, $domain );
572
		if ( !$conn ) {
573
			// Throw an exception
574
			$this->reportConnectionError();
575
			return null; // not reached
576
		}
577
578
		# Profile any new connections that happen
579
		if ( $this->connsOpened > $oldConnsOpened ) {
580
			$host = $conn->getServer();
581
			$dbname = $conn->getDBname();
582
			$this->trxProfiler->recordConnection( $host, $dbname, $masterOnly );
583
		}
584
585
		if ( $masterOnly ) {
586
			# Make master-requested DB handles inherit any read-only mode setting
587
			$conn->setLBInfo( 'readOnlyReason', $this->getReadOnlyReason( $domain, $conn ) );
0 ignored issues
show
Bug introduced by
It seems like $conn defined by $this->openConnection($i, $domain) on line 571 can also be of type boolean; however, LoadBalancer::getReadOnlyReason() does only seem to accept null|object<IDatabase>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
588
		}
589
590
		return $conn;
591
	}
592
593
	public function reuseConnection( $conn ) {
594
		$serverIndex = $conn->getLBInfo( 'serverIndex' );
595
		$refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
596
		if ( $serverIndex === null || $refCount === null ) {
597
			/**
598
			 * This can happen in code like:
599
			 *   foreach ( $dbs as $db ) {
600
			 *     $conn = $lb->getConnection( $lb::DB_REPLICA, [], $db );
601
			 *     ...
602
			 *     $lb->reuseConnection( $conn );
603
			 *   }
604
			 * When a connection to the local DB is opened in this way, reuseConnection()
605
			 * should be ignored
606
			 */
607
			return;
608
		} elseif ( $conn instanceof DBConnRef ) {
609
			// DBConnRef already handles calling reuseConnection() and only passes the live
610
			// Database instance to this method. Any caller passing in a DBConnRef is broken.
611
			$this->connLogger->error( __METHOD__ . ": got DBConnRef instance.\n" .
612
				( new RuntimeException() )->getTraceAsString() );
613
614
			return;
615
		}
616
617
		$domain = $conn->getDomainID();
618
		if ( !isset( $this->mConns['foreignUsed'][$serverIndex][$domain] ) ) {
619
			throw new InvalidArgumentException( __METHOD__ .
620
				": connection $serverIndex/$domain not found; it may have already been freed." );
621
		} elseif ( $this->mConns['foreignUsed'][$serverIndex][$domain] !== $conn ) {
622
			throw new InvalidArgumentException( __METHOD__ .
623
				": connection $serverIndex/$domain mismatched; it may have already been freed." );
624
		}
625
		$conn->setLBInfo( 'foreignPoolRefCount', --$refCount );
626
		if ( $refCount <= 0 ) {
627
			$this->mConns['foreignFree'][$serverIndex][$domain] = $conn;
628
			unset( $this->mConns['foreignUsed'][$serverIndex][$domain] );
629
			if ( !$this->mConns['foreignUsed'][$serverIndex] ) {
630
				unset( $this->mConns[ 'foreignUsed' ][$serverIndex] ); // clean up
631
			}
632
			$this->connLogger->debug( __METHOD__ . ": freed connection $serverIndex/$domain" );
633
		} else {
634
			$this->connLogger->debug( __METHOD__ .
635
				": reference count for $serverIndex/$domain reduced to $refCount" );
636
		}
637
	}
638
639
	public function getConnectionRef( $db, $groups = [], $domain = false ) {
640
		$domain = ( $domain !== false ) ? $domain : $this->localDomain;
641
642
		return new DBConnRef( $this, $this->getConnection( $db, $groups, $domain ) );
0 ignored issues
show
Bug introduced by
It seems like $domain defined by $domain !== false ? $domain : $this->localDomain on line 640 can also be of type object<DatabaseDomain>; however, LoadBalancer::getConnection() does only seem to accept boolean, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
Bug introduced by
It seems like $this->getConnection($db, $groups, $domain) can be null; however, __construct() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
643
	}
644
645
	public function getLazyConnectionRef( $db, $groups = [], $domain = false ) {
646
		$domain = ( $domain !== false ) ? $domain : $this->localDomain;
647
648
		return new DBConnRef( $this, [ $db, $groups, $domain ] );
649
	}
650
651
	/**
652
	 * @see ILoadBalancer::openConnection()
653
	 *
654
	 * @param int $i
655
	 * @param bool $domain
656
	 * @return bool|Database
657
	 * @throws DBAccessError
658
	 */
659
	public function openConnection( $i, $domain = false ) {
660
		if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) {
661
			$domain = false; // local connection requested
662
		}
663
664
		if ( $domain !== false ) {
665
			$conn = $this->openForeignConnection( $i, $domain );
666
		} elseif ( isset( $this->mConns['local'][$i][0] ) ) {
667
			$conn = $this->mConns['local'][$i][0];
668
		} else {
669
			$server = $this->mServers[$i];
670
			$server['serverIndex'] = $i;
671
			$conn = $this->reallyOpenConnection( $server, false );
672
			$serverName = $this->getServerName( $i );
673
			if ( $conn->isOpen() ) {
674
				$this->connLogger->debug( "Connected to database $i at '$serverName'." );
675
				$this->mConns['local'][$i][0] = $conn;
676
			} else {
677
				$this->connLogger->warning( "Failed to connect to database $i at '$serverName'." );
678
				$this->mErrorConnection = $conn;
679
				$conn = false;
680
			}
681
		}
682
683
		if ( $conn && !$conn->isOpen() ) {
684
			// Connection was made but later unrecoverably lost for some reason.
685
			// Do not return a handle that will just throw exceptions on use,
686
			// but let the calling code (e.g. getReaderIndex) try another server.
687
			// See DatabaseMyslBase::ping() for how this can happen.
688
			$this->mErrorConnection = $conn;
689
			$conn = false;
690
		}
691
692
		return $conn;
693
	}
694
695
	/**
696
	 * Open a connection to a foreign DB, or return one if it is already open.
697
	 *
698
	 * Increments a reference count on the returned connection which locks the
699
	 * connection to the requested domain. This reference count can be
700
	 * decremented by calling reuseConnection().
701
	 *
702
	 * If a connection is open to the appropriate server already, but with the wrong
703
	 * database, it will be switched to the right database and returned, as long as
704
	 * it has been freed first with reuseConnection().
705
	 *
706
	 * On error, returns false, and the connection which caused the
707
	 * error will be available via $this->mErrorConnection.
708
	 *
709
	 * @note If disable() was called on this LoadBalancer, this method will throw a DBAccessError.
710
	 *
711
	 * @param int $i Server index
712
	 * @param string $domain Domain ID to open
713
	 * @return Database
714
	 */
715
	private function openForeignConnection( $i, $domain ) {
716
		$domainInstance = DatabaseDomain::newFromId( $domain );
717
		$dbName = $domainInstance->getDatabase();
718
		$prefix = $domainInstance->getTablePrefix();
719
720
		if ( isset( $this->mConns['foreignUsed'][$i][$domain] ) ) {
721
			// Reuse an already-used connection
722
			$conn = $this->mConns['foreignUsed'][$i][$domain];
723
			$this->connLogger->debug( __METHOD__ . ": reusing connection $i/$domain" );
724
		} elseif ( isset( $this->mConns['foreignFree'][$i][$domain] ) ) {
725
			// Reuse a free connection for the same domain
726
			$conn = $this->mConns['foreignFree'][$i][$domain];
727
			unset( $this->mConns['foreignFree'][$i][$domain] );
728
			$this->mConns['foreignUsed'][$i][$domain] = $conn;
729
			$this->connLogger->debug( __METHOD__ . ": reusing free connection $i/$domain" );
730
		} elseif ( !empty( $this->mConns['foreignFree'][$i] ) ) {
731
			// Reuse a connection from another domain
732
			$conn = reset( $this->mConns['foreignFree'][$i] );
733
			$oldDomain = key( $this->mConns['foreignFree'][$i] );
734
			// The empty string as a DB name means "don't care".
735
			// DatabaseMysqlBase::open() already handle this on connection.
736
			if ( strlen( $dbName ) && !$conn->selectDB( $dbName ) ) {
737
				$this->mLastError = "Error selecting database '$dbName' on server " .
738
					$conn->getServer() . " from client host {$this->host}";
739
				$this->mErrorConnection = $conn;
740
				$conn = false;
741
			} else {
742
				$conn->tablePrefix( $prefix );
743
				unset( $this->mConns['foreignFree'][$i][$oldDomain] );
744
				$this->mConns['foreignUsed'][$i][$domain] = $conn;
745
				$this->connLogger->debug( __METHOD__ .
746
					": reusing free connection from $oldDomain for $domain" );
747
			}
748
		} else {
749
			// Open a new connection
750
			$server = $this->mServers[$i];
751
			$server['serverIndex'] = $i;
752
			$server['foreignPoolRefCount'] = 0;
753
			$server['foreign'] = true;
754
			$conn = $this->reallyOpenConnection( $server, $dbName );
755
			if ( !$conn->isOpen() ) {
756
				$this->connLogger->warning( __METHOD__ . ": connection error for $i/$domain" );
757
				$this->mErrorConnection = $conn;
758
				$conn = false;
759
			} else {
760
				$conn->tablePrefix( $prefix );
761
				$this->mConns['foreignUsed'][$i][$domain] = $conn;
762
				$this->connLogger->debug( __METHOD__ . ": opened new connection for $i/$domain" );
763
			}
764
		}
765
766
		// Increment reference count
767
		if ( $conn ) {
768
			$refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
769
			$conn->setLBInfo( 'foreignPoolRefCount', $refCount + 1 );
770
		}
771
772
		return $conn;
773
	}
774
775
	/**
776
	 * Test if the specified index represents an open connection
777
	 *
778
	 * @param int $index Server index
779
	 * @access private
780
	 * @return bool
781
	 */
782
	private function isOpen( $index ) {
783
		if ( !is_integer( $index ) ) {
784
			return false;
785
		}
786
787
		return (bool)$this->getAnyOpenConnection( $index );
788
	}
789
790
	/**
791
	 * Really opens a connection. Uncached.
792
	 * Returns a Database object whether or not the connection was successful.
793
	 * @access private
794
	 *
795
	 * @param array $server
796
	 * @param string|bool $dbNameOverride Use "" to not select any database
797
	 * @return Database
798
	 * @throws DBAccessError
799
	 * @throws InvalidArgumentException
800
	 */
801
	protected function reallyOpenConnection( $server, $dbNameOverride = false ) {
802
		if ( $this->disabled ) {
803
			throw new DBAccessError();
804
		}
805
806
		if ( !is_array( $server ) ) {
807
			throw new InvalidArgumentException(
808
				'You must update your load-balancing configuration. ' .
809
				'See DefaultSettings.php entry for $wgDBservers.' );
810
		}
811
812
		if ( $dbNameOverride !== false ) {
813
			$server['dbname'] = $dbNameOverride;
814
		}
815
816
		// Let the handle know what the cluster master is (e.g. "db1052")
817
		$masterName = $this->getServerName( $this->getWriterIndex() );
818
		$server['clusterMasterHost'] = $masterName;
819
820
		// Log when many connection are made on requests
821
		if ( ++$this->connsOpened >= self::CONN_HELD_WARN_THRESHOLD ) {
822
			$this->perfLogger->warning( __METHOD__ . ": " .
823
				"{$this->connsOpened}+ connections made (master=$masterName)" );
824
		}
825
826
		$server['srvCache'] = $this->srvCache;
827
		// Set loggers and profilers
828
		$server['connLogger'] = $this->connLogger;
829
		$server['queryLogger'] = $this->queryLogger;
830
		$server['errorLogger'] = $this->errorLogger;
831
		$server['profiler'] = $this->profiler;
832
		$server['trxProfiler'] = $this->trxProfiler;
833
		// Use the same agent and PHP mode for all DB handles
834
		$server['cliMode'] = $this->cliMode;
835
		$server['agent'] = $this->agent;
836
		// Use DBO_DEFAULT flags by default for LoadBalancer managed databases. Assume that the
837
		// application calls LoadBalancer::commitMasterChanges() before the PHP script completes.
838
		$server['flags'] = isset( $server['flags'] ) ? $server['flags'] : IDatabase::DBO_DEFAULT;
839
840
		// Create a live connection object
841
		try {
842
			$db = Database::factory( $server['type'], $server );
843
		} catch ( DBConnectionError $e ) {
844
			// FIXME: This is probably the ugliest thing I have ever done to
845
			// PHP. I'm half-expecting it to segfault, just out of disgust. -- TS
846
			$db = $e->db;
847
		}
848
849
		$db->setLBInfo( $server );
850
		$db->setLazyMasterHandle(
851
			$this->getLazyConnectionRef( self::DB_MASTER, [], $db->getDomainID() )
852
		);
853
		$db->setTableAliases( $this->tableAliases );
854
855
		if ( $server['serverIndex'] === $this->getWriterIndex() ) {
856
			if ( $this->trxRoundId !== false ) {
857
				$this->applyTransactionRoundFlags( $db );
858
			}
859
			foreach ( $this->trxRecurringCallbacks as $name => $callback ) {
860
				$db->setTransactionListener( $name, $callback );
861
			}
862
		}
863
864
		return $db;
865
	}
866
867
	/**
868
	 * @throws DBConnectionError
869
	 */
870
	private function reportConnectionError() {
871
		$conn = $this->mErrorConnection; // the connection which caused the error
872
		$context = [
873
			'method' => __METHOD__,
874
			'last_error' => $this->mLastError,
875
		];
876
877
		if ( !is_object( $conn ) ) {
878
			// No last connection, probably due to all servers being too busy
879
			$this->connLogger->error(
880
				"LB failure with no last connection. Connection error: {last_error}",
881
				$context
882
			);
883
884
			// If all servers were busy, mLastError will contain something sensible
885
			throw new DBConnectionError( null, $this->mLastError );
886
		} else {
887
			$context['db_server'] = $conn->getProperty( 'mServer' );
888
			$this->connLogger->warning(
889
				"Connection error: {last_error} ({db_server})",
890
				$context
891
			);
892
893
			// throws DBConnectionError
894
			$conn->reportConnectionError( "{$this->mLastError} ({$context['db_server']})" );
895
		}
896
	}
897
898
	public function getWriterIndex() {
899
		return 0;
900
	}
901
902
	public function haveIndex( $i ) {
903
		return array_key_exists( $i, $this->mServers );
904
	}
905
906
	public function isNonZeroLoad( $i ) {
907
		return array_key_exists( $i, $this->mServers ) && $this->mLoads[$i] != 0;
908
	}
909
910
	public function getServerCount() {
911
		return count( $this->mServers );
912
	}
913
914
	public function getServerName( $i ) {
915
		if ( isset( $this->mServers[$i]['hostName'] ) ) {
916
			$name = $this->mServers[$i]['hostName'];
917
		} elseif ( isset( $this->mServers[$i]['host'] ) ) {
918
			$name = $this->mServers[$i]['host'];
919
		} else {
920
			$name = '';
921
		}
922
923
		return ( $name != '' ) ? $name : 'localhost';
924
	}
925
926
	public function getServerInfo( $i ) {
927
		if ( isset( $this->mServers[$i] ) ) {
928
			return $this->mServers[$i];
929
		} else {
930
			return false;
931
		}
932
	}
933
934
	public function setServerInfo( $i, array $serverInfo ) {
935
		$this->mServers[$i] = $serverInfo;
936
	}
937
938
	public function getMasterPos() {
939
		# If this entire request was served from a replica DB without opening a connection to the
940
		# master (however unlikely that may be), then we can fetch the position from the replica DB.
941
		$masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() );
942
		if ( !$masterConn ) {
943
			$serverCount = count( $this->mServers );
944
			for ( $i = 1; $i < $serverCount; $i++ ) {
945
				$conn = $this->getAnyOpenConnection( $i );
946
				if ( $conn ) {
947
					return $conn->getReplicaPos();
0 ignored issues
show
Bug introduced by
The method getReplicaPos cannot be called on $conn (of type boolean).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
948
				}
949
			}
950
		} else {
951
			return $masterConn->getMasterPos();
952
		}
953
954
		return false;
955
	}
956
957
	public function disable() {
958
		$this->closeAll();
959
		$this->disabled = true;
960
	}
961
962
	public function closeAll() {
963
		$this->forEachOpenConnection( function ( IDatabase $conn ) {
964
			$host = $conn->getServer();
965
			$this->connLogger->debug( "Closing connection to database '$host'." );
966
			$conn->close();
967
		} );
968
969
		$this->mConns = [
0 ignored issues
show
Documentation Bug introduced by
It seems like array('local' => array()...oreignUsed' => array()) of type array<string,array,{"loc..."foreignUsed":"array"}> is incompatible with the declared type array<integer,array> of property $mConns.

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...
970
			'local' => [],
971
			'foreignFree' => [],
972
			'foreignUsed' => [],
973
		];
974
		$this->connsOpened = 0;
975
	}
976
977
	public function closeConnection( IDatabase $conn ) {
978
		$serverIndex = $conn->getLBInfo( 'serverIndex' ); // second index level of mConns
979
		foreach ( $this->mConns as $type => $connsByServer ) {
980
			if ( !isset( $connsByServer[$serverIndex] ) ) {
981
				continue;
982
			}
983
984
			foreach ( $connsByServer[$serverIndex] as $i => $trackedConn ) {
985
				if ( $conn === $trackedConn ) {
986
					$host = $this->getServerName( $i );
987
					$this->connLogger->debug( "Closing connection to database $i at '$host'." );
988
					unset( $this->mConns[$type][$serverIndex][$i] );
989
					--$this->connsOpened;
990
					break 2;
991
				}
992
			}
993
		}
994
995
		$conn->close();
996
	}
997
998
	public function commitAll( $fname = __METHOD__ ) {
999
		$failures = [];
1000
1001
		$restore = ( $this->trxRoundId !== false );
1002
		$this->trxRoundId = false;
1003
		$this->forEachOpenConnection(
1004
			function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) {
1005
				try {
1006
					$conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
1007
				} catch ( DBError $e ) {
1008
					call_user_func( $this->errorLogger, $e );
1009
					$failures[] = "{$conn->getServer()}: {$e->getMessage()}";
1010
				}
1011
				if ( $restore && $conn->getLBInfo( 'master' ) ) {
1012
					$this->undoTransactionRoundFlags( $conn );
1013
				}
1014
			}
1015
		);
1016
1017
		if ( $failures ) {
1018
			throw new DBExpectedError(
1019
				null,
1020
				"Commit failed on server(s) " . implode( "\n", array_unique( $failures ) )
1021
			);
1022
		}
1023
	}
1024
1025
	public function finalizeMasterChanges() {
1026
		$this->forEachOpenMasterConnection( function ( Database $conn ) {
1027
			// Any error should cause all DB transactions to be rolled back together
1028
			$conn->setTrxEndCallbackSuppression( false );
1029
			$conn->runOnTransactionPreCommitCallbacks();
1030
			// Defer post-commit callbacks until COMMIT finishes for all DBs
1031
			$conn->setTrxEndCallbackSuppression( true );
1032
		} );
1033
	}
1034
1035
	public function approveMasterChanges( array $options ) {
1036
		$limit = isset( $options['maxWriteDuration'] ) ? $options['maxWriteDuration'] : 0;
1037
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( $limit ) {
1038
			// If atomic sections or explicit transactions are still open, some caller must have
1039
			// caught an exception but failed to properly rollback any changes. Detect that and
1040
			// throw and error (causing rollback).
1041
			if ( $conn->explicitTrxActive() ) {
1042
				throw new DBTransactionError(
1043
					$conn,
1044
					"Explicit transaction still active. A caller may have caught an error."
1045
				);
1046
			}
1047
			// Assert that the time to replicate the transaction will be sane.
1048
			// If this fails, then all DB transactions will be rollback back together.
1049
			$time = $conn->pendingWriteQueryDuration( $conn::ESTIMATE_DB_APPLY );
1050
			if ( $limit > 0 && $time > $limit ) {
1051
				throw new DBTransactionSizeError(
1052
					$conn,
1053
					"Transaction spent $time second(s) in writes, exceeding the $limit limit.",
1054
					[ $time, $limit ]
1055
				);
1056
			}
1057
			// If a connection sits idle while slow queries execute on another, that connection
1058
			// may end up dropped before the commit round is reached. Ping servers to detect this.
1059
			if ( $conn->writesOrCallbacksPending() && !$conn->ping() ) {
1060
				throw new DBTransactionError(
1061
					$conn,
1062
					"A connection to the {$conn->getDBname()} database was lost before commit."
1063
				);
1064
			}
1065
		} );
1066
	}
1067
1068
	public function beginMasterChanges( $fname = __METHOD__ ) {
1069
		if ( $this->trxRoundId !== false ) {
1070
			throw new DBTransactionError(
1071
				null,
1072
				"$fname: Transaction round '{$this->trxRoundId}' already started."
1073
			);
1074
		}
1075
		$this->trxRoundId = $fname;
1076
1077
		$failures = [];
1078
		$this->forEachOpenMasterConnection(
1079
			function ( Database $conn ) use ( $fname, &$failures ) {
1080
				$conn->setTrxEndCallbackSuppression( true );
1081
				try {
1082
					$conn->flushSnapshot( $fname );
1083
				} catch ( DBError $e ) {
1084
					call_user_func( $this->errorLogger, $e );
1085
					$failures[] = "{$conn->getServer()}: {$e->getMessage()}";
1086
				}
1087
				$conn->setTrxEndCallbackSuppression( false );
1088
				$this->applyTransactionRoundFlags( $conn );
1089
			}
1090
		);
1091
1092 View Code Duplication
		if ( $failures ) {
1093
			throw new DBExpectedError(
1094
				null,
1095
				"$fname: Flush failed on server(s) " . implode( "\n", array_unique( $failures ) )
1096
			);
1097
		}
1098
	}
1099
1100
	public function commitMasterChanges( $fname = __METHOD__ ) {
1101
		$failures = [];
1102
1103
		/** @noinspection PhpUnusedLocalVariableInspection */
1104
		$scope = $this->getScopedPHPBehaviorForCommit(); // try to ignore client aborts
0 ignored issues
show
Unused Code introduced by
$scope is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1105
1106
		$restore = ( $this->trxRoundId !== false );
1107
		$this->trxRoundId = false;
1108
		$this->forEachOpenMasterConnection(
1109
			function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) {
1110
				try {
1111
					if ( $conn->writesOrCallbacksPending() ) {
1112
						$conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
1113
					} elseif ( $restore ) {
1114
						$conn->flushSnapshot( $fname );
1115
					}
1116
				} catch ( DBError $e ) {
1117
					call_user_func( $this->errorLogger, $e );
1118
					$failures[] = "{$conn->getServer()}: {$e->getMessage()}";
1119
				}
1120
				if ( $restore ) {
1121
					$this->undoTransactionRoundFlags( $conn );
1122
				}
1123
			}
1124
		);
1125
1126 View Code Duplication
		if ( $failures ) {
1127
			throw new DBExpectedError(
1128
				null,
1129
				"$fname: Commit failed on server(s) " . implode( "\n", array_unique( $failures ) )
1130
			);
1131
		}
1132
	}
1133
1134
	public function runMasterPostTrxCallbacks( $type ) {
1135
		$e = null; // first exception
1136
		$this->forEachOpenMasterConnection( function ( Database $conn ) use ( $type, &$e ) {
1137
			$conn->setTrxEndCallbackSuppression( false );
1138
			if ( $conn->writesOrCallbacksPending() ) {
1139
				// This happens if onTransactionIdle() callbacks leave callbacks on *another* DB
1140
				// (which finished its callbacks already). Warn and recover in this case. Let the
1141
				// callbacks run in the final commitMasterChanges() in LBFactory::shutdown().
1142
				$this->queryLogger->error( __METHOD__ . ": found writes/callbacks pending." );
1143
				return;
1144
			} elseif ( $conn->trxLevel() ) {
1145
				// This happens for single-DB setups where DB_REPLICA uses the master DB,
1146
				// thus leaving an implicit read-only transaction open at this point. It
1147
				// also happens if onTransactionIdle() callbacks leave implicit transactions
1148
				// open on *other* DBs (which is slightly improper). Let these COMMIT on the
1149
				// next call to commitMasterChanges(), possibly in LBFactory::shutdown().
1150
				return;
1151
			}
1152
			try {
1153
				$conn->runOnTransactionIdleCallbacks( $type );
1154
			} catch ( Exception $ex ) {
1155
				$e = $e ?: $ex;
1156
			}
1157
			try {
1158
				$conn->runTransactionListenerCallbacks( $type );
1159
			} catch ( Exception $ex ) {
1160
				$e = $e ?: $ex;
1161
			}
1162
		} );
1163
1164
		return $e;
1165
	}
1166
1167
	public function rollbackMasterChanges( $fname = __METHOD__ ) {
1168
		$restore = ( $this->trxRoundId !== false );
1169
		$this->trxRoundId = false;
1170
		$this->forEachOpenMasterConnection(
1171
			function ( IDatabase $conn ) use ( $fname, $restore ) {
1172
				if ( $conn->writesOrCallbacksPending() ) {
1173
					$conn->rollback( $fname, $conn::FLUSHING_ALL_PEERS );
1174
				}
1175
				if ( $restore ) {
1176
					$this->undoTransactionRoundFlags( $conn );
1177
				}
1178
			}
1179
		);
1180
	}
1181
1182
	public function suppressTransactionEndCallbacks() {
1183
		$this->forEachOpenMasterConnection( function ( Database $conn ) {
1184
			$conn->setTrxEndCallbackSuppression( true );
1185
		} );
1186
	}
1187
1188
	/**
1189
	 * @param IDatabase $conn
1190
	 */
1191
	private function applyTransactionRoundFlags( IDatabase $conn ) {
1192
		if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) {
1193
			// DBO_TRX is controlled entirely by CLI mode presence with DBO_DEFAULT.
1194
			// Force DBO_TRX even in CLI mode since a commit round is expected soon.
1195
			$conn->setFlag( $conn::DBO_TRX, $conn::REMEMBER_PRIOR );
1196
			// If config has explicitly requested DBO_TRX be either on or off by not
1197
			// setting DBO_DEFAULT, then respect that. Forcing no transactions is useful
1198
			// for things like blob stores (ExternalStore) which want auto-commit mode.
1199
		}
1200
	}
1201
1202
	/**
1203
	 * @param IDatabase $conn
1204
	 */
1205
	private function undoTransactionRoundFlags( IDatabase $conn ) {
1206
		if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) {
1207
			$conn->restoreFlags( $conn::RESTORE_PRIOR );
1208
		}
1209
	}
1210
1211
	public function flushReplicaSnapshots( $fname = __METHOD__ ) {
1212
		$this->forEachOpenReplicaConnection( function ( IDatabase $conn ) {
1213
			$conn->flushSnapshot( __METHOD__ );
1214
		} );
1215
	}
1216
1217
	public function hasMasterConnection() {
1218
		return $this->isOpen( $this->getWriterIndex() );
1219
	}
1220
1221
	public function hasMasterChanges() {
1222
		$pending = 0;
1223
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$pending ) {
1224
			$pending |= $conn->writesOrCallbacksPending();
1225
		} );
1226
1227
		return (bool)$pending;
1228
	}
1229
1230
	public function lastMasterChangeTimestamp() {
1231
		$lastTime = false;
1232
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$lastTime ) {
1233
			$lastTime = max( $lastTime, $conn->lastDoneWrites() );
1234
		} );
1235
1236
		return $lastTime;
1237
	}
1238
1239
	public function hasOrMadeRecentMasterChanges( $age = null ) {
1240
		$age = ( $age === null ) ? $this->mWaitTimeout : $age;
1241
1242
		return ( $this->hasMasterChanges()
1243
			|| $this->lastMasterChangeTimestamp() > microtime( true ) - $age );
1244
	}
1245
1246
	public function pendingMasterChangeCallers() {
1247
		$fnames = [];
1248
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$fnames ) {
1249
			$fnames = array_merge( $fnames, $conn->pendingWriteCallers() );
1250
		} );
1251
1252
		return $fnames;
1253
	}
1254
1255
	public function getLaggedReplicaMode( $domain = false ) {
1256
		// No-op if there is only one DB (also avoids recursion)
1257
		if ( !$this->laggedReplicaMode && $this->getServerCount() > 1 ) {
1258
			try {
1259
				// See if laggedReplicaMode gets set
1260
				$conn = $this->getConnection( self::DB_REPLICA, false, $domain );
1261
				$this->reuseConnection( $conn );
0 ignored issues
show
Bug introduced by
It seems like $conn defined by $this->getConnection(sel...EPLICA, false, $domain) on line 1260 can be null; however, LoadBalancer::reuseConnection() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1262
			} catch ( DBConnectionError $e ) {
1263
				// Avoid expensive re-connect attempts and failures
1264
				$this->allReplicasDownMode = true;
1265
				$this->laggedReplicaMode = true;
1266
			}
1267
		}
1268
1269
		return $this->laggedReplicaMode;
1270
	}
1271
1272
	/**
1273
	 * @param bool $domain
1274
	 * @return bool
1275
	 * @deprecated 1.28; use getLaggedReplicaMode()
1276
	 */
1277
	public function getLaggedSlaveMode( $domain = false ) {
1278
		return $this->getLaggedReplicaMode( $domain );
1279
	}
1280
1281
	public function laggedReplicaUsed() {
1282
		return $this->laggedReplicaMode;
1283
	}
1284
1285
	/**
1286
	 * @return bool
1287
	 * @since 1.27
1288
	 * @deprecated Since 1.28; use laggedReplicaUsed()
1289
	 */
1290
	public function laggedSlaveUsed() {
1291
		return $this->laggedReplicaUsed();
1292
	}
1293
1294
	public function getReadOnlyReason( $domain = false, IDatabase $conn = null ) {
1295
		if ( $this->readOnlyReason !== false ) {
1296
			return $this->readOnlyReason;
1297
		} elseif ( $this->getLaggedReplicaMode( $domain ) ) {
1298
			if ( $this->allReplicasDownMode ) {
1299
				return 'The database has been automatically locked ' .
1300
					'until the replica database servers become available';
1301
			} else {
1302
				return 'The database has been automatically locked ' .
1303
					'while the replica database servers catch up to the master.';
1304
			}
1305
		} elseif ( $this->masterRunningReadOnly( $domain, $conn ) ) {
1306
			return 'The database master is running in read-only mode.';
1307
		}
1308
1309
		return false;
1310
	}
1311
1312
	/**
1313
	 * @param string $domain Domain ID, or false for the current domain
1314
	 * @param IDatabase|null DB master connectionl used to avoid loops [optional]
1315
	 * @return bool
1316
	 */
1317
	private function masterRunningReadOnly( $domain, IDatabase $conn = null ) {
1318
		$cache = $this->wanCache;
1319
		$masterServer = $this->getServerName( $this->getWriterIndex() );
1320
1321
		return (bool)$cache->getWithSetCallback(
1322
			$cache->makeGlobalKey( __CLASS__, 'server-read-only', $masterServer ),
1323
			self::TTL_CACHE_READONLY,
1324
			function () use ( $domain, $conn ) {
1325
				$this->trxProfiler->setSilenced( true );
1326
				try {
1327
					$dbw = $conn ?: $this->getConnection( self::DB_MASTER, [], $domain );
1328
					$readOnly = (int)$dbw->serverIsReadOnly();
1329
					if ( !$conn ) {
1330
						$this->reuseConnection( $dbw );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by $conn ?: $this->getConne...STER, array(), $domain) on line 1327 can be null; however, LoadBalancer::reuseConnection() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1331
					}
1332
				} catch ( DBError $e ) {
1333
					$readOnly = 0;
1334
				}
1335
				$this->trxProfiler->setSilenced( false );
1336
				return $readOnly;
1337
			},
1338
			[ 'pcTTL' => $cache::TTL_PROC_LONG, 'busyValue' => 0 ]
1339
		);
1340
	}
1341
1342
	public function allowLagged( $mode = null ) {
1343
		if ( $mode === null ) {
1344
			return $this->mAllowLagged;
1345
		}
1346
		$this->mAllowLagged = $mode;
1347
1348
		return $this->mAllowLagged;
1349
	}
1350
1351
	public function pingAll() {
1352
		$success = true;
1353
		$this->forEachOpenConnection( function ( IDatabase $conn ) use ( &$success ) {
1354
			if ( !$conn->ping() ) {
1355
				$success = false;
1356
			}
1357
		} );
1358
1359
		return $success;
1360
	}
1361
1362 View Code Duplication
	public function forEachOpenConnection( $callback, array $params = [] ) {
1363
		foreach ( $this->mConns as $connsByServer ) {
1364
			foreach ( $connsByServer as $serverConns ) {
1365
				foreach ( $serverConns as $conn ) {
1366
					$mergedParams = array_merge( [ $conn ], $params );
1367
					call_user_func_array( $callback, $mergedParams );
1368
				}
1369
			}
1370
		}
1371
	}
1372
1373
	public function forEachOpenMasterConnection( $callback, array $params = [] ) {
1374
		$masterIndex = $this->getWriterIndex();
1375
		foreach ( $this->mConns as $connsByServer ) {
1376
			if ( isset( $connsByServer[$masterIndex] ) ) {
1377
				/** @var IDatabase $conn */
1378
				foreach ( $connsByServer[$masterIndex] as $conn ) {
1379
					$mergedParams = array_merge( [ $conn ], $params );
1380
					call_user_func_array( $callback, $mergedParams );
1381
				}
1382
			}
1383
		}
1384
	}
1385
1386 View Code Duplication
	public function forEachOpenReplicaConnection( $callback, array $params = [] ) {
1387
		foreach ( $this->mConns as $connsByServer ) {
1388
			foreach ( $connsByServer as $i => $serverConns ) {
1389
				if ( $i === $this->getWriterIndex() ) {
1390
					continue; // skip master
1391
				}
1392
				foreach ( $serverConns as $conn ) {
1393
					$mergedParams = array_merge( [ $conn ], $params );
1394
					call_user_func_array( $callback, $mergedParams );
1395
				}
1396
			}
1397
		}
1398
	}
1399
1400
	public function getMaxLag( $domain = false ) {
1401
		$maxLag = -1;
1402
		$host = '';
1403
		$maxIndex = 0;
1404
1405
		if ( $this->getServerCount() <= 1 ) {
1406
			return [ $host, $maxLag, $maxIndex ]; // no replication = no lag
1407
		}
1408
1409
		$lagTimes = $this->getLagTimes( $domain );
1410
		foreach ( $lagTimes as $i => $lag ) {
1411
			if ( $this->mLoads[$i] > 0 && $lag > $maxLag ) {
1412
				$maxLag = $lag;
1413
				$host = $this->mServers[$i]['host'];
1414
				$maxIndex = $i;
1415
			}
1416
		}
1417
1418
		return [ $host, $maxLag, $maxIndex ];
1419
	}
1420
1421
	public function getLagTimes( $domain = false ) {
1422
		if ( $this->getServerCount() <= 1 ) {
1423
			return [ $this->getWriterIndex() => 0 ]; // no replication = no lag
1424
		}
1425
1426
		$knownLagTimes = []; // map of (server index => 0 seconds)
1427
		$indexesWithLag = [];
1428
		foreach ( $this->mServers as $i => $server ) {
1429
			if ( empty( $server['is static'] ) ) {
1430
				$indexesWithLag[] = $i; // DB server might have replication lag
1431
			} else {
1432
				$knownLagTimes[$i] = 0; // DB server is a non-replicating and read-only archive
1433
			}
1434
		}
1435
1436
		return $this->getLoadMonitor()->getLagTimes( $indexesWithLag, $domain ) + $knownLagTimes;
1437
	}
1438
1439
	public function safeGetLag( IDatabase $conn ) {
1440
		if ( $this->getServerCount() <= 1 ) {
1441
			return 0;
1442
		} else {
1443
			return $conn->getLag();
1444
		}
1445
	}
1446
1447
	public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ) {
1448
		if ( $this->getServerCount() <= 1 || !$conn->getLBInfo( 'replica' ) ) {
1449
			return true; // server is not a replica DB
1450
		}
1451
1452
		if ( !$pos ) {
1453
			// Get the current master position
1454
			$dbw = $this->getConnection( self::DB_MASTER );
1455
			$pos = $dbw->getMasterPos();
1456
			$this->reuseConnection( $dbw );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by $this->getConnection(self::DB_MASTER) on line 1454 can be null; however, LoadBalancer::reuseConnection() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1457
		}
1458
1459
		if ( $pos instanceof DBMasterPos ) {
1460
			$result = $conn->masterPosWait( $pos, $timeout );
1461
			if ( $result == -1 || is_null( $result ) ) {
1462
				$msg = __METHOD__ . ": Timed out waiting on {$conn->getServer()} pos {$pos}";
1463
				$this->replLogger->warning( "$msg" );
1464
				$ok = false;
1465
			} else {
1466
				$this->replLogger->info( __METHOD__ . ": Done" );
1467
				$ok = true;
1468
			}
1469
		} else {
1470
			$ok = false; // something is misconfigured
1471
			$this->replLogger->error( "Could not get master pos for {$conn->getServer()}." );
1472
		}
1473
1474
		return $ok;
1475
	}
1476
1477
	public function setTransactionListener( $name, callable $callback = null ) {
1478
		if ( $callback ) {
1479
			$this->trxRecurringCallbacks[$name] = $callback;
1480
		} else {
1481
			unset( $this->trxRecurringCallbacks[$name] );
1482
		}
1483
		$this->forEachOpenMasterConnection(
1484
			function ( IDatabase $conn ) use ( $name, $callback ) {
1485
				$conn->setTransactionListener( $name, $callback );
1486
			}
1487
		);
1488
	}
1489
1490
	public function setTableAliases( array $aliases ) {
1491
		$this->tableAliases = $aliases;
1492
	}
1493
1494
	public function setDomainPrefix( $prefix ) {
1495
		if ( $this->mConns['foreignUsed'] ) {
1496
			// Do not switch connections to explicit foreign domains unless marked as free
1497
			$domains = [];
1498
			foreach ( $this->mConns['foreignUsed'] as $i => $connsByDomain ) {
1499
				$domains = array_merge( $domains, array_keys( $connsByDomain ) );
1500
			}
1501
			$domains = implode( ', ', $domains );
1502
			throw new DBUnexpectedError( null,
1503
				"Foreign domain connections are still in use ($domains)." );
1504
		}
1505
1506
		$this->localDomain = new DatabaseDomain(
1507
			$this->localDomain->getDatabase(),
1508
			null,
1509
			$prefix
1510
		);
1511
1512
		$this->forEachOpenConnection( function ( IDatabase $db ) use ( $prefix ) {
1513
			$db->tablePrefix( $prefix );
1514
		} );
1515
	}
1516
1517
	/**
1518
	 * Make PHP ignore user aborts/disconnects until the returned
1519
	 * value leaves scope. This returns null and does nothing in CLI mode.
1520
	 *
1521
	 * @return ScopedCallback|null
1522
	 */
1523 View Code Duplication
	final protected function getScopedPHPBehaviorForCommit() {
1524
		if ( PHP_SAPI != 'cli' ) { // http://bugs.php.net/bug.php?id=47540
1525
			$old = ignore_user_abort( true ); // avoid half-finished operations
1526
			return new ScopedCallback( function () use ( $old ) {
0 ignored issues
show
Deprecated Code introduced by
The class ScopedCallback has been deprecated with message: since 1.28 use Wikimedia\ScopedCallback

This class, trait or interface 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 type will be removed from the class and what other constant to use instead.

Loading history...
1527
				ignore_user_abort( $old );
1528
			} );
1529
		}
1530
1531
		return null;
1532
	}
1533
1534
	function __destruct() {
1535
		// Avoid connection leaks for sanity
1536
		$this->closeAll();
1537
	}
1538
}
1539