From bd238749954801e5c651cf6faf021ff08bd46d4f Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 8 Apr 2026 23:43:13 -0700 Subject: [PATCH 01/16] Phase 1: Add housekeeping scheduler + extract PurgeRegionsService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding for the shift away from chunk-copy island deletion. No behavior change yet — reset and admin delete still go through the old pipeline. - Settings: add island.deletion.housekeeping.{enabled,interval-days, region-age-days} (defaults off/30/60). Deprecate keep-previous-island-on-reset and slow-deletion config entries (unbound from config; getters/setters kept as @Deprecated(forRemoval=true) for binary compat until Phase 4). - PurgeRegionsService: extract scan/filter/delete/player-cleanup logic out of AdminPurgeRegionsCommand so the command and the scheduler share one code path. Handles both pre-26.1 (DIM-1/DIM1 subfolders) and 26.1.1+ (sibling world folders) dimension layouts. - AdminPurgeRegionsCommand: reduced to ~180 LOC, delegates to the service and retains only the two-step confirmation UX + per-island display. - HousekeepingManager: new manager wired in BentoBox.onEnable(). Hourly wall-clock check; runs the purge service across every gamemode overworld if enabled and interval has elapsed. Last-run timestamp persisted to /database/housekeeping.yml regardless of DB backend, so the schedule survives restarts. Progress logged to console. - AdminPurgeRegionsCommandTest: stub plugin.getPurgeRegionsService() with a real service over the mocked plugin so the extraction is exercised exactly as the command runs. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/BentoBox.java | 31 + .../world/bentobox/bentobox/Settings.java | 116 ++- .../admin/purge/AdminPurgeRegionsCommand.java | 719 ++---------------- .../managers/HousekeepingManager.java | 229 ++++++ .../managers/PurgeRegionsService.java | 639 ++++++++++++++++ .../purge/AdminPurgeRegionsCommandTest.java | 5 + 6 files changed, 1052 insertions(+), 687 deletions(-) create mode 100644 src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java create mode 100644 src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index b97654e6c..47edea158 100644 --- a/src/main/java/world/bentobox/bentobox/BentoBox.java +++ b/src/main/java/world/bentobox/bentobox/BentoBox.java @@ -32,7 +32,9 @@ import world.bentobox.bentobox.managers.FlagsManager; import world.bentobox.bentobox.managers.HooksManager; import world.bentobox.bentobox.managers.ChunkPregenManager; +import world.bentobox.bentobox.managers.HousekeepingManager; import world.bentobox.bentobox.managers.IslandDeletionManager; +import world.bentobox.bentobox.managers.PurgeRegionsService; import world.bentobox.bentobox.managers.IslandWorldManager; import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.managers.LocalesManager; @@ -72,6 +74,8 @@ public class BentoBox extends JavaPlugin implements Listener { private MapManager mapManager; private IslandDeletionManager islandDeletionManager; private ChunkPregenManager chunkPregenManager; + private PurgeRegionsService purgeRegionsService; + private HousekeepingManager housekeepingManager; private WebManager webManager; // Settings @@ -143,6 +147,9 @@ public void onEnable(){ } islandsManager = new IslandsManager(this); + // Shared purge-regions logic (command + housekeeping) + purgeRegionsService = new PurgeRegionsService(this); + // Start head getter headGetter = new HeadGetter(this); @@ -233,6 +240,10 @@ private void completeSetup(long loadTime) { webManager = new WebManager(this); + // Housekeeping: auto-purge of unused region files (default OFF) + housekeepingManager = new HousekeepingManager(this); + housekeepingManager.start(); + final long enableTime = System.currentTimeMillis() - enableStart; // Show banner @@ -308,6 +319,9 @@ public void onDisable() { if (chunkPregenManager != null) { chunkPregenManager.shutdown(); } + if (housekeepingManager != null) { + housekeepingManager.stop(); + } } @@ -566,6 +580,23 @@ public ChunkPregenManager getChunkPregenManager() { return chunkPregenManager; } + /** + * @return the shared {@link PurgeRegionsService} used by the purge + * regions command and the housekeeping scheduler. + * @since 3.14.0 + */ + public PurgeRegionsService getPurgeRegionsService() { + return purgeRegionsService; + } + + /** + * @return the {@link HousekeepingManager}, or {@code null} if not yet initialized. + * @since 3.14.0 + */ + public HousekeepingManager getHousekeepingManager() { + return housekeepingManager; + } + /** * @return an optional of the Bstats instance * @since 1.1 diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 3bdb5eba6..9cee6e591 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -319,27 +319,44 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "island.delete-speed", since = "1.7.0") private int deleteSpeed = 1; - // Island deletion related settings - @ConfigComment("Toggles whether islands, when players are resetting them, should be kept in the world or deleted.") - @ConfigComment("* If set to 'true', whenever a player resets his island, his previous island will become unowned and won't be deleted from the world.") - @ConfigComment(" You can, however, still delete those unowned islands through purging.") - @ConfigComment(" On bigger servers, this can lead to an increasing world size.") - @ConfigComment(" Yet, this allows admins to retrieve a player's old island in case of an improper use of the reset command.") - @ConfigComment(" Admins can indeed re-add the player to his old island by registering him to it.") - @ConfigComment("* If set to 'false', whenever a player resets his island, his previous island will be deleted from the world.") - @ConfigComment(" This is the default behaviour.") - @ConfigEntry(path = "island.deletion.keep-previous-island-on-reset", since = "1.13.0") + /** + * @deprecated No longer bound to config. Reset always soft-deletes now. + * Slated for removal. + */ + @Deprecated(since = "3.14.0", forRemoval = true) private boolean keepPreviousIslandOnReset = false; - @ConfigComment("Toggles how the islands are deleted.") - @ConfigComment("* If set to 'false', all islands will be deleted at once.") - @ConfigComment(" This is fast but may cause an impact on the performance") - @ConfigComment(" as it'll load all the chunks of the in-deletion islands.") - @ConfigComment("* If set to 'true', the islands will be deleted one by one.") - @ConfigComment(" This is slower but will not cause any impact on the performance.") - @ConfigEntry(path = "island.deletion.slow-deletion", since = "1.19.1") + /** + * @deprecated No longer bound to config. The chunk-by-chunk deletion + * pipeline has been removed. Slated for removal. + */ + @Deprecated(since = "3.14.0", forRemoval = true) private boolean slowDeletion = false; + // Island deletion housekeeping + @ConfigComment("Housekeeping: periodic auto-purge of unused region files.") + @ConfigComment("When a player resets their island, the old island is no longer physically") + @ConfigComment("deleted. Instead it is orphaned (marked deletable) and the region files on") + @ConfigComment("disk are reclaimed later by a scheduled purge. This avoids the brittle") + @ConfigComment("chunk-copy mechanism that required pristine seed worlds.") + @ConfigComment("") + @ConfigComment("WARNING: housekeeping deletes .mca region files from disk. It uses the same") + @ConfigComment("protections as the /bbox purge regions command (online players, island level,") + @ConfigComment("purge-protected flag, spawn islands, unowned-but-not-deletable islands are all") + @ConfigComment("skipped) but is destructive by design. Default is OFF.") + @ConfigComment("Enable the scheduled housekeeping task.") + @ConfigEntry(path = "island.deletion.housekeeping.enabled", since = "3.14.0") + private boolean housekeepingEnabled = false; + + @ConfigComment("How often the housekeeping task runs, in days.") + @ConfigEntry(path = "island.deletion.housekeeping.interval-days", since = "3.14.0") + private int housekeepingIntervalDays = 30; + + @ConfigComment("Minimum age (in days) of region files considered for purge. Passed to the") + @ConfigComment("same scanner the /bbox purge regions command uses.") + @ConfigEntry(path = "island.deletion.housekeeping.region-age-days", since = "3.14.0") + private int housekeepingRegionAgeDays = 60; + // Chunk pre-generation settings @ConfigComment("") @ConfigComment("Chunk pre-generation settings.") @@ -828,22 +845,27 @@ public void setDatabasePrefix(String databasePrefix) { /** * Returns whether islands, when reset, should be kept or deleted. - * + * * @return {@code true} if islands, when reset, should be kept; {@code false} * otherwise. * @since 1.13.0 + * @deprecated Reset always soft-deletes now. Physical cleanup is handled + * by the housekeeping auto-purge. Slated for removal. */ + @Deprecated(since = "3.14.0", forRemoval = true) public boolean isKeepPreviousIslandOnReset() { return keepPreviousIslandOnReset; } /** * Sets whether islands, when reset, should be kept or deleted. - * + * * @param keepPreviousIslandOnReset {@code true} if islands, when reset, should * be kept; {@code false} otherwise. * @since 1.13.0 + * @deprecated See {@link #isKeepPreviousIslandOnReset()}. */ + @Deprecated(since = "3.14.0", forRemoval = true) public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) { this.keepPreviousIslandOnReset = keepPreviousIslandOnReset; } @@ -1014,7 +1036,11 @@ public void setSafeSpotSearchVerticalRange(int safeSpotSearchVerticalRange) { * Is slow deletion boolean. * * @return the boolean + * @deprecated The chunk-by-chunk deletion pipeline is being removed. This + * setting no longer has any effect and will be deleted in a + * future release. Configure the housekeeping auto-purge instead. */ + @Deprecated(since = "3.14.0", forRemoval = true) public boolean isSlowDeletion() { return slowDeletion; } @@ -1023,11 +1049,63 @@ public boolean isSlowDeletion() { * Sets slow deletion. * * @param slowDeletion the slow deletion + * @deprecated See {@link #isSlowDeletion()}. */ + @Deprecated(since = "3.14.0", forRemoval = true) public void setSlowDeletion(boolean slowDeletion) { this.slowDeletion = slowDeletion; } + /** + * @return whether the periodic housekeeping task (auto-purge of unused + * region files) is enabled. + * @since 3.14.0 + */ + public boolean isHousekeepingEnabled() { + return housekeepingEnabled; + } + + /** + * @param housekeepingEnabled whether the periodic housekeeping task is enabled. + * @since 3.14.0 + */ + public void setHousekeepingEnabled(boolean housekeepingEnabled) { + this.housekeepingEnabled = housekeepingEnabled; + } + + /** + * @return how often the housekeeping task runs, in days. + * @since 3.14.0 + */ + public int getHousekeepingIntervalDays() { + return housekeepingIntervalDays; + } + + /** + * @param housekeepingIntervalDays how often the housekeeping task runs, in days. + * @since 3.14.0 + */ + public void setHousekeepingIntervalDays(int housekeepingIntervalDays) { + this.housekeepingIntervalDays = housekeepingIntervalDays; + } + + /** + * @return minimum age (in days) of region files considered for auto-purge. + * @since 3.14.0 + */ + public int getHousekeepingRegionAgeDays() { + return housekeepingRegionAgeDays; + } + + /** + * @param housekeepingRegionAgeDays minimum age (in days) of region files + * considered for auto-purge. + * @since 3.14.0 + */ + public void setHousekeepingRegionAgeDays(int housekeepingRegionAgeDays) { + this.housekeepingRegionAgeDays = housekeepingRegionAgeDays; + } + /** * @return whether chunk pre-generation is enabled * @since 3.14.0 diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java index 294fe17a4..0af812802 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java @@ -1,23 +1,11 @@ package world.bentobox.bentobox.api.commands.admin.purge; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.file.Files; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.UUID; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.bukkit.Bukkit; @@ -28,40 +16,32 @@ import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.bentobox.managers.island.IslandGrid; -import world.bentobox.bentobox.managers.island.IslandGrid.IslandData; +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; import world.bentobox.bentobox.util.Pair; import world.bentobox.bentobox.util.Util; -import world.bentobox.level.Level; +/** + * Admin command to scan and delete old region files in the gamemode world. + * + *

Heavy lifting (scanning, filtering, deletion) is delegated to + * {@link PurgeRegionsService}. This command owns the two-step confirmation + * flow and the per-island display messages. + */ public class AdminPurgeRegionsCommand extends CompositeCommand implements Listener { private static final String NONE_FOUND = "commands.admin.purge.none-found"; - private static final String REGION = "region"; - private static final String ENTITIES = "entities"; - private static final String POI = "poi"; - private static final String DIM_1 = "DIM-1"; - private static final String DIM1 = "DIM1"; - private static final String PLAYERS = "players"; - private static final String PLAYERDATA = "playerdata"; private static final String IN_WORLD = " in world "; private static final String WILL_BE_DELETED = " will be deleted"; - private static final String EXISTS_PREFIX = " (exists="; - private static final String PURGE_FOUND = "Purge found "; - + private volatile boolean inPurge; private boolean toBeConfirmed; private User user; - private Map, Set> deleteableRegions; - private boolean isNether; - private boolean isEnd; - private int days; + private PurgeScanResult lastScan; public AdminPurgeRegionsCommand(CompositeCommand parent) { super(parent, "regions"); getAddon().registerListener(this); - // isNether/isEnd are NOT computed here: IWM may not have loaded the addon world - // config yet at command-registration time. They are evaluated lazily in findIslands(). } @Override @@ -79,7 +59,6 @@ public boolean canExecute(User user, String label, List args) { return false; } if (args.isEmpty()) { - // Show help showHelp(this, user); return false; } @@ -92,421 +71,56 @@ public boolean execute(User user, String label, List args) { if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) { return deleteEverything(); } - /* - * This part does the searching for region files - */ - // Clear tbc toBeConfirmed = false; + int days; try { days = Integer.parseInt(args.getFirst()); if (days <= 0) { user.sendMessage("commands.admin.purge.days-one-or-more"); return false; } - } catch (NumberFormatException e) { user.sendMessage("commands.admin.purge.days-one-or-more"); return false; } - + user.sendMessage("commands.admin.purge.scanning"); // Save all worlds to update any region files Bukkit.getWorlds().forEach(World::save); - // Find the potential islands - Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), ()-> findIslands(getWorld(), days)); + inPurge = true; + final int finalDays = days; + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + try { + PurgeRegionsService service = getPlugin().getPurgeRegionsService(); + lastScan = service.scan(getWorld(), finalDays); + displayResultsAndPrompt(lastScan); + } finally { + inPurge = false; + } + }); return true; } - private boolean deleteEverything() { - if (deleteableRegions.isEmpty()) { - user.sendMessage(NONE_FOUND); // Should never happen + if (lastScan == null || lastScan.isEmpty()) { + user.sendMessage(NONE_FOUND); return false; } - // Save the worlds - Bukkit.getWorlds().forEach(World::save); - // Recheck to see if any regions are newer than days and if so, stop everything - getPlugin().log("Now deleting region files"); - if (!deleteRegionFiles()) { - // Fail! - getPlugin().logError("Not all region files could be deleted"); - } - // Delete islands and regions - for (Set islandIDs : deleteableRegions.values()) { - for (String islandID : islandIDs) { - deletePlayerFromWorldFolder(islandID); - // Remove island from the cache - getPlugin().getIslands().getIslandCache().deleteIslandFromCache(islandID); - // Delete island from database using id - if (getPlugin().getIslands().deleteIslandId(islandID)) { - // Log - getPlugin().log("Island ID " + islandID + " deleted from cache and database" ); - } - } - } - - user.sendMessage("general.success"); + PurgeScanResult scan = lastScan; + lastScan = null; toBeConfirmed = false; - deleteableRegions.clear(); - return true; - - } - - private void deletePlayerFromWorldFolder(String islandID) { - File playerData = resolvePlayerDataFolder(); - getPlugin().getIslands().getIslandById(islandID) - .ifPresent(island -> island.getMemberSet() - .forEach(uuid -> maybeDeletePlayerData(uuid, playerData))); - } - - private void maybeDeletePlayerData(UUID uuid, File playerData) { - List memberOf = new ArrayList<>(getIslands().getIslands(getWorld(), uuid)); - deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); - if (!memberOf.isEmpty()) { - return; - } - if (Bukkit.getOfflinePlayer(uuid).isOp()) { - return; - } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - if (resolveLastLogin(uuid) >= cutoffMillis) { - return; - } - deletePlayerFiles(uuid, playerData); - } - - private long resolveLastLogin(UUID uuid) { - Long lastLogin = getPlugin().getPlayers().getLastLoginTimestamp(uuid); - return lastLogin != null ? lastLogin : Bukkit.getOfflinePlayer(uuid).getLastSeen(); - } - - private void deletePlayerFiles(UUID uuid, File playerData) { - if (!playerData.exists()) { - return; - } - deletePlayerFile(new File(playerData, uuid + ".dat"), "player data file"); - deletePlayerFile(new File(playerData, uuid + ".dat_old"), "player data backup file"); - } - - private void deletePlayerFile(File file, String description) { - try { - Files.deleteIfExists(file.toPath()); - } catch (IOException ex) { - getPlugin().logError("Failed to delete " + description + ": " + file.getAbsolutePath()); - } - } - - /** - * Resolves the base data folder for a world, accounting for the dimension - * subfolder layout. - *

- * Pre-Minecraft version 26.1 (old format): Nether data lives in {@code DIM-1/} and - * End data lives in {@code DIM1/} subfolders inside the world folder. - *

- * Minecraft version 26.1.1+ (new format): Each dimension has its own world folder - * under {@code dimensions/minecraft/} and data (region/, entities/, poi/) - * lives directly in it — no DIM-1/DIM1 subfolders. - * - * @param world the world to resolve - * @return the base folder containing region/, entities/, poi/ subfolders - */ - private File resolveDataFolder(World world) { - File worldFolder = world.getWorldFolder(); - return switch (world.getEnvironment()) { - case NETHER -> { - File dim = new File(worldFolder, DIM_1); - yield dim.isDirectory() ? dim : worldFolder; - } - case THE_END -> { - File dim = new File(worldFolder, DIM1); - yield dim.isDirectory() ? dim : worldFolder; - } - default -> worldFolder; - }; - } - - /** - * Resolves the player data folder, supporting both old and new formats. - *

- * Pre-26.1: {@code /playerdata/} - *

- * 26.1.1+: {@code /players/data/} (centralized) - * - * @return the folder containing player .dat files - */ - private File resolvePlayerDataFolder() { - File worldFolder = getWorld().getWorldFolder(); - // Old format - File oldPath = new File(worldFolder, PLAYERDATA); - if (oldPath.isDirectory()) { - return oldPath; - } - // New 26.1.1 format: walk up from dimensions/minecraft// to world root - File root = worldFolder.getParentFile(); // minecraft/ - if (root != null) root = root.getParentFile(); // dimensions/ - if (root != null) root = root.getParentFile(); // world root - if (root != null) { - File newPath = new File(root, PLAYERS + File.separator + "data"); - if (newPath.isDirectory()) { - return newPath; - } - } - return oldPath; // fallback - } - - /** - * Resolves the nether data folder when the Nether World object is unavailable. - * Tries the old DIM-1 subfolder first, then the 26.1.1 sibling world folder. - * - * @param overworldFolder the overworld's world folder - * @return the nether base folder (may not exist) - */ - private File resolveNetherFallback(File overworldFolder) { - // Old format: /DIM-1/ - File dim = new File(overworldFolder, DIM_1); - if (dim.isDirectory()) { - return dim; - } - // New 26.1.1 format: sibling folder _nether in same parent - File parent = overworldFolder.getParentFile(); - if (parent != null) { - File sibling = new File(parent, overworldFolder.getName() + "_nether"); - if (sibling.isDirectory()) { - return sibling; - } - } - return dim; // fallback to old path - } - - /** - * Resolves the end data folder when the End World object is unavailable. - * Tries the old DIM1 subfolder first, then the 26.1.1 sibling world folder. - * - * @param overworldFolder the overworld's world folder - * @return the end base folder (may not exist) - */ - private File resolveEndFallback(File overworldFolder) { - // Old format: /DIM1/ - File dim = new File(overworldFolder, DIM1); - if (dim.isDirectory()) { - return dim; - } - // New 26.1.1 format: sibling folder _the_end in same parent - File parent = overworldFolder.getParentFile(); - if (parent != null) { - File sibling = new File(parent, overworldFolder.getName() + "_the_end"); - if (sibling.isDirectory()) { - return sibling; - } - } - return dim; // fallback to old path - } - - /** - * Deletes a file if it exists, logging an error if deletion fails. - * Does not log if the parent folder does not exist (normal for entities/poi). - * @param file the file to delete - * @return true if deleted or does not exist, false if exists but could not be deleted - */ - private boolean deleteIfExists(File file) { - if (!file.getParentFile().exists()) { - // Parent folder missing is normal for entities/poi, do not log - return true; - } - try { - Files.deleteIfExists(file.toPath()); - return true; - } catch (IOException e) { - getPlugin().logError("Failed to delete file: " + file.getAbsolutePath()); - return false; - } - } - - /** - * Deletes all region files in deleteableRegions that are older than {@code days}. - * Also deletes corresponding entities and poi files in each dimension. - * @return {@code true} if deletion was performed; {@code false} if cancelled - * due to any file being newer than the cutoff - */ - private boolean deleteRegionFiles() { - if (days <= 0) { - getPlugin().logError("Days is somehow zero or negative!"); - return false; - } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - - World world = getWorld(); - File base = world.getWorldFolder(); - File overworldRegion = new File(base, REGION); - File overworldEntities = new File(base, ENTITIES); - File overworldPoi = new File(base, POI); - - World netherWorld = getPlugin().getIWM().getNetherWorld(world); - File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base); - File netherRegion = new File(netherBase, REGION); - File netherEntities = new File(netherBase, ENTITIES); - File netherPoi = new File(netherBase, POI); - - World endWorld = getPlugin().getIWM().getEndWorld(world); - File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base); - File endRegion = new File(endBase, REGION); - File endEntities = new File(endBase, ENTITIES); - File endPoi = new File(endBase, POI); - - // Phase 1: verify none of the files have been updated since the cutoff - for (Pair coords : deleteableRegions.keySet()) { - String name = "r." + coords.x() + "." + coords.z() + ".mca"; - if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis)) { - return false; - } - } - - // Phase 2: perform deletions - DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); - DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); - DimFolders end = new DimFolders(endRegion, endEntities, endPoi); - for (Pair coords : deleteableRegions.keySet()) { - String name = "r." + coords.x() + "." + coords.z() + ".mca"; - if (!deleteOneRegion(name, ow, nether, end)) { - getPlugin().logError("Could not delete all the region/entity/poi files for some reason"); - } - } - + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + boolean ok = getPlugin().getPurgeRegionsService().delete(scan); + Bukkit.getScheduler().runTask(getPlugin(), () -> + user.sendMessage(ok ? "general.success" : NONE_FOUND)); + }); return true; } - private boolean isFileFresh(File file, long cutoffMillis) { - return file.exists() && getRegionTimestamp(file) >= cutoffMillis; - } - - private boolean isAnyDimensionFresh(String name, File overworldRegion, File netherRegion, - File endRegion, long cutoffMillis) { - if (isFileFresh(new File(overworldRegion, name), cutoffMillis)) return true; - if (isNether && isFileFresh(new File(netherRegion, name), cutoffMillis)) return true; - return isEnd && isFileFresh(new File(endRegion, name), cutoffMillis); - } - - /** Groups the three folder types (region, entities, poi) for one world dimension. */ - private record DimFolders(File region, File entities, File poi) {} - - private boolean deleteOneRegion(String name, DimFolders overworld, DimFolders nether, DimFolders end) { - boolean owRegionOk = deleteIfExists(new File(overworld.region(), name)); - boolean owEntitiesOk = deleteIfExists(new File(overworld.entities(), name)); - boolean owPoiOk = deleteIfExists(new File(overworld.poi(), name)); - boolean ok = owRegionOk && owEntitiesOk && owPoiOk; - if (isNether) { - ok &= deleteIfExists(new File(nether.region(), name)); - ok &= deleteIfExists(new File(nether.entities(), name)); - ok &= deleteIfExists(new File(nether.poi(), name)); - } - if (isEnd) { - ok &= deleteIfExists(new File(end.region(), name)); - ok &= deleteIfExists(new File(end.entities(), name)); - ok &= deleteIfExists(new File(end.poi(), name)); - } - return ok; - } - - /** Tracks island-level and region-level block counts during filtering. */ - private record FilterStats(int islandsOverLevel, int islandsPurgeProtected, - int regionsBlockedByLevel, int regionsBlockedByProtection) {} - - /** - * This method is run async! - * @param world world - * @param days days old - */ - private void findIslands(World world, int days) { - // Evaluate here, not in the constructor - IWM config is loaded by the time a command runs - isNether = getPlugin().getIWM().isNetherGenerate(world) && getPlugin().getIWM().isNetherIslands(world); - isEnd = getPlugin().getIWM().isEndGenerate(world) && getPlugin().getIWM().isEndIslands(world); - try { - // Get the grid that covers this world - IslandGrid islandGrid = getPlugin().getIslands().getIslandCache().getIslandGrid(world); - if (islandGrid == null) { - Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND)); - return; - } - // Find old regions - List> oldRegions = this.findOldRegions(days); - // Get islands that are associated with these regions - deleteableRegions = this.mapIslandsToRegions(oldRegions, islandGrid); - // Filter regions and log summary - FilterStats stats = filterNonDeletableRegions(); - logFilterStats(stats); - // Display results and prompt for confirmation - displayResultsAndPrompt(); - } finally { - inPurge = false; - } - } - - /** - * Removes regions from {@code deleteableRegions} whose island-set contains - * at least one island that cannot be deleted, and returns blocking statistics. - */ - private FilterStats filterNonDeletableRegions() { - int islandsOverLevel = 0; - int islandsPurgeProtected = 0; - int regionsBlockedByLevel = 0; - int regionsBlockedByProtection = 0; - - var iter = deleteableRegions.entrySet().iterator(); - while (iter.hasNext()) { - var entry = iter.next(); - int[] regionCounts = evaluateRegionIslands(entry.getValue()); - if (regionCounts[0] > 0) { // shouldRemove - iter.remove(); - islandsOverLevel += regionCounts[1]; - islandsPurgeProtected += regionCounts[2]; - if (regionCounts[1] > 0) regionsBlockedByLevel++; - if (regionCounts[2] > 0) regionsBlockedByProtection++; - } - } - return new FilterStats(islandsOverLevel, islandsPurgeProtected, - regionsBlockedByLevel, regionsBlockedByProtection); - } - - /** - * Evaluates a set of island IDs for a single region. - * @return int array: [shouldRemove (0 or 1), levelBlockCount, purgeProtectedCount] - */ - private int[] evaluateRegionIslands(Set islandIds) { - int shouldRemove = 0; - int levelBlocked = 0; - int purgeBlocked = 0; - for (String id : islandIds) { - Optional opt = getPlugin().getIslands().getIslandById(id); - if (opt.isEmpty()) { - shouldRemove = 1; - continue; - } - Island isl = opt.get(); - if (canDeleteIsland(isl)) { - shouldRemove = 1; - if (isl.isPurgeProtected()) purgeBlocked++; - if (isLevelTooHigh(isl)) levelBlocked++; - } - } - return new int[] { shouldRemove, levelBlocked, purgeBlocked }; - } - - private void logFilterStats(FilterStats stats) { - if (stats.islandsOverLevel() > 0) { - getPlugin().log("Purge: " + stats.islandsOverLevel() + " island(s) exceed the level threshold of " - + getPlugin().getSettings().getIslandPurgeLevel() - + " - preventing " + stats.regionsBlockedByLevel() + " region(s) from being purged"); - } - if (stats.islandsPurgeProtected() > 0) { - getPlugin().log("Purge: " + stats.islandsPurgeProtected() + " island(s) are purge-protected" - + " - preventing " + stats.regionsBlockedByProtection() + " region(s) from being purged"); - } - } - - private void displayResultsAndPrompt() { - Set uniqueIslands = deleteableRegions.values().stream() + private void displayResultsAndPrompt(PurgeScanResult scan) { + Set uniqueIslands = scan.deleteableRegions().values().stream() .flatMap(Set::stream) .map(getPlugin().getIslands()::getIslandById) .flatMap(Optional::stream) @@ -514,16 +128,18 @@ private void displayResultsAndPrompt() { uniqueIslands.forEach(this::displayIsland); - deleteableRegions.entrySet().stream() + scan.deleteableRegions().entrySet().stream() .filter(e -> e.getValue().isEmpty()) .forEach(e -> displayEmptyRegion(e.getKey())); - if (deleteableRegions.isEmpty()) { + if (scan.isEmpty()) { Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND)); } else { Bukkit.getScheduler().runTask(getPlugin(), () -> { - user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, String.valueOf(uniqueIslands.size())); - user.sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, this.getLabel()); + user.sendMessage("commands.admin.purge.purgable-islands", + TextVariables.NUMBER, String.valueOf(uniqueIslands.size())); + user.sendMessage("commands.admin.purge.regions.confirm", + TextVariables.LABEL, this.getLabel()); user.sendMessage("general.beta"); // TODO Remove beta in the future this.toBeConfirmed = true; }); @@ -531,268 +147,35 @@ private void displayResultsAndPrompt() { } private void displayIsland(Island island) { - // Log the island data if (island.isDeletable()) { - getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); + getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); return; } if (island.getOwner() == null) { - getPlugin().log("Unowned island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); + getPlugin().log("Unowned island at " + Util.xyz(island.getCenter().toVector()) + + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); return; } - getPlugin().log("Island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + getPlugin().log("Island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + " owned by " + getPlugin().getPlayers().getName(island.getOwner()) - + " who last logged in " + formatLocalTimestamp(getPlugin().getPlayers().getLastLoginTimestamp(island.getOwner())) + + " who last logged in " + + formatLocalTimestamp(getPlugin().getPlayers().getLastLoginTimestamp(island.getOwner())) + WILL_BE_DELETED); } private void displayEmptyRegion(Pair region) { - getPlugin().log("Empty region at r." + region.x() + "." + region.z() + IN_WORLD + getWorld().getName() + " will be deleted (no islands)"); + getPlugin().log("Empty region at r." + region.x() + "." + region.z() + IN_WORLD + + getWorld().getName() + " will be deleted (no islands)"); } - /** - * Formats a millisecond timestamp into a human-readable string - * using the system's local time zone. - * - * @param millis the timestamp in milliseconds - * @return formatted string in the form "yyyy-MM-dd HH:mm" - */ private String formatLocalTimestamp(Long millis) { if (millis == null) { return "(unknown or never recorded)"; } Instant instant = Instant.ofEpochMilli(millis); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - .withZone(ZoneId.systemDefault()); // Uses the machine's local time zone - + .withZone(ZoneId.systemDefault()); return formatter.format(instant); } - - /** - * Check if an island cannot be deleted. Purge protected, spawn, or unowned islands cannot be deleted. - * Islands whose members recently logged in, or that exceed the level threshold, cannot be deleted. - * @param island island - * @return true means "cannot delete" - */ - private boolean canDeleteIsland(Island island) { - // If the island is marked deletable it can always be purged - if (island.isDeletable()) { - return false; - } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - // Block if ANY member (owner or team) has logged in within the cutoff window - boolean recentLogin = island.getMemberSet().stream().anyMatch(uuid -> { - Long lastLogin = getPlugin().getPlayers().getLastLoginTimestamp(uuid); - if (lastLogin == null) { - lastLogin = Bukkit.getOfflinePlayer(uuid).getLastSeen(); - } - return lastLogin >= cutoffMillis; - }); - if (recentLogin) { - return true; - } - if (isLevelTooHigh(island)) { - return true; - } - return island.isPurgeProtected() || island.isSpawn() || !island.isOwned(); - } - - /** - * Returns true if the island's level meets or exceeds the configured purge threshold. - * Returns false when the Level addon is not present. - * @param island island to check - * @return true if the island level is too high to purge - */ - private boolean isLevelTooHigh(Island island) { - return getPlugin().getAddonsManager().getAddonByName("Level") - .map(l -> ((Level) l).getIslandLevel(getWorld(), island.getOwner()) - >= getPlugin().getSettings().getIslandPurgeLevel()) - .orElse(false); - } - - /** - * Finds all region files in the overworld (and optionally the Nether and End) - * that have not been modified in the last {@code days} days, and returns their - * region coordinates. - * - *

If {@code nether} is {@code true}, the matching region file in the - * Nether (DIM-1) must also be older than the cutoff to include the coordinate. - * If {@code end} is {@code true}, the matching region file in the End (DIM1) - * must likewise be older than the cutoff. When both {@code nether} and - * {@code end} are {@code true}, all three dimension files must satisfy the - * age requirement.

- * - * @param days the minimum age in days of region files to include - * @return a list of {@code Pair} for each region meeting - * the age criteria - */ - private List> findOldRegions(int days) { - World world = this.getWorld(); - File worldDir = world.getWorldFolder(); - File overworldRegion = new File(worldDir, REGION); - - World netherWorld = getPlugin().getIWM().getNetherWorld(world); - File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir); - File netherRegion = new File(netherBase, REGION); - - World endWorld = getPlugin().getIWM().getEndWorld(world); - File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir); - File endRegion = new File(endBase, REGION); - - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - - logRegionFolderPaths(overworldRegion, netherRegion, endRegion, world); - - // Collect all candidate region names from overworld, nether, and end. - // This ensures orphaned nether/end files are caught even if the overworld - // file was already deleted by a previous (buggy) purge run. - Set candidateNames = collectCandidateNames(overworldRegion, netherRegion, endRegion); - getPlugin().log("Purge total candidate region coordinates: " + candidateNames.size()); - getPlugin().log("Purge checking candidate region(s) against island data, please wait..."); - - List> regions = new ArrayList<>(); - for (String name : candidateNames) { - Pair coords = parseRegionCoords(name); - if (coords == null) continue; - if (!isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis)) { - regions.add(coords); - } - } - return regions; - } - - private void logRegionFolderPaths(File overworldRegion, File netherRegion, File endRegion, World world) { - getPlugin().log("Purge region folders - Overworld: " + overworldRegion.getAbsolutePath() - + EXISTS_PREFIX + overworldRegion.isDirectory() + ")"); - if (isNether) { - getPlugin().log("Purge region folders - Nether: " + netherRegion.getAbsolutePath() - + EXISTS_PREFIX + netherRegion.isDirectory() + ")"); - } else { - getPlugin().log("Purge region folders - Nether: disabled (isNetherGenerate=" - + getPlugin().getIWM().isNetherGenerate(world) + ", isNetherIslands=" - + getPlugin().getIWM().isNetherIslands(world) + ")"); - } - if (isEnd) { - getPlugin().log("Purge region folders - End: " + endRegion.getAbsolutePath() - + EXISTS_PREFIX + endRegion.isDirectory() + ")"); - } else { - getPlugin().log("Purge region folders - End: disabled (isEndGenerate=" - + getPlugin().getIWM().isEndGenerate(world) + ", isEndIslands=" - + getPlugin().getIWM().isEndIslands(world) + ")"); - } - } - - private Set collectCandidateNames(File overworldRegion, File netherRegion, File endRegion) { - Set names = new HashSet<>(); - addFileNames(names, overworldRegion.listFiles((dir, name) -> name.endsWith(".mca")), "overworld"); - if (isNether) { - addFileNames(names, netherRegion.listFiles((dir, name) -> name.endsWith(".mca")), "nether"); - } - if (isEnd) { - addFileNames(names, endRegion.listFiles((dir, name) -> name.endsWith(".mca")), "end"); - } - return names; - } - - private void addFileNames(Set names, File[] files, String dimension) { - if (files != null) { - for (File f : files) names.add(f.getName()); - } - getPlugin().log(PURGE_FOUND + (files != null ? files.length : 0) + " " + dimension + " region files"); - } - - private Pair parseRegionCoords(String name) { - // Parse region coords from filename "r...mca" - String coordsPart = name.substring(2, name.length() - 4); - String[] parts = coordsPart.split("\\."); - if (parts.length != 2) return null; - try { - return new Pair<>(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); - } catch (NumberFormatException ex) { - return null; - } - } - - /** - * Maps each old region to the set of island IDs whose island-squares overlap it. - * - *

Each region covers blocks - * [regionX*512 .. regionX*512 + 511] x [regionZ*512 .. regionZ*512 + 511].

- * - * @param oldRegions the list of region coordinates to process - * @param islandGrid the spatial grid to query - * @return a map from region coords to the set of overlapping island IDs - */ - private Map, Set> mapIslandsToRegions( - List> oldRegions, - IslandGrid islandGrid - ) { - final int blocksPerRegion = 512; - Map, Set> regionToIslands = new HashMap<>(); - - for (Pair region : oldRegions) { - int regionMinX = region.x() * blocksPerRegion; - int regionMinZ = region.z() * blocksPerRegion; - int regionMaxX = regionMinX + blocksPerRegion - 1; - int regionMaxZ = regionMinZ + blocksPerRegion - 1; - - Set ids = new HashSet<>(); - for (IslandData data : islandGrid.getIslandsInBounds(regionMinX, regionMinZ, regionMaxX, regionMaxZ)) { - ids.add(data.id()); - } - - // Always add the region, even if ids is empty - regionToIslands.put(region, ids); - } - - return regionToIslands; - } - - /** - * Reads a Minecraft region file (.mca) and returns the most recent - * per-chunk timestamp found in its header, in milliseconds since epoch. - * - * @param regionFile the .mca file - * @return the most recent timestamp (in millis) among all chunk entries, - * or 0 if the file is invalid or empty - */ - private long getRegionTimestamp(File regionFile) { - if (!regionFile.exists() || regionFile.length() < 8192) { - return 0L; - } - - try (FileInputStream fis = new FileInputStream(regionFile)) { - byte[] buffer = new byte[4096]; // Second 4KB block is the timestamp table - - // Skip first 4KB (location table) - if (fis.skip(4096) != 4096) { - return 0L; - } - - // Read the timestamp table - if (fis.read(buffer) != 4096) { - return 0L; - } - - ByteBuffer bb = ByteBuffer.wrap(buffer); - bb.order(ByteOrder.BIG_ENDIAN); // Timestamps are stored as big-endian ints - - long maxTimestampSeconds = 0; - - for (int i = 0; i < 1024; i++) { - long timestamp = Integer.toUnsignedLong(bb.getInt()); - if (timestamp > maxTimestampSeconds) { - maxTimestampSeconds = timestamp; - } - } - - // Convert seconds to milliseconds - return maxTimestampSeconds * 1000L; - - } catch (IOException e) { - getPlugin().logError("Failed to read region file timestamps: " + regionFile.getAbsolutePath() + " " + e.getMessage()); - return 0L; - } - } } diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java new file mode 100644 index 000000000..cbeeb25d7 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -0,0 +1,229 @@ +package world.bentobox.bentobox.managers; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; + +/** + * Periodic housekeeping: automatically runs the region-files purge against + * every gamemode overworld on a configurable schedule. + * + *

Enabled via {@code island.deletion.housekeeping.enabled}. The task runs + * every {@code interval-days} days (wall-clock, not uptime) and scans for + * regions older than {@code region-age-days}. Since player resets now + * orphan islands instead of physically deleting their blocks, this scheduler + * is how the disk space is eventually reclaimed. + * + *

Last-run timestamp is persisted to + * {@code /database/housekeeping.yml} regardless of the + * configured database backend, so the schedule survives restarts. + * + *

This manager is destructive by design: it deletes {@code .mca} region + * files from disk. Default is OFF. + * + * @since 3.14.0 + */ +public class HousekeepingManager { + + private static final String LAST_RUN_KEY = "lastRunMillis"; + private static final long CHECK_INTERVAL_TICKS = 20L * 60L * 60L; // 1 hour + private static final long STARTUP_DELAY_TICKS = 20L * 60L * 5L; // 5 minutes + + private final BentoBox plugin; + private final File stateFile; + private volatile long lastRunMillis; + private volatile boolean inProgress; + private BukkitTask scheduledTask; + + public HousekeepingManager(BentoBox plugin) { + this.plugin = plugin; + this.stateFile = new File(new File(plugin.getDataFolder(), "database"), "housekeeping.yml"); + this.lastRunMillis = loadLastRun(); + } + + // --------------------------------------------------------------- + // Scheduling + // --------------------------------------------------------------- + + /** + * Starts the periodic housekeeping check. Safe to call multiple times — + * the task is only scheduled once. + */ + public synchronized void start() { + if (scheduledTask != null) { + return; + } + // Check hourly; each check runs the purge only if the wall-clock + // interval since the last run has elapsed and the feature is enabled. + scheduledTask = Bukkit.getScheduler().runTaskTimer(plugin, + this::checkAndMaybeRun, STARTUP_DELAY_TICKS, CHECK_INTERVAL_TICKS); + plugin.log("Housekeeping scheduler started (enabled=" + + plugin.getSettings().isHousekeepingEnabled() + + ", interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d" + + ", last-run=" + (lastRunMillis == 0 ? "never" : Instant.ofEpochMilli(lastRunMillis)) + ")"); + } + + /** + * Stops the periodic housekeeping check. Does not clear the last-run + * timestamp on disk. + */ + public synchronized void stop() { + if (scheduledTask != null) { + scheduledTask.cancel(); + scheduledTask = null; + } + } + + /** + * @return {@code true} if a housekeeping run is currently in progress. + */ + public boolean isInProgress() { + return inProgress; + } + + /** + * @return the wall-clock timestamp (millis) of the last successful run, + * or {@code 0} if the task has never run. + */ + public long getLastRunMillis() { + return lastRunMillis; + } + + private void checkAndMaybeRun() { + if (inProgress) { + return; + } + if (!plugin.getSettings().isHousekeepingEnabled()) { + return; + } + int intervalDays = plugin.getSettings().getHousekeepingIntervalDays(); + if (intervalDays <= 0) { + plugin.logWarning("Housekeeping: interval-days must be >= 1, skipping run"); + return; + } + long intervalMillis = TimeUnit.DAYS.toMillis(intervalDays); + long now = System.currentTimeMillis(); + if (lastRunMillis != 0 && (now - lastRunMillis) < intervalMillis) { + return; + } + runNow(); + } + + /** + * Triggers an immediate housekeeping cycle, regardless of the + * wall-clock interval (but still respecting {@code enabled}). + * Runs asynchronously. + */ + public synchronized void runNow() { + if (inProgress) { + plugin.log("Housekeeping: run requested but already in progress, ignoring"); + return; + } + inProgress = true; + Bukkit.getScheduler().runTaskAsynchronously(plugin, this::executeCycle); + } + + // --------------------------------------------------------------- + // Cycle execution + // --------------------------------------------------------------- + + private void executeCycle() { + long startMillis = System.currentTimeMillis(); + try { + int ageDays = plugin.getSettings().getHousekeepingRegionAgeDays(); + if (ageDays <= 0) { + plugin.logError("Housekeeping: region-age-days must be >= 1, aborting run"); + return; + } + List gameModes = plugin.getAddonsManager().getGameModeAddons(); + plugin.log("Housekeeping: starting auto-purge cycle across " + gameModes.size() + + " gamemode(s), region-age=" + ageDays + "d"); + // Save worlds up-front so disk state matches memory + Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getWorlds().forEach(World::save)); + + int totalWorlds = 0; + int totalRegionsPurged = 0; + for (GameModeAddon gm : gameModes) { + World overworld = gm.getOverWorld(); + if (overworld == null) { + continue; + } + totalWorlds++; + plugin.log("Housekeeping: scanning gamemode '" + gm.getDescription().getName() + + "' world '" + overworld.getName() + "'"); + PurgeScanResult scan = plugin.getPurgeRegionsService().scan(overworld, ageDays); + if (scan.isEmpty()) { + plugin.log("Housekeeping: nothing to purge in " + overworld.getName()); + continue; + } + plugin.log("Housekeeping: " + scan.deleteableRegions().size() + " region(s) and " + + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName()); + boolean ok = plugin.getPurgeRegionsService().delete(scan); + if (ok) { + totalRegionsPurged += scan.deleteableRegions().size(); + } else { + plugin.logError("Housekeeping: purge of " + overworld.getName() + + " completed with errors"); + } + } + + Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); + plugin.log("Housekeeping: cycle complete — " + totalWorlds + " world(s) processed, " + + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); + lastRunMillis = System.currentTimeMillis(); + saveLastRun(); + } catch (Exception e) { + plugin.logError("Housekeeping: cycle failed: " + e.getMessage()); + plugin.logStacktrace(e); + } finally { + inProgress = false; + } + } + + // --------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------- + + private long loadLastRun() { + if (!stateFile.exists()) { + return 0L; + } + try { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(stateFile); + return yaml.getLong(LAST_RUN_KEY, 0L); + } catch (Exception e) { + plugin.logError("Housekeeping: could not read " + stateFile.getAbsolutePath() + + ": " + e.getMessage()); + return 0L; + } + } + + private void saveLastRun() { + try { + File parent = stateFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + plugin.logError("Housekeeping: could not create " + parent.getAbsolutePath()); + return; + } + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(LAST_RUN_KEY, lastRunMillis); + yaml.save(stateFile); + } catch (IOException e) { + plugin.logError("Housekeeping: could not write " + stateFile.getAbsolutePath() + + ": " + e.getMessage()); + } + } +} diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java new file mode 100644 index 000000000..06042d8a8 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -0,0 +1,639 @@ +package world.bentobox.bentobox.managers; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.bukkit.Bukkit; +import org.bukkit.World; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.island.IslandGrid; +import world.bentobox.bentobox.managers.island.IslandGrid.IslandData; +import world.bentobox.bentobox.util.Pair; +import world.bentobox.level.Level; + +/** + * Core implementation of the "purge region files" operation shared by the + * {@code /bbox admin purge regions} command and the periodic + * {@link HousekeepingManager} auto-purge task. + * + *

All public methods perform blocking disk I/O and must be called from an + * async thread. The service does not interact with players or issue + * confirmations — the caller is responsible for any user-facing UX. + * + *

Extracted from {@code AdminPurgeRegionsCommand} so the command and the + * scheduler can share a single code path for scanning, filtering, and + * deleting region files across the overworld + optional nether/end + * dimensions. + * + * @since 3.14.0 + */ +public class PurgeRegionsService { + + private static final String REGION = "region"; + private static final String ENTITIES = "entities"; + private static final String POI = "poi"; + private static final String DIM_1 = "DIM-1"; + private static final String DIM1 = "DIM1"; + private static final String PLAYERS = "players"; + private static final String PLAYERDATA = "playerdata"; + private static final String EXISTS_PREFIX = " (exists="; + private static final String PURGE_FOUND = "Purge found "; + + private final BentoBox plugin; + + public PurgeRegionsService(BentoBox plugin) { + this.plugin = plugin; + } + + /** + * Result of a purge scan — a map of deletable region coordinates to the + * set of island IDs in each region, plus filtering statistics. + * + * @param world the world scanned + * @param days the age cutoff (days) used + * @param deleteableRegions regions considered deletable keyed by region + * coordinate {@code (regionX, regionZ)} + * @param isNether whether the nether dimension was included + * @param isEnd whether the end dimension was included + * @param stats filter statistics for logging/reporting + */ + public record PurgeScanResult( + World world, + int days, + Map, Set> deleteableRegions, + boolean isNether, + boolean isEnd, + FilterStats stats) { + public boolean isEmpty() { + return deleteableRegions.isEmpty(); + } + + public int uniqueIslandCount() { + Set ids = new HashSet<>(); + deleteableRegions.values().forEach(ids::addAll); + return ids.size(); + } + } + + /** Tracks island-level and region-level block counts during filtering. */ + public record FilterStats(int islandsOverLevel, int islandsPurgeProtected, + int regionsBlockedByLevel, int regionsBlockedByProtection) {} + + /** Groups the three folder types (region, entities, poi) for one world dimension. */ + private record DimFolders(File region, File entities, File poi) {} + + // --------------------------------------------------------------- + // Public API + // --------------------------------------------------------------- + + /** + * Scans the given world (and its nether/end if the gamemode owns them) + * for region files older than {@code days} and returns the set of + * regions whose overlapping islands may all be safely deleted. + * + *

Runs synchronously on the calling thread and performs disk I/O. + * Callers must invoke this from an async task. + * + * @param world the gamemode overworld to scan + * @param days minimum age in days for region files to be candidates + * @return scan result, never {@code null} + */ + public PurgeScanResult scan(World world, int days) { + boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world); + boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world); + + IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world); + if (islandGrid == null) { + return new PurgeScanResult(world, days, new HashMap<>(), isNether, isEnd, + new FilterStats(0, 0, 0, 0)); + } + + List> oldRegions = findOldRegions(world, days, isNether, isEnd); + Map, Set> deleteableRegions = mapIslandsToRegions(oldRegions, islandGrid); + FilterStats stats = filterNonDeletableRegions(deleteableRegions, days); + logFilterStats(stats); + return new PurgeScanResult(world, days, deleteableRegions, isNether, isEnd, stats); + } + + /** + * Deletes the region files identified by a prior {@link #scan(World, int)} + * along with any island database entries, island cache entries, and + * orphaned player data files that correspond to them. + * + *

Runs synchronously on the calling thread and performs disk I/O. + * Callers must invoke this from an async task. Worlds should be saved + * first to flush any in-memory chunk state. + * + * @param scan the prior scan result + * @return {@code true} if all file deletions succeeded; {@code false} if + * any file was unexpectedly fresh or could not be deleted + */ + public boolean delete(PurgeScanResult scan) { + if (scan.deleteableRegions().isEmpty()) { + return false; + } + // Save the worlds to flush any in-memory region state + Bukkit.getWorlds().forEach(World::save); + + plugin.log("Now deleting region files for world " + scan.world().getName()); + boolean ok = deleteRegionFiles(scan); + if (!ok) { + plugin.logError("Not all region files could be deleted"); + } + + // Delete islands + player data + int islandsRemoved = 0; + for (Set islandIDs : scan.deleteableRegions().values()) { + for (String islandID : islandIDs) { + deletePlayerFromWorldFolder(scan.world(), islandID, scan.deleteableRegions(), scan.days()); + plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); + if (plugin.getIslands().deleteIslandId(islandID)) { + plugin.log("Island ID " + islandID + " deleted from cache and database"); + islandsRemoved++; + } + } + } + plugin.log("Purge complete for world " + scan.world().getName() + + ": " + scan.deleteableRegions().size() + " region(s), " + + islandsRemoved + " island(s) removed"); + return ok; + } + + // --------------------------------------------------------------- + // Filtering + // --------------------------------------------------------------- + + /** + * Removes regions whose island-set contains at least one island that + * cannot be deleted, returning blocking statistics. + */ + private FilterStats filterNonDeletableRegions( + Map, Set> deleteableRegions, int days) { + int islandsOverLevel = 0; + int islandsPurgeProtected = 0; + int regionsBlockedByLevel = 0; + int regionsBlockedByProtection = 0; + + var iter = deleteableRegions.entrySet().iterator(); + while (iter.hasNext()) { + var entry = iter.next(); + int[] regionCounts = evaluateRegionIslands(entry.getValue(), days); + if (regionCounts[0] > 0) { // shouldRemove + iter.remove(); + islandsOverLevel += regionCounts[1]; + islandsPurgeProtected += regionCounts[2]; + if (regionCounts[1] > 0) regionsBlockedByLevel++; + if (regionCounts[2] > 0) regionsBlockedByProtection++; + } + } + return new FilterStats(islandsOverLevel, islandsPurgeProtected, + regionsBlockedByLevel, regionsBlockedByProtection); + } + + private int[] evaluateRegionIslands(Set islandIds, int days) { + int shouldRemove = 0; + int levelBlocked = 0; + int purgeBlocked = 0; + for (String id : islandIds) { + Optional opt = plugin.getIslands().getIslandById(id); + if (opt.isEmpty()) { + shouldRemove = 1; + continue; + } + Island isl = opt.get(); + if (cannotDeleteIsland(isl, days)) { + shouldRemove = 1; + if (isl.isPurgeProtected()) purgeBlocked++; + if (isLevelTooHigh(isl)) levelBlocked++; + } + } + return new int[] { shouldRemove, levelBlocked, purgeBlocked }; + } + + private void logFilterStats(FilterStats stats) { + if (stats.islandsOverLevel() > 0) { + plugin.log("Purge: " + stats.islandsOverLevel() + " island(s) exceed the level threshold of " + + plugin.getSettings().getIslandPurgeLevel() + + " - preventing " + stats.regionsBlockedByLevel() + " region(s) from being purged"); + } + if (stats.islandsPurgeProtected() > 0) { + plugin.log("Purge: " + stats.islandsPurgeProtected() + " island(s) are purge-protected" + + " - preventing " + stats.regionsBlockedByProtection() + " region(s) from being purged"); + } + } + + /** + * Check if an island cannot be deleted. Purge protected, spawn, or + * unowned (non-deletable) islands cannot be deleted. Islands whose members + * recently logged in, or that exceed the level threshold, cannot be + * deleted. + * + * @param island island + * @param days the age cutoff + * @return {@code true} if the island cannot be deleted + */ + public boolean cannotDeleteIsland(Island island, int days) { + if (island.isDeletable()) { + return false; + } + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + boolean recentLogin = island.getMemberSet().stream().anyMatch(uuid -> { + Long lastLogin = plugin.getPlayers().getLastLoginTimestamp(uuid); + if (lastLogin == null) { + lastLogin = Bukkit.getOfflinePlayer(uuid).getLastSeen(); + } + return lastLogin >= cutoffMillis; + }); + if (recentLogin) { + return true; + } + if (isLevelTooHigh(island)) { + return true; + } + return island.isPurgeProtected() || island.isSpawn() || !island.isOwned(); + } + + private boolean isLevelTooHigh(Island island) { + return plugin.getAddonsManager().getAddonByName("Level") + .map(l -> ((Level) l).getIslandLevel(island.getWorld(), island.getOwner()) + >= plugin.getSettings().getIslandPurgeLevel()) + .orElse(false); + } + + // --------------------------------------------------------------- + // Scan + // --------------------------------------------------------------- + + /** + * Finds all region files in the overworld (and optionally nether/end) + * that have not been modified in the last {@code days} days. + */ + private List> findOldRegions(World world, int days, boolean isNether, boolean isEnd) { + File worldDir = world.getWorldFolder(); + File overworldRegion = new File(worldDir, REGION); + + World netherWorld = plugin.getIWM().getNetherWorld(world); + File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir); + File netherRegion = new File(netherBase, REGION); + + World endWorld = plugin.getIWM().getEndWorld(world); + File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir); + File endRegion = new File(endBase, REGION); + + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + + logRegionFolderPaths(overworldRegion, netherRegion, endRegion, world, isNether, isEnd); + + Set candidateNames = collectCandidateNames(overworldRegion, netherRegion, endRegion, isNether, isEnd); + plugin.log("Purge total candidate region coordinates: " + candidateNames.size()); + plugin.log("Purge checking candidate region(s) against island data, please wait..."); + + List> regions = new ArrayList<>(); + for (String name : candidateNames) { + Pair coords = parseRegionCoords(name); + if (coords == null) continue; + if (!isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, isNether, isEnd)) { + regions.add(coords); + } + } + return regions; + } + + private void logRegionFolderPaths(File overworldRegion, File netherRegion, File endRegion, + World world, boolean isNether, boolean isEnd) { + plugin.log("Purge region folders - Overworld: " + overworldRegion.getAbsolutePath() + + EXISTS_PREFIX + overworldRegion.isDirectory() + ")"); + if (isNether) { + plugin.log("Purge region folders - Nether: " + netherRegion.getAbsolutePath() + + EXISTS_PREFIX + netherRegion.isDirectory() + ")"); + } else { + plugin.log("Purge region folders - Nether: disabled (isNetherGenerate=" + + plugin.getIWM().isNetherGenerate(world) + ", isNetherIslands=" + + plugin.getIWM().isNetherIslands(world) + ")"); + } + if (isEnd) { + plugin.log("Purge region folders - End: " + endRegion.getAbsolutePath() + + EXISTS_PREFIX + endRegion.isDirectory() + ")"); + } else { + plugin.log("Purge region folders - End: disabled (isEndGenerate=" + + plugin.getIWM().isEndGenerate(world) + ", isEndIslands=" + + plugin.getIWM().isEndIslands(world) + ")"); + } + } + + private Set collectCandidateNames(File overworldRegion, File netherRegion, File endRegion, + boolean isNether, boolean isEnd) { + Set names = new HashSet<>(); + addFileNames(names, overworldRegion.listFiles((dir, name) -> name.endsWith(".mca")), "overworld"); + if (isNether) { + addFileNames(names, netherRegion.listFiles((dir, name) -> name.endsWith(".mca")), "nether"); + } + if (isEnd) { + addFileNames(names, endRegion.listFiles((dir, name) -> name.endsWith(".mca")), "end"); + } + return names; + } + + private void addFileNames(Set names, File[] files, String dimension) { + if (files != null) { + for (File f : files) names.add(f.getName()); + } + plugin.log(PURGE_FOUND + (files != null ? files.length : 0) + " " + dimension + " region files"); + } + + private Pair parseRegionCoords(String name) { + String coordsPart = name.substring(2, name.length() - 4); + String[] parts = coordsPart.split("\\."); + if (parts.length != 2) return null; + try { + return new Pair<>(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + } catch (NumberFormatException ex) { + return null; + } + } + + /** + * Maps each old region to the set of island IDs whose island-squares + * overlap it. Each region covers a 512x512 block square. + */ + private Map, Set> mapIslandsToRegions( + List> oldRegions, IslandGrid islandGrid) { + final int blocksPerRegion = 512; + Map, Set> regionToIslands = new HashMap<>(); + + for (Pair region : oldRegions) { + int regionMinX = region.x() * blocksPerRegion; + int regionMinZ = region.z() * blocksPerRegion; + int regionMaxX = regionMinX + blocksPerRegion - 1; + int regionMaxZ = regionMinZ + blocksPerRegion - 1; + + Set ids = new HashSet<>(); + for (IslandData data : islandGrid.getIslandsInBounds(regionMinX, regionMinZ, regionMaxX, regionMaxZ)) { + ids.add(data.id()); + } + regionToIslands.put(region, ids); + } + return regionToIslands; + } + + // --------------------------------------------------------------- + // Delete + // --------------------------------------------------------------- + + private boolean deleteRegionFiles(PurgeScanResult scan) { + int days = scan.days(); + if (days <= 0) { + plugin.logError("Days is somehow zero or negative!"); + return false; + } + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + + World world = scan.world(); + File base = world.getWorldFolder(); + File overworldRegion = new File(base, REGION); + File overworldEntities = new File(base, ENTITIES); + File overworldPoi = new File(base, POI); + + World netherWorld = plugin.getIWM().getNetherWorld(world); + File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base); + File netherRegion = new File(netherBase, REGION); + File netherEntities = new File(netherBase, ENTITIES); + File netherPoi = new File(netherBase, POI); + + World endWorld = plugin.getIWM().getEndWorld(world); + File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base); + File endRegion = new File(endBase, REGION); + File endEntities = new File(endBase, ENTITIES); + File endPoi = new File(endBase, POI); + + // Verify none of the files have been updated since the cutoff + for (Pair coords : scan.deleteableRegions().keySet()) { + String name = "r." + coords.x() + "." + coords.z() + ".mca"; + if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, + scan.isNether(), scan.isEnd())) { + return false; + } + } + + DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); + DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); + DimFolders end = new DimFolders(endRegion, endEntities, endPoi); + boolean allOk = true; + for (Pair coords : scan.deleteableRegions().keySet()) { + String name = "r." + coords.x() + "." + coords.z() + ".mca"; + if (!deleteOneRegion(name, ow, nether, end, scan.isNether(), scan.isEnd())) { + plugin.logError("Could not delete all the region/entity/poi files for some reason"); + allOk = false; + } + } + return allOk; + } + + private boolean deleteOneRegion(String name, DimFolders overworld, DimFolders nether, DimFolders end, + boolean isNether, boolean isEnd) { + boolean ok = deleteIfExists(new File(overworld.region(), name)) + && deleteIfExists(new File(overworld.entities(), name)) + && deleteIfExists(new File(overworld.poi(), name)); + if (isNether) { + ok &= deleteIfExists(new File(nether.region(), name)); + ok &= deleteIfExists(new File(nether.entities(), name)); + ok &= deleteIfExists(new File(nether.poi(), name)); + } + if (isEnd) { + ok &= deleteIfExists(new File(end.region(), name)); + ok &= deleteIfExists(new File(end.entities(), name)); + ok &= deleteIfExists(new File(end.poi(), name)); + } + return ok; + } + + private boolean deleteIfExists(File file) { + if (!file.getParentFile().exists()) { + return true; + } + try { + Files.deleteIfExists(file.toPath()); + return true; + } catch (IOException e) { + plugin.logError("Failed to delete file: " + file.getAbsolutePath()); + return false; + } + } + + // --------------------------------------------------------------- + // Player data cleanup + // --------------------------------------------------------------- + + private void deletePlayerFromWorldFolder(World world, String islandID, + Map, Set> deleteableRegions, int days) { + File playerData = resolvePlayerDataFolder(world); + plugin.getIslands().getIslandById(islandID) + .ifPresent(island -> island.getMemberSet() + .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deleteableRegions, days))); + } + + private void maybeDeletePlayerData(World world, UUID uuid, File playerData, + Map, Set> deleteableRegions, int days) { + List memberOf = new ArrayList<>(plugin.getIslands().getIslands(world, uuid)); + deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); + if (!memberOf.isEmpty()) { + return; + } + if (Bukkit.getOfflinePlayer(uuid).isOp()) { + return; + } + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + Long lastLogin = plugin.getPlayers().getLastLoginTimestamp(uuid); + long actualLast = lastLogin != null ? lastLogin : Bukkit.getOfflinePlayer(uuid).getLastSeen(); + if (actualLast >= cutoffMillis) { + return; + } + deletePlayerFiles(uuid, playerData); + } + + private void deletePlayerFiles(UUID uuid, File playerData) { + if (!playerData.exists()) { + return; + } + deletePlayerFile(new File(playerData, uuid + ".dat"), "player data file"); + deletePlayerFile(new File(playerData, uuid + ".dat_old"), "player data backup file"); + } + + private void deletePlayerFile(File file, String description) { + try { + Files.deleteIfExists(file.toPath()); + } catch (IOException ex) { + plugin.logError("Failed to delete " + description + ": " + file.getAbsolutePath()); + } + } + + // --------------------------------------------------------------- + // Dimension path resolution (pre-26.1 vs 26.1.1+) + // --------------------------------------------------------------- + + private File resolveDataFolder(World world) { + File worldFolder = world.getWorldFolder(); + return switch (world.getEnvironment()) { + case NETHER -> { + File dim = new File(worldFolder, DIM_1); + yield dim.isDirectory() ? dim : worldFolder; + } + case THE_END -> { + File dim = new File(worldFolder, DIM1); + yield dim.isDirectory() ? dim : worldFolder; + } + default -> worldFolder; + }; + } + + private File resolvePlayerDataFolder(World world) { + File worldFolder = world.getWorldFolder(); + File oldPath = new File(worldFolder, PLAYERDATA); + if (oldPath.isDirectory()) { + return oldPath; + } + File root = worldFolder.getParentFile(); + if (root != null) root = root.getParentFile(); + if (root != null) root = root.getParentFile(); + if (root != null) { + File newPath = new File(root, PLAYERS + File.separator + "data"); + if (newPath.isDirectory()) { + return newPath; + } + } + return oldPath; + } + + private File resolveNetherFallback(File overworldFolder) { + File dim = new File(overworldFolder, DIM_1); + if (dim.isDirectory()) { + return dim; + } + File parent = overworldFolder.getParentFile(); + if (parent != null) { + File sibling = new File(parent, overworldFolder.getName() + "_nether"); + if (sibling.isDirectory()) { + return sibling; + } + } + return dim; + } + + private File resolveEndFallback(File overworldFolder) { + File dim = new File(overworldFolder, DIM1); + if (dim.isDirectory()) { + return dim; + } + File parent = overworldFolder.getParentFile(); + if (parent != null) { + File sibling = new File(parent, overworldFolder.getName() + "_the_end"); + if (sibling.isDirectory()) { + return sibling; + } + } + return dim; + } + + // --------------------------------------------------------------- + // Freshness checks + region timestamp reader + // --------------------------------------------------------------- + + private boolean isFileFresh(File file, long cutoffMillis) { + return file.exists() && getRegionTimestamp(file) >= cutoffMillis; + } + + private boolean isAnyDimensionFresh(String name, File overworldRegion, File netherRegion, + File endRegion, long cutoffMillis, boolean isNether, boolean isEnd) { + if (isFileFresh(new File(overworldRegion, name), cutoffMillis)) return true; + if (isNether && isFileFresh(new File(netherRegion, name), cutoffMillis)) return true; + return isEnd && isFileFresh(new File(endRegion, name), cutoffMillis); + } + + /** + * Reads the most recent per-chunk timestamp in a Minecraft .mca file + * header, in milliseconds since epoch. + */ + private long getRegionTimestamp(File regionFile) { + if (!regionFile.exists() || regionFile.length() < 8192) { + return 0L; + } + try (FileInputStream fis = new FileInputStream(regionFile)) { + byte[] buffer = new byte[4096]; + if (fis.skip(4096) != 4096) { + return 0L; + } + if (fis.read(buffer) != 4096) { + return 0L; + } + ByteBuffer bb = ByteBuffer.wrap(buffer); + bb.order(ByteOrder.BIG_ENDIAN); + long maxTimestampSeconds = 0; + for (int i = 0; i < 1024; i++) { + long timestamp = Integer.toUnsignedLong(bb.getInt()); + if (timestamp > maxTimestampSeconds) { + maxTimestampSeconds = timestamp; + } + } + return maxTimestampSeconds * 1000L; + } catch (IOException e) { + plugin.logError("Failed to read region file timestamps: " + regionFile.getAbsolutePath() + + " " + e.getMessage()); + return 0L; + } + } +} diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java index b27e00563..5b2ac779a 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java @@ -42,6 +42,7 @@ import world.bentobox.bentobox.managers.AddonsManager; import world.bentobox.bentobox.managers.CommandsManager; import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.managers.PurgeRegionsService; import world.bentobox.bentobox.managers.island.IslandCache; import world.bentobox.bentobox.managers.island.IslandGrid; @@ -123,6 +124,10 @@ public void setUp() throws Exception { when(plugin.getAddonsManager()).thenReturn(addonsManager); when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); + // Real PurgeRegionsService wired over the mocked plugin — exercises + // the extracted scan/filter/delete logic exactly as the command does. + when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin)); + // Create commands apc = new AdminPurgeCommand(ac); aprc = new AdminPurgeRegionsCommand(apc); From da21feaba31955facb095e4df4a777a6fd55311b Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 04:26:54 -0700 Subject: [PATCH 02/16] Fix async World.save() crash in purge delete path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running /bbox purge regions confirm on Paper 26.1.1 tripped AsyncCatcher because PurgeRegionsService.delete() was saving worlds from the async worker thread, and World.save() is main-thread-only: IllegalStateException: Asynchronous world save! at PurgeRegionsService.delete(PurgeRegionsService.java:151) The pre-refactor command ran the save on the main thread inside execute() but I collapsed it into the service. Move the save back out of the service so all callers are responsible for flushing on the main thread before dispatching the async delete. - PurgeRegionsService.delete(): no longer calls Bukkit.getWorlds().save(). Javadoc updated to state the caller contract. - AdminPurgeRegionsCommand.deleteEverything(): call Bukkit.getWorlds() .forEach(World::save) before scheduling the async delete. Runs on the main thread since execute() is invoked there. - HousekeepingManager.executeCycle(): the existing runTask() save was fire-and-forget — the async cycle could start scanning/deleting before the save finished. Block via CompletableFuture.join() until the main-thread save completes. - AdminPurgeRegionsCommandTest: add regression asserting the service never calls Bukkit.getWorlds() itself (would have caught this bug). Co-Authored-By: Claude Opus 4.6 --- .../admin/purge/AdminPurgeRegionsCommand.java | 3 ++ .../managers/HousekeepingManager.java | 16 ++++++++-- .../managers/PurgeRegionsService.java | 10 +++---- .../purge/AdminPurgeRegionsCommandTest.java | 29 +++++++++++++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java index 0af812802..adb3f4b92 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java @@ -111,6 +111,9 @@ private boolean deleteEverything() { PurgeScanResult scan = lastScan; lastScan = null; toBeConfirmed = false; + // Flush in-memory chunk state on the main thread before the async + // delete — World.save() is not safe to call off-main. + Bukkit.getWorlds().forEach(World::save); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); Bukkit.getScheduler().runTask(getPlugin(), () -> diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index cbeeb25d7..0b8f4274a 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -5,6 +5,7 @@ import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; @@ -151,8 +152,19 @@ private void executeCycle() { List gameModes = plugin.getAddonsManager().getGameModeAddons(); plugin.log("Housekeeping: starting auto-purge cycle across " + gameModes.size() + " gamemode(s), region-age=" + ageDays + "d"); - // Save worlds up-front so disk state matches memory - Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getWorlds().forEach(World::save)); + // Save worlds up-front so disk state matches memory. World.save() + // must run on the main thread — hop over and block the async + // cycle until the save completes. + CompletableFuture saved = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(plugin, () -> { + try { + Bukkit.getWorlds().forEach(World::save); + saved.complete(null); + } catch (Exception e) { + saved.completeExceptionally(e); + } + }); + saved.join(); int totalWorlds = 0; int totalRegionsPurged = 0; diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 06042d8a8..60bd6d3a9 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -136,8 +136,11 @@ public PurgeScanResult scan(World world, int days) { * orphaned player data files that correspond to them. * *

Runs synchronously on the calling thread and performs disk I/O. - * Callers must invoke this from an async task. Worlds should be saved - * first to flush any in-memory chunk state. + * Callers must invoke this from an async task. Callers are also + * responsible for flushing in-memory chunk state by calling + * {@code World.save()} on the main thread before dispatching + * this method — {@code World.save()} is not safe to invoke from an + * async thread. * * @param scan the prior scan result * @return {@code true} if all file deletions succeeded; {@code false} if @@ -147,9 +150,6 @@ public boolean delete(PurgeScanResult scan) { if (scan.deleteableRegions().isEmpty()) { return false; } - // Save the worlds to flush any in-memory region state - Bukkit.getWorlds().forEach(World::save); - plugin.log("Now deleting region files for world " + scan.world().getName()); boolean ok = deleteRegionFiles(scan); if (!ok) { diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java index 5b2ac779a..97d02b579 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java @@ -518,4 +518,33 @@ void testExecuteConfirmDeletesPlayerData() throws IOException { verify(im).deleteIslandId("island-deletable"); assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted"); } + + /** + * Regression for the async {@code World.save()} crash hit on 26.1.1 Paper: + * {@code PurgeRegionsService.delete()} must not call + * {@code Bukkit.getWorlds().forEach(World::save)} because it runs on an + * async worker, and {@code World.save()} is main-thread-only. The world + * save must happen on the main thread *before* delete() is dispatched. + * + *

We call the service's {@code scan} + {@code delete} directly (as + * the async task would), then assert that {@code Bukkit.getWorlds()} + * was never invoked at all by the service — neither the scan nor the + * delete needs it. + */ + @Test + void testServiceDoesNotCallBukkitGetWorlds() throws IOException { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + // Create an old empty region file the scan will pick up + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + + PurgeRegionsService service = new PurgeRegionsService(plugin); + PurgeRegionsService.PurgeScanResult scan = service.scan(world, 10); + service.delete(scan); + + mockedBukkit.verify(Bukkit::getWorlds, never()); + } } From 9c90160e54ac91e87425ae2c5e95db6f7a8ce2e7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 04:33:04 -0700 Subject: [PATCH 03/16] Log explicit save messages around purge world saves Paper rate-limits its built-in "plugin-induced save detected" warning, so after the scan save fired once, the confirm-path save was silent and looked like it wasn't running. Add explicit plugin.log lines on both sides of every World.save() call in the purge code paths (scan, confirm, housekeeping) so operators always see when the save is happening. Co-Authored-By: Claude Opus 4.6 --- .../api/commands/admin/purge/AdminPurgeRegionsCommand.java | 4 ++++ .../world/bentobox/bentobox/managers/HousekeepingManager.java | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java index adb3f4b92..6d90789c4 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java @@ -87,7 +87,9 @@ public boolean execute(User user, String label, List args) { user.sendMessage("commands.admin.purge.scanning"); // Save all worlds to update any region files + getPlugin().log("Purge: saving all worlds before scanning region files..."); Bukkit.getWorlds().forEach(World::save); + getPlugin().log("Purge: world save complete"); inPurge = true; final int finalDays = days; @@ -113,7 +115,9 @@ private boolean deleteEverything() { toBeConfirmed = false; // Flush in-memory chunk state on the main thread before the async // delete — World.save() is not safe to call off-main. + getPlugin().log("Purge: saving all worlds before deleting region files..."); Bukkit.getWorlds().forEach(World::save); + getPlugin().log("Purge: world save complete, dispatching deletion"); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); Bukkit.getScheduler().runTask(getPlugin(), () -> diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index 0b8f4274a..af58427db 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -155,6 +155,7 @@ private void executeCycle() { // Save worlds up-front so disk state matches memory. World.save() // must run on the main thread — hop over and block the async // cycle until the save completes. + plugin.log("Housekeeping: saving all worlds before purge..."); CompletableFuture saved = new CompletableFuture<>(); Bukkit.getScheduler().runTask(plugin, () -> { try { @@ -165,6 +166,7 @@ private void executeCycle() { } }); saved.join(); + plugin.log("Housekeeping: world save complete"); int totalWorlds = 0; int totalRegionsPurged = 0; From 6bc7ae3186cfde8f914271657e8a6275d63a9817 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 04:48:43 -0700 Subject: [PATCH 04/16] Add admin purge age-regions test helper Adds /bbox admin purge age-regions to rewrite per-chunk timestamp tables in .mca files so regions become purgable without waiting wall-clock time. The purge scanner reads timestamps from the region header, not file mtime, so `touch` cannot fake ageing. Co-Authored-By: Claude Opus 4.6 --- .../purge/AdminPurgeAgeRegionsCommand.java | 96 +++++++++++++++ .../admin/purge/AdminPurgeCommand.java | 1 + .../managers/PurgeRegionsService.java | 109 ++++++++++++++++++ src/main/resources/locales/en-US.yml | 4 + .../admin/purge/AdminPurgeCommandTest.java | 2 +- 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java new file mode 100644 index 000000000..164239989 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java @@ -0,0 +1,96 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.Listener; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Admin debug/testing command that artificially ages {@code .mca} region + * files in the current gamemode world so they become candidates for the + * purge regions flow without having to wait real wall-clock time. + * + *

The purge scanner reads per-chunk timestamps from the region file + * header, not from file mtime, so {@code touch} cannot fake ageing. + * This command rewrites that timestamp table in place via + * {@link world.bentobox.bentobox.managers.PurgeRegionsService#ageRegions(World, int)}. + * + *

Usage: {@code / purge age-regions } + * + * @since 3.14.0 + */ +public class AdminPurgeAgeRegionsCommand extends CompositeCommand implements Listener { + + private volatile boolean running; + + public AdminPurgeAgeRegionsCommand(CompositeCommand parent) { + super(parent, "age-regions"); + getAddon().registerListener(this); + } + + @Override + public void setup() { + setPermission("admin.purge.age-regions"); + setOnlyPlayer(false); + setParametersHelp("commands.admin.purge.age-regions.parameters"); + setDescription("commands.admin.purge.age-regions.description"); + } + + @Override + public boolean canExecute(User user, String label, List args) { + if (running) { + user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); + return false; + } + if (args.isEmpty()) { + showHelp(this, user); + return false; + } + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + int days; + try { + days = Integer.parseInt(args.getFirst()); + if (days <= 0) { + user.sendMessage("commands.admin.purge.days-one-or-more"); + return false; + } + } catch (NumberFormatException e) { + user.sendMessage("commands.admin.purge.days-one-or-more"); + return false; + } + + // Flush in-memory chunk state on the main thread before touching + // the region files — otherwise an auto-save can overwrite our + // ageing with current timestamps. + getPlugin().log("Age-regions: saving all worlds before rewriting timestamps..."); + Bukkit.getWorlds().forEach(World::save); + getPlugin().log("Age-regions: world save complete"); + + running = true; + final int finalDays = days; + CompletableFuture.runAsync(() -> { + try { + int count = getPlugin().getPurgeRegionsService().ageRegions(getWorld(), finalDays); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + user.sendMessage("commands.admin.purge.age-regions.done", + TextVariables.NUMBER, String.valueOf(count)); + getPlugin().log("Age-regions: " + count + " region file(s) aged by " + + finalDays + " day(s) in world " + getWorld().getName()); + }); + } finally { + running = false; + } + }); + return true; + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java index b49b5164e..7c7653595 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java @@ -48,6 +48,7 @@ public void setup() { new AdminPurgeUnownedCommand(this); new AdminPurgeProtectCommand(this); new AdminPurgeRegionsCommand(this); + new AdminPurgeAgeRegionsCommand(this); } @Override diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 60bd6d3a9..b6c3ecbaf 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -3,6 +3,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.file.Files; @@ -174,6 +175,114 @@ public boolean delete(PurgeScanResult scan) { return ok; } + // --------------------------------------------------------------- + // Debug / testing: artificially age region files + // --------------------------------------------------------------- + + /** + * Debug/testing utility. Rewrites the per-chunk timestamp table of every + * {@code .mca} region file in the given world's overworld (and nether/end + * if the gamemode owns those dimensions) so that every chunk entry looks + * like it was last written {@code days} days ago. + * + *

The purge scanner reads per-chunk timestamps from the second 4KB + * block of each region file's header (not file mtime), so {@code touch} + * cannot fake ageing. This rewrites that 4KB timestamp table in place, + * setting all 1024 slots to {@code now - days*86400} seconds. File mtime + * is not modified. + * + *

Runs synchronously and performs disk I/O. Callers must invoke + * this from an async task, and should call {@code World.save()} on the + * main thread first to flush in-memory chunk state. + * + * @param world the gamemode overworld whose regions should be aged + * @param days how many days in the past to pretend the regions were + * last written + * @return number of {@code .mca} files successfully rewritten across + * all dimensions + */ + public int ageRegions(World world, int days) { + boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world); + boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world); + + File worldDir = world.getWorldFolder(); + File overworldRegion = new File(worldDir, REGION); + + World netherWorld = plugin.getIWM().getNetherWorld(world); + File netherRegion = new File( + netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir), REGION); + + World endWorld = plugin.getIWM().getEndWorld(world); + File endRegion = new File( + endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir), REGION); + + long targetSeconds = (System.currentTimeMillis() / 1000L) - (days * 86400L); + int total = 0; + total += ageRegionsInFolder(overworldRegion, "overworld", targetSeconds); + if (isNether) { + total += ageRegionsInFolder(netherRegion, "nether", targetSeconds); + } + if (isEnd) { + total += ageRegionsInFolder(endRegion, "end", targetSeconds); + } + return total; + } + + private int ageRegionsInFolder(File folder, String dimension, long targetSeconds) { + if (!folder.isDirectory()) { + plugin.log("Age-regions: " + dimension + " folder does not exist, skipping: " + + folder.getAbsolutePath()); + return 0; + } + File[] files = folder.listFiles((dir, name) -> name.endsWith(".mca")); + if (files == null || files.length == 0) { + plugin.log("Age-regions: no .mca files in " + dimension + " folder " + folder.getAbsolutePath()); + return 0; + } + int count = 0; + for (File file : files) { + if (writeTimestampTable(file, targetSeconds)) { + count++; + } + } + plugin.log("Age-regions: rewrote " + count + "/" + files.length + " " + dimension + " region file(s)"); + return count; + } + + /** + * Overwrites the 4KB timestamp table (bytes 4096..8191) of a Minecraft + * {@code .mca} file with a single repeating big-endian int timestamp. + * + * @param regionFile the file to rewrite + * @param targetSeconds the Unix timestamp (seconds) to write into every slot + * @return {@code true} if the table was rewritten + */ + private boolean writeTimestampTable(File regionFile, long targetSeconds) { + if (!regionFile.exists() || regionFile.length() < 8192) { + plugin.log("Age-regions: skipping " + regionFile.getName() + + " (missing or smaller than 8192 bytes)"); + return false; + } + byte[] table = new byte[4096]; + int ts = (int) targetSeconds; + for (int i = 0; i < 1024; i++) { + int offset = i * 4; + table[offset] = (byte) (ts >> 24); + table[offset + 1] = (byte) (ts >> 16); + table[offset + 2] = (byte) (ts >> 8); + table[offset + 3] = (byte) ts; + } + try (RandomAccessFile raf = new RandomAccessFile(regionFile, "rw")) { + raf.seek(4096); + raf.write(table); + return true; + } catch (IOException e) { + plugin.logError("Age-regions: failed to rewrite timestamp table of " + + regionFile.getAbsolutePath() + ": " + e.getMessage()); + return false; + } + } + // --------------------------------------------------------------- // Filtering // --------------------------------------------------------------- diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 3743546e7..59d9ea582 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -128,6 +128,10 @@ commands: parameters: '[days]' description: 'purge islands by deleting old region files' confirm: 'Type /[label] purge regions confirm to start purging' + age-regions: + parameters: '[days]' + description: 'debug/test: rewrite region file timestamps so they become purgable' + done: 'Aged [number] region file(s) in the current world.' protect: description: toggle [prefix_island] purge protection move-to-island: 'Move to [prefix_an-island] first!' diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java index b9723c827..d2766b70e 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java @@ -133,7 +133,7 @@ void testSetup() { assertFalse(apc.isOnlyPlayer()); assertEquals("commands.admin.purge.parameters", apc.getParameters()); assertEquals("commands.admin.purge.description", apc.getDescription()); - assertEquals(6, apc.getSubCommands().size()); + assertEquals(7, apc.getSubCommands().size()); } From 9f33f648b405c502a9343b5e02e8c28cf9300674 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 05:43:47 -0700 Subject: [PATCH 05/16] Phase 2: Reset always soft-deletes via deletable flag IslandsManager.deleteIsland() used to branch on keepPreviousIslandOnReset: false -> evict from cache, enqueue IslandChunkDeletionManager, MultiLib notify, delete DB row. true -> save with deletable=true and fire the deletion event. With the new region-file purge flow (Phase 1), physical cleanup no longer happens inline at all - old islands are left in place with deletable=true and reaped later by PurgeRegionsService / HousekeepingManager. So the hard-path branch goes away entirely: every call with removeBlocks=true now soft-deletes. Consequences in this commit: - AdminDeleteCommand also soft-deletes until Phase 3 splits it on GameModeAddon.isUsesNewChunkGeneration() (new-gen -> soft-delete, void gamemodes -> ChunkGenerator regen). - Nether/End cascade is a no-op in the soft path (nothing touches chunks); PurgeRegionsService.scan already gates nether/end on isNetherIslands/isEndIslands so vanilla-owned dimensions are skipped when the regions are eventually reaped. - keepPreviousIslandOnReset setter/getter remain as deprecated shims (no longer consulted at runtime); Phase 4 removes the field. - The bentobox-deleteIsland MultiLib subscriber is now unreachable from this server's publishers but stays until Phase 4 deletes the deletion infrastructure wholesale. Co-Authored-By: Claude Opus 4.6 --- .../bentobox/managers/IslandsManager.java | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java index aa5c74497..d57ab8494 100644 --- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java @@ -305,23 +305,13 @@ public void deleteIsland(@NonNull Island island, boolean removeBlocks, @Nullable if (removeBlocks) { // Remove players from island removePlayersFromIsland(island); - // Mark island as deletable + // Mark island as deletable - physical cleanup is handled later by + // the region-file purge (see PurgeRegionsService / HousekeepingManager). island.setDeletable(true); - if (!plugin.getSettings().isKeepPreviousIslandOnReset()) { - // Remove island from the cache - islandCache.deleteIslandFromCache(island); - // Remove blocks from world - IslandDeletion id = new IslandDeletion(island); - plugin.getIslandDeletionManager().getIslandChunkDeletionManager().add(id); - // Tell other servers - MultiLib.notify("bentobox-deleteIsland", getGson().toJson(id)); - // Delete the island from the database - handler.deleteObject(island); - } else { - handler.saveObject(island); - // Fire the deletion event immediately - IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build(); - } + handler.saveObject(island); + // Fire the deletion event immediately so listeners (hooks, maps, etc.) + // can update now that the island is orphaned. + IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build(); } } From 0a515c2f57d0731cb4a7ea25c2cd92334b72a3b4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 05:56:52 -0700 Subject: [PATCH 06/16] Surface soft-deleted islands to admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 made reset leave orphaned islands in place with deletable=true until the region purge reaps them. That meant admins walking around a server had no way to tell an orphan from a normal unowned island — /bbox admin info just showed "Unowned" and entering the area was silent. Two visible cues now: - IslandInfo.showAdminInfo() prints a new "deletable: flagged for deletion and awaiting region purge" line when island.isDeletable() is true, right after the purge-protected line. - LockAndBanListener notifies ops (once per entry, same pattern as the existing lock notification) when they step onto an island flagged deletable. Non-ops still see nothing; this is strictly an admin heads-up. The notification state is cleared when the op leaves the island, so walking back in re-triggers it. New locale keys commands.admin.info.deletable and protection.deletable-island-admin in en-US.yml. Co-Authored-By: Claude Opus 4.6 --- .../flags/protection/LockAndBanListener.java | 34 +++++++++++++++++++ .../bentobox/bentobox/util/IslandInfo.java | 3 ++ src/main/resources/locales/en-US.yml | 2 ++ 3 files changed, 39 insertions(+) diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java index 50382e323..d65628125 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java @@ -37,6 +37,14 @@ public class LockAndBanListener extends FlagListener { */ private final Set notifiedPlayers = new HashSet<>(); + /** + * Tracks ops who have already been notified that they are standing on an + * island flagged for deletion (awaiting region purge), to avoid spamming + * the notice on every move event. Cleared when the op leaves a deletable + * island. + */ + private final Set deletableNotified = new HashSet<>(); + /** * Result of checking the island for locked state or player bans * @@ -177,9 +185,35 @@ private CheckResult checkAndNotify(@NonNull Player player, Location loc) User.getInstance(player).notify("protection.locked-island-bypass"); } } + notifyIfDeletable(player, loc); return result; } + /** + * Notify ops that the island they just entered is flagged for deletion + * and awaiting the region purge. Regular players see nothing — this is + * an admin-only heads-up so server staff know the visible chunks will + * be reaped the next time housekeeping runs. + * + *

Fires at most once per entry, using the same "move out to reset" + * pattern as the lock notification. + */ + private void notifyIfDeletable(@NonNull Player player, Location loc) { + if (!player.isOp()) { + deletableNotified.remove(player.getUniqueId()); + return; + } + boolean deletable = getIslands().getProtectedIslandAt(loc) + .map(i -> i.isDeletable()).orElse(false); + if (deletable) { + if (deletableNotified.add(player.getUniqueId())) { + User.getInstance(player).notify("protection.deletable-island-admin"); + } + } else { + deletableNotified.remove(player.getUniqueId()); + } + } + /** * Sends player home * @param player - player diff --git a/src/main/java/world/bentobox/bentobox/util/IslandInfo.java b/src/main/java/world/bentobox/bentobox/util/IslandInfo.java index 579831f2c..7917baf01 100644 --- a/src/main/java/world/bentobox/bentobox/util/IslandInfo.java +++ b/src/main/java/world/bentobox/bentobox/util/IslandInfo.java @@ -119,6 +119,9 @@ public void showAdminInfo(User user, Addon addon) { if (island.isPurgeProtected()) { user.sendMessage("commands.admin.info.purge-protected"); } + if (island.isDeletable()) { + user.sendMessage("commands.admin.info.deletable"); + } // Show bundle info if available island.getMetaData("bundle").ifPresent(mdv -> user.sendMessage("commands.admin.info.bundle", TextVariables.NAME, mdv.asString())); // Fire info event to allow other addons to add to info diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 59d9ea582..331b46db3 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -280,6 +280,7 @@ commands: protection-range-bonus-title: 'Includes these bonues:' protection-range-bonus: 'Bonus: [number]' purge-protected: '[prefix_Island] is purge protected' + deletable: '[prefix_Island] is flagged for deletion and awaiting region purge.' max-protection-range: 'Largest historical protection range: [range]' protection-coords: 'Protection coordinates: [xz1] to [xz2]' is-spawn: '[prefix_Island] is a spawn [prefix_island]' @@ -1755,6 +1756,7 @@ protection: name: World TNT damage locked: 'This [prefix_island] is locked!' locked-island-bypass: 'This [prefix_island] is locked, but you have permission to bypass.' + deletable-island-admin: '[Admin] This [prefix_island] is flagged for deletion and awaiting region purge.' protected: '[prefix_Island] protected: [description].' world-protected: 'World protected: [description].' spawn-protected: 'Spawn protected: [description].' From 73a3240fb2a146f6ff054a7c16af8252a1adfef4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 06:16:07 -0700 Subject: [PATCH 07/16] Phase 3: Split AdminDeleteCommand on isUsesNewChunkGeneration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /bbox admin delete used to always call deleteIsland(island, true, uuid), which after Phase 2 soft-deletes unconditionally. That is the right behavior for new-chunk-generation gamemodes like Boxed where chunks are expensive and the region-file purge reaps them later on the HousekeepingManager schedule. For void/simple-generator gamemodes it is the wrong behavior — chunks are cheap, admins expect "delete" to actually delete, and soft-deleted rows would linger forever because the repainted region files always look fresh to the purge scan. Branch on GameModeAddon.isUsesNewChunkGeneration(): - true (new-gen): soft-delete via IslandsManager.deleteIsland(), same as /is reset. Physical cleanup happens later via PurgeRegionsService / HousekeepingManager. - false (void/simple): kick off DeleteIslandChunks (which routes to WorldRegenerator.regenerateSimple with correct nether/end cascade gating) to repaint the chunks via the addon's own ChunkGenerator, then hard-delete the island row immediately. DeleteIslandChunks snapshots the bounds in its constructor so the row can be removed before the async regen completes. Adds IslandsManager.hardDeleteIsland(island): fires the pre-delete event, kicks members, nulls owner, evicts from cache, deletes the DB row. Does not touch world chunks — caller handles physical cleanup. Phase 4 will remove DeleteIslandChunks, IslandDeletion, and the CopyWorldRegenerator.regenerateCopy seed-world path; regenerateSimple and the split here survive. Co-Authored-By: Claude Opus 4.6 --- .../commands/admin/AdminDeleteCommand.java | 27 ++++++++++++++++- .../bentobox/managers/IslandsManager.java | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java index 3a990ee41..6d2d38339 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java @@ -8,6 +8,7 @@ import org.eclipse.jdt.annotation.Nullable; +import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.commands.ConfirmableCommand; import world.bentobox.bentobox.api.commands.island.IslandGoCommand; @@ -17,6 +18,8 @@ import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.database.objects.IslandDeletion; +import world.bentobox.bentobox.util.DeleteIslandChunks; import world.bentobox.bentobox.util.Util; public class AdminDeleteCommand extends ConfirmableCommand { @@ -112,8 +115,30 @@ private void deleteIsland(User user, Island oldIsland) { .oldIsland(oldIsland).location(oldIsland.getCenter()).build(); user.sendMessage("commands.admin.delete.deleted-island", TextVariables.XYZ, Util.xyz(oldIsland.getCenter().toVector())); - getIslands().deleteIsland(oldIsland, true, targetUUID); + // Branch on how the gamemode generates its chunks. + // + // - New chunk generation (e.g. Boxed): chunks are expensive to + // recreate, so the island is soft-deleted (marked deletable, + // left in place) and PurgeRegionsService / HousekeepingManager + // reaps the region files and DB row later on its schedule. + // + // - Simple/void generation: chunks are cheap — repaint them via + // the addon's own ChunkGenerator right now using the existing + // DeleteIslandChunks + WorldRegenerator.regenerateSimple path, + // then hard-delete the island row so it does not linger. + // + // If we can't resolve the gamemode, default to soft-delete. + GameModeAddon gm = getIWM().getAddon(getWorld()).orElse(null); + if (gm != null && !gm.isUsesNewChunkGeneration()) { + // DeleteIslandChunks snapshots the island bounds in its + // constructor, so it is safe to hard-delete the row + // immediately after kicking off the regen. + new DeleteIslandChunks(getPlugin(), new IslandDeletion(oldIsland)); + getIslands().hardDeleteIsland(oldIsland); + } else { + getIslands().deleteIsland(oldIsland, true, targetUUID); + } } private void deletePlayer(User user) { diff --git a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java index d57ab8494..6de4b588b 100644 --- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java @@ -315,6 +315,36 @@ public void deleteIsland(@NonNull Island island, boolean removeBlocks, @Nullable } } + /** + * Hard-deletes an island: fires the pre-delete event, kicks members, + * nulls the owner, evicts the island from the cache, and removes the + * DB row. Does not touch world chunks — the caller is + * responsible for any physical cleanup (e.g. kicking off + * {@link world.bentobox.bentobox.util.DeleteIslandChunks} + * to regenerate via the addon's own {@code ChunkGenerator}). + * + *

Used by {@code AdminDeleteCommand} for void/simple-generator + * gamemodes where chunks are cheap to repaint and the island should + * be fully gone from the database immediately. For + * new-chunk-generation gamemodes, use + * {@link #deleteIsland(Island, boolean, UUID)} which soft-deletes + * and leaves the region-file purge to reap the chunks and row later. + * + * @param island the island to hard-delete, not null + * @since 3.14.0 + */ + public void hardDeleteIsland(@NonNull Island island) { + IslandBaseEvent event = IslandEvent.builder().island(island).reason(Reason.DELETE).build(); + if (event.getNewEvent().map(IslandBaseEvent::isCancelled).orElse(event.isCancelled())) { + return; + } + removePlayersFromIsland(island); + island.setOwner(null); + island.setFlag(Flags.LOCK, RanksManager.VISITOR_RANK); + islandCache.deleteIslandFromCache(island); + handler.deleteObject(island); + } + /** * Deletes an island by ID. If the id doesn't exist it will do nothing. * @param uniqueId island ID From 23bd5cb5b97cda2ed8a70ee37ab6a6da0c9336fc Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 14:38:37 -0700 Subject: [PATCH 08/16] Phase 3.5: Add /bbox admin purge deleted + daily housekeeping sweep Add a second purge mode that reaps region files for any island already flagged as deletable, regardless of region-file age. Exposed as /bbox admin purge deleted and run from HousekeepingManager on a configurable hourly cadence (default 24h) alongside the existing monthly age sweep. Closes the post-reset gap where orphan island regions sat on disk for 60+ days waiting for the age threshold. Fix: evict in-memory chunks via World.unloadChunk(cx, cz, false) on the main thread before the async file delete, otherwise Paper's autosave re-flushes the deleted region files with the stale chunks. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/Settings.java | 25 ++ .../admin/purge/AdminPurgeCommand.java | 1 + .../admin/purge/AdminPurgeDeletedCommand.java | 138 +++++++ .../managers/HousekeepingManager.java | 305 +++++++++----- .../managers/PurgeRegionsService.java | 186 ++++++++- src/main/resources/locales/en-US.yml | 4 + .../admin/purge/AdminPurgeCommandTest.java | 2 +- .../purge/AdminPurgeDeletedCommandTest.java | 375 ++++++++++++++++++ .../managers/HousekeepingManagerTest.java | 341 ++++++++++++++++ .../managers/PurgeRegionsServiceTest.java | 359 +++++++++++++++++ 10 files changed, 1634 insertions(+), 102 deletions(-) create mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java create mode 100644 src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java create mode 100644 src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java create mode 100644 src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 9cee6e591..ba5d4556d 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -357,6 +357,13 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "island.deletion.housekeeping.region-age-days", since = "3.14.0") private int housekeepingRegionAgeDays = 60; + @ConfigComment("How often the deleted-islands sweep runs, in hours. This reaps region") + @ConfigComment("files for any island already flagged as deletable (e.g. from /is reset)") + @ConfigComment("and is independent of the age-based sweep above. Set to 0 to disable") + @ConfigComment("the deleted sweep while leaving the age sweep running.") + @ConfigEntry(path = "island.deletion.housekeeping.deleted-interval-hours", since = "3.14.0") + private int housekeepingDeletedIntervalHours = 24; + // Chunk pre-generation settings @ConfigComment("") @ConfigComment("Chunk pre-generation settings.") @@ -1106,6 +1113,24 @@ public void setHousekeepingRegionAgeDays(int housekeepingRegionAgeDays) { this.housekeepingRegionAgeDays = housekeepingRegionAgeDays; } + /** + * @return how often the deleted-islands sweep runs, in hours. {@code 0} + * disables the deleted sweep. + * @since 3.14.0 + */ + public int getHousekeepingDeletedIntervalHours() { + return housekeepingDeletedIntervalHours; + } + + /** + * @param housekeepingDeletedIntervalHours how often the deleted sweep runs + * in hours. {@code 0} disables it. + * @since 3.14.0 + */ + public void setHousekeepingDeletedIntervalHours(int housekeepingDeletedIntervalHours) { + this.housekeepingDeletedIntervalHours = housekeepingDeletedIntervalHours; + } + /** * @return whether chunk pre-generation is enabled * @since 3.14.0 diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java index 7c7653595..91a466c12 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java @@ -49,6 +49,7 @@ public void setup() { new AdminPurgeProtectCommand(this); new AdminPurgeRegionsCommand(this); new AdminPurgeAgeRegionsCommand(this); + new AdminPurgeDeletedCommand(this); } @Override diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java new file mode 100644 index 000000000..1fb65f10c --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java @@ -0,0 +1,138 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.Listener; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; +import world.bentobox.bentobox.util.Util; + +/** + * Admin command to reap region files for every island already flagged as + * {@code deletable}, ignoring region-file age entirely. + * + *

Counterpart to {@link AdminPurgeRegionsCommand} which filters on the + * age of the .mca files. This command trusts the {@code deletable} flag + * set by {@code /is reset} (and Phase 2 soft-delete) and reaps immediately. + * + *

Heavy lifting is delegated to {@link PurgeRegionsService#scanDeleted(World)} + * and {@link PurgeRegionsService#delete(PurgeScanResult)}. + * + * @since 3.14.0 + */ +public class AdminPurgeDeletedCommand extends CompositeCommand implements Listener { + + private static final String NONE_FOUND = "commands.admin.purge.none-found"; + + private volatile boolean inPurge; + private boolean toBeConfirmed; + private User user; + private PurgeScanResult lastScan; + + public AdminPurgeDeletedCommand(CompositeCommand parent) { + super(parent, "deleted"); + getAddon().registerListener(this); + } + + @Override + public void setup() { + setPermission("admin.purge.deleted"); + setOnlyPlayer(false); + setParametersHelp("commands.admin.purge.deleted.parameters"); + setDescription("commands.admin.purge.deleted.description"); + } + + @Override + public boolean canExecute(User user, String label, List args) { + if (inPurge) { + user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); + return false; + } + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + this.user = user; + if (!args.isEmpty() && args.getFirst().equalsIgnoreCase("confirm") + && toBeConfirmed && this.user.equals(user)) { + return deleteEverything(); + } + toBeConfirmed = false; + + user.sendMessage("commands.admin.purge.scanning"); + // Save all worlds to flush in-memory chunk state before scanning. + getPlugin().log("Purge deleted: saving all worlds before scanning..."); + Bukkit.getWorlds().forEach(World::save); + getPlugin().log("Purge deleted: world save complete"); + + inPurge = true; + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + try { + PurgeRegionsService service = getPlugin().getPurgeRegionsService(); + lastScan = service.scanDeleted(getWorld()); + displayResultsAndPrompt(lastScan); + } finally { + inPurge = false; + } + }); + return true; + } + + private boolean deleteEverything() { + if (lastScan == null || lastScan.isEmpty()) { + user.sendMessage(NONE_FOUND); + return false; + } + PurgeScanResult scan = lastScan; + lastScan = null; + toBeConfirmed = false; + getPlugin().log("Purge deleted: saving all worlds before deleting region files..."); + Bukkit.getWorlds().forEach(World::save); + // Evict in-memory chunks for the target regions on the main thread, + // otherwise Paper's autosave/unload would re-flush them over the + // about-to-be-deleted region files (#region-purge bug). + getPlugin().getPurgeRegionsService().evictChunks(scan); + getPlugin().log("Purge deleted: world save complete, dispatching deletion"); + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + boolean ok = getPlugin().getPurgeRegionsService().delete(scan); + Bukkit.getScheduler().runTask(getPlugin(), () -> + user.sendMessage(ok ? "general.success" : NONE_FOUND)); + }); + return true; + } + + private void displayResultsAndPrompt(PurgeScanResult scan) { + Set uniqueIslands = scan.deleteableRegions().values().stream() + .flatMap(Set::stream) + .map(getPlugin().getIslands()::getIslandById) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + uniqueIslands.forEach(island -> + getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + + " in world " + getWorld().getName() + " will be reaped")); + + if (scan.isEmpty()) { + Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND)); + } else { + Bukkit.getScheduler().runTask(getPlugin(), () -> { + user.sendMessage("commands.admin.purge.purgable-islands", + TextVariables.NUMBER, String.valueOf(uniqueIslands.size())); + user.sendMessage("commands.admin.purge.deleted.confirm", + TextVariables.LABEL, this.getLabel()); + this.toBeConfirmed = true; + }); + } + } +} diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index af58427db..1d7bbcc2d 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -19,39 +19,47 @@ /** * Periodic housekeeping: automatically runs the region-files purge against - * every gamemode overworld on a configurable schedule. + * every gamemode overworld on a configurable schedule. Two independent cycles: * - *

Enabled via {@code island.deletion.housekeeping.enabled}. The task runs - * every {@code interval-days} days (wall-clock, not uptime) and scans for - * regions older than {@code region-age-days}. Since player resets now - * orphan islands instead of physically deleting their blocks, this scheduler - * is how the disk space is eventually reclaimed. + *

    + *
  • Age sweep — runs every {@code interval-days} days and reaps + * regions whose .mca files are older than {@code region-age-days}.
  • + *
  • Deleted sweep — runs every {@code deleted-interval-hours} + * hours and reaps regions for any island already flagged as + * {@code deletable} (e.g. from {@code /is reset}), ignoring file age.
  • + *
* - *

Last-run timestamp is persisted to + *

Both cycles are gated on the single {@code housekeeping.enabled} flag + * (default OFF) and share an {@code inProgress} guard so they never overlap. + * + *

Last-run timestamps are persisted to * {@code /database/housekeeping.yml} regardless of the * configured database backend, so the schedule survives restarts. * *

This manager is destructive by design: it deletes {@code .mca} region - * files from disk. Default is OFF. + * files from disk. * * @since 3.14.0 */ public class HousekeepingManager { - private static final String LAST_RUN_KEY = "lastRunMillis"; + private static final String LEGACY_LAST_RUN_KEY = "lastRunMillis"; + private static final String LAST_AGE_RUN_KEY = "lastAgeRunMillis"; + private static final String LAST_DELETED_RUN_KEY = "lastDeletedRunMillis"; private static final long CHECK_INTERVAL_TICKS = 20L * 60L * 60L; // 1 hour private static final long STARTUP_DELAY_TICKS = 20L * 60L * 5L; // 5 minutes private final BentoBox plugin; private final File stateFile; - private volatile long lastRunMillis; + private volatile long lastAgeRunMillis; + private volatile long lastDeletedRunMillis; private volatile boolean inProgress; private BukkitTask scheduledTask; public HousekeepingManager(BentoBox plugin) { this.plugin = plugin; this.stateFile = new File(new File(plugin.getDataFolder(), "database"), "housekeeping.yml"); - this.lastRunMillis = loadLastRun(); + loadState(); } // --------------------------------------------------------------- @@ -66,20 +74,24 @@ public synchronized void start() { if (scheduledTask != null) { return; } - // Check hourly; each check runs the purge only if the wall-clock - // interval since the last run has elapsed and the feature is enabled. scheduledTask = Bukkit.getScheduler().runTaskTimer(plugin, this::checkAndMaybeRun, STARTUP_DELAY_TICKS, CHECK_INTERVAL_TICKS); plugin.log("Housekeeping scheduler started (enabled=" + plugin.getSettings().isHousekeepingEnabled() - + ", interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + + ", age-interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d" - + ", last-run=" + (lastRunMillis == 0 ? "never" : Instant.ofEpochMilli(lastRunMillis)) + ")"); + + ", deleted-interval=" + plugin.getSettings().getHousekeepingDeletedIntervalHours() + "h" + + ", last-age-run=" + formatTs(lastAgeRunMillis) + + ", last-deleted-run=" + formatTs(lastDeletedRunMillis) + ")"); + } + + private static String formatTs(long millis) { + return millis == 0 ? "never" : Instant.ofEpochMilli(millis).toString(); } /** * Stops the periodic housekeeping check. Does not clear the last-run - * timestamp on disk. + * timestamps on disk. */ public synchronized void stop() { if (scheduledTask != null) { @@ -96,11 +108,19 @@ public boolean isInProgress() { } /** - * @return the wall-clock timestamp (millis) of the last successful run, - * or {@code 0} if the task has never run. + * @return wall-clock timestamp (millis) of the last successful age sweep, + * or {@code 0} if it has never run. + */ + public long getLastAgeRunMillis() { + return lastAgeRunMillis; + } + + /** + * @return wall-clock timestamp (millis) of the last successful deleted + * sweep, or {@code 0} if it has never run. */ - public long getLastRunMillis() { - return lastRunMillis; + public long getLastDeletedRunMillis() { + return lastDeletedRunMillis; } private void checkAndMaybeRun() { @@ -110,122 +130,222 @@ private void checkAndMaybeRun() { if (!plugin.getSettings().isHousekeepingEnabled()) { return; } + long now = System.currentTimeMillis(); + boolean ageDue = isAgeCycleDue(now); + boolean deletedDue = isDeletedCycleDue(now); + if (!ageDue && !deletedDue) { + return; + } + runNow(ageDue, deletedDue); + } + + private boolean isAgeCycleDue(long now) { int intervalDays = plugin.getSettings().getHousekeepingIntervalDays(); if (intervalDays <= 0) { - plugin.logWarning("Housekeeping: interval-days must be >= 1, skipping run"); - return; + return false; } long intervalMillis = TimeUnit.DAYS.toMillis(intervalDays); - long now = System.currentTimeMillis(); - if (lastRunMillis != 0 && (now - lastRunMillis) < intervalMillis) { - return; + return lastAgeRunMillis == 0 || (now - lastAgeRunMillis) >= intervalMillis; + } + + private boolean isDeletedCycleDue(long now) { + int intervalHours = plugin.getSettings().getHousekeepingDeletedIntervalHours(); + if (intervalHours <= 0) { + return false; } - runNow(); + long intervalMillis = TimeUnit.HOURS.toMillis(intervalHours); + return lastDeletedRunMillis == 0 || (now - lastDeletedRunMillis) >= intervalMillis; } /** - * Triggers an immediate housekeeping cycle, regardless of the - * wall-clock interval (but still respecting {@code enabled}). - * Runs asynchronously. + * Triggers an immediate housekeeping cycle for both sweeps (respecting + * the enabled flag but ignoring the interval timers). Runs asynchronously. */ public synchronized void runNow() { + runNow(true, true); + } + + private synchronized void runNow(boolean runAge, boolean runDeleted) { if (inProgress) { plugin.log("Housekeeping: run requested but already in progress, ignoring"); return; } + if (!runAge && !runDeleted) { + return; + } inProgress = true; - Bukkit.getScheduler().runTaskAsynchronously(plugin, this::executeCycle); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + // Save worlds once per cycle — both sweeps see a consistent + // on-disk snapshot. + if (!saveAllWorlds()) { + return; + } + if (runAge) { + executeAgeCycle(); + } + if (runDeleted) { + executeDeletedCycle(); + } + } catch (Exception e) { + plugin.logError("Housekeeping: cycle failed: " + e.getMessage()); + plugin.logStacktrace(e); + } finally { + inProgress = false; + } + }); } // --------------------------------------------------------------- // Cycle execution // --------------------------------------------------------------- - private void executeCycle() { - long startMillis = System.currentTimeMillis(); - try { - int ageDays = plugin.getSettings().getHousekeepingRegionAgeDays(); - if (ageDays <= 0) { - plugin.logError("Housekeeping: region-age-days must be >= 1, aborting run"); - return; + private boolean saveAllWorlds() { + plugin.log("Housekeeping: saving all worlds before purge..."); + CompletableFuture saved = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(plugin, () -> { + try { + Bukkit.getWorlds().forEach(World::save); + saved.complete(null); + } catch (Exception e) { + saved.completeExceptionally(e); } - List gameModes = plugin.getAddonsManager().getGameModeAddons(); - plugin.log("Housekeeping: starting auto-purge cycle across " + gameModes.size() - + " gamemode(s), region-age=" + ageDays + "d"); - // Save worlds up-front so disk state matches memory. World.save() - // must run on the main thread — hop over and block the async - // cycle until the save completes. - plugin.log("Housekeeping: saving all worlds before purge..."); - CompletableFuture saved = new CompletableFuture<>(); - Bukkit.getScheduler().runTask(plugin, () -> { - try { - Bukkit.getWorlds().forEach(World::save); - saved.complete(null); - } catch (Exception e) { - saved.completeExceptionally(e); - } - }); + }); + try { saved.join(); plugin.log("Housekeeping: world save complete"); + return true; + } catch (Exception e) { + plugin.logError("Housekeeping: world save failed: " + e.getMessage()); + return false; + } + } - int totalWorlds = 0; - int totalRegionsPurged = 0; - for (GameModeAddon gm : gameModes) { - World overworld = gm.getOverWorld(); - if (overworld == null) { - continue; - } - totalWorlds++; - plugin.log("Housekeeping: scanning gamemode '" + gm.getDescription().getName() - + "' world '" + overworld.getName() + "'"); - PurgeScanResult scan = plugin.getPurgeRegionsService().scan(overworld, ageDays); - if (scan.isEmpty()) { - plugin.log("Housekeeping: nothing to purge in " + overworld.getName()); - continue; - } - plugin.log("Housekeeping: " + scan.deleteableRegions().size() + " region(s) and " - + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName()); - boolean ok = plugin.getPurgeRegionsService().delete(scan); - if (ok) { - totalRegionsPurged += scan.deleteableRegions().size(); - } else { - plugin.logError("Housekeeping: purge of " + overworld.getName() - + " completed with errors"); - } + private void executeAgeCycle() { + long startMillis = System.currentTimeMillis(); + int ageDays = plugin.getSettings().getHousekeepingRegionAgeDays(); + if (ageDays <= 0) { + plugin.logError("Housekeeping: region-age-days must be >= 1, skipping age sweep"); + return; + } + List gameModes = plugin.getAddonsManager().getGameModeAddons(); + plugin.log("Housekeeping age sweep: starting across " + gameModes.size() + + " gamemode(s), region-age=" + ageDays + "d"); + + int totalWorlds = 0; + int totalRegionsPurged = 0; + for (GameModeAddon gm : gameModes) { + World overworld = gm.getOverWorld(); + if (overworld == null) { + continue; } + totalWorlds++; + plugin.log("Housekeeping age sweep: scanning '" + gm.getDescription().getName() + + "' world '" + overworld.getName() + "'"); + PurgeScanResult scan = plugin.getPurgeRegionsService().scan(overworld, ageDays); + totalRegionsPurged += runDeleteIfNonEmpty(scan, overworld, "age sweep"); + } + + Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); + plugin.log("Housekeeping age sweep: complete — " + totalWorlds + " world(s) processed, " + + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); + lastAgeRunMillis = System.currentTimeMillis(); + saveState(); + } + + private void executeDeletedCycle() { + long startMillis = System.currentTimeMillis(); + List gameModes = plugin.getAddonsManager().getGameModeAddons(); + plugin.log("Housekeeping deleted sweep: starting across " + gameModes.size() + " gamemode(s)"); - Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); - plugin.log("Housekeeping: cycle complete — " + totalWorlds + " world(s) processed, " - + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); - lastRunMillis = System.currentTimeMillis(); - saveLastRun(); + int totalWorlds = 0; + int totalRegionsPurged = 0; + for (GameModeAddon gm : gameModes) { + World overworld = gm.getOverWorld(); + if (overworld == null) { + continue; + } + totalWorlds++; + plugin.log("Housekeeping deleted sweep: scanning '" + gm.getDescription().getName() + + "' world '" + overworld.getName() + "'"); + PurgeScanResult scan = plugin.getPurgeRegionsService().scanDeleted(overworld); + // Evict in-memory chunks on the main thread before the async delete, + // so Paper's autosave can't re-flush them over the deleted region files. + if (!scan.isEmpty()) { + evictChunksOnMainThread(scan); + } + totalRegionsPurged += runDeleteIfNonEmpty(scan, overworld, "deleted sweep"); + } + + Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); + plugin.log("Housekeeping deleted sweep: complete — " + totalWorlds + " world(s) processed, " + + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); + lastDeletedRunMillis = System.currentTimeMillis(); + saveState(); + } + + private void evictChunksOnMainThread(PurgeScanResult scan) { + CompletableFuture done = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(plugin, () -> { + try { + plugin.getPurgeRegionsService().evictChunks(scan); + done.complete(null); + } catch (Exception e) { + done.completeExceptionally(e); + } + }); + try { + done.join(); } catch (Exception e) { - plugin.logError("Housekeeping: cycle failed: " + e.getMessage()); - plugin.logStacktrace(e); - } finally { - inProgress = false; + plugin.logError("Housekeeping: chunk eviction failed: " + e.getMessage()); } } + private int runDeleteIfNonEmpty(PurgeScanResult scan, World overworld, String label) { + if (scan.isEmpty()) { + plugin.log("Housekeeping " + label + ": nothing to purge in " + overworld.getName()); + return 0; + } + plugin.log("Housekeeping " + label + ": " + scan.deleteableRegions().size() + " region(s) and " + + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName()); + boolean ok = plugin.getPurgeRegionsService().delete(scan); + if (!ok) { + plugin.logError("Housekeeping " + label + ": purge of " + overworld.getName() + + " completed with errors"); + return 0; + } + return scan.deleteableRegions().size(); + } + // --------------------------------------------------------------- // Persistence // --------------------------------------------------------------- - private long loadLastRun() { + private void loadState() { if (!stateFile.exists()) { - return 0L; + lastAgeRunMillis = 0L; + lastDeletedRunMillis = 0L; + return; } try { YamlConfiguration yaml = YamlConfiguration.loadConfiguration(stateFile); - return yaml.getLong(LAST_RUN_KEY, 0L); + // Migrate legacy single-cycle key: if the new key is absent but + // the old one is present, adopt it as the age-cycle timestamp. + if (yaml.contains(LAST_AGE_RUN_KEY)) { + lastAgeRunMillis = yaml.getLong(LAST_AGE_RUN_KEY, 0L); + } else { + lastAgeRunMillis = yaml.getLong(LEGACY_LAST_RUN_KEY, 0L); + } + lastDeletedRunMillis = yaml.getLong(LAST_DELETED_RUN_KEY, 0L); } catch (Exception e) { plugin.logError("Housekeeping: could not read " + stateFile.getAbsolutePath() + ": " + e.getMessage()); - return 0L; + lastAgeRunMillis = 0L; + lastDeletedRunMillis = 0L; } } - private void saveLastRun() { + private void saveState() { try { File parent = stateFile.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs()) { @@ -233,7 +353,8 @@ private void saveLastRun() { return; } YamlConfiguration yaml = new YamlConfiguration(); - yaml.set(LAST_RUN_KEY, lastRunMillis); + yaml.set(LAST_AGE_RUN_KEY, lastAgeRunMillis); + yaml.set(LAST_DELETED_RUN_KEY, lastDeletedRunMillis); yaml.save(stateFile); } catch (IOException e) { plugin.logError("Housekeeping: could not write " + stateFile.getAbsolutePath() diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index b6c3ecbaf..8b6067141 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -102,6 +102,63 @@ private record DimFolders(File region, File entities, File poi) {} // Public API // --------------------------------------------------------------- + /** + * Scans the given world for islands flagged as {@code deletable} and + * returns the set of region files that can be reaped immediately, + * ignoring region-file age. + * + *

Unlike {@link #scan(World, int)} this does not look at region + * timestamps at all: the {@code deletable} flag is the sole source of + * truth. A region is only returned if every island that + * overlaps it is deletable — a lone active neighbour blocks the whole + * region. + * + *

The returned {@link PurgeScanResult} uses {@code days = 0} as a + * sentinel meaning "no age filter" so that {@link #delete(PurgeScanResult)} + * and {@code deleteRegionFiles} skip their freshness re-check. + * + *

Runs synchronously on the calling thread and performs disk I/O. + * Callers must invoke this from an async task. + * + * @param world the gamemode overworld to scan + * @return scan result, never {@code null} + * @since 3.14.0 + */ + public PurgeScanResult scanDeleted(World world) { + boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world); + boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world); + + IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world); + if (islandGrid == null) { + return new PurgeScanResult(world, 0, new HashMap<>(), isNether, isEnd, + new FilterStats(0, 0, 0, 0)); + } + + // Collect candidate region coords from every deletable island's + // protection bounds. A single island may straddle multiple regions. + Set> candidateRegions = new HashSet<>(); + for (Island island : plugin.getIslands().getIslandCache().getIslands(world)) { + if (!island.isDeletable()) continue; + int minRX = island.getMinProtectedX() >> 9; + int maxRX = (island.getMaxProtectedX() - 1) >> 9; + int minRZ = island.getMinProtectedZ() >> 9; + int maxRZ = (island.getMaxProtectedZ() - 1) >> 9; + for (int rx = minRX; rx <= maxRX; rx++) { + for (int rz = minRZ; rz <= maxRZ; rz++) { + candidateRegions.add(new Pair<>(rx, rz)); + } + } + } + plugin.log("Purge deleted-sweep: " + candidateRegions.size() + + " candidate region(s) from deletable islands in world " + world.getName()); + + Map, Set> deleteableRegions = + mapIslandsToRegions(new ArrayList<>(candidateRegions), islandGrid); + FilterStats stats = filterForDeletedSweep(deleteableRegions); + logFilterStats(stats); + return new PurgeScanResult(world, 0, deleteableRegions, isNether, isEnd, stats); + } + /** * Scans the given world (and its nether/end if the gamemode owns them) * for region files older than {@code days} and returns the set of @@ -175,6 +232,72 @@ public boolean delete(PurgeScanResult scan) { return ok; } + // --------------------------------------------------------------- + // Chunk eviction + // --------------------------------------------------------------- + + /** + * Unloads every loaded chunk that falls inside any region in + * {@code scan.deleteableRegions()} with {@code save = false}, so the + * in-memory chunk copy is thrown away rather than flushed back over the + * region files we are about to delete. + * + *

Each {@code r.X.Z.mca} covers a 32×32 chunk square. For every target + * region this iterates {@code (rX*32 .. rX*32+31, rZ*32 .. rZ*32+31)} and + * unloads any chunk currently loaded. Chunks that cannot be unloaded + * (e.g. a player is inside, or the chunk is force-loaded) are silently + * skipped — reaping a chunk out from under a present player would be + * worse than waiting for the next sweep. + * + *

The deleted-sweep callers (manual command + housekeeping) must + * invoke this on the main thread before dispatching the async + * {@link #delete(PurgeScanResult)}; otherwise Paper's autosave or shutdown + * will rewrite the region file with the stale in-memory chunks immediately + * after we delete it on disk. + * + *

Nether and end dimensions are evicted only when the gamemode owns + * them, mirroring the dimension gating in {@link #deleteRegionFiles}. + * + * @param scan a prior scan result whose regions should be evicted + */ + public void evictChunks(PurgeScanResult scan) { + if (scan.deleteableRegions().isEmpty()) { + return; + } + World overworld = scan.world(); + World netherWorld = scan.isNether() ? plugin.getIWM().getNetherWorld(overworld) : null; + World endWorld = scan.isEnd() ? plugin.getIWM().getEndWorld(overworld) : null; + + int evicted = 0; + for (Pair coords : scan.deleteableRegions().keySet()) { + int baseCx = coords.x() << 5; // rX * 32 + int baseCz = coords.z() << 5; + evicted += evictRegion(overworld, baseCx, baseCz); + if (netherWorld != null) { + evicted += evictRegion(netherWorld, baseCx, baseCz); + } + if (endWorld != null) { + evicted += evictRegion(endWorld, baseCx, baseCz); + } + } + plugin.log("Purge deleted: evicted " + evicted + " loaded chunk(s) from " + + scan.deleteableRegions().size() + " target region(s)"); + } + + private int evictRegion(World world, int baseCx, int baseCz) { + int count = 0; + for (int dx = 0; dx < 32; dx++) { + for (int dz = 0; dz < 32; dz++) { + int cx = baseCx + dx; + int cz = baseCz + dz; + if (world.isChunkLoaded(cx, cz) && world.unloadChunk(cx, cz, false)) { + count++; + } + } + } + return count; + } + // --------------------------------------------------------------- // Debug / testing: artificially age region files // --------------------------------------------------------------- @@ -314,6 +437,38 @@ private FilterStats filterNonDeletableRegions( regionsBlockedByLevel, regionsBlockedByProtection); } + /** + * Strict filter for the deleted sweep: any non-deletable island in a + * region blocks the whole region. Unlike {@link #filterNonDeletableRegions} + * this has no age/login/level logic — only the {@code deletable} flag + * matters. + */ + private FilterStats filterForDeletedSweep( + Map, Set> deleteableRegions) { + int regionsBlockedByProtection = 0; + var iter = deleteableRegions.entrySet().iterator(); + while (iter.hasNext()) { + var entry = iter.next(); + boolean block = false; + for (String id : entry.getValue()) { + Optional opt = plugin.getIslands().getIslandById(id); + if (opt.isEmpty()) { + // Missing rows don't block — they're already gone. + continue; + } + if (!opt.get().isDeletable()) { + block = true; + break; + } + } + if (block) { + iter.remove(); + regionsBlockedByProtection++; + } + } + return new FilterStats(0, 0, 0, regionsBlockedByProtection); + } + private int[] evaluateRegionIslands(Set islandIds, int days) { int shouldRemove = 0; int levelBlocked = 0; @@ -506,11 +661,14 @@ private Map, Set> mapIslandsToRegions( private boolean deleteRegionFiles(PurgeScanResult scan) { int days = scan.days(); - if (days <= 0) { - plugin.logError("Days is somehow zero or negative!"); + if (days < 0) { + plugin.logError("Days is somehow negative!"); return false; } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + // days == 0 is the "deleted sweep" sentinel — no age filter and no + // freshness recheck. days > 0 is the age-based sweep. + boolean ageGated = days > 0; + long cutoffMillis = ageGated ? System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days) : 0L; World world = scan.world(); File base = world.getWorldFolder(); @@ -530,12 +688,16 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { File endEntities = new File(endBase, ENTITIES); File endPoi = new File(endBase, POI); - // Verify none of the files have been updated since the cutoff - for (Pair coords : scan.deleteableRegions().keySet()) { - String name = "r." + coords.x() + "." + coords.z() + ".mca"; - if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, - scan.isNether(), scan.isEnd())) { - return false; + // Verify none of the files have been updated since the cutoff. + // Skipped for the deleted sweep (ageGated == false) — the deletable + // flag on the island row is the sole authority there. + if (ageGated) { + for (Pair coords : scan.deleteableRegions().keySet()) { + String name = "r." + coords.x() + "." + coords.z() + ".mca"; + if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, + scan.isNether(), scan.isEnd())) { + return false; + } } } @@ -598,6 +760,12 @@ private void deletePlayerFromWorldFolder(World world, String islandID, private void maybeDeletePlayerData(World world, UUID uuid, File playerData, Map, Set> deleteableRegions, int days) { + // Deleted sweep (days == 0) skips player-data cleanup entirely — + // the player might still be active, and the age-based sweep will + // reap orphaned .dat files later. + if (days <= 0) { + return; + } List memberOf = new ArrayList<>(plugin.getIslands().getIslands(world, uuid)); deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); if (!memberOf.isEmpty()) { diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 331b46db3..41cfbf147 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -132,6 +132,10 @@ commands: parameters: '[days]' description: 'debug/test: rewrite region file timestamps so they become purgable' done: 'Aged [number] region file(s) in the current world.' + deleted: + parameters: '' + description: 'purge region files for any [prefix_island] already flagged as deleted' + confirm: 'Type /[label] purge deleted confirm to reap the region files' protect: description: toggle [prefix_island] purge protection move-to-island: 'Move to [prefix_an-island] first!' diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java index d2766b70e..c2789d9be 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java @@ -133,7 +133,7 @@ void testSetup() { assertFalse(apc.isOnlyPlayer()); assertEquals("commands.admin.purge.parameters", apc.getParameters()); assertEquals("commands.admin.purge.description", apc.getDescription()); - assertEquals(7, apc.getSubCommands().size()); + assertEquals(8, apc.getSubCommands().size()); } diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java new file mode 100644 index 000000000..ac96b885a --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java @@ -0,0 +1,375 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; + +import com.google.common.collect.ImmutableSet; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.addons.Addon; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.AddonsManager; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.island.IslandCache; +import world.bentobox.bentobox.managers.island.IslandGrid; + +/** + * Tests for {@link AdminPurgeDeletedCommand}. + * + *

Exercises the command against a real {@link PurgeRegionsService} + * wired over the mocked plugin, so the scan/filter/delete logic is + * driven end-to-end through the async scheduler mock. + */ +class AdminPurgeDeletedCommandTest extends CommonTestSetup { + + @Mock + private CompositeCommand ac; + @Mock + private User user; + @Mock + private Addon addon; + @Mock + private BukkitScheduler scheduler; + @Mock + private IslandCache islandCache; + @Mock + private PlayersManager pm; + @Mock + private AddonsManager addonsManager; + + @TempDir + Path tempDir; + + private AdminPurgeCommand apc; + private AdminPurgeDeletedCommand apdc; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + // Run scheduled tasks inline so async/main scheduling collapses into + // a synchronous call chain for the test. + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList()); + + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + when(ac.getWorld()).thenReturn(world); + when(ac.getAddon()).thenReturn(addon); + when(ac.getTopLabel()).thenReturn("bsb"); + + when(iwm.isNetherGenerate(world)).thenReturn(false); + when(iwm.isNetherIslands(world)).thenReturn(false); + when(iwm.isEndGenerate(world)).thenReturn(false); + when(iwm.isEndIslands(world)).thenReturn(false); + when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); + when(iwm.getNetherWorld(world)).thenReturn(null); + when(iwm.getEndWorld(world)).thenReturn(null); + + when(plugin.getPlayers()).thenReturn(pm); + when(pm.getName(any())).thenReturn("PlayerName"); + + when(im.getIslandCache()).thenReturn(islandCache); + when(islandCache.getIslands(world)).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(null); + + when(world.getWorldFolder()).thenReturn(tempDir.toFile()); + + when(island.getCenter()).thenReturn(location); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); + + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); + + when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin)); + + apc = new AdminPurgeCommand(ac); + apdc = new AdminPurgeDeletedCommand(apc); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * The command is registered as a listener both during AdminPurgeCommand.setup() + * and again when constructed directly in the test. + */ + @Test + void testConstructor() { + verify(addon, times(2)).registerListener(any(AdminPurgeDeletedCommand.class)); + } + + @Test + void testSetup() { + assertEquals("admin.purge.deleted", apdc.getPermission()); + assertFalse(apdc.isOnlyPlayer()); + assertEquals("commands.admin.purge.deleted.parameters", apdc.getParameters()); + assertEquals("commands.admin.purge.deleted.description", apdc.getDescription()); + } + + /** + * canExecute should accept zero arguments — unlike the age-based command, + * the deleted sweep takes no parameters. + */ + @Test + void testCanExecuteNoArgs() { + assertTrue(apdc.canExecute(user, "deleted", Collections.emptyList())); + } + + /** + * With an empty island cache the scan finds nothing and the user is told. + */ + @Test + void testExecuteNoIslands() { + when(islandCache.getIslands(world)).thenReturn(Collections.emptyList()); + IslandGrid grid = mock(IslandGrid.class); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * A null island grid (world never registered) yields none-found. + */ + @Test + void testExecuteNullGrid() { + when(islandCache.getIslandGrid(world)).thenReturn(null); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * Non-deletable islands must be ignored by the deleted sweep — no candidate + * regions, no confirm prompt. + */ + @Test + void testExecuteNonDeletableIgnored() { + when(island.getUniqueId()).thenReturn("island-active"); + when(island.isDeletable()).thenReturn(false); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid grid = mock(IslandGrid.class); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * A lone deletable island's region is surfaced and the confirm prompt fires. + */ + @Test + void testExecuteDeletableIslandFound() { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isDeletable()).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + // Occupies region r.0.0 (blocks 0..100) + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(List.of(data)); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1"); + verify(user).sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, "deleted"); + } + + /** + * A region shared between a deletable and a non-deletable neighbour must + * be dropped by the strict filter — non-deletable neighbour blocks reap. + */ + @Test + void testExecuteStrictFilterBlocksMixedRegion() { + UUID owner1 = UUID.randomUUID(); + UUID owner2 = UUID.randomUUID(); + + // Deletable island straddling r.0.0 + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMemberSet()).thenReturn(ImmutableSet.of(owner1)); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + // Active neighbour sharing r.0.0 + Island active = mock(Island.class); + when(active.getUniqueId()).thenReturn("act"); + when(active.isDeletable()).thenReturn(false); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable, active)); + IslandGrid grid = mock(IslandGrid.class); + Collection inRegion = List.of( + new IslandGrid.IslandData("del", 0, 0, 100), + new IslandGrid.IslandData("act", 200, 200, 100)); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(inRegion); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.getIslandById("act")).thenReturn(Optional.of(active)); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * Confirm after a scan deletes the region file on disk regardless of age. + * Crucially this file's timestamp is "now" — the age-based sweep would + * never touch it, but the deleted sweep must because the island row is + * flagged. + */ + @Test + void testExecuteConfirmReapsFreshRegion() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isDeletable()).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandGrid.IslandData("island-deletable", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + when(im.deleteIslandId("island-deletable")).thenReturn(true); + + // Build a fresh 8KB .mca with "now" timestamps — age sweep would skip + // this; deleted sweep must still reap it. + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Path regionFile = regionDir.resolve("r.0.0.mca"); + byte[] data = new byte[8192]; + int nowSeconds = (int) (System.currentTimeMillis() / 1000L); + for (int i = 0; i < 1024; i++) { + int offset = 4096 + i * 4; + data[offset] = (byte) (nowSeconds >> 24); + data[offset + 1] = (byte) (nowSeconds >> 16); + data[offset + 2] = (byte) (nowSeconds >> 8); + data[offset + 3] = (byte) nowSeconds; + } + Files.write(regionFile, data); + + // Scan + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, "deleted"); + + // Confirm + assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); + verify(user).sendMessage("general.success"); + assertFalse(regionFile.toFile().exists(), "Fresh region file should be reaped by the deleted sweep"); + verify(im).deleteIslandId("island-deletable"); + } + + /** + * Player data files must NOT be touched by the deleted sweep — the active + * player could still be playing and reaping their .dat would be harmful. + */ + @Test + void testExecuteConfirmLeavesPlayerData() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isDeletable()).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandGrid.IslandData("island-deletable", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + when(im.deleteIslandId("island-deletable")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata")); + Path playerFile = playerDataDir.resolve(ownerUUID + ".dat"); + Files.createFile(playerFile); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); + verify(user).sendMessage("general.success"); + assertTrue(playerFile.toFile().exists(), + "Deleted sweep must NOT remove player data — only the age sweep does"); + } + + /** + * A confirm before any scan falls through to the scan path (no args == + * empty args are equivalent). It should not produce an error. + */ + @Test + void testExecuteConfirmWithoutPriorScan() { + assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + } +} diff --git a/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java new file mode 100644 index 000000000..c817eff0a --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java @@ -0,0 +1,341 @@ +package world.bentobox.bentobox.managers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.Settings; +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.managers.PurgeRegionsService.FilterStats; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; + +/** + * Tests for {@link HousekeepingManager}. + * + *

Focus areas: + *

    + *
  1. State persistence — the YAML file round-trips both + * {@code lastAgeRunMillis} and {@code lastDeletedRunMillis}.
  2. + *
  3. Legacy migration — an existing state file written by the + * previous single-cycle implementation (only {@code lastRunMillis}) + * is adopted as the age-cycle timestamp so existing installs don't + * reset their schedule on upgrade.
  4. + *
  5. Dual cycle dispatch — the hourly check decides which + * cycle(s) to run based on the independent interval settings, and + * correctly skips when neither is due or the feature is disabled.
  6. + *
+ */ +class HousekeepingManagerTest extends CommonTestSetup { + + @Mock + private BukkitScheduler scheduler; + @Mock + private AddonsManager addonsManager; + @Mock + private GameModeAddon gameMode; + @Mock + private AddonDescription addonDescription; + @Mock + private PurgeRegionsService purgeService; + + @TempDir + Path tempDir; + + private Settings settings; + private File stateFile; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + // Point the plugin data folder at the temp dir so housekeeping.yml + // lives in an isolated location per test. + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + stateFile = new File(new File(tempDir.toFile(), "database"), "housekeeping.yml"); + + // Real settings with test-specific overrides + settings = new Settings(); + when(plugin.getSettings()).thenReturn(settings); + + // Scheduler: run tasks inline so async cycles become synchronous. + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList()); + + // Addons: single gamemode with a single overworld + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getGameModeAddons()).thenReturn(List.of(gameMode)); + when(gameMode.getOverWorld()).thenReturn(world); + when(gameMode.getDescription()).thenReturn(addonDescription); + when(addonDescription.getName()).thenReturn("TestMode"); + + when(plugin.getPurgeRegionsService()).thenReturn(purgeService); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + // ------------------------------------------------------------------ + // Persistence + // ------------------------------------------------------------------ + + /** + * An install with no prior state file starts both timestamps at zero. + */ + @Test + void testLoadStateNoPriorFile() { + HousekeepingManager hm = new HousekeepingManager(plugin); + assertEquals(0L, hm.getLastAgeRunMillis()); + assertEquals(0L, hm.getLastDeletedRunMillis()); + } + + /** + * Legacy state files from the previous single-cycle implementation wrote + * only {@code lastRunMillis}. The new manager must adopt it as the + * age-cycle timestamp so upgrades don't reset the schedule. The deleted + * cycle starts from scratch. + */ + @Test + void testLoadStateMigratesLegacyKey() throws Exception { + Files.createDirectories(stateFile.getParentFile().toPath()); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("lastRunMillis", 1700000000000L); + yaml.save(stateFile); + + HousekeepingManager hm = new HousekeepingManager(plugin); + assertEquals(1700000000000L, hm.getLastAgeRunMillis(), + "legacy lastRunMillis should be adopted as age-cycle timestamp"); + assertEquals(0L, hm.getLastDeletedRunMillis()); + } + + /** + * When both new keys are present the legacy key is ignored even if it's + * still in the file. + */ + @Test + void testLoadStatePrefersNewKeysOverLegacy() throws Exception { + Files.createDirectories(stateFile.getParentFile().toPath()); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("lastRunMillis", 1000L); // legacy — ignored + yaml.set("lastAgeRunMillis", 2000L); // new + yaml.set("lastDeletedRunMillis", 3000L); // new + yaml.save(stateFile); + + HousekeepingManager hm = new HousekeepingManager(plugin); + assertEquals(2000L, hm.getLastAgeRunMillis()); + assertEquals(3000L, hm.getLastDeletedRunMillis()); + } + + /** + * Running a cycle must persist both timestamps to the YAML file so a + * restart doesn't lose either cadence. + */ + @Test + void testSaveStateRoundTripsBothKeys() { + settings.setHousekeepingEnabled(true); + settings.setHousekeepingIntervalDays(1); + settings.setHousekeepingRegionAgeDays(30); + settings.setHousekeepingDeletedIntervalHours(1); + + when(purgeService.scan(eq(world), anyInt())).thenReturn(emptyScan(30)); + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + hm.runNow(); + + assertTrue(hm.getLastAgeRunMillis() > 0, "age cycle timestamp should be set"); + assertTrue(hm.getLastDeletedRunMillis() > 0, "deleted cycle timestamp should be set"); + + // Read back from disk with a second manager instance to prove the + // state is actually persisted, not just in-memory. + HousekeepingManager reread = new HousekeepingManager(plugin); + assertEquals(hm.getLastAgeRunMillis(), reread.getLastAgeRunMillis()); + assertEquals(hm.getLastDeletedRunMillis(), reread.getLastDeletedRunMillis()); + } + + // ------------------------------------------------------------------ + // Cycle dispatch + // ------------------------------------------------------------------ + + /** + * When the feature is disabled both cycles are skipped regardless of + * what {@code runNow} does with the schedule. + */ + @Test + void testDisabledFeatureSkipsAllCycles() throws Exception { + settings.setHousekeepingEnabled(false); + + HousekeepingManager hm = new HousekeepingManager(plugin); + // Invoke the internal hourly check via reflection so we go through + // the enabled gate (runNow bypasses that gate). + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, never()).scanDeleted(any()); + } + + /** + * {@code runNow()} fires both cycles unconditionally (subject to the + * enabled flag) and dispatches them to the service. + */ + @Test + void testRunNowDispatchesBothCycles() { + settings.setHousekeepingEnabled(true); + settings.setHousekeepingRegionAgeDays(30); + + when(purgeService.scan(eq(world), eq(30))).thenReturn(emptyScan(30)); + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + hm.runNow(); + + verify(purgeService, times(1)).scan(world, 30); + verify(purgeService, times(1)).scanDeleted(world); + } + + /** + * When the deleted interval is 0 the hourly check only runs the age + * cycle — the deleted cycle is effectively disabled. + */ + @Test + void testDeletedIntervalZeroDisablesDeletedCycle() throws Exception { + settings.setHousekeepingEnabled(true); + settings.setHousekeepingIntervalDays(1); + settings.setHousekeepingRegionAgeDays(30); + settings.setHousekeepingDeletedIntervalHours(0); // disabled + + when(purgeService.scan(eq(world), eq(30))).thenReturn(emptyScan(30)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, times(1)).scan(world, 30); + verify(purgeService, never()).scanDeleted(any()); + } + + /** + * When the age interval is 0 only the deleted cycle runs. + */ + @Test + void testAgeIntervalZeroDisablesAgeCycle() throws Exception { + settings.setHousekeepingEnabled(true); + settings.setHousekeepingIntervalDays(0); // disabled + settings.setHousekeepingDeletedIntervalHours(1); + + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, times(1)).scanDeleted(world); + } + + /** + * If both cycles ran recently (last-run timestamps inside their intervals) + * the hourly check does nothing. + */ + @Test + void testBothCyclesRecentlyRunIsNoop() throws Exception { + settings.setHousekeepingEnabled(true); + settings.setHousekeepingIntervalDays(30); + settings.setHousekeepingDeletedIntervalHours(24); + + // Pre-populate the state file so both timestamps are "just now". + long now = System.currentTimeMillis(); + Files.createDirectories(stateFile.getParentFile().toPath()); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("lastAgeRunMillis", now); + yaml.set("lastDeletedRunMillis", now); + yaml.save(stateFile); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, never()).scanDeleted(any()); + } + + /** + * If only the deleted cycle has aged past its interval, only it runs — + * the age cycle is left alone. + */ + @Test + void testOnlyDeletedCycleDueDispatchesDeletedOnly() throws Exception { + settings.setHousekeepingEnabled(true); + settings.setHousekeepingIntervalDays(30); + settings.setHousekeepingDeletedIntervalHours(24); + + long now = System.currentTimeMillis(); + long twoHoursAgo = now - TimeUnit.DAYS.toMillis(2); + Files.createDirectories(stateFile.getParentFile().toPath()); + YamlConfiguration yaml = new YamlConfiguration(); + // Age cycle ran 1 hour ago (<< 30d interval, not due). + yaml.set("lastAgeRunMillis", now - TimeUnit.HOURS.toMillis(1)); + // Deleted cycle ran 2 days ago (>= 24h interval, due). + yaml.set("lastDeletedRunMillis", twoHoursAgo); + yaml.save(stateFile); + + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, times(1)).scanDeleted(world); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static PurgeScanResult emptyScan(int days) { + return new PurgeScanResult(mock(World.class), days, Collections.emptyMap(), + false, false, new FilterStats(0, 0, 0, 0)); + } + + /** Reflective access to the package-private {@code checkAndMaybeRun} so + * tests can drive the hourly path without waiting for the scheduler. */ + private static void invokeCheckAndMaybeRun(HousekeepingManager hm) throws Exception { + var m = HousekeepingManager.class.getDeclaredMethod("checkAndMaybeRun"); + m.setAccessible(true); + m.invoke(hm); + } +} diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java new file mode 100644 index 000000000..cc1a1f13e --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -0,0 +1,359 @@ +package world.bentobox.bentobox.managers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import com.google.common.collect.ImmutableSet; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PurgeRegionsService.FilterStats; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; +import world.bentobox.bentobox.managers.island.IslandCache; +import world.bentobox.bentobox.managers.island.IslandGrid; +import world.bentobox.bentobox.managers.island.IslandGrid.IslandData; +import world.bentobox.bentobox.util.Pair; + +/** + * Direct tests for {@link PurgeRegionsService} focused on the deleted-sweep + * path ({@link PurgeRegionsService#scanDeleted(org.bukkit.World)}) and the + * {@code days == 0} behavior of {@link PurgeRegionsService#delete}. + * + *

These tests exercise the service directly (no command layer) so the + * assertions stay tightly scoped to the scanning/filtering logic. + */ +class PurgeRegionsServiceTest extends CommonTestSetup { + + @Mock + private IslandCache islandCache; + @Mock + private AddonsManager addonsManager; + @Mock + private PlayersManager pm; + + @TempDir + Path tempDir; + + private PurgeRegionsService service; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + when(iwm.isNetherGenerate(world)).thenReturn(false); + when(iwm.isNetherIslands(world)).thenReturn(false); + when(iwm.isEndGenerate(world)).thenReturn(false); + when(iwm.isEndIslands(world)).thenReturn(false); + when(iwm.getNetherWorld(world)).thenReturn(null); + when(iwm.getEndWorld(world)).thenReturn(null); + + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); + when(plugin.getPlayers()).thenReturn(pm); + + when(im.getIslandCache()).thenReturn(islandCache); + when(world.getWorldFolder()).thenReturn(tempDir.toFile()); + + service = new PurgeRegionsService(plugin); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * A world with no island grid returns an empty deleted-sweep result + * rather than crashing. + */ + @Test + void testScanDeletedNullGrid() { + when(islandCache.getIslandGrid(world)).thenReturn(null); + + PurgeScanResult result = service.scanDeleted(world); + assertTrue(result.isEmpty()); + assertEquals(0, result.days(), "days sentinel should be 0 for deleted sweep"); + } + + /** + * A world with only non-deletable islands yields no candidate regions. + */ + @Test + void testScanDeletedNoDeletableIslands() { + Island active = mock(Island.class); + when(active.isDeletable()).thenReturn(false); + + when(islandCache.getIslands(world)).thenReturn(List.of(active)); + when(islandCache.getIslandGrid(world)).thenReturn(mock(IslandGrid.class)); + + PurgeScanResult result = service.scanDeleted(world); + assertTrue(result.isEmpty()); + } + + /** + * A single deletable island with no neighbours produces one candidate + * region matching its protection bounds. + */ + @Test + void testScanDeletedLoneDeletableIsland() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + // Occupies r.0.0 (0..100 in X/Z) + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandData("del", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + + PurgeScanResult result = service.scanDeleted(world); + assertFalse(result.isEmpty()); + assertEquals(1, result.deleteableRegions().size()); + assertEquals(0, result.days()); + } + + /** + * An island straddling two regions (X = 500..700 crosses the r.0/r.1 + * boundary at X=512) produces two candidate region entries. + */ + @Test + void testScanDeletedIslandStraddlesRegionBoundary() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(500); + when(deletable.getMaxProtectedX()).thenReturn(700); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandData("del", 500, 0, 200))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + + PurgeScanResult result = service.scanDeleted(world); + assertEquals(2, result.deleteableRegions().size(), + "Island straddling r.0.0 and r.1.0 should produce two candidate regions"); + } + + /** + * Strict filter: a region containing one deletable and one non-deletable + * island must be dropped. + */ + @Test + void testScanDeletedStrictFilterDropsMixedRegion() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + Island active = mock(Island.class); + when(active.getUniqueId()).thenReturn("act"); + when(active.isDeletable()).thenReturn(false); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable, active)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of( + new IslandData("del", 0, 0, 100), + new IslandData("act", 200, 200, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.getIslandById("act")).thenReturn(Optional.of(active)); + + PurgeScanResult result = service.scanDeleted(world); + assertTrue(result.isEmpty(), "Mixed region must be blocked by strict filter"); + } + + /** + * Missing island rows in the grid must not block the region — they're + * already gone and count as "no blocker". + */ + @Test + void testScanDeletedMissingIslandRowDoesNotBlock() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of( + new IslandData("del", 0, 0, 100), + new IslandData("ghost", 300, 300, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.getIslandById("ghost")).thenReturn(Optional.empty()); + + PurgeScanResult result = service.scanDeleted(world); + assertFalse(result.isEmpty(), "Ghost island (no DB row) must not block the reap"); + assertEquals(1, result.deleteableRegions().size()); + } + + /** + * {@code delete} with a {@code days == 0} scan must bypass the freshness + * recheck — a region file touched seconds ago must still be reaped. + * This is the core of the deleted-sweep semantics. + */ + @Test + void testDeleteWithZeroDaysBypassesFreshnessCheck() throws IOException { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMemberSet()).thenReturn(ImmutableSet.of()); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandData("del", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.deleteIslandId("del")).thenReturn(true); + // deletePlayerFromWorldFolder iterates members (empty set here) so + // getIslands(World, UUID) is never reached — no stub needed. + + // Create a fresh .mca file — timestamp is "now". The age sweep would + // skip this file; the deleted sweep must reap it anyway. + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Path regionFile = regionDir.resolve("r.0.0.mca"); + byte[] data = new byte[8192]; + int nowSeconds = (int) (System.currentTimeMillis() / 1000L); + for (int i = 0; i < 1024; i++) { + int offset = 4096 + i * 4; + data[offset] = (byte) (nowSeconds >> 24); + data[offset + 1] = (byte) (nowSeconds >> 16); + data[offset + 2] = (byte) (nowSeconds >> 8); + data[offset + 3] = (byte) nowSeconds; + } + Files.write(regionFile, data); + + PurgeScanResult scan = service.scanDeleted(world); + assertFalse(scan.isEmpty()); + + boolean ok = service.delete(scan); + assertTrue(ok, "delete() should return true for a fresh-timestamp region under the deleted sweep"); + assertFalse(regionFile.toFile().exists(), + "Fresh region file must be reaped when days == 0"); + } + + /** + * {@code evictChunks} must walk the full 32x32 chunk square inside each + * target region and call {@code unloadChunk(cx, cz, false)} on every chunk + * that is currently loaded. This is the fix for the bug where reaped + * region files were re-flushed by Paper's autosave because the in-memory + * chunks survived the disk delete. + */ + @Test + void testEvictChunksUnloadsLoadedChunksInTargetRegion() { + // Build a scan result with a single target region at r.0.0 — chunks + // (0,0) .. (31,31). Mark only (5,7) and (10,12) as currently loaded + // so we can prove the call is gated on isChunkLoaded. + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("del")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + when(world.isChunkLoaded(5, 7)).thenReturn(true); + when(world.isChunkLoaded(10, 12)).thenReturn(true); + when(world.unloadChunk(anyInt(), anyInt(), eq(false))).thenReturn(true); + + service.evictChunks(scan); + + // Sweep covers all 32*32 = 1024 chunk coordinates exactly once. + verify(world, times(1024)).isChunkLoaded(anyInt(), anyInt()); + // Only the two loaded chunks were unloaded. + verify(world).unloadChunk(5, 7, false); + verify(world).unloadChunk(10, 12, false); + verify(world, times(2)).unloadChunk(anyInt(), anyInt(), eq(false)); + } + + /** + * Region coordinates must translate to chunk coordinates via {@code << 5} + * (each region holds 32×32 chunks). r.1.-1 → chunks (32..63, -32..-1). + */ + @Test + void testEvictChunksUsesCorrectChunkCoordsForNonZeroRegion() { + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(1, -1), Set.of("del")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + // The bottom-left corner of r.1.-1 is (32, -32); the top-right is (63, -1). + when(world.isChunkLoaded(32, -32)).thenReturn(true); + when(world.isChunkLoaded(63, -1)).thenReturn(true); + when(world.unloadChunk(anyInt(), anyInt(), eq(false))).thenReturn(true); + + service.evictChunks(scan); + + verify(world).unloadChunk(32, -32, false); + verify(world).unloadChunk(63, -1, false); + // Coordinates outside the region (e.g. (0,0)) must never be checked. + verify(world, never()).isChunkLoaded(0, 0); + verify(world, never()).isChunkLoaded(31, -1); + } + + /** + * An empty scan must short-circuit — no chunk-loaded probes at all. + */ + @Test + void testEvictChunksEmptyScanIsNoop() { + PurgeScanResult scan = new PurgeScanResult(world, 0, new HashMap<>(), false, false, + new FilterStats(0, 0, 0, 0)); + + service.evictChunks(scan); + + verify(world, never()).isChunkLoaded(anyInt(), anyInt()); + verify(world, never()).unloadChunk(anyInt(), anyInt(), eq(false)); + } +} From a63da7cf687212d9032d1bba222dbed1987d8750 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Apr 2026 05:30:25 -0700 Subject: [PATCH 09/16] Defer deleted-sweep island DB removal to plugin shutdown Paper's internal chunk cache keeps serving stale block data even after the .mca region files are deleted from disk. The chunks only clear on server restart when Paper discards its cache. Deleting the island DB row immediately left a window where players see old blocks but BentoBox reports no island at that location. The deleted sweep (days==0) now adds island IDs to a pendingDeletions set instead of removing them from the DB inline. On plugin shutdown (BentoBox.onDisable), flushPendingDeletions() processes the set. If the server crashes before a clean shutdown, the islands stay deletable=true and the next purge cycle retries safely. The age-based sweep (days>0) keeps immediate DB removal with the existing residual-region completeness check, since old regions won't be in Paper's memory cache. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/BentoBox.java | 6 + .../admin/purge/AdminPurgeDeletedCommand.java | 9 +- .../managers/PurgeRegionsService.java | 167 ++++++++++++- src/main/resources/locales/en-US.yml | 1 + .../purge/AdminPurgeDeletedCommandTest.java | 8 +- .../managers/PurgeRegionsServiceTest.java | 234 ++++++++++++++++++ 6 files changed, 414 insertions(+), 11 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index 47edea158..c23eb54e4 100644 --- a/src/main/java/world/bentobox/bentobox/BentoBox.java +++ b/src/main/java/world/bentobox/bentobox/BentoBox.java @@ -319,6 +319,12 @@ public void onDisable() { if (chunkPregenManager != null) { chunkPregenManager.shutdown(); } + // Flush deferred island deletions from the deleted-sweep purge. + // Paper's internal chunk cache is cleared on shutdown, so the stale + // in-memory chunks are guaranteed gone at this point. + if (purgeRegionsService != null) { + purgeRegionsService.flushPendingDeletions(); + } if (housekeepingManager != null) { housekeepingManager.stop(); } diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java index 1fb65f10c..f9a89d25e 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java @@ -106,8 +106,13 @@ private boolean deleteEverything() { getPlugin().log("Purge deleted: world save complete, dispatching deletion"); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); - Bukkit.getScheduler().runTask(getPlugin(), () -> - user.sendMessage(ok ? "general.success" : NONE_FOUND)); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + if (ok) { + user.sendMessage("commands.admin.purge.deleted.deferred"); + } else { + user.sendMessage(NONE_FOUND); + } + }); }); return true; } diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 8b6067141..d69c9ea73 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -8,6 +8,7 @@ import java.nio.ByteOrder; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -15,6 +16,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; @@ -57,6 +59,15 @@ public class PurgeRegionsService { private final BentoBox plugin; + /** + * Island IDs whose region files were deleted by a deleted-sweep + * ({@code days == 0}) but whose DB rows are deferred until plugin + * shutdown. Paper's internal chunk cache may still serve stale block + * data even after the {@code .mca} file is gone from disk; only a + * clean shutdown guarantees the cache is cleared. + */ + private final Set pendingDeletions = Collections.newSetFromMap(new ConcurrentHashMap<>()); + public PurgeRegionsService(BentoBox plugin) { this.plugin = plugin; } @@ -214,10 +225,44 @@ public boolean delete(PurgeScanResult scan) { plugin.logError("Not all region files could be deleted"); } - // Delete islands + player data - int islandsRemoved = 0; + // Collect unique island IDs across all reaped regions. An island + // that spans multiple regions will only be considered once here. + Set affectedIds = new HashSet<>(); for (Set islandIDs : scan.deleteableRegions().values()) { - for (String islandID : islandIDs) { + affectedIds.addAll(islandIDs); + } + + int islandsRemoved = 0; + int islandsDeferred = 0; + for (String islandID : affectedIds) { + Optional opt = plugin.getIslands().getIslandById(islandID); + if (opt.isEmpty()) { + continue; + } + Island island = opt.get(); + + if (scan.days() == 0) { + // Deleted sweep: region files are gone from disk but Paper + // may still serve stale chunk data from its internal memory + // cache. Defer DB row removal to plugin shutdown when the + // cache is guaranteed clear. + pendingDeletions.add(islandID); + islandsDeferred++; + plugin.log("Island ID " + islandID + + " region files deleted \u2014 DB row deferred to shutdown"); + } else { + // Age sweep: regions are old enough that Paper won't have + // them cached. Gate on residual-region completeness check + // to avoid orphaning blocks when the strict filter blocked + // some of the island's regions. + List> residual = findResidualRegions(island, scan.world()); + if (!residual.isEmpty()) { + islandsDeferred++; + plugin.log("Island ID " + islandID + " has " + residual.size() + + " residual region(s) still on disk: " + residual + + " \u2014 DB row retained for a future purge"); + continue; + } deletePlayerFromWorldFolder(scan.world(), islandID, scan.deleteableRegions(), scan.days()); plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); if (plugin.getIslands().deleteIslandId(islandID)) { @@ -228,10 +273,96 @@ public boolean delete(PurgeScanResult scan) { } plugin.log("Purge complete for world " + scan.world().getName() + ": " + scan.deleteableRegions().size() + " region(s), " - + islandsRemoved + " island(s) removed"); + + islandsRemoved + " island(s) removed, " + + islandsDeferred + " island(s) deferred" + + (scan.days() == 0 ? " (to shutdown)" : " (partial cleanup)")); return ok; } + /** + * Processes all island IDs whose region files were deleted by a prior + * deleted-sweep but whose DB rows were deferred because Paper's internal + * memory cache may still serve stale chunk data. Call this on plugin + * shutdown when the cache is guaranteed to be cleared. + * + *

If the server crashes before a clean shutdown, the pending set is + * lost — the islands stay {@code deletable=true} in the database and the + * next purge cycle will pick them up again (safe failure mode). + */ + public void flushPendingDeletions() { + if (pendingDeletions.isEmpty()) { + return; + } + plugin.log("Flushing " + pendingDeletions.size() + " deferred island deletion(s)..."); + int count = 0; + for (String islandID : pendingDeletions) { + plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); + if (plugin.getIslands().deleteIslandId(islandID)) { + count++; + } + } + pendingDeletions.clear(); + plugin.log("Flushed " + count + " island(s) from cache and database"); + } + + /** + * Returns an unmodifiable view of the island IDs currently pending + * DB deletion (deferred to shutdown). Primarily for testing. + */ + public Set getPendingDeletions() { + return Collections.unmodifiableSet(pendingDeletions); + } + + /** + * Returns the region coordinates for every {@code r.X.Z.mca} file still + * present on disk that overlaps the island's protection box, across the + * overworld and (if the gamemode owns them) the nether and end + * dimensions. An empty list means every region file the island touches + * is gone from disk and the island DB row can safely be reaped. + * + *

The protection box is converted to region coordinates with + * {@code blockX >> 9} (each .mca covers a 512×512 block square). The + * maximum bound is inclusive at the block level so we shift + * {@code max - 1} to avoid picking up a neighbour region when the + * protection ends exactly on a region boundary. + */ + private List> findResidualRegions(Island island, World overworld) { + int rxMin = island.getMinProtectedX() >> 9; + int rxMax = (island.getMaxProtectedX() - 1) >> 9; + int rzMin = island.getMinProtectedZ() >> 9; + int rzMax = (island.getMaxProtectedZ() - 1) >> 9; + + File base = overworld.getWorldFolder(); + File overworldRegionDir = new File(base, REGION); + + World netherWorld = plugin.getIWM().getNetherWorld(overworld); + File netherRegionDir = plugin.getIWM().isNetherIslands(overworld) + ? new File(netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base), REGION) + : null; + + World endWorld = plugin.getIWM().getEndWorld(overworld); + File endRegionDir = plugin.getIWM().isEndIslands(overworld) + ? new File(endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base), REGION) + : null; + + List> residual = new ArrayList<>(); + for (int rx = rxMin; rx <= rxMax; rx++) { + for (int rz = rzMin; rz <= rzMax; rz++) { + String name = "r." + rx + "." + rz + ".mca"; + if (regionFileExists(overworldRegionDir, name) + || regionFileExists(netherRegionDir, name) + || regionFileExists(endRegionDir, name)) { + residual.add(new Pair<>(rx, rz)); + } + } + } + return residual; + } + + private static boolean regionFileExists(File dir, String name) { + return dir != null && new File(dir, name).exists(); + } + // --------------------------------------------------------------- // Chunk eviction // --------------------------------------------------------------- @@ -704,6 +835,16 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); DimFolders end = new DimFolders(endRegion, endEntities, endPoi); + plugin.log("Purge delete: overworld region folder = " + overworldRegion.getAbsolutePath() + + " (exists=" + overworldRegion.isDirectory() + ")"); + if (scan.isNether()) { + plugin.log("Purge delete: nether region folder = " + netherRegion.getAbsolutePath() + + " (exists=" + netherRegion.isDirectory() + ")"); + } + if (scan.isEnd()) { + plugin.log("Purge delete: end region folder = " + endRegion.getAbsolutePath() + + " (exists=" + endRegion.isDirectory() + ")"); + } boolean allOk = true; for (Pair coords : scan.deleteableRegions().keySet()) { String name = "r." + coords.x() + "." + coords.z() + ".mca"; @@ -735,13 +876,27 @@ && deleteIfExists(new File(overworld.entities(), name)) private boolean deleteIfExists(File file) { if (!file.getParentFile().exists()) { + plugin.log("Purge delete: parent folder missing, skipping " + file.getAbsolutePath()); return true; } + boolean existedBefore = file.exists(); + long sizeBefore = existedBefore ? file.length() : -1L; try { - Files.deleteIfExists(file.toPath()); + boolean removed = Files.deleteIfExists(file.toPath()); + boolean existsAfter = file.exists(); + if (existedBefore) { + plugin.log("Purge delete: " + file.getAbsolutePath() + + " size=" + sizeBefore + "B" + + " removed=" + removed + + " existsAfter=" + existsAfter); + if (existsAfter) { + plugin.logError("Purge delete: file still present after delete! " + file.getAbsolutePath()); + return false; + } + } return true; } catch (IOException e) { - plugin.logError("Failed to delete file: " + file.getAbsolutePath()); + plugin.logError("Failed to delete file: " + file.getAbsolutePath() + " — " + e.getMessage()); return false; } } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 41cfbf147..bdca6d8c5 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -136,6 +136,7 @@ commands: parameters: '' description: 'purge region files for any [prefix_island] already flagged as deleted' confirm: 'Type /[label] purge deleted confirm to reap the region files' + deferred: 'Region files deleted. Island database entries will be removed on next server restart.' protect: description: toggle [prefix_island] purge protection move-to-island: 'Move to [prefix_an-island] first!' diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java index ac96b885a..2817e63fc 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -320,9 +321,10 @@ void testExecuteConfirmReapsFreshRegion() throws IOException { // Confirm assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); - verify(user).sendMessage("general.success"); + verify(user).sendMessage("commands.admin.purge.deleted.deferred"); assertFalse(regionFile.toFile().exists(), "Fresh region file should be reaped by the deleted sweep"); - verify(im).deleteIslandId("island-deletable"); + // DB row deletion is deferred to shutdown for days==0 (deleted sweep). + verify(im, never()).deleteIslandId("island-deletable"); } /** @@ -358,7 +360,7 @@ void testExecuteConfirmLeavesPlayerData() throws IOException { assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); - verify(user).sendMessage("general.success"); + verify(user).sendMessage("commands.admin.purge.deleted.deferred"); assertTrue(playerFile.toFile().exists(), "Deleted sweep must NOT remove player data — only the age sweep does"); } diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java index cc1a1f13e..47d4f33a9 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -343,6 +344,122 @@ void testEvictChunksUsesCorrectChunkCoordsForNonZeroRegion() { verify(world, never()).isChunkLoaded(31, -1); } + /** + * When an island's protection box extends beyond the reaped region(s) + * and one of its non-reaped regions still has an {@code r.X.Z.mca} file + * on disk, {@code delete} must not remove the island DB row — + * residual blocks exist with no other cleanup path. The next purge cycle + * will retry. + */ + @Test + void testDeleteDefersDBRowWhenResidualRegionExists() throws IOException { + // Age sweep (days=30): island spans X=0..1000 (crosses r.0 and r.1) + // but only r.0.0 is in the scan — the strict filter blocked r.1.0 + // because of an active neighbour. r.1.0.mca stays on disk. + Island spans = mock(Island.class); + when(spans.getUniqueId()).thenReturn("spans"); + when(spans.isDeletable()).thenReturn(true); + when(spans.getMinProtectedX()).thenReturn(0); + when(spans.getMaxProtectedX()).thenReturn(1000); + when(spans.getMinProtectedZ()).thenReturn(0); + when(spans.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("spans")).thenReturn(Optional.of(spans)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Path reaped = regionDir.resolve("r.0.0.mca"); + Path residual = regionDir.resolve("r.1.0.mca"); + Files.write(reaped, new byte[0]); + Files.write(residual, new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("spans")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + assertFalse(reaped.toFile().exists()); + assertTrue(residual.toFile().exists()); + // Age sweep: DB row must NOT be removed while residual region exists. + verify(im, never()).deleteIslandId(anyString()); + verify(islandCache, never()).deleteIslandFromCache(anyString()); + } + + /** + * Age sweep (days > 0): when every region the island's bounds touch is + * absent from disk after the reap, the DB row must be removed immediately. + */ + @Test + void testDeleteRemovesDBRowWhenAllRegionsGone() throws IOException { + Island tiny = mock(Island.class); + when(tiny.getUniqueId()).thenReturn("tiny"); + when(tiny.isDeletable()).thenReturn(true); + when(tiny.getMemberSet()).thenReturn(ImmutableSet.of()); + // Fits entirely in r.0.0 + when(tiny.getMinProtectedX()).thenReturn(0); + when(tiny.getMaxProtectedX()).thenReturn(100); + when(tiny.getMinProtectedZ()).thenReturn(0); + when(tiny.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny)); + when(im.deleteIslandId("tiny")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("tiny")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + verify(im, times(1)).deleteIslandId("tiny"); + verify(islandCache, times(1)).deleteIslandFromCache("tiny"); + } + + /** + * Age sweep: a mixed batch where one island is fully reaped and another + * has a residual region — only the fully-reaped island's DB row is removed. + */ + @Test + void testDeleteDefersOnlySomeIslandsInMixedBatch() throws IOException { + Island tiny = mock(Island.class); + when(tiny.getUniqueId()).thenReturn("tiny"); + when(tiny.isDeletable()).thenReturn(true); + when(tiny.getMemberSet()).thenReturn(ImmutableSet.of()); + when(tiny.getMinProtectedX()).thenReturn(0); + when(tiny.getMaxProtectedX()).thenReturn(100); + when(tiny.getMinProtectedZ()).thenReturn(0); + when(tiny.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny)); + when(im.deleteIslandId("tiny")).thenReturn(true); + + Island spans = mock(Island.class); + when(spans.getUniqueId()).thenReturn("spans"); + when(spans.isDeletable()).thenReturn(true); + when(spans.getMinProtectedX()).thenReturn(0); + when(spans.getMaxProtectedX()).thenReturn(1000); + when(spans.getMinProtectedZ()).thenReturn(0); + when(spans.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("spans")).thenReturn(Optional.of(spans)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + Files.write(regionDir.resolve("r.1.0.mca"), new byte[0]); // residual for "spans" + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("tiny", "spans")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + verify(im, times(1)).deleteIslandId("tiny"); + verify(im, never()).deleteIslandId("spans"); + verify(islandCache, times(1)).deleteIslandFromCache("tiny"); + verify(islandCache, never()).deleteIslandFromCache("spans"); + } + /** * An empty scan must short-circuit — no chunk-loaded probes at all. */ @@ -356,4 +473,121 @@ void testEvictChunksEmptyScanIsNoop() { verify(world, never()).isChunkLoaded(anyInt(), anyInt()); verify(world, never()).unloadChunk(anyInt(), anyInt(), eq(false)); } + + // ------------------------------------------------------------------ + // Deferred deletion (deleted sweep, days == 0) + // ------------------------------------------------------------------ + + /** + * Deleted sweep (days=0): DB row removal must be deferred to shutdown, + * not executed immediately. The island ID goes into pendingDeletions. + */ + @Test + void testDeletedSweepDefersDBDeletionToShutdown() throws IOException { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del1"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("del1")).thenReturn(Optional.of(deletable)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("del1")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + // DB row must NOT be removed immediately — deferred to shutdown. + verify(im, never()).deleteIslandId(anyString()); + verify(islandCache, never()).deleteIslandFromCache(anyString()); + // Island ID must be in pending set. + assertTrue(service.getPendingDeletions().contains("del1")); + } + + /** + * {@link PurgeRegionsService#flushPendingDeletions()} must process all + * deferred island IDs and clear the pending set. + */ + @Test + void testFlushPendingDeletionsRemovesIslands() throws IOException { + Island del1 = mock(Island.class); + when(del1.getUniqueId()).thenReturn("del1"); + when(del1.isDeletable()).thenReturn(true); + when(del1.getMinProtectedX()).thenReturn(0); + when(del1.getMaxProtectedX()).thenReturn(100); + when(del1.getMinProtectedZ()).thenReturn(0); + when(del1.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("del1")).thenReturn(Optional.of(del1)); + when(im.deleteIslandId("del1")).thenReturn(true); + + Island del2 = mock(Island.class); + when(del2.getUniqueId()).thenReturn("del2"); + when(del2.isDeletable()).thenReturn(true); + when(del2.getMinProtectedX()).thenReturn(512); + when(del2.getMaxProtectedX()).thenReturn(612); + when(del2.getMinProtectedZ()).thenReturn(0); + when(del2.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("del2")).thenReturn(Optional.of(del2)); + when(im.deleteIslandId("del2")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + Files.write(regionDir.resolve("r.1.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("del1")); + regions.put(new Pair<>(1, 0), Set.of("del2")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + service.delete(scan); + // Both deferred — not yet deleted. + verify(im, never()).deleteIslandId(anyString()); + assertEquals(2, service.getPendingDeletions().size()); + + // Now flush (simulates plugin shutdown). + service.flushPendingDeletions(); + verify(im, times(1)).deleteIslandId("del1"); + verify(im, times(1)).deleteIslandId("del2"); + verify(islandCache, times(1)).deleteIslandFromCache("del1"); + verify(islandCache, times(1)).deleteIslandFromCache("del2"); + assertTrue(service.getPendingDeletions().isEmpty()); + } + + /** + * Age sweep (days > 0) must still delete DB rows immediately when all + * regions are gone from disk — no deferral to shutdown. + */ + @Test + void testAgeSweepStillDeletesImmediately() throws IOException { + Island tiny = mock(Island.class); + when(tiny.getUniqueId()).thenReturn("tiny"); + when(tiny.isDeletable()).thenReturn(true); + when(tiny.getMemberSet()).thenReturn(ImmutableSet.of()); + when(tiny.getMinProtectedX()).thenReturn(0); + when(tiny.getMaxProtectedX()).thenReturn(100); + when(tiny.getMinProtectedZ()).thenReturn(0); + when(tiny.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny)); + when(im.deleteIslandId("tiny")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("tiny")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + service.delete(scan); + // Age sweep: immediate deletion, not deferred. + verify(im, times(1)).deleteIslandId("tiny"); + assertTrue(service.getPendingDeletions().isEmpty()); + } } From 107e56ffb316168b5631d21de55142b0248eef5c Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Apr 2026 05:38:16 -0700 Subject: [PATCH 10/16] Remove verbose per-file debug logging from deleteRegionFiles Strip the diagnostic logging added during development that printed file size, removed status, and existsAfter for every .mca deletion. Co-Authored-By: Claude Opus 4.6 --- .../managers/PurgeRegionsService.java | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index d69c9ea73..91063a111 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -835,16 +835,6 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); DimFolders end = new DimFolders(endRegion, endEntities, endPoi); - plugin.log("Purge delete: overworld region folder = " + overworldRegion.getAbsolutePath() - + " (exists=" + overworldRegion.isDirectory() + ")"); - if (scan.isNether()) { - plugin.log("Purge delete: nether region folder = " + netherRegion.getAbsolutePath() - + " (exists=" + netherRegion.isDirectory() + ")"); - } - if (scan.isEnd()) { - plugin.log("Purge delete: end region folder = " + endRegion.getAbsolutePath() - + " (exists=" + endRegion.isDirectory() + ")"); - } boolean allOk = true; for (Pair coords : scan.deleteableRegions().keySet()) { String name = "r." + coords.x() + "." + coords.z() + ".mca"; @@ -876,27 +866,13 @@ && deleteIfExists(new File(overworld.entities(), name)) private boolean deleteIfExists(File file) { if (!file.getParentFile().exists()) { - plugin.log("Purge delete: parent folder missing, skipping " + file.getAbsolutePath()); return true; } - boolean existedBefore = file.exists(); - long sizeBefore = existedBefore ? file.length() : -1L; try { - boolean removed = Files.deleteIfExists(file.toPath()); - boolean existsAfter = file.exists(); - if (existedBefore) { - plugin.log("Purge delete: " + file.getAbsolutePath() - + " size=" + sizeBefore + "B" - + " removed=" + removed - + " existsAfter=" + existsAfter); - if (existsAfter) { - plugin.logError("Purge delete: file still present after delete! " + file.getAbsolutePath()); - return false; - } - } + Files.deleteIfExists(file.toPath()); return true; } catch (IOException e) { - plugin.logError("Failed to delete file: " + file.getAbsolutePath() + " — " + e.getMessage()); + plugin.logError("Failed to delete file: " + file.getAbsolutePath()); return false; } } From 8fd252a4d0ecf6b1e88bf2ef661ccc07ad54cda7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Apr 2026 05:47:49 -0700 Subject: [PATCH 11/16] Remove keep-previous-island-on-reset setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This setting was made obsolete by Phase 2 which changed /is reset to always soft-delete. The only remaining references were in AdminPurgeCommand for conditional logging — now simplified to always use tier-based progress reporting. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/Settings.java | 33 ------------------- .../admin/purge/AdminPurgeCommand.java | 12 +++---- src/main/resources/config.yml | 10 ------ src/main/resources/locales/en-US.yml | 6 +--- .../world/bentobox/bentobox/SettingsTest.java | 20 ----------- 5 files changed, 5 insertions(+), 76 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index ba5d4556d..5c322cfcc 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -319,12 +319,6 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "island.delete-speed", since = "1.7.0") private int deleteSpeed = 1; - /** - * @deprecated No longer bound to config. Reset always soft-deletes now. - * Slated for removal. - */ - @Deprecated(since = "3.14.0", forRemoval = true) - private boolean keepPreviousIslandOnReset = false; /** * @deprecated No longer bound to config. The chunk-by-chunk deletion @@ -850,33 +844,6 @@ public void setDatabasePrefix(String databasePrefix) { this.databasePrefix = databasePrefix; } - /** - * Returns whether islands, when reset, should be kept or deleted. - * - * @return {@code true} if islands, when reset, should be kept; {@code false} - * otherwise. - * @since 1.13.0 - * @deprecated Reset always soft-deletes now. Physical cleanup is handled - * by the housekeeping auto-purge. Slated for removal. - */ - @Deprecated(since = "3.14.0", forRemoval = true) - public boolean isKeepPreviousIslandOnReset() { - return keepPreviousIslandOnReset; - } - - /** - * Sets whether islands, when reset, should be kept or deleted. - * - * @param keepPreviousIslandOnReset {@code true} if islands, when reset, should - * be kept; {@code false} otherwise. - * @since 1.13.0 - * @deprecated See {@link #isKeepPreviousIslandOnReset()}. - */ - @Deprecated(since = "3.14.0", forRemoval = true) - public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) { - this.keepPreviousIslandOnReset = keepPreviousIslandOnReset; - } - /** * Returns a MongoDB client connection URI to override default connection * options. diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java index 91a466c12..77ca92b27 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java @@ -12,7 +12,6 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; -import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.events.island.IslandDeletedEvent; import world.bentobox.bentobox.api.localization.TextVariables; @@ -91,8 +90,7 @@ public boolean execute(User user, String label, List args) { getOldIslands(days).thenAccept(islandSet -> { user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, String.valueOf(islandSet.size())); - if (islandSet.size() > TOO_MANY - && !BentoBox.getInstance().getSettings().isKeepPreviousIslandOnReset()) { + if (islandSet.size() > TOO_MANY) { user.sendMessage("commands.admin.purge.too-many"); // Give warning } if (!islandSet.isEmpty()) { @@ -132,12 +130,10 @@ private void deleteIsland() { // Round the percentage to check for specific tiers int roundedPercentage = (int) Math.floor(percentage); - // Determine if this percentage should be logged: 1%, 5%, or any new multiple of 5% - if (!BentoBox.getInstance().getSettings().isKeepPreviousIslandOnReset() || (roundedPercentage > 0 + // Log at 1%, 5%, and every multiple of 5% thereafter + if (roundedPercentage > 0 && (roundedPercentage == 1 || roundedPercentage % 5 == 0) - && !loggedTiers.contains(roundedPercentage))) { - - // Log the message and add the tier to the logged set + && !loggedTiers.contains(roundedPercentage)) { getPlugin().log(count + " islands purged out of " + getPurgeableIslandsCount() + " (" + percentageStr + " %)"); loggedTiers.add(roundedPercentage); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 00001355e..e487df1de 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -206,16 +206,6 @@ island: # Added since 1.7.0. delete-speed: 100 deletion: - # Toggles whether islands, when players are resetting them, should be kept in the world or deleted. - # * If set to 'true', whenever a player resets his island, his previous island will become unowned and won't be deleted from the world. - # You can, however, still delete those unowned islands through purging. - # On bigger servers, this can lead to an increasing world size. - # Yet, this allows admins to retrieve a player's old island in case of an improper use of the reset command. - # Admins can indeed re-add the player to his old island by registering him to it. - # * If set to 'false', whenever a player resets his island, his previous island will be deleted from the world. - # This is the default behaviour. - # Added since 1.13.0. - keep-previous-island-on-reset: false # Toggles how the islands are deleted. # * If set to 'false', all islands will be deleted at once. # This is fast but may cause an impact on the performance diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index bdca6d8c5..3623cb69d 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -106,11 +106,7 @@ commands: description: purge [prefix_Islands] abandoned for more than [days] days-one-or-more: 'Must be at least 1 day or more' purgable-islands: 'Found [number] purgable [prefix_Islands].' - too-many: | - This is a lot and could take a very long time to delete. - Consider using Regionerator plugin for deleting world chunks - and setting keep-previous-island-on-reset: true in BentoBox's config.yml. - Then run a purge. + too-many: 'This is a lot of [prefix_Islands]. The purge will soft-delete them and housekeeping will clean up the region files on schedule.' purge-in-progress: 'Purging in progress. Use /[label] purge stop to cancel.' scanning: 'Scanning [prefix_Islands] in the database. This may take a while depending diff --git a/src/test/java/world/bentobox/bentobox/SettingsTest.java b/src/test/java/world/bentobox/bentobox/SettingsTest.java index 0d325b52f..657503652 100644 --- a/src/test/java/world/bentobox/bentobox/SettingsTest.java +++ b/src/test/java/world/bentobox/bentobox/SettingsTest.java @@ -733,26 +733,6 @@ void testSetDatabasePrefix() { assertEquals("Prefix", s.getDatabasePrefix()); } - /** - * Test method for - * {@link world.bentobox.bentobox.Settings#isKeepPreviousIslandOnReset()}. - */ - @Test - void testIsKeepPreviousIslandOnReset() { - assertFalse(s.isKeepPreviousIslandOnReset()); - } - - /** - * Test method for - * {@link world.bentobox.bentobox.Settings#setKeepPreviousIslandOnReset(boolean)}. - */ - @Test - void testSetKeepPreviousIslandOnReset() { - assertFalse(s.isKeepPreviousIslandOnReset()); - s.setKeepPreviousIslandOnReset(true); - assertTrue(s.isKeepPreviousIslandOnReset()); - } - /** * Test method for * {@link world.bentobox.bentobox.Settings#getMongodbConnectionUri()}. From 9aa7259ad2a0e95268b61d01e285a2650933012a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:00:29 -0700 Subject: [PATCH 12/16] Bump version to 4.0.0 Co-Authored-By: Claude Opus 4.6 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index cb3a31791..d6382351a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt group = "world.bentobox" // From // Base properties from -val buildVersion = "3.14.0" +val buildVersion = "4.0.0" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version From 1a369e43feb908d094ac7f476d25f0c7a3c0bad5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:01:34 -0700 Subject: [PATCH 13/16] Update src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../bentobox/managers/PurgeRegionsService.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 91063a111..1c287985d 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -34,9 +34,13 @@ * {@code /bbox admin purge regions} command and the periodic * {@link HousekeepingManager} auto-purge task. * - *

All public methods perform blocking disk I/O and must be called from an - * async thread. The service does not interact with players or issue - * confirmations — the caller is responsible for any user-facing UX. + *

Threading requirements are method-specific. Methods that scan, read, or + * delete region data perform blocking disk I/O and should be called from an + * async thread. Methods that interact with Bukkit world or chunk APIs must be + * called from the main server thread. + * + *

The service does not interact with players or issue confirmations — the + * caller is responsible for any user-facing UX. * *

Extracted from {@code AdminPurgeRegionsCommand} so the command and the * scheduler can share a single code path for scanning, filtering, and From 9e84fe9317887b64a787dbc44df1b0dc0e518289 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:03:02 -0700 Subject: [PATCH 14/16] Update src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/commands/admin/purge/AdminPurgeDeletedCommand.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java index f9a89d25e..3654779f7 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java @@ -110,7 +110,8 @@ private boolean deleteEverything() { if (ok) { user.sendMessage("commands.admin.purge.deleted.deferred"); } else { - user.sendMessage(NONE_FOUND); + getPlugin().log("Purge deleted: failed to delete one or more region files after a non-empty scan"); + user.sendMessage("commands.admin.purge.failed"); } }); }); From a10199fcd9de11e20fe34350deebad4c5ffa1402 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:03:44 -0700 Subject: [PATCH 15/16] Update src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/commands/admin/AdminDeleteCommand.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java index 6d2d38339..d4acd4228 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java @@ -123,19 +123,19 @@ private void deleteIsland(User user, Island oldIsland) { // left in place) and PurgeRegionsService / HousekeepingManager // reaps the region files and DB row later on its schedule. // - // - Simple/void generation: chunks are cheap — repaint them via - // the addon's own ChunkGenerator right now using the existing - // DeleteIslandChunks + WorldRegenerator.regenerateSimple path, - // then hard-delete the island row so it does not linger. + // - Simple/void generation: chunks are cheap — hard-delete the + // island first so the cancellable delete path can veto the + // operation before any chunk work starts, then repaint them via + // the addon's own ChunkGenerator using the existing + // DeleteIslandChunks + WorldRegenerator.regenerateSimple path. // // If we can't resolve the gamemode, default to soft-delete. GameModeAddon gm = getIWM().getAddon(getWorld()).orElse(null); if (gm != null && !gm.isUsesNewChunkGeneration()) { - // DeleteIslandChunks snapshots the island bounds in its - // constructor, so it is safe to hard-delete the row - // immediately after kicking off the regen. - new DeleteIslandChunks(getPlugin(), new IslandDeletion(oldIsland)); getIslands().hardDeleteIsland(oldIsland); + // DeleteIslandChunks snapshots the island bounds from oldIsland, + // so it can safely run after the row has been hard-deleted. + new DeleteIslandChunks(getPlugin(), new IslandDeletion(oldIsland)); } else { getIslands().deleteIsland(oldIsland, true, targetUUID); } From 204d6c50a73cc3cdbbd15c78351ba6cfe9851643 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:10:56 +0000 Subject: [PATCH 16/16] Address PR review: fix typo, error messages, scheduler, quit cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `deleteableRegions` → `deletableRegions` in PurgeScanResult and all callers (fixes API surface typo) - Distinguish "none-found" from "purge failed" in AdminPurgeRegionsCommand.deleteEverything() with dedicated locale key - Replace CompletableFuture.runAsync() with Bukkit scheduler in AdminPurgeAgeRegionsCommand (ties task to plugin lifecycle) - Clear notifiedPlayers and deletableNotified on PlayerQuitEvent in LockAndBanListener (prevents unbounded set growth) - Add `commands.admin.purge.failed` locale key in en-US.yml Agent-Logs-Url: https://github.com/BentoBoxWorld/BentoBox/sessions/99688668-0dd8-455e-9002-3f229fbefbdc Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../purge/AdminPurgeAgeRegionsCommand.java | 3 +- .../admin/purge/AdminPurgeDeletedCommand.java | 2 +- .../admin/purge/AdminPurgeRegionsCommand.java | 14 +++-- .../flags/protection/LockAndBanListener.java | 9 +++ .../managers/HousekeepingManager.java | 4 +- .../managers/PurgeRegionsService.java | 56 +++++++++---------- src/main/resources/locales/en-US.yml | 1 + .../managers/PurgeRegionsServiceTest.java | 6 +- 8 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java index 164239989..d4aaaae69 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java @@ -1,7 +1,6 @@ package world.bentobox.bentobox.api.commands.admin.purge; import java.util.List; -import java.util.concurrent.CompletableFuture; import org.bukkit.Bukkit; import org.bukkit.World; @@ -78,7 +77,7 @@ public boolean execute(User user, String label, List args) { running = true; final int finalDays = days; - CompletableFuture.runAsync(() -> { + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { try { int count = getPlugin().getPurgeRegionsService().ageRegions(getWorld(), finalDays); Bukkit.getScheduler().runTask(getPlugin(), () -> { diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java index 3654779f7..349516b4f 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java @@ -119,7 +119,7 @@ private boolean deleteEverything() { } private void displayResultsAndPrompt(PurgeScanResult scan) { - Set uniqueIslands = scan.deleteableRegions().values().stream() + Set uniqueIslands = scan.deletableRegions().values().stream() .flatMap(Set::stream) .map(getPlugin().getIslands()::getIslandById) .flatMap(Optional::stream) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java index 6d90789c4..e69cd444b 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java @@ -120,14 +120,20 @@ private boolean deleteEverything() { getPlugin().log("Purge: world save complete, dispatching deletion"); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); - Bukkit.getScheduler().runTask(getPlugin(), () -> - user.sendMessage(ok ? "general.success" : NONE_FOUND)); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + if (ok) { + user.sendMessage("general.success"); + } else { + getPlugin().log("Purge: failed to delete one or more region files"); + user.sendMessage("commands.admin.purge.failed"); + } + }); }); return true; } private void displayResultsAndPrompt(PurgeScanResult scan) { - Set uniqueIslands = scan.deleteableRegions().values().stream() + Set uniqueIslands = scan.deletableRegions().values().stream() .flatMap(Set::stream) .map(getPlugin().getIslands()::getIslandById) .flatMap(Optional::stream) @@ -135,7 +141,7 @@ private void displayResultsAndPrompt(PurgeScanResult scan) { uniqueIslands.forEach(this::displayIsland); - scan.deleteableRegions().entrySet().stream() + scan.deletableRegions().entrySet().stream() .filter(e -> e.getValue().isEmpty()) .forEach(e -> displayEmptyRegion(e.getKey())); diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java index d65628125..06d20907b 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java @@ -12,6 +12,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.vehicle.VehicleMoveEvent; import org.bukkit.util.Vector; @@ -127,6 +128,14 @@ public void onPlayerLogin(PlayerJoinEvent e) { } } + // Quit cleanup — prevent unbounded growth of notification tracking sets + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent e) { + UUID uuid = e.getPlayer().getUniqueId(); + notifiedPlayers.remove(uuid); + deletableNotified.remove(uuid); + } + /** * Check if a player is banned or the island is locked * @param player - player diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index 1d7bbcc2d..d79c22537 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -306,7 +306,7 @@ private int runDeleteIfNonEmpty(PurgeScanResult scan, World overworld, String la plugin.log("Housekeeping " + label + ": nothing to purge in " + overworld.getName()); return 0; } - plugin.log("Housekeeping " + label + ": " + scan.deleteableRegions().size() + " region(s) and " + plugin.log("Housekeeping " + label + ": " + scan.deletableRegions().size() + " region(s) and " + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName()); boolean ok = plugin.getPurgeRegionsService().delete(scan); if (!ok) { @@ -314,7 +314,7 @@ private int runDeleteIfNonEmpty(PurgeScanResult scan, World overworld, String la + " completed with errors"); return 0; } - return scan.deleteableRegions().size(); + return scan.deletableRegions().size(); } // --------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 1c287985d..089d968e9 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -82,7 +82,7 @@ public PurgeRegionsService(BentoBox plugin) { * * @param world the world scanned * @param days the age cutoff (days) used - * @param deleteableRegions regions considered deletable keyed by region + * @param deletableRegions regions considered deletable keyed by region * coordinate {@code (regionX, regionZ)} * @param isNether whether the nether dimension was included * @param isEnd whether the end dimension was included @@ -91,17 +91,17 @@ public PurgeRegionsService(BentoBox plugin) { public record PurgeScanResult( World world, int days, - Map, Set> deleteableRegions, + Map, Set> deletableRegions, boolean isNether, boolean isEnd, FilterStats stats) { public boolean isEmpty() { - return deleteableRegions.isEmpty(); + return deletableRegions.isEmpty(); } public int uniqueIslandCount() { Set ids = new HashSet<>(); - deleteableRegions.values().forEach(ids::addAll); + deletableRegions.values().forEach(ids::addAll); return ids.size(); } } @@ -167,11 +167,11 @@ public PurgeScanResult scanDeleted(World world) { plugin.log("Purge deleted-sweep: " + candidateRegions.size() + " candidate region(s) from deletable islands in world " + world.getName()); - Map, Set> deleteableRegions = + Map, Set> deletableRegions = mapIslandsToRegions(new ArrayList<>(candidateRegions), islandGrid); - FilterStats stats = filterForDeletedSweep(deleteableRegions); + FilterStats stats = filterForDeletedSweep(deletableRegions); logFilterStats(stats); - return new PurgeScanResult(world, 0, deleteableRegions, isNether, isEnd, stats); + return new PurgeScanResult(world, 0, deletableRegions, isNether, isEnd, stats); } /** @@ -197,10 +197,10 @@ public PurgeScanResult scan(World world, int days) { } List> oldRegions = findOldRegions(world, days, isNether, isEnd); - Map, Set> deleteableRegions = mapIslandsToRegions(oldRegions, islandGrid); - FilterStats stats = filterNonDeletableRegions(deleteableRegions, days); + Map, Set> deletableRegions = mapIslandsToRegions(oldRegions, islandGrid); + FilterStats stats = filterNonDeletableRegions(deletableRegions, days); logFilterStats(stats); - return new PurgeScanResult(world, days, deleteableRegions, isNether, isEnd, stats); + return new PurgeScanResult(world, days, deletableRegions, isNether, isEnd, stats); } /** @@ -220,7 +220,7 @@ public PurgeScanResult scan(World world, int days) { * any file was unexpectedly fresh or could not be deleted */ public boolean delete(PurgeScanResult scan) { - if (scan.deleteableRegions().isEmpty()) { + if (scan.deletableRegions().isEmpty()) { return false; } plugin.log("Now deleting region files for world " + scan.world().getName()); @@ -232,7 +232,7 @@ public boolean delete(PurgeScanResult scan) { // Collect unique island IDs across all reaped regions. An island // that spans multiple regions will only be considered once here. Set affectedIds = new HashSet<>(); - for (Set islandIDs : scan.deleteableRegions().values()) { + for (Set islandIDs : scan.deletableRegions().values()) { affectedIds.addAll(islandIDs); } @@ -267,7 +267,7 @@ public boolean delete(PurgeScanResult scan) { + " \u2014 DB row retained for a future purge"); continue; } - deletePlayerFromWorldFolder(scan.world(), islandID, scan.deleteableRegions(), scan.days()); + deletePlayerFromWorldFolder(scan.world(), islandID, scan.deletableRegions(), scan.days()); plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); if (plugin.getIslands().deleteIslandId(islandID)) { plugin.log("Island ID " + islandID + " deleted from cache and database"); @@ -276,7 +276,7 @@ public boolean delete(PurgeScanResult scan) { } } plugin.log("Purge complete for world " + scan.world().getName() - + ": " + scan.deleteableRegions().size() + " region(s), " + + ": " + scan.deletableRegions().size() + " region(s), " + islandsRemoved + " island(s) removed, " + islandsDeferred + " island(s) deferred" + (scan.days() == 0 ? " (to shutdown)" : " (partial cleanup)")); @@ -373,7 +373,7 @@ private static boolean regionFileExists(File dir, String name) { /** * Unloads every loaded chunk that falls inside any region in - * {@code scan.deleteableRegions()} with {@code save = false}, so the + * {@code scan.deletableRegions()} with {@code save = false}, so the * in-memory chunk copy is thrown away rather than flushed back over the * region files we are about to delete. * @@ -396,7 +396,7 @@ private static boolean regionFileExists(File dir, String name) { * @param scan a prior scan result whose regions should be evicted */ public void evictChunks(PurgeScanResult scan) { - if (scan.deleteableRegions().isEmpty()) { + if (scan.deletableRegions().isEmpty()) { return; } World overworld = scan.world(); @@ -404,7 +404,7 @@ public void evictChunks(PurgeScanResult scan) { World endWorld = scan.isEnd() ? plugin.getIWM().getEndWorld(overworld) : null; int evicted = 0; - for (Pair coords : scan.deleteableRegions().keySet()) { + for (Pair coords : scan.deletableRegions().keySet()) { int baseCx = coords.x() << 5; // rX * 32 int baseCz = coords.z() << 5; evicted += evictRegion(overworld, baseCx, baseCz); @@ -416,7 +416,7 @@ public void evictChunks(PurgeScanResult scan) { } } plugin.log("Purge deleted: evicted " + evicted + " loaded chunk(s) from " - + scan.deleteableRegions().size() + " target region(s)"); + + scan.deletableRegions().size() + " target region(s)"); } private int evictRegion(World world, int baseCx, int baseCz) { @@ -550,13 +550,13 @@ private boolean writeTimestampTable(File regionFile, long targetSeconds) { * cannot be deleted, returning blocking statistics. */ private FilterStats filterNonDeletableRegions( - Map, Set> deleteableRegions, int days) { + Map, Set> deletableRegions, int days) { int islandsOverLevel = 0; int islandsPurgeProtected = 0; int regionsBlockedByLevel = 0; int regionsBlockedByProtection = 0; - var iter = deleteableRegions.entrySet().iterator(); + var iter = deletableRegions.entrySet().iterator(); while (iter.hasNext()) { var entry = iter.next(); int[] regionCounts = evaluateRegionIslands(entry.getValue(), days); @@ -579,9 +579,9 @@ private FilterStats filterNonDeletableRegions( * matters. */ private FilterStats filterForDeletedSweep( - Map, Set> deleteableRegions) { + Map, Set> deletableRegions) { int regionsBlockedByProtection = 0; - var iter = deleteableRegions.entrySet().iterator(); + var iter = deletableRegions.entrySet().iterator(); while (iter.hasNext()) { var entry = iter.next(); boolean block = false; @@ -827,7 +827,7 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { // Skipped for the deleted sweep (ageGated == false) — the deletable // flag on the island row is the sole authority there. if (ageGated) { - for (Pair coords : scan.deleteableRegions().keySet()) { + for (Pair coords : scan.deletableRegions().keySet()) { String name = "r." + coords.x() + "." + coords.z() + ".mca"; if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, scan.isNether(), scan.isEnd())) { @@ -840,7 +840,7 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); DimFolders end = new DimFolders(endRegion, endEntities, endPoi); boolean allOk = true; - for (Pair coords : scan.deleteableRegions().keySet()) { + for (Pair coords : scan.deletableRegions().keySet()) { String name = "r." + coords.x() + "." + coords.z() + ".mca"; if (!deleteOneRegion(name, ow, nether, end, scan.isNether(), scan.isEnd())) { plugin.logError("Could not delete all the region/entity/poi files for some reason"); @@ -886,15 +886,15 @@ private boolean deleteIfExists(File file) { // --------------------------------------------------------------- private void deletePlayerFromWorldFolder(World world, String islandID, - Map, Set> deleteableRegions, int days) { + Map, Set> deletableRegions, int days) { File playerData = resolvePlayerDataFolder(world); plugin.getIslands().getIslandById(islandID) .ifPresent(island -> island.getMemberSet() - .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deleteableRegions, days))); + .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deletableRegions, days))); } private void maybeDeletePlayerData(World world, UUID uuid, File playerData, - Map, Set> deleteableRegions, int days) { + Map, Set> deletableRegions, int days) { // Deleted sweep (days == 0) skips player-data cleanup entirely — // the player might still be active, and the age-based sweep will // reap orphaned .dat files later. @@ -902,7 +902,7 @@ private void maybeDeletePlayerData(World world, UUID uuid, File playerData, return; } List memberOf = new ArrayList<>(plugin.getIslands().getIslands(world, uuid)); - deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); + deletableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); if (!memberOf.isEmpty()) { return; } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 486b5399f..88fa8c416 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -120,6 +120,7 @@ commands: see-console-for-status: 'Purge started. See console for status or use /[label] purge status.' no-purge-in-progress: 'There is currently no purge in progress.' + failed: 'Purge completed with errors. Some region files could not be deleted. Check the server log for details.' regions: parameters: '[days]' description: 'purge islands by deleting old region files' diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java index 47d4f33a9..81556055b 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -145,7 +145,7 @@ void testScanDeletedLoneDeletableIsland() { PurgeScanResult result = service.scanDeleted(world); assertFalse(result.isEmpty()); - assertEquals(1, result.deleteableRegions().size()); + assertEquals(1, result.deletableRegions().size()); assertEquals(0, result.days()); } @@ -171,7 +171,7 @@ void testScanDeletedIslandStraddlesRegionBoundary() { when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); PurgeScanResult result = service.scanDeleted(world); - assertEquals(2, result.deleteableRegions().size(), + assertEquals(2, result.deletableRegions().size(), "Island straddling r.0.0 and r.1.0 should produce two candidate regions"); } @@ -233,7 +233,7 @@ void testScanDeletedMissingIslandRowDoesNotBlock() { PurgeScanResult result = service.scanDeleted(world); assertFalse(result.isEmpty(), "Ghost island (no DB row) must not block the reap"); - assertEquals(1, result.deleteableRegions().size()); + assertEquals(1, result.deletableRegions().size()); } /**