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 = [];
/** @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.
/**
* Array of levels of atomicity within transactions
*
- * @var array
+ * @var array List of (name, unique ID, savepoint ID)
*/
private $trxAtomicLevels = [];
/**
/** @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()
$this->connLogger = $params['connLogger'];
$this->queryLogger = $params['queryLogger'];
$this->errorLogger = $params['errorLogger'];
+ $this->deprecationLogger = $params['deprecationLogger'];
if ( isset( $params['nonNativeInsertSelectBatchSize'] ) ) {
$this->nonNativeInsertSelectBatchSize = $params['nonNativeInsertSelectBatchSize'];
* 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.
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 );
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 ) {
);
}
+ /**
+ * @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;
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 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;
}
}
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;
$this->queryLogger->debug( "{$this->dbName} {$commentedSql}" );
}
- # Avoid fatals if close() was called
- $this->assertOpen();
-
# Send the query to the server and fetch any corresponding errors
$ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname );
$lastError = $this->lastError();
}
if ( $ret === false ) {
- # Deadlocks cause the entire transaction to abort, not just the statement.
- # https://dev.mysql.com/doc/refman/5.7/en/innodb-error-handling.html
- # https://www.postgresql.org/docs/9.1/static/explicit-locking.html
- if ( $this->wasDeadlock() ) {
- if ( $this->explicitTrxActive() || $priorWritesPending ) {
- $tempIgnore = false; // not recoverable
+ 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 ];
}
- # Usually the transaction is rolled back to BEGIN, leaving an empty transaction.
- # Destroy any such transaction so the rollback callbacks run in AUTO-COMMIT mode
- # as normal. Also, if DBO_TRX is set and an explicit transaction rolled back here,
- # further queries should be back in AUTO-COMMIT mode, not stuck in a transaction.
- $this->doRollback( __METHOD__ );
- # Update state tracking to reflect transaction loss
- $this->handleTransactionLoss();
}
$this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $tempIgnore );
}
}
+ /**
+ * @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
} 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
}
private function handleSessionLoss() {
// Clean up tracking of session-level things...
// https://dev.mysql.com/doc/refman/5.7/en/implicit-commit.html
- // https://www.postgresql.org/docs/9.1/static/sql-createtable.html (ignoring ON COMMIT)
+ // https://www.postgresql.org/docs/9.2/static/sql-createtable.html (ignoring ON COMMIT)
$this->sessionTempTables = [];
// https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
// https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
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 ) {
}
$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;
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 );
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__, self::ATOMIC_CANCELABLE );
}
}
+ /**
+ * @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;
$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 ? 'n/a' : null;
+ $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 = '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;
- }
+ $savepointId = $this->nextSavepointId( $fname );
$this->doSavepoint( $savepointId, $fname );
}
- $this->trxAtomicLevels[] = [ $fname, $savepointId ];
+ $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)." );
}
- list( $savedFname, $savepointId ) = $this->trxAtomicLevels
- ? array_pop( $this->trxAtomicLevels ) : [ null, null ];
+ // 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)." );
+ 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 && $savepointId !== 'n/a' ) {
+ } 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__ ) {
- if ( !$this->trxLevel ) {
- throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." );
+ 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)." );
}
- 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 ( $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 );
}
- if ( !$savepointId ) {
- throw new DBUnexpectedError( $this, "Uncancelable atomic section canceled (got $fname)." );
+
+ // 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)."
+ );
}
- if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) {
- $this->rollback( $fname, self::FLUSHING_INTERNAL );
- } elseif ( $savepointId !== 'n/a' ) {
- $this->doRollbackToSavepoint( $savepointId, $fname );
+ // 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, self::ATOMIC_CANCELABLE );
+ 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->cancelAtomic( $fname );
+ $this->cancelAtomic( $fname, $sectionId );
+
throw $e;
}
$this->endAtomic( $fname );
}
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 = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) {
- return $accum === null ? $v[0] : "$accum, " . $v[0];
- } );
+ $levels = $this->flatAtomicSectionList();
$msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
throw new DBUnexpectedError( $this, $msg );
} elseif ( !$this->trxAutomatic ) {
$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->trxWriteCallers = [];
// First SELECT after BEGIN will establish the snapshot in REPEATABLE-READ.
// Get an estimate of the replication lag before any such queries.
+ $this->trxReplicaLag = null; // clear cached value first
$this->trxReplicaLag = $this->getApproximateLagStatus()['lag'];
// T147697: make explicitTrxActive() return true until begin() finishes. This way, no
// caller will think its OK to muck around with the transaction just because startAtomic()
$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 = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) {
- return $accum === null ? $v[0] : "$accum, " . $v[0];
- } );
+ // 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."
$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(
$this->assertOpen();
$this->doRollback( $fname );
+ $this->trxStatus = self::STATUS_TRX_NONE;
$this->trxAtomicLevels = [];
if ( $this->trxDoneWrites ) {
$this->trxProfiler->transactionWritingOut(
}
public function getSessionLagStatus() {
- return $this->getTransactionLagStatus() ?: $this->getApproximateLagStatus();
+ return $this->getRecordedTransactionLagStatus() ?: $this->getApproximateLagStatus();
}
/**
* is this lag plus transaction duration. If they don't, it is still
* safe to be pessimistic. This returns null if there is no transaction.
*
+ * This returns null if the lag status for this transaction was not yet recorded.
+ *
* @return array|null ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN)
* @since 1.27
*/
- final protected function getTransactionLagStatus() {
- return $this->trxLevel
+ final protected function getRecordedTransactionLagStatus() {
+ return ( $this->trxLevel && $this->trxReplicaLag !== null )
? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ]
: null;
}