query( 'SET TRANSACTION ISOLATION LEVEL READ COMMITTED' ) ) { throw new \RuntimeException( 'Could not configure rewards transaction isolation.' ); } $result = self::$depth ? $wpdb->query( $wpdb->prepare( 'SAVEPOINT %i', 'devoynx_rewards_' . self::$depth ) ) : $wpdb->query( 'START TRANSACTION' ); if ( false === $result ) { throw new \RuntimeException( 'Could not start rewards transaction.' ); } ++self::$depth; } /** * Commit the current transaction or release its savepoint. * * @throws \RuntimeException When the database cannot commit the transaction. */ public static function commit() { global $wpdb; $next = self::$depth - 1; $result = $next ? $wpdb->query( $wpdb->prepare( 'RELEASE SAVEPOINT %i', 'devoynx_rewards_' . $next ) ) : $wpdb->query( 'COMMIT' ); if ( false === $result ) { throw new \RuntimeException( 'Could not commit rewards transaction.' ); } self::$depth = $next; } /** * Roll back the current transaction or nested savepoint. */ public static function rollback() { global $wpdb; if ( self::$depth < 1 ) { return; } --self::$depth; if ( self::$depth ) { $wpdb->query( $wpdb->prepare( 'ROLLBACK TO SAVEPOINT %i', 'devoynx_rewards_' . self::$depth ) ); } else { $wpdb->query( 'ROLLBACK' ); } } /** * Serialize the whole operation on the row used by every ledger write. * * @param int $user_id Customer ID. * @param callable $callback Work executed with the wallet locked. * @return mixed Callback result; exceptions roll back the operation. * @throws \RuntimeException When the wallet row cannot be locked. * @throws \Throwable When callback execution fails. */ public static function wallet( $user_id, callable $callback ) { global $wpdb; self::begin(); try { $table = $wpdb->prefix . 'devoynx_rewards_balances'; // A no-op duplicate update takes an exclusive lock immediately. INSERT IGNORE // lets concurrent transactions hold shared locks and deadlock on FOR UPDATE. if ( false === $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (user_id, updated_at) VALUES (%d, %s) ON DUPLICATE KEY UPDATE user_id = user_id', $table, $user_id, current_time( 'mysql', true ) ) ) ) { throw new \RuntimeException( 'Could not initialize rewards wallet lock.' ); } $locked = $wpdb->get_var( $wpdb->prepare( 'SELECT user_id FROM %i WHERE user_id = %d FOR UPDATE', $table, $user_id ) ); if ( ! $locked ) { throw new \RuntimeException( 'Could not lock rewards wallet.' ); } wp_cache_delete( $user_id, 'user_meta' ); $result = $callback(); self::commit(); return $result; } catch ( \Throwable $error ) { self::rollback(); throw $error; } finally { wp_cache_delete( $user_id, 'user_meta' ); } } }