diff --git a/backend/fsutil.py b/backend/fsutil.py new file mode 100644 index 0000000..9c99f0d --- /dev/null +++ b/backend/fsutil.py @@ -0,0 +1,22 @@ +"""Atomare Datei-Writes: erst .tmp im selben Verzeichnis, dann os.replace. + +Ein Crash hinterlässt höchstens eine .tmp-Datei — nie eine halb geschriebene +Zieldatei. Die .tmp wird beim nächsten erfolgreichen Write überschrieben. +""" + +import json +import os +from pathlib import Path + + +def atomic_write_text(path: Path, text: str) -> None: + tmp = path.with_name(path.name + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + +def atomic_write_json(path: Path, obj, **dumps_kwargs) -> None: + atomic_write_text(path, json.dumps(obj, ensure_ascii=False, **dumps_kwargs)) diff --git a/backend/generator.py b/backend/generator.py index 470b32d..917e073 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -18,6 +18,7 @@ from config import ( MAX_CONCURRENT_GENERATIONS, ) from database import update_guide +from fsutil import atomic_write_json, atomic_write_text from paths import arbeit_dir, bausteine_path, guide_content_path, project_dir _semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS) @@ -580,10 +581,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = # Titel eindeutig machen und unsortiertes Inventar schreiben entries = _eindeutige_titel(entries) - final_path.write_text( - "\n".join(f"{i}. {t}" for i, t in entries.items()) + "\n", - encoding="utf-8", - ) + atomic_write_text(final_path, "\n".join(f"{i}. {t}" for i, t in entries.items()) + "\n") except Exception as e: log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic) _bausteine_errors[topic] = str(e)[:2000] @@ -937,10 +935,7 @@ async def _generate_onepager( return None if res is None: _log(topic, "OnePager-Fix ungültig — ursprüngliche Karten bleiben") - karten_path.write_text( - json.dumps({"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}}, ensure_ascii=False), - encoding="utf-8", - ) + atomic_write_json(karten_path, {"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}}) else: karten = res[0] @@ -1041,7 +1036,7 @@ async def _generate_sections( return None if res is None: _log(topic, "Auswahl-Fix ungültig — ursprüngliche Auswahl bleibt") - files["auswahl"].write_text(auswahl_json(), encoding="utf-8") + atomic_write_text(files["auswahl"], auswahl_json()) else: auswahl = res[0] @@ -1122,7 +1117,7 @@ async def _generate_sections( return None if res is None: _log(topic, "Gliederungs-Fix ungültig — ursprüngliche Gliederung bleibt") - files["gliederung"].write_text(gliederung_json(), encoding="utf-8") + atomic_write_text(files["gliederung"], gliederung_json()) else: plan = res[0] @@ -1310,10 +1305,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio if chapters is None or guide_id in _cancelled: return - content_path.write_text( - json.dumps({"topic": topic, "format": format_name, "chapters": chapters}, ensure_ascii=False, indent=1), - encoding="utf-8", - ) + atomic_write_json(content_path, {"topic": topic, "format": format_name, "chapters": chapters}, indent=1) now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)