From f0f4f5f5b820cba6d611bb60ae7a0271d82c7f6a Mon Sep 17 00:00:00 2001 From: Aaron Schulz Date: Fri, 23 Mar 2018 17:32:19 -0700 Subject: [PATCH] rdbms: add comments about atomic sections and callbacks Change-Id: I5e3e0aeba4e54126fd0baf5c7a24a4b88a13096d --- includes/libs/rdbms/database/IDatabase.php | 151 +++++++++++++++++---- 1 file changed, 124 insertions(+), 27 deletions(-) diff --git a/includes/libs/rdbms/database/IDatabase.php b/includes/libs/rdbms/database/IDatabase.php index beaaa14c94..b50ff70617 100644 --- a/includes/libs/rdbms/database/IDatabase.php +++ b/includes/libs/rdbms/database/IDatabase.php @@ -22,7 +22,6 @@ namespace Wikimedia\Rdbms; use InvalidArgumentException; use Wikimedia\ScopedCallback; use RuntimeException; -use UnexpectedValueException; use stdClass; /** @@ -1474,11 +1473,13 @@ interface IDatabase { /** * Run a callback as soon as the current transaction commits or rolls back. * An error is thrown if no transaction is pending. Queries in the function will run in - * AUTO-COMMIT mode unless there are begin() calls. Callbacks must commit any transactions + * AUTOCOMMIT mode unless there are begin() calls. Callbacks must commit any transactions * that they begin. * * This is useful for combining cooperative locks and DB transactions. * + * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * * The callback takes one argument: * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_ROLLBACK) * @@ -1497,7 +1498,7 @@ interface IDatabase { * of the round, just after all peer transactions COMMIT. If the transaction round * is rolled back, then the callback is cancelled. * - * Queries in the function will run in AUTO-COMMIT mode unless there are begin() calls. + * Queries in the function will run in AUTOCOMMIT mode unless there are begin() calls. * Callbacks must commit any transactions that they begin. * * This is useful for updates to different systems or when separate transactions are needed. @@ -1508,6 +1509,8 @@ interface IDatabase { * * Updates will execute in the order they were enqueued. * + * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * * The callback takes one argument: * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_IDLE) * @@ -1557,21 +1560,71 @@ interface IDatabase { public function setTransactionListener( $name, callable $callback = null ); /** - * Begin an atomic section of statements + * Begin an atomic section of SQL statements + * + * Start an implicit transaction if no transaction is already active, set a savepoint + * (if $cancelable is ATOMIC_CANCELABLE), and track the given section name to enforce + * that the transaction is not committed prematurely. The end of the section must be + * signified exactly once, either by endAtomic() or cancelAtomic(). Sections can have + * have layers of inner sections (sub-sections), but all sections must be ended in order + * of innermost to outermost. Transactions cannot be started or committed until all + * atomic sections are closed. + * + * ATOMIC_CANCELABLE is useful when the caller needs to handle specific failure cases + * by discarding the section's writes. This should not be used for failures when: + * - upsert() could easily be used instead + * - insert() with IGNORE could easily be used instead + * - select() with FOR UPDATE could be checked before issuing writes instead + * - The failure is from code that runs after the first write but doesn't need to + * - The failures are from contention solvable via onTransactionPreCommitOrIdle() + * - The failures are deadlocks; the RDBMs usually discard the whole transaction * - * If a transaction has been started already, (optionally) sets a savepoint - * and tracks the given section name to make sure the transaction is not - * committed pre-maturely. This function can be used in layers (with - * sub-sections), so use a stack to keep track of the different atomic - * sections. If there is no transaction, one is started implicitly. + * @note: callers must use additional measures for situations involving two or more + * (peer) transactions (e.g. updating two database servers at once). The transaction + * and savepoint logic of this method only applies to this specific IDatabase instance. * - * The goal of this function is to create an atomic section of SQL queries - * without having to start a new transaction if it already exists. + * Example usage: + * @code + * // Start a transaction if there isn't one already + * $dbw->startAtomic( __METHOD__ ); + * // Serialize these thread table updates + * $dbw->select( 'thread', '1', [ 'td_id' => $tid ], __METHOD__, 'FOR UPDATE' ); + * // Add a new comment for the thread + * $dbw->insert( 'comment', $row, __METHOD__ ); + * $cid = $db->insertId(); + * // Update thread reference to last comment + * $dbw->update( 'thread', [ 'td_latest' => $cid ], [ 'td_id' => $tid ], __METHOD__ ); + * // Demark the end of this conceptual unit of updates + * $dbw->endAtomic( __METHOD__ ); + * @endcode * - * All atomic levels *must* be explicitly closed using IDatabase::endAtomic() - * or IDatabase::cancelAtomic(), and any database transactions cannot be - * began or committed until all atomic levels are closed. There is no such - * thing as implicitly opening or closing an atomic section. + * Example usage (atomic changes that might have to be discarded): + * @code + * // Start a transaction if there isn't one already + * $dbw->startAtomic( __METHOD__, $dbw::ATOMIC_CANCELABLE ); + * // Create new record metadata row + * $dbw->insert( 'records', $row, __METHOD__ ); + * // Figure out where to store the data based on the new row's ID + * $path = $recordDirectory . '/' . $dbw->insertId(); + * // Write the record data to the storage system + * $status = $fileBackend->create( [ 'dst' => $path, 'content' => $data ] ); + * if ( $status->isOK() ) { + * // Try to cleanup files orphaned by transaction rollback + * $dbw->onTransactionResolution( + * function ( $type ) use ( $fileBackend, $path ) { + * if ( $type === IDatabase::TRIGGER_ROLLBACK ) { + * $fileBackend->delete( [ 'src' => $path ] ); + * } + * }, + * __METHOD__ + * ); + * // Demark the end of this conceptual unit of updates + * $dbw->endAtomic( __METHOD__ ); + * } else { + * // Discard these writes from the transaction (preserving prior writes) + * $dbw->cancelAtomic( __METHOD__ ); + * } + * @endcode * * @since 1.23 * @param string $fname @@ -1605,8 +1658,11 @@ interface IDatabase { * corresponding startAtomic() implicitly started a transaction, that * transaction is rolled back. * - * Note that a call to IDatabase::rollback() will also roll back any open - * atomic sections. + * @note: callers must use additional measures for situations involving two or more + * (peer) transactions (e.g. updating two database servers at once). The transaction + * and savepoint logic of startAtomic() are bound to specific IDatabase instances. + * + * Note that a call to IDatabase::rollback() will also roll back any open atomic sections. * * @note As a micro-optimization to save a few DB calls, this method may only * be called when startAtomic() was called with the ATOMIC_CANCELABLE flag. @@ -1620,20 +1676,62 @@ interface IDatabase { public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ); /** - * Run a callback to do an atomic set of updates for this database + * Perform an atomic section of reversable SQL statements from a callback * * The $callback takes the following arguments: * - This database object * - The value of $fname * - * If any exception occurs in the callback, then cancelAtomic() will be - * called to back out any statements executed by the callback and the error - * will be re-thrown. It may also be that the cancel itself fails with an - * exception before then. In any case, such errors are expected to - * terminate the request, without any outside caller attempting to catch - * errors and commit anyway. + * This will execute the callback inside a pair of startAtomic()/endAtomic() calls. + * If any exception occurs during execution of the callback, it will be handled as follows: + * - If $cancelable is ATOMIC_CANCELABLE, cancelAtomic() will be called to back out any + * (and only) statements executed during the atomic section. If that succeeds, then the + * exception will be re-thrown; if it fails, then a different exception will be thrown + * and any further query attempts will fail until rollback() is called. + * - If $cancelable is ATOMIC_NOT_CANCELABLE, cancelAtomic() will be called to mark the + * end of the section and the error will be re-thrown. Any further query attempts will + * fail until rollback() is called. + * + * This method is convenient for letting calls to the caller of this method be wrapped + * in a try/catch blocks for exception types that imply that the caller failed but was + * able to properly discard the changes it made in the transaction. This method can be + * an alternative to explicit calls to startAtomic()/endAtomic()/cancelAtomic(). + * + * Example usage, "RecordStore::save" method: + * @code + * $dbw->doAtomicSection( __METHOD__, function ( $dbw ) use ( $record ) { + * // Create new record metadata row + * $dbw->insert( 'records', $record->toArray(), __METHOD__ ); + * // Figure out where to store the data based on the new row's ID + * $path = $this->recordDirectory . '/' . $dbw->insertId(); + * // Write the record data to the storage system; + * // blob store throughs StoreFailureException on failure + * $this->blobStore->create( $path, $record->getJSON() ); + * // Try to cleanup files orphaned by transaction rollback + * $dbw->onTransactionResolution( + * function ( $type ) use ( $path ) { + * if ( $type === IDatabase::TRIGGER_ROLLBACK ) { + * $this->blobStore->delete( $path ); + * } + * }, + * }, + * __METHOD__ + * ); + * @endcode * - * This can be an alternative to explicit startAtomic()/endAtomic()/cancelAtomic() calls. + * Example usage, caller of the "RecordStore::save" method: + * @code + * $dbw->startAtomic( __METHOD__ ); + * // ...various SQL writes happen... + * try { + * $recordStore->save( $record ); + * } catch ( StoreFailureException $e ) { + * $dbw->cancelAtomic( __METHOD__ ); + * // ...various SQL writes happen... + * } + * // ...various SQL writes happen... + * $dbw->endAtomic( __METHOD__ ); + * @endcode * * @see Database::startAtomic * @see Database::endAtomic @@ -1646,7 +1744,6 @@ interface IDatabase { * @return mixed $res Result of the callback (since 1.28) * @throws DBError * @throws RuntimeException - * @throws UnexpectedValueException * @since 1.27; prior to 1.31 this did a rollback() instead of * cancelAtomic(), and assumed no callers up the stack would ever try to * catch the exception. @@ -1788,7 +1885,7 @@ interface IDatabase { * This is useful when transactions might use snapshot isolation * (e.g. REPEATABLE-READ in innodb), so the "real" lag of that data * is this lag plus transaction duration. If they don't, it is still - * safe to be pessimistic. In AUTO-COMMIT mode, this still gives an + * safe to be pessimistic. In AUTOCOMMIT mode, this still gives an * indication of the staleness of subsequent reads. * * @return array ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN) -- 2.20.1