[Canonical page](<https://docs.dhtmlx.com/planning-engine/net/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 from .NET. It explains when to call the one-shot [`Engine.Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/engine-schedule.md>) and when to keep a [`Project`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/project.md>). It also covers what the solve options change, how forward and backward planning place tasks, and what "converged" means in a result.

## How scheduling works

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

- [`Engine.Schedule(data, options)`](<https://docs.dhtmlx.com/planning-engine/net/api/engine-schedule.md>) takes a [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>) snapshot, schedules it once and returns a [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/net/api/core/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/net/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 copies the snapshot into its own normalized model and validates it. Next, 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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/subtree.md>). 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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#updates>) holds one [`Mutation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/mutation.md>) per task whose dates changed, with only the changed fields set. Write them back into your own model. Set [`IncludeUnchanged`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md#includeunchanged>) to get every task's dates.
- **Problems are data, not exceptions.** Conflicts, cycles, dropped input and non-convergence come back inside `ScheduleResult`. A call throws [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) for invalid input and [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) for a call that doesn't fit the project's state. See [Error handling](<https://docs.dhtmlx.com/planning-engine/net/error-handling.md>).
- **Some tasks never move.** Tasks with `AutoScheduling = false`, unscheduled tasks and completed tasks ([`UseProgress`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#useprogress>) with `Progress == 1`) stay where they are. That's how you lock work in place. There are no cancellation events.
- **Calls are synchronous and CPU-only.** There's no I/O, no clock and no async API. See [Blocking calls](<https://docs.dhtmlx.com/planning-engine/net/scheduling/scheduling-a-project.md#blocking-calls>).

## One-shot scheduling

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

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 2.0 },
        new TaskData(2) { StartDate = monday, Duration = 3.0 },
    ],
    Links = [new LinkData { Id = 1, Source = 1, Target = 2 }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
    Console.WriteLine($"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, so it isn't reported. The dates skip the weekend because the default `"global"` calendar works Mon-Fri, 08:00-12:00 and 13:00-17:00.

`Engine.Schedule` never changes the `data` you pass in, and the result it returns is yours alone, so you can modify it freely. It throws only when validation rejects the snapshot, and the exception is always [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>).

## Solving a project

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

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 2.0 },
        new TaskData(2) { StartDate = monday, Duration = 3.0 },
    ],
    Links = [new LinkData { Id = 1, Source = 1, Target = 2 }],
};

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

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

// Nothing changed since: the cached result comes back.
var cached = project.Solve(null, options);
Console.WriteLine(ReferenceEquals(loaded, cached)); // True

// Edit, then reschedule.
var changes = new SchedulingChanges();
changes.Tasks.Update.Add(new TaskUpdate(1) { Duration = 4.0 });
project.Apply(changes);

var after = project.Solve(null, options);
foreach (var update in after.Updates)
    Console.WriteLine($"task {update.Id}: {update.StartDate} .. {update.EndDate}");
```

The edit makes task 1 four days long. Task 2 moves to `2026-08-14 08:00 .. 2026-08-19 08:00`, and task 1 is reported as well.

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

- `Solve(data, …)` validates the replacement data, installs it, schedules it and accepts the dates as the new baseline. Use it on the first load and to resync the whole project. The project works on its own deep copy, so later changes to your [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>) don't leak into it. If validation fails, `Solve` throws [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) and the previous state and cached result stay as they were.
- `Solve(null, …)` schedules the current state. After an [`Apply`](<https://docs.dhtmlx.com/planning-engine/net/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, the same object, without planning again.

The cache key is the whole [`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md>) value, compared by value. Calling `Solve(null, …)` with different options replans from the accepted dates. The data doesn't change, so [`BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#baselineid>) stays the same unless a task moves.

Treat every result as read-only. The project keeps it as its baseline, can return the same object from a later `Solve`, and shares it with its clones. Call [`DeepClone()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#deepclone>) before you modify one.

After an `Apply` rejects an operation, `Solve(null, …)` throws [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) with `Code == ProjectError.NeedsResync` until you send the full data again with `Solve(data, …)`. Editing is covered in [Editing a project](<https://docs.dhtmlx.com/planning-engine/net/live-projects/editing-a-project.md>).

## Solve options

[`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md>) controls what a solve schedules and what it reports. With the defaults it schedules the whole project and returns only the moves.

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 2.0 },
        new TaskData(2) { StartDate = monday, Duration = 3.0 },
        new TaskData(3) { StartDate = monday, Duration = 1.0 },
    ],
    Links = [new LinkData { Id = 1, Source = 2, Target = 3 }],
};
var project = new Project();

// Initial load: every task's dates, why each one sits where it does,
// plus the critical path and slack.
var options = new SolveOptions
{
    IncludeUnchanged = true,
    Reasons = true,
    Analytics = new AnalyticsOptions { CriticalPath = true, Slack = true },
};
var result = project.Solve(data, options);
foreach (var update in result.Updates)
    Console.WriteLine($"{update.Id} {update.Reason}");

// Later: reschedule only task 2 and what depends on it.
project.Solve(null, new SolveOptions { Subtree = new Subtree(2) });
```

- [`IncludeUnchanged`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md#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/net/how-to/load-all-task-dates.md>).
- [`Reasons`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md#reasons>) adds a [`Reason`](<https://docs.dhtmlx.com/planning-engine/net/api/core/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 a `switch` on the nested types.
- [`Analytics`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md#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/net/scheduling/critical-path-and-slack.md>).
- [`Subtree`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md#subtree>) limits the run to one task and what depends on it. [`new Subtree(id)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/subtree.md>) sets `Inclusive = true`, so the scope also takes in the task's predecessors. 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 throws [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) with `Path == "subtree.task"`, and the cached result is kept. See [Reschedule a branch](<https://docs.dhtmlx.com/planning-engine/net/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`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>) and [`Preview`](<https://docs.dhtmlx.com/planning-engine/net/api/project-preview.md>) don't inherit these options. `Level` solves with default options first, and `Preview` takes its own [`PreviewOptions`](<https://docs.dhtmlx.com/planning-engine/net/api/core/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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#schedulefromend>) to plan backward instead. Then each task ends as late as its successors allow, and the project is anchored at its end.

```csharp
using Plen;

static Moment Date(int day, int hour) =>
    Moment.FromYmdHm(2026, 8, day, hour, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = Date(10, 8), Duration = 2.0 },
        new TaskData(2) { StartDate = Date(12, 8), Duration = 3.0 },
    ],
    Links = [new LinkData { Id = 1, Source = 1, Target = 2 }],
};
data.Config.ScheduleFromEnd = true;
data.Config.GapBehavior = GapBehavior.Compress;
data.Config.ProjectEnd = Date(28, 17);

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
    Console.WriteLine($"task {update.Id}: {update.StartDate} .. {update.EndDate}");
```

Task 2 has no successor, so [`ProjectEnd`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#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 act only on tasks at the open ends of the graph:

- [`ProjectStart`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#projectstart>) (forward) applies to tasks without predecessors. With [`GapBehavior.Compress`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#gapbehavior-1>) 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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#applyconstraints>) is on.
- `ProjectEnd` (backward) caps tasks without successors, in either gap mode. Without it, such a task keeps its current end.

All of these settings live on [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md>).

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:

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

// "order" can happen any time before "install"; "build" sets the pace.
var data = new ProjectData
{
    Tasks =
    [
        new TaskData("build") { StartDate = monday, Duration = 4.0 },
        new TaskData("order") { StartDate = monday, Duration = 1.0, ConstraintType = ConstraintType.Alap },
        new TaskData("install") { StartDate = monday, Duration = 1.0 },
    ],
    Links =
    [
        new LinkData { Id = "l1", Source = "build", Target = "install" },
        new LinkData { Id = "l2", Source = "order", Target = "install" },
    ],
};
data.Config.ApplyConstraints = true;

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
    Console.WriteLine($"{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/net/api/project-level.md>) rejects a project with `ScheduleFromEnd = true` and throws [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) at `config.schedule_from_end`. Constraint types and summary behavior are covered in [Links, constraints and summaries](<https://docs.dhtmlx.com/planning-engine/net/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.

[`BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#baselineid>) is the one exception. Tokens are opaque and unique to the process, and they can differ between runs for the same data. Never compare them across runs or store them. Take the current one from the latest [`Solve`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) result.

The planner runs passes until the dates stop changing. [`result.Converged`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#converged>) tells you whether it got there within [`Config.MaxIterations`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#maxiterations>) passes:

- An unscoped run converges in two passes by design. The second pass reproduces the first.
- A scoped run ([`Subtree`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/subtree.md>)) can need more passes, because tasks outside the scope feed into it.
- `MaxIterations` defaults to 8 and must be at least 1. `0` throws [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>).

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = monday, Duration = 1.0 }],
};
data.Config.MaxIterations = 4;

var result = Engine.Schedule(data, new SolveOptions());
if (result.Cycles.Count > 0)
    Console.WriteLine($"dependency cycles: {result.Cycles.Count}");
else if (!result.Converged)
    Console.WriteLine("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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#updates>) is empty and [`Cycles`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#cycles>) lists each unique cycle. With `IncludeUnchanged`, `Updates` lists every scheduled task with its stored dates instead. For everything else a result carries, see [Reading results](<https://docs.dhtmlx.com/planning-engine/net/scheduling/reading-results.md>).

## Blocking calls

Every method runs on the calling thread and returns when the work is done. There are no `Async` methods and no `CancellationToken` parameters. In ASP.NET Core or another async host, move large solves and long calls such as [`Level`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>), [`Evaluate`](<https://docs.dhtmlx.com/planning-engine/net/api/project-evaluate.md>) and [`SuggestAssignments`](<https://docs.dhtmlx.com/planning-engine/net/api/project-suggest-assignments.md>) off the request thread with `Task.Run`.

A [`Project`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/project.md>) isn't thread-safe. Even read-only calls such as [`Clone()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/project.md#clone>) update its internal state, so let only one thread use a shared instance at a time.

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = monday, Duration = 2.0 }],
};

// One-shot: the snapshot belongs to this call alone.
var result = await Task.Run(() => Engine.Schedule(data, new SolveOptions()));

// A long-lived project shared by request handlers: one call at a time.
var project = new Project();
var gate = new object();
var loaded = await Task.Run(() =>
{
    lock (gate)
        return project.Solve(data, new SolveOptions());
});
```

[`Engine.Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/engine-schedule.md>) builds a fresh project on every call, so parallel one-shot calls need no lock. To work on one plan from several threads, give each thread its own `Clone()`, made on one thread first. See [Call from async code](<https://docs.dhtmlx.com/planning-engine/net/how-to/call-from-async-code.md>) and [Share a project between threads](<https://docs.dhtmlx.com/planning-engine/net/how-to/share-a-project-between-threads.md>).
