Plan/Plan/common/src/main/java/com/djrapitops/plan/storage/database/transactions/Transaction.java

299 lines
10 KiB
Java

/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.storage.database.transactions;
import com.djrapitops.plan.exceptions.database.DBOpException;
import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.settings.locale.lang.PluginLang;
import com.djrapitops.plan.storage.database.DBType;
import com.djrapitops.plan.storage.database.Database;
import com.djrapitops.plan.storage.database.SQLDB;
import com.djrapitops.plan.storage.database.queries.Query;
import com.djrapitops.plan.storage.database.queries.QueryAPIQuery;
import com.djrapitops.plan.storage.database.queries.QueryStatement;
import com.djrapitops.plan.storage.database.queries.schema.MySQLSchemaQueries;
import com.djrapitops.plan.storage.database.queries.schema.SQLiteSchemaQueries;
import com.djrapitops.plan.storage.database.transactions.patches.Patch;
import com.djrapitops.plan.utilities.logging.ErrorContext;
import net.playeranalytics.plugin.scheduling.TimeAmount;
import java.sql.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Represents a database transaction.
*
* @author AuroraLS3
*/
public abstract class Transaction {
// SQLite version on 1.8.8 does not support save points, see createSavePoint() method
private static final AtomicBoolean SUPPORTS_SAVE_POINTS = new AtomicBoolean(true);
// Limit for Deadlock attempts.
private static final int ATTEMPT_LIMIT = 5;
private SQLDB db;
protected DBType dbType;
private Connection connection;
private Savepoint savepoint;
protected boolean success;
protected int attempts;
protected Transaction() {
success = false;
attempts = 0;
}
public void executeTransaction(SQLDB db) {
if (db == null) throw new IllegalArgumentException("Given database was null");
if (success) throw new IllegalStateException("Transaction has already been executed");
this.db = db;
this.dbType = db.getType();
attempts++; // Keeps track how many attempts have been made to avoid infinite recursion.
if (db.isUnderHeavyLoad()) {
try {
Thread.yield();
Thread.sleep(db.getHeavyLoadDelayMs());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
try {
initializeConnection(db);
if (shouldBeExecuted()) {
initializeTransaction();
if (this instanceof Patch) {
db.getLogger().info(db.getLocale().getString(PluginLang.DB_APPLY_PATCH, getName()));
}
performOperations();
if (connection != null) connection.commit();
}
success = true;
} catch (SQLException statementFail) {
manageFailure(statementFail); // Throws a DBOpException.
} finally {
db.returnToPool(connection);
}
}
private void manageFailure(SQLException statementFail) {
String failMsg = getClass().getSimpleName() + " failed: " + statementFail.getMessage();
String rollbackStatusMsg = rollbackTransaction();
// Retry if deadlock occurs.
int errorCode = statementFail.getErrorCode();
boolean mySQLDeadlock = dbType == DBType.MYSQL && errorCode == 1213;
boolean deadlocked = mySQLDeadlock || statementFail instanceof SQLTransactionRollbackException;
if (deadlocked && attempts < ATTEMPT_LIMIT) {
executeTransaction(db); // Recurse to attempt again.
return;
}
if (dbType == DBType.MYSQL && errorCode == 1205) {
if (!db.isUnderHeavyLoad()) {
db.getLogger().warn("Database appears to be under heavy load. Dropping some unimportant transactions and adding short pauses for next 10 minutes.");
db.getRunnableFactory().create(db::assumeNoMoreHeavyLoad)
.runTaskLaterAsynchronously(TimeAmount.toTicks(2, TimeUnit.MINUTES));
}
db.increaseHeavyLoadDelay();
executeTransaction(db); // Recurse to attempt again.
return;
}
if (attempts >= ATTEMPT_LIMIT) {
failMsg += " (Attempted " + attempts + " times)";
}
throw new DBOpException(failMsg + rollbackStatusMsg, statementFail, ErrorContext.builder()
.related("Attempts: " + attempts)
.build());
}
private String rollbackTransaction() {
String rollbackStatusMsg = ", Transaction was rolled back.";
boolean hasNoSavePoints = !SUPPORTS_SAVE_POINTS.get();
if (hasNoSavePoints) {
rollbackStatusMsg = ", additionally rollbacks are not supported on this server version.";
} else {
// Rollbacks are supported.
try {
if (connection != null && savepoint != null) {
connection.rollback(savepoint);
}
} catch (SQLException rollbackFail) {
rollbackStatusMsg = ", additionally Transaction rollback failed: " + rollbackFail.getMessage();
}
}
return rollbackStatusMsg;
}
protected void commitMidTransaction() {
try {
connection.commit();
initializeTransaction();
} catch (SQLException e) {
manageFailure(e);
}
}
/**
* Override this method for conditional execution.
* <p>
* Please note that the transaction has not been initialized and class variables are not available for
* queries. The condition should depend on other variables (Like the data that is to be stored) given to the transaction.
*
* @return false if the transaction should not execute.
*/
protected boolean shouldBeExecuted() {
return true;
}
/**
* Implement this method for transaction execution.
*/
protected abstract void performOperations();
private void initializeConnection(SQLDB db) {
try {
this.connection = db.getConnection();
} catch (SQLException e) {
throw new DBOpException(getClass().getSimpleName() + " initialization failed: " + e.getMessage(), e);
}
}
private void initializeTransaction() {
try {
createSavePoint();
} catch (SQLException e) {
throw new DBOpException(getClass().getSimpleName() + " save point initialization failed: " + e.getMessage(), e);
}
}
private void createSavePoint() throws SQLException {
try {
this.savepoint = connection.setSavepoint();
} catch (SQLFeatureNotSupportedException noSavePoints) {
SUPPORTS_SAVE_POINTS.set(false);
} catch (SQLException sqlException) {
handleUnsupportedSQLiteSavePoints(sqlException);
}
}
private void handleUnsupportedSQLiteSavePoints(SQLException sqlException) throws SQLException {
String errorMsg = sqlException.getMessage();
if (errorMsg.contains("unsupported") && errorMsg.contains("savepoints")) {
SUPPORTS_SAVE_POINTS.set(false);
} else {
throw sqlException;
}
}
protected <T> T query(Query<T> query) {
if (query instanceof QueryStatement) {
return ((QueryStatement<T>) query).executeWithConnection(connection);
} else if (query instanceof QueryAPIQuery) {
return ((QueryAPIQuery<T>) query).executeWithConnection(connection);
} else {
return db.queryWithinTransaction(query, this);
}
}
protected boolean execute(Executable executable) {
return executable.execute(connection);
}
protected int executeReturningId(ExecStatement executable) {
return executable.executeReturningId(connection);
}
protected boolean execute(String sql) {
return execute(new ExecStatement(sql) {
@Override
public void prepare(PreparedStatement statement) {
// Statement is ready for execution.
}
});
}
protected void executeSwallowingExceptions(String... statements) {
if (statements == null) return;
for (String statement : statements) {
if (statement == null) continue;
try {
execute(statement);
} catch (DBOpException ignore) {
/* Exceptions swallowed */
}
}
}
protected void executeOther(Transaction transaction) {
transaction.db = db;
transaction.dbType = dbType;
transaction.connection = this.connection;
if (transaction.shouldBeExecuted()) {
transaction.performOperations();
}
transaction.connection = null;
transaction.dbType = null;
transaction.db = null;
}
protected Database.State getDBState() {
return db.getState();
}
protected ServerUUID getServerUUID() {
return db.getServerUUIDSupplier().get();
}
@Override
public String toString() {
return getClass().getSimpleName() + (success ? " (finished)" : "");
}
public boolean wasSuccessful() {
return success;
}
public boolean dbIsNotUnderHeavyLoad() {
return !db.isUnderHeavyLoad() && !db.shouldDropUnimportantTransactions();
}
public String getName() {
String simpleName = getClass().getSimpleName();
return simpleName.isEmpty() ? getClass().getName() : simpleName;
}
protected boolean hasTable(String tableName) {
switch (dbType) {
case SQLITE:
return query(SQLiteSchemaQueries.doesTableExist(tableName));
case MYSQL:
return query(MySQLSchemaQueries.doesTableExist(tableName));
default:
throw new IllegalStateException("Unsupported Database Type: " + dbType.getName());
}
}
}