Call from async code#
Run a long scheduling call from an ASP.NET Core handler, a UI event or any other async method without blocking the calling thread.
Every Project method is synchronous and CPU-bound. There are no Async overloads and no CancellationToken parameters. Move the heavy calls (Level, Evaluate, SuggestAssignments on large projects) to the thread pool with Task.Run.
Prerequisites#
- A solved
Project:Solve(data, …)has run. - Only one thread uses that
Projectat a time. AProjectisn't thread-safe. If several requests reach the same instance, see Share a project between threads.
Steps#
-
Clone the project on the calling thread. The background call then works on its own copy, and the original stays usable while the call runs.
Clone()is cheap, because the copies share their data until one of them is edited:var draft = project.Clone(); -
Run the call on the thread pool and await it. Exceptions such as
InputExceptionandProjectExceptionsurface at theawait:var leveled = await Task.Run(() => draft.Level(new LevelingOptions())); -
To stop waiting after a timeout or when the client disconnects, pass a token to
Task.Runand toWaitAsync:using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var leveled = await Task.Run(() => draft.Level(new LevelingOptions()), cts.Token) .WaitAsync(cts.Token);The token can't stop a call that has already started.
WaitAsyncthrowsOperationCanceledExceptionand returns control to you, but the call keeps running on the pool thread until it finishes. That's why the call runs ondraft. When you stop waiting, drop the clone. The original stays untouched. -
Commit the result.
Levelhas already accepted the leveled state ondraft, 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 clone.
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:
using Plen;
// Monday, August 10, 2026, on the default calendar.
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks =
[
new TaskData("A") { StartDate = monday, Duration = 2.0 },
new TaskData("B") { StartDate = monday, Duration = 2.0 },
],
Resources = [new ResourceData { Id = "dev1" }],
Assignments =
[
new AssignmentData { Task = "A", Resource = "dev1", Value = 1.0 },
new AssignmentData { Task = "B", Resource = "dev1", Value = 1.0 },
],
};
var project = new Project();
project.Solve(data, new SolveOptions());
var draft = project.Clone();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
var leveled = await Task.Run(() => draft.Level(new LevelingOptions()), cts.Token)
.WaitAsync(cts.Token);
project = draft;
foreach (var update in leveled.Schedule.Updates)
{
Console.WriteLine($"{update.Id}: {update.StartDate} .. {update.EndDate}, delay {update.LevelingDelay}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("leveling timed out; the project is unchanged");
}
Score candidates on several cores#
EvaluateWith with Execution.Parallel() spreads candidates over worker threads, but it still blocks the caller until every candidate is scored. Wrap it in Task.Run too:
var copy = project.Clone();
var scores = await Task.Run(() =>
copy.EvaluateWith(candidates, new EvaluateOptions(), Execution.Parallel()));
Execution.Parallel() uses Environment.ProcessorCount threads. On a shared web server, pass a smaller count, such as Execution.Parallel(2), so one request doesn't take every core.
Work that outlives the request#
Task.Run still keeps the request open until the call returns. For a call that takes longer than a client should wait, queue the work to a hosted background worker (BackgroundService), return right away, and let the client fetch the result later. Follow the same rules in the worker: give it its own Clone(), made on the thread that owns the project.
Result#
The example prints the task that leveling moved. The thread that awaited it stays free while Level runs:
B: 2026-08-12 08:00 .. 2026-08-14 08:00, delay 2
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.