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
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>
98 lines
3.3 KiB
Python
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")
|