0 ) { return self::run_savepoint( $callback, false )[0]; } if ( self::$assume_outer || (bool) apply_filters( 'rooster_lms_in_outer_transaction', false ) ) { self::$outer_top = true; try { list( $result, $queue ) = self::run_savepoint( $callback, true ); } catch ( \Throwable $e ) { self::rolled_back( false ); throw $e; } self::drain( $queue ); return $result; } $attempt = 0; while ( true ) { try { list( $result, $queue ) = self::run_top( $callback ); break; } catch ( DbError $e ) { $retry = $e->is_retryable() && $attempt < self::RETRIES; self::rolled_back( $retry ); if ( ! $retry ) { throw $e; } ++$attempt; ++self::$retried; self::$retry_errnos[] = (int) $e->errno; usleep( random_int( 10, 40 ) * 1000 * $attempt ); } catch ( \Throwable $e ) { self::rolled_back( false ); throw $e; } } self::drain( $queue ); return $result; } private static function drain( array $queue ): void { $first = null; foreach ( $queue as $callback ) { try { $callback(); } catch ( \Throwable $e ) { if ( null === $first ) { $first = $e; continue; } error_log( 'rooster-lms: after-commit callback failed, ' . get_class( $e ) . ( $e instanceof DbError ? ' errno ' . (int) $e->errno : '' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational log line, the class and number only (P10). } } if ( null !== $first ) { throw $first; } } private static function rolled_back( bool $will_retry ): void { do_action( 'rooster_lms_transaction_rolled_back', $will_retry ); } public static function after_commit( callable $callback ): void { if ( 0 === self::$depth ) { $callback(); return; } self::$after[] = $callback; } public static function retries(): int { return self::$retried; } public static function reset_retries(): void { self::$retried = 0; self::$retry_errnos = array(); } public static function retry_errnos(): array { return self::$retry_errnos; } public static function in_transaction(): bool { return self::$depth > 0; } public static function in_outer(): bool { return self::$depth > 0 && self::$outer_top; } public static function assume_outer_transaction( bool $yes ): void { self::$assume_outer = $yes; } public static function now(): string { return self::$now ?? Clock::now(); } public static function statements(): int { return self::$statements; } public static function lock_statements(): int { return self::$lock_statements; } public static function note_statement( bool $lock = false ): void { if ( self::$depth <= 0 ) { return; } if ( $lock ) { ++self::$lock_statements; return; } ++self::$statements; } public static function lock_mode( string $name ): string { return self::$locks[ $name ] ?? ''; } public static function note_lock( string $name, string $mode ): void { if ( self::$depth > 0 ) { self::$locks[ $name ] = 'X' === $mode || 'X' === ( self::$locks[ $name ] ?? '' ) ? 'X' : 'S'; } } public static function is_doomed(): bool { return self::$doomed; } public static function assert_connection(): void { if ( self::$depth <= 0 || null === self::$connection || self::connection_count() === self::$connection ) { return; } if ( self::still_open() ) { self::$connection = self::connection_count(); return; } self::$doomed = true; self::$connection = null; update_option( self::RECOUNT_OPTION, Clock::now(), false ); $error = new DbError( 'connection changed inside a transaction', 2006 ); throw $error; } public static function connection_count(): int { return self::$connects; } public static function count_connect( $modes ) { ++self::$connects; return $modes; } private static function watch(): void { if ( false === has_filter( 'incompatible_sql_modes', array( self::class, 'count_connect' ) ) ) { add_filter( 'incompatible_sql_modes', array( self::class, 'count_connect' ) ); } } private static function still_open(): bool { self::control( self::OP_PROBE ); return self::control( self::OP_RELEASE_PROBE ); } public static function note_error( int $errno ): void { if ( self::$depth > 0 && in_array( $errno, DbError::RETRYABLE, true ) ) { self::$doomed = true; } } private static function run_top( callable $callback ): array { self::watch(); self::control( self::OP_START ); self::$depth = 1; self::$now = Clock::now(); self::$connection = self::connection_count(); try { $result = $callback(); if ( self::$doomed ) { $doomed = new DbError( 'doomed', 1213 ); throw $doomed; } self::assert_connection(); self::control( self::OP_COMMIT, 0, true ); } catch ( \Throwable $e ) { self::control( self::OP_ROLLBACK ); self::finish( false ); throw $e; } return array( $result, self::finish( true ) ); } private static function run_savepoint( callable $callback, bool $top ): array { if ( self::$doomed ) { $doomed = new DbError( 'doomed', 1213 ); throw $doomed; } $number = ++self::$savepoint; $queued = count( self::$after ); self::control( self::OP_SAVEPOINT, $number, true ); ++self::$depth; if ( $top ) { self::watch(); self::$now = Clock::now(); self::$connection = self::connection_count(); } try { $result = $callback(); if ( self::$doomed ) { $doomed = new DbError( 'doomed', 1213 ); throw $doomed; } self::control( self::OP_RELEASE, $number, true ); } catch ( \Throwable $e ) { --self::$depth; if ( $top ) { self::control( self::OP_ROLLBACK_TO, $number ); self::finish( false ); } else { if ( ! self::$doomed ) { self::control( self::OP_ROLLBACK_TO, $number ); } self::$after = array_slice( self::$after, 0, $queued ); self::reset_memos(); } throw $e; } --self::$depth; return array( $result, $top ? self::finish( true ) : array() ); } private static function finish( bool $committed ): array { $queue = self::$after; self::$after = array(); self::$depth = 0; self::$outer_top = false; self::$doomed = false; self::$now = null; self::$connection = null; self::$statements = 0; self::$lock_statements = 0; self::$locks = array(); if ( ! $committed ) { self::reset_memos(); return array(); } return $queue; } private static function reset_memos(): void { Services::reset(); OrgScope::forget(); } private static function control( string $op, int $number = 0, bool $strict = false ): bool { global $wpdb; $suppress = $wpdb->suppress_errors( true ); try { // phpcs:disable WordPress.DB.DirectDatabaseQuery -- transaction control has no cacheable result. $result = match ( $op ) { self::OP_START => $wpdb->query( 'START TRANSACTION' ), self::OP_COMMIT => $wpdb->query( 'COMMIT' ), self::OP_ROLLBACK => $wpdb->query( 'ROLLBACK' ), self::OP_SAVEPOINT => $wpdb->query( $wpdb->prepare( 'SAVEPOINT rooster_lms_sp%d', $number ) ), self::OP_RELEASE => $wpdb->query( $wpdb->prepare( 'RELEASE SAVEPOINT rooster_lms_sp%d', $number ) ), self::OP_ROLLBACK_TO => $wpdb->query( $wpdb->prepare( 'ROLLBACK TO SAVEPOINT rooster_lms_sp%d', $number ) ), self::OP_PROBE => $wpdb->query( 'SAVEPOINT rooster_lms_probe' ), self::OP_RELEASE_PROBE => $wpdb->query( 'RELEASE SAVEPOINT rooster_lms_probe' ), }; // phpcs:enable WordPress.DB.DirectDatabaseQuery } finally { $wpdb->suppress_errors( $suppress ); } if ( false === $result && $strict ) { $errno = Db::errno(); self::note_error( $errno ); $error = new DbError( DbError::sanitize_detail( (string) $wpdb->last_error ), $errno, '', self::verb( $op ) ); throw $error; } return false !== $result; } private static function verb( string $op ): string { return match ( $op ) { self::OP_START => 'START', self::OP_COMMIT => 'COMMIT', self::OP_ROLLBACK, self::OP_ROLLBACK_TO => 'ROLLBACK', self::OP_SAVEPOINT, self::OP_PROBE => 'SAVEPOINT', default => 'RELEASE', }; } }