Completed
Branch master (6ee3f9)
by
unknown
29:15
created

LoadBalancer::disable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 0
dl 0
loc 4
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
109
	/** @var integer Default 'max lag' when unspecified */
110
	const MAX_LAG_DEFAULT = 10;
111
	/** @var integer Seconds to cache master server read-only status */
112
	const TTL_CACHE_READONLY = 5;
113
114
	public function __construct( array $params ) {
115
		if ( !isset( $params['servers'] ) ) {
116
			throw new InvalidArgumentException( __CLASS__ . ': missing servers parameter' );
117
		}
118
		$this->mServers = $params['servers'];
119
120
		$this->localDomain = isset( $params['localDomain'] )
121
			? DatabaseDomain::newFromId( $params['localDomain'] )
122
			: DatabaseDomain::newUnspecified();
123
		// In case a caller assumes that the domain ID is simply <db>-<prefix>, which is almost
124
		// always true, gracefully handle the case when they fail to account for escaping.
125
		if ( $this->localDomain->getTablePrefix() != '' ) {
126
			$this->localDomainIdAlias =
127
				$this->localDomain->getDatabase() . '-' . $this->localDomain->getTablePrefix();
128
		} else {
129
			$this->localDomainIdAlias = $this->localDomain->getDatabase();
130
		}
131
132
		$this->mWaitTimeout = isset( $params['waitTimeout'] ) ? $params['waitTimeout'] : 10;
133
134
		$this->mReadIndex = -1;
135
		$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...
136
			'local'       => [],
137
			'foreignUsed' => [],
138
			'foreignFree' => []
139
		];
140
		$this->mLoads = [];
141
		$this->mWaitForPos = false;
142
		$this->mErrorConnection = false;
143
		$this->mAllowLagged = false;
144
145 View Code Duplication
		if ( isset( $params['readOnlyReason'] ) && is_string( $params['readOnlyReason'] ) ) {
146
			$this->readOnlyReason = $params['readOnlyReason'];
147
		}
148
149
		if ( isset( $params['loadMonitor'] ) ) {
150
			$this->loadMonitorConfig = $params['loadMonitor'];
151
		} else {
152
			$this->loadMonitorConfig = [ 'class' => 'LoadMonitorNull' ];
153
		}
154
155
		foreach ( $params['servers'] as $i => $server ) {
156
			$this->mLoads[$i] = $server['load'];
157
			if ( isset( $server['groupLoads'] ) ) {
158
				foreach ( $server['groupLoads'] as $group => $ratio ) {
159
					if ( !isset( $this->mGroupLoads[$group] ) ) {
160
						$this->mGroupLoads[$group] = [];
161
					}
162
					$this->mGroupLoads[$group][$i] = $ratio;
163
				}
164
			}
165
		}
166
167
		if ( isset( $params['srvCache'] ) ) {
168
			$this->srvCache = $params['srvCache'];
169
		} else {
170
			$this->srvCache = new EmptyBagOStuff();
171
		}
172
		if ( isset( $params['memCache'] ) ) {
173
			$this->memCache = $params['memCache'];
174
		} else {
175
			$this->memCache = new EmptyBagOStuff();
176
		}
177
		if ( isset( $params['wanCache'] ) ) {
178
			$this->wanCache = $params['wanCache'];
179
		} else {
180
			$this->wanCache = WANObjectCache::newEmpty();
181
		}
182
		$this->profiler = isset( $params['profiler'] ) ? $params['profiler'] : null;
183
		if ( isset( $params['trxProfiler'] ) ) {
184
			$this->trxProfiler = $params['trxProfiler'];
185
		} else {
186
			$this->trxProfiler = new TransactionProfiler();
187
		}
188
189
		$this->errorLogger = isset( $params['errorLogger'] )
190
			? $params['errorLogger']
191
			: function ( Exception $e ) {
192
				trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_WARNING );
193
			};
194
195
		foreach ( [ 'replLogger', 'connLogger', 'queryLogger', 'perfLogger' ] as $key ) {
196
			$this->$key = isset( $params[$key] ) ? $params[$key] : new \Psr\Log\NullLogger();
197
		}
198
199
		$this->host = isset( $params['hostname'] )
200
			? $params['hostname']
201
			: ( gethostname() ?: 'unknown' );
202
		$this->cliMode = isset( $params['cliMode'] ) ? $params['cliMode'] : PHP_SAPI === 'cli';
203
		$this->agent = isset( $params['agent'] ) ? $params['agent'] : '';
204
	}
205
206
	/**
207
	 * Get a LoadMonitor instance
208
	 *
209
	 * @return ILoadMonitor
210
	 */
211
	private function getLoadMonitor() {
212
		if ( !isset( $this->loadMonitor ) ) {
213
			$class = $this->loadMonitorConfig['class'];
214
			$this->loadMonitor = new $class(
215
				$this, $this->srvCache, $this->memCache, $this->loadMonitorConfig );
216
			$this->loadMonitor->setLogger( $this->replLogger );
217
		}
218
219
		return $this->loadMonitor;
220
	}
221
222
	/**
223
	 * @param array $loads
224
	 * @param bool|string $domain Domain to get non-lagged for
225
	 * @param int $maxLag Restrict the maximum allowed lag to this many seconds
226
	 * @return bool|int|string
227
	 */
228
	private function getRandomNonLagged( array $loads, $domain = false, $maxLag = INF ) {
229
		$lags = $this->getLagTimes( $domain );
0 ignored issues
show
Bug introduced by
It seems like $domain defined by parameter $domain on line 228 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...
230
231
		# Unset excessively lagged servers
232
		foreach ( $lags as $i => $lag ) {
233
			if ( $i != 0 ) {
234
				# How much lag this server nominally is allowed to have
235
				$maxServerLag = isset( $this->mServers[$i]['max lag'] )
236
					? $this->mServers[$i]['max lag']
237
					: self::MAX_LAG_DEFAULT; // default
238
				# Constrain that futher by $maxLag argument
239
				$maxServerLag = min( $maxServerLag, $maxLag );
240
241
				$host = $this->getServerName( $i );
242
				if ( $lag === false && !is_infinite( $maxServerLag ) ) {
243
					$this->replLogger->error( "Server $host (#$i) is not replicating?" );
244
					unset( $loads[$i] );
245
				} elseif ( $lag > $maxServerLag ) {
246
					$this->replLogger->warning( "Server $host (#$i) has >= $lag seconds of lag" );
247
					unset( $loads[$i] );
248
				}
249
			}
250
		}
251
252
		# Find out if all the replica DBs with non-zero load are lagged
253
		$sum = 0;
254
		foreach ( $loads as $load ) {
255
			$sum += $load;
256
		}
257
		if ( $sum == 0 ) {
258
			# No appropriate DB servers except maybe the master and some replica DBs with zero load
259
			# Do NOT use the master
260
			# Instead, this function will return false, triggering read-only mode,
261
			# and a lagged replica DB will be used instead.
262
			return false;
263
		}
264
265
		if ( count( $loads ) == 0 ) {
266
			return false;
267
		}
268
269
		# Return a random representative of the remainder
270
		return ArrayUtils::pickRandom( $loads );
271
	}
272
273
	public function getReaderIndex( $group = false, $domain = false ) {
274
		if ( count( $this->mServers ) == 1 ) {
275
			# Skip the load balancing if there's only one server
276
			return $this->getWriterIndex();
277
		} elseif ( $group === false && $this->mReadIndex >= 0 ) {
278
			# Shortcut if generic reader exists already
279
			return $this->mReadIndex;
280
		}
281
282
		# Find the relevant load array
283
		if ( $group !== false ) {
284
			if ( isset( $this->mGroupLoads[$group] ) ) {
285
				$nonErrorLoads = $this->mGroupLoads[$group];
286
			} else {
287
				# No loads for this group, return false and the caller can use some other group
288
				$this->connLogger->info( __METHOD__ . ": no loads for group $group" );
289
290
				return false;
291
			}
292
		} else {
293
			$nonErrorLoads = $this->mLoads;
294
		}
295
296
		if ( !count( $nonErrorLoads ) ) {
297
			throw new InvalidArgumentException( "Empty server array given to LoadBalancer" );
298
		}
299
300
		# Scale the configured load ratios according to the dynamic load if supported
301
		$this->getLoadMonitor()->scaleLoads( $nonErrorLoads, $domain );
302
303
		$laggedReplicaMode = false;
304
305
		# No server found yet
306
		$i = false;
307
		# First try quickly looking through the available servers for a server that
308
		# meets our criteria
309
		$currentLoads = $nonErrorLoads;
310
		while ( count( $currentLoads ) ) {
311
			if ( $this->mAllowLagged || $laggedReplicaMode ) {
312
				$i = ArrayUtils::pickRandom( $currentLoads );
313
			} else {
314
				$i = false;
315
				if ( $this->mWaitForPos && $this->mWaitForPos->asOfTime() ) {
316
					# ChronologyProtecter causes mWaitForPos to be set via sessions.
317
					# This triggers doWait() after connect, so it's especially good to
318
					# avoid lagged servers so as to avoid just blocking in that method.
319
					$ago = microtime( true ) - $this->mWaitForPos->asOfTime();
320
					# Aim for <= 1 second of waiting (being too picky can backfire)
321
					$i = $this->getRandomNonLagged( $currentLoads, $domain, $ago + 1 );
322
				}
323
				if ( $i === false ) {
324
					# Any server with less lag than it's 'max lag' param is preferable
325
					$i = $this->getRandomNonLagged( $currentLoads, $domain );
326
				}
327
				if ( $i === false && count( $currentLoads ) != 0 ) {
328
					# All replica DBs lagged. Switch to read-only mode
329
					$this->replLogger->error( "All replica DBs lagged. Switch to read-only mode" );
330
					$i = ArrayUtils::pickRandom( $currentLoads );
331
					$laggedReplicaMode = true;
332
				}
333
			}
334
335
			if ( $i === false ) {
336
				# pickRandom() returned false
337
				# This is permanent and means the configuration or the load monitor
338
				# wants us to return false.
339
				$this->connLogger->debug( __METHOD__ . ": pickRandom() returned false" );
340
341
				return false;
342
			}
343
344
			$serverName = $this->getServerName( $i );
345
			$this->connLogger->debug( __METHOD__ . ": Using reader #$i: $serverName..." );
346
347
			$conn = $this->openConnection( $i, $domain );
348
			if ( !$conn ) {
349
				$this->connLogger->warning( __METHOD__ . ": Failed connecting to $i/$domain" );
350
				unset( $nonErrorLoads[$i] );
351
				unset( $currentLoads[$i] );
352
				$i = false;
353
				continue;
354
			}
355
356
			// Decrement reference counter, we are finished with this connection.
357
			// It will be incremented for the caller later.
358
			if ( $domain !== false ) {
359
				$this->reuseConnection( $conn );
360
			}
361
362
			# Return this server
363
			break;
364
		}
365
366
		# If all servers were down, quit now
367
		if ( !count( $nonErrorLoads ) ) {
368
			$this->connLogger->error( "All servers down" );
369
		}
370
371
		if ( $i !== false ) {
372
			# Replica DB connection successful.
373
			# Wait for the session master pos for a short time.
374
			if ( $this->mWaitForPos && $i > 0 ) {
375
				$this->doWait( $i );
376
			}
377
			if ( $this->mReadIndex <= 0 && $this->mLoads[$i] > 0 && $group === false ) {
378
				$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...
379
				# Record if the generic reader index is in "lagged replica DB" mode
380
				if ( $laggedReplicaMode ) {
381
					$this->laggedReplicaMode = true;
382
				}
383
			}
384
			$serverName = $this->getServerName( $i );
385
			$this->connLogger->debug(
386
				__METHOD__ . ": using server $serverName for group '$group'" );
387
		}
388
389
		return $i;
390
	}
391
392
	public function waitFor( $pos ) {
393
		$this->mWaitForPos = $pos;
394
		$i = $this->mReadIndex;
395
396
		if ( $i > 0 ) {
397
			if ( !$this->doWait( $i ) ) {
398
				$this->laggedReplicaMode = true;
399
			}
400
		}
401
	}
402
403
	public function waitForOne( $pos, $timeout = null ) {
404
		$this->mWaitForPos = $pos;
405
406
		$i = $this->mReadIndex;
407
		if ( $i <= 0 ) {
408
			// Pick a generic replica DB if there isn't one yet
409
			$readLoads = $this->mLoads;
410
			unset( $readLoads[$this->getWriterIndex()] ); // replica DBs only
411
			$readLoads = array_filter( $readLoads ); // with non-zero load
412
			$i = ArrayUtils::pickRandom( $readLoads );
413
		}
414
415 View Code Duplication
		if ( $i > 0 ) {
416
			$ok = $this->doWait( $i, true, $timeout );
417
		} else {
418
			$ok = true; // no applicable loads
419
		}
420
421
		return $ok;
422
	}
423
424
	public function waitForAll( $pos, $timeout = null ) {
425
		$this->mWaitForPos = $pos;
426
		$serverCount = count( $this->mServers );
427
428
		$ok = true;
429
		for ( $i = 1; $i < $serverCount; $i++ ) {
430 View Code Duplication
			if ( $this->mLoads[$i] > 0 ) {
431
				$ok = $this->doWait( $i, true, $timeout ) && $ok;
432
			}
433
		}
434
435
		return $ok;
436
	}
437
438
	public function getAnyOpenConnection( $i ) {
439
		foreach ( $this->mConns as $connsByServer ) {
440
			if ( !empty( $connsByServer[$i] ) ) {
441
				return reset( $connsByServer[$i] );
442
			}
443
		}
444
445
		return false;
446
	}
447
448
	/**
449
	 * Wait for a given replica DB to catch up to the master pos stored in $this
450
	 * @param int $index Server index
451
	 * @param bool $open Check the server even if a new connection has to be made
452
	 * @param int $timeout Max seconds to wait; default is mWaitTimeout
453
	 * @return bool
454
	 */
455
	protected function doWait( $index, $open = false, $timeout = null ) {
456
		$close = false; // close the connection afterwards
457
458
		// Check if we already know that the DB has reached this point
459
		$server = $this->getServerName( $index );
460
		$key = $this->srvCache->makeGlobalKey( __CLASS__, 'last-known-pos', $server );
461
		/** @var DBMasterPos $knownReachedPos */
462
		$knownReachedPos = $this->srvCache->get( $key );
463
		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...
464
			$this->replLogger->debug( __METHOD__ .
465
				": replica DB $server known to be caught up (pos >= $knownReachedPos)." );
466
			return true;
467
		}
468
469
		// Find a connection to wait on, creating one if needed and allowed
470
		$conn = $this->getAnyOpenConnection( $index );
471
		if ( !$conn ) {
472
			if ( !$open ) {
473
				$this->replLogger->debug( __METHOD__ . ": no connection open for $server" );
474
475
				return false;
476
			} else {
477
				$conn = $this->openConnection( $index, self::DOMAIN_ANY );
478
				if ( !$conn ) {
479
					$this->replLogger->warning( __METHOD__ . ": failed to connect to $server" );
480
481
					return false;
482
				}
483
				// Avoid connection spam in waitForAll() when connections
484
				// are made just for the sake of doing this lag check.
485
				$close = true;
486
			}
487
		}
488
489
		$this->replLogger->info( __METHOD__ . ": Waiting for replica DB $server to catch up..." );
490
		$timeout = $timeout ?: $this->mWaitTimeout;
491
		$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...
492
493
		if ( $result == -1 || is_null( $result ) ) {
494
			// Timed out waiting for replica DB, use master instead
495
			$msg = __METHOD__ . ": Timed out waiting on $server pos {$this->mWaitForPos}";
496
			$this->replLogger->warning( "$msg" );
497
			$ok = false;
498
		} else {
499
			$this->replLogger->info( __METHOD__ . ": Done" );
500
			$ok = true;
501
			// Remember that the DB reached this point
502
			$this->srvCache->set( $key, $this->mWaitForPos, BagOStuff::TTL_DAY );
503
		}
504
505
		if ( $close ) {
506
			$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...
507
		}
508
509
		return $ok;
510
	}
511
512
	/**
513
	 * @see ILoadBalancer::getConnection()
514
	 *
515
	 * @param int $i
516
	 * @param array $groups
517
	 * @param bool $domain
518
	 * @return Database
519
	 * @throws DBConnectionError
520
	 */
521
	public function getConnection( $i, $groups = [], $domain = false ) {
522
		if ( $i === null || $i === false ) {
523
			throw new InvalidArgumentException( 'Attempt to call ' . __METHOD__ .
524
				' with invalid server index' );
525
		}
526
527
		if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) {
528
			$domain = false; // local connection requested
529
		}
530
531
		$groups = ( $groups === false || $groups === [] )
532
			? [ false ] // check one "group": the generic pool
533
			: (array)$groups;
534
535
		$masterOnly = ( $i == self::DB_MASTER || $i == $this->getWriterIndex() );
536
		$oldConnsOpened = $this->connsOpened; // connections open now
537
538
		if ( $i == self::DB_MASTER ) {
539
			$i = $this->getWriterIndex();
540
		} else {
541
			# Try to find an available server in any the query groups (in order)
542
			foreach ( $groups as $group ) {
543
				$groupIndex = $this->getReaderIndex( $group, $domain );
544
				if ( $groupIndex !== false ) {
545
					$i = $groupIndex;
546
					break;
547
				}
548
			}
549
		}
550
551
		# Operation-based index
552
		if ( $i == self::DB_REPLICA ) {
553
			$this->mLastError = 'Unknown error'; // reset error string
554
			# Try the general server pool if $groups are unavailable.
555
			$i = in_array( false, $groups, true )
556
				? false // don't bother with this if that is what was tried above
557
				: $this->getReaderIndex( false, $domain );
558
			# Couldn't find a working server in getReaderIndex()?
559
			if ( $i === false ) {
560
				$this->mLastError = 'No working replica DB server: ' . $this->mLastError;
561
				// Throw an exception
562
				$this->reportConnectionError();
563
				return null; // not reached
564
			}
565
		}
566
567
		# Now we have an explicit index into the servers array
568
		$conn = $this->openConnection( $i, $domain );
569
		if ( !$conn ) {
570
			// Throw an exception
571
			$this->reportConnectionError();
572
			return null; // not reached
573
		}
574
575
		# Profile any new connections that happen
576
		if ( $this->connsOpened > $oldConnsOpened ) {
577
			$host = $conn->getServer();
578
			$dbname = $conn->getDBname();
579
			$this->trxProfiler->recordConnection( $host, $dbname, $masterOnly );
580
		}
581
582
		if ( $masterOnly ) {
583
			# Make master-requested DB handles inherit any read-only mode setting
584
			$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 568 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...
585
		}
586
587
		return $conn;
588
	}
589
590
	public function reuseConnection( $conn ) {
591
		$serverIndex = $conn->getLBInfo( 'serverIndex' );
592
		$refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
593
		if ( $serverIndex === null || $refCount === null ) {
594
			/**
595
			 * This can happen in code like:
596
			 *   foreach ( $dbs as $db ) {
597
			 *     $conn = $lb->getConnection( $lb::DB_REPLICA, [], $db );
598
			 *     ...
599
			 *     $lb->reuseConnection( $conn );
600
			 *   }
601
			 * When a connection to the local DB is opened in this way, reuseConnection()
602
			 * should be ignored
603
			 */
604
			return;
605
		} elseif ( $conn instanceof DBConnRef ) {
606
			// DBConnRef already handles calling reuseConnection() and only passes the live
607
			// Database instance to this method. Any caller passing in a DBConnRef is broken.
608
			$this->connLogger->error( __METHOD__ . ": got DBConnRef instance.\n" .
609
				( new RuntimeException() )->getTraceAsString() );
610
611
			return;
612
		}
613
614
		if ( $this->disabled ) {
615
			return; // DBConnRef handle probably survived longer than the LoadBalancer
616
		}
617
618
		$domain = $conn->getDomainID();
619
		if ( !isset( $this->mConns['foreignUsed'][$serverIndex][$domain] ) ) {
620
			throw new InvalidArgumentException( __METHOD__ .
621
				": connection $serverIndex/$domain not found; it may have already been freed." );
622
		} elseif ( $this->mConns['foreignUsed'][$serverIndex][$domain] !== $conn ) {
623
			throw new InvalidArgumentException( __METHOD__ .
624
				": connection $serverIndex/$domain mismatched; it may have already been freed." );
625
		}
626
		$conn->setLBInfo( 'foreignPoolRefCount', --$refCount );
627
		if ( $refCount <= 0 ) {
628
			$this->mConns['foreignFree'][$serverIndex][$domain] = $conn;
629
			unset( $this->mConns['foreignUsed'][$serverIndex][$domain] );
630
			if ( !$this->mConns['foreignUsed'][$serverIndex] ) {
631
				unset( $this->mConns[ 'foreignUsed' ][$serverIndex] ); // clean up
632
			}
633
			$this->connLogger->debug( __METHOD__ . ": freed connection $serverIndex/$domain" );
634
		} else {
635
			$this->connLogger->debug( __METHOD__ .
636
				": reference count for $serverIndex/$domain reduced to $refCount" );
637
		}
638
	}
639
640
	public function getConnectionRef( $db, $groups = [], $domain = false ) {
641
		$domain = ( $domain !== false ) ? $domain : $this->localDomain;
642
643
		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 641 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...
644
	}
645
646
	public function getLazyConnectionRef( $db, $groups = [], $domain = false ) {
647
		$domain = ( $domain !== false ) ? $domain : $this->localDomain;
648
649
		return new DBConnRef( $this, [ $db, $groups, $domain ] );
650
	}
651
652
	/**
653
	 * @see ILoadBalancer::openConnection()
654
	 *
655
	 * @param int $i
656
	 * @param bool $domain
657
	 * @return bool|Database
658
	 * @throws DBAccessError
659
	 */
660
	public function openConnection( $i, $domain = false ) {
661
		if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) {
662
			$domain = false; // local connection requested
663
		}
664
665
		if ( $domain !== false ) {
666
			$conn = $this->openForeignConnection( $i, $domain );
667
		} elseif ( isset( $this->mConns['local'][$i][0] ) ) {
668
			$conn = $this->mConns['local'][$i][0];
669
		} else {
670 View Code Duplication
			if ( !isset( $this->mServers[$i] ) || !is_array( $this->mServers[$i] ) ) {
671
				throw new InvalidArgumentException( "No server with index '$i'." );
672
			}
673
			// Open a new connection
674
			$server = $this->mServers[$i];
675
			$server['serverIndex'] = $i;
676
			$conn = $this->reallyOpenConnection( $server, false );
677
			$serverName = $this->getServerName( $i );
678
			if ( $conn->isOpen() ) {
679
				$this->connLogger->debug( "Connected to database $i at '$serverName'." );
680
				$this->mConns['local'][$i][0] = $conn;
681
			} else {
682
				$this->connLogger->warning( "Failed to connect to database $i at '$serverName'." );
683
				$this->mErrorConnection = $conn;
684
				$conn = false;
685
			}
686
		}
687
688
		if ( $conn && !$conn->isOpen() ) {
689
			// Connection was made but later unrecoverably lost for some reason.
690
			// Do not return a handle that will just throw exceptions on use,
691
			// but let the calling code (e.g. getReaderIndex) try another server.
692
			// See DatabaseMyslBase::ping() for how this can happen.
693
			$this->mErrorConnection = $conn;
694
			$conn = false;
695
		}
696
697
		return $conn;
698
	}
699
700
	/**
701
	 * Open a connection to a foreign DB, or return one if it is already open.
702
	 *
703
	 * Increments a reference count on the returned connection which locks the
704
	 * connection to the requested domain. This reference count can be
705
	 * decremented by calling reuseConnection().
706
	 *
707
	 * If a connection is open to the appropriate server already, but with the wrong
708
	 * database, it will be switched to the right database and returned, as long as
709
	 * it has been freed first with reuseConnection().
710
	 *
711
	 * On error, returns false, and the connection which caused the
712
	 * error will be available via $this->mErrorConnection.
713
	 *
714
	 * @note If disable() was called on this LoadBalancer, this method will throw a DBAccessError.
715
	 *
716
	 * @param int $i Server index
717
	 * @param string $domain Domain ID to open
718
	 * @return Database
719
	 */
720
	private function openForeignConnection( $i, $domain ) {
721
		$domainInstance = DatabaseDomain::newFromId( $domain );
722
		$dbName = $domainInstance->getDatabase();
723
		$prefix = $domainInstance->getTablePrefix();
724
725
		if ( isset( $this->mConns['foreignUsed'][$i][$domain] ) ) {
726
			// Reuse an already-used connection
727
			$conn = $this->mConns['foreignUsed'][$i][$domain];
728
			$this->connLogger->debug( __METHOD__ . ": reusing connection $i/$domain" );
729
		} elseif ( isset( $this->mConns['foreignFree'][$i][$domain] ) ) {
730
			// Reuse a free connection for the same domain
731
			$conn = $this->mConns['foreignFree'][$i][$domain];
732
			unset( $this->mConns['foreignFree'][$i][$domain] );
733
			$this->mConns['foreignUsed'][$i][$domain] = $conn;
734
			$this->connLogger->debug( __METHOD__ . ": reusing free connection $i/$domain" );
735
		} elseif ( !empty( $this->mConns['foreignFree'][$i] ) ) {
736
			// Reuse a connection from another domain
737
			$conn = reset( $this->mConns['foreignFree'][$i] );
738
			$oldDomain = key( $this->mConns['foreignFree'][$i] );
739
			// The empty string as a DB name means "don't care".
740
			// DatabaseMysqlBase::open() already handle this on connection.
741
			if ( strlen( $dbName ) && !$conn->selectDB( $dbName ) ) {
742
				$this->mLastError = "Error selecting database '$dbName' on server " .
743
					$conn->getServer() . " from client host {$this->host}";
744
				$this->mErrorConnection = $conn;
745
				$conn = false;
746
			} else {
747
				$conn->tablePrefix( $prefix );
748
				unset( $this->mConns['foreignFree'][$i][$oldDomain] );
749
				$this->mConns['foreignUsed'][$i][$domain] = $conn;
750
				$this->connLogger->debug( __METHOD__ .
751
					": reusing free connection from $oldDomain for $domain" );
752
			}
753
		} else {
754 View Code Duplication
			if ( !isset( $this->mServers[$i] ) || !is_array( $this->mServers[$i] ) ) {
755
				throw new InvalidArgumentException( "No server with index '$i'." );
756
			}
757
			// Open a new connection
758
			$server = $this->mServers[$i];
759
			$server['serverIndex'] = $i;
760
			$server['foreignPoolRefCount'] = 0;
761
			$server['foreign'] = true;
762
			$conn = $this->reallyOpenConnection( $server, $dbName );
763
			if ( !$conn->isOpen() ) {
764
				$this->connLogger->warning( __METHOD__ . ": connection error for $i/$domain" );
765
				$this->mErrorConnection = $conn;
766
				$conn = false;
767
			} else {
768
				$conn->tablePrefix( $prefix );
769
				$this->mConns['foreignUsed'][$i][$domain] = $conn;
770
				$this->connLogger->debug( __METHOD__ . ": opened new connection for $i/$domain" );
771
			}
772
		}
773
774
		// Increment reference count
775
		if ( $conn ) {
776
			$refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
777
			$conn->setLBInfo( 'foreignPoolRefCount', $refCount + 1 );
778
		}
779
780
		return $conn;
781
	}
782
783
	/**
784
	 * Test if the specified index represents an open connection
785
	 *
786
	 * @param int $index Server index
787
	 * @access private
788
	 * @return bool
789
	 */
790
	private function isOpen( $index ) {
791
		if ( !is_integer( $index ) ) {
792
			return false;
793
		}
794
795
		return (bool)$this->getAnyOpenConnection( $index );
796
	}
797
798
	/**
799
	 * Really opens a connection. Uncached.
800
	 * Returns a Database object whether or not the connection was successful.
801
	 * @access private
802
	 *
803
	 * @param array $server
804
	 * @param string|bool $dbNameOverride Use "" to not select any database
805
	 * @return Database
806
	 * @throws DBAccessError
807
	 * @throws InvalidArgumentException
808
	 */
809
	protected function reallyOpenConnection( array $server, $dbNameOverride = false ) {
810
		if ( $this->disabled ) {
811
			throw new DBAccessError();
812
		}
813
814
		if ( $dbNameOverride !== false ) {
815
			$server['dbname'] = $dbNameOverride;
816
		}
817
818
		// Let the handle know what the cluster master is (e.g. "db1052")
819
		$masterName = $this->getServerName( $this->getWriterIndex() );
820
		$server['clusterMasterHost'] = $masterName;
821
822
		// Log when many connection are made on requests
823
		if ( ++$this->connsOpened >= self::CONN_HELD_WARN_THRESHOLD ) {
824
			$this->perfLogger->warning( __METHOD__ . ": " .
825
				"{$this->connsOpened}+ connections made (master=$masterName)" );
826
		}
827
828
		$server['srvCache'] = $this->srvCache;
829
		// Set loggers and profilers
830
		$server['connLogger'] = $this->connLogger;
831
		$server['queryLogger'] = $this->queryLogger;
832
		$server['errorLogger'] = $this->errorLogger;
833
		$server['profiler'] = $this->profiler;
834
		$server['trxProfiler'] = $this->trxProfiler;
835
		// Use the same agent and PHP mode for all DB handles
836
		$server['cliMode'] = $this->cliMode;
837
		$server['agent'] = $this->agent;
838
		// Use DBO_DEFAULT flags by default for LoadBalancer managed databases. Assume that the
839
		// application calls LoadBalancer::commitMasterChanges() before the PHP script completes.
840
		$server['flags'] = isset( $server['flags'] ) ? $server['flags'] : IDatabase::DBO_DEFAULT;
841
842
		// Create a live connection object
843
		try {
844
			$db = Database::factory( $server['type'], $server );
845
		} catch ( DBConnectionError $e ) {
846
			// FIXME: This is probably the ugliest thing I have ever done to
847
			// PHP. I'm half-expecting it to segfault, just out of disgust. -- TS
848
			$db = $e->db;
849
		}
850
851
		$db->setLBInfo( $server );
852
		$db->setLazyMasterHandle(
853
			$this->getLazyConnectionRef( self::DB_MASTER, [], $db->getDomainID() )
854
		);
855
		$db->setTableAliases( $this->tableAliases );
856
857
		if ( $server['serverIndex'] === $this->getWriterIndex() ) {
858
			if ( $this->trxRoundId !== false ) {
859
				$this->applyTransactionRoundFlags( $db );
860
			}
861
			foreach ( $this->trxRecurringCallbacks as $name => $callback ) {
862
				$db->setTransactionListener( $name, $callback );
863
			}
864
		}
865
866
		return $db;
867
	}
868
869
	/**
870
	 * @throws DBConnectionError
871
	 */
872
	private function reportConnectionError() {
873
		$conn = $this->mErrorConnection; // the connection which caused the error
874
		$context = [
875
			'method' => __METHOD__,
876
			'last_error' => $this->mLastError,
877
		];
878
879
		if ( !is_object( $conn ) ) {
880
			// No last connection, probably due to all servers being too busy
881
			$this->connLogger->error(
882
				"LB failure with no last connection. Connection error: {last_error}",
883
				$context
884
			);
885
886
			// If all servers were busy, mLastError will contain something sensible
887
			throw new DBConnectionError( null, $this->mLastError );
888
		} else {
889
			$context['db_server'] = $conn->getProperty( 'mServer' );
890
			$this->connLogger->warning(
891
				"Connection error: {last_error} ({db_server})",
892
				$context
893
			);
894
895
			// throws DBConnectionError
896
			$conn->reportConnectionError( "{$this->mLastError} ({$context['db_server']})" );
897
		}
898
	}
899
900
	public function getWriterIndex() {
901
		return 0;
902
	}
903
904
	public function haveIndex( $i ) {
905
		return array_key_exists( $i, $this->mServers );
906
	}
907
908
	public function isNonZeroLoad( $i ) {
909
		return array_key_exists( $i, $this->mServers ) && $this->mLoads[$i] != 0;
910
	}
911
912
	public function getServerCount() {
913
		return count( $this->mServers );
914
	}
915
916
	public function getServerName( $i ) {
917
		if ( isset( $this->mServers[$i]['hostName'] ) ) {
918
			$name = $this->mServers[$i]['hostName'];
919
		} elseif ( isset( $this->mServers[$i]['host'] ) ) {
920
			$name = $this->mServers[$i]['host'];
921
		} else {
922
			$name = '';
923
		}
924
925
		return ( $name != '' ) ? $name : 'localhost';
926
	}
927
928
	public function getServerInfo( $i ) {
929
		if ( isset( $this->mServers[$i] ) ) {
930
			return $this->mServers[$i];
931
		} else {
932
			return false;
933
		}
934
	}
935
936
	public function setServerInfo( $i, array $serverInfo ) {
937
		$this->mServers[$i] = $serverInfo;
938
	}
939
940
	public function getMasterPos() {
941
		# If this entire request was served from a replica DB without opening a connection to the
942
		# master (however unlikely that may be), then we can fetch the position from the replica DB.
943
		$masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() );
944
		if ( !$masterConn ) {
945
			$serverCount = count( $this->mServers );
946
			for ( $i = 1; $i < $serverCount; $i++ ) {
947
				$conn = $this->getAnyOpenConnection( $i );
948
				if ( $conn ) {
949
					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...
950
				}
951
			}
952
		} else {
953
			return $masterConn->getMasterPos();
954
		}
955
956
		return false;
957
	}
958
959
	public function disable() {
960
		$this->closeAll();
961
		$this->disabled = true;
962
	}
963
964
	public function closeAll() {
965
		$this->forEachOpenConnection( function ( IDatabase $conn ) {
966
			$host = $conn->getServer();
967
			$this->connLogger->debug( "Closing connection to database '$host'." );
968
			$conn->close();
969
		} );
970
971
		$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...
972
			'local' => [],
973
			'foreignFree' => [],
974
			'foreignUsed' => [],
975
		];
976
		$this->connsOpened = 0;
977
	}
978
979
	public function closeConnection( IDatabase $conn ) {
980
		$serverIndex = $conn->getLBInfo( 'serverIndex' ); // second index level of mConns
981
		foreach ( $this->mConns as $type => $connsByServer ) {
982
			if ( !isset( $connsByServer[$serverIndex] ) ) {
983
				continue;
984
			}
985
986
			foreach ( $connsByServer[$serverIndex] as $i => $trackedConn ) {
987
				if ( $conn === $trackedConn ) {
988
					$host = $this->getServerName( $i );
989
					$this->connLogger->debug( "Closing connection to database $i at '$host'." );
990
					unset( $this->mConns[$type][$serverIndex][$i] );
991
					--$this->connsOpened;
992
					break 2;
993
				}
994
			}
995
		}
996
997
		$conn->close();
998
	}
999
1000
	public function commitAll( $fname = __METHOD__ ) {
1001
		$failures = [];
1002
1003
		$restore = ( $this->trxRoundId !== false );
1004
		$this->trxRoundId = false;
1005
		$this->forEachOpenConnection(
1006
			function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) {
1007
				try {
1008
					$conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
1009
				} catch ( DBError $e ) {
1010
					call_user_func( $this->errorLogger, $e );
1011
					$failures[] = "{$conn->getServer()}: {$e->getMessage()}";
1012
				}
1013
				if ( $restore && $conn->getLBInfo( 'master' ) ) {
1014
					$this->undoTransactionRoundFlags( $conn );
1015
				}
1016
			}
1017
		);
1018
1019
		if ( $failures ) {
1020
			throw new DBExpectedError(
1021
				null,
1022
				"Commit failed on server(s) " . implode( "\n", array_unique( $failures ) )
1023
			);
1024
		}
1025
	}
1026
1027
	public function finalizeMasterChanges() {
1028
		$this->forEachOpenMasterConnection( function ( Database $conn ) {
1029
			// Any error should cause all DB transactions to be rolled back together
1030
			$conn->setTrxEndCallbackSuppression( false );
1031
			$conn->runOnTransactionPreCommitCallbacks();
1032
			// Defer post-commit callbacks until COMMIT finishes for all DBs
1033
			$conn->setTrxEndCallbackSuppression( true );
1034
		} );
1035
	}
1036
1037
	public function approveMasterChanges( array $options ) {
1038
		$limit = isset( $options['maxWriteDuration'] ) ? $options['maxWriteDuration'] : 0;
1039
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( $limit ) {
1040
			// If atomic sections or explicit transactions are still open, some caller must have
1041
			// caught an exception but failed to properly rollback any changes. Detect that and
1042
			// throw and error (causing rollback).
1043
			if ( $conn->explicitTrxActive() ) {
1044
				throw new DBTransactionError(
1045
					$conn,
1046
					"Explicit transaction still active. A caller may have caught an error."
1047
				);
1048
			}
1049
			// Assert that the time to replicate the transaction will be sane.
1050
			// If this fails, then all DB transactions will be rollback back together.
1051
			$time = $conn->pendingWriteQueryDuration( $conn::ESTIMATE_DB_APPLY );
1052
			if ( $limit > 0 && $time > $limit ) {
1053
				throw new DBTransactionSizeError(
1054
					$conn,
1055
					"Transaction spent $time second(s) in writes, exceeding the $limit limit.",
1056
					[ $time, $limit ]
1057
				);
1058
			}
1059
			// If a connection sits idle while slow queries execute on another, that connection
1060
			// may end up dropped before the commit round is reached. Ping servers to detect this.
1061
			if ( $conn->writesOrCallbacksPending() && !$conn->ping() ) {
1062
				throw new DBTransactionError(
1063
					$conn,
1064
					"A connection to the {$conn->getDBname()} database was lost before commit."
1065
				);
1066
			}
1067
		} );
1068
	}
1069
1070
	public function beginMasterChanges( $fname = __METHOD__ ) {
1071
		if ( $this->trxRoundId !== false ) {
1072
			throw new DBTransactionError(
1073
				null,
1074
				"$fname: Transaction round '{$this->trxRoundId}' already started."
1075
			);
1076
		}
1077
		$this->trxRoundId = $fname;
1078
1079
		$failures = [];
1080
		$this->forEachOpenMasterConnection(
1081
			function ( Database $conn ) use ( $fname, &$failures ) {
1082
				$conn->setTrxEndCallbackSuppression( true );
1083
				try {
1084
					$conn->flushSnapshot( $fname );
1085
				} catch ( DBError $e ) {
1086
					call_user_func( $this->errorLogger, $e );
1087
					$failures[] = "{$conn->getServer()}: {$e->getMessage()}";
1088
				}
1089
				$conn->setTrxEndCallbackSuppression( false );
1090
				$this->applyTransactionRoundFlags( $conn );
1091
			}
1092
		);
1093
1094 View Code Duplication
		if ( $failures ) {
1095
			throw new DBExpectedError(
1096
				null,
1097
				"$fname: Flush failed on server(s) " . implode( "\n", array_unique( $failures ) )
1098
			);
1099
		}
1100
	}
1101
1102
	public function commitMasterChanges( $fname = __METHOD__ ) {
1103
		$failures = [];
1104
1105
		/** @noinspection PhpUnusedLocalVariableInspection */
1106
		$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...
1107
1108
		$restore = ( $this->trxRoundId !== false );
1109
		$this->trxRoundId = false;
1110
		$this->forEachOpenMasterConnection(
1111
			function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) {
1112
				try {
1113
					if ( $conn->writesOrCallbacksPending() ) {
1114
						$conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
1115
					} elseif ( $restore ) {
1116
						$conn->flushSnapshot( $fname );
1117
					}
1118
				} catch ( DBError $e ) {
1119
					call_user_func( $this->errorLogger, $e );
1120
					$failures[] = "{$conn->getServer()}: {$e->getMessage()}";
1121
				}
1122
				if ( $restore ) {
1123
					$this->undoTransactionRoundFlags( $conn );
1124
				}
1125
			}
1126
		);
1127
1128 View Code Duplication
		if ( $failures ) {
1129
			throw new DBExpectedError(
1130
				null,
1131
				"$fname: Commit failed on server(s) " . implode( "\n", array_unique( $failures ) )
1132
			);
1133
		}
1134
	}
1135
1136
	public function runMasterPostTrxCallbacks( $type ) {
1137
		$e = null; // first exception
1138
		$this->forEachOpenMasterConnection( function ( Database $conn ) use ( $type, &$e ) {
1139
			$conn->setTrxEndCallbackSuppression( false );
1140
			if ( $conn->writesOrCallbacksPending() ) {
1141
				// This happens if onTransactionIdle() callbacks leave callbacks on *another* DB
1142
				// (which finished its callbacks already). Warn and recover in this case. Let the
1143
				// callbacks run in the final commitMasterChanges() in LBFactory::shutdown().
1144
				$this->queryLogger->error( __METHOD__ . ": found writes/callbacks pending." );
1145
				return;
1146
			} elseif ( $conn->trxLevel() ) {
1147
				// This happens for single-DB setups where DB_REPLICA uses the master DB,
1148
				// thus leaving an implicit read-only transaction open at this point. It
1149
				// also happens if onTransactionIdle() callbacks leave implicit transactions
1150
				// open on *other* DBs (which is slightly improper). Let these COMMIT on the
1151
				// next call to commitMasterChanges(), possibly in LBFactory::shutdown().
1152
				return;
1153
			}
1154
			try {
1155
				$conn->runOnTransactionIdleCallbacks( $type );
1156
			} catch ( Exception $ex ) {
1157
				$e = $e ?: $ex;
1158
			}
1159
			try {
1160
				$conn->runTransactionListenerCallbacks( $type );
1161
			} catch ( Exception $ex ) {
1162
				$e = $e ?: $ex;
1163
			}
1164
		} );
1165
1166
		return $e;
1167
	}
1168
1169
	public function rollbackMasterChanges( $fname = __METHOD__ ) {
1170
		$restore = ( $this->trxRoundId !== false );
1171
		$this->trxRoundId = false;
1172
		$this->forEachOpenMasterConnection(
1173
			function ( IDatabase $conn ) use ( $fname, $restore ) {
1174
				if ( $conn->writesOrCallbacksPending() ) {
1175
					$conn->rollback( $fname, $conn::FLUSHING_ALL_PEERS );
1176
				}
1177
				if ( $restore ) {
1178
					$this->undoTransactionRoundFlags( $conn );
1179
				}
1180
			}
1181
		);
1182
	}
1183
1184
	public function suppressTransactionEndCallbacks() {
1185
		$this->forEachOpenMasterConnection( function ( Database $conn ) {
1186
			$conn->setTrxEndCallbackSuppression( true );
1187
		} );
1188
	}
1189
1190
	/**
1191
	 * @param IDatabase $conn
1192
	 */
1193
	private function applyTransactionRoundFlags( IDatabase $conn ) {
1194
		if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) {
1195
			// DBO_TRX is controlled entirely by CLI mode presence with DBO_DEFAULT.
1196
			// Force DBO_TRX even in CLI mode since a commit round is expected soon.
1197
			$conn->setFlag( $conn::DBO_TRX, $conn::REMEMBER_PRIOR );
1198
			// If config has explicitly requested DBO_TRX be either on or off by not
1199
			// setting DBO_DEFAULT, then respect that. Forcing no transactions is useful
1200
			// for things like blob stores (ExternalStore) which want auto-commit mode.
1201
		}
1202
	}
1203
1204
	/**
1205
	 * @param IDatabase $conn
1206
	 */
1207
	private function undoTransactionRoundFlags( IDatabase $conn ) {
1208
		if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) {
1209
			$conn->restoreFlags( $conn::RESTORE_PRIOR );
1210
		}
1211
	}
1212
1213
	public function flushReplicaSnapshots( $fname = __METHOD__ ) {
1214
		$this->forEachOpenReplicaConnection( function ( IDatabase $conn ) {
1215
			$conn->flushSnapshot( __METHOD__ );
1216
		} );
1217
	}
1218
1219
	public function hasMasterConnection() {
1220
		return $this->isOpen( $this->getWriterIndex() );
1221
	}
1222
1223
	public function hasMasterChanges() {
1224
		$pending = 0;
1225
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$pending ) {
1226
			$pending |= $conn->writesOrCallbacksPending();
1227
		} );
1228
1229
		return (bool)$pending;
1230
	}
1231
1232
	public function lastMasterChangeTimestamp() {
1233
		$lastTime = false;
1234
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$lastTime ) {
1235
			$lastTime = max( $lastTime, $conn->lastDoneWrites() );
1236
		} );
1237
1238
		return $lastTime;
1239
	}
1240
1241
	public function hasOrMadeRecentMasterChanges( $age = null ) {
1242
		$age = ( $age === null ) ? $this->mWaitTimeout : $age;
1243
1244
		return ( $this->hasMasterChanges()
1245
			|| $this->lastMasterChangeTimestamp() > microtime( true ) - $age );
1246
	}
1247
1248
	public function pendingMasterChangeCallers() {
1249
		$fnames = [];
1250
		$this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$fnames ) {
1251
			$fnames = array_merge( $fnames, $conn->pendingWriteCallers() );
1252
		} );
1253
1254
		return $fnames;
1255
	}
1256
1257
	public function getLaggedReplicaMode( $domain = false ) {
1258
		// No-op if there is only one DB (also avoids recursion)
1259
		if ( !$this->laggedReplicaMode && $this->getServerCount() > 1 ) {
1260
			try {
1261
				// See if laggedReplicaMode gets set
1262
				$conn = $this->getConnection( self::DB_REPLICA, false, $domain );
1263
				$this->reuseConnection( $conn );
0 ignored issues
show
Bug introduced by
It seems like $conn defined by $this->getConnection(sel...EPLICA, false, $domain) on line 1262 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...
1264
			} catch ( DBConnectionError $e ) {
1265
				// Avoid expensive re-connect attempts and failures
1266
				$this->allReplicasDownMode = true;
1267
				$this->laggedReplicaMode = true;
1268
			}
1269
		}
1270
1271
		return $this->laggedReplicaMode;
1272
	}
1273
1274
	/**
1275
	 * @param bool $domain
1276
	 * @return bool
1277
	 * @deprecated 1.28; use getLaggedReplicaMode()
1278
	 */
1279
	public function getLaggedSlaveMode( $domain = false ) {
1280
		return $this->getLaggedReplicaMode( $domain );
1281
	}
1282
1283
	public function laggedReplicaUsed() {
1284
		return $this->laggedReplicaMode;
1285
	}
1286
1287
	/**
1288
	 * @return bool
1289
	 * @since 1.27
1290
	 * @deprecated Since 1.28; use laggedReplicaUsed()
1291
	 */
1292
	public function laggedSlaveUsed() {
1293
		return $this->laggedReplicaUsed();
1294
	}
1295
1296
	public function getReadOnlyReason( $domain = false, IDatabase $conn = null ) {
1297
		if ( $this->readOnlyReason !== false ) {
1298
			return $this->readOnlyReason;
1299
		} elseif ( $this->getLaggedReplicaMode( $domain ) ) {
1300
			if ( $this->allReplicasDownMode ) {
1301
				return 'The database has been automatically locked ' .
1302
					'until the replica database servers become available';
1303
			} else {
1304
				return 'The database has been automatically locked ' .
1305
					'while the replica database servers catch up to the master.';
1306
			}
1307
		} elseif ( $this->masterRunningReadOnly( $domain, $conn ) ) {
1308
			return 'The database master is running in read-only mode.';
1309
		}
1310
1311
		return false;
1312
	}
1313
1314
	/**
1315
	 * @param string $domain Domain ID, or false for the current domain
1316
	 * @param IDatabase|null DB master connectionl used to avoid loops [optional]
1317
	 * @return bool
1318
	 */
1319
	private function masterRunningReadOnly( $domain, IDatabase $conn = null ) {
1320
		$cache = $this->wanCache;
1321
		$masterServer = $this->getServerName( $this->getWriterIndex() );
1322
1323
		return (bool)$cache->getWithSetCallback(
1324
			$cache->makeGlobalKey( __CLASS__, 'server-read-only', $masterServer ),
1325
			self::TTL_CACHE_READONLY,
1326
			function () use ( $domain, $conn ) {
1327
				$old = $this->trxProfiler->setSilenced( true );
1328
				try {
1329
					$dbw = $conn ?: $this->getConnection( self::DB_MASTER, [], $domain );
1330
					$readOnly = (int)$dbw->serverIsReadOnly();
1331
					if ( !$conn ) {
1332
						$this->reuseConnection( $dbw );
0 ignored issues
show
Bug introduced by
It seems like $dbw defined by $conn ?: $this->getConne...STER, array(), $domain) on line 1329 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...
1333
					}
1334
				} catch ( DBError $e ) {
1335
					$readOnly = 0;
1336
				}
1337
				$this->trxProfiler->setSilenced( $old );
1338
				return $readOnly;
1339
			},
1340
			[ 'pcTTL' => $cache::TTL_PROC_LONG, 'busyValue' => 0 ]
1341
		);
1342
	}
1343
1344
	public function allowLagged( $mode = null ) {
1345
		if ( $mode === null ) {
1346
			return $this->mAllowLagged;
1347
		}
1348
		$this->mAllowLagged = $mode;
1349
1350
		return $this->mAllowLagged;
1351
	}
1352
1353
	public function pingAll() {
1354
		$success = true;
1355
		$this->forEachOpenConnection( function ( IDatabase $conn ) use ( &$success ) {
1356
			if ( !$conn->ping() ) {
1357
				$success = false;
1358
			}
1359
		} );
1360
1361
		return $success;
1362
	}
1363
1364 View Code Duplication
	public function forEachOpenConnection( $callback, array $params = [] ) {
1365
		foreach ( $this->mConns as $connsByServer ) {
1366
			foreach ( $connsByServer as $serverConns ) {
1367
				foreach ( $serverConns as $conn ) {
1368
					$mergedParams = array_merge( [ $conn ], $params );
1369
					call_user_func_array( $callback, $mergedParams );
1370
				}
1371
			}
1372
		}
1373
	}
1374
1375
	public function forEachOpenMasterConnection( $callback, array $params = [] ) {
1376
		$masterIndex = $this->getWriterIndex();
1377
		foreach ( $this->mConns as $connsByServer ) {
1378
			if ( isset( $connsByServer[$masterIndex] ) ) {
1379
				/** @var IDatabase $conn */
1380
				foreach ( $connsByServer[$masterIndex] as $conn ) {
1381
					$mergedParams = array_merge( [ $conn ], $params );
1382
					call_user_func_array( $callback, $mergedParams );
1383
				}
1384
			}
1385
		}
1386
	}
1387
1388 View Code Duplication
	public function forEachOpenReplicaConnection( $callback, array $params = [] ) {
1389
		foreach ( $this->mConns as $connsByServer ) {
1390
			foreach ( $connsByServer as $i => $serverConns ) {
1391
				if ( $i === $this->getWriterIndex() ) {
1392
					continue; // skip master
1393
				}
1394
				foreach ( $serverConns as $conn ) {
1395
					$mergedParams = array_merge( [ $conn ], $params );
1396
					call_user_func_array( $callback, $mergedParams );
1397
				}
1398
			}
1399
		}
1400
	}
1401
1402
	public function getMaxLag( $domain = false ) {
1403
		$maxLag = -1;
1404
		$host = '';
1405
		$maxIndex = 0;
1406
1407
		if ( $this->getServerCount() <= 1 ) {
1408
			return [ $host, $maxLag, $maxIndex ]; // no replication = no lag
1409
		}
1410
1411
		$lagTimes = $this->getLagTimes( $domain );
1412
		foreach ( $lagTimes as $i => $lag ) {
1413
			if ( $this->mLoads[$i] > 0 && $lag > $maxLag ) {
1414
				$maxLag = $lag;
1415
				$host = $this->mServers[$i]['host'];
1416
				$maxIndex = $i;
1417
			}
1418
		}
1419
1420
		return [ $host, $maxLag, $maxIndex ];
1421
	}
1422
1423
	public function getLagTimes( $domain = false ) {
1424
		if ( $this->getServerCount() <= 1 ) {
1425
			return [ $this->getWriterIndex() => 0 ]; // no replication = no lag
1426
		}
1427
1428
		$knownLagTimes = []; // map of (server index => 0 seconds)
1429
		$indexesWithLag = [];
1430
		foreach ( $this->mServers as $i => $server ) {
1431
			if ( empty( $server['is static'] ) ) {
1432
				$indexesWithLag[] = $i; // DB server might have replication lag
1433
			} else {
1434
				$knownLagTimes[$i] = 0; // DB server is a non-replicating and read-only archive
1435
			}
1436
		}
1437
1438
		return $this->getLoadMonitor()->getLagTimes( $indexesWithLag, $domain ) + $knownLagTimes;
1439
	}
1440
1441
	public function safeGetLag( IDatabase $conn ) {
1442
		if ( $this->getServerCount() <= 1 ) {
1443
			return 0;
1444
		} else {
1445
			return $conn->getLag();
1446
		}
1447
	}
1448
1449
	public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ) {
1450
		if ( $this->getServerCount() <= 1 || !$conn->getLBInfo( 'replica' ) ) {
1451
			return true; // server is not a replica DB
1452
		}
1453
1454
		if ( !$pos ) {
1455
			// Get the current master position, opening a connection if needed
1456
			$masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() );
1457
			if ( $masterConn ) {
1458
				$pos = $masterConn->getMasterPos();
1459
			} else {
1460
				$masterConn = $this->openConnection( $this->getWriterIndex(), self::DOMAIN_ANY );
1461
				$pos = $masterConn->getMasterPos();
1462
				$this->closeConnection( $masterConn );
0 ignored issues
show
Bug introduced by
It seems like $masterConn defined by $this->openConnection($t...ex(), self::DOMAIN_ANY) on line 1460 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...
1463
			}
1464
		}
1465
1466
		if ( $pos instanceof DBMasterPos ) {
1467
			$result = $conn->masterPosWait( $pos, $timeout );
1468
			if ( $result == -1 || is_null( $result ) ) {
1469
				$msg = __METHOD__ . ": Timed out waiting on {$conn->getServer()} pos {$pos}";
1470
				$this->replLogger->warning( "$msg" );
1471
				$ok = false;
1472
			} else {
1473
				$this->replLogger->info( __METHOD__ . ": Done" );
1474
				$ok = true;
1475
			}
1476
		} else {
1477
			$ok = false; // something is misconfigured
1478
			$this->replLogger->error( "Could not get master pos for {$conn->getServer()}." );
1479
		}
1480
1481
		return $ok;
1482
	}
1483
1484
	public function setTransactionListener( $name, callable $callback = null ) {
1485
		if ( $callback ) {
1486
			$this->trxRecurringCallbacks[$name] = $callback;
1487
		} else {
1488
			unset( $this->trxRecurringCallbacks[$name] );
1489
		}
1490
		$this->forEachOpenMasterConnection(
1491
			function ( IDatabase $conn ) use ( $name, $callback ) {
1492
				$conn->setTransactionListener( $name, $callback );
1493
			}
1494
		);
1495
	}
1496
1497
	public function setTableAliases( array $aliases ) {
1498
		$this->tableAliases = $aliases;
1499
	}
1500
1501
	public function setDomainPrefix( $prefix ) {
1502
		if ( $this->mConns['foreignUsed'] ) {
1503
			// Do not switch connections to explicit foreign domains unless marked as free
1504
			$domains = [];
1505
			foreach ( $this->mConns['foreignUsed'] as $i => $connsByDomain ) {
1506
				$domains = array_merge( $domains, array_keys( $connsByDomain ) );
1507
			}
1508
			$domains = implode( ', ', $domains );
1509
			throw new DBUnexpectedError( null,
1510
				"Foreign domain connections are still in use ($domains)." );
1511
		}
1512
1513
		$this->localDomain = new DatabaseDomain(
1514
			$this->localDomain->getDatabase(),
1515
			null,
1516
			$prefix
1517
		);
1518
1519
		$this->forEachOpenConnection( function ( IDatabase $db ) use ( $prefix ) {
1520
			$db->tablePrefix( $prefix );
1521
		} );
1522
	}
1523
1524
	/**
1525
	 * Make PHP ignore user aborts/disconnects until the returned
1526
	 * value leaves scope. This returns null and does nothing in CLI mode.
1527
	 *
1528
	 * @return ScopedCallback|null
1529
	 */
1530 View Code Duplication
	final protected function getScopedPHPBehaviorForCommit() {
1531
		if ( PHP_SAPI != 'cli' ) { // http://bugs.php.net/bug.php?id=47540
1532
			$old = ignore_user_abort( true ); // avoid half-finished operations
1533
			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...
1534
				ignore_user_abort( $old );
1535
			} );
1536
		}
1537
1538
		return null;
1539
	}
1540
1541
	function __destruct() {
1542
		// Avoid connection leaks for sanity
1543
		$this->disable();
1544
	}
1545
}
1546