From 38c95cc4bff36c63b60d761dddb29265e8a7e02d Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:43:59 +0200 Subject: [PATCH] Perf: Erreichbarkeitspruefung parallelisiert, lokale IP nur einmal aufgeloest watch_once() rief reconcile_pair() sequenziell fuer jedes Paar auf - bei mehreren/langsamen Paaren (Ping-/curl-Timeouts von mehreren Sekunden pro Seite) wuchs die Laufzeit eines watch-Durchlaufs dadurch linear mit der Anzahl konfigurierter Paare, obwohl die pro-Paar-Sperre (siehe lock.rs) genau fuer parallele Reconciles gedacht ist. Die Erreichbarkeitspruefungen laufen jetzt ueber spawn_blocking parallel; das eigentliche Mounten/ Aushaengen bleibt sequenziell (teilt sich CredentialStore/Dateizustand). Ausserdem loeste ein Reconcile-Durchlauf fuer ein MAC-adressiertes Paar dieselbe Adresse bislang zweimal ueber mac2ip auf: einmal fuer die Erreichbarkeitspruefung (check_local_reachable), ein zweites Mal beim tatsaechlichen Mounten (build_target -> local_source). check_local_reachable gibt die aufgeloeste IP jetzt zurueck und reicht sie bis zu mount_side() durch (target::build_target_with_cached_local_ip); status.rs loest die lokale Adresse ebenfalls nur noch einmal pro Paar auf und nutzt sie fuer Quelle UND Erreichbarkeitsanzeige. Co-Authored-By: Claude Sonnet 5 --- src/cli/status.rs | 34 +++++++------- src/mount/target.rs | 34 +++++++++++--- src/reconcile/mod.rs | 106 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 136 insertions(+), 38 deletions(-) diff --git a/src/cli/status.rs b/src/cli/status.rs index 4f42b39..524bc3b 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -43,14 +43,28 @@ pub async fn run(name: Option, json: bool) -> anyhow::Result<()> { Some(Side::Cloud) => Some("cloud"), None => None, }; + // Die lokale Adresse (bei MAC-Adressierung ein `mac2ip`-Subprozessaufruf) wird nur + // einmal pro Paar aufgelöst und für Quelle UND Erreichbarkeitsprüfung + // wiederverwendet, statt sie für beide Zwecke unabhängig voneinander ein zweites + // Mal aufzulösen. + let resolved_local_ip = + smart_mount::network::address::resolve_ip(&pair.local.address, &cfg.settings); + let local_source = resolved_local_ip + .as_ref() + .map(|ip| target::format_local_source(&pair.local, *ip)) + .unwrap_or_else(|_| "unresolved".to_string()); + let local_reachable = resolved_local_ip + .as_ref() + .map(|ip| network::is_reachable(&ip.to_string())) + .unwrap_or(false); + PairStatus { id: pair.id.clone(), name: pair.name.clone(), mount_point: pair.mount_point.display().to_string(), active, - local_source: target::local_source(&pair.local, &cfg.settings) - .unwrap_or_else(|_| "unresolved".to_string()), - local_reachable: local_reachable(&pair.local, &cfg.settings), + local_source, + local_reachable, cloud_source: target::cloud_source(&pair.cloud), cloud_reachable: network::is_reachable(&pair.cloud.host_or_url), } @@ -88,17 +102,3 @@ fn reachable_str(reachable: bool) -> &'static str { "unreachable" } } - -/// `false` wenn die Adresse (MAC->IP) gar nicht erst aufgelöst werden kann, statt den -/// Platzhalter-String `"unresolved"` an `network::is_reachable` weiterzureichen - das würde -/// dort einen sinnlosen `ping unresolved`-Aufruf gegen einen nicht existierenden Hostnamen -/// auslösen statt korrekt "nicht erreichbar" zu melden. -fn local_reachable( - local: &smart_mount::config::LocalSide, - settings: &smart_mount::config::GlobalSettings, -) -> bool { - match smart_mount::network::address::resolve_ip(&local.address, settings) { - Ok(ip) => network::is_reachable(&ip.to_string()), - Err(_) => false, - } -} diff --git a/src/mount/target.rs b/src/mount/target.rs index 209f985..46d4286 100644 --- a/src/mount/target.rs +++ b/src/mount/target.rs @@ -45,6 +45,20 @@ pub fn build_target( pair: &DrivePair, settings: &GlobalSettings, side: Side, +) -> Result { + build_target_with_cached_local_ip(pair, settings, side, None) +} + +/// Wie [`build_target`], nimmt aber optional eine bereits aufgelöste lokale IP entgegen +/// (`Side::Local` mit `LocalAddress::Mac`), um eine zweite `mac2ip`-Subprozess-Auflösung +/// derselben Adresse innerhalb desselben Reconcile-Durchlaufs zu vermeiden (siehe +/// [`crate::reconcile`], das die Erreichbarkeit ohnehin schon per `address::resolve_ip` prüft, +/// bevor es ggf. auf diese Seite umschaltet). Wird für `Side::Cloud` ignoriert. +pub fn build_target_with_cached_local_ip( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, + cached_local_ip: Option, ) -> Result { let invocation = match pair.context { MountContext::System => MountInvocation::Direct, @@ -54,7 +68,10 @@ pub fn build_target( let (source, mut options) = match side { Side::Local => ( - local_source(&pair.local, settings)?, + match cached_local_ip { + Some(ip) => format_local_source(&pair.local, ip), + None => local_source(&pair.local, settings)?, + }, parse_options(&pair.local.extra_options), ), Side::Cloud => ( @@ -169,14 +186,17 @@ fn run_id(username: &str, flag: &str) -> Result { }) } -/// Löst die konfigurierte lokale Adresse auf und formatiert die Mount-Quelle daraus. Gibt -/// einen Fehler zurück, statt eine fehlgeschlagene Auflösung (z. B. `mac2ip` findet die -/// MAC-Adresse nicht) hinter dem Platzhalter-String `"unresolved"` zu verstecken - Aufrufer -/// sollen einen echten Auflösungsfehler von einer tatsächlich formatierten, aber unerreichbaren -/// Quelle unterscheiden können. pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> Result { let ip = address::resolve_ip(&local.address, settings)?; - Ok(format_source(local.kind, &ip.to_string(), &local.share)) + Ok(format_local_source(local, ip)) +} + +/// Formatiert die Mount-Quelle für eine bereits aufgelöste lokale IP, ohne selbst erneut +/// aufzulösen - für Aufrufer, die die IP ohnehin schon für eine andere Prüfung (z. B. +/// Erreichbarkeit) aufgelöst haben und damit eine zweite `mac2ip`-Auflösung derselben +/// MAC-Adresse vermeiden wollen. +pub fn format_local_source(local: &LocalSide, ip: std::net::Ipv4Addr) -> String { + format_source(local.kind, &ip.to_string(), &local.share) } pub fn cloud_source(cloud: &CloudSide) -> String { diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index 1c5716d..d1c585a 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -12,6 +12,8 @@ //! neue Seite zuerst, flippt dann den Symlink, und hängt erst danach die alte Seite aus - so //! gibt es nie ein Zeitfenster, in dem der sichtbare Pfad auf nichts Gemountetes zeigt. +use std::net::Ipv4Addr; + use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide}; use crate::db::credentials::{CredentialStore, Side}; use crate::error::{Error, Result}; @@ -37,10 +39,45 @@ pub struct ReconcileOutcome { } /// Führt `reconcile_pair` für jedes aktivierte Paar in `cfg` aus. +/// +/// Die Erreichbarkeitsprüfungen (Ping/`curl`-Subprozesse, bis zu mehrere Sekunden pro Seite) +/// sind rein lesend und unabhängig voneinander - sie laufen daher parallel über +/// `spawn_blocking` statt sequenziell, damit die Gesamtlaufzeit eines `watch`-Durchlaufs bei +/// vielen/langsamen Paaren nicht linear mit deren Anzahl wächst. Das eigentliche Mounten/ +/// Aushängen bleibt dagegen sequenziell: es teilt sich den `CredentialStore` und schreibt +/// Zustand (Symlinks, Credential-/Secrets-Dateien) - die Pro-Paar-Sperre (siehe [`lock`]) +/// verhindert zwar Kollisionen, unbegrenzt paralleler DB-/Dateizugriff wäre aber unnötiges +/// Risiko für einen Effizienzgewinn, den die Reachability-Parallelisierung bereits liefert. pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec { - let mut outcomes = Vec::with_capacity(cfg.pairs.len()); - for pair in cfg.pairs.iter().filter(|p| p.enabled) { - outcomes.push(reconcile_pair(pair, &cfg.settings, creds).await); + let enabled: Vec<&DrivePair> = cfg.pairs.iter().filter(|p| p.enabled).collect(); + + let mut checks = Vec::with_capacity(enabled.len()); + for pair in &enabled { + let local = pair.local.clone(); + let cloud_host = pair.cloud.host_or_url.clone(); + let settings = cfg.settings.clone(); + checks.push(tokio::task::spawn_blocking(move || { + let (local_reachable, local_ip) = check_local_reachable(&local, &settings); + let cloud_reachable = network::is_reachable(&cloud_host); + (local_reachable, local_ip, cloud_reachable) + })); + } + + let mut outcomes = Vec::with_capacity(enabled.len()); + for (pair, check) in enabled.iter().zip(checks) { + let (local_reachable, local_ip, cloud_reachable) = + check.await.unwrap_or((false, None, false)); + outcomes.push( + reconcile_pair_checked( + pair, + &cfg.settings, + creds, + local_reachable, + local_ip, + cloud_reachable, + ) + .await, + ); } outcomes } @@ -51,11 +88,41 @@ pub async fn reconcile_pair( pair: &DrivePair, settings: &GlobalSettings, creds: &CredentialStore, +) -> ReconcileOutcome { + let (local_reachable, local_ip) = check_local_reachable(&pair.local, settings); + let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url); + reconcile_pair_checked( + pair, + settings, + creds, + local_reachable, + local_ip, + cloud_reachable, + ) + .await +} + +async fn reconcile_pair_checked( + pair: &DrivePair, + settings: &GlobalSettings, + creds: &CredentialStore, + local_reachable: bool, + local_ip: Option, + cloud_reachable: bool, ) -> ReconcileOutcome { let pair_id = pair.id.clone(); let pair_name = pair.name.clone(); - match reconcile_pair_inner(pair, settings, creds).await { + match reconcile_pair_inner( + pair, + settings, + creds, + local_reachable, + local_ip, + cloud_reachable, + ) + .await + { Ok(action) => ReconcileOutcome { pair_id, pair_name, @@ -73,10 +140,12 @@ async fn reconcile_pair_inner( pair: &DrivePair, settings: &GlobalSettings, creds: &CredentialStore, + local_reachable: bool, + local_ip: Option, + cloud_reachable: bool, ) -> Result { let _guard = lock::acquire(&pair.id).await?; - let local_reachable = check_local_reachable(&pair.local, settings); let active = target::active_side(pair); cleanup_orphaned_mounts(pair, settings, active).await; @@ -84,7 +153,7 @@ async fn reconcile_pair_inner( if active == Some(Side::Local) { return Ok(Action::NoOp); } - switch_to(pair, settings, Side::Local, creds, active).await?; + switch_to(pair, settings, Side::Local, creds, active, local_ip).await?; return Ok(if active.is_some() { Action::SwitchedToLocal } else { @@ -92,12 +161,11 @@ async fn reconcile_pair_inner( }); } - let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url); if cloud_reachable { if active == Some(Side::Cloud) { return Ok(Action::NoOp); } - switch_to(pair, settings, Side::Cloud, creds, active).await?; + switch_to(pair, settings, Side::Cloud, creds, active, local_ip).await?; return Ok(if active.is_some() { Action::SwitchedToCloud } else { @@ -108,10 +176,13 @@ async fn reconcile_pair_inner( Ok(Action::NoOp) } -fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool { +/// Prüft die Erreichbarkeit der lokalen Seite und gibt dabei - falls erfolgreich aufgelöst - +/// die konkrete IP zurück, damit ein nachfolgender `switch_to(..., Side::Local, ...)` dieselbe +/// MAC-Adresse nicht ein zweites Mal über `mac2ip` auflösen muss. +fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> (bool, Option) { match address::resolve_ip(&local.address, settings) { - Ok(ip) => network::is_reachable(&ip.to_string()), - Err(_) => false, + Ok(ip) => (network::is_reachable(&ip.to_string()), Some(ip)), + Err(_) => (false, None), } } @@ -123,7 +194,11 @@ fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool { /// manuell gemountet wurde) bliebe dadurch unbemerkt: `status`/`watch` sähen ihn nie, und er /// würde nie automatisch wieder ausgehängt. Best-effort (nur geloggt, nicht propagiert) - ein /// hier fehlschlagendes Aufräumen darf den eigentlichen Reconcile-Schritt nicht blockieren. -async fn cleanup_orphaned_mounts(pair: &DrivePair, settings: &GlobalSettings, active: Option) { +async fn cleanup_orphaned_mounts( + pair: &DrivePair, + settings: &GlobalSettings, + active: Option, +) { for side in [Side::Local, Side::Cloud] { if Some(side) == active { continue; @@ -174,8 +249,9 @@ async fn switch_to( new_side: Side, creds: &CredentialStore, old_active: Option, + cached_local_ip: Option, ) -> Result<()> { - mount_side(pair, settings, new_side, creds).await?; + mount_side(pair, settings, new_side, creds, cached_local_ip).await?; // Falls das Aktivieren des Symlinks fehlschlägt, muss die gerade gemountete `new_side` // wieder ausgehängt werden, statt sie unbemerkt gemountet zu lassen: der nächste @@ -200,8 +276,10 @@ async fn mount_side( settings: &GlobalSettings, side: Side, creds: &CredentialStore, + cached_local_ip: Option, ) -> Result<()> { - let mount_target = target::build_target(pair, settings, side)?; + let mount_target = + target::build_target_with_cached_local_ip(pair, settings, side, cached_local_ip)?; std::fs::create_dir_all(&mount_target.mount_point) .map_err(|e| crate::error::Error::io(&mount_target.mount_point, e))?;