Skip to content
Select theme
  • Auto
  • Dark
  • Light
Download

Purging history

A purge deletes edits from the history for good. It is the same thing staff run with /oasis purge, with the same checks and the same history afterwards. Nothing brings the edits back, and the store gives their disk space back.

oasis.purges() has one method:

Method Does
purge(Selection selection) Deletes the edits the selection chooses.

It returns a PurgeRequest: a purge you have not started yet. The Purge it starts fires a PurgeEndEvent when it ends.

oasis.purges().purge(Selection.all().in("old_world"))
.requestedBy(staff)
.start()
.result().thenAccept(result -> staff.sendMessage("Deleted " + result.deleted() + " edits."));

A purge takes a Selection, and the selection must narrow. purge throws IllegalArgumentException for Selection.all(), so a purge never deletes the whole history by mistake. Any part narrows: a person, a time, a world, a box, a kind, a cause or a material.

purge also throws IllegalArgumentException when the selection names Actor.ROLLBACK, or leaves it out with not().by(Actor.ROLLBACK). The rollback edits mark what a rollback reversed. If a purge deleted them, the reversed edits would stand again. A purge of anything else keeps the rollback edits, with one exception: a purge of whole worlds.

A purge by age (a selection with only an until time) also hides the incidents older than its cut. The store and the cairn keep them.

A selection with no until time deletes the edits up to the moment of start(). A selection with an until time in the future is cut back to that moment. A purge never deletes an edit that is newer than its start.

A selection that names worlds and no other part, except until, is a purge of whole worlds. It deletes the whole history of those worlds, up to the cut:

// Every edit in two worlds that you retired.
Selection retired = Selection.all().in("old_world", "old_nether");
// The same, but only the edits older than thirty days.
Selection oldRetired = retired.until(Instant.now().minus(Duration.ofDays(30)));

A purge of whole worlds differs from a narrower purge in three ways:

  • It deletes the rollback and undo edits of those worlds too. A narrower purge keeps them.
  • It deletes the entries of those worlds from the operation log, for each operation that finished before the cut. A cut that falls inside an operation keeps the record and the undo of that operation. An operation that covered several worlds stays undoable in the other worlds.
  • It gives back the disk space of that log, as a purge by age does.

Add any other part, such as by(player) or kinds(Kind.BLOCK), and the purge is a narrower purge again. A narrower purge in a world keeps its rollback edits.

in(World...) takes the worlds that are loaded. in(String...) takes worlds by their stored names.

A purge reads and writes the store alone, so it does not need the world. It takes a world that is not loaded, and a world whose folder is gone. Use in(String...) with the name the world had when Oasis logged its edits. Names are exact, as World.getName() reads them.

Oasis checks every world name of the selection when you ask for count() and when you start. A name that is not loaded under that exact name, that Oasis never saw, and that the store holds no edits of fails with Failure.Reason.NO_SUCH_WORLD, so a typo never deletes anything. The command says No world named X for the same case.

After a purge that names worlds, Oasis forgets each of those worlds that is not loaded and holds no edit any more. A later request that names such a world fails with NO_SUCH_WORLD. A world that is loaded stays known, because Oasis logs the edits in it.

A rollback or a restore is different: it writes the world. Oasis loads an unloaded world for the pass, through the world loader of the plugin that keeps it or from its folder. When nobody can load it, the operation does not run. See Selecting edits.

A request is immutable. Each method returns a new request, so you can keep one in a field and start it as often as you like.

Method Sets Default
requestedBy(UUID person) Who asked. PurgeEndEvent.requester() reads this id. Your plugin, #plugin:<Name>.
requestedBy(CommandSender sender) Who asked: the player, or Actor.CONSOLE for any other sender.
count() Nothing. It counts the edits the purge deletes. See Count and confirm.
start() Nothing. It starts the purge now and returns a Purge.

A purge has no audience. Oasis draws no bar and sends no summary for a purge that a plugin starts. Draw your own with onProgress.

Oasis never asks for a confirmation when you start a purge through the API. When your plugin wants to ask one, call count() first:

PurgeRequest request = oasis.purges()
.purge(Selection.all().in("old_world"))
.requestedBy(staff);
request.count().thenAccept(edits ->
staff.sendMessage("This deletes " + edits + " edits for good."));

count() completes with a Long: the edits the purge deletes if it starts now. It counts each edit, so it reads as many rows as the purge deletes. A narrower purge keeps the rollback edits and the FAWE undo and redo edits, and the count leaves them out. Ask for it only when you show it to someone.

When a world of the selection does not exist, or the store can’t answer, the future completes exceptionally with an OasisException. When a read fails lists its reasons and shows how to handle it.

start() returns a Purge at once. The purge begins on the same tick when you call start() on the main thread, and on the next tick when you call it from another thread.

Method Returns
progress() Where the purge is now, as a PurgeProgress.
onProgress(Consumer<PurgeProgress> listener) Adds a listener and returns the same Purge. See onProgress.
result() A future of the PurgeResult. It completes on the main thread once the purge ends.

You can read a Purge from any thread. A purge has no cancel().

The store keeps the history in files. A purge rewrites or deletes each file that can hold an edit it deletes, so the files measure how far it has come.

Field Type Holds
deleted() long The edits deleted so far.
files() int The files the purge rewrites, or 0 before it plans.
doneFiles() int The files it has finished.
finished() boolean True in the last call, when the purge ended.
startedAt() Instant When the purge started.

fraction() is how far the purge has come, from 0 to 1: the finished files over all its files. It reads 0 before the purge plans.

onProgress calls your listener on the main thread, every two seconds while the purge runs. When the purge ends, the listener always gets one last call with finished() true, before result() completes. A purge that does not start gets only the last call. A listener you add after the end gets no call.

When a listener throws a RuntimeException, Oasis logs it as a warning in the server log and goes on. The other listeners still get their calls, and the result still completes.

Add your listeners right after start(), in the same tick. Oasis gives a purge that cannot start its last call and its result one tick later, so a listener you add in the tick of start() still gets it.

result() completes on the main thread with a PurgeResult. It never completes exceptionally: a purge that could not start or that failed ends with Outcome.FAILED.

Field Type Holds
outcome() Outcome FINISHED or FAILED. A purge has no cancel, so it never ends CANCELLED.
failure() Optional<Failure> Why it failed, or empty when it finished. See When a purge fails.
deleted() long The edits the purge deleted.
duration() Duration How long the purge ran, from start() to its end.

Oasis runs one purge at a time, for commands and plugins alike. A purge also cannot start while an operation runs that can hold its edits. A purge that names no player can hold the edits of any operation, so any running operation refuses it. A purge that names players is refused only while an operation runs that names one of them.

A purge that is refused ends with Outcome.FAILED and Failure.Reason.BUSY. Nothing waits in a queue.

To start yours as soon as the other ends, listen for PurgeEndEvent or OperationEndEvent. When the event fires, the slot is free, and you can start your purge from inside the listener.

PurgeResult.failure() holds a Failure with two fields. Test reason() in code, and show message() to people. The message is the line a staff member reads for the same failure of a command.

Failure.Reason What happened What to do
BUSY A purge runs, or an operation runs that can hold the edits of this purge. Start it again when that one ends.
REBUILDING The store rebuilds the runs that another build of Oasis wrote, after an update. Start it again when the rebuild ends.
MOVING The history moves to another store with /oasis move. Start it again when the move ends.
STARTING Oasis still opens its store, just after the server started. Start it again shortly.
OVERLOADED The store holds as many requests as it takes. Start it again shortly.
NO_SUCH_WORLD The selection names a world, chosen or left out, that is not loaded under that exact name, that Oasis never saw, and that the store holds no edits of. Most often a typo. Check the name.
STORE The store failed, and the purge stopped. Tell staff. The purge does not run again by itself, and the server log names the error.

A purge that fails with STORE may have deleted some edits before it stopped. PurgeResult.deleted() and Purge.progress().deleted() hold that count.

private void explain(Player staff, PurgeResult result)
{
Failure failure = result.failure().orElseThrow();
switch (failure.reason())
{
case NO_SUCH_WORLD -> staff.sendMessage(failure.message() + " Check the name of the world.");
case BUSY, REBUILDING, MOVING, STARTING, OVERLOADED ->
staff.sendMessage(failure.message() + " Run the command again in a moment.");
default -> staff.sendMessage("The purge failed: " + failure.message());
}
}

A purge that Oasis refuses before it begins, such as one that ends with BUSY or NO_SUCH_WORLD, fires no PurgeEndEvent, because no purge ran.

This plugin adds /purgeworld <world>. It counts the edits of the world, asks the staff member to confirm, draws the progress in a boss bar, and reports the result. The world can be unloaded, or even deleted. A name that no world and no edit knows fails the count with NO_SUCH_WORLD.

package com.example.purgeworld;
import com.oasis.api.Failure;
import com.oasis.api.Oasis;
import com.oasis.api.OasisApi;
import com.oasis.api.OasisException;
import com.oasis.api.Outcome;
import com.oasis.api.Purge;
import com.oasis.api.PurgeRequest;
import com.oasis.api.Selection;
import io.papermc.paper.command.brigadier.BasicCommand;
import io.papermc.paper.command.brigadier.CommandSourceStack;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
public final class PurgeWorld extends JavaPlugin
{
private final Map<UUID, PurgeRequest> toConfirm = new HashMap<>();
private OasisApi oasis;
@Override
public void onEnable()
{
oasis = Oasis.api(this);
registerCommand("purgeworld", "Delete the history of a world, after a confirmation", new Command());
}
private final class Command implements BasicCommand
{
@Override
public void execute(CommandSourceStack source, String[] args)
{
if (!(source.getSender() instanceof Player staff))
{
source.getSender().sendMessage("Run /purgeworld in game.");
return;
}
if (args.length != 1)
{
staff.sendMessage(Component.text("Usage: /purgeworld <world>", NamedTextColor.RED));
return;
}
if (args[0].toLowerCase(Locale.ROOT).equals("confirm"))
{
start(staff, toConfirm.remove(staff.getUniqueId()));
}
else
{
ask(staff, args[0]);
}
}
@Override
public String permission()
{
return "purgeworld.use";
}
}
/** Step 1: count the edits, and ask the staff member to confirm. */
private void ask(Player staff, String world)
{
// The name needs no loaded world. Oasis reads the store alone.
PurgeRequest request = oasis.purges()
.purge(Selection.all().in(world))
.requestedBy(staff.getUniqueId());
request.count().whenComplete((edits, error) ->
{
if (error != null)
{
refused(staff, "Oasis could not count the edits", error);
return;
}
if (edits == 0)
{
staff.sendMessage(Component.text("The history holds no edit in " + world + ".", NamedTextColor.GRAY));
return;
}
toConfirm.put(staff.getUniqueId(), request);
staff.sendMessage(Component.text("Delete " + edits + " edits in " + world + " for good? ")
.append(Component.text("[Confirm]", NamedTextColor.RED)
.clickEvent(ClickEvent.runCommand("/purgeworld confirm"))));
});
}
/** Step 2: start the request the staff member confirmed, with a boss bar of its progress. */
private void start(Player staff, PurgeRequest request)
{
if (request == null)
{
staff.sendMessage(Component.text("There is nothing to confirm. Run /purgeworld <world> again.",
NamedTextColor.RED));
return;
}
BossBar bar = BossBar.bossBar(Component.text("Purging"), 0f, BossBar.Color.RED, BossBar.Overlay.PROGRESS);
staff.showBossBar(bar);
Purge purge = request.start();
purge.onProgress(progress -> bar.progress((float) progress.fraction()));
purge.result().thenAccept(result ->
{
staff.hideBossBar(bar);
if (result.outcome() == Outcome.FAILED)
{
String why = result.failure().orElseThrow().message();
staff.sendMessage(Component.text("The purge failed after deleting " + result.deleted()
+ " edits: " + why, NamedTextColor.RED));
return;
}
staff.sendMessage(Component.text("Deleted " + result.deleted() + " edits in "
+ result.duration().toSeconds() + "s.", NamedTextColor.GREEN));
});
}
/** A count fails with an OasisException that names the reason. */
private void refused(Player staff, String what, Throwable error)
{
if (error instanceof OasisException failed)
{
Failure failure = failed.failure();
staff.sendMessage(Component.text(what + ": " + failure.message(), NamedTextColor.RED));
return;
}
staff.sendMessage(Component.text(what + ". The server log has the details.", NamedTextColor.RED));
getLogger().log(Level.WARNING, what, error);
}
}

The flow has two steps:

  1. /purgeworld old_world builds a request and calls count(). Nothing changes yet. The staff member reads the size and clicks Confirm.
  2. /purgeworld confirm starts the request it kept. The purge deletes the edits up to that start.

When the purge ends, Oasis forgets old_world if it is not loaded and holds no edit any more. A later request that names old_world fails with NO_SUCH_WORLD.