LoadBalancer::__construct()   F
last analyzed

Complexity

Conditions 24
Paths > 20000

Size

Total Lines 91
Code Lines 63

Duplication

Lines 3
Ratio 3.3 %

Importance

Changes 0
Metric Value
cc 24
eloc 63
nc 491521
nop 1
dl 3
loc 91
rs 2
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
use Wikimedia\ScopedCallback;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, ScopedCallback.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

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