feat: Scan, Namenskollisionen, Buchführung und Watchdog
Kandidatensuche mit Filtern und Ausschlüssen (versteckt, unfertig, Synology-Ordner, Symlinks), Kollisionsnamen, Ledger für copied_files und file_failures, Inaktivitäts-Watchdog mit CancelToken. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e5ed8c4d88
commit
c3a42ce6f5
@@ -0,0 +1 @@
|
|||||||
|
"""Mover: Dateien finden, auf Stabilität prüfen und sicher ans Ziel bringen."""
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Buchführung in der Datenbank.
|
||||||
|
|
||||||
|
* ``copied_files`` (Fallstrick 11): Welche Dateiversion (Pfad, Größe, mtime) wurde
|
||||||
|
schon übertragen? Verhindert endloses Neu-Kopieren im Kopiermodus – und im
|
||||||
|
Verschiebemodus Duplikate, wenn die Quelle nicht gelöscht werden konnte.
|
||||||
|
* ``file_failures``: Fehlversuche pro Datei als Grundlage der Quarantäne.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from netmover.db import session_scope
|
||||||
|
from netmover.models import CopiedFile, FileFailure, utcnow
|
||||||
|
|
||||||
|
CopyKey = tuple[str, int, int] # (relpath, size, mtime_ns)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FailureInfo:
|
||||||
|
attempts: int
|
||||||
|
size: int
|
||||||
|
mtime_ns: int
|
||||||
|
|
||||||
|
|
||||||
|
class Ledger:
|
||||||
|
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||||
|
self.session_factory = session_factory
|
||||||
|
|
||||||
|
def copied_keys(self, rule_id: int) -> set[CopyKey]:
|
||||||
|
with session_scope(self.session_factory) as s:
|
||||||
|
rows = s.execute(
|
||||||
|
select(CopiedFile.src_relpath, CopiedFile.size, CopiedFile.mtime_ns).where(
|
||||||
|
CopiedFile.rule_id == rule_id
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {(r[0], r[1], r[2]) for r in rows}
|
||||||
|
|
||||||
|
def record_copy(self, rule_id: int, relpath: str, size: int, mtime_ns: int, dst: str) -> None:
|
||||||
|
with session_scope(self.session_factory) as s:
|
||||||
|
exists = s.scalar(
|
||||||
|
select(CopiedFile.id).where(
|
||||||
|
CopiedFile.rule_id == rule_id,
|
||||||
|
CopiedFile.src_relpath == relpath,
|
||||||
|
CopiedFile.size == size,
|
||||||
|
CopiedFile.mtime_ns == mtime_ns,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
s.add(
|
||||||
|
CopiedFile(
|
||||||
|
rule_id=rule_id,
|
||||||
|
src_relpath=relpath,
|
||||||
|
size=size,
|
||||||
|
mtime_ns=mtime_ns,
|
||||||
|
dst_path=dst,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def prune_copied(self, rule_id: int, present: set[str]) -> int:
|
||||||
|
"""Entfernt Einträge, deren Quelldatei verschwunden ist. Nur nach einem
|
||||||
|
*vollständigen* Scan aufrufen – sonst würde alles erneut kopiert."""
|
||||||
|
with session_scope(self.session_factory) as s:
|
||||||
|
rows = s.execute(
|
||||||
|
select(CopiedFile.id, CopiedFile.src_relpath).where(CopiedFile.rule_id == rule_id)
|
||||||
|
).all()
|
||||||
|
stale = [r[0] for r in rows if r[1] not in present]
|
||||||
|
if stale:
|
||||||
|
s.execute(delete(CopiedFile).where(CopiedFile.id.in_(stale)))
|
||||||
|
return len(stale)
|
||||||
|
|
||||||
|
def failures(self, rule_id: int) -> dict[str, FailureInfo]:
|
||||||
|
with session_scope(self.session_factory) as s:
|
||||||
|
rows = s.scalars(select(FileFailure).where(FileFailure.rule_id == rule_id)).all()
|
||||||
|
return {r.src_relpath: FailureInfo(r.attempts, r.size, r.mtime_ns) for r in rows}
|
||||||
|
|
||||||
|
def record_failure(
|
||||||
|
self, rule_id: int, relpath: str, size: int, mtime_ns: int, error: str
|
||||||
|
) -> int:
|
||||||
|
with session_scope(self.session_factory) as s:
|
||||||
|
row = s.scalar(
|
||||||
|
select(FileFailure).where(
|
||||||
|
FileFailure.rule_id == rule_id, FileFailure.src_relpath == relpath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
row = FileFailure(
|
||||||
|
rule_id=rule_id, src_relpath=relpath, size=size, mtime_ns=mtime_ns, attempts=0
|
||||||
|
)
|
||||||
|
s.add(row)
|
||||||
|
elif (row.size, row.mtime_ns) != (size, mtime_ns):
|
||||||
|
# neue Dateiversion – Zählung beginnt von vorn
|
||||||
|
row.size, row.mtime_ns, row.attempts = size, mtime_ns, 0
|
||||||
|
row.attempts += 1
|
||||||
|
row.last_error = error
|
||||||
|
row.last_attempt_at = utcnow()
|
||||||
|
return row.attempts
|
||||||
|
|
||||||
|
def clear_failure(self, rule_id: int, relpath: str) -> None:
|
||||||
|
with session_scope(self.session_factory) as s:
|
||||||
|
s.execute(
|
||||||
|
delete(FileFailure).where(
|
||||||
|
FileFailure.rule_id == rule_id, FileFailure.src_relpath == relpath
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Namenskollisionen (Fallstrick 4): ``scan001.pdf`` → ``scan001-1.pdf`` → …
|
||||||
|
|
||||||
|
Die Kandidaten werden nur erzeugt; ob ein Name frei ist, entscheidet jedes Mal
|
||||||
|
das Ziel-Dateisystem (kein Vorhalten im Speicher).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import posixpath
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
MAX_SUFFIX = 9999
|
||||||
|
|
||||||
|
|
||||||
|
def collision_candidates(relpath: str) -> Iterator[str]:
|
||||||
|
yield relpath
|
||||||
|
directory, name = posixpath.split(relpath)
|
||||||
|
stem, dot, ext = name.rpartition(".")
|
||||||
|
if not stem: # kein Punkt bzw. nur führender Punkt
|
||||||
|
stem, dot, ext = name, "", ""
|
||||||
|
for n in range(1, MAX_SUFFIX + 1):
|
||||||
|
yield posixpath.join(directory, f"{stem}-{n}{dot}{ext}")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Kandidaten im Quellordner finden (Fallstrick 1, erster Teil).
|
||||||
|
|
||||||
|
Übersprungen werden immer:
|
||||||
|
* versteckte Dateien und Ordner (führender Punkt) – darunter auch unsere eigenen
|
||||||
|
``.netmover-*.part``-Temporärdateien,
|
||||||
|
* typische Endungen unfertiger Übertragungen,
|
||||||
|
* Symlinks und alles, was keine reguläre Datei ist,
|
||||||
|
* Synology-Verwaltungsordner (``@eaDir``, ``#recycle``, ``#snapshot``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from netmover.models import PatternType
|
||||||
|
|
||||||
|
PARTIAL_SUFFIXES = (".part", ".tmp", ".crdownload", ".filepart", ".!ut")
|
||||||
|
IGNORED_DIRS = frozenset({"@eaDir", "#recycle", "#snapshot", "@tmp", "@sharebin"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileState:
|
||||||
|
size: int
|
||||||
|
mtime_ns: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Candidate:
|
||||||
|
relpath: str # POSIX, relativ zum Quellordner
|
||||||
|
path: Path
|
||||||
|
state: FileState
|
||||||
|
|
||||||
|
|
||||||
|
def is_ignored_name(name: str) -> bool:
|
||||||
|
return name.startswith(".") or name.lower().endswith(PARTIAL_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
class Matcher:
|
||||||
|
"""Glob: gegen den Dateinamen, ohne Groß-/Kleinschreibung, mehrere Muster mit
|
||||||
|
„;“ getrennt (``*.pdf;*.jpg``). Regex: ``re.search`` gegen den relativen Pfad."""
|
||||||
|
|
||||||
|
def __init__(self, pattern: str, pattern_type: PatternType) -> None:
|
||||||
|
self.pattern_type = pattern_type
|
||||||
|
if pattern_type is PatternType.REGEX:
|
||||||
|
self._regex = re.compile(pattern)
|
||||||
|
self._globs: list[str] = []
|
||||||
|
else:
|
||||||
|
self._globs = [p.strip().lower() for p in pattern.split(";") if p.strip()] or ["*"]
|
||||||
|
|
||||||
|
def __call__(self, relpath: str) -> bool:
|
||||||
|
if self.pattern_type is PatternType.REGEX:
|
||||||
|
return self._regex.search(relpath) is not None
|
||||||
|
name = relpath.rsplit("/", 1)[-1].lower()
|
||||||
|
return any(fnmatch.fnmatchcase(name, g) for g in self._globs)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pattern(pattern: str, pattern_type: PatternType) -> None:
|
||||||
|
if pattern_type is PatternType.REGEX:
|
||||||
|
re.compile(pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def scan(
|
||||||
|
root: Path,
|
||||||
|
matcher: Matcher,
|
||||||
|
recursive: bool,
|
||||||
|
exclude: frozenset[Path] = frozenset(),
|
||||||
|
) -> Iterator[Candidate]:
|
||||||
|
"""Liefert passende reguläre Dateien. ``exclude``: Verzeichnisse (realpath), die
|
||||||
|
nicht betreten werden – z. B. Quarantäne oder ein Ziel innerhalb der Quelle."""
|
||||||
|
|
||||||
|
def walk(directory: Path, prefix: str) -> Iterator[Candidate]:
|
||||||
|
with os.scandir(directory) as it:
|
||||||
|
entries = sorted(it, key=lambda e: e.name)
|
||||||
|
for entry in entries:
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
rel = f"{prefix}{entry.name}"
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
if (
|
||||||
|
recursive
|
||||||
|
and entry.name not in IGNORED_DIRS
|
||||||
|
and Path(entry.path).resolve() not in exclude
|
||||||
|
):
|
||||||
|
yield from walk(Path(entry.path), f"{rel}/")
|
||||||
|
continue
|
||||||
|
if is_ignored_name(entry.name):
|
||||||
|
continue
|
||||||
|
st = entry.stat(follow_symlinks=False)
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
|
continue
|
||||||
|
if matcher(rel):
|
||||||
|
yield Candidate(rel, Path(entry.path), FileState(st.st_size, st.st_mtime_ns))
|
||||||
|
|
||||||
|
yield from walk(root, "")
|
||||||
|
|
||||||
|
|
||||||
|
def current_state(path: Path) -> FileState | None:
|
||||||
|
try:
|
||||||
|
st = os.stat(path, follow_symlinks=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
|
return None
|
||||||
|
return FileState(st.st_size, st.st_mtime_ns)
|
||||||
@@ -10,6 +10,7 @@ Regeln. Bewusst kein Thread-Pool – hängende Threads würden ihn auffüllen.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from netmover.logging_setup import event, get_logger
|
from netmover.logging_setup import event, get_logger
|
||||||
@@ -56,3 +57,76 @@ def run_with_timeout[T](fn: Callable[[], T], seconds: float, what: str) -> T:
|
|||||||
if error:
|
if error:
|
||||||
raise error[0]
|
raise error[0]
|
||||||
return result[0]
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
class Cancelled(Exception):
|
||||||
|
"""Lauf wurde abgebrochen (Zeitlimit oder Dienstende)."""
|
||||||
|
|
||||||
|
|
||||||
|
class CancelToken:
|
||||||
|
"""Abbruch-Flag plus Lebenszeichen für :func:`run_with_watchdog`.
|
||||||
|
|
||||||
|
Der Worker ruft :meth:`check` regelmäßig auf (pro Datei, pro Datenblock).
|
||||||
|
Das meldet Fortschritt und wirft :class:`Cancelled`, sobald abgebrochen wurde.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._event = threading.Event()
|
||||||
|
self.last_beat = time.monotonic()
|
||||||
|
|
||||||
|
def beat(self) -> None:
|
||||||
|
self.last_beat = time.monotonic()
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cancelled(self) -> bool:
|
||||||
|
return self._event.is_set()
|
||||||
|
|
||||||
|
def check(self) -> None:
|
||||||
|
if self._event.is_set():
|
||||||
|
raise Cancelled
|
||||||
|
self.beat()
|
||||||
|
|
||||||
|
|
||||||
|
def run_with_watchdog[T](
|
||||||
|
fn: Callable[[CancelToken], T],
|
||||||
|
idle_timeout: float,
|
||||||
|
what: str,
|
||||||
|
*,
|
||||||
|
on_finish: Callable[[], None] | None = None,
|
||||||
|
poll: float = 0.1,
|
||||||
|
) -> T:
|
||||||
|
"""Wie :func:`run_with_timeout`, aber das Zeitlimit gilt für *Inaktivität*:
|
||||||
|
Ein großer Transfer, der stetig Fortschritt meldet, wird nicht abgebrochen,
|
||||||
|
ein hängender Dateizugriff schon. ``on_finish`` läuft im Worker-Thread, wenn
|
||||||
|
dieser wirklich endet – auch lange nach einer Zeitüberschreitung."""
|
||||||
|
token = CancelToken()
|
||||||
|
result: list[T] = []
|
||||||
|
error: list[BaseException] = []
|
||||||
|
done = threading.Event()
|
||||||
|
|
||||||
|
def target() -> None:
|
||||||
|
try:
|
||||||
|
result.append(fn(token))
|
||||||
|
except BaseException as exc:
|
||||||
|
error.append(exc)
|
||||||
|
finally:
|
||||||
|
done.set()
|
||||||
|
if on_finish is not None:
|
||||||
|
on_finish()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=target, name=f"nm-worker:{what}", daemon=True)
|
||||||
|
thread.start()
|
||||||
|
while not done.wait(poll):
|
||||||
|
if time.monotonic() - token.last_beat > idle_timeout:
|
||||||
|
token.cancel()
|
||||||
|
global _abandoned
|
||||||
|
with _abandoned_lock:
|
||||||
|
_abandoned += 1
|
||||||
|
event(log, "worker.timeout", level=30, what=what, idle_seconds=idle_timeout)
|
||||||
|
raise OperationTimeout(what, idle_timeout)
|
||||||
|
if error:
|
||||||
|
raise error[0]
|
||||||
|
return result[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user