Cross-Server Messaging
Packages: dev.anchorlight.stonelib.messaging, dev.anchorlight.stonelib.messaging.proxy
On a network, a change made on one backend usually needs the others to notice: a player picked a new cosmetic on the hub, so survival should stop showing the old one. The message bus is the fast path for that. A backend publishes a small message, the Velocity proxy relays it to every other backend, and they react.
It is not storage. Delivery is best-effort by design, so keep the database as the source of truth and use a message only to prompt a re-read.
How it flows
player changes something on hub
-> hub writes the change to MySQL
-> hub publishes "selection-changed <uuid>" to the proxy
-> proxy relays it to every other backend with players online
-> each backend re-reads that player from MySQL
Backend: MessageBus
MessageBus bus = new MessageBus(this, "myplugin:sync", getConfig().getString("server-id"));
bus.subscribe("selection-changed", message ->
scheduler.runAsync(() -> selections.reload(UUID.fromString(message.payload()))));
bus.register();
// after a change has been saved
bus.publish("selection-changed", uuid.toString());
// onDisable
bus.unregister();
| Method | Meaning |
|---|---|
new MessageBus(Plugin, String channel, String serverId) | channel is namespace:name in lower case, and must match the proxy relay. serverId identifies this backend. A blank serverId throws. |
register() | Registers the incoming and outgoing channels. Call in onEnable. Calling twice does nothing. |
unregister() | Unregisters both. Call in onDisable. |
subscribe(String type, MessageHandler) | Adds a handler for one message type. Several handlers may share a type, and run in the order added. |
publish(String type, String payload) | Sends a message stamped with this server's id. Returns whether it was handed to a connection. |
publish(Message) | Sends a pre-built message. Its origin should be this server's id. |
serverId() | This backend's id. |
@FunctionalInterface
public interface MessageHandler { void handle(Message message); }
Server ids
Give each backend a unique id, normally read from its config.yml. The bus stamps it on every outgoing message and ignores incoming messages carrying its own id, so a change never bounces back to the server that made it.
Using the same name Velocity knows the server by keeps logs easy to follow, though the proxy itself skips the sender by its connection, not by this id.
Handlers
- A handler that throws is logged as
Handler for <type> failed, and the other handlers still run. - Handlers run on the thread the plugin message arrives on. Do database work through
runAsyncand world changes throughrunSync. - A message that cannot be decoded is discarded with the warning
Discarded a malformed message on <channel>.
Message
public record Message(String type, String origin, String payload) { ... }
| Part | Meaning |
|---|---|
type | What happened. Handlers subscribe by type. Required, and must not be blank. |
origin | The sending server's id. Required. |
payload | An opaque string. null becomes "". |
Anything richer than one string is your own encoding: a UUID, a comma-joined pair, a small JSON object.
| Method | Meaning |
|---|---|
encode() | The bytes sent over the channel. Throws IllegalArgumentException if they exceed 30,000 bytes. |
Message.decode(byte[]) | The message, or null for anything malformed, empty, or oversized. |
:::tip Send an id, not a document The 30,000 byte cap keeps messages well under the plugin message size limit. A message should say what changed, such as a player's UUID, and let the receiver read the details from the database. :::
Proxy: ProxyMessageRelay
On the Velocity proxy, one relay per channel:
@Plugin(id = "myplugin-proxy", name = "MyPlugin Proxy", version = "1.0.0")
public final class MyPluginProxy {
@Inject
public MyPluginProxy(ProxyServer proxy) {
new ProxyMessageRelay(proxy, java.util.logging.Logger.getLogger("MyPlugin"), "myplugin:sync")
.register(this);
}
}
The relay takes a java.util.logging.Logger. Velocity injects an SLF4J logger, so create a JUL logger by name as above.
| Method | Meaning |
|---|---|
new ProxyMessageRelay(ProxyServer, Logger, String channel) | The channel is lower-cased. Throws if it is not namespace:name. |
register(Object plugin) | Registers the channel and the event listener against your Velocity plugin instance. |
For every message on the channel, the relay:
- Consumes it, so server-to-server traffic never reaches a client.
- Drops it if it came from a client rather than a backend. A player cannot inject messages onto the bus.
- Forwards the exact bytes to every registered server except the one that sent it, skipping any server with no players connected.
It never reads the message body, so the proxy does not need updating when you add message types. It needs no configuration.
What the bus cannot promise
Plugin messages travel over a player's connection, so a message only goes where a player can carry it:
| Situation | What happens |
|---|---|
| The sending backend has no players online | publish returns false and nothing is sent. |
| A receiving backend has no players online | The proxy skips it. |
| A backend is restarting | It misses messages sent meanwhile. |
All three are harmless when the database is the source of truth: the change is already saved, and a backend with nobody online has no cache that matters. When a player joins it, read their data fresh.
Never put state in a message that is not also persisted.
Checklist
- Same channel string on every backend and on the proxy.
- A unique
server-idon every backend. Refuse to start without one. - Save to the database before publishing.
- Receivers re-read from the database; they do not apply the payload as data.
- Load fresh data on join, since messages sent while the server was empty were never delivered.