Skip to main content

rustial_engine/
tile_request_coordinator.rs

1// ---------------------------------------------------------------------------
2//! # Cross-source tile request coordinator
3//!
4//! [`TileRequestCoordinator`] centralises tile-request scheduling across
5//! all active sources (raster tile layers, streamed vector sources, terrain
6//! DEM sources) so that requests are batched, prioritised, and rate-limited
7//! at the **map** level rather than the individual source level.
8//!
9//! ## Motivation
10//!
11//! In MapLibre GL JS the `SourceCache` object batches tile operations
12//! across sources and the `Scheduler` enforces a global worker budget.
13//! Without a coordinator, each Rustial source independently queues
14//! requests against its own `FetchPool` / `HttpClient`, leading to:
15//!
16//! - **Bandwidth contention** -- raster and vector sources compete for
17//!   the same connection slots, starving whichever starts later.
18//! - **Priority inversion** -- a burst of low-priority vector tile
19//!   requests can delay the more user-visible raster imagery.
20//! - **Redundant selection** -- each source independently computes the
21//!   same tile-coverage set from the camera, duplicating work.
22//!
23//! ## Architecture
24//!
25//! ```text
26//! MapState::update_tile_layers()
27//!     |
28//!     +-- raster TileLayer::update_with_view()
29//!     |       reports request_count -> coordinator
30//!     |
31//!     +-- vector SourceLayer::update_with_view()  (x N)
32//!     |       reports request_count -> coordinator
33//!     |
34//!     +-- TerrainManager::update()
35//!             reports request_count -> coordinator
36//!
37//! coordinator.finish_frame()
38//!     -> computes per-source budget for next frame
39//!     -> records cross-source diagnostics
40//! ```
41//!
42//! ## Budget allocation
43//!
44//! Each frame, the coordinator distributes a global request budget
45//! across source classes using configurable priority weights.
46//! The default weights (`raster=3, vector=2, terrain=1`) ensure that
47//! raster imagery -- the most visible layer -- receives the lion's
48//! share of available request slots during contention.
49//!
50//! When a source class has no pending requests, its unused budget is
51//! redistributed to the remaining classes proportionally.
52//!
53//! ## Integration
54//!
55//! The coordinator is **non-invasive**: it does not modify `TileManager`
56//! internals.  Instead, `MapState` queries the coordinator for per-source
57//! request budgets before calling each source's update method, and
58//! `TileSelectionConfig::max_requests_per_frame` limits how many new
59//! tile requests `TileManager` issues in a single update pass.
60// ---------------------------------------------------------------------------
61
62use rustial_math::TileId;
63use std::collections::HashSet;
64
65// ---------------------------------------------------------------------------
66// Configuration
67// ---------------------------------------------------------------------------
68
69/// Priority class for a tile source.
70///
71/// Higher-priority classes receive a larger share of the global
72/// per-frame request budget.  The numeric weight is used directly
73/// in the proportional budget allocation.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum SourcePriority {
76    /// Raster imagery -- the most user-visible layer.
77    Raster,
78    /// Streamed vector tile sources.
79    Vector,
80    /// Terrain DEM elevation data.
81    Terrain,
82}
83
84impl SourcePriority {
85    /// Default priority weight.
86    ///
87    /// Raster gets the highest share because blank tiles are the
88    /// most visible artefact.  Vector and terrain are lower because
89    /// they are either overlays or only affect depth/elevation.
90    fn default_weight(self) -> u32 {
91        match self {
92            Self::Raster => 3,
93            Self::Vector => 2,
94            Self::Terrain => 1,
95        }
96    }
97}
98
99/// Configuration for the cross-source request coordinator.
100#[derive(Debug, Clone)]
101pub struct CoordinatorConfig {
102    /// Maximum number of new tile requests that may be issued across
103    /// **all** sources in a single frame.
104    ///
105    /// This global cap prevents a sudden camera move from flooding the
106    /// network with hundreds of queued requests.  The budget is split
107    /// among source classes proportionally to their weights.
108    ///
109    /// Set to `0` to disable coordination (each source uses its own
110    /// internal limits).  Defaults to 32.
111    pub global_request_budget: usize,
112
113    /// Per-source-class priority weight overrides.
114    ///
115    /// If `None`, the default weights are used (raster=3, vector=2,
116    /// terrain=1).  The weights are relative -- only their ratio
117    /// matters.
118    pub weights: Option<[u32; 3]>,
119}
120
121impl Default for CoordinatorConfig {
122    fn default() -> Self {
123        Self {
124            global_request_budget: 32,
125            weights: None,
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Per-source tracking
132// ---------------------------------------------------------------------------
133
134/// Per-source-class state tracked across frames.
135#[derive(Debug, Default)]
136struct SourceSlot {
137    /// Number of tile requests the source reported as pending (wanted
138    /// but not yet issued) at the end of the previous frame.
139    demand: usize,
140    /// Budget allocated for this frame (set by `begin_frame`).
141    budget: usize,
142    /// Number of requests the source actually issued this frame.
143    issued: usize,
144    /// Number of desired tiles that still need loading (fallback +
145    /// missing), reported by the source alongside `issued`.
146    pending_demand: usize,
147    /// Tile IDs that the source reported as its desired set this frame.
148    desired: HashSet<TileId>,
149}
150
151// ---------------------------------------------------------------------------
152// Diagnostics
153// ---------------------------------------------------------------------------
154
155/// Per-frame cross-source coordination diagnostics.
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct CoordinatorStats {
158    /// Total global request budget for this frame.
159    pub budget_total: usize,
160    /// Budget allocated to raster sources.
161    pub budget_raster: usize,
162    /// Budget allocated to vector sources.
163    pub budget_vector: usize,
164    /// Budget allocated to terrain sources.
165    pub budget_terrain: usize,
166    /// Number of tile IDs that appeared in more than one source's
167    /// desired set (shared tiles).
168    pub shared_tile_count: usize,
169    /// Number of unique tile IDs across all source desired sets.
170    pub unique_desired_tiles: usize,
171}
172
173// ---------------------------------------------------------------------------
174// Coordinator
175// ---------------------------------------------------------------------------
176
177/// Centralises tile-request scheduling across all active sources.
178///
179/// See the [module-level documentation](self) for the design rationale
180/// and integration points.
181#[derive(Debug)]
182pub struct TileRequestCoordinator {
183    config: CoordinatorConfig,
184    slots: [SourceSlot; 3],
185    stats: CoordinatorStats,
186}
187
188impl TileRequestCoordinator {
189    /// Create a new coordinator with the given configuration.
190    pub fn new(config: CoordinatorConfig) -> Self {
191        Self {
192            config,
193            slots: Default::default(),
194            stats: CoordinatorStats::default(),
195        }
196    }
197
198    // -- Budget queries (called before each source updates) ----------------
199
200    /// Return the request budget allocated for `priority` this frame.
201    ///
202    /// `MapState` should call this before invoking
203    /// `TileLayer::update_with_view` and pass the result as
204    /// `TileSelectionConfig::max_requests_per_frame` so that the
205    /// `TileManager` respects the coordinator's global budget.
206    ///
207    /// Returns `usize::MAX` when coordination is disabled
208    /// (`global_request_budget == 0`).
209    pub fn budget_for(&self, priority: SourcePriority) -> usize {
210        if self.config.global_request_budget == 0 {
211            return usize::MAX;
212        }
213        self.slots[Self::idx(priority)].budget
214    }
215
216    // -- Source registration (called after each source updates) ------------
217
218    /// Record the desired tile set and request count for a source class.
219    ///
220    /// `desired` is the set of tile IDs the source wants to render.
221    /// `issued` is the number of new HTTP requests actually dispatched
222    /// during this frame's update.
223    /// `pending_demand` is the number of desired tiles that still need
224    /// loading (fallback + missing).  This drives the budget allocation
225    /// for the next frame even when the current frame's budget was zero.
226    pub fn report(
227        &mut self,
228        priority: SourcePriority,
229        desired: HashSet<TileId>,
230        issued: usize,
231        pending_demand: usize,
232    ) {
233        let slot = &mut self.slots[Self::idx(priority)];
234        slot.desired = desired;
235        slot.issued = issued;
236        slot.pending_demand = pending_demand;
237    }
238
239    /// Record only the demand (pending request count) for a source class,
240    /// without replacing its desired tile set.
241    ///
242    /// Useful for sources that don't expose their full desired set
243    /// (e.g. terrain DEM, which computes tiles internally).
244    pub fn report_demand(&mut self, priority: SourcePriority, demand: usize) {
245        self.slots[Self::idx(priority)].demand = demand;
246    }
247
248    // -- Frame lifecycle ---------------------------------------------------
249
250    /// Begin a new frame: allocate per-source budgets from the global cap.
251    ///
252    /// Must be called once per frame **before** any source updates.
253    #[allow(clippy::needless_range_loop)]
254    pub fn begin_frame(&mut self) {
255        if self.config.global_request_budget == 0 {
256            // Coordination disabled -- give each source unlimited budget.
257            for slot in &mut self.slots {
258                slot.budget = usize::MAX;
259                slot.issued = 0;
260                slot.desired.clear();
261            }
262            return;
263        }
264
265        let weights = self.config.weights.unwrap_or([
266            SourcePriority::Raster.default_weight(),
267            SourcePriority::Vector.default_weight(),
268            SourcePriority::Terrain.default_weight(),
269        ]);
270
271        // Phase 1: compute raw proportional budgets.
272        let total_weight: u32 = weights.iter().sum();
273        let budget = self.config.global_request_budget;
274        let mut budgets = [0usize; 3];
275        let mut remainder = budget;
276
277        if total_weight > 0 {
278            for (i, &w) in weights.iter().enumerate() {
279                budgets[i] = budget * w as usize / total_weight as usize;
280                remainder -= budgets[i];
281            }
282            // Distribute rounding remainder to the highest-priority
283            // source(s) that still have demand.
284            for i in 0..3 {
285                if remainder == 0 {
286                    break;
287                }
288                if self.slots[i].demand > 0 {
289                    budgets[i] += 1;
290                    remainder -= 1;
291                }
292            }
293        }
294
295        // Phase 2: redistribute unused budget from sources that have
296        // no demand to those that do.
297        let mut excess = 0usize;
298        for i in 0..3 {
299            if self.slots[i].demand == 0 {
300                excess += budgets[i];
301                budgets[i] = 0;
302            }
303        }
304        if excess > 0 {
305            let needy: Vec<usize> = (0..3).filter(|&i| self.slots[i].demand > 0).collect();
306            let needy_weight: u32 = needy.iter().map(|&i| weights[i]).sum();
307            if needy_weight > 0 {
308                let mut leftover = excess;
309                for &i in &needy {
310                    let share = excess * weights[i] as usize / needy_weight as usize;
311                    budgets[i] += share;
312                    leftover -= share;
313                }
314                // Give any leftover to the first needy source.
315                if leftover > 0 {
316                    if let Some(&first) = needy.first() {
317                        budgets[first] += leftover;
318                    }
319                }
320            }
321        }
322
323        for (i, slot) in self.slots.iter_mut().enumerate() {
324            slot.budget = budgets[i];
325            slot.issued = 0;
326            slot.desired.clear();
327        }
328    }
329
330    /// Finish the frame: compute cross-source diagnostics.
331    ///
332    /// Must be called once per frame **after** all source updates.
333    pub fn finish_frame(&mut self) {
334        // Compute shared / unique tile counts.
335        let mut all_tiles: HashSet<TileId> = HashSet::new();
336        let mut total_per_source = 0usize;
337
338        for slot in &self.slots {
339            total_per_source += slot.desired.len();
340            all_tiles.extend(slot.desired.iter());
341        }
342
343        let unique = all_tiles.len();
344        let shared = total_per_source.saturating_sub(unique);
345
346        // Update demand from this frame's desired sets for next frame's
347        // budget allocation.
348        for slot in &mut self.slots {
349            // Demand = the greater of requests actually issued and tiles
350            // that still need loading.  Using only `issued` creates a
351            // deadlock: when budget is 0, issued is 0, so demand stays 0
352            // and the budget never recovers.  `pending_demand` reflects
353            // the true need regardless of the current budget.
354            slot.demand = slot.issued.max(slot.pending_demand);
355        }
356
357        self.stats = CoordinatorStats {
358            budget_total: self.config.global_request_budget,
359            budget_raster: self.slots[0].budget,
360            budget_vector: self.slots[1].budget,
361            budget_terrain: self.slots[2].budget,
362            shared_tile_count: shared,
363            unique_desired_tiles: unique,
364        };
365    }
366
367    /// Read-only access to the most recent cross-source diagnostics.
368    pub fn stats(&self) -> &CoordinatorStats {
369        &self.stats
370    }
371
372    /// Read-only access to the coordinator configuration.
373    pub fn config(&self) -> &CoordinatorConfig {
374        &self.config
375    }
376
377    /// Replace the coordinator configuration.
378    ///
379    /// Takes effect on the next `begin_frame()` call.
380    pub fn set_config(&mut self, config: CoordinatorConfig) {
381        self.config = config;
382    }
383
384    // -- Helpers -----------------------------------------------------------
385
386    /// Map a source priority to a slot index.
387    #[inline]
388    fn idx(priority: SourcePriority) -> usize {
389        match priority {
390            SourcePriority::Raster => 0,
391            SourcePriority::Vector => 1,
392            SourcePriority::Terrain => 2,
393        }
394    }
395}
396
397impl Default for TileRequestCoordinator {
398    fn default() -> Self {
399        Self::new(CoordinatorConfig::default())
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Tests
405// ---------------------------------------------------------------------------
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn default_budget_allocation_respects_weights() {
413        let mut coord = TileRequestCoordinator::default();
414
415        // Simulate demand from all three source classes.
416        coord.slots[0].demand = 10; // raster
417        coord.slots[1].demand = 10; // vector
418        coord.slots[2].demand = 10; // terrain
419
420        coord.begin_frame();
421
422        // Default weights: raster=3, vector=2, terrain=1.  Budget=32.
423        // raster = 32 * 3/6 = 16, vector = 32 * 2/6 = 10,
424        // terrain = 32 * 1/6 = 5, remainder 1 -> raster.
425        assert_eq!(coord.budget_for(SourcePriority::Raster), 17);
426        assert_eq!(coord.budget_for(SourcePriority::Vector), 10);
427        assert_eq!(coord.budget_for(SourcePriority::Terrain), 5);
428
429        // Total should equal global budget.
430        let total = coord.budget_for(SourcePriority::Raster)
431            + coord.budget_for(SourcePriority::Vector)
432            + coord.budget_for(SourcePriority::Terrain);
433        assert_eq!(total, 32);
434    }
435
436    #[test]
437    fn unused_budget_redistributed_to_needy_sources() {
438        let mut coord = TileRequestCoordinator::default();
439
440        // Only raster has demand -- vector and terrain are idle.
441        coord.slots[0].demand = 20; // raster
442        coord.slots[1].demand = 0; // vector (idle)
443        coord.slots[2].demand = 0; // terrain (idle)
444
445        coord.begin_frame();
446
447        // Raster should get the entire budget.
448        assert_eq!(coord.budget_for(SourcePriority::Raster), 32);
449        assert_eq!(coord.budget_for(SourcePriority::Vector), 0);
450        assert_eq!(coord.budget_for(SourcePriority::Terrain), 0);
451    }
452
453    #[test]
454    fn zero_budget_disables_coordination() {
455        let config = CoordinatorConfig {
456            global_request_budget: 0,
457            ..Default::default()
458        };
459        let mut coord = TileRequestCoordinator::new(config);
460
461        coord.begin_frame();
462
463        assert_eq!(coord.budget_for(SourcePriority::Raster), usize::MAX);
464        assert_eq!(coord.budget_for(SourcePriority::Vector), usize::MAX);
465        assert_eq!(coord.budget_for(SourcePriority::Terrain), usize::MAX);
466    }
467
468    #[test]
469    fn shared_tile_count_tracks_cross_source_overlap() {
470        let mut coord = TileRequestCoordinator::default();
471        coord.begin_frame();
472
473        let tile_a = TileId::new(5, 10, 12);
474        let tile_b = TileId::new(5, 11, 12);
475        let tile_c = TileId::new(5, 12, 12);
476
477        // Raster wants {a, b}, vector wants {b, c} -- tile_b is shared.
478        coord.report(
479            SourcePriority::Raster,
480            [tile_a, tile_b].into_iter().collect(),
481            2,
482            0,
483        );
484        coord.report(
485            SourcePriority::Vector,
486            [tile_b, tile_c].into_iter().collect(),
487            2,
488            0,
489        );
490
491        coord.finish_frame();
492
493        let stats = coord.stats();
494        assert_eq!(stats.unique_desired_tiles, 3);
495        assert_eq!(stats.shared_tile_count, 1);
496    }
497
498    #[test]
499    fn finish_frame_updates_demand_for_next_frame() {
500        let mut coord = TileRequestCoordinator::default();
501
502        // Frame 1: all sources have demand.
503        coord.slots[0].demand = 10;
504        coord.slots[1].demand = 5;
505        coord.slots[2].demand = 3;
506        coord.begin_frame();
507
508        // Raster issued 8 requests, vector 3, terrain 0.
509        coord.report(SourcePriority::Raster, HashSet::new(), 8, 0);
510        coord.report(SourcePriority::Vector, HashSet::new(), 3, 0);
511        coord.report(SourcePriority::Terrain, HashSet::new(), 0, 0);
512        coord.finish_frame();
513
514        // Frame 2: demand should reflect issued counts from frame 1.
515        coord.begin_frame();
516
517        // Terrain had 0 issued -> 0 demand -> budget redistributed.
518        assert_eq!(coord.budget_for(SourcePriority::Terrain), 0);
519        // Raster and vector should share the full 32.
520        let raster_budget = coord.budget_for(SourcePriority::Raster);
521        let vector_budget = coord.budget_for(SourcePriority::Vector);
522        assert_eq!(raster_budget + vector_budget, 32);
523        // Raster (weight 3) should get more than vector (weight 2).
524        assert!(raster_budget > vector_budget);
525    }
526
527    #[test]
528    fn custom_weights_override_defaults() {
529        let config = CoordinatorConfig {
530            global_request_budget: 20,
531            weights: Some([1, 1, 1]), // equal weights
532        };
533        let mut coord = TileRequestCoordinator::new(config);
534
535        coord.slots[0].demand = 10;
536        coord.slots[1].demand = 10;
537        coord.slots[2].demand = 10;
538        coord.begin_frame();
539
540        // Equal weights: each gets ~6-7 (20/3 with remainder).
541        let r = coord.budget_for(SourcePriority::Raster);
542        let v = coord.budget_for(SourcePriority::Vector);
543        let t = coord.budget_for(SourcePriority::Terrain);
544        assert_eq!(r + v + t, 20);
545        // With equal weights, difference should be at most 1.
546        assert!(r.abs_diff(v) <= 1);
547        assert!(v.abs_diff(t) <= 1);
548    }
549}