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

Running operations

An operation changes the world back. It is the same thing staff run with /rollback, /rs and /undo, with the same speed, the same care for later edits, and the same history afterwards. oasis.operations() has one method for each:

Method Does
rollback(Selection selection) Puts back what the chosen edits changed, as it stood before them.
restore(Selection selection) Puts the chosen edits back after a rollback took them away.
undo(long operation) Reverses this operation, and the sweeps and the force that followed it, as one.
undoLast(UUID requester) Reverses the last operation this person asked for, as /oasis undo does for them.
undoLast(CommandSender requester) The same for a sender: a player, or the console for any other sender.

Each returns an OperationRequest: an operation you have not started yet. The Operation it starts, and the events it fires, name the verb as an OperationType: ROLLBACK, RESTORE or UNDO. A sweep and a force have the type of the operation they follow.

oasis.operations().rollback(Selection.all().by(griefer).within(Duration.ofHours(2)))
.requestedBy(staff)
.audience(staff)
.start()
.result().thenAccept(result -> staff.sendMessage("Rolled back " + result.applied() + " 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. Staff read this name in lookups, and undoLast and /oasis undo reverse the last operation of this person. Your plugin, #plugin:<Name>.
requestedBy(CommandSender sender) Who asked: the player, or Actor.CONSOLE for any other sender.
audience(Audience audience) Who sees it run: the start line, the progress bar and the summary, as a command shows them. Nobody.
targetTick(Duration tick) The longest tick the operation may leave the server with. A longer tick finishes sooner, and the players feel it. A zero or negative duration throws IllegalArgumentException. The config’s rollback.target-tick-ms.
estimate() Nothing. It reads how large the operation is. See Estimate and confirm.
start() Nothing. It starts the operation now and returns an Operation.

Name the staff member who asked with requestedBy, so the history says who asked for the rollback, and that staff member can /oasis undo it. Any Audience works: a player, a CommandSender, the console, or Audience.audience(staff, Bukkit.getConsoleSender()) for several at once. The audience also gets the force link of the summary, as a staff member does after a command.

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

OperationRequest request = oasis.operations()
.rollback(Selection.all().by(griefer).within(Duration.ofHours(2)))
.requestedBy(staff);
request.estimate().thenAccept(estimate ->
staff.sendMessage("This rolls back " + estimate.edits() + " edits in " + estimate.chunks()
+ " chunks, in about " + estimate.duration().toSeconds() + " seconds."));

An Estimate has three fields:

Field Type Holds
edits() long The edits the operation chooses.
chunks() int The chunks those edits are in.
duration() Duration How long it takes, at the rate this server has measured, within its target tick.

The estimate reads history and changes nothing. It is the one place in the API that reads a whole selection, so ask for it only when you show it to someone. The operation decides as it runs which of the chosen edits it writes and which a later edit holds, so edits() counts what it chooses, not what it writes. The result splits those edits into applied(), kept() and left(), plus what is incomplete(). A sweep can add edits that the estimate could not see.

The estimate of a forceKept() request is different. It names the kept positions and the chunks they lie in, as the operation that kept them counted them, and reads no history.

When the store can’t answer, a world of the selection does not exist, or an undo names nothing to undo, the estimate future completes exceptionally with an OasisException. When a read fails lists its reasons and shows how to handle it. See Example: /griefrevert for a full confirmation flow.

start() returns an Operation at once. The pass begins on a later tick.

Method Returns
id() The id of the operation, once history has recorded it: the id of its first pass. Empty before that. Pass it to undo.
type() What the operation does: OperationType.ROLLBACK, RESTORE or UNDO.
progress() Where the operation is now, as a Progress.
onProgress(Consumer<Progress> listener) Adds a listener and returns the same Operation. See onProgress.
result() A future of the Result. It completes on the main thread once the operation and its sweeps end.
cancel() Stops the operation. See Cancel.

You can read an Operation from any thread.

An operation writes a chunk at a time, so the chunks measure how far it has come.

Field Type Holds
phase() Phase PLANNING, APPLYING, SWEEPING or FINISHED.
edits() long The edits written so far.
total() OptionalLong The rows of the plan. Empty while the operation plans, or when it has none.
exact() boolean False when total() is an upper bound.
chunks() int The chunks the operation writes, or 0 while it plans.
doneChunks() int The chunks it has finished.
startedAt() Instant When the operation started.

fraction() is how far the operation has come, from 0 to 1: the finished chunks over all its chunks. It reads 0 while the operation plans.

onProgress calls your listener on the main thread, at most once a tick, while the operation runs. When the operation ends, the listener always gets one last call with Phase.FINISHED, before result() completes. That holds after a pass, after a refusal such as BUSY, and after a cancel before the pass. 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.

This listener draws the progress in a boss bar, and takes the bar away on the last call:

BossBar bar = BossBar.bossBar(Component.text("Rolling back"), 0f, BossBar.Color.GREEN, BossBar.Overlay.PROGRESS);
staff.showBossBar(bar);
Operation operation = request.start();
operation.onProgress(progress ->
{
bar.progress((float) progress.fraction());
if (progress.phase() == Phase.FINISHED)
{
staff.hideBossBar(bar);
}
});

With an audience, Oasis draws its own progress bar for that audience. Leave the audience unset when you draw your own, or the staff member sees two bars.

While a rollback runs, lava and water can keep flowing and fire can keep spreading. The first pass could not select those edits, because they did not exist yet. So after its first pass, a rollback runs sweeps: follow-up passes of the same operation that take what the world made while it ran. Progress.phase() reads SWEEPING during a sweep, and Result.swept() counts what the sweeps wrote.

A restore, an undo and a force run one pass and sweep nothing.

cancel() stops the operation after the chunk it writes now. No sweep follows a cancel. The result then says Outcome.CANCELLED, and applied() counts what it wrote before it stopped.

You can cancel from the moment start() returns, from any thread. A cancel from another thread acts on the main thread, at the next tick. A cancel before the pass begins, such as one in the same tick as start(), ends the operation before it writes anything. Its result is CANCELLED with nothing applied and no failure, and no OperationEndEvent fires for it. That holds even for a request that could not have run, such as an undo with nothing to undo.

Operation operation = request.start();
// Later, from a "Stop" button:
operation.cancel();

oasis.operations().active() lists the operations your plugin started that still run, so you can find one to cancel. It lists the operations of your plugin only, whoever requestedBy names. It does not list another plugin’s operations or the ones staff typed.

result() completes on the main thread with a Result. It never completes exceptionally: an operation that could not run ends with Outcome.FAILED.

Field Type Holds
outcome() Outcome FINISHED, CANCELLED or FAILED.
failure() Optional<Failure> Why it failed, or empty when it did not fail. See When an operation fails.
applied() long The edits it wrote, its sweeps included.
swept() long The part of applied() the sweeps wrote.
kept() long The positions it left standing because a later edit by another player held them.
left() long The positions it left standing because the world holds another block than the history names, and no edit says why.
incomplete() Optional<String> What it chose and could not write, in words for people, or empty.
duration() Duration How long it took: from the moment it took the operation slot to the end of its last sweep. For an operation that failed or was cancelled before that, from its start.
forceKept() Optional<OperationRequest> A request that changes the kept positions too. Present only when the operation finished and kept positions. See below.

kept() and left() count each position once. An edit the operation could not write whatever the world holds, such as items for a container that is gone, or a block whose data does not read, is in incomplete() and in neither count.

A rollback is careful. Say a griefer broke a wall, and another player rebuilt part of it since. A rollback of the griefer leaves the rebuilt part alone, so it doesn’t undo somebody’s repair. Those positions are kept, and kept() counts them. World changes, such as flowing water, never hold a position.

A position is left when the world holds another block than the history names, and no edit says why. That can happen after a change that Oasis did not log, such as a plugin that changed blocks without the logger. No request changes a left position.

When the person who asked wants the kept positions changed too, start forceKept(). It is a request that changes the kept positions and nothing else, over the later edits that held them. It is the same as the force link staff click after a command.

oasis.lookups().kept(id) lists the kept positions themselves, each with the person who keeps it and the edit that holds it. See The positions an operation kept.

operation.result().thenAccept(result ->
{
result.forceKept().ifPresent(force ->
{
pendingForce.put(staff.getUniqueId(), force);
staff.sendMessage(Component.text("Kept " + result.kept() + " blocks that other players changed since. ")
.append(Component.text("[Force]", NamedTextColor.GOLD)
.clickEvent(ClickEvent.runCommand("/griefrevert force"))));
});
});

forceKept() is present only when the operation finished and kept positions. It is empty:

  • when the operation kept nothing,
  • when it did not finish, that is, when its outcome is CANCELLED or FAILED,
  • for an undo,
  • for the result of a force,
  • and in an OperationEndEvent.

A request has no force of its own, so a force always follows the operation that kept the positions. A cancel and a failure both close a pass as cancelled, and a force needs a finished pass to take its positions from. So a cancelled or failed operation never offers a force, even when kept() is above 0. Check forceKept(), not kept(), before you offer a force button. An estimate() of the force request names the kept positions and their chunks, so you can show its size before the staff member clicks.

Oasis runs one operation at a time, for commands and plugins alike. A request that starts while another operation runs ends at once with Outcome.FAILED and Failure.Reason.BUSY. Nothing waits in a queue.

To run yours as soon as the other ends, listen for OperationEndEvent. When it fires, the slot is free, and you can start your operation from inside the listener.

Result.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 Another operation runs. Oasis runs one at a time. Start yours again when it 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.
NOTHING_TO_UNDO undoLast names a person who has run no operation. Tell the person there is nothing to undo.
NO_SUCH_OPERATION undo names an operation that the history does not hold. Check the id you passed.
STORE The store failed: Oasis stopped logging, or the store could not read or record the operation. Tell staff. The operation does not run again by itself, and the server log names the error.
PASS The pass stopped on an error after it began to write: the store failed under it, or a write to the world threw. Tell staff. The edits it wrote stay, and applied() counts them.
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 history holds no edit of. Most often a typo. Nothing ran. Show the message, which names the world. Check the name: names are exact, as World.getName() reads them.
WORLD_GONE The operation has edits in a world that is not loaded, that no world loader owns, and that has no folder Oasis can load. It wrote nothing. Show the message, which names each such world. Name the worlds you want with in, or leave the world out with not().in(name).
WORLD_LOAD A world of the operation did not load: its world loader failed, or its folder did not open. Tell staff; the message names the world and why. The edits written before stay.
WORLD_FORMAT A newer build of Oasis or Packs Server wrote values into a world of the operation, in a format this build does not know. The pass wrote nothing into that world. Install that build or a newer one; the message names it.

A failure before the operation reaches the slot, such as BUSY, NOTHING_TO_UNDO, NO_SUCH_WORLD or WORLD_GONE, fires no OperationEndEvent, because no operation ran.

private void explain(Player staff, Result result)
{
Failure failure = result.failure().orElseThrow();
switch (failure.reason())
{
case BUSY, STARTING, OVERLOADED ->
staff.sendMessage(failure.message() + " Run the command again in a moment.");
case MOVING -> staff.sendMessage(failure.message() + " Run the command again when the move ends.");
default -> staff.sendMessage("The rollback failed: " + failure.message());
}
}

undo(id) reverses one operation as a whole: its first pass, its sweeps and its force. Any pass id of the operation names all of it. That is the id of Operation.id(), the id of a sweep that OperationChunkEvent.operation() or Edit.operation() reads, or the id of a force. Keep the id from Operation.id():

Operation operation = request.start();
operation.result().thenAccept(result ->
operation.id().ifPresent(id -> lastOperation.put(staff.getUniqueId(), id)));
// Later:
Long id = lastOperation.get(staff.getUniqueId());
if (id != null)
{
oasis.operations().undo(id).requestedBy(staff).start();
}

undoLast(requester) reverses the last operation that person asked for, as /oasis undo does. Two cases are worth knowing:

  • undoLast(staff.getUniqueId()) reverses the last operation the staff member asked for, whether they typed it or your plugin named them with requestedBy.
  • undoLast(oasis.id()) reverses the last operation your plugin asked for in its own name.

An undo runs one pass and sweeps nothing. Its result never has a forceKept().

This plugin adds /griefrevert <player> [hours]. It sizes the rollback and asks the staff member to confirm it, draws the progress in a boss bar, reports the result, and offers a force button when the rollback finished and kept positions. /griefrevert cancel stops a running rollback.

package com.example.griefrevert;
import com.oasis.api.Failure;
import com.oasis.api.Oasis;
import com.oasis.api.OasisApi;
import com.oasis.api.OasisException;
import com.oasis.api.Operation;
import com.oasis.api.OperationRequest;
import com.oasis.api.Progress;
import com.oasis.api.Result;
import com.oasis.api.Selection;
import io.papermc.paper.command.brigadier.BasicCommand;
import io.papermc.paper.command.brigadier.CommandSourceStack;
import java.time.Duration;
import java.time.Instant;
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.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
public final class GriefRevert extends JavaPlugin
{
/** How long a staff member has to click Confirm or Force. */
private static final Duration CLICK_WINDOW = Duration.ofSeconds(60);
/** A request that waits for a click. */
private record Pending(OperationRequest request, Instant asked)
{
boolean expired()
{
return asked.plus(CLICK_WINDOW).isBefore(Instant.now());
}
}
private final Map<UUID, Pending> toConfirm = new HashMap<>();
private final Map<UUID, Pending> toForce = new HashMap<>();
private final Map<UUID, Operation> running = new HashMap<>();
private OasisApi oasis;
@Override
public void onEnable()
{
oasis = Oasis.api(this);
registerCommand("griefrevert", "Roll back a griefer, 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 /griefrevert in game.");
return;
}
if (args.length == 0 || args.length > 2)
{
staff.sendMessage(Component.text("Usage: /griefrevert <player> [hours]", NamedTextColor.RED));
return;
}
switch (args[0].toLowerCase(Locale.ROOT))
{
case "confirm" -> start(staff, toConfirm.remove(staff.getUniqueId()));
case "force" -> start(staff, toForce.remove(staff.getUniqueId()));
case "cancel" -> cancel(staff);
default -> ask(staff, args);
}
}
@Override
public String permission()
{
return "griefrevert.use";
}
}
/** Step 1: size the rollback, and ask the staff member to confirm it. */
private void ask(Player staff, String[] args)
{
OfflinePlayer griefer = Bukkit.getOfflinePlayerIfCached(args[0]);
if (griefer == null)
{
staff.sendMessage(Component.text("No player called " + args[0] + " has played here.", NamedTextColor.RED));
return;
}
long hours;
try
{
hours = args.length == 2 ? Long.parseLong(args[1]) : 2;
}
catch (NumberFormatException e)
{
hours = 0;
}
if (hours < 1)
{
staff.sendMessage(Component.text("Hours is a whole number of 1 or more.", NamedTextColor.RED));
return;
}
// The window counts back from the start, so a confirm a minute later still reads the last hours.
OperationRequest request = oasis.operations()
.rollback(Selection.all().by(griefer).within(Duration.ofHours(hours)))
.requestedBy(staff);
request.estimate().whenComplete((estimate, error) ->
{
if (error != null)
{
refused(staff, "Oasis could not size the rollback", error);
return;
}
if (estimate.edits() == 0)
{
staff.sendMessage(Component.text(griefer.getName() + " made no edits to roll back.", NamedTextColor.GRAY));
return;
}
toConfirm.put(staff.getUniqueId(), new Pending(request, Instant.now()));
staff.sendMessage(Component.text("Roll back " + estimate.edits() + " edits of " + griefer.getName()
+ " in " + estimate.chunks() + " chunks, in about " + estimate.duration().toSeconds() + "s? ")
.append(button("[Confirm]", "/griefrevert confirm", NamedTextColor.GREEN)));
});
}
/** Step 2: start a request the staff member clicked, with a boss bar of its progress. */
private void start(Player staff, Pending pending)
{
if (pending == null || pending.expired())
{
staff.sendMessage(Component.text("There is nothing to confirm. Run /griefrevert <player> again.",
NamedTextColor.RED));
return;
}
BossBar bar = BossBar.bossBar(Component.text("Rolling back"), 0f, BossBar.Color.GREEN,
BossBar.Overlay.PROGRESS);
staff.showBossBar(bar);
Operation operation = pending.request().start();
running.put(staff.getUniqueId(), operation);
operation.onProgress(progress ->
{
bar.progress((float) progress.fraction());
bar.name(Component.text(label(progress)));
});
operation.result().thenAccept(result ->
{
staff.hideBossBar(bar);
running.remove(staff.getUniqueId(), operation);
report(staff, result);
});
}
private void cancel(Player staff)
{
Operation operation = running.get(staff.getUniqueId());
if (operation == null)
{
staff.sendMessage(Component.text("You have no rollback running.", NamedTextColor.GRAY));
return;
}
operation.cancel();
staff.sendMessage(Component.text("Stopping after the chunk it writes now.", NamedTextColor.YELLOW));
}
/** Step 3: say what happened, and offer a force when the rollback kept positions. */
private void report(Player staff, Result result)
{
switch (result.outcome())
{
case FINISHED -> staff.sendMessage(Component.text("Rolled back " + result.applied() + " edits in "
+ result.duration().toSeconds() + "s.", NamedTextColor.GREEN));
case CANCELLED -> staff.sendMessage(Component.text("Cancelled after " + result.applied() + " edits.",
NamedTextColor.YELLOW));
case FAILED ->
{
staff.sendMessage(Component.text(advice(result.failure().orElseThrow()), NamedTextColor.RED));
return;
}
}
if (result.left() > 0)
{
staff.sendMessage(Component.text("Left " + result.left() + " blocks that changed with no edit to say why.",
NamedTextColor.GRAY));
}
result.incomplete().ifPresent(what ->
staff.sendMessage(Component.text("Could not write: " + what, NamedTextColor.GRAY)));
// Present only for a rollback that finished and kept positions, never after a cancel.
result.forceKept().ifPresent(force -> offerForce(staff, result.kept(), force));
}
/** The estimate of a force names the kept positions and their chunks, from the counts of the rollback. */
private void offerForce(Player staff, long kept, OperationRequest force)
{
force.estimate().whenComplete((estimate, error) ->
{
if (error != null)
{
refused(staff, "Oasis could not size the force", error);
return;
}
toForce.put(staff.getUniqueId(), new Pending(force, Instant.now()));
staff.sendMessage(Component.text("Kept " + kept + " blocks in " + estimate.chunks()
+ " chunks that other players changed since. ", NamedTextColor.GOLD)
.append(button("[Force]", "/griefrevert force", NamedTextColor.RED)));
});
}
/** An estimate fails with an OasisException that names the reason. */
private void refused(Player staff, String what, Throwable error)
{
if (error instanceof OasisException failed)
{
staff.sendMessage(Component.text(what + ": " + advice(failed.failure()), NamedTextColor.RED));
return;
}
staff.sendMessage(Component.text(what + ". The server log has the details.", NamedTextColor.RED));
getLogger().log(Level.WARNING, what, error);
}
private static String advice(Failure failure)
{
return switch (failure.reason())
{
case BUSY, STARTING, OVERLOADED -> failure.message() + " Try again in a moment.";
case MOVING -> failure.message() + " Try again when the move ends.";
case STORE, PASS -> failure.message() + " The server log has the details.";
default -> failure.message();
};
}
private static String label(Progress progress)
{
return switch (progress.phase())
{
case PLANNING -> "Planning the rollback";
case APPLYING -> "Rolling back: " + progress.doneChunks() + " of " + progress.chunks() + " chunks";
case SWEEPING -> "Sweeping up lava, water and fire";
case FINISHED -> "Done";
};
}
private static Component button(String text, String command, NamedTextColor color)
{
return Component.text(text, color).clickEvent(ClickEvent.runCommand(command));
}
}

The flow has three steps:

  1. /griefrevert Steve 3 builds a request and calls estimate(). Nothing changes yet. The staff member reads the size and clicks Confirm.
  2. /griefrevert confirm starts the request it kept. The within window counts back from that start, not from the estimate.
  3. When the result arrives, the plugin reports it. If the rollback finished and kept positions, it sizes forceKept() with estimate() and offers Force, which starts that request through the same start method.

The estimate futures come straight from Oasis, so a failed one hands whenComplete the OasisException itself, with no CompletionException around it.

The request names the staff member with requestedBy(staff), so lookups show who asked, and the staff member can reverse the rollback with /oasis undo.