Share a project between threads#

Use one solved project from several threads without corrupting its state: run what-if edits in parallel, or let many request threads edit the same plan.

A Project isn't synchronized, and neither is JsonProject. Every call can write internal state, even copy(), which marks the data as shared on both sides. Two patterns are safe:

  • Give each thread its own copy, made on the thread that owns the project.
  • Route every call on one shared project through a lock.

Prerequisites#

Steps#

Run what-ifs on per-thread copies#

  1. Make one copy per task on the thread that owns the project, before you hand any work to the pool. copy() is O(1): the copies share data until one of them is edited.

    List<Project> drafts = new ArrayList<>();
    for (int i = 0; i < options.length; i++) {
        drafts.add(project.copy());
    }
    
  2. Give each worker exactly one copy. The worker can edit and solve it freely. Its first edit deep-copies the data, so the other copies and the original don't change.

    Project draft = drafts.get(i);
    pool.submit(() -> {
        draft.apply(stretch("build", duration));
        return draft.solve(null, new SolveOptions());
    });
    
  3. Don't call anything on the original while the workers run. If you need the original, take the lock from the next section.

  4. Treat every ScheduleResult as read-only. A copy shares the cached result with its original until one of them is edited, so two threads can hold the same object. Call deepClone() before you change one.

Guard one shared project with a lock#

  1. Wrap the project in a class and take one lock for every call, reads included. solve(null, …) stores its cached result and copy() writes sharing flags, so no call counts as a pure read.

    static class SharedPlan {
        private final Project project = new Project();
    
        synchronized ScheduleResult load(ProjectData data) {
            return project.solve(data, new SolveOptions());
        }
    
        synchronized ScheduleResult edit(SchedulingChanges changes) {
            project.apply(changes);
            return project.solve(null, new SolveOptions());
        }
    
        synchronized Project snapshot() {
            return project.copy();
        }
    }
    
  2. Keep apply and the solve(null, …) that follows it in the same locked block. Otherwise another thread can apply its own edit in between, and the result covers both edits.

  3. Hand out snapshot() copies for long work such as level or suggestAssignments, so the lock isn't held while it runs.

The full example loads a two-task plan into a SharedPlan, takes three snapshots, and stretches build to a different length on each one in a thread pool:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.dhtmlx.plen.*;

public class ShareProject {
  // One project shared by many threads: every call goes through the lock.
  static class SharedPlan {
    private final Project project = new Project();

    synchronized ScheduleResult load(ProjectData data) {
      return project.solve(data, new SolveOptions());
    }

    synchronized ScheduleResult edit(SchedulingChanges changes) {
      project.apply(changes);
      return project.solve(null, new SolveOptions());
    }

    synchronized Project snapshot() {
      return project.copy();
    }
  }

  static SchedulingChanges stretch(String task, double duration) {
    var changes = new SchedulingChanges();
    changes.tasks.update.add(new TaskUpdate(Id.str(task)).withDuration(Patch.of(duration)));
    return changes;
  }

  public static void main(String[] args) throws Exception {
    var monday = Moment.fromYmdHm(2026, 8, 10, 0, 0);
    var data = new ProjectData()
        .withConfig(new SchedulingConfig().withUseCalendars(false))
        .withTasks(List.of(
            new TaskData(Id.str("design")).withStartDate(monday).withDuration(2.0),
            new TaskData(Id.str("build")).withStartDate(monday).withDuration(3.0)))
        .withLinks(List.of(new LinkData()
            .withId(Id.str("l1")).withSource(Id.str("design")).withTarget(Id.str("build"))));

    var plan = new SharedPlan();
    plan.load(data);

    double[] options = {4.0, 6.0, 8.0};
    List<Project> drafts = new ArrayList<>();
    for (int i = 0; i < options.length; i++) {
      drafts.add(plan.snapshot());
    }

    ExecutorService pool = Executors.newFixedThreadPool(options.length);
    try {
      List<Future<String>> results = new ArrayList<>();
      for (int i = 0; i < options.length; i++) {
        Project draft = drafts.get(i);
        double duration = options[i];
        results.add(pool.submit(() -> {
          draft.apply(stretch("build", duration));
          ScheduleResult result = draft.solve(null, new SolveOptions());
          Mutation build = result.updates.stream()
              .filter(u -> u.id.equals(Id.str("build")))
              .findFirst().orElseThrow();
          return "build = " + duration + " days: ends " + build.endDate;
        }));
      }
      for (Future<String> result : results) {
        System.out.println(result.get());
      }
    } finally {
      pool.shutdown();
    }

    ScheduleResult accepted = plan.edit(stretch("build", 4.0));
    System.out.println("shared project updates: " + accepted.updates.size());
  }
}

Result#

Each worker reports its own finish date for build, and the shared project is still at the loaded state until edit runs:

build = 4.0 days: ends 2026-08-16 00:00
build = 6.0 days: ends 2026-08-18 00:00
build = 8.0 days: ends 2026-08-20 00:00
shared project updates: 1

design ends on August 12, so build starts there in every copy. The last line comes from the shared project. Its baseline still has build at 3 days, which is why the 4-day edit reports one update.

To score many change sets in parallel, you don't need your own pool: evaluateWith(candidates, options, Execution.parallel(threads)) scores them on isolated copies and blocks until all are done. See Execution. To keep long calls off a request thread, see Call from async code.