Merge "rdbms: rename and clarify getTransactionLagStatus method regarding begin()"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 4 Apr 2018 21:36:36 +0000 (21:36 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 4 Apr 2018 21:36:36 +0000 (21:36 +0000)
1  2 
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/DatabaseMysqlBase.php

@@@ -635,20 -635,6 +635,20 @@@ abstract class Database implements IDat
                );
        }
  
 +      /**
 +       * @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;
        }
@@@ -320,7 -320,7 +320,7 @@@ abstract class DatabaseMysqlBase extend
        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' );