use super::cache::TileCache; use super::geometry::*; use super::tile::*; use super::viewport::ViewportState; use makepad_widgets::*; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::Instant; // Security: Rate limiting constants (Phase 4: Security Hardening) const DEFAULT_REQUESTS_PER_SECOND: f64 = 10.0; const MAX_BURST_SIZE: f64 = 10.0; /// Token bucket rate limiter for HTTP requests. /// Prevents API abuse and protects against IP bans. pub struct RateLimiter { tokens: f64, max_tokens: f64, refill_rate: f64, // tokens per second last_refill: Instant, } impl RateLimiter { pub fn new(requests_per_second: f64) -> Self { Self { tokens: requests_per_second.min(MAX_BURST_SIZE), max_tokens: requests_per_second.min(MAX_BURST_SIZE), refill_rate: requests_per_second, last_refill: Instant::now(), } } /// Try to acquire a token. Returns true if successful, false if rate limited. pub fn try_acquire(&mut self) -> bool { self.refill(); if self.tokens >= 1.0 { self.tokens -= 1.0; true } else { false } } fn refill(&mut self) { let now = Instant::now(); let elapsed = now.duration_since(self.last_refill).as_secs_f64(); self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens); self.last_refill = now; } /// Get current token count (for testing/debugging) #[cfg(test)] pub fn tokens(&self) -> f64 { self.tokens } } /// Configuration for the tile scheduler. Decouples scheduling from live properties. pub struct SchedulerConfig { pub use_network: bool, pub use_local_mbtiles: bool, pub max_pending_requests: usize, pub max_local_tile_batch: usize, pub max_tile_retries: u8, pub local_mbtiles_path: String, pub local_tile_cache_dir: String, } impl Default for SchedulerConfig { fn default() -> Self { Self { use_network: true, use_local_mbtiles: true, max_pending_requests: MAX_PENDING_REQUESTS, max_local_tile_batch: MAX_LOCAL_TILE_BATCH, max_tile_retries: MAX_TILE_RETRIES, local_mbtiles_path: LOCAL_MBTILES_PATH.to_string(), local_tile_cache_dir: TILE_CACHE_DIR.to_string(), } } } /// Actions that the scheduler requests the caller to execute. /// This decouples the scheduler from Cx/HTTP/thread pool. pub enum TileAction { /// Load a batch of tiles from local mbtiles source. LoadLocalBatch { mbtiles_path: PathBuf, cache_dir: String, requested: Vec, style_epoch: u64, generation: u64, }, /// Load a single tile from disk cache. LoadFromDiskCache { tile_key: TileKey, cache_path: std::path::PathBuf, style_epoch: u64, generation: u64, }, /// Load a tile from the network via HTTP. LoadFromNetwork { request_id: LiveId, http_request: HttpRequest, tile_key: TileKey, generation: u64, }, /// Nothing to do right now. Nothing, } /// Owns the tile request queue, retry logic, prioritization, and cancellation. /// /// Does NOT know about rendering. Produces `TileAction`s for the caller to execute. pub struct TileScheduler { visible_tiles: Vec, local_requested: HashSet, local_missing: HashSet, request_to_tile: HashMap, next_request_id: u64, local_source_missing_logged: bool, /// Generation counter: incremented on zoom/theme change. /// All requests carry this value; stale results are discarded on arrival. current_generation: u64, /// The zoom level at which the current generation was created. /// Used to detect zoom-level changes that should bump the generation. generation_zoom_level: u32, /// Rate limiter for HTTP requests (Phase 4: Security Hardening) rate_limiter: RateLimiter, } impl Default for TileScheduler { fn default() -> Self { Self { visible_tiles: Vec::new(), local_requested: HashSet::new(), local_missing: HashSet::new(), request_to_tile: HashMap::new(), next_request_id: 1, local_source_missing_logged: false, current_generation: 1, generation_zoom_level: 0, rate_limiter: RateLimiter::new(DEFAULT_REQUESTS_PER_SECOND), } } } impl TileScheduler { pub fn new() -> Self { Self::default() } /// The current generation counter. Worker results with a stale generation are discarded. pub fn current_generation(&self) -> u64 { self.current_generation } /// Force-increment the generation (e.g. on theme change or explicit user action). pub fn reset_generation(&mut self) { self.current_generation = self.current_generation.wrapping_add(1); if self.current_generation == 0 { self.current_generation = 1; } } pub fn visible_tiles(&self) -> &[TileKey] { &self.visible_tiles } pub fn is_local_missing(&self, key: TileKey) -> bool { self.local_missing.contains(&key) } pub fn is_local_requested(&self, key: TileKey) -> bool { self.local_requested.contains(&key) } /// Recompute visible tiles from viewport, update scheduler state. /// Returns true if the visible set changed. /// Automatically bumps generation if the zoom level changed. /// Only recomputes visible tiles when viewport is dirty (Phase 4 optimization). pub fn update_visible(&mut self, viewport: &mut ViewportState) -> bool { // Always compute on first call (visible_tiles is empty) // Only skip recomputation if viewport unchanged AND we have tiles if !self.visible_tiles.is_empty() && !viewport.dirty { return false; } // Use non-allocating method: reuse self.visible_tiles buffer viewport.visible_tile_keys_into(&mut self.visible_tiles); let changed = true; // We always recompute when called // Bump generation on zoom level change (prevents stale results) let new_zoom = self.visible_tiles.first().map(|k| k.z).unwrap_or(0); if new_zoom != self.generation_zoom_level && self.generation_zoom_level != 0 { self.current_generation = self.current_generation.wrapping_add(1); if self.current_generation == 0 { self.current_generation = 1; } } self.generation_zoom_level = new_zoom; viewport.clear_dirty(); changed } /// Core scheduling: determine what tiles need loading and return actions. pub fn schedule( &mut self, cache: &TileCache, config: &SchedulerConfig, style_epoch: u64, ) -> Vec { let mut actions = Vec::new(); let generation = self.current_generation; let _target_zoom = self .visible_tiles .first() .map(|k| k.z) .unwrap_or(0); // 1. Try local mbtiles batch loading if config.use_local_mbtiles { if let Some(action) = self.schedule_local_batch(cache, config, style_epoch, generation) { actions.push(action); } } // 2. Schedule individual tile requests let mut pending = cache.pending_loading(); for key in self.visible_tiles.clone() { // Check retry if let Some(attempts) = cache.should_retry(key, config.max_tile_retries) { if pending < config.max_pending_requests { if let Some(action) = self.schedule_single_tile(key, attempts, config, style_epoch, generation) { actions.push(action); pending += 1; } } continue; } // Skip if already in cache or requested if cache.contains(key) { continue; } if self.local_missing.contains(&key) { if config.use_network && pending < config.max_pending_requests { if let Some(action) = self.schedule_single_tile(key, 0, config, style_epoch, generation) { actions.push(action); pending += 1; } } continue; } // New tile if let Some(action) = self.schedule_single_tile(key, 0, config, style_epoch, generation) { actions.push(action); pending += 1; } } actions } fn schedule_local_batch( &mut self, cache: &TileCache, config: &SchedulerConfig, style_epoch: u64, generation: u64, ) -> Option { let mut missing = Vec::new(); for &key in &self.visible_tiles { if cache.contains(key) || self.local_requested.contains(&key) || self.local_missing.contains(&key) { continue; } missing.push(key); } if missing.is_empty() { return None; } if missing.len() > config.max_local_tile_batch { missing.truncate(config.max_local_tile_batch); } // Check mbtiles path exists let mbtiles_path = resolve_mbtiles_path( &config.local_mbtiles_path, &config.local_tile_cache_dir, ); if !mbtiles_path.is_file() { if !self.local_source_missing_logged { log!( "NigigMapView: local mbtiles source missing at {} (resolved from: {})", mbtiles_path.display(), config.local_mbtiles_path ); self.local_source_missing_logged = true; } return None; } for key in &missing { self.local_requested.insert(*key); } Some(TileAction::LoadLocalBatch { mbtiles_path, cache_dir: config.local_tile_cache_dir.clone(), requested: missing, style_epoch, generation, }) } fn schedule_single_tile( &mut self, tile_key: TileKey, attempts: u8, config: &SchedulerConfig, style_epoch: u64, generation: u64, ) -> Option { // Disk cache path (only for network tiles without local mbtiles) if attempts == 0 && !config.use_local_mbtiles { let cache_path = tile_data_cache_path_for(tile_key); if cache_path.is_file() { return Some(TileAction::LoadFromDiskCache { tile_key, cache_path, style_epoch, generation, }); } } if !config.use_network { return None; } // Security: Rate limiting (Phase 4: Security Hardening) if !self.rate_limiter.try_acquire() { log!("NigigMapView: rate limit exceeded, deferring tile request for {:?}", tile_key); return None; } let request_id = LiveId(self.next_request_id); self.next_request_id = self.next_request_id.wrapping_add(1); if self.next_request_id == 0 { self.next_request_id = 1; } let query = overpass_query(tile_key); let endpoint = overpass_endpoint(attempts); let mut http_request = HttpRequest::new(endpoint.to_string(), HttpMethod::POST); http_request.set_header("Content-Type".to_string(), "text/plain".to_string()); http_request.set_header("Accept".to_string(), "application/json".to_string()); http_request.set_header("User-Agent".to_string(), "makepad-map-view".to_string()); http_request.set_body_string(&query); self.request_to_tile .insert(request_id, PendingTileRequest { tile_key, endpoint, generation }); Some(TileAction::LoadFromNetwork { request_id, http_request, tile_key, generation, }) } // --- Worker message handling --- pub fn on_batch_loaded(&mut self, requested: Vec, loaded_keys: &HashSet) { for key in &requested { self.local_requested.remove(key); if !loaded_keys.contains(key) { self.local_missing.insert(*key); } } } pub fn on_batch_failed(&mut self, requested: Vec) { for key in &requested { self.local_requested.remove(&key); } } pub fn on_tile_loaded(&mut self, _tile_key: TileKey) { // Nothing to clean up for network tiles } pub fn on_tile_failed(&mut self, _tile_key: TileKey) { } // --- HTTP integration --- pub fn on_http_response(&mut self, request_id: LiveId) -> Option<(TileKey, u64)> { self.request_to_tile.remove(&request_id).map(|p| (p.tile_key, p.generation)) } pub fn on_http_error(&mut self, request_id: LiveId) -> Option<(TileKey, u64)> { self.request_to_tile.remove(&request_id).map(|p| (p.tile_key, p.generation)) } pub fn register_http_request(&mut self, request_id: LiveId, tile_key: TileKey) { // Already registered in schedule_single_tile let _ = (request_id, tile_key); } // --- Theme change --- pub fn reset(&mut self) { self.local_requested.clear(); self.local_missing.clear(); self.request_to_tile.clear(); } /// Remove all entries from local_missing so tiles can be re-requested. pub fn clear_missing(&mut self) { self.local_missing.clear(); } } /// Resolve the mbtiles path using multiple fallback locations. pub fn resolve_mbtiles_path(local_mbtiles_path: &str, cache_dir: &str) -> PathBuf { let p = std::path::Path::new(local_mbtiles_path); if p.is_file() { return p.to_path_buf(); } let name = match p.file_name() { Some(n) => n, None => return p.to_path_buf(), }; let cache = std::path::Path::new(cache_dir); let candidate = cache.join("mbtiles").join(name); if candidate.is_file() { return candidate; } if let Ok(env_dir) = std::env::var("ROBRIX_MAP_DATA_DIR") { let candidate = PathBuf::from(env_dir).join("mbtiles").join(name); if candidate.is_file() { return candidate; } } p.to_path_buf() } #[cfg(test)] mod tests { use super::*; fn make_config() -> SchedulerConfig { SchedulerConfig { use_network: true, use_local_mbtiles: true, max_pending_requests: 2, max_local_tile_batch: 10, max_tile_retries: 6, local_mbtiles_path: "test.mbtiles".to_string(), local_tile_cache_dir: "local/tilecache_v5".to_string(), } } #[test] fn scheduler_default_visible_empty() { let s = TileScheduler::new(); assert!(s.visible_tiles().is_empty()); } #[test] fn on_batch_loaded_clears_local_requested() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 0, y: 0 }; s.local_requested.insert(k); let loaded = HashSet::new(); s.on_batch_loaded(vec![k], &loaded); assert!(!s.local_requested.contains(&k)); assert!(s.local_missing.contains(&k), "should be marked missing"); } #[test] fn on_batch_loaded_with_found_tile() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 0, y: 0 }; s.local_requested.insert(k); let mut loaded = HashSet::new(); loaded.insert(k); s.on_batch_loaded(vec![k], &loaded); assert!(!s.local_missing.contains(&k)); } #[test] fn on_batch_failed_clears_local_requested() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 0, y: 0 }; s.local_requested.insert(k); s.on_batch_failed(vec![k]); assert!(!s.local_requested.contains(&k)); assert!(!s.local_missing.contains(&k)); } #[test] fn on_http_response_returns_tile_key() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 1, y: 2 }; s.request_to_tile.insert( LiveId(42), PendingTileRequest { tile_key: k, endpoint: "https://test.com", generation: 7, }, ); assert_eq!(s.on_http_response(LiveId(42)), Some((k, 7))); assert_eq!(s.on_http_response(LiveId(42)), None); // consumed } #[test] fn on_http_error_returns_tile_key() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 1, y: 2 }; s.request_to_tile.insert( LiveId(99), PendingTileRequest { tile_key: k, endpoint: "https://test.com", generation: 3, }, ); assert_eq!(s.on_http_error(LiveId(99)), Some((k, 3))); assert_eq!(s.on_http_error(LiveId(99)), None); } #[test] fn reset_clears_all() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 0, y: 0 }; s.local_requested.insert(k); s.local_missing.insert(k); s.request_to_tile.insert( LiveId(1), PendingTileRequest { tile_key: k, endpoint: "test", generation: 1, }, ); s.reset(); assert!(s.local_requested.is_empty()); assert!(s.local_missing.is_empty()); assert!(s.request_to_tile.is_empty()); } #[test] fn clear_missing_resets() { let mut s = TileScheduler::new(); s.local_missing.insert(TileKey { z: 14, x: 0, y: 0 }); s.local_missing.insert(TileKey { z: 14, x: 1, y: 0 }); s.clear_missing(); assert!(s.local_missing.is_empty()); } #[test] fn request_id_increments() { let mut s = TileScheduler::new(); let id1 = s.next_request_id; // Simulate a schedule call that generates a request let _ = s.schedule_single_tile( TileKey { z: 14, x: 0, y: 0 }, 0, &make_config(), 1, 1, ); assert!(s.next_request_id > id1); } #[test] fn schedule_no_action_when_cache_full() { let mut s = TileScheduler::new(); let mut cache = TileCache::new(100); // Fill cache with all visible tiles let visible = vec![ TileKey { z: 14, x: 0, y: 0 }, TileKey { z: 14, x: 1, y: 0 }, ]; for k in &visible { cache.tiles.insert( *k, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 5, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); } s.visible_tiles = visible; let config = make_config(); let actions = s.schedule(&cache, &config, 1); assert!(actions.is_empty()); } #[test] fn schedule_requests_missing_tiles() { let mut s = TileScheduler::new(); let cache = TileCache::new(100); let k = TileKey { z: 14, x: 0, y: 0 }; s.visible_tiles = vec![k]; // Network only, no mbtiles let mut config = make_config(); config.use_local_mbtiles = false; config.use_network = true; let actions = s.schedule(&cache, &config, 1); assert!(!actions.is_empty(), "should request missing tile"); } #[test] fn schedule_skips_when_nothing_enabled() { let mut s = TileScheduler::new(); let cache = TileCache::new(100); s.visible_tiles = vec![TileKey { z: 14, x: 0, y: 0 }]; let mut config = make_config(); config.use_local_mbtiles = false; config.use_network = false; let actions = s.schedule(&cache, &config, 1); assert!(actions.is_empty()); } #[test] fn schedule_respects_pending_limit() { let mut s = TileScheduler::new(); let mut cache = TileCache::new(100); // Add 3 loading tiles for i in 0..3 { cache.tiles.insert( TileKey { z: 14, x: i, y: 0 }, TileEntry { state: TileLoadState::LoadingNetwork, last_used: 0, attempts: 0, }, ); } s.visible_tiles = (0..5) .map(|i| TileKey { z: 14, x: i, y: 0 }) .collect(); let mut config = make_config(); config.use_local_mbtiles = false; config.use_network = true; config.max_pending_requests = 2; let actions = s.schedule(&cache, &config, 1); // Should not exceed max pending let network_actions = actions .iter() .filter(|a| matches!(a, TileAction::LoadFromNetwork { .. })) .count(); assert!(network_actions <= config.max_pending_requests); } #[test] fn schedule_doesnt_retry_exhausted() { let mut s = TileScheduler::new(); let mut cache = TileCache::new(100); let k = TileKey { z: 14, x: 0, y: 0 }; // Fail with max attempts cache.tiles.insert( k, TileEntry { state: TileLoadState::Failed { retry_after: 0 }, last_used: 0, attempts: 6, }, ); s.visible_tiles = vec![k]; let mut config = make_config(); config.use_local_mbtiles = false; config.use_network = true; config.max_tile_retries = 6; let actions = s.schedule(&cache, &config, 1); assert!(actions.is_empty(), "should not retry exhausted tile"); } #[test] fn scheduler_is_local_missing_check() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 0, y: 0 }; assert!(!s.is_local_missing(k)); s.local_missing.insert(k); assert!(s.is_local_missing(k)); } #[test] fn scheduler_is_local_requested_check() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 0, y: 0 }; assert!(!s.is_local_requested(k)); s.local_requested.insert(k); assert!(s.is_local_requested(k)); } #[test] fn generation_starts_at_1() { let s = TileScheduler::new(); assert_eq!(s.current_generation(), 1); } #[test] fn reset_generation_increments() { let mut s = TileScheduler::new(); let g1 = s.current_generation(); s.reset_generation(); assert_eq!(s.current_generation(), g1 + 1); } #[test] fn reset_generation_wraps_to_1() { let mut s = TileScheduler::new(); s.current_generation = u64::MAX; s.reset_generation(); assert_eq!(s.current_generation(), 1); } #[test] fn zoom_change_bumps_generation() { let mut s = TileScheduler::new(); let mut vp = ViewportState::new(36.8, -1.3, 14.0, 0.0, 20.0); vp.set_rect(Rect { pos: dvec2(0.0, 0.0), size: dvec2(1080.0, 1920.0) }); // First call sets generation_zoom_level s.update_visible(&mut vp); let g1 = s.current_generation(); // Change zoom let mut vp2 = ViewportState::new(36.8, -1.3, 16.0, 0.0, 20.0); vp2.set_rect(Rect { pos: dvec2(0.0, 0.0), size: dvec2(1080.0, 1920.0) }); s.update_visible(&mut vp2); assert!(s.current_generation() > g1, "zoom change should bump generation"); } #[test] fn same_zoom_does_not_bump_generation() { let mut s = TileScheduler::new(); let mut vp = ViewportState::new(36.8, -1.3, 14.0, 0.0, 20.0); vp.set_rect(Rect { pos: dvec2(0.0, 0.0), size: dvec2(1080.0, 1920.0) }); s.update_visible(&mut vp); let g1 = s.current_generation(); // Same zoom, different pan let mut vp2 = ViewportState::new(37.0, -1.5, 14.0, 0.0, 20.0); vp2.set_rect(Rect { pos: dvec2(0.0, 0.0), size: dvec2(1080.0, 1920.0) }); s.update_visible(&mut vp2); assert_eq!(s.current_generation(), g1, "same zoom should not bump generation"); } #[test] fn schedule_actions_carry_generation() { let mut s = TileScheduler::new(); let mut vp = ViewportState::new(36.8, -1.3, 14.0, 0.0, 20.0); vp.set_rect(Rect { pos: dvec2(0.0, 0.0), size: dvec2(1080.0, 1920.0) }); s.update_visible(&mut vp); let gen = s.current_generation(); let cache = TileCache::new(100); let mut config = make_config(); config.use_local_mbtiles = false; config.use_network = true; let actions = s.schedule(&cache, &config, 1); for action in &actions { match action { TileAction::LoadFromNetwork { generation: g, .. } => { assert_eq!(*g, gen); } TileAction::LoadLocalBatch { generation: g, .. } => { assert_eq!(*g, gen); } TileAction::LoadFromDiskCache { generation: g, .. } => { assert_eq!(*g, gen); } _ => {} } } } #[test] fn http_response_includes_generation() { let mut s = TileScheduler::new(); let k = TileKey { z: 14, x: 1, y: 2 }; s.request_to_tile.insert( LiveId(10), PendingTileRequest { tile_key: k, endpoint: "https://test.com", generation: 5, }, ); let (key, gen) = s.on_http_response(LiveId(10)).unwrap(); assert_eq!(key, k); assert_eq!(gen, 5); } #[test] fn update_visible_skips_when_not_dirty() { let mut s = TileScheduler::new(); let mut vp = ViewportState::new(36.8, -1.3, 14.0, 0.0, 20.0); vp.set_rect(Rect { pos: dvec2(0.0, 0.0), size: dvec2(1080.0, 1920.0) }); // First call: dirty=true, computes visible tiles let changed = s.update_visible(&mut vp); assert!(changed, "first call should compute and return changed"); assert!(!vp.dirty, "dirty should be cleared after update"); assert!(!s.visible_tiles().is_empty()); // Second call without changes: dirty=false, should skip recomputation let changed2 = s.update_visible(&mut vp); assert!(!changed2, "should skip when not dirty"); } #[test] fn update_visible_recomputes_when_dirty() { let mut s = TileScheduler::new(); let mut vp = ViewportState::new(36.8, -1.3, 14.0, 0.0, 20.0); vp.set_rect(Rect { pos: dvec2(0.0, 0.0), size: dvec2(1080.0, 1920.0) }); s.update_visible(&mut vp); assert!(!vp.dirty); // Apply a drag to set dirty vp.apply_drag(dvec2(100.0, 0.0)); assert!(vp.dirty, "drag should set dirty"); // Update should recompute let changed = s.update_visible(&mut vp); assert!(changed, "should recompute when dirty"); assert!(!vp.dirty, "dirty should be cleared"); } }