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."));The selection
Section titled “The selection”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 purge of whole worlds
Section titled “A purge of whole worlds”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 world that is not loaded
Section titled “A world that is not loaded”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.
The request
Section titled “The request”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.
Count and confirm
Section titled “Count and confirm”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 and follow a purge
Section titled “Start and follow a purge”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().
Progress
Section titled “Progress”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
Section titled “onProgress”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.
The result
Section titled “The result”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. |
One purge at a time
Section titled “One purge at a time”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.
When a purge fails
Section titled “When a purge fails”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.
Example: /purgeworld
Section titled “Example: /purgeworld”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:
/purgeworld old_worldbuilds a request and callscount(). Nothing changes yet. The staff member reads the size and clicks Confirm./purgeworld confirmstarts 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.