Completed
Branch master (7da094)
by
unknown
29:41
created

LoadBalancer::getScopedPHPBehaviorForCommit()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 10
Ratio 100 %

Importance

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