[Canonical page](<https://docs.dhtmlx.com/planning-engine/java/scheduling/scheduling-a-project/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Scheduling a Project

This guide shows how to run the scheduler: when the one-shot [`Engine.schedule`](<https://docs.dhtmlx.com/planning-engine/java/api/engine-schedule.md>) is enough and when to keep a [`Project`](<https://docs.dhtmlx.com/planning-engine/java/api/project/project.md>). It also covers what the solve options change, how forward and backward planning place tasks, what "converged" means in a result, and how to run solves off a request thread.

## How scheduling works

There are two entry points, and they run the same planner:

- [`Engine.schedule(data, options)`](<https://docs.dhtmlx.com/planning-engine/java/api/engine-schedule.md>) takes a [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/java/api/data/project-data.md>) snapshot, schedules it once and returns a [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/java/api/results/schedule-result.md>). Nothing is kept after the call. It's a shortcut for `new Project().solve(data, options)`.
- [`Project#solve(data, options)`](<https://docs.dhtmlx.com/planning-engine/java/api/project-solve.md>) does the same work on a long-lived `Project`. The project keeps the scheduled dates as its **accepted baseline**, so later calls can edit, preview, level and evaluate against it.

Every solve goes through the same steps. The engine deep-copies the snapshot, imports it into its own normalized model and validates it. It builds the scheduling graph from links and summaries, checks for dependency cycles and works out the scope: the whole project, or one branch with `subtree`. Then the planner runs until the dates stop changing. The result lists only what moved.

A few rules shape what you get back:

- **Updates are sparse.** `result.updates` holds one [`Mutation`](<https://docs.dhtmlx.com/planning-engine/java/api/results/mutation.md>) per task whose dates changed, and only the changed fields are set; the others are `null`. Write them back into your own model. To get every task's dates, set `includeUnchanged`.
- **Problems are data, not exceptions.** Conflicts, cycles, dropped input and non-convergence come back inside `ScheduleResult`. A call throws only for invalid input ([`InputException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/input-exception.md>)) or for a call that doesn't fit the project's state ([`ProjectException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/project-exception.md>)). Both are unchecked. See [Error handling](<https://docs.dhtmlx.com/planning-engine/java/error-handling.md>).
- **Some tasks never move.** Tasks with `autoScheduling = false`, unscheduled tasks and completed tasks (`useProgress` with `progress == 1`) stay where they are. That's how you lock work in place.
- **Calls are synchronous and CPU-only.** There's no I/O, no clock and no `CompletableFuture` in the API. See [Blocking calls](<https://docs.dhtmlx.com/planning-engine/java/scheduling/scheduling-a-project.md#blocking-calls>).

## One-shot scheduling

Use [`Engine.schedule`](<https://docs.dhtmlx.com/planning-engine/java/api/engine-schedule.md>) in batch jobs, request handlers and scripts that schedule a snapshot and throw it away.

```java
import java.util.List;
import org.dhtmlx.plen.*;

public class OneShot {
  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);

    ProjectData data = new ProjectData()
        .withTasks(List.of(
            new TaskData(Id.num(1)).withStartDate(monday).withDuration(2.0),
            new TaskData(Id.num(2)).withStartDate(monday).withDuration(3.0)))
        .withLinks(List.of(
            new LinkData().withId(Id.num(1)).withSource(Id.num(1)).withTarget(Id.num(2))));

    ScheduleResult result = Engine.schedule(data, new SolveOptions());
    for (Mutation update : result.updates) {
      System.out.println("task " + update.id + ": " + update.startDate + " .. " + update.endDate);
    }
  }
}
```

Task 2 has to wait for task 1, so it's the only update: `task 2: 2026-08-12 08:00 .. 2026-08-17 08:00`. Task 1 is already where it belongs and isn't reported. The link uses the default type `FS` with no lag. The default `"global"` calendar works Mon-Fri, 08:00-12:00 and 13:00-17:00, which is why the dates skip the weekend.

`Engine.schedule` never changes the `data` you pass in. It throws [`InputException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/input-exception.md>) when validation rejects the snapshot, and `NullPointerException` for a `null` argument.

## Solving a project

Keep a [`Project`](<https://docs.dhtmlx.com/planning-engine/java/api/project/project.md>) when the plan lives on: an editor backend with one project per open document, or a service that applies edits over time.

```java
import java.util.List;
import org.dhtmlx.plen.*;

public class LiveProject {
  static TaskData task(int id, Moment start, double duration) {
    return new TaskData(Id.num(id)).withStartDate(start).withDuration(duration);
  }

  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    ProjectData data = new ProjectData()
        .withTasks(List.of(task(1, monday, 2.0), task(2, monday, 3.0)))
        .withLinks(List.of(
            new LinkData().withId(Id.num(1)).withSource(Id.num(1)).withTarget(Id.num(2))));

    Project project = new Project(new ProjectOptions());
    SolveOptions options = new SolveOptions();

    // Install the data, schedule it and accept the dates as the baseline.
    ScheduleResult loaded = project.solve(data, options);

    // Nothing changed since: the cached result comes back.
    ScheduleResult cached = project.solve(null, new SolveOptions());
    System.out.println("same object: " + (loaded == cached));

    // Edit, then reschedule.
    SchedulingChanges changes = new SchedulingChanges();
    changes.tasks.update.add(new TaskUpdate(Id.num(1)).withDuration(Patch.of(4.0)));
    project.apply(changes);

    ScheduleResult after = project.solve(null, options);
    for (Mutation update : after.updates) {
      System.out.println("task " + update.id + ": " + update.startDate + " .. " + update.endDate);
    }
  }
}
```

This prints `same object: true`, then `task 1: 2026-08-10 08:00 .. 2026-08-14 08:00` and `task 2: 2026-08-14 08:00 .. 2026-08-19 08:00`.

The two forms of [`solve`](<https://docs.dhtmlx.com/planning-engine/java/api/project-solve.md>) do different jobs:

- `solve(data, options)` deep-copies the replacement data, validates it, schedules it and accepts the dates as the new baseline. Use it on the first load and to resync the whole project. Later changes to your `ProjectData` object don't reach the project. If validation fails, you get an [`InputException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/input-exception.md>), and the previous state and cached result stay as they were.
- `solve(null, options)` schedules the current state. After an [`apply`](<https://docs.dhtmlx.com/planning-engine/java/api/project-apply.md>) it plans the edits. Tasks the edit touched (task 1 above) are reported too, even when the planner leaves them where the edit put them. On a clean project with equal options, it returns the cached result without planning again.

The cache key is the whole [`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/java/api/project/solve-options.md>), compared by value. That's why a fresh `new SolveOptions()` hits the cache in the example above. Calling `solve(null, …)` with different options replans from the accepted dates. That doesn't change the data, so `baselineId` stays the same unless a task moves.

The cached result is the same object every time, and copies of the project share it. Treat it as read-only, or call `deepClone()` before you change it. After a rejected `apply` operation, `solve(null, …)` throws [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/project-exception.md>) with `code == ProjectError.NEEDS_RESYNC` until you send the full data again with `solve(data, …)`. Editing is covered in [Data updates](<https://docs.dhtmlx.com/planning-engine/java/live-projects/editing-a-project.md>).

## Solve options

[`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/java/api/project/solve-options.md>) changes what a solve schedules and what it reports. The defaults schedule the whole project and return only the moves.

```java
import java.util.List;
import org.dhtmlx.plen.*;

public class Options {
  static TaskData task(int id, Moment start, double duration) {
    return new TaskData(Id.num(id)).withStartDate(start).withDuration(duration);
  }

  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    ProjectData data = new ProjectData()
        .withTasks(List.of(task(1, monday, 2.0), task(2, monday, 3.0), task(3, monday, 1.0)))
        .withLinks(List.of(
            new LinkData().withId(Id.num(1)).withSource(Id.num(2)).withTarget(Id.num(3))));
    Project project = new Project();

    // Initial load: every task's dates, why each one sits where it does,
    // plus the critical path and slack.
    SolveOptions options = new SolveOptions()
        .withIncludeUnchanged(true)
        .withReasons(true)
        .withAnalytics(new AnalyticsOptions().withCriticalPath(true).withSlack(true));
    ScheduleResult result = project.solve(data, options);
    for (Mutation update : result.updates) {
      System.out.println(update.id + " " + update.reason);
    }

    // Later: reschedule only task 2 and what depends on it.
    SolveOptions branch = new SolveOptions().withSubtree(new Subtree(Id.num(2)));
    project.solve(null, branch);
  }
}
```

The loop prints `1 Preferred{}`, `2 Preferred{}` and `3 Asap{linkId=1, sourceId=2}`.

- `includeUnchanged` also reports tasks that didn't move, each with `startDate`, `endDate` and `duration` set. The result alone is then the full normalized date list, which is what an editor needs on first load. Unscheduled tasks are never reported. See [Load all task dates](<https://docs.dhtmlx.com/planning-engine/java/how-to/load-all-task-dates.md>).
- `reasons` adds a [`Reason`](<https://docs.dhtmlx.com/planning-engine/java/api/results/reason.md>) to each update: pushed by a predecessor link (`Reason.Asap`), pulled by a successor (`Reason.Alap`), moved with its summary block (`Reason.SummaryTranslation`), placed by its constraint (`Reason.Constraint`) or kept at its preferred date (`Reason.Preferred`). Read it with `instanceof` patterns or a pattern `switch`.
- `analytics` asks for the critical path, slack, or both. Both are off by default. See [Critical path and slack](<https://docs.dhtmlx.com/planning-engine/java/scheduling/critical-path-and-slack.md>).
- `subtree` limits the run to one task and what depends on it. [`new Subtree(id)`](<https://docs.dhtmlx.com/planning-engine/java/api/project/subtree.md>) sets `inclusive = true`, so the root task is scheduled too. The subtree applies to this call only. A scoped solve accepts only its own moves, and a later unscoped solve picks up the rest. An unknown or unscheduled subtree task is an `InputException` at path `subtree.task`, and the cached result is kept. See [Reschedule a branch](<https://docs.dhtmlx.com/planning-engine/java/how-to/reschedule-a-branch.md>).

With `subtree` and `includeUnchanged` together, the result holds every task in the subtree (with their ancestors) plus the out-of-subtree tasks that changed or were touched by an edit.

`level` and `preview` don't inherit these options. [`level`](<https://docs.dhtmlx.com/planning-engine/java/api/project-level.md>) solves with defaults first, and [`preview`](<https://docs.dhtmlx.com/planning-engine/java/api/project-preview.md>) takes its own [`PreviewOptions`](<https://docs.dhtmlx.com/planning-engine/java/api/project/preview-options.md>).

## Forward and backward planning

By default the planner works forward: each task starts as early as its links allow. Set `config.scheduleFromEnd` to plan backward instead. Then each task ends as late as its successors allow, and the project is anchored at its end.

```java
import java.util.List;
import org.dhtmlx.plen.*;

public class Backward {
  static TaskData task(int id, Moment start, double duration) {
    return new TaskData(Id.num(id)).withStartDate(start).withDuration(duration);
  }

  public static void main(String[] args) throws Exception {
    ProjectData data = new ProjectData()
        .withTasks(List.of(
            task(1, Moment.fromYmdHm(2026, 8, 10, 8, 0), 2.0),
            task(2, Moment.fromYmdHm(2026, 8, 12, 8, 0), 3.0)))
        .withLinks(List.of(
            new LinkData().withId(Id.num(1)).withSource(Id.num(1)).withTarget(Id.num(2))));
    data.config
        .withScheduleFromEnd(true)
        .withGapBehavior(GapBehavior.COMPRESS)
        .withProjectEnd(Moment.fromYmdHm(2026, 8, 28, 17, 0));

    ScheduleResult result = Engine.schedule(data, new SolveOptions());
    for (Mutation update : result.updates) {
      System.out.println("task " + update.id + ": " + update.startDate + " .. " + update.endDate);
    }
  }
}
```

Task 2 has no successor, so `projectEnd` caps it and it ends at `2026-08-28 17:00`. Task 1 ends where task 2 starts, on `2026-08-25 17:00`.

The project bounds in [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/java/api/data/scheduling-config.md>) act only on tasks at the open ends of the graph:

- `projectStart` (forward) applies to tasks without predecessors. With [`GapBehavior.COMPRESS`](<https://docs.dhtmlx.com/planning-engine/java/api/data/scheduling-config.md#gapbehavior>) it pulls them onto the project start. With `GapBehavior.PRESERVE` it only raises tasks that start earlier and leaves later ones at their dates. For a task with predecessors, it's a hard floor only when `applyConstraints` is on.
- `projectEnd` (backward) caps tasks without successors, in either gap mode. Without it, such a task keeps its current end.

A task whose constraint points the other way gets a secondary pass in the opposite direction. In a forward project with `applyConstraints`, an `ALAP` task moves as late as its successors allow:

```java
import java.util.List;
import org.dhtmlx.plen.*;

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

  static LinkData fs(String id, String source, String target) {
    return new LinkData().withId(Id.str(id)).withSource(Id.str(source)).withTarget(Id.str(target));
  }

  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);

    // "order" can happen any time before "install"; "build" sets the pace.
    TaskData order = task("order", monday, 1.0).withConstraintType(ConstraintType.ALAP);

    ProjectData data = new ProjectData()
        .withTasks(List.of(task("build", monday, 4.0), order, task("install", monday, 1.0)))
        .withLinks(List.of(fs("l1", "build", "install"), fs("l2", "order", "install")));
    data.config.applyConstraints = true;

    ScheduleResult result = Engine.schedule(data, new SolveOptions());
    for (Mutation update : result.updates) {
      System.out.println(update.id + ": " + update.startDate + " .. " + update.endDate);
    }
  }
}
```

`install` waits for `build` and starts on `2026-08-14 08:00`. `order` moves from Monday to `2026-08-13 08:00`, so it ends right when `install` starts.

Leveling is forward-only. [`Project#level`](<https://docs.dhtmlx.com/planning-engine/java/api/project-level.md>) rejects a project with `scheduleFromEnd = true` with an `InputException` at path `config.schedule_from_end`. Constraint types and summary behavior are covered in [Links, constraints and summaries](<https://docs.dhtmlx.com/planning-engine/java/scheduling/links-constraints-summaries.md>).

## Determinism and convergence

The same input and options always give the same result. The engine reads no clock and uses no randomness. It iterates in input order and topological order, never in hash order. That makes results safe to cache, diff and compare in tests.

The one exception is [`baselineId`](<https://docs.dhtmlx.com/planning-engine/java/api/results/schedule-result.md#baselineid>). Baseline tokens are opaque and unique only within the process, so the same data can get a different token on another run. Don't store them or compare them across runs. Take the current one from the latest [`solve`](<https://docs.dhtmlx.com/planning-engine/java/api/project-solve.md>) result.

The planner runs passes until the dates stop changing. [`result.converged`](<https://docs.dhtmlx.com/planning-engine/java/api/results/schedule-result.md#converged>) tells you whether it reached that point within `config.maxIterations` passes:

- An unscoped run converges in two passes by design. The second pass reproduces the first.
- A scoped run (`subtree`) can need more passes, because tasks outside the scope feed into it.
- `maxIterations` is an `int`, defaults to 8 and must be at least 1. `0` is rejected with an `InputException`.

```java
import java.util.List;
import org.dhtmlx.plen.*;

public class Converge {
  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    ProjectData data = new ProjectData()
        .withTasks(List.of(new TaskData(Id.num(1)).withStartDate(monday).withDuration(1.0)));
    data.config.maxIterations = 4;

    ScheduleResult result = Engine.schedule(data, new SolveOptions());
    if (!result.cycles.isEmpty()) {
      System.out.println("dependency cycles: " + result.cycles.size());
    } else if (!result.converged) {
      System.out.println("planner stopped after maxIterations");
    }
  }
}
```

`converged == false` isn't an error, and the result is still valid.

A dependency cycle stops the run before planning. Dates stay unchanged, `updates` is empty and `cycles` lists each unique cycle. For everything else a result carries, see [Reading results](<https://docs.dhtmlx.com/planning-engine/java/scheduling/reading-results.md>).

## Blocking calls

Every call is synchronous and CPU-bound. [`solve`](<https://docs.dhtmlx.com/planning-engine/java/api/project-solve.md>) and [`Engine.schedule`](<https://docs.dhtmlx.com/planning-engine/java/api/engine-schedule.md>) return only when the schedule is done, and there's no cancellation. In a reactive server, or anywhere the calling thread must stay free, run large solves on your own executor:

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

public class Async {
  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    ProjectData data = new ProjectData()
        .withTasks(List.of(new TaskData(Id.num(1)).withStartDate(monday).withDuration(2.0)));

    ExecutorService workers = Executors.newFixedThreadPool(2);
    try {
      CompletableFuture<ScheduleResult> pending =
          CompletableFuture.supplyAsync(() -> Engine.schedule(data, new SolveOptions()), workers);
      ScheduleResult result = pending.get();
      System.out.println("converged: " + result.converged);
    } finally {
      workers.shutdown();
    }
  }
}
```

`Engine.schedule` builds a fresh project for each call, so concurrent calls on separate snapshots share no project state. A `Project` isn't synchronized, and even [`copy()`](<https://docs.dhtmlx.com/planning-engine/java/api/project/project.md#copy>) updates its internal sharing flags. Serialize access to one instance, or give each thread its own `copy()` made on one thread. See [Call from async code](<https://docs.dhtmlx.com/planning-engine/java/how-to/call-from-async-code.md>) and [Share a project between threads](<https://docs.dhtmlx.com/planning-engine/java/how-to/share-a-project-between-threads.md>).
