From c4a203aaf433a98143baf2ddf9a9d3477df34aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:01:22 +0000 Subject: [PATCH 1/5] Fixed world purges leaving orphaned entity data co_entity has no world column, so /co purge r:#world deleted the kill rows in co_block but kept every co_entity row. Those blobs became orphans that no later purge removed. - SQLite: copy only the co_entity rows that a retained kill row still references. This also drops orphans left by earlier purges. - MySQL/DuckDB: delete the co_entity rows of the kill rows a world purge removes, before co_block is purged. MySQL uses a join so MariaDB and MySQL 5.7 do not run a dependent subquery. Global purges keep the cheaper time-based delete, which is equivalent. If MySQL stops between the two deletes, running the same purge again removes the remaining kill rows. Player kills use the kill action with type 0 and a user id in data, so they are never treated as co_entity references. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- .../net/coreprotect/command/PurgeCommand.java | 20 ++- .../net/coreprotect/database/PurgeFilter.java | 144 ++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/coreprotect/database/PurgeFilter.java diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 40a8cdcb3..ea3e67437 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); 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..97cd450e9 --- /dev/null +++ b/src/main/java/net/coreprotect/database/PurgeFilter.java @@ -0,0 +1,144 @@ +package net.coreprotect.database; + +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 blockTypes; + + /** + * @param timeStart + * the oldest time (inclusive) to purge + * @param timeEnd + * the newest time (exclusive) to purge + * @param worldId + * the world to purge, or 0 for every world + * @param blockTypes + * material ids (i:) that restrict the purge to those co_block rows, or an empty list + */ + public PurgeFilter(long timeStart, long timeEnd, int worldId, List blockTypes) { + this.timeStart = timeStart; + this.timeEnd = timeEnd; + this.worldId = worldId; + this.blockTypes = List.copyOf(blockTypes); + } + + /** + * Returns whether the purge is limited to selected co_block rows, which leaves the other tables untouched. + */ + private boolean restrictsTables() { + return !blockTypes.isEmpty(); + } + + /** + * Returns whether the purge removes co_block kill rows. A restriction to materials keeps every kill row. + */ + public boolean removesKills() { + return blockTypes.isEmpty(); + } + + /** + * Returns whether co_entity rows can be removed by time alone. This holds when every kill row in the time range + * is purged, because a co_entity row always has the same time as its kill row. + */ + public boolean purgesEntitiesByTime() { + return worldId <= 0 && !restrictsTables(); + } + + /** + * Builds the condition that matches the co_block rows this purge removes. + * + * @param qualifier + * the table alias to prefix each column with, such as "b.", or an empty string + * @return the SQL condition + */ + private String blockCondition(String qualifier) { + StringBuilder condition = new StringBuilder(timeCondition(qualifier)); + if (worldId > 0) { + condition.append(" AND ").append(qualifier).append("wid = ").append(worldId); + } + + if (!blockTypes.isEmpty()) { + condition.append(" AND ").append(qualifier).append("action NOT IN(").append(LookupActions.ENTITY_KILL).append(",").append(LookupActions.ENTITY_SPAWN).append(")"); + condition.append(" AND ").append(qualifier).append("type IN(").append(joinIds(blockTypes)).append(")"); + } + return condition.toString(); + } + + /** + * Builds the MySQL or DuckDB statement that deletes the co_entity rows of the kill rows this purge removes. Run it + * before the co_block delete, while those kill rows still exist. + * + * @param databaseType + * the MySQL or DuckDB backend + * @param prefix + * the table prefix + * @return the SQL statement + */ + public String deleteEntitiesOfPurgedKills(DatabaseType databaseType, String prefix) { + if (databaseType.isMySQL()) { + // The join form avoids a dependent subquery on MySQL 5.7 and MariaDB. + return "DELETE e FROM " + prefix + "entity AS e INNER JOIN " + prefix + "block AS b ON b.data = e.rowid WHERE " + killReference("b.") + " AND " + blockCondition("b."); + } + return "DELETE FROM " + prefix + "entity WHERE rowid IN(SELECT data FROM " + prefix + "block WHERE " + killReference("") + " AND " + blockCondition("") + ")"; + } + + /** + * Builds the SQLite copy condition that keeps the co_entity rows still referenced by a retained kill row. + * + * @param retainedBlockTable + * the co_block table that holds the rows the purge keeps + * @return the SQL condition + */ + public static String entityRetainCondition(String retainedBlockTable) { + return "rowid IN(" + killReferences(retainedBlockTable) + ")"; + } + + /** + * Builds the statement that deletes co_entity rows that no kill row references. + * + * @param entityTable + * the co_entity table to clean + * @param blockTable + * the co_block table that holds the kill rows + * @return the SQL statement + */ + public static String deleteUnreferencedEntities(String entityTable, String blockTable) { + return "DELETE FROM " + entityTable + " WHERE rowid NOT IN(" + killReferences(blockTable) + ")"; + } + + private String timeCondition(String qualifier) { + return qualifier + "time < " + timeEnd + " AND " + qualifier + "time >= " + timeStart; + } + + private static String killReferences(String blockTable) { + return "SELECT data FROM " + blockTable + " WHERE " + killReference("") + " AND data IS NOT NULL"; + } + + private static String killReference(String qualifier) { + return qualifier + "action = " + LookupActions.ENTITY_KILL + " AND " + qualifier + "type <> 0"; + } + + private static String joinIds(List ids) { + StringJoiner joiner = new StringJoiner(","); + for (Integer id : ids) { + joiner.add(String.valueOf(id)); + } + return joiner.toString(); + } +} From 57d80f03333502589af8fccb2c371c3dd06ee48a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:54:46 +0000 Subject: [PATCH 2/5] Fixed SQLite purges with world and block filters purging other tables On SQLite, /co purge r:#world i: built the retain condition for world-scoped tables without checking the block restriction, so it purged co_container, co_chat and the other world-scoped tables in that world although a block restriction should leave them untouched. The entity_container/entity_interaction branch and the MySQL path already check it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- src/main/java/net/coreprotect/command/PurgeCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 40a8cdcb3..53befa4c0 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -513,7 +513,7 @@ else if (hasBlockRestriction) { timeLimit = " WHERE (" + worldMatch + " AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "')) OR NOT " + worldMatch; } } - else { + else if (purge) { timeLimit = " WHERE (" + blockRestriction + "wid = '" + argWid + "' AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "'))) OR (wid != '" + argWid + "')"; } } From e7080bb1dc630c25c42e829cc86b00c9923c30f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:01:27 +0000 Subject: [PATCH 3/5] Added orphaned entity data cleanup to MySQL #optimize purges MySQL deletes in place, so co_entity rows orphaned by earlier world purges stay until something removes them. With #optimize, delete every co_entity row that no kill row references, through a temporary table of referenced ids (avoids an anti-join on the unindexed co_block.data), then let OPTIMIZE reclaim the space. Failures are reported like the table loop, so the entity_spawn link cleanup still runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- docs/commands.md | 2 +- .../net/coreprotect/command/PurgeCommand.java | 26 ++++++++++++ .../net/coreprotect/database/PurgeFilter.java | 41 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) 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 ea3e67437..4164e81f6 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -705,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 index 97cd450e9..5879cec89 100644 --- a/src/main/java/net/coreprotect/database/PurgeFilter.java +++ b/src/main/java/net/coreprotect/database/PurgeFilter.java @@ -1,5 +1,6 @@ package net.coreprotect.database; +import java.util.ArrayList; import java.util.List; import java.util.StringJoiner; @@ -122,6 +123,46 @@ public static String deleteUnreferencedEntities(String entityTable, String block return "DELETE FROM " + entityTable + " WHERE rowid NOT IN(" + killReferences(blockTable) + ")"; } + /** + * Builds the MySQL statements that prepare an orphan sweep: a temporary table of the co_entity ids that kill rows + * reference. The table avoids an anti-join on the unindexed co_block.data column. + * + * @param prefix + * the table prefix + * @return the SQL statements, in order + */ + public static List mysqlOrphanSweepSetup(String prefix) { + String keepTable = prefix + "entity_keep"; + List statements = new ArrayList<>(); + statements.add("DROP TEMPORARY TABLE IF EXISTS " + keepTable); + statements.add("CREATE TEMPORARY TABLE " + keepTable + " (rowid INT NOT NULL PRIMARY KEY) ENGINE=InnoDB"); + statements.add("INSERT IGNORE INTO " + keepTable + " (rowid) " + killReferences(prefix + "block")); + return statements; + } + + /** + * Builds the MySQL statement that deletes co_entity rows no kill row references, such as rows left by earlier + * world purges. Run {@link #mysqlOrphanSweepSetup(String)} first. + * + * @param prefix + * the table prefix + * @return the SQL statement + */ + public static String mysqlOrphanSweepDelete(String prefix) { + return "DELETE e FROM " + prefix + "entity AS e LEFT JOIN " + prefix + "entity_keep AS k ON k.rowid = e.rowid WHERE k.rowid IS NULL"; + } + + /** + * Builds the MySQL statement that drops the temporary table of an orphan sweep. + * + * @param prefix + * the table prefix + * @return the SQL statement + */ + public static String mysqlOrphanSweepTeardown(String prefix) { + return "DROP TEMPORARY TABLE IF EXISTS " + prefix + "entity_keep"; + } + private String timeCondition(String qualifier) { return qualifier + "time < " + timeEnd + " AND " + qualifier + "time >= " + timeStart; } From d9e2210a1366a6512407b4725a0d9ef9ba5eb561 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:01:37 +0000 Subject: [PATCH 4/5] Moved purge conditions into PurgeFilter PurgeFilter now builds the purge condition for every table, and the SQLite copy, the SQLite recovery path and the MySQL/DuckDB delete all use it instead of three hand-built copies. Behavior is unchanged; the block restriction check that the previous commit added to the SQLite copy is part of the shared condition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- .../net/coreprotect/command/PurgeCommand.java | 111 +++--------------- .../net/coreprotect/database/PurgeFilter.java | 31 +++++ 2 files changed, 47 insertions(+), 95 deletions(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index dc4e733e2..d59fd78cd 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -32,7 +32,6 @@ import net.coreprotect.database.PurgePolicy; import net.coreprotect.language.Phrase; import net.coreprotect.language.Selector; -import net.coreprotect.model.action.LookupActions; import net.coreprotect.patch.Patch; import net.coreprotect.utility.Chat; import net.coreprotect.utility.ChatMessage; @@ -219,7 +218,6 @@ else if (endTime < 86400) { } StringBuilder restrict = new StringBuilder(); - String includeBlock = ""; List includeBlockIds = new ArrayList<>(); String includeEntity = ""; boolean hasBlock = false; @@ -228,7 +226,6 @@ else if (endTime < 86400) { int restrictCount = 0; if (argBlocks.size() > 0) { - StringBuilder includeListMaterial = new StringBuilder(); StringBuilder includeListEntity = new StringBuilder(); for (Object restrictTarget : argBlocks) { @@ -238,17 +235,10 @@ else if (endTime < 86400) { targetName = ((Material) restrictTarget).name(); int blockId = MaterialUtils.getBlockId(targetName, false); includeBlockIds.add(blockId); - if (includeListMaterial.length() == 0) { - includeListMaterial = includeListMaterial.append(blockId); - } - else { - includeListMaterial.append(",").append(blockId); - } /* Include legacy IDs */ int legacyId = BukkitAdapter.ADAPTER.getLegacyBlockId((Material) restrictTarget); if (legacyId > 0) { - includeListMaterial.append(",").append(legacyId); includeBlockIds.add(legacyId); } @@ -271,12 +261,6 @@ else if (restrictTarget instanceof EntityType) { else if (restrictTarget instanceof String) { int blockId = MaterialUtils.getBlockId((String) restrictTarget, false); includeBlockIds.add(blockId); - if (includeListMaterial.length() == 0) { - includeListMaterial = includeListMaterial.append(blockId); - } - else { - includeListMaterial.append(",").append(blockId); - } targetName = ((String) restrictTarget).toLowerCase(Locale.ROOT); hasBlock = true; @@ -292,7 +276,6 @@ else if (restrictTarget instanceof String) { restrictCount++; } - includeBlock = includeListMaterial.toString(); includeEntity = includeListEntity.toString(); } @@ -310,7 +293,6 @@ else if (restrictTarget instanceof String) { } final StringBuilder restrictTargets = restrict; - final String includeBlockFinal = includeBlock; final List includeBlockIdsFinal = List.copyOf(includeBlockIds); final boolean optimize = optimizeCheck; final boolean hasBlockRestriction = hasBlock; @@ -494,7 +476,6 @@ public void run() { boolean error = false; if (!excludeTables.contains(table)) { try { - boolean purge = true; String timeLimit = ""; 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)"; @@ -502,28 +483,10 @@ public void run() { else if (table.equals("entity")) { timeLimit = " WHERE " + PurgeFilter.entityRetainCondition(purgePrefix + "block"); } - else if (PurgePolicy.isPurgeable(table)) { - String blockRestriction = "("; - if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { - blockRestriction = "action IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") OR type NOT IN(" + includeBlockFinal + ") OR (type IN(" + includeBlockFinal + ") AND "; - } - else if (hasBlockRestriction) { - purge = false; - } - - if (argWid > 0 && PurgePolicy.isWorldScoped(table)) { - if (table.equals("entity_container") || table.equals("entity_interaction")) { - if (purge) { - String worldMatch = "(wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))"; - timeLimit = " WHERE (" + worldMatch + " AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "')) OR NOT " + worldMatch; - } - } - else if (purge) { - timeLimit = " WHERE (" + blockRestriction + "wid = '" + argWid + "' AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "'))) OR (wid != '" + argWid + "')"; - } - } - else if (argWid == 0 && purge) { - timeLimit = " WHERE " + blockRestriction + "(time >= '" + timeEnd + "' OR time < '" + timeStart + "'))"; + else { + String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix); + if (purgeCondition != null) { + timeLimit = " WHERE NOT (" + purgeCondition + ")"; } } query = "INSERT INTO " + purgePrefix + table + insertColumns + " SELECT " + selectColumns + " FROM " + ConfigHandler.prefix + table + timeLimit; @@ -576,40 +539,20 @@ else if (argWid == 0 && purge) { } try { - boolean purge = PurgePolicy.isPurgeable(table); - - String blockRestriction = ""; - if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { - blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND "; - } - else if (hasBlockRestriction) { - purge = false; - } - - String worldRestriction = ""; - if (argWid > 0 && PurgePolicy.isWorldScoped(table)) { - if (table.equals("entity_container") || table.equals("entity_interaction")) { - worldRestriction = " AND (wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))"; - } - else { - worldRestriction = " AND wid = '" + argWid + "'"; - } - } - else if (argWid > 0) { - purge = false; - } - 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(); - preparedStmt.close(); + else { + String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix); + if (purgeCondition != null) { + query = "DELETE FROM " + purgePrefix + table + " WHERE " + purgeCondition; + preparedStmt = preparePurgeStatement(connection, query); + preparedStmt.execute(); + preparedStmt.close(); + } } } catch (Exception e) { @@ -654,38 +597,16 @@ else if (purge) { if (!ConfigHandler.databaseType.isSQLite()) { try { - boolean purge = PurgePolicy.isPurgeable(table); - - String blockRestriction = ""; - if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { - blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND "; - } - else if (hasBlockRestriction) { - purge = false; - } - - String worldRestriction = ""; - if (argWid > 0 && PurgePolicy.isWorldScoped(table)) { - if (table.equals("entity_container") || table.equals("entity_interaction")) { - worldRestriction = " AND (wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))"; - } - else { - worldRestriction = " AND wid = '" + argWid + "'"; - } - } - else if (argWid > 0) { - purge = false; - } - - if (purge && table.equals("block") && purgeFilter.removesKills() && !purgeFilter.purgesEntitiesByTime()) { + String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix); + if (purgeCondition != null && 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; + if (purgeCondition != null) { + query = "DELETE FROM " + ConfigHandler.prefix + table + " WHERE " + purgeCondition; preparedStmt = preparePurgeStatement(connection, query); removed = removed + preparedStmt.executeUpdate(); preparedStmt.close(); diff --git a/src/main/java/net/coreprotect/database/PurgeFilter.java b/src/main/java/net/coreprotect/database/PurgeFilter.java index 5879cec89..a701b006c 100644 --- a/src/main/java/net/coreprotect/database/PurgeFilter.java +++ b/src/main/java/net/coreprotect/database/PurgeFilter.java @@ -61,6 +61,37 @@ public boolean purgesEntitiesByTime() { return worldId <= 0 && !restrictsTables(); } + /** + * Builds the condition that matches the rows this purge removes from a table. + * + * @param table + * the table name without prefix + * @param prefix + * the prefix of the tables that subqueries read from + * @return the SQL condition, or null when the purge keeps every row of the table + */ + public String deleteCondition(String table, String prefix) { + if (table.equals("block")) { + return blockCondition(""); + } + if (table.equals("entity")) { + return purgesEntitiesByTime() ? timeCondition("") : null; + } + if (!PurgePolicy.isPurgeable(table) || restrictsTables()) { + return null; + } + if (worldId <= 0) { + return timeCondition(""); + } + if (!PurgePolicy.isWorldScoped(table)) { + return null; + } + if (table.equals("entity_container") || table.equals("entity_interaction")) { + return timeCondition("") + " AND (wid = " + worldId + " OR entity_spawn_rowid IN(SELECT rowid FROM " + prefix + "entity_spawn WHERE current_wid = " + worldId + "))"; + } + return timeCondition("") + " AND wid = " + worldId; + } + /** * Builds the condition that matches the co_block rows this purge removes. * From ee0dc983d332e292edf355fc73421d28d6bea689 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:01:42 +0000 Subject: [PATCH 5/5] Added entity kill filters to purge command /co purge rejected entity types and actions, so mob farm kill logs could only be removed together with all other data. New arguments on SQLite, MySQL and DuckDB: - a:kill purges only entity kill rows. - i: purges kills of those entity types; it can be combined with block types. - e: keeps kills of those entity types while purging the rest. Each purged kill also removes its co_entity row. a:kill with block types, entity types in both i: and e:, e: with only block types in i:, non-entity exclusions and entity filters on ClickHouse are rejected. Any a: value other than a kill alias is rejected instead of falling through to an unrestricted purge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- docs/commands.md | 12 ++- lang/en.yml | 4 +- .../net/coreprotect/command/HelpCommand.java | 2 + .../net/coreprotect/command/PurgeCommand.java | 76 ++++++++++++++----- .../net/coreprotect/command/TabHandler.java | 6 +- .../net/coreprotect/database/PurgeFilter.java | 39 ++++++++-- .../net/coreprotect/language/Language.java | 4 +- .../java/net/coreprotect/language/Phrase.java | 2 + 8 files changed, 113 insertions(+), 32 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 5f7d52428..b87264a24 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -95,7 +95,7 @@ Purge old block data. Useful for freeing up space on your HDD if you don't need | Command | Parameters | | --- | --- | -| /co purge | `t: