feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)
Datenverlust behoben: - Nach hartem Stopp blieb das Original in working/ liegen und wurde nie wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht. - TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf. Config-Drift sichtbar gemacht: - Neues --check-config (Exit 0 sauber / 1 Warnungen / 2 Fehler), das update.sh vor dem Neustart ueber alle Instanz-Configs laufen laesst. - Warnungen fuer [ocr].timeout >= 900 (seit 0.4.0 pro SEITE) und gesetztes pdfa_level, beim Dienststart wie im Check. - Unbekannte Config-Keys werden nicht mehr still verworfen, sondern genannt. Updater feldtauglich: - venv-Health-Check erkennt toten Symlink UND Versions-Drift gegen das System-Python; --rebuild-venv als ausdruecklicher Weg nach einem Debian- Major-Upgrade. Neubau ist ganz-oder-gar-nicht mit Rollback. - apt-Pakete werden auch beim Update synchronisiert (Quelle: install.sh). - Instanz-Erfassung inkl. activating/failed, Verifikation prueft is-failed und NRestarts statt sleep 1 + is-active. - Backup enthaelt Configs, Unit, Drop-ins und pip-freeze.txt, liegt auf 0600 und rotiert auf 5; schlaegt es fehl, bricht das Update vorher ab. - ERR-Trap faehrt die vorher laufenden Instanzen wieder hoch. - lxc-compat.conf wird beim Update nachgezogen. - requirements.txt gepinnt (ocrmypdf 16.13.0, geprueft fuer Python 3.11+3.13). Doku in Installation / Update / OS-Upgrade aufgeteilt (docs/). 135 Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,13 +9,20 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from .config import Config
|
||||
from .processor import VALID_NAME_MODES, ProcessResult, _move_to_error, process_pdf
|
||||
from .processor import (
|
||||
OCR_TEMP_PREFIX,
|
||||
VALID_NAME_MODES,
|
||||
ProcessResult,
|
||||
_move_to_error,
|
||||
process_pdf,
|
||||
)
|
||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -216,7 +223,7 @@ class HotfolderService:
|
||||
self.shutdown()
|
||||
|
||||
def run_once(self) -> int:
|
||||
"""Verarbeitet alle bereits im incoming-Ordner liegenden PDFs und beendet sich.
|
||||
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
|
||||
|
||||
Returns:
|
||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
||||
@@ -243,11 +250,84 @@ class HotfolderService:
|
||||
# ---- Queue ----
|
||||
|
||||
def _scan_existing(self) -> None:
|
||||
"""Beim Start: bereits liegende PDFs aufgreifen."""
|
||||
for p in self.cfg.paths.incoming.iterdir():
|
||||
"""Beim Start: bereits liegende PDFs aufgreifen.
|
||||
|
||||
Zuerst working/ (abgebrochene Läufe, siehe `_scan_working`), danach
|
||||
incoming/. Die Reihenfolge ist wichtig, damit eine Namenskollision
|
||||
zwischen beiden Verzeichnissen aufgelöst ist, bevor die
|
||||
incoming-Datei nach working/ will.
|
||||
"""
|
||||
self._scan_working()
|
||||
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
||||
if _is_pdf(p):
|
||||
self.enqueue(p)
|
||||
|
||||
def _scan_working(self) -> None:
|
||||
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
|
||||
|
||||
`process_pdf()` verschiebt das Original vor dem OCR nach working/.
|
||||
Wird der Dienst dort abgeschossen (SIGKILL nach TimeoutStopSec),
|
||||
bleibt es liegen und wurde bisher nie wieder angefasst — stiller
|
||||
Datenverlust. Die Datei wird deshalb an Ort und Stelle
|
||||
wiederaufgenommen; `process_pdf()` erkennt das und verschiebt sie
|
||||
nicht erneut.
|
||||
|
||||
Die Zwischendateien des abgebrochenen OCR-Laufs (Präfix `__ocr_`)
|
||||
sind unvollständige Fragmente: als Eingabe unbrauchbar und als
|
||||
Ergebnis wertlos. Sie werden gelöscht, damit sie niemand für ein
|
||||
fertiges PDF hält und damit der neue Lauf sauber startet.
|
||||
"""
|
||||
working = self.cfg.paths.working
|
||||
if not working.is_dir():
|
||||
return
|
||||
for p in sorted(working.iterdir()):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.name.startswith(OCR_TEMP_PREFIX):
|
||||
log.warning(
|
||||
"Unvollständiges OCR-Fragment aus abgebrochenem Lauf "
|
||||
"gefunden und gelöscht: %s", p,
|
||||
)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
log.exception("Konnte OCR-Fragment %s nicht löschen", p)
|
||||
continue
|
||||
if not _is_pdf(p):
|
||||
continue
|
||||
target = self._free_resume_name(p)
|
||||
log.warning(
|
||||
"Abgebrochener Lauf wird fortgesetzt: %s lag noch in %s "
|
||||
"(Dienst wurde vermutlich hart gestoppt) — OCR startet neu",
|
||||
target.name, working,
|
||||
)
|
||||
self.enqueue(target)
|
||||
|
||||
def _free_resume_name(self, p: Path) -> Path:
|
||||
"""Entschärft eine Namenskollision zwischen working/ und incoming/.
|
||||
|
||||
Liegt in incoming/ eine gleichnamige (aber andere) Datei, würden beide
|
||||
dieselbe working- und dieselbe outgoing-Datei beanspruchen. Die
|
||||
wiederaufgenommene Datei bekommt deshalb einen Zeitstempel angehängt —
|
||||
dann laufen beide durch, statt dass eine überschrieben wird.
|
||||
"""
|
||||
if not (self.cfg.paths.incoming / p.name).exists():
|
||||
return p
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
renamed = p.with_name(f"{p.stem}_{ts}{p.suffix}")
|
||||
try:
|
||||
p.rename(renamed)
|
||||
except OSError:
|
||||
log.exception("Konnte %s nicht umbenennen — Wiederaufnahme unter "
|
||||
"Originalnamen", p)
|
||||
return p
|
||||
log.warning(
|
||||
"In %s liegt eine gleichnamige Datei %s — die wiederaufgenommene "
|
||||
"Datei wurde nach %s umbenannt, damit sich beide nicht "
|
||||
"überschreiben", self.cfg.paths.incoming, p.name, renamed.name,
|
||||
)
|
||||
return renamed
|
||||
|
||||
def enqueue(self, path: Path) -> None:
|
||||
if not _is_pdf(path):
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user