"""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) def _build_factory(seconds: int) -> SolverFactory: """One factory per solve so we can honour per-job timeout overrides. Cheap to construct — the Java side caches what it can across builds.""" return 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=seconds), ), ) ) def _solve_sync(problem: Timetable, seconds: int) -> Timetable: """Blocking solver call; runs in a worker thread.""" solver = _build_factory(seconds).build_solver() return solver.solve(problem) async def _fetch_solve_options(pool, solution_id: str) -> tuple[str | None, int]: """Read parent_solution_id + seconds_limit from tt_solution.""" async with pool.acquire() as conn: row = await conn.fetchrow(""" SELECT parent_solution_id::text, seconds_limit FROM tt_solution WHERE id = $1 """, solution_id) parent = row["parent_solution_id"] if row else None limit = row["seconds_limit"] if row and row["seconds_limit"] else settings.solver_seconds_limit return parent, int(limit) 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: parent_id, seconds = await _fetch_solve_options(pool, solution_id) await mark_running(pool, solution_id) problem = await build_problem(pool, user_id, parent_solution_id=parent_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, seconds) 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")