Timefold Server in Practice: From Planning Models to the Exposed Persistence Boundary

Application Design Starts Where the Quickstart Ends
Section titled “Application Design Starts Where the Quickstart Ends”An optimization quickstart teaches the essential loop quickly: define the planning entities, identify the values the Solver may change, and score candidate solutions with constraints. Once that loop runs, the algorithm works. A real service immediately raises a different set of questions:
- Where do we keep the problem data as it existed when optimization was requested?
- How can a client inspect a running job and the best solution found so far?
- Who approves a Solver result, and when does it become business data?
- Can the application find jobs and results again after a restart?
timefold-workshop provides useful material on both sides of this
boundary. The bed-allocation and school-timetabling modules model two different planning problems. The Exposed JDBC
and R2DBC examples verify how Timefold Score values can be stored in database columns.
Those examples do not form a complete persistence application by themselves. The quickstarts prove planning and Solver execution, while the Exposed examples prove Score round trips. Durable job state, problem snapshots, candidate solutions, approval, and business-data updates remain application responsibilities between those two endpoints.
Bed Allocation: Choose One Bed for the Entire Stay
Section titled “Bed Allocation: Choose One Bed for the Entire Stay”The planning entity in the bed allocation quickstart is
Stay.
The patient, arrival and departure dates, required equipment, and department are fixed. Only bed is a planning
variable. One stay receives one bed for its full duration.
@PlanningEntityclass Stay { var arrivalDate: LocalDate? = null var departureDate: LocalDate? = null
@PlanningVariable(allowsUnassigned = true) var bed: Bed? = null}Because allowsUnassigned = true, the Solver may leave bed as null when capacity is insufficient or no bed satisfies
the requirements. This does not hide failure. It makes an unassigned stay visible in the score and keeps candidate
solutions comparable even when a fully assigned solution is impossible.
BedPlan
builds rooms and beds from departments, then groups them with stays and a HardMediumSoftScore in one planning problem.
BedAllocationConstraintProvider
treats same-night bed conflicts, room gender restrictions, department age ranges, and required equipment as Hard
constraints. Unassigned nights are penalized at the Medium level. Preferences such as room capacity and department
priority are compared at the Soft level.
There is also a small inconsistency worth reviewing. preferredPatientEquipment() is placed in the Soft section, but
the current implementation penalizes it with HardMediumSoftScore.ofHard(50). Documentation based only on the method
name and section would say that preferred equipment is Soft, while the running code says otherwise. Constraint names,
comments, and score levels need to be reviewed together.
School Timetabling: Choose Both a Time and a Room
Section titled “School Timetabling: Choose Both a Time and a Room”The planning entity in the school timetabling quickstart is
Lesson.
Subject, teacher, and student group are fixed. The Solver changes both timeslot and room for each lesson.
@PlanningEntitydata class Lesson( val subject: String, val teacher: String, val studentGroup: String,) { @PlanningVariable var timeslot: Timeslot? = null
@PlanningVariable var room: Room? = null}Timetable
provides available timeslots and rooms as problem facts and stores the lessons with a HardSoftScore.
TimetableConstraintProvider
uses Hard constraints to prevent room, teacher, and student-group conflicts in the same timeslot. Soft constraints compare
teacher room stability, gaps between lessons, and subject variety within a student group’s day.
Unlike bed allocation, each planning entity needs two values. Changing the timeslot also changes which room combinations are possible. The number of planning variables is not the only difference; the shape of the search space changes too.
Why the Same Timefold APIs Use Different Score Structures
Section titled “Why the Same Timefold APIs Use Different Score Structures”
Choosing between HardSoftScore and HardMediumSoftScore is not about selecting the more sophisticated-looking type.
Score levels encode business priorities: a higher level is compared before every lower level. Bed allocation uses a
separate Medium level so that assigning a suitable bed to as many patients as possible takes precedence over ordinary
preferences. School timetabling first uses Hard constraints to eliminate conflicts, then compares the remaining quality
goals at the Soft level.
Timefold does not know which solution is better for a particular business. Developers express that judgment through the planning model and score calculation. Reading the official guides to modeling planning problems and scores and constraints together helps put the business boundary ahead of the annotations.
Separate Solver Execution from the API Request
Section titled “Separate Solver Execution from the API Request”The school timetabling
TimetableController
starts optimization asynchronously through SolverManager. POST /timetables does not hold the connection open until
a solution is ready. It returns a job ID, which the client uses to query the solution and status later.
val jobId = UUID.randomUUID().toString()jobIdToJob[jobId] = Job.ofTimetable(problem)
solverManager.solveBuilder() .withProblemId(jobId) .withProblemFinder { jobIdToJob[it.toString()]!!.timetable!! } .withBestSolutionEventConsumer { event -> jobIdToJob[jobId] = Job.ofTimetable(event.solution()!!) } .run()This shape demonstrates the important separation between a long-running Solver job and an HTTP request lifetime. The
current job and solution, however, live in an in-process ConcurrentHashMap. They disappear when the server restarts.
As the source comment notes, the map has no TTL and may keep growing in a long-running process. It also does not allow one
server instance to query a job created by another instance.
Replacing the quickstart map with a database is therefore not the whole design. The application still needs contracts
for job ID uniqueness, state transitions, retries, cancellation, snapshot versions, and intermediate-solution retention.
The Timefold documentation describes the SolverManager execution model in more detail under
library integration.
A Finished Solver Job Is Not Automatically a Successful Optimization
Section titled “A Finished Solver Job Is Not Automatically a Successful Optimization”When the Solver terminates, it returns the best solution found so far. That does not mean it proved the solution to be
globally optimal. A metaheuristic search usually does not know the optimal score in advance. If it stops because of a time
limit or a lack of recent improvement, it generally cannot prove that no better combination exists. The Timefold
documentation therefore frames the practical goal as finding the
best solution within the available time,
not proving optimality for every real-world planning problem.
For that reason, a job state such as COMPLETED should mean only that execution ended. The application needs a separate
decision before it treats the result as acceptable business data.
| Outcome | Meaning | Service response |
|---|---|---|
| No feasible solution | The best solution still violates at least one hard constraint | Expose the violated constraints and missing resources, then review the input, capacity, and model |
| Feasible, optimality unknown | Hard constraints are satisfied, but the search did not prove that a better solution is impossible | Compare the score with a business quality threshold and the time budget, then approve or run again |
| Execution failed | An exception occurred while loading the problem, calculating the Score, solving, or processing the result | Store the failed stage and cause, then classify whether retry is safe |
| Cancelled | An operator or the system stopped the search | Do not apply the last candidate automatically; evaluate whether it is eligible for approval |
The phrase “failed to find the optimal solution” can therefore describe two different situations. The Solver may have failed to find any feasible solution, or it may have found a feasible solution without proving optimality. The first usually points to missing resources or a conflicting model. The second is an operational decision about time and quality. Feasibility becomes harder when rules are promoted to hard constraints too broadly, planning value ranges are too narrow, or the required resources do not exist. Overconstrained planning, such as allowing unassigned stays and exposing them through a medium score in the bed allocation example, is one way to retain comparable candidates when demand exceeds capacity.
The school timetabling quickstart also keeps Solver exceptions visible. Its withExceptionHandler stores the error with
the job, and a later read surfaces it as a TimetableSolverException.
.withExceptionHandler { id, exception -> jobIdToJob[id.toString()] = Job.ofException(exception) log.error(exception) { "Solver failed for jobId: $id" }}A production service may additionally retain the failed stage, retry count, termination reason, and last observed Score. The existence of a last candidate must not turn the job into a success automatically. Check that execution ended, the Score is feasible, the result meets the business quality threshold, and the source-data version is still current. Only a candidate that passes those checks should move to approval.
What the Exposed Examples Actually Persist
Section titled “What the Exposed Examples Actually Persist”The Exposed modules in timefold-workshop verify Timefold-specific Score column types over both JDBC and R2DBC. For
example, the JDBC
HardSoftScoreTest
declares a hardSoftScore("hardsoft_score") column, inserts a Score, and checks that the same value is read back.
object T1 : IntIdTable() { val name = varchar("name", 255) val hardSoftScore = hardSoftScore("hardsoft_score")}
val id = T1.insertAndGetId { it[T1.hardSoftScore] = HardSoftScore.of(hard, soft)}
val loaded = T1.selectAll().where { T1.id eq id }.single()loaded[T1.hardSoftScore] shouldBeEqualTo expectedThe R2DBC
HardSoftScoreTest
checks the same round trip through coroutines and the R2DBC DSL. The JDBC examples also cover DAO entities, while the
R2DBC examples focus on the suspend-based DSL. That difference belongs to the application’s data-access model, not to
the Solver model.
The tests prove a precise contract: a HardSoftScore can be written to a database column and read back as the same Score.
They do not implement repositories for an entire Timetable or BedPlan, durable job state, multiple best-solution
versions, or approval records. Score persistence support must not be expanded into a claim that the examples provide
PlanningSolution persistence.
Reference Design for What a Production Service Still Needs to Store
Section titled “Reference Design for What a Production Service Still Needs to Store”
The current timefold-workshop does not implement the OptimizationJobService or the two Exposed stores shown here.
This reference design makes the missing application boundary between the quickstarts and Score-column tests concrete.
A production service should keep at least the following records distinct:
| Record | Why it is needed |
|---|---|
| Problem snapshot | Reproduce the business data and constraint settings used for a run |
| Job state | Query queued, running, completed, failed, and cancelled states with timestamps |
| Candidate solution and Score | Compare results and explain why one was selected |
| Approval record | Record who accepted which result and when |
| Business-apply history | Connect a candidate solution to the source-of-truth data it changed |
The most important boundary is that a Solver result is not automatically the business source of truth. Reservations, beds, classrooms, or other source data may change while the Solver is searching. Before applying a result, the service must compare the snapshot version with current data and rerun the necessary conflict checks. Only an approved solution should update business data inside a transaction, and a failure must not leave a partial update behind.
Keeping every intermediate solution is rarely necessary. A service may overwrite the latest candidate or retain periodic versions depending on whether operators need progress visibility and which evidence incident analysis requires. The key is not volume. It is the traceable relationship between the problem, the generated candidate, the approving actor, and the applied business change.
Testing and Review: Verify Constraints and Boundaries Separately
Section titled “Testing and Review: Verify Constraints and Boundaries Separately”Optimization tests are easier to interpret when they are split into two layers instead of relying on one full Solver run.
First, test each constraint with a small input. Pin facts such as whether a bed conflict is penalized once per overlapping night or whether a teacher conflict occurs only within the same timeslot. These tests verify the score-calculation contract, not the quality of the Solver search. They are especially useful for mismatches such as preferred equipment in the bed example, where the name, section, and actual score level disagree.
Second, test the application boundary as a state and data lifecycle:
- Can a completed job and its solution be queried after a server restart?
- Can the same job ID avoid duplicate execution?
- Do cancellation and failure retain an explicit state and cause?
- Is a solution from a stale snapshot prevented from overwriting current business data?
- Are approval and business application executed only once?
A constraint test asks, “Is this solution scored correctly?” A boundary test asks, “Can this candidate be retained and applied safely?” Combining those questions makes Solver defects and application-state defects unnecessarily difficult to separate.
Checklist for Turning a Demo into an Application Feature
Section titled “Checklist for Turning a Demo into an Application Feature”The following order works well when extending a quickstart into a production feature:
- State the business question in one sentence. Define what the Solver may change and what makes one result better.
- Separate planning entities from problem facts. Keep only changing values as planning variables and capture the rest in a snapshot.
- Agree on score levels in business language. Document and test what Hard, Medium, and Soft mean operationally.
- Separate the API request from Solver execution. Define the job ID and status contract before adding time limits and cancellation.
- Keep persistence units distinct. Do not treat Score columns, candidates, snapshots, job state, approval, and apply history as one object.
- Do not change the source of truth before approval. Store the result as a candidate and recheck it against current data.
- Choose JDBC or R2DBC as a data-access model. Keep the Solver model and Score semantics the same while matching the service’s transaction and concurrency model.
- Test restarts and duplicate execution. Treat failure, cancellation, retry, and stale-result application as seriously as the happy path.
Timefold searches planning spaces with many combinations and compares candidate solutions through scores. Exposed Score columns persist an important part of that result. The application still owns the lifecycle of jobs and solutions between those tools, as well as the decision to accept a candidate into business data. Drawing that boundary first makes the next production tasks concrete without growing quickstart code into an accidental architecture.
Comments
Leave a note or reaction with your GitHub account.