Error handling#

This guide covers how Planning Engine reports failures in Java: which problems throw, which come back as data inside a normal result, and how to get a live project working again after each kind.

How failures surface#

Planning Engine reports failures with unchecked exceptions. No method declares throws, so the compiler won't remind you to catch anything. Every call is synchronous, so the call itself throws the failure. There's no CompletableFuture to inspect.

Every engine exception extends PlenException, an abstract subclass of RuntimeException. It has two concrete subclasses:

Exception Meaning Project state after the exception
InputException The data or options you passed are invalid. Unchanged. The project keeps the previous accepted state and its cached result.
ProjectException 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.

The public code field of a ProjectException is a ProjectError value:

Code getMessage() When it occurs
NO_BASELINE no-baseline preview, loadProfile, evaluate, suggestAssignments or roster on a project that has no solved baseline, including right after apply
STALE_BASELINE stale-baseline A baselineId in the options or in SchedulingChanges doesn't name the current baseline
NEEDS_RESYNC needs-resync solve(null, โ€ฆ), apply or level after an apply rejected an operation, until a full solve(data, โ€ฆ) succeeds

Most of what goes wrong in a plan isn't an exception at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected apply operations come back inside the result. An exception means the call itself couldn't run.

Two other parts of the library have their own exception types. SkillLayer.slots throws MatchException, which isn't a PlenException. The JSON API returns error envelopes instead of throwing. A few JDK exceptions, mostly NullPointerException, cover misuse of the types themselves. They're listed under JDK exceptions.

Catching errors#

catch (PlenException e) covers every engine error. Catch the subclasses when you need to tell them apart:

import org.dhtmlx.plen.*;

import java.util.List;

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

        try {
            ScheduleResult result = Engine.schedule(data, new SolveOptions());
            System.out.println(result.updates.size() + " tasks moved");
        } catch (InputException e) {
            System.err.println("bad input at " + e.path + ": " + e.getMessage());
        } catch (ProjectException e) {
            System.err.println("wrong project state: " + e.code);
        }
    }
}

getMessage() holds different things on the two subclasses:

  • On an InputException it's only the description, such as invalid duration. The name of the bad field is in the public path field, so read both when you show the error to a user.
  • On a ProjectException it's the kebab-case code (no-baseline, stale-baseline, needs-resync). Branch on code, not on the text.

Input errors#

An InputException carries:

  • kind, an InputErrorKind. Calls on the typed API produce VALIDATION. PARSE comes only from JSON decoding: Decode, Wire and JsonProject.
  • getMessage(), a short description such as invalid duration.
  • path, the offending field in its JSON (wire) spelling: tasks[0].duration, assignments[1].id, calendars[0].worktime.days, scope.resources. It's null for parse errors and for refusals that concern the whole state, such as suggestAssignments on a cyclic project.

Paths use the snake_case wire names, not the Java field names. TaskData.constraintDate on the fourth task is reported as tasks[3].constraint_date.

With the default Validation.STRICT, solve(data, โ€ฆ) and Engine.schedule reject the input on the first problem they find. Strict validation rejects, among other things:

  • a leaf task with fewer than two of startDate, endDate and duration, or with all three when they don't agree
  • a dated constraint (SNET, FNLT, ...) without a constraintDate
  • duplicate task, link or calendar ids (Id.num(1) and Id.str("1") count as the same id)
  • a cycle in the parent hierarchy
  • a summary with startDate + duration (a summary takes both dates or neither)
  • a negative duration, or a priority outside 0..1000
  • a Worktime.days array that doesn't have exactly 7 entries, and calendars with no working time
  • maxIterations = 0, a non-finite lag, or a negative or non-finite defaultResourceCapacity

The full list per field is on the reference pages for ProjectData, TaskData and AssignmentData.

Reporting a validation error with its path#

import org.dhtmlx.plen.*;

import java.util.List;

public class ReportPath {
    public static void main(String[] args) throws Exception {
        var data = new ProjectData().withTasks(List.of(
            new TaskData(Id.num(1))
                .withStartDate(Moment.fromYmdHm(2026, 1, 5, 8, 0))
                .withDuration(-1.0)));

        try {
            ScheduleResult result = Engine.schedule(data, new SolveOptions());
            System.out.println(result.updates.size() + " tasks moved");
        } catch (InputException e) {
            // Prints "tasks[0].duration: invalid duration"
            String path = e.path != null ? e.path : "<input>";
            System.err.println(path + ": " + e.getMessage());
        }
    }
}

A rejected replacement keeps the old state#

On a live Project, a solve(data, โ€ฆ) that fails validation installs nothing. The project keeps its accepted baseline, and solve(null, โ€ฆ) still returns the result you had before:

import org.dhtmlx.plen.*;

import java.util.List;

public class RejectedReplacement {
    static TaskData leaf(int id, double duration) {
        return new TaskData(Id.num(id))
            .withStartDate(Moment.fromYmdHm(2026, 1, 5, 8, 0))
            .withDuration(duration);
    }

    public static void main(String[] args) throws Exception {
        var options = new SolveOptions();
        var project = new Project();
        ScheduleResult first = project.solve(new ProjectData().withTasks(List.of(leaf(1, 2.0))), options);

        try {
            project.solve(new ProjectData().withTasks(List.of(leaf(2, -1.0))), options);
        } catch (InputException e) {
            System.err.println("replacement rejected at " + e.path + ": " + e.getMessage());
        }

        // Still the first data set.
        ScheduleResult again = project.solve(null, options);
        System.out.println(again.equals(first)); // true
    }
}

Project errors#

A ProjectException tells you the call came at the wrong moment. Each code has one recovery:

Code Recovery
NO_BASELINE Call solve first. After apply, call solve(null, โ€ฆ) before preview, loadProfile, evaluate or suggestAssignments.
STALE_BASELINE The project was solved, edited or leveled since you read the token. Take baselineId from the latest result and retry.
NEEDS_RESYNC Send the full data again with solve(data, โ€ฆ). Nothing else is accepted until that call succeeds.

Telling the codes apart#

ProjectError is an enum, so a switch expression on e.code covers every code without a default branch:

import org.dhtmlx.plen.*;

public class TellCodesApart {
    static String previewOrExplain(Project project, SchedulingChanges changes, String baselineId) {
        try {
            ScheduleResult result = project.preview(changes, new PreviewOptions().withBaselineId(baselineId));
            return result.updates.size() + " tasks would move";
        } catch (ProjectException e) {
            return switch (e.code) {
                case NO_BASELINE -> "solve the project first";
                case STALE_BASELINE -> "the plan changed since you loaded it; refresh and try again";
                case NEEDS_RESYNC -> "reload the full project";
            };
        } catch (InputException e) {
            return "invalid change at " + e.path + ": " + e.getMessage();
        }
    }

    public static void main(String[] args) throws Exception {
        // An unsolved project: prints "solve the project first".
        System.out.println(previewOrExplain(new Project(), new SchedulingChanges(), null));
    }
}

preview checks both options.baselineId and changes.baselineId. Baseline tokens are opaque and unique to the process, so never store them across processes or compare them in tests.

Recovering from NEEDS_RESYNC#

A rejected operation in apply doesn't throw. It's listed in ApplyReport.rejected and sets needsResync. From then on, solve(null, โ€ฆ), apply and level throw a ProjectException with code == ProjectError.NEEDS_RESYNC, because your model and the project's model no longer agree. Resync with the full data you hold:

import org.dhtmlx.plen.*;

import java.util.List;

public class Resync {
    static ScheduleResult commit(Project project, SchedulingChanges changes, ProjectData sourceOfTruth) {
        var options = new SolveOptions();
        ApplyReport report = project.apply(changes);
        if (report.needsResync) {
            for (RejectedOperation rejected : report.rejected) {
                System.err.println("rejected " + rejected.path + ": " + rejected.message);
            }
            return project.solve(sourceOfTruth, options);
        }
        return project.solve(null, options);
    }

    public static void main(String[] args) throws Exception {
        var data = new ProjectData().withTasks(List.of(
            new TaskData(Id.num(1))
                .withStartDate(Moment.fromYmdHm(2026, 1, 5, 8, 0))
                .withDuration(2.0)));
        var project = new Project();
        project.solve(data, new SolveOptions());

        // Task 999 doesn't exist: the operation is rejected.
        var changes = new SchedulingChanges();
        changes.tasks.update.add(new TaskUpdate(Id.num(999)).withDuration(Patch.of(3.0)));

        ScheduleResult result = commit(project, changes, data);
        System.out.println("resynced, baseline " + result.baselineId);
    }
}

solve deep-copies the data it receives, so you can pass the same ProjectData object again. If the resync data itself fails validation, the InputException leaves the project in the NEEDS_RESYNC state. Fix the data and call solve(data, โ€ฆ) again. The recovery how-to covers this flow in an editor.

Results that are not errors#

These all come back in a normal return value. 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 Conflict.ConstraintViolation (a link and a hard constraint disagree, the constraint wins) and Conflict.CalendarNonWorking
ScheduleResult.notes Input the engine dropped (Note.DroppedLink, Note.DroppedTask, Note.DroppedAssignment) or fixed (Note.Repaired)
ScheduleResult.converged false when the planner didn't reach a fixed point within config.maxIterations. 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#

Conflict and Note are abstract classes with a closed set of nested subclasses, so read them with instanceof patterns:

import org.dhtmlx.plen.*;

import java.util.List;

public class CheckProblems {
    static LinkData link(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 start = Moment.fromYmdHm(2026, 1, 5, 8, 0);
        var data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.str("a")).withStartDate(start).withDuration(1.0),
                new TaskData(Id.str("b")).withStartDate(start).withDuration(1.0)))
            // a -> b -> a: a dependency cycle, reported as data.
            .withLinks(List.of(link("l1", "a", "b"), link("l2", "b", "a")));

        ScheduleResult result = Engine.schedule(data, new SolveOptions());
        for (Cycle cycle : result.cycles) {
            System.out.println("cycle through tasks " + cycle.tasks);
        }
        for (Conflict conflict : result.conflicts) {
            if (conflict instanceof Conflict.ConstraintViolation v) {
                System.out.println("task " + v.task + ": required " + v.required + ", got " + v.actual);
            }
        }
        if (!result.converged) {
            System.out.println("planner hit maxIterations");
        }
    }
}

Dependency cycles are accepted state. Cycles in the parent hierarchy are different: they're invalid input and throw an InputException.

Repair instead of reject#

Validation.STRICT suits data your own code produces. For data from a UI or an older store, Validation.REPAIR coerces it instead of throwing:

  • 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 pauses lists and assignment slice values are cleared

Each fix shows up as a Note.Repaired with a path and a message, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still throw an InputException.

import org.dhtmlx.plen.*;

import java.util.List;

public class RepairMode {
    public static void main(String[] args) throws Exception {
        TaskData first = new TaskData(Id.num(1))
            .withStartDate(Moment.fromYmdHm(2026, 1, 5, 8, 0))
            .withDuration(1.0);
        TaskData duplicate = first.deepClone().withDuration(2.0);

        var project = new Project(new ProjectOptions().withValidation(Validation.REPAIR));
        ScheduleResult result = project.solve(
            new ProjectData().withTasks(List.of(first, duplicate)), new SolveOptions());
        for (Note note : result.notes) {
            if (note instanceof Note.Repaired repaired) {
                // Prints "repaired tasks[1].id: ..."
                System.out.println("repaired " + repaired.path + ": " + repaired.message);
            }
        }
    }
}

The messy input how-to shows the repair flow end to end.

Errors from SkillLayer.slots#

SkillLayer.slots and SkillLayer.validatePool throw MatchException, an abstract RuntimeException with two nested subclasses. It isn't a PlenException, so catch (PlenException e) doesn't catch it.

Subclass Fields When
MatchException.Invalid path, detail A bad pool or requirement. path uses the same format as InputException: pool.workers[0].resource, requirements[2].count, requirements[1].assignments[0]. getMessage() is "<path>: <detail>".
MatchException.Project error (the ProjectException), code() The project has no clean solved baseline (NO_BASELINE). Call solve before slots, including after apply.

Java 21 also has java.lang.MatchException. With only import org.dhtmlx.plen.*;, the simple name MatchException is ambiguous and the code doesn't compile. Import the Planning Engine class by name:

import org.dhtmlx.plen.*;
import org.dhtmlx.plen.MatchException;

import java.util.List;

public class SlotErrors {
    public static void main(String[] args) throws Exception {
        var data = new ProjectData()
            .withTasks(List.of(new TaskData(Id.num(1))
                .withStartDate(Moment.fromYmdHm(2026, 1, 5, 8, 0))
                .withDuration(2.0)))
            .withResources(List.of(new ResourceData().withId(Id.str("dev1"))));
        var project = new Project();
        project.solve(data, new SolveOptions());

        var pool = new Pool().withWorkers(List.of(new Worker().withResource(Id.str("dev1"))));
        var requirement = new Requirement(Id.str("backend"), Id.num(1), List.of()).withCount(0);

        try {
            SkillLayer.slots(project, pool, List.of(requirement));
        } catch (MatchException.Invalid e) {
            // Prints "requirements[0].count: must be at least 1"
            System.err.println(e.path + ": " + e.detail);
        } catch (MatchException.Project e) {
            System.err.println("project state: " + e.code());
        }
    }
}

Handling slots and suggestions through one type#

toPlenException() converts either subclass into the matching engine exception. Invalid becomes an InputException with kind VALIDATION and the same path, and Project gives back the wrapped ProjectException. If you rethrow the converted exception, a method that calls both slots and suggestAssignments needs only one set of catch blocks:

import org.dhtmlx.plen.*;
import org.dhtmlx.plen.MatchException;

import java.util.List;

public class Staffing {
    static SuggestResult staff(Project project, Pool pool, List<Requirement> requirements) {
        try {
            Vec<Slot> slots = SkillLayer.slots(project, pool, requirements);
            return project.suggestAssignments(slots, new SuggestOptions());
        } catch (MatchException e) {
            throw e.toPlenException();
        }
    }

    public static void main(String[] args) throws Exception {
        var pool = new Pool().withWorkers(List.of(
            new Worker()
                .withResource(Id.str("dev1"))
                .withSkills(List.of(new Skill().withId(Id.str("java")).withValue(1.0)))));
        var requirements = List.of(new Requirement(
            Id.str("backend"), Id.num(1), List.of(SkillMatch.atLeast(Id.str("java"), 1.0))));

        try {
            SuggestResult result = staff(new Project(), pool, requirements);
            System.out.println(result.steps.size() + " steps");
        } catch (InputException e) {
            System.err.println(e.path + ": " + e.getMessage());
        } catch (ProjectException e) {
            // An unsolved project: prints "project state: no-baseline".
            System.err.println("project state: " + e.getMessage());
        }
    }
}

One slots failure isn't a MatchException. When the summed count of all requirements reaches Integer.MAX_VALUE, slots throws an InputException (kind VALIDATION, path null) before it allocates anything.

Errors in the JSON API#

JsonProject and the Wire calls don't throw engine errors. They catch every PlenException and return an error envelope in place of the result:

{"error":{"code":null,"kind":"validation","message":"invalid duration","path":"tasks[0].duration"},"ok":false}

The error object has four fields:

Field Value
kind parse (malformed JSON, a missing required field, a type mismatch), validation or project
message The exception's getMessage()
path The InputException path, or null
code no-baseline, stale-baseline or needs-resync for kind: "project", otherwise null

Wire.slots and JsonProject#slots convert a MatchException the same way, so the skill layer's errors arrive as validation or project envelopes too.

The one JSON call that throws is the JsonProject(String optionsJson) constructor. Invalid options throw a JsonProjectException, since there's no envelope to return:

import org.dhtmlx.plen.*;

public class JsonErrors {
    public static void main(String[] args) throws Exception {
        var project = new JsonProject();
        String reply = project.solve(
            "{\"tasks\":[{\"id\":1,\"start_date\":\"2026-01-05 08:00\",\"duration\":-1}]}", "{}");
        System.out.println(reply); // {"error":{...,"kind":"validation",...},"ok":false}

        try {
            new JsonProject("{\"validation\":\"lenient\"}");
        } catch (JsonProjectException e) {
            System.err.println(e.getErrorJson());                         // the error envelope
            System.err.println(e.getError().getClass().getSimpleName()); // InputException
        }
    }
}

getErrorJson() returns the same envelope the other calls would return, and getError() returns the PlenException behind it.

Only PlenException becomes an envelope. A JDK exception from a programming error, such as NullPointerException, still propagates out of Wire and JsonProject. Decode.fromJson is a typed call, so it throws its InputException (kind PARSE or VALIDATION) instead of returning an envelope. The JSON how-to shows a full endpoint.

JDK exceptions#

Outside the engine exceptions, the library throws standard JDK exceptions only for misuse of its types:

Exception When
NullPointerException A public method gets null for an argument that isn't documented as nullable. Id.str(null) throws it too, and so does a Patch.of(null) on a field that can't be cleared that way, when the patch is applied.
IllegalArgumentException Execution.parallel(threads) with threads <= 0, Moment.parse of an invalid date, Json.parse of malformed text, and Json.stringify of a non-finite number
IllegalStateException Patch#value() on a patch without a value, Id#key() (and equals / hashCode) of an infinite number, ScheduleResult#acceptedId() without a baseline, and Encode.text of a non-finite number
UnsupportedOperationException Encode.text of an object that has no wire encoder
OutOfMemoryError SkillLayer.slots with requirement counts that are huge but still below the slot limit

These point at a bug in the calling code, so you rarely catch them. Avoid them up front instead:

import org.dhtmlx.plen.*;

public class PatchSafe {
    public static void main(String[] args) throws Exception {
        TaskUpdate update = new TaskUpdate(Id.num(1)).withDuration(Patch.of(3.0));

        // tryGetValue never throws; update.startDate.value() would, because the field is missing.
        var start = new Ref<Moment>();
        if (update.startDate.tryGetValue(start)) {
            System.out.println("new start " + start.value);
        }
        var duration = new Ref<Double>();
        if (update.duration.tryGetValue(duration)) {
            System.out.println("new duration " + duration.value);
        }

        // Moment.fromYmdHm returns null for an impossible date instead of throwing.
        Moment bad = Moment.fromYmdHm(2026, 2, 30, 8, 0);
        System.out.println(bad == null ? "no such date" : bad.toString());
    }
}

Valid input doesn't throw anything else. One known bug is a hang, not an exception: level doesn't return when an assignment asks for more units than its resource can ever supply (for example value 1.25 on capacity 1). The same applies to suggestAssignments with level set.