A P I
The MythicProxy API lets another plugin on a Paper backend use the connection that MythicProxy keeps to the proxy:
- Channels send bytes to another backend.
- Leases let one backend at a time hold a named lock.
- Blobs store named byte arrays on the proxy.
This page is for plugin developers. The API exists on the backends only, has no commands, permissions or events, and also works without MythicMobs Premium.
Dependency
The API is the mythicproxy-common module, io.lumine:mythicproxy-common:0.1.0, compiled for Java 21. The build publishes it to no remote repository, so install it into your local Maven repository by running gradlew :common:publishToMavenLocal in the MythicProxy source that matches the jar on your servers.
Do not shade it into your plugin jar. Use
compileOnly(Gradle) or theprovidedscope (Maven). A shaded copy is a different class from the one MythicProxy registers, so the service lookup returnsnull.
Maven
<dependency>
<groupId>io.lumine</groupId>
<artifactId>mythicproxy-common</artifactId>
<version>0.1.0</version>
<scope>provided</scope>
</dependency>
Gradle (Groovy)
repositories {
mavenLocal()
}
dependencies {
compileOnly 'io.lumine:mythicproxy-common:0.1.0'
}
Gradle (Kotlin)
repositories {
mavenLocal()
}
dependencies {
compileOnly("io.lumine:mythicproxy-common:0.1.0")
}
plugin.yml
Add depend: [MythicProxy]. If your plugin should also run without MythicProxy, use softdepend: [MythicProxy] instead, check getServer().getPluginManager().isPluginEnabled("MythicProxy") first, and keep every use of the API in a class that you create only after that check. Without MythicProxy the API classes are missing, and code that refers to them throws NoClassDefFoundError.
Getting the API
MythicProxy registers io.lumine.mythicproxy.common.api.MythicProxyNetwork as a Bukkit service when it enables. Look it up and check for null:
package com.example.myplugin;
import io.lumine.mythicproxy.common.api.MythicProxyNetwork;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
public final class MyPlugin extends JavaPlugin {
private MythicProxyNetwork network;
@Override
public void onEnable() {
network = Bukkit.getServicesManager().load(MythicProxyNetwork.class);
if (network == null) {
getLogger().warning("MythicProxy is not available");
}
}
}
loadreturnsnullwhen MythicProxy did not enable, for example because it found no secret or an entry inproxieshas a port that is not a number.- The service exists before the backend connects to a proxy.
isConnected()staysfalseuntil a link is up, and there is no connect or disconnect event. See How Backends Find the Proxy for when a backend connects.
| Method | Description |
|---|---|
String serverName() |
The name of this backend on the proxy: its server-name, or empty until a proxy has named it. Other backends send channel messages to this name, and leases are held under it |
boolean isConnected() |
true while at least one proxy link is up |
Use only the methods on this page and the records LeaseReply and LeaseState. The public constructor and the handle method are for MythicProxy itself.
Channels
A channel message goes from one backend to another through the proxy, which passes it on and stores nothing. Nothing confirms delivery and nothing is retried.
| Method | Description |
|---|---|
boolean send(String target, String channel, byte[] data) |
Sends data on channel to the backend named target. Returns false when nothing was sent, see Limits and Connection |
void onChannel(String channel, BiConsumer<String, byte[]> handler) |
Sets the handler for channel. It receives the name of the sending backend and the data |
void removeChannel(String channel) |
Removes the handler for channel |
targetmust match the receiving backend'sserverName()exactly, letter case included. There is no broadcast: callsendonce per backend.- A message for a backend that is not connected to the proxy is dropped, and
sendstill returnstrue. A backend never receives its own messages. - A backend has one handler per channel name. Calling
onChannelagain replaces the handler, also one that another plugin registered, so start your channel names with your plugin name. A message for a channel without a handler is ignored. - Handlers run on MythicProxy's connection thread, see Threads.
Channel Example
requestReload("survival", "weapons") makes the backend named survival log the request. The handler moves its work to the global region scheduler.
package com.example.myplugin;
import io.lumine.mythicproxy.common.api.MythicProxyNetwork;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
import java.nio.charset.StandardCharsets;
public final class ReloadRelay extends JavaPlugin {
private static final String CHANNEL = "myplugin:reload";
private MythicProxyNetwork network;
@Override
public void onEnable() {
network = Bukkit.getServicesManager().load(MythicProxyNetwork.class);
if (network == null) {
getLogger().warning("MythicProxy is not available");
return;
}
network.onChannel(CHANNEL, (origin, data) -> {
String shop = new String(data, StandardCharsets.UTF_8);
Bukkit.getGlobalRegionScheduler().run(this, task -> reload(origin, shop));
});
}
@Override
public void onDisable() {
if (network != null) {
network.removeChannel(CHANNEL);
}
}
public boolean requestReload(String server, String shop) {
return network != null && network.send(server, CHANNEL, shop.getBytes(StandardCharsets.UTF_8));
}
private void reload(String origin, String shop) {
getLogger().info("Reloading " + shop + " on request of " + origin);
}
}
Leases
A lease is a named lock that one backend holds until it expires or is released. Leases are stored in the proxy's database, so they survive a proxy restart, and a backend that disconnects or crashes keeps its leases until they expire.
| Method | Description |
|---|---|
boolean lease(String name, long ttlMillis, boolean release, long tag) |
With release set to false, takes or renews the lease name for ttlMillis milliseconds. With release set to true, releases it and ignores ttlMillis. The reply carries tag back unchanged. Returns false when nothing was sent |
void onLease(Consumer<LeaseReply> listener) |
Adds a listener for lease replies. Every listener receives every reply this backend gets, also replies meant for other plugins, so check state().name(), and tag() if you send several kinds of requests. A listener cannot be removed |
What the Proxy Does
| Request | Situation | Result |
|---|---|---|
| Take | You hold the lease, expired or not | You keep it with the same token and a new expiry |
| Take | Nobody holds it, or another backend holds it and its time has run out | You hold it, the token goes up by one, new expiry |
| Take | Another backend holds it and its time has not run out | Nothing changes |
| Release | You hold the lease | Nobody holds it, same token, expiresAt is 0 |
| Release | You do not hold it | Nothing changes |
- The new expiry is the proxy's clock plus
ttlMillis, so renewing does not add to the old expiry. AttlMillisof0or less expires at once. Do not passLong.MAX_VALUEfor a lease that never expires: the sum overflows and the lease counts as expired. - The holder is a backend, by server name, not a plugin or a player. Two plugins on one backend that use the same lease name share it, and a restarted backend with the same name still holds its leases.
- There is no way to read a lease without asking for it.
- The proxy answers only the backend that asked. Other backends are not told when a lease changes hands, and a backend that lost its lease finds out from the reply to its next request.
- When the proxy cannot process a request, for example because its database fails, it logs a warning and sends no reply. Use a timeout when you wait for one.
Reply Records
Both records are in io.lumine.mythicproxy.common.lease. Read a field with the accessor of the same name, for example reply.state().token().
LeaseReply(LeaseState state, long tag): state is the lease after the proxy handled your request, and tag is the value you passed.
LeaseState(String name, String holder, long token, long expiresAt):
name: the name of the lease.holder: the server name of the holder, ornullwhen the lease was never held or was released. An expired lease still names its last holder.token: the fencing token, see Fencing Token.expiresAt: when the lease expires, in epoch milliseconds on the proxy's clock.0after a release or when it was never held.
Fencing Token
The token tells a newer holder from an older one. Use it as the fence of a blob.
- The first time a lease is taken its token is
1. The token goes up by one each time a backend takes the lease while it is not the stored holder, which also happens after a release, even when the same backend takes it again. - Renewing keeps the token, also when your lease had expired and nobody else took it.
- The token never goes down, and every reply carries the current token, also a reply that refuses your request.
Expiry and Clocks
expiresAt comes from the proxy's clock, so do not compare it with your own clock. Instead, put the time you sent the request in tag: no other backend can take the lease before that time plus ttlMillis, so stop using the lease at that moment, measured on your own clock. To keep a lease, renew it from a repeating task that runs more often than ttlMillis. The Blob Example uses a lease.
Blobs
A blob is a named byte array stored in the proxy's database. Backends read it through the proxy, it survives a proxy restart, and it cannot be deleted.
| Method | Description |
|---|---|
boolean putBlob(String name, long fence, byte[] data) |
Stores data under name unless the fence already stored for that name is higher than fence. There is no reply. Returns false when nothing was sent |
boolean getBlob(String name) |
Asks for the blob name. The answer goes to the onBlob listeners. Returns false when nothing was sent |
void onBlob(BiConsumer<String, byte[]> listener) |
Adds a listener for blob replies. It receives the name and the data, or null when nothing is stored under that name. Every listener receives every reply this backend gets, so check the name. A listener cannot be removed |
- The first write for a name is always stored. A later write replaces it when its
fenceis greater than or equal to the stored fence, otherwise it is ignored without notice. The stored fence never goes down. - The proxy does not look at leases. To let only the current lease holder write, take the lease and use its token as the fence: after a newer holder has written with its higher token, writes with an older token are ignored.
- The proxy handles one backend's requests in order and answers in the same order, so a
getBlobsent after aputBlobsees that write. Use it to confirm a write. - As with leases, a request the proxy cannot process gets no reply.
Blob Example
save asks for the lease. When the reply says this backend holds it, the class writes the blob with the lease token as fence and releases the lease. When another backend holds it, nothing is written. loaded() returns the data of the last blob reply, null when nothing is stored or no reply has arrived.
package com.example.myplugin;
import io.lumine.mythicproxy.common.api.MythicProxyNetwork;
import io.lumine.mythicproxy.common.lease.LeaseReply;
import io.lumine.mythicproxy.common.lease.LeaseState;
import java.util.concurrent.atomic.AtomicReference;
public final class SharedSettings {
private static final String LEASE = "myplugin:settings-writer";
private static final String BLOB = "myplugin:settings";
private final MythicProxyNetwork network;
private final AtomicReference<byte[]> pending = new AtomicReference<>();
private volatile byte[] loaded;
public SharedSettings(MythicProxyNetwork network) {
this.network = network;
network.onLease(this::onLease);
network.onBlob(this::onBlob);
}
public boolean save(byte[] data) {
pending.set(data);
return network.lease(LEASE, 10_000L, false, 0L);
}
public boolean load() {
return network.getBlob(BLOB);
}
public byte[] loaded() {
return loaded;
}
private void onLease(LeaseReply reply) {
LeaseState lease = reply.state();
if (!lease.name().equals(LEASE) || !network.serverName().equals(lease.holder())) {
return;
}
byte[] data = pending.getAndSet(null);
if (data == null) {
return;
}
network.putBlob(BLOB, lease.token(), data);
network.lease(LEASE, 0L, true, 0L);
}
private void onBlob(String name, byte[] data) {
if (name.equals(BLOB)) {
loaded = data;
}
}
}
Threads
- Channel handlers and lease and blob listeners run on the thread that reads from the proxy connection, not on the server thread or a region thread. Hand work that uses the Bukkit API to a scheduler, as the Channel Example does.
- Keep them short: while one runs, no other message from that proxy is handled, variable updates included. With several proxies each has its own thread, so callbacks can run at the same time.
- When a callback throws, MythicProxy logs
Failed to handle <TYPE> from <origin>with the stack trace and keeps the connection. The listeners added after the one that threw do not receive that reply, so catch exceptions inside your listeners. - You can register handlers and call
send,lease,putBlobandgetBlobfrom any thread.
Limits and Connection
- No proxy connected:
send,lease,putBlobandgetBlobreturnfalseat once. Nothing is queued, retried or logged, unlike variable writes (see When a Proxy Is Down). trueis not delivery: it only means the message was handed to the connection. If the connection fails right after, the message is lost without notice.- Size: a message can be at most 1 MiB, see Limits. Data travels Base64 encoded, which makes it a third larger, so one call can carry about 786,000 bytes. A call over the limit returns
false, sends nothing and logs nothing. - Arguments are not checked:
nulldata makessendandputBlobthrow aNullPointerExceptionbefore anything is sent. Anullchannel or name is sent anyway, the call returnstrue, and the receiving side logs a warning and ignores it. - Names: channel, lease and blob names are shared by every plugin on every backend, so start them with your plugin name. Keep lease and blob names to 191 characters or fewer, the size of the name column with MySQL storage.
- Proxy replies: a backend whose name equals a proxy's
proxy-idignores that proxy's lease and blob replies, see Server Names.
Multiple Proxies
- Each call goes to the same proxy that receives the backend's variable changes, see Multiple Proxies. The reply comes from that proxy.
- Leases and blobs live in the database of the proxy that handled the request. With the default SQLite storage every proxy has its own file, so two proxies can give the same lease to two backends, and a blob written through one proxy is missing on the other. Point every proxy at the same MySQL database, see Storage. Each proxy judges lease expiry with its own clock.
- A proxy passes a channel message only to the backends connected to it, so connect every backend to every proxy.