Call from async code#
Run a long scheduling call from a Spring or Jakarta EE handler, a reactive pipeline or a UI event without blocking the calling thread.
Every Project method is synchronous and CPU-bound. There's no CompletableFuture variant and no cancellation parameter. Move the heavy calls (level, evaluate, suggestAssignments on large projects) to an executor with CompletableFuture.supplyAsync.
Prerequisites#
- A solved
Project:solve(data, options)has run. - Only one thread uses that
Projectat a time. AProjectisn't synchronized. If several requests reach the same instance, see Share a project between threads. - An
ExecutorServicefor engine work. A fixed pool of platform threads, sized to your cores, fits CPU-bound calls.
Steps#
-
Copy the project on the thread that owns it.
copy()updates sharing flags on the original too, so don't call it from the background thread. The background call works on the copy, and the original stays usable while the call runs.copy()is cheap, because copies share their data until one of them is edited.Project draft ; -
Run the call on the executor:
CompletableFuture pending ;Exceptions such as
InputExceptionandProjectExceptioncomplete the future exceptionally.join()throws them wrapped in aCompletionException, andget()wraps them in anExecutionException. Read the original fromgetCause(). -
To stop waiting after a timeout, add
orTimeout:CompletableFuture pending ;The timeout can't stop a call that has already started. The future completes with a
TimeoutExceptionand returns control to you, but the call keeps running on the executor thread until it finishes.cancel(true)on aCompletableFuturedoesn't interrupt that thread either. That's why the call runs ondraft. When you stop waiting, drop the copy. The original stays untouched. -
Commit the result.
levelhas already accepted the leveled state ondraft, so replace the original with it:project ;Replace the original only if nobody edited it while the call ran. Otherwise the result comes from an older state, so run the call again on a new copy.
evaluateandsuggestAssignmentsnever modify the project, so there's nothing to swap. Pass the change set you pick toapplyon the original.
The full example levels two tasks in the background and gives up after 30 seconds:
;
;
;
;
;
;
;
org.dhtmlx.plen has its own TimeUnit enum, so the example imports the java.util.concurrent classes by name and spells out java.util.concurrent.TimeUnit. Closing the ExecutorService at the end of the try block waits for running tasks. In a server, keep one long-lived executor instead of one per request.
Score candidates on several cores#
evaluateWith with Execution.parallel(...) spreads candidates over a thread pool of its own, but it still blocks the caller until every candidate is scored. Run it through supplyAsync too:
Project copy ;
CompletableFuture scores ;
Execution.parallel() uses Runtime.getRuntime().availableProcessors() threads. On a shared server, pass a smaller count, such as Execution.parallel(2), so one request doesn't take every core.
If the thread blocked in evaluateWith is interrupted, the call throws an IllegalStateException that wraps the InterruptedException, and it sets the thread's interrupt flag again. Candidates already handed to the pool still run to the end on its threads. No other call reacts to interrupts.
Virtual threads#
Virtual threads (Executors.newVirtualThreadPerTaskExecutor()) suit the side that waits: a request handler can block on join() without holding a platform thread. The engine call itself never blocks on I/O, so on a virtual thread it occupies a carrier thread for its whole run. Keep the engine work on a bounded platform pool and let the virtual thread wait for its future.
Work that outlives the request#
join() still keeps the request open until the call returns. For a call that takes longer than a client should wait, submit it to a background worker, return right away, and let the client fetch the result later. Follow the same rules in the worker: give it its own copy(), made on the thread that owns the project.
Result#
The example prints the task that leveling moved. The calling thread only waits in join(), while level runs on the executor:
B: 2026-08-12 08:00 .. 2026-08-14 08:00, delay 2.0
If leveling takes longer than 30 seconds, the example prints leveling timed out; the project is unchanged, and project keeps its unleveled baseline. For the leveling flow itself, see Preview leveling. For the exceptions each call throws, see Error handling.