Merge "Replace spinner.gif with CSS solution"
[lhc/web/wiklou.git] / includes / libs / rdbms / database / Database.php
index 13bf8f0..dea7aab 100644 (file)
@@ -280,6 +280,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var int No transaction is active */
        const STATUS_TRX_NONE = 3;
 
+       /** @var int Writes to this temporary table do not affect lastDoneWrites() */
+       const TEMP_NORMAL = 1;
+       /** @var int Writes to this temporary table effect lastDoneWrites() */
+       const TEMP_PSEUDO_PERMANENT = 2;
+
        /**
         * @note exceptions for missing libraries/drivers should be thrown in initConnection()
         * @param array $params Parameters passed from Database::factory()
@@ -926,6 +931,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        final public function close() {
                $exception = null; // error to throw after disconnecting
 
+               $wasOpen = $this->opened;
+               // This should mostly do nothing if the connection is already closed
                if ( $this->conn ) {
                        // Roll back any dangling transaction first
                        if ( $this->trxLevel ) {
@@ -968,11 +975,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
                        // Close the actual connection in the binding handle
                        $closed = $this->closeConnection();
-                       $this->conn = false;
                } else {
                        $closed = true; // already closed; nothing to do
                }
 
+               $this->conn = false;
                $this->opened = false;
 
                // Throw any unexpected errors after having disconnected
@@ -980,28 +987,55 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        throw $exception;
                }
 
-               // Sanity check that no callbacks are dangling
-               $fnames = $this->pendingWriteAndCallbackCallers();
-               if ( $fnames ) {
-                       throw new RuntimeException(
-                               "Transaction callbacks are still pending:\n" . implode( ', ', $fnames )
-                       );
+               // Note that various subclasses call close() at the start of open(), which itself is
+               // called by replaceLostConnection(). In that case, just because onTransactionResolution()
+               // callbacks are pending does not mean that an exception should be thrown. Rather, they
+               // will be executed after the reconnection step.
+               if ( $wasOpen ) {
+                       // Sanity check that no callbacks are dangling
+                       $fnames = $this->pendingWriteAndCallbackCallers();
+                       if ( $fnames ) {
+                               throw new RuntimeException(
+                                       "Transaction callbacks are still pending:\n" . implode( ', ', $fnames )
+                               );
+                       }
                }
 
                return $closed;
        }
 
        /**
-        * Make sure isOpen() returns true as a sanity check
+        * Make sure there is an open connection handle (alive or not) as a sanity check
+        *
+        * This guards against fatal errors to the binding handle not being defined
+        * in cases where open() was never called or close() was already called
         *
         * @throws DBUnexpectedError
         */
-       protected function assertOpen() {
+       protected function assertHasConnectionHandle() {
                if ( !$this->isOpen() ) {
                        throw new DBUnexpectedError( $this, "DB connection was already closed." );
                }
        }
 
+       /**
+        * Make sure that this server is not marked as a replica nor read-only as a sanity check
+        *
+        * @throws DBUnexpectedError
+        */
+       protected function assertIsWritableMaster() {
+               if ( $this->getLBInfo( 'replica' ) === true ) {
+                       throw new DBUnexpectedError(
+                               $this,
+                               'Write operations are not allowed on replica database connections.'
+                       );
+               }
+               $reason = $this->getReadOnlyReason();
+               if ( $reason !== false ) {
+                       throw new DBReadOnlyError( $this, "Database is read-only: $reason" );
+               }
+       }
+
        /**
         * Closes underlying database connection
         * @since 1.20
@@ -1106,131 +1140,114 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
        /**
         * @param string $sql A SQL query
-        * @return bool Whether $sql is SQL for TEMPORARY table operation
+        * @param bool $pseudoPermanent Treat any table from CREATE TEMPORARY as pseudo-permanent
+        * @return int|null A self::TEMP_* constant for temp table operations or null otherwise
         */
-       protected function registerTempTableOperation( $sql ) {
+       protected function registerTempTableWrite( $sql, $pseudoPermanent ) {
+               static $qt = '[`"\']?(\w+)[`"\']?'; // quoted table
+
                if ( preg_match(
-                       '/^CREATE\s+TEMPORARY\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i',
+                       '/^CREATE\s+TEMPORARY\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?' . $qt . '/i',
                        $sql,
                        $matches
                ) ) {
-                       $this->sessionTempTables[$matches[1]] = 1;
+                       $type = $pseudoPermanent ? self::TEMP_PSEUDO_PERMANENT : self::TEMP_NORMAL;
+                       $this->sessionTempTables[$matches[1]] = $type;
 
-                       return true;
+                       return $type;
                } elseif ( preg_match(
-                       '/^DROP\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i',
+                       '/^DROP\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?' . $qt . '/i',
                        $sql,
                        $matches
                ) ) {
-                       $isTemp = isset( $this->sessionTempTables[$matches[1]] );
+                       $type = $this->sessionTempTables[$matches[1]] ?? null;
                        unset( $this->sessionTempTables[$matches[1]] );
 
-                       return $isTemp;
+                       return $type;
                } elseif ( preg_match(
-                       '/^TRUNCATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i',
+                       '/^TRUNCATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?' . $qt . '/i',
                        $sql,
                        $matches
                ) ) {
-                       return isset( $this->sessionTempTables[$matches[1]] );
+                       return $this->sessionTempTables[$matches[1]] ?? null;
                } elseif ( preg_match(
-                       '/^(?:INSERT\s+(?:\w+\s+)?INTO|UPDATE|DELETE\s+FROM)\s+[`"\']?(\w+)[`"\']?/i',
+                       '/^(?:(?:INSERT|REPLACE)\s+(?:\w+\s+)?INTO|UPDATE|DELETE\s+FROM)\s+' . $qt . '/i',
                        $sql,
                        $matches
                ) ) {
-                       return isset( $this->sessionTempTables[$matches[1]] );
+                       return $this->sessionTempTables[$matches[1]] ?? null;
                }
 
-               return false;
+               return null;
        }
 
-       public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
+       public function query( $sql, $fname = __METHOD__, $flags = 0 ) {
                $this->assertTransactionStatus( $sql, $fname );
+               $this->assertHasConnectionHandle();
 
-               # Avoid fatals if close() was called
-               $this->assertOpen();
+               $flags = (int)$flags; // b/c; this field used to be a bool
+               $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS );
+               $pseudoPermanent = $this->hasFlags( $flags, self::QUERY_PSEUDO_PERMANENT );
 
+               $priorTransaction = $this->trxLevel;
                $priorWritesPending = $this->writesOrCallbacksPending();
                $this->lastQuery = $sql;
 
-               $isWrite = $this->isWriteQuery( $sql );
-               if ( $isWrite ) {
-                       $isNonTempWrite = !$this->registerTempTableOperation( $sql );
-               } else {
-                       $isNonTempWrite = false;
-               }
-
-               if ( $isWrite ) {
-                       if ( $this->getLBInfo( 'replica' ) === true ) {
-                               throw new DBError(
-                                       $this,
-                                       'Write operations are not allowed on replica database connections.'
-                               );
-                       }
+               if ( $this->isWriteQuery( $sql ) ) {
                        # In theory, non-persistent writes are allowed in read-only mode, but due to things
                        # like https://bugs.mysql.com/bug.php?id=33669 that might not work anyway...
-                       $reason = $this->getReadOnlyReason();
-                       if ( $reason !== false ) {
-                               throw new DBReadOnlyError( $this, "Database is read-only: $reason" );
-                       }
-                       # Set a flag indicating that writes have been done
-                       $this->lastWriteTime = microtime( true );
+                       $this->assertIsWritableMaster();
+                       # Do not treat temporary table writes as "meaningful writes" that need committing.
+                       # Profile them as reads. Integration tests can override this behavior via $flags.
+                       $tableType = $this->registerTempTableWrite( $sql, $pseudoPermanent );
+                       $isEffectiveWrite = ( $tableType !== self::TEMP_NORMAL );
+               } else {
+                       $isEffectiveWrite = false;
                }
 
                # Add trace comment to the begin of the sql string, right after the operator.
                # Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598)
                $commentedSql = preg_replace( '/\s|$/', " /* $fname {$this->agent} */ ", $sql, 1 );
 
-               # Start implicit transactions that wrap the request if DBO_TRX is enabled
-               if ( !$this->trxLevel && $this->getFlag( self::DBO_TRX )
-                       && $this->isTransactableQuery( $sql )
-               ) {
-                       $this->begin( __METHOD__ . " ($fname)", self::TRANSACTION_INTERNAL );
-                       $this->trxAutomatic = true;
-               }
-
-               # Keep track of whether the transaction has write queries pending
-               if ( $this->trxLevel && !$this->trxDoneWrites && $isWrite ) {
-                       $this->trxDoneWrites = true;
-                       $this->trxProfiler->transactionWritingIn(
-                               $this->server, $this->getDomainID(), $this->trxShortId );
-               }
-
-               if ( $this->getFlag( self::DBO_DEBUG ) ) {
-                       $this->queryLogger->debug( "{$this->getDomainID()} {$commentedSql}" );
-               }
-
                # Send the query to the server and fetch any corresponding errors
-               $ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname );
+               $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname );
                $lastError = $this->lastError();
                $lastErrno = $this->lastErrno();
 
-               # Try reconnecting if the connection was lost
+               $recoverableSR = false; // recoverable statement rollback?
+               $recoverableCL = false; // recoverable connection loss?
+
                if ( $ret === false && $this->wasConnectionLoss() ) {
-                       # Check if any meaningful session state was lost
-                       $recoverable = $this->canRecoverFromDisconnect( $sql, $priorWritesPending );
+                       # Check if no meaningful session state was lost
+                       $recoverableCL = $this->canRecoverFromDisconnect( $sql, $priorWritesPending );
                        # 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 );
+                       if ( $recoverableCL && $reconnected ) {
+                               $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $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 {
+                                       $recoverableCL = false; // connection does not need recovering
+                                       $recoverableSR = $this->wasKnownStatementRollbackError();
                                }
                        }
+               } else {
+                       $recoverableSR = $this->wasKnownStatementRollbackError();
                }
 
                if ( $ret === false ) {
-                       if ( $this->trxLevel ) {
-                               if ( $this->wasKnownStatementRollbackError() ) {
+                       if ( $priorTransaction ) {
+                               if ( $recoverableSR ) {
                                        # 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 ];
-                               } else {
+                               } elseif ( !$recoverableCL ) {
                                        # Either the query was aborted or all queries after BEGIN where aborted.
                                        # 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
@@ -1238,12 +1255,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                        $this->trxStatus = self::STATUS_TRX_ERROR;
                                        $this->trxStatusCause =
                                                $this->getQueryExceptionAndLog( $lastError, $lastErrno, $sql, $fname );
-                                       $tempIgnore = false; // cannot recover
+                                       $ignoreErrors = false; // cannot recover
                                        $this->trxStatusIgnoredCause = null;
                                }
                        }
 
-                       $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $tempIgnore );
+                       $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $ignoreErrors );
                }
 
                return $this->resultObject( $ret );
@@ -1254,12 +1271,28 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         *
         * @param string $sql Original SQL query
         * @param string $commentedSql SQL query with debugging/trace comment
-        * @param bool $isWrite Whether the query is a (non-temporary) write operation
+        * @param bool $isEffectiveWrite Whether the query is a (non-temporary table) write
         * @param string $fname Name of the calling function
         * @return bool|ResultWrapper True for a successful write query, ResultWrapper
         *     object for a successful read query, or false on failure
         */
-       private function doProfiledQuery( $sql, $commentedSql, $isWrite, $fname ) {
+       private function attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ) {
+               $this->beginIfImplied( $sql, $fname );
+
+               # Keep track of whether the transaction has write queries pending
+               if ( $isEffectiveWrite ) {
+                       $this->lastWriteTime = microtime( true );
+                       if ( $this->trxLevel && !$this->trxDoneWrites ) {
+                               $this->trxDoneWrites = true;
+                               $this->trxProfiler->transactionWritingIn(
+                                       $this->server, $this->getDomainID(), $this->trxShortId );
+                       }
+               }
+
+               if ( $this->getFlag( self::DBO_DEBUG ) ) {
+                       $this->queryLogger->debug( "{$this->getDomainID()} {$commentedSql}" );
+               }
+
                $isMaster = !is_null( $this->getLBInfo( 'master' ) );
                # generalizeSQL() will probably cut down the query to reasonable
                # logging size most of the time. The substr is really just a sanity check.
@@ -1282,7 +1315,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
                if ( $ret !== false ) {
                        $this->lastPing = $startTime;
-                       if ( $isWrite && $this->trxLevel ) {
+                       if ( $isEffectiveWrite && $this->trxLevel ) {
                                $this->updateTrxWriteQueryTime( $sql, $queryRuntime, $this->affectedRows() );
                                $this->trxWriteCallers[] = $fname;
                        }
@@ -1295,8 +1328,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->trxProfiler->recordQueryCompletion(
                        $queryProf,
                        $startTime,
-                       $isWrite,
-                       $isWrite ? $this->affectedRows() : $this->numRows( $ret )
+                       $isEffectiveWrite,
+                       $isEffectiveWrite ? $this->affectedRows() : $this->numRows( $ret )
                );
                $this->queryLogger->debug( $sql, [
                        'method' => $fname,
@@ -1307,6 +1340,23 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return $ret;
        }
 
+       /**
+        * Start an implicit transaction if DBO_TRX is enabled and no transaction is active
+        *
+        * @param string $sql
+        * @param string $fname
+        */
+       private function beginIfImplied( $sql, $fname ) {
+               if (
+                       !$this->trxLevel &&
+                       $this->getFlag( self::DBO_TRX ) &&
+                       $this->isTransactableQuery( $sql )
+               ) {
+                       $this->begin( __METHOD__ . " ($fname)", self::TRANSACTION_INTERNAL );
+                       $this->trxAutomatic = true;
+               }
+       }
+
        /**
         * Update the estimated run-time of a query, not counting large row lock times
         *
@@ -1386,8 +1436,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        /**
-        * Determine whether or not it is safe to retry queries after a database
-        * connection is lost
+        * Determine whether it is safe to retry queries after a database connection is lost
         *
         * @param string $sql SQL query
         * @param bool $priorWritesPending Whether there is a transaction open with
@@ -1418,9 +1467,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        /**
-        * Clean things up after session (and thus transaction) loss
+        * Clean things up after session (and thus transaction) loss before reconnect
         */
-       private function handleSessionLoss() {
+       private function handleSessionLossPreconnect() {
                // 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)
@@ -1429,17 +1478,26 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
                $this->namedLocksHeld = [];
                // Session loss implies transaction loss
-               $this->handleTransactionLoss();
-       }
-
-       /**
-        * Clean things up after transaction loss
-        */
-       private function handleTransactionLoss() {
                $this->trxLevel = 0;
                $this->trxAtomicCounter = 0;
                $this->trxIdleCallbacks = []; // T67263; transaction already lost
                $this->trxPreCommitCallbacks = []; // T67263; transaction already lost
+               // @note: leave trxRecurringCallbacks in place
+               if ( $this->trxDoneWrites ) {
+                       $this->trxProfiler->transactionWritingOut(
+                               $this->server,
+                               $this->getDomainID(),
+                               $this->trxShortId,
+                               $this->pendingWriteQueryDuration( self::ESTIMATE_TOTAL ),
+                               $this->trxWriteAffectedRows
+                       );
+               }
+       }
+
+       /**
+        * Clean things up after session (and thus transaction) loss after reconnect
+        */
+       private function handleSessionLossPostconnect() {
                try {
                        // Handle callbacks in trxEndCallbacks, e.g. onTransactionResolution().
                        // If callback suppression is set then the array will remain unhandled.
@@ -1471,17 +1529,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
        /**
         * Report a query error. Log the error, and if neither the object ignore
-        * flag nor the $tempIgnore flag is set, throw a DBQueryError.
+        * flag nor the $ignoreErrors flag is set, throw a DBQueryError.
         *
         * @param string $error
         * @param int $errno
         * @param string $sql
         * @param string $fname
-        * @param bool $tempIgnore
+        * @param bool $ignoreErrors
         * @throws DBQueryError
         */
-       public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
-               if ( $tempIgnore ) {
+       public function reportQueryError( $error, $errno, $sql, $fname, $ignoreErrors = false ) {
+               if ( $ignoreErrors ) {
                        $this->queryLogger->debug( "SQL ERROR (ignored): $error\n" );
                } else {
                        $exception = $this->getQueryExceptionAndLog( $error, $errno, $sql, $fname );
@@ -3284,7 +3342,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        /**
-        * @return bool Whether it is safe to assume the given error only caused statement rollback
+        * @return bool Whether it is known that the last query 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
@@ -3845,8 +3903,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        throw new DBUnexpectedError( $this, $msg );
                }
 
-               // Avoid fatals if close() was called
-               $this->assertOpen();
+               $this->assertHasConnectionHandle();
 
                $this->doBegin( $fname );
                $this->trxStatus = self::STATUS_TRX_OK;
@@ -3922,8 +3979,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        }
                }
 
-               // Avoid fatals if close() was called
-               $this->assertOpen();
+               $this->assertHasConnectionHandle();
 
                $this->runOnTransactionPreCommitCallbacks();
 
@@ -3975,8 +4031,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
 
                if ( $trxActive ) {
-                       // Avoid fatals if close() was called
-                       $this->assertOpen();
+                       $this->assertHasConnectionHandle();
 
                        $this->doRollback( $fname );
                        $this->trxStatus = self::STATUS_TRX_NONE;
@@ -4145,6 +4200,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->closeConnection();
                $this->opened = false;
                $this->conn = false;
+
+               $this->handleSessionLossPreconnect();
+
                try {
                        $this->open(
                                $this->server,
@@ -4173,7 +4231,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        );
                }
 
-               $this->handleSessionLoss();
+               $this->handleSessionLossPostconnect();
 
                return $ok;
        }
@@ -4641,6 +4699,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->indexAliases = $aliases;
        }
 
+       /**
+        * @param int $field
+        * @param int $flags
+        * @return bool
+        */
+       protected function hasFlags( $field, $flags ) {
+               return ( ( $field & $flags ) === $flags );
+       }
+
        /**
         * Get the underlying binding connection handle
         *
@@ -4686,7 +4753,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->opened = false;
                        $this->conn = false;
                        $this->trxEndCallbacks = []; // don't copy
-                       $this->handleSessionLoss(); // no trx or locks anymore
+                       $this->handleSessionLossPreconnect(); // no trx or locks anymore
                        $this->open(
                                $this->server,
                                $this->user,