Ottimizza controllo stabilita file in batch

This commit is contained in:
2026-08-13 10:02:03 +02:00
parent 6b356e8983
commit 6fa96f8abe
4 changed files with 152 additions and 30 deletions

View File

@@ -241,6 +241,42 @@ Conseguenza osservata:
Un primo lancio puo' saltare un file come instabile. Un secondo lancio poco dopo puo' copiarlo correttamente. Questo e' comportamento previsto, non un errore. Un primo lancio puo' saltare un file come instabile. Un secondo lancio poco dopo puo' copiarlo correttamente. Questo e' comportamento previsto, non un errore.
Problema prestazionale risolto:
La prima implementazione controllava la stabilita' in modo sequenziale, file per file. Con 1359 file in coda e valori standard:
```toml
unchanged_check_interval_seconds = 5
unchanged_checks_required = 2
```
il backup poteva restare fermo prima del primo `robocopy` per circa:
```text
1359 * 5 * 2 = 13590 secondi, cioe' quasi 4 ore
```
Soluzione adottata:
Il controllo stabilita' ora lavora a batch:
1. legge dimensione e timestamp di tutti i file candidati;
2. attende l'intervallo configurato;
3. ricontrolla tutti i file;
4. ripete per il numero di controlli richiesti.
Con la stessa configurazione, 1359 file richiedono circa 10 secondi di attesa stabilita' complessiva, non 10 secondi per ogni file.
Sono stati aggiunti log espliciti:
```text
Found N pending files to back up
Checking stability for N existing files
Running X batch stability check(s) every Y second(s) for N file(s)
Batch stability check ...
Stability check completed: A stable, B unstable
```
## Controllo raggiungibilita' slave ## Controllo raggiungibilita' slave
Problema: Problema:

View File

@@ -35,12 +35,15 @@ def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult:
logger.info("No pending files to back up") logger.info("No pending files to back up")
return BackupResult(True, "Nessun file in attesa", copied_count=0) return BackupResult(True, "Nessun file in attesa", copied_count=0)
logger.info("Found %s pending files to back up", len(pending))
existing, skipped = _split_existing_files(pending) existing, skipped = _split_existing_files(pending)
if skipped: if skipped:
logger.info("Skipped %s non-existing files", len(skipped)) logger.info("Skipped %s non-existing files", len(skipped))
registry.mark_status([event.path for event in skipped], "ignored") registry.mark_status([event.path for event in skipped], "ignored")
stable, unstable = _split_stable_files(existing, config) logger.info("Checking stability for %s existing files", len(existing))
stable, unstable = _split_stable_files(existing, config, logger)
logger.info("Stability check completed: %s stable, %s unstable", len(stable), len(unstable))
if unstable: if unstable:
logger.warning("Skipped %s unstable files", len(unstable)) logger.warning("Skipped %s unstable files", len(unstable))
@@ -95,38 +98,74 @@ def _split_existing_files(events: list[ChangeEvent]) -> tuple[list[ChangeEvent],
return existing, skipped return existing, skipped
def _split_stable_files(events: list[ChangeEvent], config: AppConfig) -> tuple[list[ChangeEvent], list[ChangeEvent]]: def _split_stable_files(
stable: list[ChangeEvent] = [] events: list[ChangeEvent],
config: AppConfig,
logger: logging.Logger | None = None,
) -> tuple[list[ChangeEvent], list[ChangeEvent]]:
candidates: list[ChangeEvent] = []
unstable: list[ChangeEvent] = [] unstable: list[ChangeEvent] = []
signatures: dict[str, tuple[int, int]] = {}
now = time.time()
for event in events: for event in events:
if _is_file_stable(Path(event.path), config): path = Path(event.path)
stable.append(event)
else:
unstable.append(event)
return stable, unstable
def _is_file_stable(path: Path, config: AppConfig) -> bool:
try: try:
stat = path.stat() stat = path.stat()
except OSError: except OSError:
return False unstable.append(event)
continue
if time.time() - stat.st_mtime < config.stability.min_age_seconds: if now - stat.st_mtime < config.stability.min_age_seconds:
return False unstable.append(event)
continue
previous = (stat.st_size, stat.st_mtime_ns) candidates.append(event)
for _ in range(config.stability.unchanged_checks_required): signatures[event.path] = (stat.st_size, stat.st_mtime_ns)
checks_required = config.stability.unchanged_checks_required
if candidates and checks_required > 0 and logger:
logger.info(
"Running %s batch stability check(s) every %s second(s) for %s file(s)",
checks_required,
config.stability.unchanged_check_interval_seconds,
len(candidates),
)
for check_index in range(checks_required):
time.sleep(config.stability.unchanged_check_interval_seconds) time.sleep(config.stability.unchanged_check_interval_seconds)
next_candidates: list[ChangeEvent] = []
next_signatures: dict[str, tuple[int, int]] = {}
for event in candidates:
try: try:
current_stat = path.stat() current_stat = Path(event.path).stat()
except OSError: except OSError:
return False unstable.append(event)
continue
current = (current_stat.st_size, current_stat.st_mtime_ns) current = (current_stat.st_size, current_stat.st_mtime_ns)
if current != previous: if current != signatures[event.path]:
return False unstable.append(event)
previous = current continue
return True
next_candidates.append(event)
next_signatures[event.path] = current
candidates = next_candidates
signatures = next_signatures
if logger:
logger.info(
"Batch stability check %s/%s completed: %s candidate file(s) remain",
check_index + 1,
checks_required,
len(candidates),
)
if not candidates:
break
return candidates, unstable
def _build_rsync_command(config: AppConfig, events: list[ChangeEvent]) -> list[str]: def _build_rsync_command(config: AppConfig, events: list[ChangeEvent]) -> list[str]:

View File

@@ -10,6 +10,7 @@ from bakrest.backup import (
_run_command, _run_command,
_run_robocopy, _run_robocopy,
_split_existing_files, _split_existing_files,
_split_stable_files,
) )
from bakrest.config import parse_config from bakrest.config import parse_config
from bakrest.registry import ChangeEvent, ChangeRegistry from bakrest.registry import ChangeEvent, ChangeRegistry
@@ -161,6 +162,51 @@ def test_robocopy_marks_each_file_copied_after_all_destinations(
assert pending_counts_after_commands == [2, 2, 1, 1] assert pending_counts_after_commands == [2, 2, 1, 1]
def test_stability_check_waits_per_batch_not_per_file(tmp_path: Path, monkeypatch) -> None:
source = tmp_path / "Lavori"
source.mkdir()
files = []
for index in range(3):
path = source / f"file-{index}.docx"
path.write_text("ok", encoding="utf-8")
files.append(path)
config = parse_config(
{
"watch": {
"include_dirs": [str(source)],
"include_extensions": [".docx"],
"exclude_dirs": [],
"exclude_patterns": [],
},
"backup": {
"engine": "robocopy",
"remote_destinations": ["\\\\server\\Backup1"],
"server_check": {"type": "tcp", "port": 445, "interval_seconds": 1800, "timeout_seconds": 1},
},
"stability": {
"min_age_seconds": 0,
"unchanged_check_interval_seconds": 5,
"unchanged_checks_required": 2,
},
},
tmp_path / "config.toml",
)
events = [ChangeEvent(path=str(path), event_type="modified") for path in files]
sleeps: list[int] = []
def fake_sleep(seconds):
sleeps.append(seconds)
monkeypatch.setattr("bakrest.backup.time.sleep", fake_sleep)
stable, unstable = _split_stable_files(events, config, _NullLogger())
assert [event.path for event in stable] == [str(path) for path in files]
assert unstable == []
assert sleeps == [5, 5]
class _NullLogger: class _NullLogger:
def info(self, *args, **kwargs) -> None: def info(self, *args, **kwargs) -> None:
pass pass

View File

@@ -27,5 +27,6 @@ def test_notifier_task_runs_every_30_minutes() -> None:
def test_backup_task_uses_logoff_event_and_nogui_backup() -> None: def test_backup_task_uses_logoff_event_and_nogui_backup() -> None:
xml = (Path("tasks") / "BakRestBackupOnLogoff.xml").read_text(encoding="utf-8") xml = (Path("tasks") / "BakRestBackupOnLogoff.xml").read_text(encoding="utf-8")
assert "EventID=4647" in xml assert "Microsoft-Windows-Winlogon" in xml
assert "EventID=7002" in xml
assert "-m bakrest.tray_app --nogui" in xml assert "-m bakrest.tray_app --nogui" in xml