Threading¶
The Rule¶
Database access and other blocking IO never run on the Paper main
thread. Anything touching the Bukkit API (inventories, entities, world
access) runs only on the main thread. The crossover between the two
goes exclusively through
TaskScheduler:
public interface TaskScheduler {
<T> CompletableFuture<T> supplyAsync(Supplier<T> task);
CompletableFuture<Void> runAsync(Runnable task);
void runOnMainThread(Runnable task);
void close();
}
Repositories and services never call Bukkit.getScheduler() directly -
always through TaskScheduler.
Why Virtual Threads¶
PaperTaskScheduler (the default implementation) uses
Executors.newVirtualThreadPerTaskExecutor() (virtual threads, stable
since Java 21, the bytecode level the project targets) for the background path.
Blocking JDBC calls are the textbook example virtual threads were built
for: every database call gets its own cheap thread instead of sharing a
small fixed pool - without any repository code having to do anything
async-specific beyond calling a blocking JDBC method inside a lambda.
The Way Back to the Main Thread¶
runOnMainThread uses Bukkit's own scheduler
(Bukkit.getScheduler().runTask). Every GUI update, every inventory
access happening from inside an async callback (e.g. "database result is
in, now set the item in the inventory") has to go back to the main thread
this way.
The Documented Exception¶
UniversalAdminPlugin#onEnable calls storage.migrations().runPending()
synchronously on the main thread - twice, not once, because migrations get
registered from two different places:
- Once in
bootstrapCore, right after the two core audit migrations are registered - beforeModuleManager.loadAll()/enableAll(). - A second time in
onEnable, right aftermoduleManager.enableAll()- because every module only registers its ownMigration(s) during its ownonEnable(see adding-module.md step 3), so necessarily after the first call. Without this second call, every module-owned table (player_profiles,punishments,server_maintenance_state,whitelist_entries,vanish_state, ...) would never get created - that was a real bug until this second call was added.MigrationRunner.runPending()is idempotent (seeMigrationRunnerTest), so the second call only applies whatever was newly registered since the first, never anything twice.
Both calls run before players can join. That's a deliberate exception:
- Paper offers no asynchronous
onEnablelifecycle hook. - Migrations run at startup, not during live operation.
- No module may rely on a database whose schema isn't current yet - so
every migration has to be finished before a player can join (not
necessarily before every single module's
onEnable, since a module only registers its own migration there in the first place).
This is not a template for other code. Any other blocking operation
belongs behind TaskScheduler.
No Bukkit.reload()¶
UniversalAdmin never triggers a global Bukkit reload and doesn't direct
users to use one to reset plugin state. A global reload bypasses the
plugin lifecycle in ways UniversalAdmin (and other plugins on the same
server) can't control - state in UniversalAdmin/ModuleManager would
become inconsistent with what Bukkit believes is loaded afterward. A
"reload" feature, if wanted, is its own explicitly implemented
onDisable/onEnable pair for UniversalAdmin itself, not a server-wide
reload.