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 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 21:43:59 +02:00
co-authored by Claude Sonnet 5
parent e05d1174e7
commit 38c95cc4bf
3 changed files with 136 additions and 38 deletions
+17 -17
View File
@@ -43,14 +43,28 @@ pub async fn run(name: Option<String>, 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,
}
}
+27 -7
View File
@@ -45,6 +45,20 @@ pub fn build_target(
pair: &DrivePair,
settings: &GlobalSettings,
side: Side,
) -> Result<MountTarget> {
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<std::net::Ipv4Addr>,
) -> Result<MountTarget> {
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<u32> {
})
}
/// 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<String> {
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 {
+92 -14
View File
@@ -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<ReconcileOutcome> {
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<Ipv4Addr>,
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<Ipv4Addr>,
cloud_reachable: bool,
) -> Result<Action> {
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<Ipv4Addr>) {
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<Side>) {
async fn cleanup_orphaned_mounts(
pair: &DrivePair,
settings: &GlobalSettings,
active: Option<Side>,
) {
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<Side>,
cached_local_ip: Option<Ipv4Addr>,
) -> 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<Ipv4Addr>,
) -> 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))?;