rdbms: Log debug message traces as 'exception.trace' instead of 'trace'
[lhc/web/wiklou.git] / includes / libs / rdbms / database / Database.php
index 97ea266..ab4ab68 100644 (file)
@@ -35,6 +35,7 @@ use BagOStuff;
 use HashBagOStuff;
 use LogicException;
 use InvalidArgumentException;
+use UnexpectedValueException;
 use Exception;
 use RuntimeException;
 
@@ -100,17 +101,19 @@ 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;
        /** @var bool */
        protected $opened = false;
 
-       /** @var array[] List of (callable, method name) */
+       /** @var array[] List of (callable, method name, atomic section id) */
        protected $trxIdleCallbacks = [];
-       /** @var array[] List of (callable, method name) */
+       /** @var array[] List of (callable, method name, atomic section id) */
        protected $trxPreCommitCallbacks = [];
-       /** @var array[] List of (callable, method name) */
+       /** @var array[] List of (callable, method name, atomic section id) */
        protected $trxEndCallbacks = [];
        /** @var callable[] Map of (name => callable) */
        protected $trxRecurringCallbacks = [];
@@ -140,6 +143,19 @@ 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;
+       /**
+        * @var array|null If wasKnownStatementRollbackError() prevented trxStatus from being set,
+        *  the relevant details are stored here.
+        */
+       protected $trxStatusIgnoredCause;
        /**
         * Either 1 if a transaction is active or 0 otherwise.
         * The other Trx fields may not be meaningfull if this is 0.
@@ -187,10 +203,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         * @see Database::trxLevel
         */
        private $trxAutomatic = false;
+       /**
+        * Counter for atomic savepoint identifiers. Reset when a new transaction begins.
+        *
+        * @var int
+        */
+       private $trxAtomicCounter = 0;
        /**
         * Array of levels of atomicity within transactions
         *
-        * @var array
+        * @var array List of (name, unique ID, savepoint ID)
         */
        private $trxAtomicLevels = [];
        /**
@@ -252,6 +274,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var int */
        protected $nonNativeInsertSelectBatchSize = 10000;
 
+       /** @var string Idiom used when a cancelable atomic section started the transaction */
+       private static $NOT_APPLICABLE = 'n/a';
+       /** @var string Prefix to the atomic section counter used to make savepoint IDs */
+       private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic';
+
+       /** @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()
@@ -276,6 +310,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                $this->flags |= self::DBO_TRX;
                        }
                }
+               // Disregard deprecated DBO_IGNORE flag (T189999)
+               $this->flags &= ~self::DBO_IGNORE;
 
                $this->sessionVars = $params['variables'];
 
@@ -288,6 +324,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'];
@@ -372,6 +409,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.
@@ -413,6 +451,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 );
@@ -531,32 +574,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return $res;
        }
 
-       /**
-        * Turns on (false) or off (true) the automatic generation and sending
-        * of a "we're sorry, but there has been a database error" page on
-        * database errors. Default is on (false). When turned off, the
-        * code should use lastErrno() and lastError() to handle the
-        * situation as appropriate.
-        *
-        * Do not use this function outside of the Database classes.
-        *
-        * @param null|bool $ignoreErrors
-        * @return bool The previous value of the flag.
-        */
-       protected function ignoreErrors( $ignoreErrors = null ) {
-               $res = $this->getFlag( self::DBO_IGNORE );
-               if ( $ignoreErrors !== null ) {
-                       // setFlag()/clearFlag() do not allow DBO_IGNORE changes for sanity
-                       if ( $ignoreErrors ) {
-                               $this->flags |= self::DBO_IGNORE;
-                       } else {
-                               $this->flags &= ~self::DBO_IGNORE;
-                       }
-               }
-
-               return $res;
-       }
-
        public function trxLevel() {
                return $this->trxLevel;
        }
@@ -565,6 +582,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 ) {
@@ -652,6 +677,20 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                );
        }
 
+       /**
+        * @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;
@@ -707,13 +746,22 @@ 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;
        }
 
        public function setFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
                if ( ( $flag & self::DBO_IGNORE ) ) {
-                       throw new \UnexpectedValueException( "Modifying DBO_IGNORE is not allowed." );
+                       throw new UnexpectedValueException( "Modifying DBO_IGNORE is not allowed." );
                }
 
                if ( $remember === self::REMEMBER_PRIOR ) {
@@ -724,7 +772,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
        public function clearFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
                if ( ( $flag & self::DBO_IGNORE ) ) {
-                       throw new \UnexpectedValueException( "Modifying DBO_IGNORE is not allowed." );
+                       throw new UnexpectedValueException( "Modifying DBO_IGNORE is not allowed." );
                }
 
                if ( $remember === self::REMEMBER_PRIOR ) {
@@ -849,42 +897,78 @@ 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() ) {
-                               // Meaningful transactions should ideally have been resolved by now
-                               if ( $this->writesOrCallbacksPending() ) {
+                       if ( $this->trxLevel ) {
+                               if ( $this->trxAtomicLevels ) {
+                                       // Cannot let incomplete atomic sections be committed
+                                       $levels = $this->flatAtomicSectionList();
+                                       $exception = new DBUnexpectedError(
+                                               $this,
+                                               __METHOD__ . ": atomic sections $levels are still open."
+                                       );
+                               } elseif ( $this->trxAutomatic ) {
+                                       // Only the connection manager can commit non-empty DBO_TRX transactions
+                                       if ( $this->writesOrCallbacksPending() ) {
+                                               $exception = new DBUnexpectedError(
+                                                       $this,
+                                                       __METHOD__ .
+                                                       ": mass commit/rollback of peer transaction required (DBO_TRX set)."
+                                               );
+                                       }
+                               } elseif ( $this->trxLevel ) {
+                                       // Commit explicit transactions as if this was commit()
                                        $this->queryLogger->warning(
                                                __METHOD__ . ": writes or callbacks still pending.",
                                                [ 'trace' => ( new RuntimeException() )->getTraceAsString() ]
                                        );
                                }
-                               // Check if it is possible to properly commit and trigger callbacks
+
                                if ( $this->trxEndCallbacksSuppressed ) {
-                                       throw new DBUnexpectedError(
+                                       $exception = $exception ?: new DBUnexpectedError(
                                                $this,
                                                __METHOD__ . ': callbacks are suppressed; cannot properly commit.'
                                        );
                                }
-                               // Commit the changes and run any callbacks as needed
-                               $this->commit( __METHOD__, self::FLUSHING_INTERNAL );
+
+                               // Commit or rollback the changes and run any callbacks as needed
+                               if ( $this->trxStatus === self::STATUS_TRX_OK && !$exception ) {
+                                       $this->commit(
+                                               __METHOD__,
+                                               $this->trxAutomatic ? self::FLUSHING_INTERNAL : self::FLUSHING_ONE
+                                       );
+                               } else {
+                                       $this->rollback( __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;
        }
 
@@ -906,6 +990,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         */
        abstract protected function closeConnection();
 
+       /**
+        * @param string $error Fallback error message, used if none is given by DB
+        * @throws DBConnectionError
+        */
        public function reportConnectionError( $error = 'Unknown error' ) {
                $myError = $this->lastError();
                if ( $myError ) {
@@ -1004,6 +1092,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
+               $this->assertTransactionStatus( $sql, $fname );
+
+               # Avoid fatals if close() was called
+               $this->assertOpen();
+
                $priorWritesPending = $this->writesOrCallbacksPending();
                $this->lastQuery = $sql;
 
@@ -1054,59 +1147,60 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->queryLogger->debug( "{$this->dbName} {$commentedSql}" );
                }
 
-               # 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->wasErrorReissuable() ) {
+               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 ) {
-                       # 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->explicitTrxActive() || $priorWritesPending ) {
-                                       $tempIgnore = false; // not recoverable
+               if ( $ret === false ) {
+                       if ( $this->trxLevel ) {
+                               if ( !$this->wasKnownStatementRollbackError() ) {
+                                       # Either the query was aborted or all queries after BEGIN where aborted.
+                                       if ( $this->explicitTrxActive() || $priorWritesPending ) {
+                                               # 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->rollback( __METHOD__ . " ($fname)", self::FLUSHING_INTERNAL );
+                                       }
+                                       $this->trxStatusIgnoredCause = null;
+                               } else {
+                                       # We're ignoring an error that caused just the current query to be aborted.
+                                       # But log the cause so we can log a deprecation notice if a
+                                       # caller actually does ignore it.
+                                       $this->trxStatusIgnoredCause = [ $lastError, $lastErrno, $fname ];
                                }
-                               # Update state tracking to reflect transaction loss
-                               $this->handleSessionLoss();
                        }
 
-                       $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 );
        }
 
        /**
@@ -1204,6 +1298,33 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
        }
 
+       /**
+        * @param string $sql
+        * @param string $fname
+        * @throws DBTransactionStateError
+        */
+       private function assertTransactionStatus( $sql, $fname ) {
+               if ( $this->getQueryVerb( $sql ) === 'ROLLBACK' ) { // transaction/savepoint
+                       return;
+               }
+
+               if ( $this->trxStatus < self::STATUS_TRX_OK ) {
+                       throw new DBTransactionStateError(
+                               $this,
+                               "Cannot execute query from $fname while transaction status is ERROR.",
+                               [],
+                               $this->trxStatusCause
+                       );
+               } elseif ( $this->trxStatus === self::STATUS_TRX_OK && $this->trxStatusIgnoredCause ) {
+                       list( $iLastError, $iLastErrno, $iFname ) = $this->trxStatusIgnoredCause;
+                       call_user_func( $this->deprecationLogger,
+                               "Caller from $fname ignored an error originally raised from $iFname: " .
+                               "[$iLastErrno] $iLastError"
+                       );
+                       $this->trxStatusIgnoredCause = null;
+               }
+       }
+
        /**
         * Determine whether or not it is safe to retry queries after a database
         * connection is lost
@@ -1221,12 +1342,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                # 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' ) {
                        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
                }
@@ -1235,35 +1358,41 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        /**
-        * Clean things up after transaction loss due to disconnection
-        *
-        * @return null|Exception
+        * Clean things up after session (and thus transaction) loss
         */
        private function handleSessionLoss() {
-               $this->trxLevel = 0;
-               $this->trxIdleCallbacks = []; // T67263; transaction already lost
-               $this->trxPreCommitCallbacks = []; // T67263; transaction already lost
+               // 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.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
                $this->namedLocksHeld = [];
+               // Session loss implies transaction loss
+               $this->handleTransactionLoss();
+       }
 
-               // Note: if callback suppression is set then some *Callbacks arrays are not cleared here
-               $e = null;
+       /**
+        * 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
                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;
        }
 
        /**
@@ -1280,31 +1409,57 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return false;
        }
 
+       /**
+        * Report a query error. Log the error, and if neither the object ignore
+        * flag nor the $tempIgnore flag is set, throw a DBQueryError.
+        *
+        * @param string $error
+        * @param int $errno
+        * @param string $sql
+        * @param string $fname
+        * @param bool $tempIgnore
+        * @throws DBQueryError
+        */
        public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
-               if ( $this->ignoreErrors() || $tempIgnore ) {
+               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 ) {
        }
 
@@ -1348,14 +1503,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $options = [ $options ];
                }
 
-               $res = $this->select( $table, $var, $cond, $fname, $options, $join_conds );
+               $res = $this->select( $table, [ 'value' => $var ], $cond, $fname, $options, $join_conds );
                if ( $res === false ) {
                        return false;
                }
 
                $values = [];
                foreach ( $res as $row ) {
-                       $values[] = $row->$var;
+                       $values[] = $row->value;
                }
 
                return $values;
@@ -1588,8 +1743,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function estimateRowCount(
-               $table, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+               $table, $var = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
        ) {
+               $conds = $this->normalizeConditions( $conds, $fname );
+               $column = $this->extractSingleFieldFromList( $var );
+               if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
+                       $conds[] = "$column IS NOT NULL";
+               }
+
                $res = $this->select(
                        $table, [ 'rowcount' => 'COUNT(*)' ], $conds, $fname, $options, $join_conds
                );
@@ -1599,21 +1760,76 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function selectRowCount(
-               $tables, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+               $tables, $var = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
        ) {
-               $rows = 0;
-               $sql = $this->selectSQLText( $tables, '1', $conds, $fname, $options, $join_conds );
-               // The identifier quotes is primarily for MSSQL.
-               $rowCountCol = $this->addIdentifierQuotes( "rowcount" );
-               $tableName = $this->addIdentifierQuotes( "tmp_count" );
-               $res = $this->query( "SELECT COUNT(*) AS $rowCountCol FROM ($sql) $tableName", $fname );
+               $conds = $this->normalizeConditions( $conds, $fname );
+               $column = $this->extractSingleFieldFromList( $var );
+               if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
+                       $conds[] = "$column IS NOT NULL";
+               }
 
-               if ( $res ) {
-                       $row = $this->fetchRow( $res );
-                       $rows = ( isset( $row['rowcount'] ) ) ? (int)$row['rowcount'] : 0;
+               $res = $this->select(
+                       [
+                               'tmp_count' => $this->buildSelectSubquery(
+                                       $tables,
+                                       '1',
+                                       $conds,
+                                       $fname,
+                                       $options,
+                                       $join_conds
+                               )
+                       ],
+                       [ 'rowcount' => 'COUNT(*)' ],
+                       [],
+                       $fname
+               );
+               $row = $res ? $this->fetchRow( $res ) : [];
+
+               return isset( $row['rowcount'] ) ? (int)$row['rowcount'] : 0;
+       }
+
+       /**
+        * @param array|string $conds
+        * @param string $fname
+        * @return array
+        */
+       final protected function normalizeConditions( $conds, $fname ) {
+               if ( $conds === null || $conds === false ) {
+                       $this->queryLogger->warning(
+                               __METHOD__
+                               . ' called from '
+                               . $fname
+                               . ' with incorrect parameters: $conds must be a string or an array'
+                       );
+                       $conds = '';
                }
 
-               return $rows;
+               if ( !is_array( $conds ) ) {
+                       $conds = ( $conds === '' ) ? [] : [ $conds ];
+               }
+
+               return $conds;
+       }
+
+       /**
+        * @param array|string $var Field parameter in the style of select()
+        * @return string|null Column name or null; ignores aliases
+        * @throws DBUnexpectedError Errors out if multiple columns are given
+        */
+       final protected function extractSingleFieldFromList( $var ) {
+               if ( is_array( $var ) ) {
+                       if ( !$var ) {
+                               $column = null;
+                       } elseif ( count( $var ) == 1 ) {
+                               $column = isset( $var[0] ) ? $var[0] : reset( $var );
+                       } else {
+                               throw new DBUnexpectedError( $this, __METHOD__ . ': got multiple columns.' );
+                       }
+               } else {
+                       $column = $var;
+               }
+
+               return $column;
        }
 
        /**
@@ -1963,6 +2179,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return 'CAST( ' . $field . ' AS INTEGER )';
        }
 
+       public function buildSelectSubquery(
+               $table, $vars, $conds = '', $fname = __METHOD__,
+               $options = [], $join_conds = []
+       ) {
+               return new Subquery(
+                       $this->selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds )
+               );
+       }
+
        public function databasesAreIndependent() {
                return false;
        }
@@ -1985,6 +2210,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function tableName( $name, $format = 'quoted' ) {
+               if ( $name instanceof Subquery ) {
+                       throw new DBUnexpectedError(
+                               $this,
+                               __METHOD__ . ': got Subquery instance when expecting a string.'
+                       );
+               }
+
                # Skip the entire process when we have a string quoted on both ends.
                # Note that we check the end so that we will still quote any use of
                # use of `database`.table. But won't break things if someone wants
@@ -2001,6 +2233,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                # any remote case where a word like on may be inside of a table name
                # surrounded by symbols which may be considered word breaks.
                if ( preg_match( '/(^|\s)(DISTINCT|JOIN|ON|AS)(\s|$)/i', $name ) !== 0 ) {
+                       $this->queryLogger->warning(
+                               __METHOD__ . ": use of subqueries is not supported this way.",
+                               [ 'exception' => new RuntimeException() ]
+                       );
+
                        return $name;
                }
 
@@ -2105,17 +2342,32 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
        /**
         * Get an aliased table name
-        * e.g. tableName AS newTableName
         *
-        * @param string $name Table name, see tableName()
-        * @param string|bool $alias Alias (optional)
+        * This returns strings like "tableName AS newTableName" for aliased tables
+        * and "(SELECT * from tableA) newTablename" for subqueries (e.g. derived tables)
+        *
+        * @see Database::tableName()
+        * @param string|Subquery $table Table name or object with a 'sql' field
+        * @param string|bool $alias Table alias (optional)
         * @return string SQL name for aliased table. Will not alias a table to its own name
         */
-       protected function tableNameWithAlias( $name, $alias = false ) {
-               if ( !$alias || $alias == $name ) {
-                       return $this->tableName( $name );
+       protected function tableNameWithAlias( $table, $alias = false ) {
+               if ( is_string( $table ) ) {
+                       $quotedTable = $this->tableName( $table );
+               } elseif ( $table instanceof Subquery ) {
+                       $quotedTable = (string)$table;
+               } else {
+                       throw new InvalidArgumentException( "Table must be a string or Subquery." );
+               }
+
+               if ( !strlen( $alias ) || $alias === $table ) {
+                       if ( $table instanceof Subquery ) {
+                               throw new InvalidArgumentException( "Subquery table missing alias." );
+                       }
+
+                       return $quotedTable;
                } else {
-                       return $this->tableName( $name ) . ' ' . $this->addIdentifierQuotes( $alias );
+                       return $quotedTable . ' ' . $this->addIdentifierQuotes( $alias );
                }
        }
 
@@ -2414,7 +2666,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
 
                try {
-                       $this->startAtomic( $fname );
+                       $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
                        $affectedRowCount = 0;
                        foreach ( $rows as $row ) {
                                // Delete rows which collide with this one
@@ -2450,7 +2702,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->endAtomic( $fname );
                        $this->affectedRowCount = $affectedRowCount;
                } catch ( Exception $e ) {
-                       $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                       $this->cancelAtomic( $fname );
                        throw $e;
                }
        }
@@ -2519,7 +2771,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
                $affectedRowCount = 0;
                try {
-                       $this->startAtomic( $fname );
+                       $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
                        # Update any existing conflicting row(s)
                        if ( $where !== false ) {
                                $ok = $this->update( $table, $set, $where, $fname );
@@ -2533,7 +2785,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->endAtomic( $fname );
                        $this->affectedRowCount = $affectedRowCount;
                } catch ( Exception $e ) {
-                       $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                       $this->cancelAtomic( $fname );
                        throw $e;
                }
 
@@ -2675,7 +2927,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
                try {
                        $affectedRowCount = 0;
-                       $this->startAtomic( $fname );
+                       $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
                        $rows = [];
                        $ok = true;
                        foreach ( $res as $row ) {
@@ -2701,11 +2953,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                $this->endAtomic( $fname );
                                $this->affectedRowCount = $affectedRowCount;
                        } else {
-                               $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                               $this->cancelAtomic( $fname );
                        }
                        return $ok;
                } catch ( Exception $e ) {
-                       $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                       $this->cancelAtomic( $fname );
                        throw $e;
                }
        }
@@ -2888,14 +3140,22 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return false;
        }
 
-       public function wasErrorReissuable() {
-               return false;
+       public function wasConnectionLoss() {
+               return $this->wasConnectionError( $this->lastErrno() );
        }
 
        public function wasReadOnlyError() {
                return false;
        }
 
+       public function wasErrorReissuable() {
+               return (
+                       $this->wasDeadlock() ||
+                       $this->wasLockTimeout() ||
+                       $this->wasConnectionLoss()
+               );
+       }
+
        /**
         * Do not use this method outside of Database/DBError classes
         *
@@ -2906,6 +3166,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 );
@@ -2965,35 +3235,110 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                if ( !$this->trxLevel ) {
                        throw new DBUnexpectedError( $this, "No transaction is active." );
                }
-               $this->trxEndCallbacks[] = [ $callback, $fname ];
+               $this->trxEndCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
        }
 
        final public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) {
-               $this->trxIdleCallbacks[] = [ $callback, $fname ];
+               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, $this->currentAtomicSectionId() ];
                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.
-                       $this->trxPreCommitCallbacks[] = [ $callback, $fname ];
+               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, $this->currentAtomicSectionId() ];
                } else {
                        // No transaction is active nor will start implicitly, so make one for this callback
-                       $this->startAtomic( __METHOD__ );
+                       $this->startAtomic( __METHOD__, self::ATOMIC_CANCELABLE );
                        try {
                                call_user_func( $callback );
                                $this->endAtomic( __METHOD__ );
                        } catch ( Exception $e ) {
-                               $this->rollback( __METHOD__, self::FLUSHING_INTERNAL );
+                               $this->cancelAtomic( __METHOD__ );
                                throw $e;
                        }
                }
        }
 
+       /**
+        * @return AtomicSectionIdentifier|null ID of the topmost atomic section level
+        */
+       private function currentAtomicSectionId() {
+               if ( $this->trxLevel && $this->trxAtomicLevels ) {
+                       $levelInfo = end( $this->trxAtomicLevels );
+
+                       return $levelInfo[1];
+               }
+
+               return null;
+       }
+
+       /**
+        * @param AtomicSectionIdentifier $old
+        * @param AtomicSectionIdentifier $new
+        */
+       private function reassignCallbacksForSection(
+               AtomicSectionIdentifier $old, AtomicSectionIdentifier $new
+       ) {
+               foreach ( $this->trxPreCommitCallbacks as $key => $info ) {
+                       if ( $info[2] === $old ) {
+                               $this->trxPreCommitCallbacks[$key][2] = $new;
+                       }
+               }
+               foreach ( $this->trxIdleCallbacks as $key => $info ) {
+                       if ( $info[2] === $old ) {
+                               $this->trxIdleCallbacks[$key][2] = $new;
+                       }
+               }
+               foreach ( $this->trxEndCallbacks as $key => $info ) {
+                       if ( $info[2] === $old ) {
+                               $this->trxEndCallbacks[$key][2] = $new;
+                       }
+               }
+       }
+
+       /**
+        * @param AtomicSectionIdentifier[] $sectionIds ID of an actual savepoint
+        * @throws UnexpectedValueException
+        */
+       private function modifyCallbacksForCancel( array $sectionIds ) {
+               // Cancel the "on commit" callbacks owned by this savepoint
+               $this->trxIdleCallbacks = array_filter(
+                       $this->trxIdleCallbacks,
+                       function ( $entry ) use ( $sectionIds ) {
+                               return !in_array( $entry[2], $sectionIds, true );
+                       }
+               );
+               $this->trxPreCommitCallbacks = array_filter(
+                       $this->trxPreCommitCallbacks,
+                       function ( $entry ) use ( $sectionIds ) {
+                               return !in_array( $entry[2], $sectionIds, true );
+                       }
+               );
+               // Make "on resolution" callbacks owned by this savepoint to perceive a rollback
+               foreach ( $this->trxEndCallbacks as $key => $entry ) {
+                       if ( in_array( $entry[2], $sectionIds, true ) ) {
+                               $callback = $entry[0];
+                               $this->trxEndCallbacks[$key][0] = function () use ( $callback ) {
+                                       return $callback( self::TRIGGER_ROLLBACK );
+                               };
+                       }
+               }
+       }
+
        final public function setTransactionListener( $name, callable $callback = null ) {
                if ( $callback ) {
                        $this->trxRecurringCallbacks[$name] = $callback;
@@ -3125,40 +3470,201 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
        }
 
-       final public function startAtomic( $fname = __METHOD__ ) {
+       /**
+        * Create a savepoint
+        *
+        * This is used internally to implement atomic sections. It should not be
+        * used otherwise.
+        *
+        * @since 1.31
+        * @param string $identifier Identifier for the savepoint
+        * @param string $fname Calling function name
+        */
+       protected function doSavepoint( $identifier, $fname ) {
+               $this->query( 'SAVEPOINT ' . $this->addIdentifierQuotes( $identifier ), $fname );
+       }
+
+       /**
+        * Release a savepoint
+        *
+        * This is used internally to implement atomic sections. It should not be
+        * used otherwise.
+        *
+        * @since 1.31
+        * @param string $identifier Identifier for the savepoint
+        * @param string $fname Calling function name
+        */
+       protected function doReleaseSavepoint( $identifier, $fname ) {
+               $this->query( 'RELEASE SAVEPOINT ' . $this->addIdentifierQuotes( $identifier ), $fname );
+       }
+
+       /**
+        * Rollback to a savepoint
+        *
+        * This is used internally to implement atomic sections. It should not be
+        * used otherwise.
+        *
+        * @since 1.31
+        * @param string $identifier Identifier for the savepoint
+        * @param string $fname Calling function name
+        */
+       protected function doRollbackToSavepoint( $identifier, $fname ) {
+               $this->query( 'ROLLBACK TO SAVEPOINT ' . $this->addIdentifierQuotes( $identifier ), $fname );
+       }
+
+       /**
+        * @param string $fname
+        * @return string
+        */
+       private function nextSavepointId( $fname ) {
+               $savepointId = self::$SAVEPOINT_PREFIX . ++$this->trxAtomicCounter;
+               if ( strlen( $savepointId ) > 30 ) {
+                       // 30 == Oracle's identifier length limit (pre 12c)
+                       // With a 22 character prefix, that puts the highest number at 99999999.
+                       throw new DBUnexpectedError(
+                               $this,
+                               'There have been an excessively large number of atomic sections in a transaction'
+                               . " started by $this->trxFname (at $fname)"
+                       );
+               }
+
+               return $savepointId;
+       }
+
+       final public function startAtomic(
+               $fname = __METHOD__, $cancelable = self::ATOMIC_NOT_CANCELABLE
+       ) {
+               $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? self::$NOT_APPLICABLE : null;
+
                if ( !$this->trxLevel ) {
                        $this->begin( $fname, self::TRANSACTION_INTERNAL );
                        // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result
                        // in all changes being in one transaction to keep requests transactional.
-                       if ( !$this->getFlag( self::DBO_TRX ) ) {
+                       if ( $this->getFlag( self::DBO_TRX ) ) {
+                               // Since writes could happen in between the topmost atomic sections as part
+                               // of the transaction, those sections will need savepoints.
+                               $savepointId = $this->nextSavepointId( $fname );
+                               $this->doSavepoint( $savepointId, $fname );
+                       } else {
                                $this->trxAutomaticAtomic = true;
                        }
+               } elseif ( $cancelable === self::ATOMIC_CANCELABLE ) {
+                       $savepointId = $this->nextSavepointId( $fname );
+                       $this->doSavepoint( $savepointId, $fname );
                }
 
-               $this->trxAtomicLevels[] = $fname;
+               $sectionId = new AtomicSectionIdentifier;
+               $this->trxAtomicLevels[] = [ $fname, $sectionId, $savepointId ];
+
+               return $sectionId;
        }
 
        final public function endAtomic( $fname = __METHOD__ ) {
-               if ( !$this->trxLevel ) {
-                       throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." );
+               if ( !$this->trxLevel || !$this->trxAtomicLevels ) {
+                       throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
                }
-               if ( !$this->trxAtomicLevels ||
-                       array_pop( $this->trxAtomicLevels ) !== $fname
-               ) {
-                       throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." );
+
+               // Check if the current section matches $fname
+               $pos = count( $this->trxAtomicLevels ) - 1;
+               list( $savedFname, $sectionId, $savepointId ) = $this->trxAtomicLevels[$pos];
+
+               if ( $savedFname !== $fname ) {
+                       throw new DBUnexpectedError(
+                               $this,
+                               "Invalid atomic section ended (got $fname but expected $savedFname)."
+                       );
                }
 
+               // Remove the last section (no need to re-index the array)
+               array_pop( $this->trxAtomicLevels );
+
                if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) {
                        $this->commit( $fname, self::FLUSHING_INTERNAL );
+               } elseif ( $savepointId !== null && $savepointId !== self::$NOT_APPLICABLE ) {
+                       $this->doReleaseSavepoint( $savepointId, $fname );
+               }
+
+               // Hoist callback ownership for callbacks in the section that just ended;
+               // all callbacks should have an owner that is present in trxAtomicLevels.
+               $currentSectionId = $this->currentAtomicSectionId();
+               if ( $currentSectionId ) {
+                       $this->reassignCallbacksForSection( $sectionId, $currentSectionId );
+               }
+       }
+
+       final public function cancelAtomic(
+               $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null
+       ) {
+               if ( !$this->trxLevel || !$this->trxAtomicLevels ) {
+                       throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
+               }
+
+               if ( $sectionId !== null ) {
+                       // Find the (last) section with the given $sectionId
+                       $pos = -1;
+                       foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) {
+                               if ( $asId === $sectionId ) {
+                                       $pos = $i;
+                               }
+                       }
+                       if ( $pos < 0 ) {
+                               throw new DBUnexpectedError( "Atomic section not found (for $fname)" );
+                       }
+                       // Remove all descendant sections and re-index the array
+                       $excisedIds = [];
+                       $len = count( $this->trxAtomicLevels );
+                       for ( $i = $pos + 1; $i < $len; ++$i ) {
+                               $excisedIds[] = $this->trxAtomicLevels[$i][1];
+                       }
+                       $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 );
+                       $this->modifyCallbacksForCancel( $excisedIds );
+               }
+
+               // Check if the current section matches $fname
+               $pos = count( $this->trxAtomicLevels ) - 1;
+               list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos];
+
+               if ( $savedFname !== $fname ) {
+                       throw new DBUnexpectedError(
+                               $this,
+                               "Invalid atomic section ended (got $fname but expected $savedFname)."
+                       );
                }
+
+               // Remove the last section (no need to re-index the array)
+               array_pop( $this->trxAtomicLevels );
+               $this->modifyCallbacksForCancel( [ $savedSectionId ] );
+
+               if ( $savepointId !== null ) {
+                       // Rollback the transaction to the state just before this atomic section
+                       if ( $savepointId === self::$NOT_APPLICABLE ) {
+                               $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                       } else {
+                               $this->doRollbackToSavepoint( $savepointId, $fname );
+                               $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered
+                               $this->trxStatusIgnoredCause = null;
+                       }
+               } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) {
+                       // Put the transaction into an error state if it's not already in one
+                       $this->trxStatus = self::STATUS_TRX_ERROR;
+                       $this->trxStatusCause = new DBUnexpectedError(
+                               $this,
+                               "Uncancelable atomic section canceled (got $fname)."
+                       );
+               }
+
+               $this->affectedRowCount = 0; // for the sake of consistency
        }
 
-       final public function doAtomicSection( $fname, callable $callback ) {
-               $this->startAtomic( $fname );
+       final public function doAtomicSection(
+               $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE
+       ) {
+               $sectionId = $this->startAtomic( $fname, $cancelable );
                try {
                        $res = call_user_func_array( $callback, [ $this, $fname ] );
                } catch ( Exception $e ) {
-                       $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                       $this->cancelAtomic( $fname, $sectionId );
+
                        throw $e;
                }
                $this->endAtomic( $fname );
@@ -3167,32 +3673,36 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        final public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT ) {
+               static $modes = [ self::TRANSACTION_EXPLICIT, self::TRANSACTION_INTERNAL ];
+               if ( !in_array( $mode, $modes, true ) ) {
+                       throw new DBUnexpectedError( $this, "$fname: invalid mode parameter '$mode'." );
+               }
+
                // Protect against mismatched atomic section, transaction nesting, and snapshot loss
                if ( $this->trxLevel ) {
                        if ( $this->trxAtomicLevels ) {
-                               $levels = implode( ', ', $this->trxAtomicLevels );
+                               $levels = $this->flatAtomicSectionList();
                                $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
                                throw new DBUnexpectedError( $this, $msg );
                        } elseif ( !$this->trxAutomatic ) {
                                $msg = "$fname: Explicit transaction already active (from {$this->trxFname}).";
                                throw new DBUnexpectedError( $this, $msg );
                        } else {
-                               // @TODO: make this an exception at some point
                                $msg = "$fname: Implicit transaction already active (from {$this->trxFname}).";
-                               $this->queryLogger->error( $msg );
-                               return; // join the main transaction set
+                               throw new DBUnexpectedError( $this, $msg );
                        }
                } elseif ( $this->getFlag( self::DBO_TRX ) && $mode !== self::TRANSACTION_INTERNAL ) {
-                       // @TODO: make this an exception at some point
                        $msg = "$fname: Implicit transaction expected (DBO_TRX set).";
-                       $this->queryLogger->error( $msg );
-                       return; // let any writes be in the main transaction
+                       throw new DBUnexpectedError( $this, $msg );
                }
 
                // Avoid fatals if close() was called
                $this->assertOpen();
 
                $this->doBegin( $fname );
+               $this->trxStatus = self::STATUS_TRX_OK;
+               $this->trxStatusIgnoredCause = null;
+               $this->trxAtomicCounter = 0;
                $this->trxTimestamp = microtime( true );
                $this->trxFname = $fname;
                $this->trxDoneWrites = false;
@@ -3206,10 +3716,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->trxWriteAdjQueryCount = 0;
                $this->trxWriteCallers = [];
                // First SELECT after BEGIN will establish the snapshot in REPEATABLE-READ.
-               // Get an estimate of the replica DB lag before then, treating estimate staleness
-               // as lag itself just to be safe
-               $status = $this->getApproximateLagStatus();
-               $this->trxReplicaLag = $status['lag'] + ( microtime( true ) - $status['since'] );
+               // 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()
                // has not yet completed (e.g. setting trxAtomicLevels).
@@ -3227,10 +3736,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->trxLevel = 1;
        }
 
-       final public function commit( $fname = __METHOD__, $flush = '' ) {
+       final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
+               static $modes = [ self::FLUSHING_ONE, self::FLUSHING_ALL_PEERS, self::FLUSHING_INTERNAL ];
+               if ( !in_array( $flush, $modes, true ) ) {
+                       throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'." );
+               }
+
                if ( $this->trxLevel && $this->trxAtomicLevels ) {
-                       // There are still atomic sections open. This cannot be ignored
-                       $levels = implode( ', ', $this->trxAtomicLevels );
+                       // There are still atomic sections open; this cannot be ignored
+                       $levels = $this->flatAtomicSectionList();
                        throw new DBUnexpectedError(
                                $this,
                                "$fname: Got COMMIT while atomic sections $levels are still open."
@@ -3252,10 +3766,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                        "$fname: No transaction to commit, something got out of sync." );
                                return; // nothing to do
                        } elseif ( $this->trxAutomatic ) {
-                               // @TODO: make this an exception at some point
-                               $msg = "$fname: Explicit commit of implicit transaction.";
-                               $this->queryLogger->error( $msg );
-                               return; // wait for the main transaction set commit round
+                               throw new DBUnexpectedError(
+                                       $this,
+                                       "$fname: Expected mass commit of all peer transactions (DBO_TRX set)."
+                               );
                        }
                }
 
@@ -3265,6 +3779,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(
@@ -3294,50 +3809,52 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        final public function rollback( $fname = __METHOD__, $flush = '' ) {
-               if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) {
-                       if ( !$this->trxLevel ) {
-                               return; // nothing to do
-                       }
-               } else {
-                       if ( !$this->trxLevel ) {
-                               $this->queryLogger->error(
-                                       "$fname: No transaction to rollback, something got out of sync." );
-                               return; // nothing to do
-                       } elseif ( $this->getFlag( self::DBO_TRX ) ) {
+               $trxActive = $this->trxLevel;
+
+               if ( $flush !== self::FLUSHING_INTERNAL && $flush !== self::FLUSHING_ALL_PEERS ) {
+                       if ( $this->getFlag( self::DBO_TRX ) ) {
                                throw new DBUnexpectedError(
                                        $this,
-                                       "$fname: Expected mass rollback of all peer databases (DBO_TRX set)."
+                                       "$fname: Expected mass rollback of all peer transactions (DBO_TRX set)."
                                );
                        }
                }
 
-               // Avoid fatals if close() was called
-               $this->assertOpen();
+               if ( $trxActive ) {
+                       // Avoid fatals if close() was called
+                       $this->assertOpen();
 
-               $this->doRollback( $fname );
-               $this->trxAtomicLevels = [];
-               if ( $this->trxDoneWrites ) {
-                       $this->trxProfiler->transactionWritingOut(
-                               $this->server,
-                               $this->dbName,
-                               $this->trxShortId
-                       );
+                       $this->doRollback( $fname );
+                       $this->trxStatus = self::STATUS_TRX_NONE;
+                       $this->trxAtomicLevels = [];
+                       if ( $this->trxDoneWrites ) {
+                               $this->trxProfiler->transactionWritingOut(
+                                       $this->server,
+                                       $this->dbName,
+                                       $this->trxShortId
+                               );
+                       }
                }
 
-               $this->trxIdleCallbacks = []; // clear
-               $this->trxPreCommitCallbacks = []; // clear
-               try {
-                       $this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK );
-               } catch ( Exception $e ) {
-                       // already logged; finish and let LoadBalancer move on during mass-rollback
-               }
-               try {
-                       $this->runTransactionListenerCallbacks( self::TRIGGER_ROLLBACK );
-               } catch ( Exception $e ) {
-                       // already logged; let LoadBalancer move on during mass-rollback
-               }
+               // Clear any commit-dependant callbacks. They might even be present
+               // only due to transaction rounds, with no SQL transaction being active
+               $this->trxIdleCallbacks = [];
+               $this->trxPreCommitCallbacks = [];
 
-               $this->affectedRowCount = 0; // for the sake of consistency
+               if ( $trxActive ) {
+                       try {
+                               $this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK );
+                       } catch ( Exception $e ) {
+                               // already logged; finish and let LoadBalancer move on during mass-rollback
+                       }
+                       try {
+                               $this->runTransactionListenerCallbacks( self::TRIGGER_ROLLBACK );
+                       } catch ( Exception $e ) {
+                               // already logged; let LoadBalancer move on during mass-rollback
+                       }
+
+                       $this->affectedRowCount = 0; // for the sake of consistency
+               }
        }
 
        /**
@@ -3459,11 +3976,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        /**
-        * 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;
@@ -3471,15 +3989,30 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $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(),
+                                       'exception' => new RuntimeException()
+                               ]
+                       );
                } 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();
        }
 
        /**
@@ -3490,11 +4023,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
         */
-       protected function getTransactionLagStatus() {
-               return $this->trxLevel
+       final protected function getRecordedTransactionLagStatus() {
+               return ( $this->trxLevel && $this->trxReplicaLag !== null )
                        ? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ]
                        : null;
        }
@@ -3975,8 +4510,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         */
        public function __clone() {
                $this->connLogger->warning(
-                       "Cloning " . static::class . " is not recomended; forking connection:\n" .
-                       ( new RuntimeException() )->getTraceAsString()
+                       "Cloning " . static::class . " is not recommended; forking connection",
+                       [ 'exception' => new RuntimeException() ]
                );
 
                if ( $this->isOpen() ) {