);
}
+ /**
+ * @return string|null
+ */
+ final protected function getTransactionRoundId() {
+ // If transaction round participation is enabled, see if one is active
+ if ( $this->getFlag( self::DBO_TRX ) ) {
+ $id = $this->getLBInfo( 'trxRoundId' );
+
+ return is_string( $id ) ? $id : null;
+ }
+
+ return null;
+ }
+
public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) {
if ( !$this->trxLevel ) {
return false;
# Avoid fatals if close() was called
$this->assertOpen();
- # Send the query to the server
+ # Send the query to the server and fetch any corresponding errors
$ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname );
+ $lastError = $this->lastError();
+ $lastErrno = $this->lastErrno();
# Try reconnecting if the connection was lost
- if ( false === $ret && $this->wasConnectionLoss() ) {
+ if ( $ret === false && $this->wasConnectionLoss() ) {
+ # Check if any meaningful session state was lost
$recoverable = $this->canRecoverFromDisconnect( $sql, $priorWritesPending );
- # Stash the last error values before anything might clear them
- $lastError = $this->lastError();
- $lastErrno = $this->lastErrno();
- # Update state tracking to reflect transaction loss due to disconnection
- $this->handleSessionLoss();
- if ( $this->reconnect() ) {
- $msg = __METHOD__ . ': lost connection to {dbserver}; reconnected';
- $params = [ 'dbserver' => $this->getServer() ];
- $this->connLogger->warning( $msg, $params );
- $this->queryLogger->warning( $msg, $params +
- [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] );
-
- if ( $recoverable ) {
- # Should be safe to silently retry the query
- $ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname );
- } else {
- # Callers may catch the exception and continue to use the DB
- $this->reportQueryError( $lastError, $lastErrno, $sql, $fname );
+ # Update session state tracking and try to restore the connection
+ $reconnected = $this->replaceLostConnection( __METHOD__ );
+ # Silently resend the query to the server if it is safe and possible
+ if ( $reconnected && $recoverable ) {
+ $ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname );
+ $lastError = $this->lastError();
+ $lastErrno = $this->lastErrno();
+
+ if ( $ret === false && $this->wasConnectionLoss() ) {
+ # Query probably causes disconnects; reconnect and do not re-run it
+ $this->replaceLostConnection( __METHOD__ );
}
- } else {
- $msg = __METHOD__ . ': lost connection to {dbserver} permanently';
- $this->connLogger->error( $msg, [ 'dbserver' => $this->getServer() ] );
}
}
- if ( false === $ret ) {
+ if ( $ret === false ) {
# Deadlocks cause the entire transaction to abort, not just the statement.
# https://dev.mysql.com/doc/refman/5.7/en/innodb-error-handling.html
# https://www.postgresql.org/docs/9.1/static/explicit-locking.html
if ( $this->explicitTrxActive() || $priorWritesPending ) {
$tempIgnore = false; // not recoverable
}
+ # Usually the transaction is rolled back to BEGIN, leaving an empty transaction.
+ # Destroy any such transaction so the rollback callbacks run in AUTO-COMMIT mode
+ # as normal. Also, if DBO_TRX is set and an explicit transaction rolled back here,
+ # further queries should be back in AUTO-COMMIT mode, not stuck in a transaction.
+ $this->doRollback( __METHOD__ );
# Update state tracking to reflect transaction loss
- $this->handleSessionLoss();
+ $this->handleTransactionLoss();
}
- $this->reportQueryError(
- $this->lastError(), $this->lastErrno(), $sql, $fname, $tempIgnore );
+ $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $tempIgnore );
}
- $res = $this->resultObject( $ret );
-
- return $res;
+ return $this->resultObject( $ret );
}
/**
# didn't matter anyway (aside from DBO_TRX snapshot loss).
if ( $this->namedLocksHeld ) {
return false; // possible critical section violation
+ } elseif ( $this->sessionTempTables ) {
+ return false; // tables might be queried latter
} elseif ( $sql === 'COMMIT' ) {
return !$priorWritesPending; // nothing written anyway? (T127428)
} elseif ( $sql === 'ROLLBACK' ) {
}
/**
- * Clean things up after transaction loss due to disconnection
- *
- * @return null|Exception
+ * Clean things up after session (and thus transaction) loss
*/
private function handleSessionLoss() {
+ // Clean up tracking of session-level things...
+ // https://dev.mysql.com/doc/refman/5.7/en/implicit-commit.html
+ // https://www.postgresql.org/docs/9.1/static/sql-createtable.html (ignoring ON COMMIT)
+ $this->sessionTempTables = [];
+ // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
+ // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
+ $this->namedLocksHeld = [];
+ // Session loss implies transaction loss
+ $this->handleTransactionLoss();
+ }
+
+ /**
+ * Clean things up after transaction loss
+ */
+ private function handleTransactionLoss() {
$this->trxLevel = 0;
$this->trxAtomicCounter = 0;
$this->trxIdleCallbacks = []; // T67263; transaction already lost
$this->trxPreCommitCallbacks = []; // T67263; transaction already lost
- $this->sessionTempTables = [];
- $this->namedLocksHeld = [];
-
- // Note: if callback suppression is set then some *Callbacks arrays are not cleared here
- $e = null;
try {
- // Handle callbacks in trxEndCallbacks
+ // Handle callbacks in trxEndCallbacks, e.g. onTransactionResolution().
+ // If callback suppression is set then the array will remain unhandled.
$this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK );
} catch ( Exception $ex ) {
// Already logged; move on...
- $e = $e ?: $ex;
}
try {
- // Handle callbacks in trxRecurringCallbacks
+ // Handle callbacks in trxRecurringCallbacks, e.g. setTransactionListener()
$this->runTransactionListenerCallbacks( self::TRIGGER_ROLLBACK );
} catch ( Exception $ex ) {
// Already logged; move on...
- $e = $e ?: $ex;
}
-
- return $e;
}
/**
}
final public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) {
+ if ( !$this->trxLevel && $this->getTransactionRoundId() ) {
+ // Start an implicit transaction similar to how query() does
+ $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
+ $this->trxAutomatic = true;
+ }
+
$this->trxIdleCallbacks[] = [ $callback, $fname ];
if ( !$this->trxLevel ) {
$this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE );
}
final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
- if ( $this->trxLevel || $this->getFlag( self::DBO_TRX ) ) {
- // As long as DBO_TRX is set, writes will accumulate until the load balancer issues
- // an implicit commit of all peer databases. This is true even if a transaction has
- // not yet been triggered by writes; make sure $callback runs *after* any such writes.
+ if ( !$this->trxLevel && $this->getTransactionRoundId() ) {
+ // Start an implicit transaction similar to how query() does
+ $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
+ $this->trxAutomatic = true;
+ }
+
+ if ( $this->trxLevel ) {
$this->trxPreCommitCallbacks[] = [ $callback, $fname ];
} else {
// No transaction is active nor will start implicitly, so make one for this callback
$this->trxWriteCallers = [];
// First SELECT after BEGIN will establish the snapshot in REPEATABLE-READ.
// Get an estimate of the replication lag before any such queries.
+ $this->trxReplicaLag = null; // clear cached value first
$this->trxReplicaLag = $this->getApproximateLagStatus()['lag'];
// T147697: make explicitTrxActive() return true until begin() finishes. This way, no
// caller will think its OK to muck around with the transaction just because startAtomic()
}
/**
- * Close existing database connection and open a new connection
+ * Close any existing (dead) database connection and open a new connection
*
+ * @param string $fname
* @return bool True if new connection is opened successfully, false if error
*/
- protected function reconnect() {
+ protected function replaceLostConnection( $fname ) {
$this->closeConnection();
$this->opened = false;
$this->conn = false;
$this->open( $this->server, $this->user, $this->password, $this->dbName );
$this->lastPing = microtime( true );
$ok = true;
+
+ $this->connLogger->warning(
+ $fname . ': lost connection to {dbserver}; reconnected',
+ [
+ 'dbserver' => $this->getServer(),
+ 'trace' => ( new RuntimeException() )->getTraceAsString()
+ ]
+ );
} catch ( DBConnectionError $e ) {
$ok = false;
+
+ $this->connLogger->error(
+ $fname . ': lost connection to {dbserver} permanently',
+ [ 'dbserver' => $this->getServer() ]
+ );
}
+ $this->handleSessionLoss();
+
return $ok;
}
public function getSessionLagStatus() {
- return $this->getTransactionLagStatus() ?: $this->getApproximateLagStatus();
+ return $this->getRecordedTransactionLagStatus() ?: $this->getApproximateLagStatus();
}
/**
* is this lag plus transaction duration. If they don't, it is still
* safe to be pessimistic. This returns null if there is no transaction.
*
+ * This returns null if the lag status for this transaction was not yet recorded.
+ *
* @return array|null ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN)
* @since 1.27
*/
- final protected function getTransactionLagStatus() {
- return $this->trxLevel
+ final protected function getRecordedTransactionLagStatus() {
+ return ( $this->trxLevel && $this->trxReplicaLag !== null )
? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ]
: null;
}
abstract protected function mysqlFetchObject( $res );
/**
- * @param ResultWrapper|resource $res
+ * @param IResultWrapper|resource $res
* @return array|bool
* @throws DBUnexpectedError
*/
protected function getLagFromPtHeartbeat() {
$options = $this->lagDetectionOptions;
- $staleness = $this->trxLevel
- ? microtime( true ) - $this->trxTimestamp()
- : 0;
- if ( $staleness > self::LAG_STALE_WARN_THRESHOLD ) {
- // Avoid returning higher and higher lag value due to snapshot age
- // given that the isolation level will typically be REPEATABLE-READ
- $this->queryLogger->warning(
- "Using cached lag value for {db_server} due to active transaction",
- $this->getLogContext( [ 'method' => __METHOD__ ] )
- );
+ $currentTrxInfo = $this->getRecordedTransactionLagStatus();
+ if ( $currentTrxInfo ) {
+ // There is an active transaction and the initial lag was already queried
+ $staleness = microtime( true ) - $currentTrxInfo['since'];
+ if ( $staleness > self::LAG_STALE_WARN_THRESHOLD ) {
+ // Avoid returning higher and higher lag value due to snapshot age
+ // given that the isolation level will typically be REPEATABLE-READ
+ $this->queryLogger->warning(
+ "Using cached lag value for {db_server} due to active transaction",
+ $this->getLogContext( [ 'method' => __METHOD__, 'age' => $staleness ] )
+ );
+ }
- return $this->getTransactionLagStatus()['lag'];
+ return $currentTrxInfo['lag'];
}
if ( isset( $options['conds'] ) ) {
}
// Wait on the GTID set (MariaDB only)
$gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) );
- if ( strpos( $gtidArg, ':' ) !== false ) {
- // MySQL GTIDs, e.g "source_id:transaction_id"
- $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" );
- } else {
- // MariaDB GTIDs, e.g."domain:server:sequence"
- $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
- }
+ $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
} else {
// Wait on the binlog coordinates
$encFile = $this->addQuotes( $pos->getLogFile() );
- $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] );
+ $encPos = intval( $pos->pos[1] );
$res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" );
}
$row = $res ? $this->fetchRow( $res ) : false;
if ( !$row ) {
- throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" );
+ throw new DBExpectedError( $this,
+ "MASTER_POS_WAIT() or MASTER_GTID_WAIT() failed: {$this->lastError()}" );
}
// Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
* @return MySQLMasterPos|bool
*/
public function getReplicaPos() {
- $now = microtime( true ); // as-of-time *before* fetching GTID variables
-
- if ( $this->useGTIDs() ) {
- // Try to use GTIDs, fallbacking to binlog positions if not possible
- $data = $this->getServerGTIDs( __METHOD__ );
- // Use gtid_current_pos for MariaDB and gtid_executed for MySQL
- foreach ( [ 'gtid_current_pos', 'gtid_executed' ] as $name ) {
- if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
- return new MySQLMasterPos( $data[$name], $now );
- }
+ $now = microtime( true );
+
+ if ( $this->useGTIDs ) {
+ $res = $this->query( "SELECT @@global.gtid_slave_pos AS Value", __METHOD__ );
+ $gtidRow = $this->fetchObject( $res );
+ if ( $gtidRow && strlen( $gtidRow->Value ) ) {
+ return new MySQLMasterPos( $gtidRow->Value, $now );
}
}
- $data = $this->getServerRoleStatus( 'SLAVE', __METHOD__ );
- if ( $data && strlen( $data['Relay_Master_Log_File'] ) ) {
+ $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
+ $row = $this->fetchObject( $res );
+ if ( $row && strlen( $row->Relay_Master_Log_File ) ) {
return new MySQLMasterPos(
- "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}",
+ "{$row->Relay_Master_Log_File}/{$row->Exec_Master_Log_Pos}",
$now
);
}
* @return MySQLMasterPos|bool
*/
public function getMasterPos() {
- $now = microtime( true ); // as-of-time *before* fetching GTID variables
-
- $pos = false;
- if ( $this->useGTIDs() ) {
- // Try to use GTIDs, fallbacking to binlog positions if not possible
- $data = $this->getServerGTIDs( __METHOD__ );
- // Use gtid_current_pos for MariaDB and gtid_executed for MySQL
- foreach ( [ 'gtid_current_pos', 'gtid_executed' ] as $name ) {
- if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
- $pos = new MySQLMasterPos( $data[$name], $now );
- break;
- }
- }
- // Filter domains that are inactive or not relevant to the session
- if ( $pos ) {
- $pos->setActiveOriginServerId( $this->getServerId() );
- $pos->setActiveOriginServerUUID( $this->getServerUUID() );
- if ( isset( $data['gtid_domain_id'] ) ) {
- $pos->setActiveDomain( $data['gtid_domain_id'] );
- }
- }
- }
+ $now = microtime( true );
- if ( !$pos ) {
- $data = $this->getServerRoleStatus( 'MASTER', __METHOD__ );
- if ( $data && strlen( $data['File'] ) ) {
- $pos = new MySQLMasterPos( "{$data['File']}/{$data['Position']}", $now );
+ if ( $this->useGTIDs ) {
+ $res = $this->query( "SELECT @@global.gtid_binlog_pos AS Value", __METHOD__ );
+ $gtidRow = $this->fetchObject( $res );
+ if ( $gtidRow && strlen( $gtidRow->Value ) ) {
+ return new MySQLMasterPos( $gtidRow->Value, $now );
}
}
- return $pos;
- }
-
- /**
- * @return int
- * @throws DBQueryError If the variable doesn't exist for some reason
- */
- protected function getServerId() {
- return $this->srvCache->getWithSetCallback(
- $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServer() ),
- self::SERVER_ID_CACHE_TTL,
- function () {
- $res = $this->query( "SELECT @@server_id AS id", __METHOD__ );
- return intval( $this->fetchObject( $res )->id );
- }
- );
- }
-
- /**
- * @return string|null
- */
- protected function getServerUUID() {
- return $this->srvCache->getWithSetCallback(
- $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServer() ),
- self::SERVER_ID_CACHE_TTL,
- function () {
- $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'" );
- $row = $this->fetchObject( $res );
-
- return $row ? $row->Value : null;
- }
- );
- }
-
- /**
- * @param string $fname
- * @return string[]
- */
- protected function getServerGTIDs( $fname = __METHOD__ ) {
- $map = [];
- // Get global-only variables like gtid_executed
- $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname );
- foreach ( $res as $row ) {
- $map[$row->Variable_name] = $row->Value;
- }
- // Get session-specific (e.g. gtid_domain_id since that is were writes will log)
- $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname );
- foreach ( $res as $row ) {
- $map[$row->Variable_name] = $row->Value;
+ $res = $this->query( 'SHOW MASTER STATUS', __METHOD__ );
+ $row = $this->fetchObject( $res );
+ if ( $row && strlen( $row->File ) ) {
+ return new MySQLMasterPos( "{$row->File}/{$row->Position}", $now );
}
- return $map;
- }
-
- /**
- * @param string $role One of "MASTER"/"SLAVE"
- * @param string $fname
- * @return string[] Latest available server status row
- */
- protected function getServerRoleStatus( $role, $fname = __METHOD__ ) {
- return $this->query( "SHOW $role STATUS", $fname )->fetchRow() ?: [];
+ return false;
}
public function serverIsReadOnly() {
return 'CAST( ' . $field . ' AS SIGNED )';
}
- /*
- * @return bool Whether GTID support is used (mockable for testing)
- */
- protected function useGTIDs() {
- return $this->useGTIDs;
- }
}
class_alias( DatabaseMysqlBase::class, 'DatabaseMysqlBase' );