rdbms: Add a deprecationLogger callback
[lhc/web/wiklou.git] / includes / libs / rdbms / database / Database.php
index 056f189..5336b25 100644 (file)
@@ -101,6 +101,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        protected $queryLogger;
        /** @var callback Error logging callback */
        protected $errorLogger;
+       /** @var callback Deprecation logging callback */
+       protected $deprecationLogger;
 
        /** @var resource|null Database connection */
        protected $conn = null;
@@ -141,6 +143,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */
        protected $affectedRowCount;
 
+       /**
+        * @var int Transaction status
+        */
+       protected $trxStatus = self::STATUS_TRX_NONE;
+       /**
+        * @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR
+        */
+       protected $trxStatusCause;
        /**
         * Either 1 if a transaction is active or 0 otherwise.
         * The other Trx fields may not be meaningfull if this is 0.
@@ -259,6 +269,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var int */
        protected $nonNativeInsertSelectBatchSize = 10000;
 
+       /** @var int Transaction is in a error state requiring a full or savepoint rollback */
+       const STATUS_TRX_ERROR = 1;
+       /** @var int Transaction is active and in a normal state */
+       const STATUS_TRX_OK = 2;
+       /** @var int No transaction is active */
+       const STATUS_TRX_NONE = 3;
+
        /**
         * @note: exceptions for missing libraries/drivers should be thrown in initConnection()
         * @param array $params Parameters passed from Database::factory()
@@ -297,6 +314,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->connLogger = $params['connLogger'];
                $this->queryLogger = $params['queryLogger'];
                $this->errorLogger = $params['errorLogger'];
+               $this->deprecationLogger = $params['deprecationLogger'];
 
                if ( isset( $params['nonNativeInsertSelectBatchSize'] ) ) {
                        $this->nonNativeInsertSelectBatchSize = $params['nonNativeInsertSelectBatchSize'];
@@ -381,6 +399,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         *      includes the agent as a SQL comment.
         *   - trxProfiler: Optional TransactionProfiler instance.
         *   - errorLogger: Optional callback that takes an Exception and logs it.
+        *   - deprecationLogger: Optional callback that takes a string and logs it.
         *   - cliMode: Whether to consider the execution context that of a CLI script.
         *   - agent: Optional name used to identify the end-user in query profiling/logging.
         *   - srvCache: Optional BagOStuff instance to an APC-style cache.
@@ -422,6 +441,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                        trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
                                };
                        }
+                       if ( !isset( $p['deprecationLogger'] ) ) {
+                               $p['deprecationLogger'] = function ( $msg ) {
+                                       trigger_error( $msg, E_USER_DEPRECATED );
+                               };
+                       }
 
                        /** @var Database $conn */
                        $conn = new $class( $p );
@@ -548,6 +572,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return $this->trxLevel ? $this->trxTimestamp : null;
        }
 
+       /**
+        * @return int One of the STATUS_TRX_* class constants
+        * @since 1.31
+        */
+       public function trxStatus() {
+               return $this->trxStatus;
+       }
+
        public function tablePrefix( $prefix = null ) {
                $old = $this->tablePrefix;
                if ( $prefix !== null ) {
@@ -704,6 +736,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return $fnames;
        }
 
+       /**
+        * @return string
+        */
+       private function flatAtomicSectionList() {
+               return array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) {
+                       return $accum === null ? $v[0] : "$accum, " . $v[0];
+               } );
+       }
+
        public function isOpen() {
                return $this->opened;
        }
@@ -846,42 +887,64 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                );
        }
 
-       public function close() {
+       final public function close() {
+               $exception = null; // error to throw after disconnecting
+
                if ( $this->conn ) {
                        // Resolve any dangling transaction first
-                       if ( $this->trxLevel() ) {
+                       if ( $this->trxLevel ) {
                                // Meaningful transactions should ideally have been resolved by now
                                if ( $this->writesOrCallbacksPending() ) {
                                        $this->queryLogger->warning(
                                                __METHOD__ . ": writes or callbacks still pending.",
                                                [ 'trace' => ( new RuntimeException() )->getTraceAsString() ]
                                        );
+                                       // Cannot let incomplete atomic sections be committed
+                                       if ( $this->trxAtomicLevels ) {
+                                               $levels = $this->flatAtomicSectionList();
+                                               $exception = new DBUnexpectedError(
+                                                       $this,
+                                                       __METHOD__ . ": atomic sections $levels are still open."
+                                               );
+                                       // Check if it is possible to properly commit and trigger callbacks
+                                       } elseif ( $this->trxEndCallbacksSuppressed ) {
+                                               $exception = new DBUnexpectedError(
+                                                       $this,
+                                                       __METHOD__ . ': callbacks are suppressed; cannot properly commit.'
+                                               );
+                                       }
                                }
-                               // Check if it is possible to properly commit and trigger callbacks
-                               if ( $this->trxEndCallbacksSuppressed ) {
-                                       throw new DBUnexpectedError(
-                                               $this,
-                                               __METHOD__ . ': callbacks are suppressed; cannot properly commit.'
-                                       );
+                               // Commit or rollback the changes and run any callbacks as needed
+                               if ( $this->trxStatus === self::STATUS_TRX_OK && !$exception ) {
+                                       $this->commit( __METHOD__, self::TRANSACTION_INTERNAL );
+                               } else {
+                                       $this->rollback( __METHOD__, self::TRANSACTION_INTERNAL );
                                }
-                               // Commit the changes and run any callbacks as needed
-                               $this->commit( __METHOD__, self::FLUSHING_INTERNAL );
                        }
                        // Close the actual connection in the binding handle
                        $closed = $this->closeConnection();
                        $this->conn = false;
-                       // Sanity check that no callbacks are dangling
-                       if (
-                               $this->trxIdleCallbacks || $this->trxPreCommitCallbacks || $this->trxEndCallbacks
-                       ) {
-                               throw new RuntimeException( "Transaction callbacks still pending." );
-                       }
                } else {
                        $closed = true; // already closed; nothing to do
                }
 
                $this->opened = false;
 
+               // Throw any unexpected errors after having disconnected
+               if ( $exception instanceof Exception ) {
+                       throw $exception;
+               }
+
+               // Sanity check that no callbacks are dangling
+               if (
+                       $this->trxIdleCallbacks || $this->trxPreCommitCallbacks || $this->trxEndCallbacks
+               ) {
+                       throw new RuntimeException(
+                               "Transaction callbacks are still pending:\n" .
+                               implode( ', ', $this->pendingWriteAndCallbackCallers() )
+                       );
+               }
+
                return $closed;
        }
 
@@ -1005,6 +1068,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
+               $this->assertTransactionStatus( $sql, $fname );
+
                $priorWritesPending = $this->writesOrCallbacksPending();
                $this->lastQuery = $sql;
 
@@ -1083,20 +1148,22 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
 
                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->wasDeadlock() ) {
+                       if ( $this->trxLevel && !$this->wasKnownStatementRollbackError() ) {
+                               # Either the query was aborted or all queries after BEGIN where aborted.
                                if ( $this->explicitTrxActive() || $priorWritesPending ) {
-                                       $tempIgnore = false; // not recoverable
+                                       # In the first case, the only options going forward are (a) ROLLBACK, or
+                                       # (b) ROLLBACK TO SAVEPOINT (if one was set). If the later case, the only
+                                       # option is ROLLBACK, since the snapshots would have been released.
+                                       $this->trxStatus = self::STATUS_TRX_ERROR;
+                                       $this->trxStatusCause =
+                                               $this->makeQueryException( $lastError, $lastErrno, $sql, $fname );
+                                       $tempIgnore = false; // cannot recover
+                               } else {
+                                       # Nothing prior was there to lose from the transaction,
+                                       # so just roll it back.
+                                       $this->doRollback( __METHOD__ . " ($fname)" );
+                                       $this->trxStatus = self::STATUS_TRX_OK;
                                }
-                               # 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->handleTransactionLoss();
                        }
 
                        $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $tempIgnore );
@@ -1200,6 +1267,25 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
        }
 
+       /**
+        * @param string $sql
+        * @param string $fname
+        * @throws DBTransactionStateError
+        */
+       private function assertTransactionStatus( $sql, $fname ) {
+               if (
+                       $this->trxStatus < self::STATUS_TRX_OK &&
+                       $this->getQueryVerb( $sql ) !== 'ROLLBACK' // transaction/savepoint
+               ) {
+                       throw new DBTransactionStateError(
+                               $this,
+                               "Cannot execute query from $fname while transaction status is ERROR. ",
+                               [],
+                               $this->trxStatusCause
+                       );
+               }
+       }
+
        /**
         * Determine whether or not it is safe to retry queries after a database
         * connection is lost
@@ -1224,7 +1310,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                } elseif ( $sql === 'ROLLBACK' ) {
                        return true; // transaction lost...which is also what was requested :)
                } elseif ( $this->explicitTrxActive() ) {
-                       return false; // don't drop atomocity
+                       return false; // don't drop atomocity and explicit snapshots
                } elseif ( $priorWritesPending ) {
                        return false; // prior writes lost from implicit transaction
                }
@@ -1238,7 +1324,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        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)
+               // https://www.postgresql.org/docs/9.2/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
@@ -1299,27 +1385,42 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                if ( $tempIgnore ) {
                        $this->queryLogger->debug( "SQL ERROR (ignored): $error\n" );
                } else {
-                       $sql1line = mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 );
-                       $this->queryLogger->error(
-                               "{fname}\t{db_server}\t{errno}\t{error}\t{sql1line}",
-                               $this->getLogContext( [
-                                       'method' => __METHOD__,
-                                       'errno' => $errno,
-                                       'error' => $error,
-                                       'sql1line' => $sql1line,
-                                       'fname' => $fname,
-                               ] )
-                       );
-                       $this->queryLogger->debug( "SQL ERROR: " . $error . "\n" );
-                       $wasQueryTimeout = $this->wasQueryTimeout( $error, $errno );
-                       if ( $wasQueryTimeout ) {
-                               throw new DBQueryTimeoutError( $this, $error, $errno, $sql, $fname );
-                       } else {
-                               throw new DBQueryError( $this, $error, $errno, $sql, $fname );
-                       }
+                       $exception = $this->makeQueryException( $error, $errno, $sql, $fname );
+
+                       throw $exception;
                }
        }
 
+       /**
+        * @param string $error
+        * @param string|int $errno
+        * @param string $sql
+        * @param string $fname
+        * @return DBError
+        */
+       private function makeQueryException( $error, $errno, $sql, $fname ) {
+               $sql1line = mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 );
+               $this->queryLogger->error(
+                       "{fname}\t{db_server}\t{errno}\t{error}\t{sql1line}",
+                       $this->getLogContext( [
+                               'method' => __METHOD__,
+                               'errno' => $errno,
+                               'error' => $error,
+                               'sql1line' => $sql1line,
+                               'fname' => $fname,
+                       ] )
+               );
+               $this->queryLogger->debug( "SQL ERROR: " . $error . "\n" );
+               $wasQueryTimeout = $this->wasQueryTimeout( $error, $errno );
+               if ( $wasQueryTimeout ) {
+                       $e = new DBQueryTimeoutError( $this, $error, $errno, $sql, $fname );
+               } else {
+                       $e = new DBQueryError( $this, $error, $errno, $sql, $fname );
+               }
+
+               return $e;
+       }
+
        public function freeResult( $res ) {
        }
 
@@ -3026,6 +3127,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return false;
        }
 
+       /**
+        * @return bool Whether it is safe to assume the given error only caused statement rollback
+        * @note This is for backwards compatibility for callers catching DBError exceptions in
+        *   order to ignore problems like duplicate key errors or foriegn key violations
+        * @since 1.31
+        */
+       protected function wasKnownStatementRollbackError() {
+               return false; // don't know; it could have caused a transaction rollback
+       }
+
        public function deadlockLoop() {
                $args = func_get_args();
                $function = array_shift( $args );
@@ -3360,6 +3471,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->rollback( $fname, self::FLUSHING_INTERNAL );
                } elseif ( $savepointId !== 'n/a' ) {
                        $this->doRollbackToSavepoint( $savepointId, $fname );
+                       $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered
                }
 
                $this->affectedRowCount = 0; // for the sake of consistency
@@ -3382,9 +3494,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // Protect against mismatched atomic section, transaction nesting, and snapshot loss
                if ( $this->trxLevel ) {
                        if ( $this->trxAtomicLevels ) {
-                               $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) {
-                                       return $accum === null ? $v[0] : "$accum, " . $v[0];
-                               } );
+                               $levels = $this->flatAtomicSectionList();
                                $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
                                throw new DBUnexpectedError( $this, $msg );
                        } elseif ( !$this->trxAutomatic ) {
@@ -3403,6 +3513,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->assertOpen();
 
                $this->doBegin( $fname );
+               $this->trxStatus = self::STATUS_TRX_OK;
                $this->trxAtomicCounter = 0;
                $this->trxTimestamp = microtime( true );
                $this->trxFname = $fname;
@@ -3418,6 +3529,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $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()
@@ -3439,9 +3551,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        final public function commit( $fname = __METHOD__, $flush = '' ) {
                if ( $this->trxLevel && $this->trxAtomicLevels ) {
                        // There are still atomic sections open. This cannot be ignored
-                       $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) {
-                               return $accum === null ? $v[0] : "$accum, " . $v[0];
-                       } );
+                       $levels = $this->flatAtomicSectionList();
                        throw new DBUnexpectedError(
                                $this,
                                "$fname: Got COMMIT while atomic sections $levels are still open."
@@ -3476,6 +3586,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->runOnTransactionPreCommitCallbacks();
                $writeTime = $this->pendingWriteQueryDuration( self::ESTIMATE_DB_APPLY );
                $this->doCommit( $fname );
+               $this->trxStatus = self::STATUS_TRX_NONE;
                if ( $this->trxDoneWrites ) {
                        $this->lastWriteTime = microtime( true );
                        $this->trxProfiler->transactionWritingOut(
@@ -3521,6 +3632,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->assertOpen();
 
                        $this->doRollback( $fname );
+                       $this->trxStatus = self::STATUS_TRX_NONE;
                        $this->trxAtomicLevels = [];
                        if ( $this->trxDoneWrites ) {
                                $this->trxProfiler->transactionWritingOut(
@@ -3707,7 +3819,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function getSessionLagStatus() {
-               return $this->getTransactionLagStatus() ?: $this->getApproximateLagStatus();
+               return $this->getRecordedTransactionLagStatus() ?: $this->getApproximateLagStatus();
        }
 
        /**
@@ -3718,11 +3830,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         * 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;
        }