Per-key ordered task execution for Java thread pools — sequential guarantees without global locking, plus safe nested synchronous calls that avoid deadlocks.
- Ordered execution per key — tasks sharing the same key run one after another, in submission order.
- Concurrent across keys — different keys use the shared thread pool in parallel.
- Deadlock-safe nesting —
supply/executeSyncdetect re-entrant calls on the same key and run inline instead of blocking. - Distributed tracing — built-in trace IDs and parent/child spans for ordered call chains.
- Low overhead — lock-free hot path during task execution; per-key queues use Caffeine with access-based expiry.
- Java 21+
<dependency>
<groupId>io.github.jinganix.peashooter</groupId>
<artifactId>peashooter</artifactId>
<version>0.0.9</version>
</dependency>implementation 'io.github.jinganix.peashooter:peashooter:0.0.9'implementation("io.github.jinganix.peashooter:peashooter:0.0.9")ExecutorService pool = Executors.newFixedThreadPool(8);
OrderedTraceExecutor executor = new OrderedTraceExecutor(pool);
List<String> values = new ArrayList<>();
executor.executeAsync("user-1", () -> values.add("1"));
executor.supply("user-1", () -> { values.add("2"); return null; });
executor.executeAsync("user-1", () -> values.add("3"));
executor.executeSync("user-1", () -> values.add("4"));
// Submission order is preserved: [1, 2, 3, 4]
System.out.println("Values: [" + String.join(", ", values) + "]");OrderedTraceExecutor maintains one TaskQueue per key. Submitters take a short lock to enqueue work; the worker runs tasks for that key strictly in FIFO order.
Suppose three tasks are submitted from different threads in this code order:
executor.executeAsync("foo", task1); // L1
executor.executeAsync("foo", task2); // L2
executor.executeAsync("foo", task3); // L3Regardless of which thread wins the race to enqueue, execution for key "foo" is always task1 → task2 → task3. Later tasks never start until the previous one finishes.
Different keys are independent — "foo" and "bar" can run at the same time on the pool.
Nested synchronous calls on different keys are scheduled through the same mechanism. The inner supply runs on the correct queue without holding locks across keys:
int value =
executor.supply(
"foo",
() ->
executor.supply(
"bar", () -> executor.supply("foo", () -> 1)));
// value == 1Re-entrant synchronous calls on the same key are detected via the current trace span and execute inline, so they cannot deadlock waiting on themselves.
| Method | Description |
|---|---|
executeAsync(key, task) |
Enqueue task for key; returns immediately. |
executeSync(key, task) |
Run task on the queue for key and block until it completes (default timeout 10 seconds). |
executeSync(keys, task) |
Acquire multiple keys in iteration order, then run task. |
supply(key, supplier) |
Like executeSync, but returns the supplier result. |
supply(keys, supplier) |
Multi-key variant of supply. |
setTimeout(timeout, unit) |
Configure timeout for synchronous waits. |
getTracer() |
Access the tracer for custom span integration. |
Advanced wiring is available via constructors that accept a custom TaskQueueProvider, ExecutorSelector, and Tracer.
Measured on Apple M1 Pro (10 cores, 16 GB RAM). Run locally with:
./gradlew :lib:test --tests "*Benchmark*"TaskQueue (source)
5,000,000 counter increments, single-threaded queue drain:
| Implementation | Time |
|---|---|
TaskQueue |
620 ms |
synchronized |
2336 ms |
ReentrantLock |
2412 ms |
Redis lockable queue (source)
500 tasks (requires Redis; see test setup):
| Implementation | Time |
|---|---|
RedisLockableTaskQueue |
84 ms |
| Per-task Redis lock | 1268 ms |
See CONTRIBUTING.md for how to report issues, run tests, and submit changes.
./gradlew build