Completed
Branch master (19cd63)
by
unknown
40:04
created

includes/libs/rdbms/database/DatabaseMysqlBase.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * This is the MySQL database abstraction layer.
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
24
/**
25
 * Database abstraction object for MySQL.
26
 * Defines methods independent on used MySQL extension.
27
 *
28
 * @ingroup Database
29
 * @since 1.22
30
 * @see Database
31
 */
32
abstract class DatabaseMysqlBase extends Database {
33
	/** @var MysqlMasterPos */
34
	protected $lastKnownReplicaPos;
35
	/** @var string Method to detect replica DB lag */
36
	protected $lagDetectionMethod;
37
	/** @var array Method to detect replica DB lag */
38
	protected $lagDetectionOptions = [];
39
	/** @var bool bool Whether to use GTID methods */
40
	protected $useGTIDs = false;
41
	/** @var string|null */
42
	protected $sslKeyPath;
43
	/** @var string|null */
44
	protected $sslCertPath;
45
	/** @var string|null */
46
	protected $sslCAPath;
47
	/** @var string[]|null */
48
	protected $sslCiphers;
49
	/** @var string sql_mode value to send on connection */
50
	protected $sqlMode;
51
	/** @var bool Use experimental UTF-8 transmission encoding */
52
	protected $utf8Mode;
53
54
	/** @var string|null */
55
	private $serverVersion = null;
56
57
	/**
58
	 * Additional $params include:
59
	 *   - lagDetectionMethod : set to one of (Seconds_Behind_Master,pt-heartbeat).
60
	 *       pt-heartbeat assumes the table is at heartbeat.heartbeat
61
	 *       and uses UTC timestamps in the heartbeat.ts column.
62
	 *       (https://www.percona.com/doc/percona-toolkit/2.2/pt-heartbeat.html)
63
	 *   - lagDetectionOptions : if using pt-heartbeat, this can be set to an array map to change
64
	 *       the default behavior. Normally, the heartbeat row with the server
65
	 *       ID of this server's master will be used. Set the "conds" field to
66
	 *       override the query conditions, e.g. ['shard' => 's1'].
67
	 *   - useGTIDs : use GTID methods like MASTER_GTID_WAIT() when possible.
68
	 *   - sslKeyPath : path to key file [default: null]
69
	 *   - sslCertPath : path to certificate file [default: null]
70
	 *   - sslCAPath : parth to certificate authority PEM files [default: null]
71
	 *   - sslCiphers : array list of allowable ciphers [default: null]
72
	 * @param array $params
73
	 */
74
	function __construct( array $params ) {
75
		$this->lagDetectionMethod = isset( $params['lagDetectionMethod'] )
76
			? $params['lagDetectionMethod']
77
			: 'Seconds_Behind_Master';
78
		$this->lagDetectionOptions = isset( $params['lagDetectionOptions'] )
79
			? $params['lagDetectionOptions']
80
			: [];
81
		$this->useGTIDs = !empty( $params['useGTIDs' ] );
82
		foreach ( [ 'KeyPath', 'CertPath', 'CAPath', 'Ciphers' ] as $name ) {
83
			$var = "ssl{$name}";
84
			if ( isset( $params[$var] ) ) {
85
				$this->$var = $params[$var];
86
			}
87
		}
88
		$this->sqlMode = isset( $params['sqlMode'] ) ? $params['sqlMode'] : '';
89
		$this->utf8Mode = !empty( $params['utf8Mode'] );
90
91
		parent::__construct( $params );
92
	}
93
94
	/**
95
	 * @return string
96
	 */
97
	function getType() {
98
		return 'mysql';
99
	}
100
101
	/**
102
	 * @param string $server
103
	 * @param string $user
104
	 * @param string $password
105
	 * @param string $dbName
106
	 * @throws Exception|DBConnectionError
107
	 * @return bool
108
	 */
109
	function open( $server, $user, $password, $dbName ) {
110
		# Close/unset connection handle
111
		$this->close();
112
113
		$this->mServer = $server;
114
		$this->mUser = $user;
115
		$this->mPassword = $password;
116
		$this->mDBname = $dbName;
117
118
		$this->installErrorHandler();
119
		try {
120
			$this->mConn = $this->mysqlConnect( $this->mServer );
121
		} catch ( Exception $ex ) {
122
			$this->restoreErrorHandler();
123
			throw $ex;
124
		}
125
		$error = $this->restoreErrorHandler();
126
127
		# Always log connection errors
128
		if ( !$this->mConn ) {
129
			if ( !$error ) {
130
				$error = $this->lastError();
131
			}
132
			$this->connLogger->error(
133
				"Error connecting to {db_server}: {error}",
134
				$this->getLogContext( [
135
					'method' => __METHOD__,
136
					'error' => $error,
137
				] )
138
			);
139
			$this->connLogger->debug( "DB connection error\n" .
140
				"Server: $server, User: $user, Password: " .
141
				substr( $password, 0, 3 ) . "..., error: " . $error . "\n" );
142
143
			$this->reportConnectionError( $error );
144
		}
145
146
		if ( $dbName != '' ) {
147
			MediaWiki\suppressWarnings();
148
			$success = $this->selectDB( $dbName );
149
			MediaWiki\restoreWarnings();
150
			if ( !$success ) {
151
				$this->queryLogger->error(
152
					"Error selecting database {db_name} on server {db_server}",
153
					$this->getLogContext( [
154
						'method' => __METHOD__,
155
					] )
156
				);
157
				$this->queryLogger->debug(
158
					"Error selecting database $dbName on server {$this->mServer}" );
159
160
				$this->reportConnectionError( "Error selecting database $dbName" );
161
			}
162
		}
163
164
		// Tell the server what we're communicating with
165
		if ( !$this->connectInitCharset() ) {
166
			$this->reportConnectionError( "Error setting character set" );
167
		}
168
169
		// Abstract over any insane MySQL defaults
170
		$set = [ 'group_concat_max_len = 262144' ];
171
		// Set SQL mode, default is turning them all off, can be overridden or skipped with null
172
		if ( is_string( $this->sqlMode ) ) {
173
			$set[] = 'sql_mode = ' . $this->addQuotes( $this->sqlMode );
174
		}
175
		// Set any custom settings defined by site config
176
		// (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html)
177
		foreach ( $this->mSessionVars as $var => $val ) {
178
			// Escape strings but not numbers to avoid MySQL complaining
179
			if ( !is_int( $val ) && !is_float( $val ) ) {
180
				$val = $this->addQuotes( $val );
181
			}
182
			$set[] = $this->addIdentifierQuotes( $var ) . ' = ' . $val;
183
		}
184
185
		if ( $set ) {
186
			// Use doQuery() to avoid opening implicit transactions (DBO_TRX)
187
			$success = $this->doQuery( 'SET ' . implode( ', ', $set ) );
188
			if ( !$success ) {
189
				$this->queryLogger->error(
190
					'Error setting MySQL variables on server {db_server} (check $wgSQLMode)',
191
					$this->getLogContext( [
192
						'method' => __METHOD__,
193
					] )
194
				);
195
				$this->reportConnectionError(
196
					'Error setting MySQL variables on server {db_server} (check $wgSQLMode)' );
197
			}
198
		}
199
200
		$this->mOpened = true;
201
202
		return true;
203
	}
204
205
	/**
206
	 * Set the character set information right after connection
207
	 * @return bool
208
	 */
209
	protected function connectInitCharset() {
210
		if ( $this->utf8Mode ) {
211
			// Tell the server we're communicating with it in UTF-8.
212
			// This may engage various charset conversions.
213
			return $this->mysqlSetCharset( 'utf8' );
214
		} else {
215
			return $this->mysqlSetCharset( 'binary' );
216
		}
217
	}
218
219
	/**
220
	 * Open a connection to a MySQL server
221
	 *
222
	 * @param string $realServer
223
	 * @return mixed Raw connection
224
	 * @throws DBConnectionError
225
	 */
226
	abstract protected function mysqlConnect( $realServer );
227
228
	/**
229
	 * Set the character set of the MySQL link
230
	 *
231
	 * @param string $charset
232
	 * @return bool
233
	 */
234
	abstract protected function mysqlSetCharset( $charset );
235
236
	/**
237
	 * @param ResultWrapper|resource $res
238
	 * @throws DBUnexpectedError
239
	 */
240 View Code Duplication
	function freeResult( $res ) {
241
		if ( $res instanceof ResultWrapper ) {
242
			$res = $res->result;
243
		}
244
		MediaWiki\suppressWarnings();
245
		$ok = $this->mysqlFreeResult( $res );
246
		MediaWiki\restoreWarnings();
247
		if ( !$ok ) {
248
			throw new DBUnexpectedError( $this, "Unable to free MySQL result" );
249
		}
250
	}
251
252
	/**
253
	 * Free result memory
254
	 *
255
	 * @param resource $res Raw result
256
	 * @return bool
257
	 */
258
	abstract protected function mysqlFreeResult( $res );
259
260
	/**
261
	 * @param ResultWrapper|resource $res
262
	 * @return stdClass|bool
263
	 * @throws DBUnexpectedError
264
	 */
265 View Code Duplication
	function fetchObject( $res ) {
266
		if ( $res instanceof ResultWrapper ) {
267
			$res = $res->result;
268
		}
269
		MediaWiki\suppressWarnings();
270
		$row = $this->mysqlFetchObject( $res );
271
		MediaWiki\restoreWarnings();
272
273
		$errno = $this->lastErrno();
274
		// Unfortunately, mysql_fetch_object does not reset the last errno.
275
		// Only check for CR_SERVER_LOST and CR_UNKNOWN_ERROR, as
276
		// these are the only errors mysql_fetch_object can cause.
277
		// See http://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
278
		if ( $errno == 2000 || $errno == 2013 ) {
279
			throw new DBUnexpectedError(
280
				$this,
281
				'Error in fetchObject(): ' . htmlspecialchars( $this->lastError() )
282
			);
283
		}
284
285
		return $row;
286
	}
287
288
	/**
289
	 * Fetch a result row as an object
290
	 *
291
	 * @param resource $res Raw result
292
	 * @return stdClass
293
	 */
294
	abstract protected function mysqlFetchObject( $res );
295
296
	/**
297
	 * @param ResultWrapper|resource $res
298
	 * @return array|bool
299
	 * @throws DBUnexpectedError
300
	 */
301 View Code Duplication
	function fetchRow( $res ) {
302
		if ( $res instanceof ResultWrapper ) {
303
			$res = $res->result;
304
		}
305
		MediaWiki\suppressWarnings();
306
		$row = $this->mysqlFetchArray( $res );
307
		MediaWiki\restoreWarnings();
308
309
		$errno = $this->lastErrno();
310
		// Unfortunately, mysql_fetch_array does not reset the last errno.
311
		// Only check for CR_SERVER_LOST and CR_UNKNOWN_ERROR, as
312
		// these are the only errors mysql_fetch_array can cause.
313
		// See http://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
314
		if ( $errno == 2000 || $errno == 2013 ) {
315
			throw new DBUnexpectedError(
316
				$this,
317
				'Error in fetchRow(): ' . htmlspecialchars( $this->lastError() )
318
			);
319
		}
320
321
		return $row;
322
	}
323
324
	/**
325
	 * Fetch a result row as an associative and numeric array
326
	 *
327
	 * @param resource $res Raw result
328
	 * @return array
329
	 */
330
	abstract protected function mysqlFetchArray( $res );
331
332
	/**
333
	 * @throws DBUnexpectedError
334
	 * @param ResultWrapper|resource $res
335
	 * @return int
336
	 */
337
	function numRows( $res ) {
338
		if ( $res instanceof ResultWrapper ) {
339
			$res = $res->result;
340
		}
341
		MediaWiki\suppressWarnings();
342
		$n = $this->mysqlNumRows( $res );
343
		MediaWiki\restoreWarnings();
344
345
		// Unfortunately, mysql_num_rows does not reset the last errno.
346
		// We are not checking for any errors here, since
347
		// these are no errors mysql_num_rows can cause.
348
		// See http://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
349
		// See https://phabricator.wikimedia.org/T44430
350
		return $n;
351
	}
352
353
	/**
354
	 * Get number of rows in result
355
	 *
356
	 * @param resource $res Raw result
357
	 * @return int
358
	 */
359
	abstract protected function mysqlNumRows( $res );
360
361
	/**
362
	 * @param ResultWrapper|resource $res
363
	 * @return int
364
	 */
365
	function numFields( $res ) {
366
		if ( $res instanceof ResultWrapper ) {
367
			$res = $res->result;
368
		}
369
370
		return $this->mysqlNumFields( $res );
371
	}
372
373
	/**
374
	 * Get number of fields in result
375
	 *
376
	 * @param resource $res Raw result
377
	 * @return int
378
	 */
379
	abstract protected function mysqlNumFields( $res );
380
381
	/**
382
	 * @param ResultWrapper|resource $res
383
	 * @param int $n
384
	 * @return string
385
	 */
386
	function fieldName( $res, $n ) {
387
		if ( $res instanceof ResultWrapper ) {
388
			$res = $res->result;
389
		}
390
391
		return $this->mysqlFieldName( $res, $n );
392
	}
393
394
	/**
395
	 * Get the name of the specified field in a result
396
	 *
397
	 * @param ResultWrapper|resource $res
398
	 * @param int $n
399
	 * @return string
400
	 */
401
	abstract protected function mysqlFieldName( $res, $n );
402
403
	/**
404
	 * mysql_field_type() wrapper
405
	 * @param ResultWrapper|resource $res
406
	 * @param int $n
407
	 * @return string
408
	 */
409
	public function fieldType( $res, $n ) {
410
		if ( $res instanceof ResultWrapper ) {
411
			$res = $res->result;
412
		}
413
414
		return $this->mysqlFieldType( $res, $n );
415
	}
416
417
	/**
418
	 * Get the type of the specified field in a result
419
	 *
420
	 * @param ResultWrapper|resource $res
421
	 * @param int $n
422
	 * @return string
423
	 */
424
	abstract protected function mysqlFieldType( $res, $n );
425
426
	/**
427
	 * @param ResultWrapper|resource $res
428
	 * @param int $row
429
	 * @return bool
430
	 */
431
	function dataSeek( $res, $row ) {
432
		if ( $res instanceof ResultWrapper ) {
433
			$res = $res->result;
434
		}
435
436
		return $this->mysqlDataSeek( $res, $row );
437
	}
438
439
	/**
440
	 * Move internal result pointer
441
	 *
442
	 * @param ResultWrapper|resource $res
443
	 * @param int $row
444
	 * @return bool
445
	 */
446
	abstract protected function mysqlDataSeek( $res, $row );
447
448
	/**
449
	 * @return string
450
	 */
451
	function lastError() {
452
		if ( $this->mConn ) {
453
			# Even if it's non-zero, it can still be invalid
454
			MediaWiki\suppressWarnings();
455
			$error = $this->mysqlError( $this->mConn );
456
			if ( !$error ) {
457
				$error = $this->mysqlError();
458
			}
459
			MediaWiki\restoreWarnings();
460
		} else {
461
			$error = $this->mysqlError();
462
		}
463
		if ( $error ) {
464
			$error .= ' (' . $this->mServer . ')';
465
		}
466
467
		return $error;
468
	}
469
470
	/**
471
	 * Returns the text of the error message from previous MySQL operation
472
	 *
473
	 * @param resource $conn Raw connection
474
	 * @return string
475
	 */
476
	abstract protected function mysqlError( $conn = null );
477
478
	/**
479
	 * @param string $table
480
	 * @param array $uniqueIndexes
481
	 * @param array $rows
482
	 * @param string $fname
483
	 * @return ResultWrapper
484
	 */
485
	function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
486
		return $this->nativeReplace( $table, $rows, $fname );
487
	}
488
489
	/**
490
	 * Estimate rows in dataset
491
	 * Returns estimated count, based on EXPLAIN output
492
	 * Takes same arguments as Database::select()
493
	 *
494
	 * @param string|array $table
495
	 * @param string|array $vars
496
	 * @param string|array $conds
497
	 * @param string $fname
498
	 * @param string|array $options
499
	 * @return bool|int
500
	 */
501
	public function estimateRowCount( $table, $vars = '*', $conds = '',
502
		$fname = __METHOD__, $options = []
503
	) {
504
		$options['EXPLAIN'] = true;
505
		$res = $this->select( $table, $vars, $conds, $fname, $options );
506
		if ( $res === false ) {
507
			return false;
508
		}
509
		if ( !$this->numRows( $res ) ) {
510
			return 0;
511
		}
512
513
		$rows = 1;
514
		foreach ( $res as $plan ) {
515
			$rows *= $plan->rows > 0 ? $plan->rows : 1; // avoid resetting to zero
516
		}
517
518
		return (int)$rows;
519
	}
520
521
	function tableExists( $table, $fname = __METHOD__ ) {
522
		$table = $this->tableName( $table, 'raw' );
523
		if ( isset( $this->mSessionTempTables[$table] ) ) {
524
			return true; // already known to exist and won't show in SHOW TABLES anyway
525
		}
526
527
		$encLike = $this->buildLike( $table );
528
529
		return $this->query( "SHOW TABLES $encLike", $fname )->numRows() > 0;
530
	}
531
532
	/**
533
	 * @param string $table
534
	 * @param string $field
535
	 * @return bool|MySQLField
536
	 */
537
	function fieldInfo( $table, $field ) {
538
		$table = $this->tableName( $table );
539
		$res = $this->query( "SELECT * FROM $table LIMIT 1", __METHOD__, true );
540
		if ( !$res ) {
541
			return false;
542
		}
543
		$n = $this->mysqlNumFields( $res->result );
544
		for ( $i = 0; $i < $n; $i++ ) {
545
			$meta = $this->mysqlFetchField( $res->result, $i );
546
			if ( $field == $meta->name ) {
547
				return new MySQLField( $meta );
548
			}
549
		}
550
551
		return false;
552
	}
553
554
	/**
555
	 * Get column information from a result
556
	 *
557
	 * @param resource $res Raw result
558
	 * @param int $n
559
	 * @return stdClass
560
	 */
561
	abstract protected function mysqlFetchField( $res, $n );
562
563
	/**
564
	 * Get information about an index into an object
565
	 * Returns false if the index does not exist
566
	 *
567
	 * @param string $table
568
	 * @param string $index
569
	 * @param string $fname
570
	 * @return bool|array|null False or null on failure
571
	 */
572
	function indexInfo( $table, $index, $fname = __METHOD__ ) {
573
		# SHOW INDEX works in MySQL 3.23.58, but SHOW INDEXES does not.
574
		# SHOW INDEX should work for 3.x and up:
575
		# http://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html
576
		$table = $this->tableName( $table );
577
		$index = $this->indexName( $index );
578
579
		$sql = 'SHOW INDEX FROM ' . $table;
580
		$res = $this->query( $sql, $fname );
581
582
		if ( !$res ) {
583
			return null;
584
		}
585
586
		$result = [];
587
588
		foreach ( $res as $row ) {
589
			if ( $row->Key_name == $index ) {
590
				$result[] = $row;
591
			}
592
		}
593
594
		return empty( $result ) ? false : $result;
595
	}
596
597
	/**
598
	 * @param string $s
599
	 * @return string
600
	 */
601
	function strencode( $s ) {
602
		return $this->mysqlRealEscapeString( $s );
603
	}
604
605
	/**
606
	 * @param string $s
607
	 * @return mixed
608
	 */
609
	abstract protected function mysqlRealEscapeString( $s );
610
611
	public function addQuotes( $s ) {
612
		if ( is_bool( $s ) ) {
613
			// Parent would transform to int, which does not play nice with MySQL type juggling.
614
			// When searching for an int in a string column, the strings are cast to int, which
615
			// means false would match any string not starting with a number.
616
			$s = (string)(int)$s;
617
		}
618
		return parent::addQuotes( $s );
619
	}
620
621
	/**
622
	 * MySQL uses `backticks` for identifier quoting instead of the sql standard "double quotes".
623
	 *
624
	 * @param string $s
625
	 * @return string
626
	 */
627
	public function addIdentifierQuotes( $s ) {
628
		// Characters in the range \u0001-\uFFFF are valid in a quoted identifier
629
		// Remove NUL bytes and escape backticks by doubling
630
		return '`' . str_replace( [ "\0", '`' ], [ '', '``' ], $s ) . '`';
631
	}
632
633
	/**
634
	 * @param string $name
635
	 * @return bool
636
	 */
637
	public function isQuotedIdentifier( $name ) {
638
		return strlen( $name ) && $name[0] == '`' && substr( $name, -1, 1 ) == '`';
639
	}
640
641
	function getLag() {
642
		if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
643
			return $this->getLagFromPtHeartbeat();
644
		} else {
645
			return $this->getLagFromSlaveStatus();
646
		}
647
	}
648
649
	/**
650
	 * @return string
651
	 */
652
	protected function getLagDetectionMethod() {
653
		return $this->lagDetectionMethod;
654
	}
655
656
	/**
657
	 * @return bool|int
658
	 */
659
	protected function getLagFromSlaveStatus() {
660
		$res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
661
		$row = $res ? $res->fetchObject() : false;
662
		if ( $row && strval( $row->Seconds_Behind_Master ) !== '' ) {
663
			return intval( $row->Seconds_Behind_Master );
664
		}
665
666
		return false;
667
	}
668
669
	/**
670
	 * @return bool|float
671
	 */
672
	protected function getLagFromPtHeartbeat() {
673
		$options = $this->lagDetectionOptions;
674
675
		if ( isset( $options['conds'] ) ) {
676
			// Best method for multi-DC setups: use logical channel names
677
			$data = $this->getHeartbeatData( $options['conds'] );
678
		} else {
679
			// Standard method: use master server ID (works with stock pt-heartbeat)
680
			$masterInfo = $this->getMasterServerInfo();
681
			if ( !$masterInfo ) {
682
				$this->queryLogger->error(
683
					"Unable to query master of {db_server} for server ID",
684
					$this->getLogContext( [
685
						'method' => __METHOD__
686
					] )
687
				);
688
689
				return false; // could not get master server ID
690
			}
691
692
			$conds = [ 'server_id' => intval( $masterInfo['serverId'] ) ];
693
			$data = $this->getHeartbeatData( $conds );
694
		}
695
696
		list( $time, $nowUnix ) = $data;
697
		if ( $time !== null ) {
698
			// @time is in ISO format like "2015-09-25T16:48:10.000510"
699
			$dateTime = new DateTime( $time, new DateTimeZone( 'UTC' ) );
700
			$timeUnix = (int)$dateTime->format( 'U' ) + $dateTime->format( 'u' ) / 1e6;
701
702
			return max( $nowUnix - $timeUnix, 0.0 );
703
		}
704
705
		$this->queryLogger->error(
706
			"Unable to find pt-heartbeat row for {db_server}",
707
			$this->getLogContext( [
708
				'method' => __METHOD__
709
			] )
710
		);
711
712
		return false;
713
	}
714
715
	protected function getMasterServerInfo() {
716
		$cache = $this->srvCache;
717
		$key = $cache->makeGlobalKey(
718
			'mysql',
719
			'master-info',
720
			// Using one key for all cluster replica DBs is preferable
721
			$this->getLBInfo( 'clusterMasterHost' ) ?: $this->getServer()
722
		);
723
724
		return $cache->getWithSetCallback(
725
			$key,
726
			$cache::TTL_INDEFINITE,
727
			function () use ( $cache, $key ) {
728
				// Get and leave a lock key in place for a short period
729
				if ( !$cache->lock( $key, 0, 10 ) ) {
730
					return false; // avoid master connection spike slams
731
				}
732
733
				$conn = $this->getLazyMasterHandle();
734
				if ( !$conn ) {
735
					return false; // something is misconfigured
736
				}
737
738
				// Connect to and query the master; catch errors to avoid outages
739
				try {
740
					$res = $conn->query( 'SELECT @@server_id AS id', __METHOD__ );
741
					$row = $res ? $res->fetchObject() : false;
742
					$id = $row ? (int)$row->id : 0;
743
				} catch ( DBError $e ) {
744
					$id = 0;
745
				}
746
747
				// Cache the ID if it was retrieved
748
				return $id ? [ 'serverId' => $id, 'asOf' => time() ] : false;
749
			}
750
		);
751
	}
752
753
	/**
754
	 * @param array $conds WHERE clause conditions to find a row
755
	 * @return array (heartbeat `ts` column value or null, UNIX timestamp) for the newest beat
756
	 * @see https://www.percona.com/doc/percona-toolkit/2.1/pt-heartbeat.html
757
	 */
758
	protected function getHeartbeatData( array $conds ) {
759
		$whereSQL = $this->makeList( $conds, self::LIST_AND );
760
		// Use ORDER BY for channel based queries since that field might not be UNIQUE.
761
		// Note: this would use "TIMESTAMPDIFF(MICROSECOND,ts,UTC_TIMESTAMP(6))" but the
762
		// percision field is not supported in MySQL <= 5.5.
763
		$res = $this->query(
764
			"SELECT ts FROM heartbeat.heartbeat WHERE $whereSQL ORDER BY ts DESC LIMIT 1"
765
		);
766
		$row = $res ? $res->fetchObject() : false;
767
768
		return [ $row ? $row->ts : null, microtime( true ) ];
769
	}
770
771
	public function getApproximateLagStatus() {
772
		if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
773
			// Disable caching since this is fast enough and we don't wan't
774
			// to be *too* pessimistic by having both the cache TTL and the
775
			// pt-heartbeat interval count as lag in getSessionLagStatus()
776
			return parent::getApproximateLagStatus();
777
		}
778
779
		$key = $this->srvCache->makeGlobalKey( 'mysql-lag', $this->getServer() );
780
		$approxLag = $this->srvCache->get( $key );
781
		if ( !$approxLag ) {
782
			$approxLag = parent::getApproximateLagStatus();
783
			$this->srvCache->set( $key, $approxLag, 1 );
784
		}
785
786
		return $approxLag;
787
	}
788
789
	function masterPosWait( DBMasterPos $pos, $timeout ) {
790
		if ( !( $pos instanceof MySQLMasterPos ) ) {
791
			throw new InvalidArgumentException( "Position not an instance of MySQLMasterPos" );
792
		}
793
794
		if ( $this->getLBInfo( 'is static' ) === true ) {
795
			return 0; // this is a copy of a read-only dataset with no master DB
796
		} elseif ( $this->lastKnownReplicaPos && $this->lastKnownReplicaPos->hasReached( $pos ) ) {
797
			return 0; // already reached this point for sure
798
		}
799
800
		// Call doQuery() directly, to avoid opening a transaction if DBO_TRX is set
801
		if ( $this->useGTIDs && $pos->gtids ) {
802
			// Wait on the GTID set (MariaDB only)
803
			$gtidArg = $this->addQuotes( implode( ',', $pos->gtids ) );
804
			$res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
805
		} else {
806
			// Wait on the binlog coordinates
807
			$encFile = $this->addQuotes( $pos->file );
808
			$encPos = intval( $pos->pos );
809
			$res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" );
810
		}
811
812
		$row = $res ? $this->fetchRow( $res ) : false;
813
		if ( !$row ) {
814
			throw new DBExpectedError( $this, "Failed to query MASTER_POS_WAIT()" );
815
		}
816
817
		// Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
818
		$status = ( $row[0] !== null ) ? intval( $row[0] ) : null;
819
		if ( $status === null ) {
820
			// T126436: jobs programmed to wait on master positions might be referencing binlogs
821
			// with an old master hostname. Such calls make MASTER_POS_WAIT() return null. Try
822
			// to detect this and treat the replica DB as having reached the position; a proper master
823
			// switchover already requires that the new master be caught up before the switch.
824
			$replicationPos = $this->getReplicaPos();
825
			if ( $replicationPos && !$replicationPos->channelsMatch( $pos ) ) {
826
				$this->lastKnownReplicaPos = $replicationPos;
827
				$status = 0;
828
			}
829
		} elseif ( $status >= 0 ) {
830
			// Remember that this position was reached to save queries next time
831
			$this->lastKnownReplicaPos = $pos;
832
		}
833
834
		return $status;
835
	}
836
837
	/**
838
	 * Get the position of the master from SHOW SLAVE STATUS
839
	 *
840
	 * @return MySQLMasterPos|bool
841
	 */
842
	function getReplicaPos() {
843
		$res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
844
		$row = $this->fetchObject( $res );
845
846
		if ( $row ) {
847
			$pos = isset( $row->Exec_master_log_pos )
848
				? $row->Exec_master_log_pos
849
				: $row->Exec_Master_Log_Pos;
850
			// Also fetch the last-applied GTID set (MariaDB)
851 View Code Duplication
			if ( $this->useGTIDs ) {
852
				$res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_slave_pos'", __METHOD__ );
853
				$gtidRow = $this->fetchObject( $res );
854
				$gtidSet = $gtidRow ? $gtidRow->Value : '';
855
			} else {
856
				$gtidSet = '';
857
			}
858
859
			return new MySQLMasterPos( $row->Relay_Master_Log_File, $pos, $gtidSet );
860
		} else {
861
			return false;
862
		}
863
	}
864
865
	/**
866
	 * Get the position of the master from SHOW MASTER STATUS
867
	 *
868
	 * @return MySQLMasterPos|bool
869
	 */
870
	function getMasterPos() {
871
		$res = $this->query( 'SHOW MASTER STATUS', __METHOD__ );
872
		$row = $this->fetchObject( $res );
873
874
		if ( $row ) {
875
			// Also fetch the last-written GTID set (MariaDB)
876 View Code Duplication
			if ( $this->useGTIDs ) {
877
				$res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_binlog_pos'", __METHOD__ );
878
				$gtidRow = $this->fetchObject( $res );
879
				$gtidSet = $gtidRow ? $gtidRow->Value : '';
880
			} else {
881
				$gtidSet = '';
882
			}
883
884
			return new MySQLMasterPos( $row->File, $row->Position, $gtidSet );
885
		} else {
886
			return false;
887
		}
888
	}
889
890
	public function serverIsReadOnly() {
891
		$res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'read_only'", __METHOD__ );
892
		$row = $this->fetchObject( $res );
893
894
		return $row ? ( strtolower( $row->Value ) === 'on' ) : false;
895
	}
896
897
	/**
898
	 * @param string $index
899
	 * @return string
900
	 */
901
	function useIndexClause( $index ) {
902
		return "FORCE INDEX (" . $this->indexName( $index ) . ")";
903
	}
904
905
	/**
906
	 * @param string $index
907
	 * @return string
908
	 */
909
	function ignoreIndexClause( $index ) {
910
		return "IGNORE INDEX (" . $this->indexName( $index ) . ")";
911
	}
912
913
	/**
914
	 * @return string
915
	 */
916
	function lowPriorityOption() {
917
		return 'LOW_PRIORITY';
918
	}
919
920
	/**
921
	 * @return string
922
	 */
923
	public function getSoftwareLink() {
924
		// MariaDB includes its name in its version string; this is how MariaDB's version of
925
		// the mysql command-line client identifies MariaDB servers (see mariadb_connection()
926
		// in libmysql/libmysql.c).
927
		$version = $this->getServerVersion();
928
		if ( strpos( $version, 'MariaDB' ) !== false || strpos( $version, '-maria-' ) !== false ) {
929
			return '[{{int:version-db-mariadb-url}} MariaDB]';
930
		}
931
932
		// Percona Server's version suffix is not very distinctive, and @@version_comment
933
		// doesn't give the necessary info for source builds, so assume the server is MySQL.
934
		// (Even Percona's version of mysql doesn't try to make the distinction.)
935
		return '[{{int:version-db-mysql-url}} MySQL]';
936
	}
937
938
	/**
939
	 * @return string
940
	 */
941
	public function getServerVersion() {
942
		// Not using mysql_get_server_info() or similar for consistency: in the handshake,
943
		// MariaDB 10 adds the prefix "5.5.5-", and only some newer client libraries strip
944
		// it off (see RPL_VERSION_HACK in include/mysql_com.h).
945
		if ( $this->serverVersion === null ) {
946
			$this->serverVersion = $this->selectField( '', 'VERSION()', '', __METHOD__ );
947
		}
948
		return $this->serverVersion;
949
	}
950
951
	/**
952
	 * @param array $options
953
	 */
954
	public function setSessionOptions( array $options ) {
955
		if ( isset( $options['connTimeout'] ) ) {
956
			$timeout = (int)$options['connTimeout'];
957
			$this->query( "SET net_read_timeout=$timeout" );
958
			$this->query( "SET net_write_timeout=$timeout" );
959
		}
960
	}
961
962
	/**
963
	 * @param string $sql
964
	 * @param string $newLine
965
	 * @return bool
966
	 */
967
	public function streamStatementEnd( &$sql, &$newLine ) {
968
		if ( strtoupper( substr( $newLine, 0, 9 ) ) == 'DELIMITER' ) {
969
			preg_match( '/^DELIMITER\s+(\S+)/', $newLine, $m );
970
			$this->delimiter = $m[1];
971
			$newLine = '';
972
		}
973
974
		return parent::streamStatementEnd( $sql, $newLine );
975
	}
976
977
	/**
978
	 * Check to see if a named lock is available. This is non-blocking.
979
	 *
980
	 * @param string $lockName Name of lock to poll
981
	 * @param string $method Name of method calling us
982
	 * @return bool
983
	 * @since 1.20
984
	 */
985 View Code Duplication
	public function lockIsFree( $lockName, $method ) {
986
		$encName = $this->addQuotes( $this->makeLockName( $lockName ) );
987
		$result = $this->query( "SELECT IS_FREE_LOCK($encName) AS lockstatus", $method );
988
		$row = $this->fetchObject( $result );
989
990
		return ( $row->lockstatus == 1 );
991
	}
992
993
	/**
994
	 * @param string $lockName
995
	 * @param string $method
996
	 * @param int $timeout
997
	 * @return bool
998
	 */
999 View Code Duplication
	public function lock( $lockName, $method, $timeout = 5 ) {
1000
		$encName = $this->addQuotes( $this->makeLockName( $lockName ) );
1001
		$result = $this->query( "SELECT GET_LOCK($encName, $timeout) AS lockstatus", $method );
1002
		$row = $this->fetchObject( $result );
1003
1004
		if ( $row->lockstatus == 1 ) {
1005
			parent::lock( $lockName, $method, $timeout ); // record
1006
			return true;
1007
		}
1008
1009
		$this->queryLogger->warning( __METHOD__ . " failed to acquire lock '$lockName'\n" );
1010
1011
		return false;
1012
	}
1013
1014
	/**
1015
	 * FROM MYSQL DOCS:
1016
	 * http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_release-lock
1017
	 * @param string $lockName
1018
	 * @param string $method
1019
	 * @return bool
1020
	 */
1021 View Code Duplication
	public function unlock( $lockName, $method ) {
1022
		$encName = $this->addQuotes( $this->makeLockName( $lockName ) );
1023
		$result = $this->query( "SELECT RELEASE_LOCK($encName) as lockstatus", $method );
1024
		$row = $this->fetchObject( $result );
1025
1026
		if ( $row->lockstatus == 1 ) {
1027
			parent::unlock( $lockName, $method ); // record
1028
			return true;
1029
		}
1030
1031
		$this->queryLogger->warning( __METHOD__ . " failed to release lock '$lockName'\n" );
1032
1033
		return false;
1034
	}
1035
1036
	private function makeLockName( $lockName ) {
1037
		// http://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
1038
		// Newer version enforce a 64 char length limit.
1039
		return ( strlen( $lockName ) > 64 ) ? sha1( $lockName ) : $lockName;
1040
	}
1041
1042
	public function namedLocksEnqueue() {
1043
		return true;
1044
	}
1045
1046
	/**
1047
	 * @param array $read
1048
	 * @param array $write
1049
	 * @param string $method
1050
	 * @param bool $lowPriority
1051
	 * @return bool
1052
	 */
1053
	public function lockTables( $read, $write, $method, $lowPriority = true ) {
1054
		$items = [];
1055
1056
		foreach ( $write as $table ) {
1057
			$tbl = $this->tableName( $table ) .
1058
				( $lowPriority ? ' LOW_PRIORITY' : '' ) .
1059
				' WRITE';
1060
			$items[] = $tbl;
1061
		}
1062
		foreach ( $read as $table ) {
1063
			$items[] = $this->tableName( $table ) . ' READ';
1064
		}
1065
		$sql = "LOCK TABLES " . implode( ',', $items );
1066
		$this->query( $sql, $method );
1067
1068
		return true;
1069
	}
1070
1071
	/**
1072
	 * @param string $method
1073
	 * @return bool
1074
	 */
1075
	public function unlockTables( $method ) {
1076
		$this->query( "UNLOCK TABLES", $method );
1077
1078
		return true;
1079
	}
1080
1081
	/**
1082
	 * @param bool $value
1083
	 */
1084
	public function setBigSelects( $value = true ) {
1085
		if ( $value === 'default' ) {
1086
			if ( $this->mDefaultBigSelects === null ) {
1087
				# Function hasn't been called before so it must already be set to the default
1088
				return;
1089
			} else {
1090
				$value = $this->mDefaultBigSelects;
1091
			}
1092
		} elseif ( $this->mDefaultBigSelects === null ) {
1093
			$this->mDefaultBigSelects =
1094
				(bool)$this->selectField( false, '@@sql_big_selects', '', __METHOD__ );
1095
		}
1096
		$encValue = $value ? '1' : '0';
1097
		$this->query( "SET sql_big_selects=$encValue", __METHOD__ );
1098
	}
1099
1100
	/**
1101
	 * DELETE where the condition is a join. MySql uses multi-table deletes.
1102
	 * @param string $delTable
1103
	 * @param string $joinTable
1104
	 * @param string $delVar
1105
	 * @param string $joinVar
1106
	 * @param array|string $conds
1107
	 * @param bool|string $fname
1108
	 * @throws DBUnexpectedError
1109
	 * @return bool|ResultWrapper
1110
	 */
1111 View Code Duplication
	function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds, $fname = __METHOD__ ) {
1112
		if ( !$conds ) {
1113
			throw new DBUnexpectedError( $this, __METHOD__ . ' called with empty $conds' );
1114
		}
1115
1116
		$delTable = $this->tableName( $delTable );
1117
		$joinTable = $this->tableName( $joinTable );
1118
		$sql = "DELETE $delTable FROM $delTable, $joinTable WHERE $delVar=$joinVar ";
1119
1120
		if ( $conds != '*' ) {
1121
			$sql .= ' AND ' . $this->makeList( $conds, self::LIST_AND );
1122
		}
1123
1124
		return $this->query( $sql, $fname );
1125
	}
1126
1127
	/**
1128
	 * @param string $table
1129
	 * @param array $rows
1130
	 * @param array $uniqueIndexes
1131
	 * @param array $set
1132
	 * @param string $fname
1133
	 * @return bool
1134
	 */
1135
	public function upsert( $table, array $rows, array $uniqueIndexes,
1136
		array $set, $fname = __METHOD__
1137
	) {
1138
		if ( !count( $rows ) ) {
1139
			return true; // nothing to do
1140
		}
1141
1142
		if ( !is_array( reset( $rows ) ) ) {
1143
			$rows = [ $rows ];
1144
		}
1145
1146
		$table = $this->tableName( $table );
1147
		$columns = array_keys( $rows[0] );
1148
1149
		$sql = "INSERT INTO $table (" . implode( ',', $columns ) . ') VALUES ';
1150
		$rowTuples = [];
1151
		foreach ( $rows as $row ) {
1152
			$rowTuples[] = '(' . $this->makeList( $row ) . ')';
1153
		}
1154
		$sql .= implode( ',', $rowTuples );
1155
		$sql .= " ON DUPLICATE KEY UPDATE " . $this->makeList( $set, self::LIST_SET );
1156
1157
		return (bool)$this->query( $sql, $fname );
1158
	}
1159
1160
	/**
1161
	 * Determines how long the server has been up
1162
	 *
1163
	 * @return int
1164
	 */
1165
	function getServerUptime() {
1166
		$vars = $this->getMysqlStatus( 'Uptime' );
1167
1168
		return (int)$vars['Uptime'];
1169
	}
1170
1171
	/**
1172
	 * Determines if the last failure was due to a deadlock
1173
	 *
1174
	 * @return bool
1175
	 */
1176
	function wasDeadlock() {
1177
		return $this->lastErrno() == 1213;
1178
	}
1179
1180
	/**
1181
	 * Determines if the last failure was due to a lock timeout
1182
	 *
1183
	 * @return bool
1184
	 */
1185
	function wasLockTimeout() {
1186
		return $this->lastErrno() == 1205;
1187
	}
1188
1189
	function wasErrorReissuable() {
1190
		return $this->lastErrno() == 2013 || $this->lastErrno() == 2006;
1191
	}
1192
1193
	/**
1194
	 * Determines if the last failure was due to the database being read-only.
1195
	 *
1196
	 * @return bool
1197
	 */
1198
	function wasReadOnlyError() {
1199
		return $this->lastErrno() == 1223 ||
1200
			( $this->lastErrno() == 1290 && strpos( $this->lastError(), '--read-only' ) !== false );
1201
	}
1202
1203
	function wasConnectionError( $errno ) {
1204
		return $errno == 2013 || $errno == 2006;
1205
	}
1206
1207
	/**
1208
	 * @param string $oldName
1209
	 * @param string $newName
1210
	 * @param bool $temporary
1211
	 * @param string $fname
1212
	 * @return bool
1213
	 */
1214
	function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) {
1215
		$tmp = $temporary ? 'TEMPORARY ' : '';
1216
		$newName = $this->addIdentifierQuotes( $newName );
1217
		$oldName = $this->addIdentifierQuotes( $oldName );
1218
		$query = "CREATE $tmp TABLE $newName (LIKE $oldName)";
1219
1220
		return $this->query( $query, $fname );
1221
	}
1222
1223
	/**
1224
	 * List all tables on the database
1225
	 *
1226
	 * @param string $prefix Only show tables with this prefix, e.g. mw_
1227
	 * @param string $fname Calling function name
1228
	 * @return array
1229
	 */
1230
	function listTables( $prefix = null, $fname = __METHOD__ ) {
1231
		$result = $this->query( "SHOW TABLES", $fname );
1232
1233
		$endArray = [];
1234
1235 View Code Duplication
		foreach ( $result as $table ) {
1236
			$vars = get_object_vars( $table );
1237
			$table = array_pop( $vars );
1238
1239
			if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
1240
				$endArray[] = $table;
1241
			}
1242
		}
1243
1244
		return $endArray;
1245
	}
1246
1247
	/**
1248
	 * @param string $tableName
1249
	 * @param string $fName
1250
	 * @return bool|ResultWrapper
1251
	 */
1252 View Code Duplication
	public function dropTable( $tableName, $fName = __METHOD__ ) {
1253
		if ( !$this->tableExists( $tableName, $fName ) ) {
1254
			return false;
1255
		}
1256
1257
		return $this->query( "DROP TABLE IF EXISTS " . $this->tableName( $tableName ), $fName );
1258
	}
1259
1260
	/**
1261
	 * Get status information from SHOW STATUS in an associative array
1262
	 *
1263
	 * @param string $which
1264
	 * @return array
1265
	 */
1266
	function getMysqlStatus( $which = "%" ) {
1267
		$res = $this->query( "SHOW STATUS LIKE '{$which}'" );
1268
		$status = [];
1269
1270
		foreach ( $res as $row ) {
0 ignored issues
show
The expression $res of type boolean|object<ResultWrapper> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

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

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

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

Loading history...
1271
			$status[$row->Variable_name] = $row->Value;
1272
		}
1273
1274
		return $status;
1275
	}
1276
1277
	/**
1278
	 * Lists VIEWs in the database
1279
	 *
1280
	 * @param string $prefix Only show VIEWs with this prefix, eg.
1281
	 * unit_test_, or $wgDBprefix. Default: null, would return all views.
1282
	 * @param string $fname Name of calling function
1283
	 * @return array
1284
	 * @since 1.22
1285
	 */
1286
	public function listViews( $prefix = null, $fname = __METHOD__ ) {
1287
		// The name of the column containing the name of the VIEW
1288
		$propertyName = 'Tables_in_' . $this->mDBname;
1289
1290
		// Query for the VIEWS
1291
		$res = $this->query( 'SHOW FULL TABLES WHERE TABLE_TYPE = "VIEW"' );
1292
		$allViews = [];
1293
		foreach ( $res as $row ) {
1294
			array_push( $allViews, $row->$propertyName );
1295
		}
1296
1297
		if ( is_null( $prefix ) || $prefix === '' ) {
1298
			return $allViews;
1299
		}
1300
1301
		$filteredViews = [];
1302
		foreach ( $allViews as $viewName ) {
1303
			// Does the name of this VIEW start with the table-prefix?
1304
			if ( strpos( $viewName, $prefix ) === 0 ) {
1305
				array_push( $filteredViews, $viewName );
1306
			}
1307
		}
1308
1309
		return $filteredViews;
1310
	}
1311
1312
	/**
1313
	 * Differentiates between a TABLE and a VIEW.
1314
	 *
1315
	 * @param string $name Name of the TABLE/VIEW to test
1316
	 * @param string $prefix
1317
	 * @return bool
1318
	 * @since 1.22
1319
	 */
1320
	public function isView( $name, $prefix = null ) {
1321
		return in_array( $name, $this->listViews( $prefix ) );
1322
	}
1323
}
1324
1325