27 use Wikimedia\AtEase\AtEase;
28 use InvalidArgumentException;
76 const LAG_STALE_WARN_THRESHOLD = 0.100;
98 $this->lagDetectionMethod = $params[
'lagDetectionMethod'] ??
'Seconds_Behind_Master';
99 $this->lagDetectionOptions = $params[
'lagDetectionOptions'] ?? [];
100 $this->
useGTIDs = !empty( $params[
'useGTIDs' ] );
101 foreach ( [
'KeyPath',
'CertPath',
'CAFile',
'CAPath',
'Ciphers' ] as $name ) {
103 if ( isset( $params[$var] ) ) {
104 $this->$var = $params[$var];
107 $this->sqlMode = $params[
'sqlMode'] ??
null;
108 $this->utf8Mode = !empty( $params[
'utf8Mode'] );
109 $this->insertSelectIsSafe = isset( $params[
'insertSelectIsSafe'] )
110 ? (bool)$params[
'insertSelectIsSafe'] :
null;
112 parent::__construct( $params );
125 if ( $schema !==
null ) {
135 $this->conn = $this->
mysqlConnect( $this->server, $dbName );
136 }
catch ( Exception $e ) {
142 if ( !$this->conn ) {
148 strlen( $dbName ) ? $dbName :
null,
153 $set = [
'group_concat_max_len = 262144' ];
155 if ( is_string( $this->sqlMode ) ) {
156 $set[] =
'sql_mode = ' . $this->
addQuotes( $this->sqlMode );
160 foreach ( $this->connectionVariables as $var => $val ) {
162 if ( !is_int( $val ) && !is_float( $val ) ) {
170 'SET ' . implode(
', ', $set ),
172 self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY
175 }
catch ( Exception $e ) {
184 __CLASS__ .
": domain '{$domain->getId()}' has a schema component"
190 if ( $database ===
null ) {
192 $this->currentDomain->getDatabase(),
200 if ( $database !== $this->
getDBname() ) {
202 list(
$res, $err, $errno ) =
203 $this->
executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX );
205 if (
$res ===
false ) {
212 $this->currentDomain = $domain;
225 abstract protected function mysqlConnect( $realServer, $dbName );
232 AtEase::suppressWarnings();
234 AtEase::restoreWarnings();
254 AtEase::suppressWarnings();
256 AtEase::restoreWarnings();
263 if ( $errno == 2000 || $errno == 2013 ) {
266 'Error in fetchObject(): ' . htmlspecialchars( $this->
lastError() )
287 AtEase::suppressWarnings();
289 AtEase::restoreWarnings();
296 if ( $errno == 2000 || $errno == 2013 ) {
299 'Error in fetchRow(): ' . htmlspecialchars( $this->
lastError() )
320 if ( is_bool(
$res ) ) {
323 AtEase::suppressWarnings();
325 AtEase::restoreWarnings();
420 # Even if it's non-zero, it can still be invalid
421 AtEase::suppressWarnings();
426 AtEase::restoreWarnings();
431 $error .=
' (' . $this->server .
')';
448 return in_array( $errno, [ 2062, 3024 ] );
451 public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
458 if ( $row->binlog_format ===
'ROW' ) {
462 if ( isset( $selectOptions[
'LIMIT'] ) ) {
473 in_array(
'NO_AUTO_COLUMNS', $insertOptions ) ||
474 (
int)$row->innodb_autoinc_lock_mode === 0
482 if ( $this->replicationInfoRow ===
null ) {
483 $this->replicationInfoRow = $this->
selectRow(
486 'innodb_autoinc_lock_mode' =>
'@@innodb_autoinc_lock_mode',
487 'binlog_format' =>
'@@binlog_format',
511 $fname = __METHOD__, $options = [], $join_conds = []
515 if ( is_string( $column ) && !in_array( $column, [
'*',
'1' ] ) ) {
516 $conds[] =
"$column IS NOT NULL";
519 $options[
'EXPLAIN'] =
true;
520 $res = $this->
select( $table, $var, $conds, $fname, $options, $join_conds );
521 if (
$res ===
false ) {
529 foreach (
$res as $plan ) {
530 $rows *= $plan->rows > 0 ? $plan->rows : 1;
540 $tableName =
"{$prefix}{$table}";
542 if ( isset( $this->sessionTempTables[$tableName] ) ) {
551 if ( $database !==
'' ) {
553 $query =
"SHOW TABLES FROM $encDatabase LIKE '$encLike'";
555 $query =
"SHOW TABLES LIKE '$encLike'";
558 return $this->
query( $query, $fname )->numRows() > 0;
568 $flags = self::QUERY_SILENCE_ERRORS;
569 $res = $this->
query(
"SELECT * FROM $table LIMIT 1", __METHOD__,
$flags );
574 for ( $i = 0; $i < $n; $i++ ) {
576 if ( $field == $meta->name ) {
602 public function indexInfo( $table, $index, $fname = __METHOD__ ) {
603 # SHOW INDEX works in MySQL 3.23.58, but SHOW INDEXES does not.
604 # SHOW INDEX should work for 3.x and up:
605 # https://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html
609 $sql =
'SHOW INDEX FROM ' . $table;
618 foreach (
$res as $row ) {
619 if ( $row->Key_name == $index ) {
624 return $result ?:
false;
642 if ( is_bool(
$s ) ) {
646 $s = (string)(
int)
$s;
648 return parent::addQuotes(
$s );
660 return '`' . str_replace( [
"\0",
'`' ], [
'',
'``' ],
$s ) .
'`';
668 return strlen( $name ) && $name[0] ==
'`' && substr( $name, -1, 1 ) ==
'`';
690 $flags = self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX;
692 $row =
$res ?
$res->fetchObject() :
false;
694 if ( $row && strval( $row->Seconds_Behind_Master ) !==
'' ) {
695 return intval( $row->Seconds_Behind_Master );
708 if ( $currentTrxInfo ) {
710 $staleness = microtime(
true ) - $currentTrxInfo[
'since'];
711 if ( $staleness > self::LAG_STALE_WARN_THRESHOLD ) {
714 $this->queryLogger->warning(
715 "Using cached lag value for {db_server} due to active transaction",
717 'method' => __METHOD__,
719 'exception' =>
new RuntimeException()
724 return $currentTrxInfo[
'lag'];
727 if ( isset( $options[
'conds'] ) ) {
733 if ( !$masterInfo ) {
734 $this->queryLogger->error(
735 "Unable to query master of {db_server} for server ID",
737 'method' => __METHOD__
744 $conds = [
'server_id' => intval( $masterInfo[
'serverId'] ) ];
748 list( $time, $nowUnix ) = $data;
749 if ( $time !==
null ) {
751 $dateTime =
new DateTime( $time,
new DateTimeZone(
'UTC' ) );
752 $timeUnix = (int)$dateTime->format(
'U' ) + $dateTime->format(
'u' ) / 1e6;
754 return max( $nowUnix - $timeUnix, 0.0 );
757 $this->queryLogger->error(
758 "Unable to find pt-heartbeat row for {db_server}",
760 'method' => __METHOD__
769 $key =
$cache->makeGlobalKey(
777 return $cache->getWithSetCallback(
779 $cache::TTL_INDEFINITE,
780 function () use (
$cache, $key, $fname ) {
782 if ( !
$cache->lock( $key, 0, 10 ) ) {
793 $flags = self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX;
795 $row =
$res ?
$res->fetchObject() :
false;
796 $id = $row ? (int)$row->id : 0;
802 return $id ? [
'serverId' => $id,
'asOf' => time() ] :
false;
814 $nowUnix = microtime(
true );
820 "SELECT ts FROM heartbeat.heartbeat WHERE $whereSQL ORDER BY ts DESC LIMIT 1",
822 self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX
824 $row =
$res ?
$res->fetchObject() :
false;
826 return [ $row ? $row->ts :
null, $nowUnix ];
834 return parent::getApproximateLagStatus();
837 $key = $this->srvCache->makeGlobalKey(
'mysql-lag', $this->
getServer() );
838 $approxLag = $this->srvCache->get( $key );
840 $approxLag = parent::getApproximateLagStatus();
841 $this->srvCache->set( $key, $approxLag, 1 );
849 throw new InvalidArgumentException(
"Position not an instance of MySQLMasterPos" );
852 if ( $this->
getLBInfo(
'is static' ) ===
true ) {
853 $this->queryLogger->debug(
854 "Bypassed replication wait; database has a static dataset",
859 } elseif ( $this->lastKnownReplicaPos && $this->lastKnownReplicaPos->hasReached( $pos ) ) {
860 $this->queryLogger->debug(
861 "Bypassed replication wait; replication already known to have reached $pos",
869 if ( $pos->getGTIDs() ) {
873 $this->queryLogger->error(
874 "Could not get replication position",
881 $gtidsWait = $pos::getRelevantActiveGTIDs( $pos, $refPos );
883 $this->queryLogger->error(
884 "No active GTIDs in $pos share a domain with those in $refPos",
885 $this->
getLogContext( [
'method' => __METHOD__,
'activeDomain' => $pos ] )
891 $gtidArg = $this->
addQuotes( implode(
',', $gtidsWait ) );
892 if ( strpos( $gtidArg,
':' ) !==
false ) {
894 $sql =
"SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)";
897 $sql =
"SELECT MASTER_GTID_WAIT($gtidArg, $timeout)";
901 $encFile = $this->
addQuotes( $pos->getLogFile() );
902 $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] );
903 $sql =
"SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)";
906 $res = $this->
query( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX );
910 $status = ( $row[0] !== null ) ? intval( $row[0] ) :
null;
912 $this->queryLogger->error(
913 "An error occurred while waiting for replication to reach $pos",
914 $this->
getLogContext( [
'method' => __METHOD__,
'sql' => $sql ] )
917 $this->queryLogger->error(
918 "Timed out waiting for replication to reach $pos",
920 'method' => __METHOD__,
'sql' => $sql,
'timeout' => $timeout
924 $this->queryLogger->debug(
925 "Replication has reached $pos",
929 $this->lastKnownReplicaPos = $pos;
941 $now = microtime(
true );
947 foreach ( [
'gtid_slave_pos',
'gtid_executed' ] as $name ) {
948 if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
955 if ( $data && strlen( $data[
'Relay_Master_Log_File'] ) ) {
957 "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}",
971 $now = microtime(
true );
978 foreach ( [
'gtid_binlog_pos',
'gtid_executed' ] as $name ) {
979 if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
986 $pos->setActiveOriginServerId( $this->
getServerId() );
988 if ( isset( $data[
'gtid_domain_id'] ) ) {
989 $pos->setActiveDomain( $data[
'gtid_domain_id'] );
996 if ( $data && strlen( $data[
'File'] ) ) {
997 $pos =
new MySQLMasterPos(
"{$data['File']}/{$data['Position']}", $now );
1009 $fname = __METHOD__;
1010 return $this->srvCache->getWithSetCallback(
1011 $this->srvCache->makeGlobalKey(
'mysql-server-id', $this->getServer() ),
1012 self::SERVER_ID_CACHE_TTL,
1013 function () use ( $fname ) {
1014 $flags = self::QUERY_IGNORE_DBO_TRX;
1026 $fname = __METHOD__;
1027 return $this->srvCache->getWithSetCallback(
1028 $this->srvCache->makeGlobalKey(
'mysql-server-uuid', $this->getServer() ),
1029 self::SERVER_ID_CACHE_TTL,
1030 function () use ( $fname ) {
1031 $flags = self::QUERY_IGNORE_DBO_TRX;
1032 $res = $this->
query(
"SHOW GLOBAL VARIABLES LIKE 'server_uuid'", $fname,
$flags );
1035 return $row ? $row->Value :
null;
1047 $flags = self::QUERY_IGNORE_DBO_TRX;
1049 $res = $this->
query(
"SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname,
$flags );
1050 foreach (
$res as $row ) {
1051 $map[$row->Variable_name] = $row->Value;
1054 $res = $this->
query(
"SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname,
$flags );
1055 foreach (
$res as $row ) {
1056 $map[$row->Variable_name] = $row->Value;
1068 $flags = self::QUERY_IGNORE_DBO_TRX;
1070 return $this->
query(
"SHOW $role STATUS", $fname,
$flags )->fetchRow() ?: [];
1075 $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_SILENCE_ERRORS;
1076 $res = $this->
query(
"SELECT @@GLOBAL.read_only AS Value", __METHOD__,
$flags );
1079 return $row ? (bool)$row->Value :
false;
1087 return "FORCE INDEX (" . $this->
indexName( $index ) .
")";
1095 return "IGNORE INDEX (" . $this->
indexName( $index ) .
")";
1106 if ( strpos( $version,
'MariaDB' ) !==
false || strpos( $version,
'-maria-' ) !==
false ) {
1107 return '[{{int:version-db-mariadb-url}} MariaDB]';
1113 return '[{{int:version-db-mysql-url}} MySQL]';
1121 $fname = __METHOD__;
1123 return $cache->getWithSetCallback(
1124 $cache->makeGlobalKey(
'mysql-server-version', $this->getServer() ),
1126 function () use ( $fname ) {
1130 return $this->
selectField(
'',
'VERSION()',
'', $fname );
1139 if ( isset( $options[
'connTimeout'] ) ) {
1140 $flags = self::QUERY_IGNORE_DBO_TRX;
1141 $timeout = (int)$options[
'connTimeout'];
1142 $this->
query(
"SET net_read_timeout=$timeout", __METHOD__,
$flags );
1143 $this->
query(
"SET net_write_timeout=$timeout", __METHOD__,
$flags );
1153 if ( strtoupper( substr( $newLine, 0, 9 ) ) ==
'DELIMITER' ) {
1154 preg_match(
'/^DELIMITER\s+(\S+)/', $newLine, $m );
1155 $this->delimiter = $m[1];
1159 return parent::streamStatementEnd( $sql, $newLine );
1171 if ( !parent::lockIsFree( $lockName, $method ) ) {
1177 $flags = self::QUERY_IGNORE_DBO_TRX;
1178 $res = $this->
query(
"SELECT IS_FREE_LOCK($encName) AS lockstatus", $method,
$flags );
1181 return ( $row->lockstatus == 1 );
1190 public function lock( $lockName, $method, $timeout = 5 ) {
1193 $flags = self::QUERY_IGNORE_DBO_TRX;
1194 $res = $this->
query(
"SELECT GET_LOCK($encName, $timeout) AS lockstatus", $method,
$flags );
1197 if ( $row->lockstatus == 1 ) {
1198 parent::lock( $lockName, $method, $timeout );
1202 $this->queryLogger->info( __METHOD__ .
" failed to acquire lock '{lockname}'",
1203 [
'lockname' => $lockName ] );
1215 public function unlock( $lockName, $method ) {
1218 $flags = self::QUERY_IGNORE_DBO_TRX;
1219 $res = $this->
query(
"SELECT RELEASE_LOCK($encName) as lockstatus", $method,
$flags );
1222 if ( $row->lockstatus == 1 ) {
1223 parent::unlock( $lockName, $method );
1227 $this->queryLogger->warning( __METHOD__ .
" failed to release lock '$lockName'\n" );
1235 return ( strlen( $lockName ) > 64 ) ? sha1( $lockName ) : $lockName;
1248 foreach ( $write as $table ) {
1249 $items[] = $this->
tableName( $table ) .
' WRITE';
1251 foreach ( $read as $table ) {
1252 $items[] = $this->
tableName( $table ) .
' READ';
1255 $sql =
"LOCK TABLES " . implode(
',', $items );
1256 $this->
query( $sql, $method, self::QUERY_IGNORE_DBO_TRX );
1262 $this->
query(
"UNLOCK TABLES", $method, self::QUERY_IGNORE_DBO_TRX );
1271 if ( $value ===
'default' ) {
1272 if ( $this->defaultBigSelects ===
null ) {
1273 # Function hasn't been called before so it must already be set to the default
1278 } elseif ( $this->defaultBigSelects ===
null ) {
1279 $this->defaultBigSelects =
1280 (bool)$this->
selectField(
false,
'@@sql_big_selects',
'', __METHOD__ );
1282 $encValue = $value ?
'1' :
'0';
1283 $this->
query(
"SET sql_big_selects=$encValue", __METHOD__, self::QUERY_IGNORE_DBO_TRX );
1297 $delTable, $joinTable, $delVar, $joinVar, $conds, $fname = __METHOD__
1303 $delTable = $this->
tableName( $delTable );
1304 $joinTable = $this->
tableName( $joinTable );
1305 $sql =
"DELETE $delTable FROM $delTable, $joinTable WHERE $delVar=$joinVar ";
1307 if ( $conds !=
'*' ) {
1311 $this->
query( $sql, $fname );
1315 $table, array $rows, $uniqueIndexes, array $set, $fname = __METHOD__
1317 if ( $rows === [] ) {
1321 if ( !is_array( reset( $rows ) ) ) {
1326 $columns = array_keys( $rows[0] );
1328 $sql =
"INSERT INTO $table (" . implode(
',', $columns ) .
') VALUES ';
1330 foreach ( $rows as $row ) {
1331 $rowTuples[] =
'(' . $this->
makeList( $row ) .
')';
1333 $sql .= implode(
',', $rowTuples );
1336 $this->
query( $sql, $fname );
1349 return (
int)$vars[
'Uptime'];
1377 ( $this->
lastErrno() == 1290 && strpos( $this->
lastError(),
'--read-only' ) !== false );
1381 return $errno == 2013 || $errno == 2006;
1387 if ( $errno === 1205 ) {
1391 [
'innodb_rollback_on_timeout' =>
'@@innodb_rollback_on_timeout' ],
1397 return $row->innodb_rollback_on_timeout ? false :
true;
1401 return in_array( $errno, [ 1022, 1062, 1216, 1217, 1137, 1146, 1051, 1054 ],
true );
1412 $oldName, $newName, $temporary =
false, $fname = __METHOD__
1414 $tmp = $temporary ?
'TEMPORARY ' :
'';
1417 $query =
"CREATE $tmp TABLE $newName (LIKE $oldName)";
1419 return $this->
query( $query, $fname, $this::QUERY_PSEUDO_PERMANENT );
1429 public function listTables( $prefix =
null, $fname = __METHOD__ ) {
1430 $result = $this->
query(
"SHOW TABLES", $fname );
1434 foreach ( $result as $table ) {
1435 $vars = get_object_vars( $table );
1436 $table = array_pop( $vars );
1438 if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
1439 $endArray[] = $table;
1451 public function dropTable( $tableName, $fName = __METHOD__ ) {
1452 if ( !$this->
tableExists( $tableName, $fName ) ) {
1456 return $this->
query(
"DROP TABLE IF EXISTS " . $this->
tableName( $tableName ), $fName );
1466 $flags = self::QUERY_IGNORE_DBO_TRX;
1467 $res = $this->
query(
"SHOW STATUS LIKE '{$which}'", __METHOD__,
$flags );
1470 foreach (
$res as $row ) {
1471 $status[$row->Variable_name] = $row->Value;
1486 public function listViews( $prefix =
null, $fname = __METHOD__ ) {
1488 $propertyName =
'Tables_in_' . $this->
getDBname();
1491 $res = $this->
query(
'SHOW FULL TABLES WHERE TABLE_TYPE = "VIEW"' );
1493 foreach (
$res as $row ) {
1494 array_push( $allViews, $row->$propertyName );
1497 if ( is_null( $prefix ) || $prefix ===
'' ) {
1501 $filteredViews = [];
1502 foreach ( $allViews as $viewName ) {
1504 if ( strpos( $viewName, $prefix ) === 0 ) {
1505 array_push( $filteredViews, $viewName );
1509 return $filteredViews;
1520 public function isView( $name, $prefix =
null ) {
1521 return in_array( $name, $this->
listViews( $prefix ) );
1525 return parent::isTransactableQuery( $sql ) &&
1526 !preg_match(
'/^SELECT\s+(GET|RELEASE|IS_FREE)_LOCK\(/', $sql );
1530 return "CAST( $field AS BINARY )";
1538 return 'CAST( ' . $field .
' AS SIGNED )';
1552 class_alias( DatabaseMysqlBase::class,
'DatabaseMysqlBase' );