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 Project at a time. A Project isn't synchronized. If several requests reach the same instance, see Share a project between threads.
  • An ExecutorService for engine work. A fixed pool of platform threads, sized to your cores, fits CPU-bound calls.

Steps#

  1. 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 = project.copy();
    
  2. Run the call on the executor:

    CompletableFuture<LevelingResult> pending =
        CompletableFuture.supplyAsync(() -> draft.level(new LevelingOptions()), engine);
    

    Exceptions such as InputException and ProjectException complete the future exceptionally. join() throws them wrapped in a CompletionException, and get() wraps them in an ExecutionException. Read the original from getCause().

  3. To stop waiting after a timeout, add orTimeout:

    CompletableFuture<LevelingResult> pending = CompletableFuture
        .supplyAsync(() -> draft.level(new LevelingOptions()), engine)
        .orTimeout(30, java.util.concurrent.TimeUnit.SECONDS);
    

    The timeout can't stop a call that has already started. The future completes with a TimeoutException and returns control to you, but the call keeps running on the executor thread until it finishes. cancel(true) on a CompletableFuture doesn't interrupt that thread either. That's why the call runs on draft. When you stop waiting, drop the copy. The original stays untouched.

  4. Commit the result. level has already accepted the leveled state on draft, so replace the original with it:

    project = draft;
    

    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.

    evaluate and suggestAssignments never modify the project, so there's nothing to swap. Pass the change set you pick to apply on the original.

The full example levels two tasks in the background and gives up after 30 seconds:

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeoutException;
import org.dhtmlx.plen.*;

public class CallFromAsyncCode {
  static TaskData task(String id, Moment start, double duration) {
    return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
  }

  static AssignmentData booking(String task, String resource) {
    return new AssignmentData()
        .withTask(Id.str(task))
        .withResource(Id.str(resource))
        .withValue(1.0);
  }

  public static void main(String[] args) throws Exception {
    // Monday, August 10, 2026, on the default calendar.
    var monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    var data = new ProjectData()
        .withTasks(List.of(task("A", monday, 2.0), task("B", monday, 2.0)))
        .withResources(List.of(new ResourceData().withId(Id.str("dev1"))))
        .withAssignments(List.of(booking("A", "dev1"), booking("B", "dev1")));

    var project = new Project();
    project.solve(data, new SolveOptions());

    Project draft = project.copy();
    try (ExecutorService engine = Executors.newFixedThreadPool(2)) {
      CompletableFuture<LevelingResult> pending = CompletableFuture
          .supplyAsync(() -> draft.level(new LevelingOptions()), engine)
          .orTimeout(30, java.util.concurrent.TimeUnit.SECONDS);
      try {
        LevelingResult leveled = pending.join();
        project = draft;
        for (Mutation update : leveled.schedule.updates) {
          if (update.startDate != null && update.endDate != null) {
            System.out.println(update.id + ": " + update.startDate + " .. " + update.endDate
                + ", delay " + update.levelingDelay);
          }
        }
      } catch (CompletionException e) {
        if (!(e.getCause() instanceof TimeoutException)) throw e;
        System.out.println("leveling timed out; the project is unchanged");
      }
    }
  }
}

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 = project.copy();
CompletableFuture<EvaluateResult> scores = CompletableFuture.supplyAsync(
    () -> copy.evaluateWith(candidates, new EvaluateOptions(), Execution.parallel(2)),
    engine);

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.