A concurrency framework for Paper. Work goes into named lanes, each with its own thread pool, bounded priority queue and backpressure policy. Blocking the server thread is prevented rather than just discouraged.
Built for Minecraft 26.2, Java 25, Kotlin.
Lanes. A lane is a named pool with its own queue. main runs on the server thread and drains
against a per tick budget. Everything else runs on its own threads. Lanes are defined in the config,
so a plugin asking for io gets whatever pool the server owner sized.
Priority queues. Five levels from CRITICAL down to IDLE. The queue is an array of deques
indexed by priority, so offer, poll and eviction are all constant time and arrival order is kept
inside a level. No comparator, no sequence numbers, no heap.
Backpressure. Five policies per lane. When a queue fills you pick what gives: refuse the work, drop it quietly, evict something less important, run it on the calling thread, or block until there is room.
Async results. Later<T> settles exactly once through a compare and set. It supports map,
flatMap, recover, cancellation with hooks, and timeouts. Callbacks registered after it settles
fire immediately, and every waiter wakes when the value lands.
Deadlock prevention. Three layers, described below.
Metrics and benchmarks. Per lane counters, wait and run latency percentiles, and a benchmark command that reports throughput and p50 through p99.
This is the part worth reading. Three things stop the classic Bukkit freeze.
Awaiting on the server thread throws. Later.await() checks the calling thread and raises
MainThreadBlocked instead of parking the tick. If you want a value on the main thread, chain onto
it with map or onDone and let the callback come to you.
Awaiting your own lane throws. If a worker on lane io awaits a Later whose work is queued to
io, that worker can never run the thing it is waiting for. TaskFlow raises WouldDeadlock at the
call rather than hanging. Blocking backpressure has the same guard, since a lane cannot wait for
room in its own queue.
The main lane has a budget. It drains only until main.budget-micros is spent, then stops and
leaves the rest queued. A flood of main thread work costs you queue depth, not tps. /taskflow status shows how many overruns have happened.
On top of that a watchdog checks every two seconds for a lane with work queued and workers busy that
has settled nothing since the last sweep. If that holds for watchdog.stall-seconds it logs the
lane and dumps the stack of every one of its threads, which is usually enough to see what is stuck
on what.
- Paper
26.2or newer - Java 25
./gradlew buildThe jar lands in build/libs/TaskFlow-1.0.jar.
Put TaskFlow in your depend or softdepend, then reach it through the static handle.
val flow = TaskFlow.flow()Read a file off the server thread and apply the result back on it:
flow.submit("io", Priority.NORMAL) {
Files.readString(path)
}.map("main") { text ->
Bukkit.broadcast(Component.text(text))
}map takes the lane its body should run on, so the hop back to the main thread is part of the
chain rather than a nested scheduler call.
Failures travel down the chain instead of being thrown at you:
flow.submit("io") { risky() }
.recover { -1 }
.onValue { plugin.logger.info("got $it") }
.onError { plugin.logger.warning("gave up: ${it.message}") }Give a task a deadline:
val later = flow.submit("compute", Priority.HIGH) { expensive() }
flow.withTimeout(later, 500)Delay and repeat, both of which go through the same lanes:
flow.after(1000, "io", Priority.LOW) { cleanup() }
val job = flow.every(5000, "compute", Priority.IDLE) { rebuildCache() }
job.cancel()Cancel work that is still queued. A cancelled task is skipped by the worker rather than run and discarded:
val later = flow.submit("io") { slowThing() }
later.onCancel { closeHandle() }
later.cancel()If you genuinely need to block, do it from a lane that is not the one you are waiting on:
val value = flow.submit("io") { lookup() }.await(2000)That call throws MainThreadBlocked on the server thread and WouldDeadlock from inside the io
lane. Both are bugs caught at the call site rather than a frozen server.
CRITICAL, HIGH, NORMAL, LOW, IDLE. Higher priority always drains first, and tasks of the
same priority run in the order they arrived.
REJECTthe submit fails and theLatersettles as rejectedDROP_NEWESTthe incoming task is cancelled quietlyDROP_LOWESTevicts the least important queued task to make room, and refuses if the newcomer is itself the least important thing thereCALLER_RUNSruns the task inline on the calling thread, refused on the server threadBLOCKwaits up toblock-msfor room, refused on the server thread
Root command is /taskflow, aliased to /tf.
/taskflow statuslanes, main queue depth, budget spend and overruns, watchdog alarms/taskflow lanesone line per lane with busy workers, queue depth and totals/taskflow lane <name>full counters and latency percentiles for one lane/taskflow bench [lane] [tasks]throughput and latency benchmark/taskflow resetclear the counters/taskflow reloadreload the config and rebuild the lanes
taskflow.useread lane stats, op by defaulttaskflow.adminbenchmark, reset and reload, op by default
plugins/TaskFlow/config.yml. Values are clamped on load.
main:
queue-capacity: 512
budget-micros: 2000
backpressure: DROP_LOWEST
lanes:
io:
threads: 4
queue-capacity: 2048
backpressure: BLOCK
block-ms: 250
compute:
threads: 2
queue-capacity: 1024
backpressure: REJECT
block-ms: 0
watchdog:
enabled: true
stall-seconds: 10
dump-threads: true
shutdown:
drain-ms: 2000Add your own lanes under lanes and they exist on next reload. A lane called main in that block
is ignored, since the main lane is configured in its own section.
./gradlew test53 tests. The queue is covered for priority order, arrival order inside a level, capacity, the
eviction rules including the case where the newcomer is the least important, blocking offer and
poll, and a four producer race that checks nothing is lost or duplicated. Later is covered for
settle once under eight racing threads, callbacks running exactly once whether registered before or
after settling, every waiter waking, and the map, flatMap and recover paths. The lanes are covered
for all five backpressure policies, priority ordering through a real pool, cancelled tasks being
skipped, a failing task not killing its worker, and the main lane stopping on budget.
- The main lane has no threads of its own. It drains from the plugin tick task, which is why the budget is expressed per tick.
Laternever exposes a blocking get that is safe to call anywhere. That is deliberate.awaitis the escape hatch and it is guarded.- Lane worker threads are named
TaskFlow-<lane>-<n>, so they are easy to pick out in a thread dump or a profiler. - There are no comments in the source. Naming carries it.
MIT. See LICENSE.
Written by Am4er.