Files
breakpilot-lehrer/timetable-solver-service/app/runner.py
T
Benjamin Admin d3f311a32e
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m31s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 21s
Wrap score-director config in ScoreDirectorFactoryConfig
Timefold's SolverConfig expects a typed config object, not a dict —
plain dicts hit AttributeError when the wrapper tries to materialise
the Java side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:33:34 +02:00

98 lines
3.3 KiB
Python

"""Solver job runner. One async entry point per solve.
Lifecycle:
1. mark_running -> tt_solution.status = 'running'
2. build_problem -> Timetable from DB
3. SolverFactory.buildSolver() -> Timefold solver
4. solver.solve(problem) -> completed Timetable
5. persist_solution or mark_infeasible based on hard_score
Errors at any step → mark_failed.
Long solves are CPU-bound. We run the solver in an executor so the FastAPI
event loop stays responsive for other requests.
"""
import asyncio
import logging
import traceback
from concurrent.futures import ThreadPoolExecutor
from timefold.solver import SolverFactory
from timefold.solver.config import (
SolverConfig,
ScoreDirectorFactoryConfig,
TerminationConfig,
Duration,
)
from .config import settings
from .constraints import define_constraints
from .db import get_pool
from .domain import Lesson, Timetable
from .repository import build_problem, mark_failed, mark_infeasible, mark_running, persist_solution
logger = logging.getLogger(__name__)
_executor = ThreadPoolExecutor(max_workers=2)
_solver_factory = SolverFactory.create(
SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints,
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=settings.solver_seconds_limit),
),
)
)
def _solve_sync(problem: Timetable) -> Timetable:
"""Blocking solver call; runs in a worker thread."""
solver = _solver_factory.build_solver()
return solver.solve(problem)
async def run_solve(solution_id: str, user_id: str) -> None:
"""Top-level async entry. Caller fires-and-forgets via BackgroundTasks."""
pool = await get_pool()
try:
await mark_running(pool, solution_id)
problem = await build_problem(pool, user_id)
if not problem.lessons:
await mark_failed(pool, solution_id,
"Keine Lessons — pruefe Stundentafel + Lehrauftraege.")
return
if not problem.timeslots:
await mark_failed(pool, solution_id,
"Kein Zeitraster definiert.")
return
if not problem.rooms:
await mark_failed(pool, solution_id,
"Keine Raeume definiert.")
return
loop = asyncio.get_running_loop()
solved: Timetable = await loop.run_in_executor(_executor, _solve_sync, problem)
score = solved.score
hard = score.hard_score() if score else 0
soft = score.soft_score() if score else 0
if hard < 0:
await mark_infeasible(pool, solution_id, hard, soft)
logger.info("Solution %s infeasible: hard=%d soft=%d", solution_id, hard, soft)
else:
await persist_solution(pool, solution_id, solved, hard, soft)
logger.info("Solution %s completed: hard=%d soft=%d", solution_id, hard, soft)
except Exception as exc:
logger.exception("Solver failed for %s", solution_id)
try:
await mark_failed(pool, solution_id, f"{exc.__class__.__name__}: {exc}\n{traceback.format_exc()[:1000]}")
except Exception:
logger.exception("Failed to even mark solution as failed")