"""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")