Error handling#
This guide covers how Planning Engine reports failures in Rust: which problems come back as an Err, which arrive as data inside a successful result, and how to recover a live project after each kind.
How failures surface#
Every fallible call returns Result<_, plen_core::Error>. The library has no async API, so there's no second failure path: each call is synchronous and either returns a value or an Err.
Error has two variants, and they answer different questions:
| Variant | Meaning | Project state after the error |
|---|---|---|
Error::Input(InputError) |
The data or options you passed are invalid. | Unchanged. The previous accepted state and its cached result are kept. |
Error::Project(ProjectError) |
The input may be fine, but the call doesn't fit the project's current state. | Unchanged. You fix it by calling the right method first. |
ProjectError has three variants:
| Variant | Display | When it occurs |
|---|---|---|
NoBaseline |
no-baseline |
preview, load_profile, evaluate, suggest_assignments or roster on a project that has no solved baseline, including right after apply |
StaleBaseline |
stale-baseline |
A baseline_id in the options or in SchedulingChanges doesn't name the current baseline |
NeedsResync |
needs-resync |
solve(None), apply or level after an apply rejected an operation, until a full solve(Some(data)) succeeds |
Most of what goes wrong in a plan isn't an error at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected apply operations come back inside Ok(...). An Err means the call itself couldn't run.
Error implements std::error::Error (through thiserror), so ? converts it into Box<dyn std::error::Error> or anyhow::Error without extra code. Panics covers the one place where the library can crash instead of returning an Err.
Input errors#
An InputError carries three fields:
kindis anInputErrorKind. Native calls produceValidation.Parsecomes only from JSON decoding in the separateplen-wirecrate.messageis a short description, such asinvalid durationorduplicate task id.pathpoints at the offending field, in the formtasks[0].duration,assignments[1].idorscope.resources. It'sNoneonly for wire decode errors.
With the default Validation::Strict, a solve(Some(data)) or schedule call rejects the input on the first problem it finds. Strict validation rejects, among other things:
- a leaf task with fewer than two of
start_date,end_dateandduration, or with all three when they don't agree - a dated constraint (
Snet,Fnlt, ...) without aconstraint_date - duplicate task, link or calendar ids (
1and"1"count as the same id) - an unknown
parent, or a cycle in theparenthierarchy - a summary with
start_date+duration(a summary takes both dates or neither) - a negative
duration, apriorityabove 1000, a negativeleveling_delay - an assignment to an unknown task or resource, or with
value: 0 week,monthoryearduration units, and calendars with no working time
The full list per field is on the reference pages for ProjectData, TaskData and AssignmentData.
Reporting a validation error with its path#
use ;
The Display text of an InputError (and of Error::Input) is only the message. Read path yourself when you need to point the user at the field.
A rejected replacement keeps the old state#
On a live Project, a solve(Some(data)) that fails validation installs nothing. The project keeps its accepted baseline, and solve(None) still returns the result you had before:
use ;
Project errors#
A ProjectError tells you the call came at the wrong moment. Each variant has one recovery:
| Variant | Recovery |
|---|---|
NoBaseline |
Call solve first. After apply, call solve(None) before preview, load_profile, evaluate or suggest_assignments. |
StaleBaseline |
The project was solved, edited or leveled since you read the token. Take baseline_id from the latest result and retry. |
NeedsResync |
Send the full data again with solve(Some(data)). Nothing else is accepted until that call succeeds. |
ProjectError is Copy and PartialEq, so you can match on it directly or compare it.
Telling the variants apart#
use ;
preview checks both options.baseline_id and changes.baseline_id. Baseline tokens are opaque and can differ between runs for the same data, so never store them across processes or compare them in tests.
Recovering from NeedsResync#
A rejected operation in apply isn't an Err. It's listed in ApplyReport::rejected and sets needs_resync. From then on, solve(None), apply and level fail with NeedsResync, because your model and the project's model no longer agree. Resync with the full data you hold:
use Arc;
use ;
If the resync data itself fails validation, the project stays in the NeedsResync state. Fix the data and call solve(Some(data)) again. The recovery how-to covers this flow in an editor.
Results that are not errors#
These all come back in Ok(...). Check them on every result you show to a user:
| Where | What it means |
|---|---|
ScheduleResult::cycles |
Dependency cycles. A cyclic solve returns unchanged dates and empty updates, and still installs a baseline. |
ScheduleResult::conflicts |
ConstraintViolation (a link and a hard constraint disagree, the constraint wins) and CalendarNonWorking |
ScheduleResult::notes |
Input the engine dropped (DroppedLink, DroppedTask, DroppedAssignment) or fixed (Repaired) |
ScheduleResult::converged |
false when the planner didn't reach a fixed point within config.max_iterations. The dates are still valid output. |
ApplyReport::rejected |
Operations that apply refused. The other operations in the batch were still applied. |
CandidateResult::error |
An evaluate candidate whose change set was rejected. That candidate has no metrics; the other candidates are scored normally. |
Checking a schedule for problems#
use ;
Dependency cycles are accepted state. Cycles in the parent hierarchy are different: they're invalid input and fail with Error::Input.
Repair instead of reject#
Validation::Strict suits data your own code produces. For data from a UI or an older dhtmlxGantt store, Validation::Repair applies gantt's coercions instead of failing:
- duplicate ids: the last record wins, at the first record's position
- missing dates or durations are derived
- in an inconsistent start/end/duration triple the dates win
- dated constraints without a date are cleared
- invalid
pauseslists and assignment slice values are cleared
Each fix shows up as a Note::Repaired { path, message }, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still return Error::Input.
use ;
The messy input how-to shows the repair flow end to end.
The skill layer's MatchError#
The plen-assign crate has its own error type. slots returns Result<Vec<Slot>, MatchError>, and MatchError has two variants:
Invalid { path, message }for a bad pool or requirement. The path uses the same format asInputError:pool.workers[0].resource,requirements[2].count,requirements[1].assignments[0].Displayprints"{path}: {message}".Project(ProjectError)when the project has no clean solved baseline (NoBaseline). Callsolvebeforeslots, including afterapply.
MatchError converts into plen_core::Error: Invalid becomes Error::Input with kind Validation and the same path, and Project becomes Error::Project. A function that returns plen_core::Error can call slots and suggest_assignments with ? and handle both through one type:
use ;
use ;
Panics#
Valid input never panics.