Skip to content

Repository files navigation

TaskFlow

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.

What it does

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.

Deadlock prevention

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.

Requirements

  • Paper 26.2 or newer
  • Java 25

Build

./gradlew build

The jar lands in build/libs/TaskFlow-1.0.jar.

Using it from another plugin

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.

Priorities

CRITICAL, HIGH, NORMAL, LOW, IDLE. Higher priority always drains first, and tasks of the same priority run in the order they arrived.

Backpressure policies

  • REJECT the submit fails and the Later settles as rejected
  • DROP_NEWEST the incoming task is cancelled quietly
  • DROP_LOWEST evicts the least important queued task to make room, and refuses if the newcomer is itself the least important thing there
  • CALLER_RUNS runs the task inline on the calling thread, refused on the server thread
  • BLOCK waits up to block-ms for room, refused on the server thread

Commands

Root command is /taskflow, aliased to /tf.

  • /taskflow status lanes, main queue depth, budget spend and overruns, watchdog alarms
  • /taskflow lanes one 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 reset clear the counters
  • /taskflow reload reload the config and rebuild the lanes

Permissions

  • taskflow.use read lane stats, op by default
  • taskflow.admin benchmark, reset and reload, op by default

Configuration

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: 2000

Add 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.

Tests

./gradlew test

53 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.

Notes

  • 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.
  • Later never exposes a blocking get that is safe to call anywhere. That is deliberate. await is 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.

License

MIT. See LICENSE.

Written by Am4er.

About

Concurrency framework for Paper. Priority lanes, backpressure policies, cancellation, timeouts and deadlock guards.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages