diff --git a/docs/commands.md b/docs/commands.md index c84367e60..5f7d52428 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -115,7 +115,7 @@ For example, `/co purge t:30d i:stone,dirt` will delete all stone and dirt data In CoreProtect v2.15+, adding `#optimize` to the end of the command (for example, `/co purge t:30d #optimize`) will also optimize supported database tables and reclaim unused disk space. How this option is handled depends on the database backend: * SQLite already rebuilds the database from retained data and reclaims unused file space as part of a manual purge, so `#optimize` is not needed. -* MySQL normally deletes matching rows. Adding `#optimize` also optimizes its tables to reclaim unused space. +* MySQL normally deletes matching rows. Adding `#optimize` also removes saved entity data that no entity kill references anymore (this requires the `CREATE TEMPORARY TABLES` privilege), then optimizes its tables to reclaim unused space. * DuckDB deletes matching rows in one transaction and checkpoints afterward. `#optimize` has no additional effect. * ClickHouse drops fully covered monthly partitions for an unfiltered time purge and synchronously removes rows from partial or filtered partitions. Adding `#optimize` also runs `OPTIMIZE TABLE ... FINAL`. diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 40a8cdcb3..4164e81f6 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -28,6 +28,7 @@ import net.coreprotect.config.ConfigHandler; import net.coreprotect.consumer.Consumer; import net.coreprotect.database.Database; +import net.coreprotect.database.PurgeFilter; import net.coreprotect.database.PurgePolicy; import net.coreprotect.language.Phrase; import net.coreprotect.language.Selector; @@ -340,6 +341,7 @@ public void run() { long timeStart = startTime > 0 ? (timestamp - startTime) : 0; long timeEnd = timestamp - endTime; long removed = 0; + PurgeFilter purgeFilter = new PurgeFilter(timeStart, timeEnd, argWid, includeBlockIdsFinal); for (int i = 0; i <= 5; i++) { requirePurgeNotCancelled(); @@ -497,6 +499,9 @@ public void run() { if (table.equals("entity_spawn")) { timeLimit = " WHERE removed=0 OR block_rowid IN(SELECT rowid FROM " + purgePrefix + "block) OR kill_rowid IN(SELECT rowid FROM " + purgePrefix + "entity) OR rowid IN(SELECT entity_spawn_rowid FROM " + purgePrefix + "entity_container) OR rowid IN(SELECT entity_spawn_rowid FROM " + purgePrefix + "entity_interaction)"; } + else if (table.equals("entity")) { + timeLimit = " WHERE " + PurgeFilter.entityRetainCondition(purgePrefix + "block"); + } else if (PurgePolicy.isPurgeable(table)) { String blockRestriction = "("; if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { @@ -594,7 +599,13 @@ else if (argWid > 0) { purge = false; } - if (purge) { + if (table.equals("entity")) { + query = PurgeFilter.deleteUnreferencedEntities(purgePrefix + "entity", purgePrefix + "block"); + preparedStmt = preparePurgeStatement(connection, query); + preparedStmt.execute(); + preparedStmt.close(); + } + else if (purge) { query = "DELETE FROM " + purgePrefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction; preparedStmt = preparePurgeStatement(connection, query); preparedStmt.execute(); @@ -666,6 +677,13 @@ else if (argWid > 0) { purge = false; } + if (purge && table.equals("block") && purgeFilter.removesKills() && !purgeFilter.purgesEntitiesByTime()) { + query = purgeFilter.deleteEntitiesOfPurgedKills(ConfigHandler.databaseType, ConfigHandler.prefix); + preparedStmt = preparePurgeStatement(connection, query); + removed = removed + preparedStmt.executeUpdate(); + preparedStmt.close(); + } + if (purge) { query = "DELETE FROM " + ConfigHandler.prefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction; preparedStmt = preparePurgeStatement(connection, query); @@ -687,6 +705,32 @@ else if (argWid > 0) { } } + if (ConfigHandler.databaseType.isMySQL() && optimize) { + try { + for (String sweepQuery : PurgeFilter.mysqlOrphanSweepSetup(ConfigHandler.prefix)) { + preparedStmt = preparePurgeStatement(connection, sweepQuery); + preparedStmt.execute(); + preparedStmt.close(); + } + preparedStmt = preparePurgeStatement(connection, PurgeFilter.mysqlOrphanSweepDelete(ConfigHandler.prefix)); + removed = removed + preparedStmt.executeUpdate(); + preparedStmt.close(); + } + catch (Exception e) { + reportPurgeFailure(e); + } + finally { + try { + preparedStmt = preparePurgeStatement(connection, PurgeFilter.mysqlOrphanSweepTeardown(ConfigHandler.prefix)); + preparedStmt.execute(); + preparedStmt.close(); + } + catch (Exception e) { + reportPurgeFailure(e); + } + } + } + requirePurgeNotCancelled(); String retainedPrefix = ConfigHandler.databaseType.isSQLite() ? purgePrefix : ConfigHandler.prefix; query = "UPDATE " + retainedPrefix + "entity_spawn SET kill_rowid=NULL WHERE kill_rowid IS NOT NULL AND NOT EXISTS (SELECT 1 FROM " + retainedPrefix + "entity WHERE " + retainedPrefix + "entity.rowid=" + retainedPrefix + "entity_spawn.kill_rowid)"; diff --git a/src/main/java/net/coreprotect/database/PurgeFilter.java b/src/main/java/net/coreprotect/database/PurgeFilter.java new file mode 100644 index 000000000..5879cec89 --- /dev/null +++ b/src/main/java/net/coreprotect/database/PurgeFilter.java @@ -0,0 +1,185 @@ +package net.coreprotect.database; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +import net.coreprotect.model.action.LookupActions; + +/** + * Selects the rows that one /co purge removes on the relational backends (SQLite, MySQL, DuckDB). + * + *
+ * co_entity has no world or type column: each row belongs to the co_block kill row whose data column holds its
+ * rowid. The statements built here remove co_entity rows through those kill rows, so a scoped purge never leaves
+ * entity data behind without the kill row that references it. Player kills also use the kill action, but they
+ * store type 0 and a user id in data, so they are never treated as co_entity references.
+ */
+public final class PurgeFilter {
+
+ private final long timeStart;
+ private final long timeEnd;
+ private final int worldId;
+ private final List