From a8a69296d00324aa7070c00fc08264a88c808f85 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 26 Mar 2026 14:42:27 +0100 Subject: [PATCH 1/3] feat: expose precise cache-layer metrics --- CHANGELOG.md | 7 + Cargo.lock | 2 +- Cargo.toml | 2 +- Makefile | 15 +- README.md | 6 +- examples/live_network_visualization.rs | 85 +++++++++ src/routing/cache.rs | 61 +++--- src/routing/fetch.rs | 252 +++++++++++++++++++++++-- tests/live_integration.rs | 147 +++++---------- 9 files changed, 432 insertions(+), 145 deletions(-) create mode 100644 examples/live_network_visualization.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d26d7ad..dd16fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [2.1.0](https://github.com/SolverForge/solverforge-maps/compare/v2.0.1...v2.1.0) (2026-03-26) + +### Features + +* expose precise cache-layer metrics for `load_or_fetch` +* run live external-service integration checks from local Makefile validation + ## [2.0.1](https://github.com/SolverForge/solverforge-maps/compare/v2.0.0...v2.0.1) (2026-03-21) ### Chores diff --git a/Cargo.lock b/Cargo.lock index 5679731..9797c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,7 +1111,7 @@ dependencies = [ [[package]] name = "solverforge-maps" -version = "2.0.1" +version = "2.1.0" dependencies = [ "rayon", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 94d32d8..db19685 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solverforge-maps" -version = "2.0.1" +version = "2.1.0" edition = "2021" description = "Generic map and routing utilities for VRP and similar problems" license = "Apache-2.0" diff --git a/Makefile b/Makefile index 8763ca4..a5bfa7b 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ VERSION := $(shell grep -m1 '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1 RUST_VERSION := 1.80+ # ============== Phony Targets ============== -.PHONY: banner help build build-release test test-quick test-doc test-unit test-one \ +.PHONY: banner help build build-release test test-live test-quick test-doc test-unit test-one \ lint fmt fmt-check clippy ci-local pre-release version bump-patch bump-minor bump-major \ bump-dry publish-dry publish clean watch @@ -68,8 +68,13 @@ test: banner @cargo test && \ printf "\n$(GREEN)$(CHECK) All tests passed$(RESET)\n\n" || \ (printf "\n$(RED)$(CROSS) Tests failed$(RESET)\n\n" && exit 1) - @printf "$(ARROW) $(BOLD)Visual test output:$(RESET)\n" - @cargo test visual --quiet -- --nocapture 2>/dev/null + @$(MAKE) test-live --no-print-directory + +test-live: + @printf "$(PROGRESS) Running live integration tests...\n" + @SOLVERFORGE_RUN_LIVE_TESTS=1 cargo test --test live_integration -- --nocapture && \ + printf "$(GREEN)$(CHECK) Live integration tests passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Live integration tests failed$(RESET)\n" && exit 1) test-quick: banner @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n" @@ -180,6 +185,7 @@ pre-release: banner @$(MAKE) clippy --no-print-directory @printf "$(PROGRESS) Running full test suite...\n" @cargo test --quiet && printf "$(GREEN)$(CHECK) All tests passed$(RESET)\n" + @$(MAKE) test-live --no-print-directory @printf "\n$(GREEN)$(BOLD)$(CHECK) Ready for release v$(VERSION)$(RESET)\n\n" # ============== Publishing ============== @@ -231,6 +237,7 @@ help: banner @/bin/echo -e "" @/bin/echo -e "$(CYAN)$(BOLD)Test Commands:$(RESET)" @/bin/echo -e " $(GREEN)make test$(RESET) - Run all tests" + @/bin/echo -e " $(GREEN)make test-live$(RESET) - Run live integration tests against external services" @/bin/echo -e " $(GREEN)make test-quick$(RESET) - Run doctests + unit + integration tests (fast)" @/bin/echo -e " $(GREEN)make test-doc$(RESET) - Run doctests only" @/bin/echo -e " $(GREEN)make test-unit$(RESET) - Run unit tests only" @@ -244,7 +251,7 @@ help: banner @/bin/echo -e "" @/bin/echo -e "$(CYAN)$(BOLD)CI & Quality:$(RESET)" @/bin/echo -e " $(GREEN)make ci-local$(RESET) - $(YELLOW)$(BOLD)Simulate GitHub Actions CI locally$(RESET)" - @/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run all validation checks" + @/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run all validation checks, including live integration tests" @/bin/echo -e "" @/bin/echo -e "$(CYAN)$(BOLD)Version Management:$(RESET)" @/bin/echo -e " $(GREEN)make version$(RESET) - Show current version" diff --git a/README.md b/README.md index d7ed32a..3680787 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,11 @@ println!("Cached networks: {}", stats.networks_cached); println!("Total nodes: {}", stats.total_nodes); println!("Total edges: {}", stats.total_edges); println!("Memory: {} bytes", stats.memory_bytes); -println!("Hits: {}, Misses: {}", stats.hits, stats.misses); +println!("Load requests: {}", stats.load_requests); +println!("Memory hits: {}", stats.memory_hits); +println!("Disk hits: {}", stats.disk_hits); +println!("Network fetches: {}", stats.network_fetches); +println!("In-flight waits: {}", stats.in_flight_waits); // List cached regions let regions: Vec = RoadNetwork::cached_regions().await; diff --git a/examples/live_network_visualization.rs b/examples/live_network_visualization.rs new file mode 100644 index 0000000..fac1b39 --- /dev/null +++ b/examples/live_network_visualization.rs @@ -0,0 +1,85 @@ +use solverforge_maps::{BoundingBox, NetworkConfig, RoadNetwork}; +use textplots::{Chart, Plot, Shape}; + +struct Location { + name: &'static str, + bbox: BoundingBox, +} + +fn locations() -> Vec { + vec![ + Location { + name: "Philadelphia (City Hall area)", + bbox: BoundingBox::new(39.946, -75.174, 39.962, -75.150), + }, + Location { + name: "Dragoncello (Poggio Rusco, IT)", + bbox: BoundingBox::new(44.978, 11.095, 44.986, 11.108), + }, + Location { + name: "Clusone (Lombardy, IT)", + bbox: BoundingBox::new(45.882, 9.940, 45.895, 9.960), + }, + ] +} + +fn plot_network(name: &str, network: &RoadNetwork, bbox: &BoundingBox) { + let nodes: Vec<(f64, f64)> = network.nodes_iter().collect(); + let edges: Vec<(usize, usize, f64, f64)> = network.edges_iter().collect(); + + if nodes.is_empty() { + println!("\n{}: No data", name); + return; + } + + let mut segments: Vec<(f32, f32)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for &(from, to, _, _) in &edges { + let key = (from.min(to), from.max(to)); + if seen.insert(key) && from < nodes.len() && to < nodes.len() { + let (lat1, lng1) = nodes[from]; + let (lat2, lng2) = nodes[to]; + segments.push((lng1 as f32, lat1 as f32)); + segments.push((lng2 as f32, lat2 as f32)); + segments.push((f32::NAN, f32::NAN)); + } + } + + let intersections: Vec<(f32, f32)> = nodes + .iter() + .map(|(lat, lng)| (*lng as f32, *lat as f32)) + .collect(); + + println!("\n{}", name); + println!( + "{} nodes, {} edges", + network.node_count(), + network.edge_count() + ); + + let x_min = bbox.min_lng as f32; + let x_max = bbox.max_lng as f32; + + Chart::new(180, 60, x_min, x_max) + .lineplot(&Shape::Lines(&segments)) + .lineplot(&Shape::Points(&intersections)) + .nice(); +} + +#[tokio::main] +async fn main() { + let config = NetworkConfig::default(); + + for loc in locations() { + print!("\nFetching {}...", loc.name); + match RoadNetwork::load_or_fetch(&loc.bbox, &config, None).await { + Ok(network_ref) => { + println!(" done"); + plot_network(loc.name, &network_ref, &loc.bbox); + } + Err(e) => { + println!(" failed: {}", e); + } + } + } +} diff --git a/src/routing/cache.rs b/src/routing/cache.rs index 4034be3..3e23c9b 100644 --- a/src/routing/cache.rs +++ b/src/routing/cache.rs @@ -17,8 +17,11 @@ pub const CACHE_VERSION: u32 = 5; static NETWORK_CACHE: OnceLock>> = OnceLock::new(); static IN_FLIGHT_LOADS: OnceLock>>>> = OnceLock::new(); -static CACHE_HITS: AtomicU64 = AtomicU64::new(0); -static CACHE_MISSES: AtomicU64 = AtomicU64::new(0); +static LOAD_REQUESTS: AtomicU64 = AtomicU64::new(0); +static MEMORY_HITS: AtomicU64 = AtomicU64::new(0); +static DISK_HITS: AtomicU64 = AtomicU64::new(0); +static NETWORK_FETCHES: AtomicU64 = AtomicU64::new(0); +static IN_FLIGHT_WAITS: AtomicU64 = AtomicU64::new(0); pub(crate) fn cache() -> &'static RwLock> { NETWORK_CACHE.get_or_init(|| RwLock::new(HashMap::new())) @@ -28,12 +31,33 @@ pub(crate) fn in_flight_loads() -> &'static Mutex> IN_FLIGHT_LOADS.get_or_init(|| Mutex::new(HashMap::new())) } -pub(crate) fn record_hit() { - CACHE_HITS.fetch_add(1, Ordering::Relaxed); +pub(crate) fn record_load_request() { + LOAD_REQUESTS.fetch_add(1, Ordering::Relaxed); } -pub(crate) fn record_miss() { - CACHE_MISSES.fetch_add(1, Ordering::Relaxed); +pub(crate) fn record_memory_hit() { + MEMORY_HITS.fetch_add(1, Ordering::Relaxed); +} + +pub(crate) fn record_disk_hit() { + DISK_HITS.fetch_add(1, Ordering::Relaxed); +} + +pub(crate) fn record_network_fetch() { + NETWORK_FETCHES.fetch_add(1, Ordering::Relaxed); +} + +pub(crate) fn record_in_flight_wait() { + IN_FLIGHT_WAITS.fetch_add(1, Ordering::Relaxed); +} + +#[cfg(test)] +pub(crate) fn reset_cache_metrics() { + LOAD_REQUESTS.store(0, Ordering::Relaxed); + MEMORY_HITS.store(0, Ordering::Relaxed); + DISK_HITS.store(0, Ordering::Relaxed); + NETWORK_FETCHES.store(0, Ordering::Relaxed); + IN_FLIGHT_WAITS.store(0, Ordering::Relaxed); } #[derive(Debug, Clone)] @@ -42,19 +66,11 @@ pub struct CacheStats { pub total_nodes: usize, pub total_edges: usize, pub memory_bytes: usize, - pub hits: u64, - pub misses: u64, -} - -impl CacheStats { - pub fn hit_ratio(&self) -> f64 { - let total = self.hits + self.misses; - if total == 0 { - 0.0 - } else { - self.hits as f64 / total as f64 - } - } + pub load_requests: u64, + pub memory_hits: u64, + pub disk_hits: u64, + pub network_fetches: u64, + pub in_flight_waits: u64, } /// RAII guard providing zero-cost access to a cached RoadNetwork. @@ -135,8 +151,11 @@ impl RoadNetwork { total_nodes, total_edges, memory_bytes, - hits: CACHE_HITS.load(Ordering::Relaxed), - misses: CACHE_MISSES.load(Ordering::Relaxed), + load_requests: LOAD_REQUESTS.load(Ordering::Relaxed), + memory_hits: MEMORY_HITS.load(Ordering::Relaxed), + disk_hits: DISK_HITS.load(Ordering::Relaxed), + network_fetches: NETWORK_FETCHES.load(Ordering::Relaxed), + in_flight_waits: IN_FLIGHT_WAITS.load(Ordering::Relaxed), } } diff --git a/src/routing/fetch.rs b/src/routing/fetch.rs index 3d4f2f1..eef1276 100644 --- a/src/routing/fetch.rs +++ b/src/routing/fetch.rs @@ -12,8 +12,9 @@ use tracing::{debug, info}; use super::bbox::BoundingBox; use super::cache::{ - cache, in_flight_loads, record_hit, record_miss, CachedEdge, CachedNetwork, CachedNode, - NetworkRef, CACHE_VERSION, + cache, in_flight_loads, record_disk_hit, record_in_flight_wait, record_load_request, + record_memory_hit, record_network_fetch, CachedEdge, CachedNetwork, CachedNode, NetworkRef, + CACHE_VERSION, }; use super::config::{ConnectivityPolicy, NetworkConfig}; use super::coord::Coord; @@ -29,6 +30,7 @@ impl RoadNetwork { progress: Option<&Sender>, ) -> Result { let cache_key = bbox.cache_key(); + record_load_request(); if let Some(tx) = progress { let _ = tx.send(RoutingProgress::CheckingCache { percent: 0 }).await; @@ -37,7 +39,7 @@ impl RoadNetwork { { let cache_guard = cache().read().await; if cache_guard.contains_key(&cache_key) { - record_hit(); + record_memory_hit(); info!("Using in-memory cached road network for {}", cache_key); if let Some(tx) = progress { let _ = tx @@ -47,8 +49,6 @@ impl RoadNetwork { return Ok(NetworkRef::new(cache_guard, cache_key)); } } - record_miss(); - if let Some(tx) = progress { let _ = tx.send(RoutingProgress::CheckingCache { percent: 5 }).await; } @@ -64,6 +64,7 @@ impl RoadNetwork { } match Self::load_from_file(&cache_path, config).await { Ok(network) => { + record_disk_hit(); if let Some(tx) = progress { let _ = tx .send(RoutingProgress::BuildingGraph { percent: 50 }) @@ -77,6 +78,7 @@ impl RoadNetwork { info!("Downloading road network from Overpass API"); } + record_network_fetch(); let network = Self::fetch_from_api(bbox, config, progress).await?; network.save_to_file(&cache_path).await?; info!("Saved road network to file cache: {:?}", cache_path); @@ -405,9 +407,10 @@ out body;"#, return Ok(cached); } - record_miss(); - - let (slot, _slot_guard) = acquire_in_flight_slot(&cache_key).await; + let (slot, _slot_guard, waited) = acquire_in_flight_slot(&cache_key).await; + if waited { + record_in_flight_wait(); + } if let Some(cached) = Self::get_cached_network(cache_key.clone()).await { cleanup_in_flight_slot(&cache_key, &slot).await; @@ -431,7 +434,6 @@ out body;"#, async fn get_cached_network(cache_key: String) -> Option { let cache_guard = cache().read().await; if cache_guard.contains_key(&cache_key) { - record_hit(); Some(NetworkRef::new(cache_guard, cache_key)) } else { None @@ -558,17 +560,26 @@ fn is_retryable_error(error: &reqwest::Error) -> bool { error.is_timeout() || error.is_connect() || error.is_request() } -async fn acquire_in_flight_slot(cache_key: &str) -> (Arc>, OwnedMutexGuard<()>) { +async fn acquire_in_flight_slot(cache_key: &str) -> (Arc>, OwnedMutexGuard<()>, bool) { let slot = { let mut in_flight = in_flight_loads().lock().await; - in_flight - .entry(cache_key.to_string()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() + match in_flight.get(cache_key) { + Some(slot) => slot.clone(), + None => { + let slot = Arc::new(Mutex::new(())); + in_flight.insert(cache_key.to_string(), slot.clone()); + slot + } + } }; - let guard = slot.clone().lock_owned().await; - (slot, guard) + match slot.clone().try_lock_owned() { + Ok(guard) => (slot, guard, false), + Err(_) => { + let guard = slot.clone().lock_owned().await; + (slot, guard, true) + } + } } async fn cleanup_in_flight_slot(cache_key: &str, slot: &Arc>) { @@ -591,12 +602,13 @@ mod tests { use std::sync::Arc; use std::sync::OnceLock; use std::thread; - use std::time::{Duration, Instant}; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex; use tokio::time::sleep; use super::*; + use crate::routing::cache::{reset_cache_metrics, CacheStats}; use crate::routing::BoundingBox; static FETCH_TEST_LOCK: OnceLock> = OnceLock::new(); @@ -612,6 +624,28 @@ mod tests { async fn reset_test_state() { RoadNetwork::clear_cache().await; in_flight_loads().lock().await.clear(); + reset_cache_metrics(); + } + + fn unique_cache_dir(prefix: &str) -> std::path::PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "solverforge-maps-{prefix}-{}-{suffix}", + std::process::id() + )) + } + + async fn assert_cache_stats(expected: CacheStats) { + let stats = RoadNetwork::cache_stats().await; + assert_eq!(stats.networks_cached, expected.networks_cached); + assert_eq!(stats.load_requests, expected.load_requests); + assert_eq!(stats.memory_hits, expected.memory_hits); + assert_eq!(stats.disk_hits, expected.disk_hits); + assert_eq!(stats.network_fetches, expected.network_fetches); + assert_eq!(stats.in_flight_waits, expected.in_flight_waits); } #[tokio::test] @@ -685,6 +719,190 @@ mod tests { assert_eq!(loads.load(Ordering::Relaxed), 1); } + #[tokio::test] + async fn load_or_fetch_records_network_then_memory_hit() { + let _guard = fetch_test_lock().lock().await; + reset_test_state().await; + + let bbox = BoundingBox::new(39.95, -75.17, 39.96, -75.16); + let cache_dir = unique_cache_dir("network-memory"); + let (endpoint, requests, handle) = + spawn_overpass_server(vec![("200 OK", overpass_fixture_json())]); + let config = NetworkConfig::new() + .overpass_endpoints(vec![endpoint]) + .cache_dir(&cache_dir) + .overpass_max_retries(0); + + let first = RoadNetwork::load_or_fetch(&bbox, &config, None).await; + assert!(first.is_ok(), "first load should succeed"); + let second = RoadNetwork::load_or_fetch(&bbox, &config, None).await; + assert!(second.is_ok(), "second load should hit memory cache"); + + handle.join().expect("server thread should finish"); + assert_eq!(requests.load(Ordering::Relaxed), 1); + + assert_cache_stats(CacheStats { + networks_cached: 1, + total_nodes: 0, + total_edges: 0, + memory_bytes: 0, + load_requests: 2, + memory_hits: 1, + disk_hits: 0, + network_fetches: 1, + in_flight_waits: 0, + }) + .await; + + let _ = tokio::fs::remove_dir_all(&cache_dir).await; + } + + #[tokio::test] + async fn load_or_fetch_records_disk_hit_without_network_fetch() { + let _guard = fetch_test_lock().lock().await; + reset_test_state().await; + + let bbox = BoundingBox::new(39.95, -75.17, 39.96, -75.16); + let cache_dir = unique_cache_dir("disk-hit"); + tokio::fs::create_dir_all(&cache_dir) + .await + .expect("cache dir should be created"); + let cache_path = cache_dir.join(format!("{}.json", bbox.cache_key())); + let cached = CachedNetwork { + version: CACHE_VERSION, + nodes: vec![ + CachedNode { + lat: 39.95, + lng: -75.16, + }, + CachedNode { + lat: 39.96, + lng: -75.17, + }, + ], + edges: vec![CachedEdge { + from: 0, + to: 1, + travel_time_s: 60.0, + distance_m: 1_000.0, + }], + }; + let data = serde_json::to_string(&cached).expect("cached network should serialize"); + tokio::fs::write(&cache_path, data) + .await + .expect("cache file should be written"); + + let config = NetworkConfig::new().cache_dir(&cache_dir); + let network = RoadNetwork::load_or_fetch(&bbox, &config, None).await; + assert!(network.is_ok(), "disk cache load should succeed"); + + assert_cache_stats(CacheStats { + networks_cached: 1, + total_nodes: 0, + total_edges: 0, + memory_bytes: 0, + load_requests: 1, + memory_hits: 0, + disk_hits: 1, + network_fetches: 0, + in_flight_waits: 0, + }) + .await; + + let _ = tokio::fs::remove_dir_all(&cache_dir).await; + } + + #[tokio::test] + async fn load_or_fetch_records_waiter_for_same_key_contention() { + let _guard = fetch_test_lock().lock().await; + reset_test_state().await; + + let bbox = BoundingBox::new(39.95, -75.17, 39.96, -75.16); + let cache_dir = unique_cache_dir("waiter"); + let (endpoint, requests, handle) = + spawn_overpass_server(vec![("200 OK", overpass_fixture_json())]); + let config = NetworkConfig::new() + .overpass_endpoints(vec![endpoint]) + .cache_dir(&cache_dir) + .overpass_max_retries(0); + + let first = RoadNetwork::load_or_fetch(&bbox, &config, None); + let second = RoadNetwork::load_or_fetch(&bbox, &config, None); + let (left, right) = tokio::join!(first, second); + assert!(left.is_ok(), "first concurrent load should succeed"); + assert!(right.is_ok(), "second concurrent load should succeed"); + + handle.join().expect("server thread should finish"); + assert_eq!(requests.load(Ordering::Relaxed), 1); + + assert_cache_stats(CacheStats { + networks_cached: 1, + total_nodes: 0, + total_edges: 0, + memory_bytes: 0, + load_requests: 2, + memory_hits: 0, + disk_hits: 0, + network_fetches: 1, + in_flight_waits: 1, + }) + .await; + + let _ = tokio::fs::remove_dir_all(&cache_dir).await; + } + + #[tokio::test] + async fn acquire_in_flight_slot_does_not_count_existing_unlocked_slot_as_wait() { + let _guard = fetch_test_lock().lock().await; + reset_test_state().await; + + let key = "burst-window"; + let slot = Arc::new(Mutex::new(())); + in_flight_loads() + .lock() + .await + .insert(key.to_string(), slot.clone()); + + let (_slot, acquired_guard, waited) = acquire_in_flight_slot(key).await; + assert!( + !waited, + "existing slot without lock contention should not count as a wait" + ); + drop(acquired_guard); + + cleanup_in_flight_slot(key, &slot).await; + } + + #[tokio::test] + async fn acquire_in_flight_slot_reports_wait_when_lock_is_held() { + let _guard = fetch_test_lock().lock().await; + reset_test_state().await; + + let key = "held-slot"; + let slot = Arc::new(Mutex::new(())); + let held_guard = slot.clone().lock_owned().await; + in_flight_loads() + .lock() + .await + .insert(key.to_string(), slot.clone()); + + let waiter = tokio::spawn(async move { + let (_slot, guard, waited) = acquire_in_flight_slot(key).await; + (guard, waited) + }); + + tokio::task::yield_now().await; + drop(held_guard); + + let (acquired_guard, waited) = waiter + .await + .expect("waiter task should complete after lock release"); + assert!(waited, "blocked acquisition should count as a wait"); + drop(acquired_guard); + + cleanup_in_flight_slot(key, &slot).await; + } + fn overpass_fixture_json() -> &'static str { r#"{ "elements": [ diff --git a/tests/live_integration.rs b/tests/live_integration.rs index d447058..00c61a1 100644 --- a/tests/live_integration.rs +++ b/tests/live_integration.rs @@ -1,15 +1,29 @@ -//! Opt-in live integration tests for solverforge-maps. +//! Live integration tests for solverforge-maps. //! //! These tests hit public network services and mutable upstream map data. -//! Run them explicitly with: -//! -//! `cargo test --test live_integration -- --ignored` +//! They are enabled only when `SOLVERFORGE_RUN_LIVE_TESTS=1` is set. use serde::Deserialize; use solverforge_maps::{ decode_polyline, haversine_distance, BoundingBox, Coord, NetworkConfig, RoadNetwork, }; -use textplots::{Chart, Plot, Shape}; + +const LIVE_TESTS_ENV: &str = "SOLVERFORGE_RUN_LIVE_TESTS"; + +fn live_tests_enabled() -> bool { + std::env::var(LIVE_TESTS_ENV).is_ok_and(|value| value == "1") +} + +fn require_live_tests() -> bool { + if live_tests_enabled() { + true + } else { + eprintln!( + "live integration tests disabled; set {LIVE_TESTS_ENV}=1 to run external-service checks" + ); + false + } +} fn philadelphia_bbox() -> BoundingBox { BoundingBox::new(39.946, -75.174, 39.962, -75.150) @@ -32,8 +46,11 @@ mod verification { /// Verify every point in the route geometry is within a small tolerance of a network node. #[tokio::test] - #[ignore = "requires live Overpass data"] async fn geometry_points_lie_on_network() { + if !require_live_tests() { + return; + } + let bbox = philadelphia_bbox(); let config = NetworkConfig::default(); @@ -68,8 +85,11 @@ mod verification { /// Use a known straight road segment where distance is predictable. #[tokio::test] - #[ignore = "requires live Overpass data"] async fn known_straight_segment() { + if !require_live_tests() { + return; + } + let bbox = philadelphia_bbox(); let config = NetworkConfig::default(); @@ -101,8 +121,11 @@ mod verification { /// Verify that routing from A to B actually reaches B. #[tokio::test] - #[ignore = "requires live Overpass data"] async fn roundtrip_snap_route_reaches_destination() { + if !require_live_tests() { + return; + } + let bbox = philadelphia_bbox(); let config = NetworkConfig::default(); @@ -159,8 +182,11 @@ mod verification { /// Multiple sanity assertions: distance bounds, speed range, geometry continuity. #[tokio::test] - #[ignore = "requires live Overpass data"] async fn route_sanity_checks() { + if !require_live_tests() { + return; + } + let bbox = philadelphia_bbox(); let config = NetworkConfig::default(); @@ -256,8 +282,11 @@ mod osrm_comparison { } #[tokio::test] - #[ignore = "requires live Overpass and OSRM services"] async fn distance_matches_osrm() { + if !require_live_tests() { + return; + } + let bbox = philadelphia_bbox(); let config = NetworkConfig::default(); @@ -288,8 +317,11 @@ mod osrm_comparison { } #[tokio::test] - #[ignore = "requires live Overpass and OSRM services"] async fn duration_matches_osrm() { + if !require_live_tests() { + return; + } + let bbox = philadelphia_bbox(); let config = NetworkConfig::default(); @@ -320,8 +352,11 @@ mod osrm_comparison { } #[tokio::test] - #[ignore = "requires live Overpass and OSRM services"] async fn geometry_similar_to_osrm() { + if !require_live_tests() { + return; + } + let bbox = philadelphia_bbox(); let config = NetworkConfig::default(); @@ -362,91 +397,3 @@ mod osrm_comparison { } } } - -mod visual { - use super::*; - - struct Location { - name: &'static str, - bbox: BoundingBox, - } - - fn locations() -> Vec { - vec![ - Location { - name: "Philadelphia (City Hall area)", - bbox: philadelphia_bbox(), - }, - Location { - name: "Dragoncello (Poggio Rusco, IT)", - bbox: BoundingBox::new(44.978, 11.095, 44.986, 11.108), - }, - Location { - name: "Clusone (Lombardy, IT)", - bbox: BoundingBox::new(45.882, 9.940, 45.895, 9.960), - }, - ] - } - - fn plot_network(name: &str, network: &RoadNetwork, bbox: &BoundingBox) { - let nodes: Vec<(f64, f64)> = network.nodes_iter().collect(); - let edges: Vec<(usize, usize, f64, f64)> = network.edges_iter().collect(); - - if nodes.is_empty() { - println!("\n{}: No data", name); - return; - } - - let mut segments: Vec<(f32, f32)> = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for &(from, to, _, _) in &edges { - let key = (from.min(to), from.max(to)); - if seen.insert(key) && from < nodes.len() && to < nodes.len() { - let (lat1, lng1) = nodes[from]; - let (lat2, lng2) = nodes[to]; - segments.push((lng1 as f32, lat1 as f32)); - segments.push((lng2 as f32, lat2 as f32)); - segments.push((f32::NAN, f32::NAN)); - } - } - - let intersections: Vec<(f32, f32)> = nodes - .iter() - .map(|(lat, lng)| (*lng as f32, *lat as f32)) - .collect(); - - println!("\n{}", name); - println!( - "{} nodes, {} edges", - network.node_count(), - network.edge_count() - ); - - let x_min = bbox.min_lng as f32; - let x_max = bbox.max_lng as f32; - - Chart::new(180, 60, x_min, x_max) - .lineplot(&Shape::Lines(&segments)) - .lineplot(&Shape::Points(&intersections)) - .nice(); - } - - #[tokio::test] - #[ignore = "requires live Overpass data"] - async fn road_network_visualization() { - let config = NetworkConfig::default(); - - for loc in locations() { - print!("\nFetching {}...", loc.name); - match RoadNetwork::load_or_fetch(&loc.bbox, &config, None).await { - Ok(network_ref) => { - println!(" done"); - plot_network(loc.name, &network_ref, &loc.bbox); - } - Err(e) => { - println!(" failed: {}", e); - } - } - } - } -} From e8ab79038c009ef1660039b3068904879a411a81 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Fri, 27 Mar 2026 12:10:56 +0100 Subject: [PATCH 2/3] docs: update README.md --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3680787..9b4bf5c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Generic map and routing utilities for Vehicle Routing Problems (VRP) and similar ```toml [dependencies] -solverforge-maps = "1.0" +solverforge-maps = "2.1" tokio = { version = "1", features = ["full"] } ``` @@ -398,6 +398,11 @@ let evicted: bool = RoadNetwork::evict(&bbox).await; RoadNetwork::clear_cache().await; ``` +`CacheStats` reports outcome-based cache metrics for `load_or_fetch` requests. +The old aggregate `hits` / `misses` counters are intentionally not exposed +because they did not distinguish memory, disk, network, or contention outcomes +accurately. + --- ### Progress Reporting @@ -584,6 +589,32 @@ RoadNetwork::clear_cache().await; --- +## Testing + +Hermetic tests run by default: + +```bash +cargo test +``` + +Live external-service integration tests are enabled explicitly for local runs: + +```bash +make test-live +``` + +`make test` and `make pre-release` include the live suite locally. GitHub CI +does not set `SOLVERFORGE_RUN_LIVE_TESTS=1`, so workflow runs stay self-contained +and skip external-service checks by policy. + +For manual network visualization against live Overpass data, run: + +```bash +cargo run --example live_network_visualization +``` + +--- + ## License Apache-2.0 From 686b7359cd6d500c1576faa75fa1e9c3a7d89a12 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Fri, 27 Mar 2026 12:11:32 +0100 Subject: [PATCH 3/3] chore(release): 2.1.0 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd16fc9..876f9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [2.1.0](///compare/v2.0.1...v2.1.0) (2026-03-27) + + +### Features + +* expose precise cache-layer metrics a8a6929 + ## [2.1.0](https://github.com/SolverForge/solverforge-maps/compare/v2.0.1...v2.1.0) (2026-03-26) ### Features