299 lines
10 KiB
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());
|
|
}
|
|
}
|
|
} |