X-Git-Url: https://git.cyclocoop.org/%7B%24admin_url%7Dcompta/operations/modifier.php?a=blobdiff_plain;f=includes%2Flibs%2Frdbms%2Fdatabase%2FDatabase.php;h=056f18959fb9c3984dbefb101855b8b797d046d5;hb=6c169ee1fd84dcf82596edc8c696eff40f2b9aed;hp=53cf55caf18dd7eb2ea1fcc592e7e1d29a43b670;hpb=51995087f101b654e07b3a660b886a455d7a667b;p=lhc%2Fweb%2Fwiklou.git diff --git a/includes/libs/rdbms/database/Database.php b/includes/libs/rdbms/database/Database.php index 53cf55caf1..056f18959f 100644 --- a/includes/libs/rdbms/database/Database.php +++ b/includes/libs/rdbms/database/Database.php @@ -35,6 +35,7 @@ use BagOStuff; use HashBagOStuff; use LogicException; use InvalidArgumentException; +use UnexpectedValueException; use Exception; use RuntimeException; @@ -187,6 +188,12 @@ 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 * @@ -276,6 +283,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']; @@ -531,32 +540,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; } @@ -652,6 +635,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; @@ -713,7 +710,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware 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 +721,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 ) { @@ -906,6 +903,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 ) { @@ -1057,38 +1058,31 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware # 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 ) { + if ( $ret === false ) { # Deadlocks cause the entire transaction to abort, not just the statement. # https://dev.mysql.com/doc/refman/5.7/en/innodb-error-handling.html # https://www.postgresql.org/docs/9.1/static/explicit-locking.html @@ -1096,17 +1090,19 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $this->explicitTrxActive() || $priorWritesPending ) { $tempIgnore = false; // not recoverable } + # Usually the transaction is rolled back to BEGIN, leaving an empty transaction. + # Destroy any such transaction so the rollback callbacks run in AUTO-COMMIT mode + # as normal. Also, if DBO_TRX is set and an explicit transaction rolled back here, + # further queries should be back in AUTO-COMMIT mode, not stuck in a transaction. + $this->doRollback( __METHOD__ ); # Update state tracking to reflect transaction loss - $this->handleSessionLoss(); + $this->handleTransactionLoss(); } - $this->reportQueryError( - $this->lastError(), $this->lastErrno(), $sql, $fname, $tempIgnore ); + $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $tempIgnore ); } - $res = $this->resultObject( $ret ); - - return $res; + return $this->resultObject( $ret ); } /** @@ -1221,6 +1217,8 @@ 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' ) { @@ -1235,35 +1233,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.1/static/sql-createtable.html (ignoring ON COMMIT) $this->sessionTempTables = []; + // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock + // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS $this->namedLocksHeld = []; + // Session loss implies transaction loss + $this->handleTransactionLoss(); + } - // 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,8 +1284,19 @@ 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 ); @@ -1588,37 +1603,93 @@ 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 = [] ) { - $rows = 0; + $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 ); + $row = $res ? $this->fetchRow( $res ) : []; - if ( $res ) { - $row = $this->fetchRow( $res ); - $rows = ( isset( $row['rowcount'] ) ) ? (int)$row['rowcount'] : 0; - } - - return $rows; + return isset( $row['rowcount'] ) ? (int)$row['rowcount'] : 0; } 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"; + } + + $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; + } - if ( $res ) { - $row = $this->fetchRow( $res ); - $rows = ( 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; } /** @@ -1968,6 +2039,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; } @@ -1990,6 +2070,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 @@ -2006,6 +2093,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.", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] + ); + return $name; } @@ -2110,17 +2202,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 { - return $this->tableName( $name ) . ' ' . $this->addIdentifierQuotes( $alias ); + 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 $quotedTable . ' ' . $this->addIdentifierQuotes( $alias ); } } @@ -2204,9 +2311,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( is_array( $table ) ) { // A parenthesized group if ( count( $table ) > 1 ) { - $joinedTable = '(' - . $this->tableNamesWithIndexClauseOrJOIN( $table, $use_index, $ignore_index, $join_conds ) - . ')'; + $joinedTable = '(' . + $this->tableNamesWithIndexClauseOrJOIN( + $table, $use_index, $ignore_index, $join_conds ) . ')'; } else { // Degenerate case $innerTable = reset( $table ); @@ -2364,7 +2471,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } } - return ' LIKE ' . $this->addQuotes( $s ) . ' ESCAPE ' . $this->addQuotes( $escapeChar ) . ' '; + return ' LIKE ' . + $this->addQuotes( $s ) . ' ESCAPE ' . $this->addQuotes( $escapeChar ) . ' '; } public function anyChar() { @@ -2418,7 +2526,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 @@ -2454,7 +2562,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; } } @@ -2523,7 +2631,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 ); @@ -2537,7 +2645,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; } @@ -2679,7 +2787,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 ) { @@ -2705,11 +2813,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; } } @@ -2892,14 +3000,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 * @@ -2973,6 +3089,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) { + if ( !$this->trxLevel && $this->getTransactionRoundId() ) { + // Start an implicit transaction similar to how query() does + $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); + $this->trxAutomatic = true; + } + $this->trxIdleCallbacks[] = [ $callback, $fname ]; if ( !$this->trxLevel ) { $this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE ); @@ -2980,19 +3102,22 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) { - if ( $this->trxLevel || $this->getFlag( self::DBO_TRX ) ) { - // As long as DBO_TRX is set, writes will accumulate until the load balancer issues - // an implicit commit of all peer databases. This is true even if a transaction has - // not yet been triggered by writes; make sure $callback runs *after* any such writes. + if ( !$this->trxLevel && $this->getTransactionRoundId() ) { + // Start an implicit transaction similar to how query() does + $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); + $this->trxAutomatic = true; + } + + if ( $this->trxLevel ) { $this->trxPreCommitCallbacks[] = [ $callback, $fname ]; } else { // No transaction is active nor will start implicitly, so make one for this callback - $this->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; } } @@ -3129,7 +3254,52 @@ 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 ); + } + + final public function startAtomic( + $fname = __METHOD__, $cancelable = self::ATOMIC_NOT_CANCELABLE + ) { + $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? 'n/a' : null; if ( !$this->trxLevel ) { $this->begin( $fname, self::TRANSACTION_INTERNAL ); // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result @@ -3137,32 +3307,70 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( !$this->getFlag( self::DBO_TRX ) ) { $this->trxAutomaticAtomic = true; } + } elseif ( $cancelable === self::ATOMIC_CANCELABLE ) { + $savepointId = 'wikimedia_rdbms_atomic' . ++$this->trxAtomicCounter; + if ( strlen( $savepointId ) > 30 ) { // 30 == Oracle's identifier length limit (pre 12c) + $this->queryLogger->warning( + 'There have been an excessively large number of atomic sections in a transaction' + . " started by $this->trxFname, reusing IDs (at $fname)", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] + ); + $this->trxAtomicCounter = 0; + $savepointId = 'wikimedia_rdbms_atomic' . ++$this->trxAtomicCounter; + } + $this->doSavepoint( $savepointId, $fname ); } - $this->trxAtomicLevels[] = $fname; + $this->trxAtomicLevels[] = [ $fname, $savepointId ]; } final public function endAtomic( $fname = __METHOD__ ) { if ( !$this->trxLevel ) { throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." ); } - if ( !$this->trxAtomicLevels || - array_pop( $this->trxAtomicLevels ) !== $fname - ) { + + list( $savedFname, $savepointId ) = $this->trxAtomicLevels + ? array_pop( $this->trxAtomicLevels ) : [ null, null ]; + if ( $savedFname !== $fname ) { throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." ); } if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) { $this->commit( $fname, self::FLUSHING_INTERNAL ); + } elseif ( $savepointId && $savepointId !== 'n/a' ) { + $this->doReleaseSavepoint( $savepointId, $fname ); } } + final public function cancelAtomic( $fname = __METHOD__ ) { + if ( !$this->trxLevel ) { + throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." ); + } + + list( $savedFname, $savepointId ) = $this->trxAtomicLevels + ? array_pop( $this->trxAtomicLevels ) : [ null, null ]; + if ( $savedFname !== $fname ) { + throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." ); + } + if ( !$savepointId ) { + throw new DBUnexpectedError( $this, "Uncancelable atomic section canceled (got $fname)." ); + } + + if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) { + $this->rollback( $fname, self::FLUSHING_INTERNAL ); + } elseif ( $savepointId !== 'n/a' ) { + $this->doRollbackToSavepoint( $savepointId, $fname ); + } + + $this->affectedRowCount = 0; // for the sake of consistency + } + final public function doAtomicSection( $fname, callable $callback ) { - $this->startAtomic( $fname ); + $this->startAtomic( $fname, self::ATOMIC_CANCELABLE ); try { $res = call_user_func_array( $callback, [ $this, $fname ] ); } catch ( Exception $e ) { - $this->rollback( $fname, self::FLUSHING_INTERNAL ); + $this->cancelAtomic( $fname ); throw $e; } $this->endAtomic( $fname ); @@ -3174,29 +3382,28 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // Protect against mismatched atomic section, transaction nesting, and snapshot loss if ( $this->trxLevel ) { if ( $this->trxAtomicLevels ) { - $levels = implode( ', ', $this->trxAtomicLevels ); + $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) { + return $accum === null ? $v[0] : "$accum, " . $v[0]; + } ); $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->trxAtomicCounter = 0; $this->trxTimestamp = microtime( true ); $this->trxFname = $fname; $this->trxDoneWrites = false; @@ -3210,10 +3417,8 @@ 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 = $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). @@ -3234,7 +3439,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware final public function commit( $fname = __METHOD__, $flush = '' ) { if ( $this->trxLevel && $this->trxAtomicLevels ) { // There are still atomic sections open. This cannot be ignored - $levels = implode( ', ', $this->trxAtomicLevels ); + $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) { + return $accum === null ? $v[0] : "$accum, " . $v[0]; + } ); throw new DBUnexpectedError( $this, "$fname: Got COMMIT while atomic sections $levels are still open." @@ -3256,10 +3463,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)." + ); } } @@ -3298,50 +3505,51 @@ 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->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 + } } /** @@ -3463,11 +3671,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; @@ -3475,10 +3684,25 @@ 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(), + 'trace' => ( new RuntimeException() )->getTraceAsString() + ] + ); } catch ( DBConnectionError $e ) { $ok = false; + + $this->connLogger->error( + $fname . ': lost connection to {dbserver} permanently', + [ 'dbserver' => $this->getServer() ] + ); } + $this->handleSessionLoss(); + return $ok; } @@ -3497,7 +3721,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @return array|null ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN) * @since 1.27 */ - protected function getTransactionLagStatus() { + final protected function getTransactionLagStatus() { return $this->trxLevel ? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ] : null;