MediaWiki REL1_39
DatabaseMysqlBase.php
Go to the documentation of this file.
1<?php
20namespace Wikimedia\Rdbms;
21
22use InvalidArgumentException;
23use RuntimeException;
24use stdClass;
27
42abstract class DatabaseMysqlBase extends Database {
48 protected $lagDetectionOptions = [];
50 protected $useGTIDs = false;
52 protected $sslKeyPath;
54 protected $sslCertPath;
56 protected $sslCAFile;
58 protected $sslCAPath;
64 protected $sslCiphers;
66 protected $utf8Mode;
69
71 private $insertSelectIsSafe;
73 private $replicationInfoRow;
74
75 // Cache getServerId() for 24 hours
76 private const SERVER_ID_CACHE_TTL = 86400;
77
79 private const LAG_STALE_WARN_THRESHOLD = 0.100;
80
82 protected $platform;
83
104 public function __construct( array $params ) {
105 $this->lagDetectionMethod = $params['lagDetectionMethod'] ?? 'Seconds_Behind_Master';
106 $this->lagDetectionOptions = $params['lagDetectionOptions'] ?? [];
107 $this->useGTIDs = !empty( $params['useGTIDs' ] );
108 foreach ( [ 'KeyPath', 'CertPath', 'CAFile', 'CAPath', 'Ciphers' ] as $name ) {
109 $var = "ssl{$name}";
110 if ( isset( $params[$var] ) ) {
111 $this->$var = $params[$var];
112 }
113 }
114 $this->utf8Mode = !empty( $params['utf8Mode'] );
115 $this->insertSelectIsSafe = isset( $params['insertSelectIsSafe'] )
116 ? (bool)$params['insertSelectIsSafe'] : null;
117 parent::__construct( $params );
118 $this->platform = new MySQLPlatform(
119 $this,
120 $params['queryLogger'],
121 $this->currentDomain,
122 $this->errorLogger
123 );
124 }
125
129 public function getType() {
130 return 'mysql';
131 }
132
133 protected function open( $server, $user, $password, $db, $schema, $tablePrefix ) {
134 $this->close( __METHOD__ );
135
136 if ( $schema !== null ) {
137 throw $this->newExceptionAfterConnectError( "Got schema '$schema'; not supported." );
138 }
139
140 $this->installErrorHandler();
141 try {
142 $this->conn = $this->mysqlConnect( $server, $user, $password, $db );
143 } catch ( RuntimeException $e ) {
144 $this->restoreErrorHandler();
145 throw $this->newExceptionAfterConnectError( $e->getMessage() );
146 }
147 $error = $this->restoreErrorHandler();
148
149 if ( !$this->conn ) {
150 throw $this->newExceptionAfterConnectError( $error ?: $this->lastError() );
151 }
152
153 try {
154 $this->currentDomain = new DatabaseDomain(
155 $db && strlen( $db ) ? $db : null,
156 null,
157 $tablePrefix
158 );
159 $this->platform->setPrefix( $tablePrefix );
160 // Abstract over any excessive MySQL defaults
161 $set = [ 'group_concat_max_len = 262144' ];
162 // Set any custom settings defined by site config
163 // https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html
164 foreach ( $this->connectionVariables as $var => $val ) {
165 // Escape strings but not numbers to avoid MySQL complaining
166 if ( !is_int( $val ) && !is_float( $val ) ) {
167 $val = $this->addQuotes( $val );
168 }
169 $set[] = $this->platform->addIdentifierQuotes( $var ) . ' = ' . $val;
170 }
171
172 // @phan-suppress-next-next-line PhanRedundantCondition
173 // Safety check to avoid empty SET query
174 if ( $set ) {
175 $sql = 'SET ' . implode( ', ', $set );
176 $flags = self::QUERY_NO_RETRY | self::QUERY_CHANGE_TRX;
177 // Avoid using query() so that replaceLostConnection() does not throw
178 // errors if the transaction status is STATUS_TRX_ERROR
179 $qs = $this->executeQuery( $sql, __METHOD__, $flags, $sql );
180 if ( $qs->res === false ) {
181 $this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ );
182 }
183 }
184 } catch ( RuntimeException $e ) {
185 throw $this->newExceptionAfterConnectError( $e->getMessage() );
186 }
187 }
188
189 protected function doSelectDomain( DatabaseDomain $domain ) {
190 if ( $domain->getSchema() !== null ) {
191 throw new DBExpectedError(
192 $this,
193 __CLASS__ . ": domain '{$domain->getId()}' has a schema component"
194 );
195 }
196
197 $database = $domain->getDatabase();
198 // A null database means "don't care" so leave it as is and update the table prefix
199 if ( $database === null ) {
200 $this->currentDomain = new DatabaseDomain(
201 $this->currentDomain->getDatabase(),
202 null,
203 $domain->getTablePrefix()
204 );
205 $this->platform->setPrefix( $domain->getTablePrefix() );
206
207 return true;
208 }
209
210 if ( $database !== $this->getDBname() ) {
211 $sql = 'USE ' . $this->addIdentifierQuotes( $database );
212 $qs = $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX, $sql );
213 if ( $qs->res === false ) {
214 $this->reportQueryError( $qs->message, $qs->code, $sql, __METHOD__ );
215 return false; // unreachable
216 }
217 }
218
219 // Update that domain fields on success (no exception thrown)
220 $this->currentDomain = $domain;
221 $this->platform->setPrefix( $domain->getTablePrefix() );
222
223 return true;
224 }
225
236 abstract protected function mysqlConnect( $server, $user, $password, $db );
237
241 public function lastError() {
242 if ( $this->conn ) {
243 // Even if it's non-zero, it can still be invalid
244 $error = $this->mysqlError( $this->conn );
245 if ( !$error ) {
246 $error = $this->mysqlError();
247 }
248 } else {
249 $error = $this->mysqlError();
250 }
251
252 return $error;
253 }
254
261 abstract protected function mysqlError( $conn = null );
262
263 protected function isInsertSelectSafe( array $insertOptions, array $selectOptions ) {
264 $row = $this->getReplicationSafetyInfo();
265 // For row-based-replication, the resulting changes will be relayed, not the query
266 if ( $row->binlog_format === 'ROW' ) {
267 return true;
268 }
269 // LIMIT requires ORDER BY on a unique key or it is non-deterministic
270 if ( isset( $selectOptions['LIMIT'] ) ) {
271 return false;
272 }
273 // In MySQL, an INSERT SELECT is only replication safe with row-based
274 // replication or if innodb_autoinc_lock_mode is 0. When those
275 // conditions aren't met, use non-native mode.
276 // While we could try to determine if the insert is safe anyway by
277 // checking if the target table has an auto-increment column that
278 // isn't set in $varMap, that seems unlikely to be worth the extra
279 // complexity.
280 return (
281 in_array( 'NO_AUTO_COLUMNS', $insertOptions ) ||
282 (int)$row->innodb_autoinc_lock_mode === 0
283 );
284 }
285
289 protected function getReplicationSafetyInfo() {
290 if ( $this->replicationInfoRow === null ) {
291 $this->replicationInfoRow = $this->selectRow(
292 false,
293 [
294 'innodb_autoinc_lock_mode' => '@@innodb_autoinc_lock_mode',
295 'binlog_format' => '@@binlog_format',
296 ],
297 [],
298 __METHOD__
299 );
300 }
301
302 return $this->replicationInfoRow;
303 }
304
318 public function estimateRowCount(
319 $tables,
320 $var = '*',
321 $conds = '',
322 $fname = __METHOD__,
323 $options = [],
324 $join_conds = []
325 ) {
326 $conds = $this->platform->normalizeConditions( $conds, $fname );
327 $column = $this->platform->extractSingleFieldFromList( $var );
328 if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
329 $conds[] = "$column IS NOT NULL";
330 }
331
332 $options['EXPLAIN'] = true;
333 $res = $this->select( $tables, $var, $conds, $fname, $options, $join_conds );
334 if ( $res === false ) {
335 return false;
336 }
337 if ( !$res->numRows() ) {
338 return 0;
339 }
340
341 $rows = 1;
342 foreach ( $res as $plan ) {
343 $rows *= $plan->rows > 0 ? $plan->rows : 1; // avoid resetting to zero
344 }
345
346 return (int)$rows;
347 }
348
349 public function tableExists( $table, $fname = __METHOD__ ) {
350 // Split database and table into proper variables as Database::tableName() returns
351 // shared tables prefixed with their database, which do not work in SHOW TABLES statements
352 list( $database, , $prefix, $table ) = $this->platform->qualifiedTableComponents( $table );
353 $tableName = "{$prefix}{$table}";
354
355 if ( isset( $this->sessionTempTables[$tableName] ) ) {
356 return true; // already known to exist and won't show in SHOW TABLES anyway
357 }
358
359 // We can't use buildLike() here, because it specifies an escape character
360 // other than the backslash, which is the only one supported by SHOW TABLES
361 // TODO: Avoid using platform's internal methods
362 $encLike = $this->platform->escapeLikeInternal( $tableName, '\\' );
363
364 // If the database has been specified (such as for shared tables), use "FROM"
365 if ( $database !== '' ) {
366 $encDatabase = $this->platform->addIdentifierQuotes( $database );
367 $sql = "SHOW TABLES FROM $encDatabase LIKE '$encLike'";
368 } else {
369 $sql = "SHOW TABLES LIKE '$encLike'";
370 }
371
372 $res = $this->query(
373 $sql,
374 $fname,
375 self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
376 );
377
378 return $res->numRows() > 0;
379 }
380
386 public function fieldInfo( $table, $field ) {
387 $res = $this->query(
388 "SELECT * FROM " . $this->tableName( $table ) . " LIMIT 1",
389 __METHOD__,
390 self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
391 );
392 if ( !$res ) {
393 return false;
394 }
396 '@phan-var MysqliResultWrapper $res';
397 return $res->getInternalFieldInfo( $field );
398 }
399
409 public function indexInfo( $table, $index, $fname = __METHOD__ ) {
410 # https://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html
411 $index = $this->indexName( $index );
412
413 $res = $this->query(
414 'SHOW INDEX FROM ' . $this->tableName( $table ),
415 $fname,
416 self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
417 );
418
419 if ( !$res ) {
420 return null;
421 }
422
423 $result = [];
424
425 foreach ( $res as $row ) {
426 if ( $row->Key_name == $index ) {
427 $result[] = $row;
428 }
429 }
430
431 return $result ?: false;
432 }
433
438 public function strencode( $s ) {
439 return $this->mysqlRealEscapeString( $s );
440 }
441
448 abstract protected function mysqlRealEscapeString( $s );
449
450 protected function doGetLag() {
451 if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
452 return $this->getLagFromPtHeartbeat();
453 } else {
454 return $this->getLagFromSlaveStatus();
455 }
456 }
457
461 protected function getLagDetectionMethod() {
463 }
464
468 protected function getLagFromSlaveStatus() {
469 $res = $this->query(
470 'SHOW SLAVE STATUS',
471 __METHOD__,
472 self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
473 );
474 $row = $res ? $res->fetchObject() : false;
475 // If the server is not replicating, there will be no row
476 if ( $row && strval( $row->Seconds_Behind_Master ) !== '' ) {
477 // https://mariadb.com/kb/en/delayed-replication/
478 // https://dev.mysql.com/doc/refman/5.6/en/replication-delayed.html
479 return intval( $row->Seconds_Behind_Master + ( $row->SQL_Remaining_Delay ?? 0 ) );
480 }
481
482 return false;
483 }
484
488 protected function getLagFromPtHeartbeat() {
490
491 $currentTrxInfo = $this->getRecordedTransactionLagStatus();
492 if ( $currentTrxInfo ) {
493 // There is an active transaction and the initial lag was already queried
494 $staleness = microtime( true ) - $currentTrxInfo['since'];
495 if ( $staleness > self::LAG_STALE_WARN_THRESHOLD ) {
496 // Avoid returning higher and higher lag value due to snapshot age
497 // given that the isolation level will typically be REPEATABLE-READ
498 $this->queryLogger->warning(
499 "Using cached lag value for {db_server} due to active transaction",
500 $this->getLogContext( [
501 'method' => __METHOD__,
502 'age' => $staleness,
503 'exception' => new RuntimeException()
504 ] )
505 );
506 }
507
508 return $currentTrxInfo['lag'];
509 }
510
511 if ( isset( $options['conds'] ) ) {
512 // Custom/explicit method: specify the server_id or use logical channel names.
513 // This works well for multi-datacenter setups with read-only "standby masters"
514 // in secondary datacenters that are used as replication sources. The `heartbeat`
515 // row for the primary server can be found without resorting to slow queries to
516 // fetch the server_id of the primary.
517 $conds = $options['conds'];
518 } else {
519 // Standard method: determine source server ID (works with stock pt-heartbeat).
520 // This assumes that the immediate source server is the primary server.
521 $sourceInfo = $this->getSourceServerInfo();
522 if ( !$sourceInfo ) {
523 $this->queryLogger->error(
524 "Unable to query primary of {db_server} for server ID",
525 $this->getLogContext( [
526 'method' => __METHOD__
527 ] )
528 );
529
530 return false; // could not get primary server ID
531 }
532
533 $conds = [ 'server_id' => $sourceInfo['serverId'] ];
534 }
535
536 $ago = $this->fetchSecondsSinceHeartbeat( $conds );
537 if ( $ago !== null ) {
538 return max( $ago, 0.0 );
539 }
540
541 $this->queryLogger->error(
542 "Unable to find pt-heartbeat row for {db_server}",
543 $this->getLogContext( [
544 'method' => __METHOD__
545 ] )
546 );
547
548 return false;
549 }
550
558 protected function getSourceServerInfo() {
559 $row = $this->getServerRoleStatus( 'SLAVE', __METHOD__ );
560 if ( $row ) {
561 // MariaDB uses Master_Server_Id; MySQL uses Source_Server_Id
562 // https://mariadb.com/kb/en/show-replica-status/
563 // https://dev.mysql.com/doc/refman/8.0/en/show-replica-status.html
564 $id = (int)( $row['Master_Server_Id'] ?? $row['Source_Server_Id'] ?? 0 );
565 } else {
566 $id = 0;
567 }
568
569 // Cache the ID if it was retrieved
570 return $id ? [ 'serverId' => $id, 'asOf' => time() ] : false;
571 }
572
578 protected function fetchSecondsSinceHeartbeat( array $conds ) {
579 $whereSQL = $this->makeList( $conds, self::LIST_AND );
580 // User mysql server time so that query time and trip time are not counted.
581 // Use ORDER BY for channel based queries since that field might not be UNIQUE.
582 $res = $this->query(
583 "SELECT TIMESTAMPDIFF(MICROSECOND,ts,UTC_TIMESTAMP(6)) AS us_ago " .
584 "FROM heartbeat.heartbeat WHERE $whereSQL ORDER BY ts DESC LIMIT 1",
585 __METHOD__,
586 self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
587 );
588 $row = $res ? $res->fetchObject() : false;
589
590 return $row ? ( $row->us_ago / 1e6 ) : null;
591 }
592
593 protected function getApproximateLagStatus() {
594 if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
595 // Disable caching since this is fast enough and we don't want
596 // to be *too* pessimistic by having both the cache TTL and the
597 // pt-heartbeat interval count as lag in getSessionLagStatus()
598 return parent::getApproximateLagStatus();
599 }
600
601 $key = $this->srvCache->makeGlobalKey( 'mysql-lag', $this->getServerName() );
602 $approxLag = $this->srvCache->get( $key );
603 if ( !$approxLag ) {
604 $approxLag = parent::getApproximateLagStatus();
605 $this->srvCache->set( $key, $approxLag, 1 );
606 }
607
608 return $approxLag;
609 }
610
611 public function primaryPosWait( DBPrimaryPos $pos, $timeout ) {
612 if ( !( $pos instanceof MySQLPrimaryPos ) ) {
613 throw new InvalidArgumentException( "Position not an instance of MySQLPrimaryPos" );
614 }
615
616 if ( $this->topologyRole === self::ROLE_STATIC_CLONE ) {
617 $this->queryLogger->debug(
618 "Bypassed replication wait; database has a static dataset",
619 $this->getLogContext( [ 'method' => __METHOD__, 'raw_pos' => $pos ] )
620 );
621
622 return 0; // this is a copy of a read-only dataset with no primary DB
623 } elseif ( $this->lastKnownReplicaPos && $this->lastKnownReplicaPos->hasReached( $pos ) ) {
624 $this->queryLogger->debug(
625 "Bypassed replication wait; replication known to have reached {raw_pos}",
626 $this->getLogContext( [ 'method' => __METHOD__, 'raw_pos' => $pos ] )
627 );
628
629 return 0; // already reached this point for sure
630 }
631
632 // Call doQuery() directly, to avoid opening a transaction if DBO_TRX is set
633 if ( $pos->getGTIDs() ) {
634 // Get the GTIDs from this replica server too see the domains (channels)
635 $refPos = $this->getReplicaPos();
636 if ( !$refPos ) {
637 $this->queryLogger->error(
638 "Could not get replication position on replica DB to compare to {raw_pos}",
639 $this->getLogContext( [ 'method' => __METHOD__, 'raw_pos' => $pos ] )
640 );
641
642 return -1; // this is the primary DB itself?
643 }
644 // GTIDs with domains (channels) that are active and are present on the replica
645 $gtidsWait = $pos::getRelevantActiveGTIDs( $pos, $refPos );
646 if ( !$gtidsWait ) {
647 $this->queryLogger->error(
648 "No active GTIDs in {raw_pos} share a domain with those in {current_pos}",
649 $this->getLogContext( [
650 'method' => __METHOD__,
651 'raw_pos' => $pos,
652 'current_pos' => $refPos
653 ] )
654 );
655
656 return -1; // $pos is from the wrong cluster?
657 }
658 // Wait on the GTID set
659 $gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) );
660 if ( strpos( $gtidArg, ':' ) !== false ) {
661 // MySQL GTIDs, e.g "source_id:transaction_id"
662 $sql = "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)";
663 } else {
664 // MariaDB GTIDs, e.g."domain:server:sequence"
665 $sql = "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)";
666 }
667 $waitPos = implode( ',', $gtidsWait );
668 } else {
669 // Wait on the binlog coordinates
670 $encFile = $this->addQuotes( $pos->getLogFile() );
671 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
672 $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] );
673 $sql = "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)";
674 $waitPos = $pos->__toString();
675 }
676
677 $start = microtime( true );
678 $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
679 $res = $this->query( $sql, __METHOD__, $flags );
680 $row = $res->fetchRow();
681 $seconds = max( microtime( true ) - $start, 0 );
682
683 // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
684 $status = ( $row[0] !== null ) ? intval( $row[0] ) : null;
685 if ( $status === null ) {
686 $this->replLogger->error(
687 "An error occurred while waiting for replication to reach {wait_pos}",
688 $this->getLogContext( [
689 'raw_pos' => $pos,
690 'wait_pos' => $waitPos,
691 'sql' => $sql,
692 'seconds_waited' => $seconds,
693 'exception' => new RuntimeException()
694 ] )
695 );
696 } elseif ( $status < 0 ) {
697 $this->replLogger->error(
698 "Timed out waiting for replication to reach {wait_pos}",
699 $this->getLogContext( [
700 'raw_pos' => $pos,
701 'wait_pos' => $waitPos,
702 'timeout' => $timeout,
703 'sql' => $sql,
704 'seconds_waited' => $seconds,
705 'exception' => new RuntimeException()
706 ] )
707 );
708 } elseif ( $status >= 0 ) {
709 $this->replLogger->debug(
710 "Replication has reached {wait_pos}",
711 $this->getLogContext( [
712 'raw_pos' => $pos,
713 'wait_pos' => $waitPos,
714 'seconds_waited' => $seconds,
715 ] )
716 );
717 // Remember that this position was reached to save queries next time
718 $this->lastKnownReplicaPos = $pos;
719 }
720
721 return $status;
722 }
723
729 public function getReplicaPos() {
730 $now = microtime( true ); // as-of-time *before* fetching GTID variables
731
732 if ( $this->useGTIDs() ) {
733 // Try to use GTIDs, fallbacking to binlog positions if not possible
734 $data = $this->getServerGTIDs( __METHOD__ );
735 // Use gtid_slave_pos for MariaDB and gtid_executed for MySQL
736 foreach ( [ 'gtid_slave_pos', 'gtid_executed' ] as $name ) {
737 if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
738 return new MySQLPrimaryPos( $data[$name], $now );
739 }
740 }
741 }
742
743 $data = $this->getServerRoleStatus( 'SLAVE', __METHOD__ );
744 if ( $data && strlen( $data['Relay_Master_Log_File'] ) ) {
745 return new MySQLPrimaryPos(
746 "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}",
747 $now
748 );
749 }
750
751 return false;
752 }
753
759 public function getPrimaryPos() {
760 $now = microtime( true ); // as-of-time *before* fetching GTID variables
761
762 $pos = false;
763 if ( $this->useGTIDs() ) {
764 // Try to use GTIDs, fallbacking to binlog positions if not possible
765 $data = $this->getServerGTIDs( __METHOD__ );
766 // Use gtid_binlog_pos for MariaDB and gtid_executed for MySQL
767 foreach ( [ 'gtid_binlog_pos', 'gtid_executed' ] as $name ) {
768 if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
769 $pos = new MySQLPrimaryPos( $data[$name], $now );
770 break;
771 }
772 }
773 // Filter domains that are inactive or not relevant to the session
774 if ( $pos ) {
775 $pos->setActiveOriginServerId( $this->getServerId() );
776 $pos->setActiveOriginServerUUID( $this->getServerUUID() );
777 if ( isset( $data['gtid_domain_id'] ) ) {
778 $pos->setActiveDomain( $data['gtid_domain_id'] );
779 }
780 }
781 }
782
783 if ( !$pos ) {
784 $data = $this->getServerRoleStatus( 'MASTER', __METHOD__ );
785 if ( $data && strlen( $data['File'] ) ) {
786 $pos = new MySQLPrimaryPos( "{$data['File']}/{$data['Position']}", $now );
787 }
788 }
789
790 return $pos;
791 }
792
797 public function getTopologyBasedServerId() {
798 return $this->getServerId();
799 }
800
805 protected function getServerId() {
806 $fname = __METHOD__;
807 return $this->srvCache->getWithSetCallback(
808 $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServerName() ),
809 self::SERVER_ID_CACHE_TTL,
810 function () use ( $fname ) {
811 $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
812 $res = $this->query( "SELECT @@server_id AS id", $fname, $flags );
813
814 return $res->fetchObject()->id;
815 }
816 );
817 }
818
823 protected function getServerUUID() {
824 $fname = __METHOD__;
825 return $this->srvCache->getWithSetCallback(
826 $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServerName() ),
827 self::SERVER_ID_CACHE_TTL,
828 function () use ( $fname ) {
829 $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
830 $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'", $fname, $flags );
831 $row = $res->fetchObject();
832
833 return $row ? $row->Value : null;
834 }
835 );
836 }
837
842 protected function getServerGTIDs( $fname = __METHOD__ ) {
843 $map = [];
844
845 $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
846
847 // Get global-only variables like gtid_executed
848 $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname, $flags );
849 foreach ( $res as $row ) {
850 $map[$row->Variable_name] = $row->Value;
851 }
852 // Get session-specific (e.g. gtid_domain_id since that is were writes will log)
853 $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname, $flags );
854 foreach ( $res as $row ) {
855 $map[$row->Variable_name] = $row->Value;
856 }
857
858 return $map;
859 }
860
866 protected function getServerRoleStatus( $role, $fname = __METHOD__ ) {
867 $flags = self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
868 $res = $this->query( "SHOW $role STATUS", $fname, $flags );
869 $row = $res ? $res->fetchRow() : false;
870
871 return ( $row ?: null );
872 }
873
874 public function serverIsReadOnly() {
875 // Avoid SHOW to avoid internal temporary tables
876 $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
877 $res = $this->query( "SELECT @@GLOBAL.read_only AS Value", __METHOD__, $flags );
878 $row = $res->fetchObject();
879
880 return $row ? (bool)$row->Value : false;
881 }
882
886 public function getSoftwareLink() {
887 list( $variant ) = $this->getMySqlServerVariant();
888 if ( $variant === 'MariaDB' ) {
889 return '[{{int:version-db-mariadb-url}} MariaDB]';
890 }
891
892 return '[{{int:version-db-mysql-url}} MySQL]';
893 }
894
898 protected function getMySqlServerVariant() {
899 $version = $this->getServerVersion();
900
901 // MariaDB includes its name in its version string; this is how MariaDB's version of
902 // the mysql command-line client identifies MariaDB servers.
903 // https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_version
904 // https://mariadb.com/kb/en/version/
905 $parts = explode( '-', $version, 2 );
906 $number = $parts[0];
907 $suffix = $parts[1] ?? '';
908 if ( strpos( $suffix, 'MariaDB' ) !== false || strpos( $suffix, '-maria-' ) !== false ) {
909 $vendor = 'MariaDB';
910 } else {
911 $vendor = 'MySQL';
912 }
913
914 return [ $vendor, $number ];
915 }
916
920 public function getServerVersion() {
922 $fname = __METHOD__;
923
925 $cache->makeGlobalKey( 'mysql-server-version', $this->getServerName() ),
926 $cache::TTL_HOUR,
927 function () use ( $fname ) {
928 // Not using mysql_get_server_info() or similar for consistency: in the handshake,
929 // MariaDB 10 adds the prefix "5.5.5-", and only some newer client libraries strip
930 // it off (see RPL_VERSION_HACK in include/mysql_com.h).
931 return $this->selectField( '', 'VERSION()', '', $fname );
932 }
933 );
934 }
935
939 public function setSessionOptions( array $options ) {
940 $sqlAssignments = [];
941
942 if ( isset( $options['connTimeout'] ) ) {
943 $encTimeout = (int)$options['connTimeout'];
944 $sqlAssignments[] = "net_read_timeout=$encTimeout";
945 $sqlAssignments[] = "net_write_timeout=$encTimeout";
946 }
947
948 if ( $sqlAssignments ) {
949 $this->query(
950 'SET ' . implode( ', ', $sqlAssignments ),
951 __METHOD__,
952 self::QUERY_CHANGE_TRX | self::QUERY_CHANGE_NONE
953 );
954 }
955 }
956
962 public function streamStatementEnd( &$sql, &$newLine ) {
963 if ( preg_match( '/^DELIMITER\s+(\S+)/i', $newLine, $m ) ) {
964 $this->delimiter = $m[1];
965 $newLine = '';
966 }
967
968 return parent::streamStatementEnd( $sql, $newLine );
969 }
970
971 public function doLockIsFree( string $lockName, string $method ) {
972 $res = $this->query(
973 $this->platform->lockIsFreeSQLText( $lockName ),
974 $method,
975 self::QUERY_CHANGE_LOCKS
976 );
977 $row = $res->fetchObject();
978
979 return ( $row->unlocked == 1 );
980 }
981
982 public function doLock( string $lockName, string $method, int $timeout ) {
983 $res = $this->query(
984 $this->platform->lockSQLText( $lockName, $timeout ),
985 $method,
986 self::QUERY_CHANGE_LOCKS
987 );
988 $row = $res->fetchObject();
989
990 return ( $row->acquired !== null ) ? (float)$row->acquired : null;
991 }
992
993 public function doUnlock( string $lockName, string $method ) {
994 $res = $this->query(
995 $this->platform->unlockSQLText( $lockName ),
996 $method,
997 self::QUERY_CHANGE_LOCKS
998 );
999 $row = $res->fetchObject();
1000
1001 return ( $row->released == 1 );
1002 }
1003
1004 public function namedLocksEnqueue() {
1005 return true;
1006 }
1007
1008 protected function doFlushSession( $fname ) {
1009 $flags = self::QUERY_CHANGE_LOCKS | self::QUERY_NO_RETRY;
1010 // Note that RELEASE_ALL_LOCKS() is not supported well enough to use here.
1011 // https://mariadb.com/kb/en/release_all_locks/
1012 $releaseLockFields = [];
1013 foreach ( $this->sessionNamedLocks as $name => $info ) {
1014 $encName = $this->addQuotes( $this->platform->makeLockName( $name ) );
1015 $releaseLockFields[] = "RELEASE_LOCK($encName)";
1016 }
1017 if ( $releaseLockFields ) {
1018 $sql = 'SELECT ' . implode( ',', $releaseLockFields );
1019 $qs = $this->executeQuery( $sql, __METHOD__, $flags, $sql );
1020 if ( $qs->res === false ) {
1021 $this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true );
1022 }
1023 }
1024 }
1025
1029 public function setBigSelects( $value = true ) {
1030 if ( $value === 'default' ) {
1031 if ( $this->defaultBigSelects === null ) {
1032 # Function hasn't been called before so it must already be set to the default
1033 return;
1034 } else {
1035 $value = $this->defaultBigSelects;
1036 }
1037 } elseif ( $this->defaultBigSelects === null ) {
1038 $this->defaultBigSelects =
1039 (bool)$this->selectField( false, '@@sql_big_selects', '', __METHOD__ );
1040 }
1041
1042 $this->query(
1043 "SET sql_big_selects=" . ( $value ? '1' : '0' ),
1044 __METHOD__,
1045 self::QUERY_CHANGE_TRX
1046 );
1047 }
1048
1049 protected function doUpsert(
1050 string $table,
1051 array $rows,
1052 array $identityKey,
1053 array $set,
1054 string $fname
1055 ) {
1056 $encTable = $this->tableName( $table );
1057 list( $sqlColumns, $sqlTuples ) = $this->platform->makeInsertLists( $rows );
1058 $sqlColumnAssignments = $this->makeList( $set, self::LIST_SET );
1059 // No need to expose __NEW.* since buildExcludedValue() uses VALUES(column)
1060
1061 // https://mariadb.com/kb/en/insert-on-duplicate-key-update/
1062 // https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html
1063 $sql =
1064 "INSERT INTO $encTable " .
1065 "($sqlColumns) VALUES $sqlTuples " .
1066 "ON DUPLICATE KEY UPDATE $sqlColumnAssignments";
1067
1068 $this->query( $sql, $fname, self::QUERY_CHANGE_ROWS );
1069 }
1070
1071 protected function doReplace( $table, array $identityKey, array $rows, $fname ) {
1072 $encTable = $this->tableName( $table );
1073 list( $sqlColumns, $sqlTuples ) = $this->platform->makeInsertLists( $rows );
1074
1075 $sql = "REPLACE INTO $encTable ($sqlColumns) VALUES $sqlTuples";
1076
1077 $this->query( $sql, $fname, self::QUERY_CHANGE_ROWS );
1078 }
1079
1085 public function wasDeadlock() {
1086 return $this->lastErrno() == 1213;
1087 }
1088
1094 public function wasLockTimeout() {
1095 return $this->lastErrno() == 1205;
1096 }
1097
1103 public function wasReadOnlyError() {
1104 return $this->lastErrno() == 1223 ||
1105 ( $this->lastErrno() == 1290 && strpos( $this->lastError(), '--read-only' ) !== false );
1106 }
1107
1108 protected function isConnectionError( $errno ) {
1109 // https://mariadb.com/kb/en/mariadb-error-codes/
1110 // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html
1111 // https://dev.mysql.com/doc/mysql-errors/8.0/en/client-error-reference.html
1112 return in_array( $errno, [ 2013, 2006, 2003, 1927, 1053 ], true );
1113 }
1114
1115 protected function isQueryTimeoutError( $errno ) {
1116 // https://mariadb.com/kb/en/mariadb-error-codes/
1117 // https://dev.mysql.com/doc/refman/8.0/en/client-error-reference.html
1118 // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html
1119 return in_array( $errno, [ 3024, 2062, 1969, 1028 ], true );
1120 }
1121
1122 protected function isKnownStatementRollbackError( $errno ) {
1123 // https://mariadb.com/kb/en/mariadb-error-codes/
1124 // https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html
1125 if ( $errno === 1205 ) { // lock wait timeout
1126 // Note that this is uncached to avoid stale values if SET is used
1127 $res = $this->query(
1128 "SELECT @@innodb_rollback_on_timeout AS Value",
1129 __METHOD__,
1130 self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
1131 );
1132 $row = $res ? $res->fetchObject() : false;
1133 // https://dev.mysql.com/doc/refman/5.7/en/innodb-error-handling.html
1134 // https://dev.mysql.com/doc/refman/5.5/en/innodb-parameters.html
1135 return ( $row && !$row->Value );
1136 }
1137
1138 return in_array(
1139 $errno,
1140 [ 3024, 1969, 1022, 1062, 1216, 1217, 1137, 1146, 1051, 1054 ],
1141 true
1142 );
1143 }
1144
1153 $oldName, $newName, $temporary = false, $fname = __METHOD__
1154 ) {
1155 $tmp = $temporary ? 'TEMPORARY ' : '';
1156 $newName = $this->addIdentifierQuotes( $newName );
1157 $oldName = $this->addIdentifierQuotes( $oldName );
1158
1159 return $this->query(
1160 "CREATE $tmp TABLE $newName (LIKE $oldName)",
1161 $fname,
1162 self::QUERY_PSEUDO_PERMANENT | self::QUERY_CHANGE_SCHEMA
1163 );
1164 }
1165
1173 public function listTables( $prefix = null, $fname = __METHOD__ ) {
1174 $result = $this->query(
1175 "SHOW TABLES",
1176 $fname,
1177 self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
1178 );
1179
1180 $endArray = [];
1181
1182 foreach ( $result as $table ) {
1183 $vars = get_object_vars( $table );
1184 $table = array_pop( $vars );
1185
1186 if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
1187 $endArray[] = $table;
1188 }
1189 }
1190
1191 return $endArray;
1192 }
1193
1203 public function listViews( $prefix = null, $fname = __METHOD__ ) {
1204 // The name of the column containing the name of the VIEW
1205 $propertyName = 'Tables_in_' . $this->getDBname();
1206
1207 // Query for the VIEWS
1208 $res = $this->query(
1209 'SHOW FULL TABLES WHERE TABLE_TYPE = "VIEW"',
1210 $fname,
1211 self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE
1212 );
1213
1214 $allViews = [];
1215 foreach ( $res as $row ) {
1216 array_push( $allViews, $row->$propertyName );
1217 }
1218
1219 if ( $prefix === null || $prefix === '' ) {
1220 return $allViews;
1221 }
1222
1223 $filteredViews = [];
1224 foreach ( $allViews as $viewName ) {
1225 // Does the name of this VIEW start with the table-prefix?
1226 if ( strpos( $viewName, $prefix ) === 0 ) {
1227 array_push( $filteredViews, $viewName );
1228 }
1229 }
1230
1231 return $filteredViews;
1232 }
1233
1242 public function isView( $name, $prefix = null ) {
1243 return in_array( $name, $this->listViews( $prefix, __METHOD__ ) );
1244 }
1245
1246 public function selectSQLText(
1247 $table,
1248 $vars,
1249 $conds = '',
1250 $fname = __METHOD__,
1251 $options = [],
1252 $join_conds = []
1253 ) {
1254 $sql = parent::selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
1255 // https://dev.mysql.com/doc/refman/5.7/en/optimizer-hints.html
1256 // https://mariadb.com/kb/en/library/aborting-statements/
1257 $timeoutMsec = intval( $options['MAX_EXECUTION_TIME'] ?? 0 );
1258 if ( $timeoutMsec > 0 ) {
1259 list( $vendor, $number ) = $this->getMySqlServerVariant();
1260 if ( $vendor === 'MariaDB' && version_compare( $number, '10.1.2', '>=' ) ) {
1261 $timeoutSec = $timeoutMsec / 1000;
1262 $sql = "SET STATEMENT max_statement_time=$timeoutSec FOR $sql";
1263 } elseif ( $vendor === 'MySQL' && version_compare( $number, '5.7.0', '>=' ) ) {
1264 $sql = preg_replace(
1265 '/^SELECT(?=\s)/',
1266 "SELECT /*+ MAX_EXECUTION_TIME($timeoutMsec)*/",
1267 $sql
1268 );
1269 }
1270 }
1271
1272 return $sql;
1273 }
1274
1278 protected function useGTIDs() {
1279 return $this->useGTIDs;
1280 }
1281}
1282
1286class_alias( DatabaseMysqlBase::class, 'DatabaseMysqlBase' );
getWithSetCallback( $key, $exptime, $callback, $flags=0)
Get an item, regenerating and setting it if not found.
Base class for the more common types of database errors.
Class to handle database/schema/prefix specifications for IDatabase.
MySQL database abstraction layer.
doSelectDomain(DatabaseDomain $domain)
__construct(array $params)
Additional $params include:
string null $sslCiphers
Open SSL cipher list string.
duplicateTableStructure( $oldName, $newName, $temporary=false, $fname=__METHOD__)
doLock(string $lockName, string $method, int $timeout)
namedLocksEnqueue()
Check to see if a named lock used by lock() use blocking queues.bool 1.26to override
array $lagDetectionOptions
Method to detect replica DB lag.
isInsertSelectSafe(array $insertOptions, array $selectOptions)
doUnlock(string $lockName, string $method)
wasReadOnlyError()
Determines if the last failure was due to the database being read-only.
string $lagDetectionMethod
Method to detect replica DB lag.
getApproximateLagStatus()
Get a replica DB lag estimate for this server at the start of a transaction.
doUpsert(string $table, array $rows, array $identityKey, array $set, string $fname)
Perform an UPSERT query.
isConnectionError( $errno)
Do not use this method outside of Database/DBError classes.
doFlushSession( $fname)
Reset the server-side session state for named locks and table locks.
serverIsReadOnly()
bool Whether the DB is marked as read-only server-side query} 1.28to override
indexInfo( $table, $index, $fname=__METHOD__)
Get information about an index into an object Returns false if the index does not exist.
selectSQLText( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Take the same arguments as IDatabase::select() and return the SQL it would use.
mysqlError( $conn=null)
Returns the text of the error message from previous MySQL operation.
open( $server, $user, $password, $db, $schema, $tablePrefix)
Open a new connection to the database (closing any existing one)
getSourceServerInfo()
Get information about the direct replication source server for this replica server.
bool $utf8Mode
Use experimental UTF-8 transmission encoding.
doLockIsFree(string $lockName, string $method)
listTables( $prefix=null, $fname=__METHOD__)
List all tables on the database.
bool $useGTIDs
bool Whether to use GTID methods
mysqlRealEscapeString( $s)
Escape special characters in a string for use in an SQL statement.
getServerRoleStatus( $role, $fname=__METHOD__)
getTopologyBasedServerId()
Get a non-recycled ID that uniquely identifies this server within the replication topology....
wasLockTimeout()
Determines if the last failure was due to a lock timeout.
doReplace( $table, array $identityKey, array $rows, $fname)
getPrimaryPos()
Get the position of the primary DB from SHOW MASTER STATUS.
primaryPosWait(DBPrimaryPos $pos, $timeout)
Wait for the replica DB to catch up to a given primary DB position.Note that this does not start any ...
estimateRowCount( $tables, $var=' *', $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Estimate rows in dataset Returns estimated count, based on EXPLAIN output Takes same arguments as Dat...
mysqlConnect( $server, $user, $password, $db)
Open a connection to a MySQL server.
isQueryTimeoutError( $errno)
Checks whether the cause of the error is detected to be a timeout.
getReplicaPos()
Get the position of the primary DB from SHOW SLAVE STATUS.
listViews( $prefix=null, $fname=__METHOD__)
Lists VIEWs in the database.
isView( $name, $prefix=null)
Differentiates between a TABLE and a VIEW.
tableExists( $table, $fname=__METHOD__)
Query whether a given table exists.
wasDeadlock()
Determines if the last failure was due to a deadlock.
doGetLag()
Get the amount of replication lag for this database server.
Relational database abstraction object.
Definition Database.php:43
string null $password
Password used to establish the current connection.
Definition Database.php:78
selectRow( $table, $vars, $conds, $fname=__METHOD__, $options=[], $join_conds=[])
Wrapper to IDatabase::select() that only fetches one row (via LIMIT)
restoreErrorHandler()
Restore the previous error handler and return the last PHP error for this DB.
Definition Database.php:628
object resource null $conn
Database connection.
Definition Database.php:68
addQuotes( $s)
Escape and quote a raw value string for use in a SQL query.string
newExceptionAfterConnectError( $error)
select( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Execute a SELECT query constructed using the various parameters provided.
getRecordedTransactionLagStatus()
Get the replica DB lag when the current transaction started.
int $flags
Current bit field of class DBO_* constants.
Definition Database.php:95
installErrorHandler()
Set a custom error handler for logging errors during database connection.
Definition Database.php:617
tableName( $name, $format='quoted')
Format a table name ready for use in constructing an SQL query.
addIdentifierQuotes( $s)
Escape a SQL identifier (e.g.
getLogContext(array $extras=[])
Create a log context to pass to PSR-3 logger functions.
Definition Database.php:669
string null $server
Server that this instance is currently connected to.
Definition Database.php:74
close( $fname=__METHOD__)
Close the database connection.
Definition Database.php:680
getServerName()
Get the readable name for the server.
reportQueryError( $error, $errno, $sql, $fname, $ignore=false)
Report a query error.
string null $user
User that this instance is currently connected under the name of.
Definition Database.php:76
makeList(array $a, $mode=self::LIST_COMMA)
Makes an encoded list of strings from an array.
BagOStuff $srvCache
APC cache.
Definition Database.php:45
query( $sql, $fname=__METHOD__, $flags=0)
Run an SQL query statement and return the result.
Definition Database.php:932
executeQuery( $sqls, $fname, $flags, $summarySql)
Execute a set of queries without enforcing public (non-Database) caller restrictions.
getDBname()
Get the current database name; null if there isn't one.
selectField( $table, $var, $cond='', $fname=__METHOD__, $options=[], $join_conds=[])
A SELECT wrapper which returns a single field from a single result row.
DBPrimaryPos implementation for MySQL and MariaDB.
An object representing a primary or replica DB position in a replicated setup.
lastErrno()
Get the RDBMS-specific error code from the last query statement.
Interface for query language.
$cache
Definition mcc.php:33
foreach( $mmfl['setupFiles'] as $fileName) if($queue) if(empty( $mmfl['quiet'])) $s