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    pub fn begin_frame(&mut self) {
254        if self.config.global_request_budget == 0 {
255            // Coordination disabled -- give each source unlimited budget.
256            for slot in &mut self.slots {
257                slot.budget = usize::MAX;
258                slot.issued = 0;
259                slot.desired.clear();
260            }
261            return;
262        }
263
264        let weights = self.config.weights.unwrap_or([
265            SourcePriority::Raster.default_weight(),
266            SourcePriority::Vector.default_weight(),
267            SourcePriority::Terrain.default_weight(),
268        ]);
269
270        // Phase 1: compute raw proportional budgets.
271        let total_weight: u32 = weights.iter().sum();
272        let budget = self.config.global_request_budget;
273        let mut budgets = [0usize; 3];
274        let mut remainder = budget;
275
276        if total_weight > 0 {
277            for (i, &w) in weights.iter().enumerate() {
278                budgets[i] = budget * w as usize / total_weight as usize;
279                remainder -= budgets[i];
280            }
281            // Distribute rounding remainder to the highest-priority
282            // source(s) that still have demand.
283            for i in 0..3 {
284                if remainder == 0 {
285                    break;
286                }
287                if self.slots[i].demand > 0 {
288                    budgets[i] += 1;
289                    remainder -= 1;
290                }
291            }
292        }
293
294        // Phase 2: redistribute unused budget from sources that have
295        // no demand to those that do.
296        let mut excess = 0usize;
297        for i in 0..3 {
298            if self.slots[i].demand == 0 {
299                excess += budgets[i];
300                budgets[i] = 0;
301            }
302        }
303        if excess > 0 {
304            let needy: Vec<usize> = (0..3)
305                .filter(|&i| self.slots[i].demand > 0)
306                .collect();
307            let needy_weight: u32 = needy.iter().map(|&i| weights[i]).sum();
308            if needy_weight > 0 {
309                let mut leftover = excess;
310                for &i in &needy {
311                    let share = excess * weights[i] as usize / needy_weight as usize;
312                    budgets[i] += share;
313                    leftover -= share;
314                }
315                // Give any leftover to the first needy source.
316                if leftover > 0 {
317                    if let Some(&first) = needy.first() {
318                        budgets[first] += leftover;
319                    }
320                }
321            }
322        }
323
324        for (i, slot) in self.slots.iter_mut().enumerate() {
325            slot.budget = budgets[i];
326            slot.issued = 0;
327            slot.desired.clear();
328        }
329    }
330
331    /// Finish the frame: compute cross-source diagnostics.
332    ///
333    /// Must be called once per frame **after** all source updates.
334    pub fn finish_frame(&mut self) {
335        // Compute shared / unique tile counts.
336        let mut all_tiles: HashSet<TileId> = HashSet::new();
337        let mut total_per_source = 0usize;
338
339        for slot in &self.slots {
340            total_per_source += slot.desired.len();
341            all_tiles.extend(slot.desired.iter());
342        }
343
344        let unique = all_tiles.len();
345        let shared = total_per_source.saturating_sub(unique);
346
347        // Update demand from this frame's desired sets for next frame's
348        // budget allocation.
349        for slot in &mut self.slots {
350            // Demand = the greater of requests actually issued and tiles
351            // that still need loading.  Using only `issued` creates a
352            // deadlock: when budget is 0, issued is 0, so demand stays 0
353            // and the budget never recovers.  `pending_demand` reflects
354            // the true need regardless of the current budget.
355            slot.demand = slot.issued.max(slot.pending_demand);
356        }
357
358        self.stats = CoordinatorStats {
359            budget_total: self.config.global_request_budget,
360            budget_raster: self.slots[0].budget,
361            budget_vector: self.slots[1].budget,
362            budget_terrain: self.slots[2].budget,
363            shared_tile_count: shared,
364            unique_desired_tiles: unique,
365        };
366    }
367
368    /// Read-only access to the most recent cross-source diagnostics.
369    pub fn stats(&self) -> &CoordinatorStats {
370        &self.stats
371    }
372
373    /// Read-only access to the coordinator configuration.
374    pub fn config(&self) -> &CoordinatorConfig {
375        &self.config
376    }
377
378    /// Replace the coordinator configuration.
379    ///
380    /// Takes effect on the next `begin_frame()` call.
381    pub fn set_config(&mut self, config: CoordinatorConfig) {
382        self.config = config;
383    }
384
385    // -- Helpers -----------------------------------------------------------
386
387    /// Map a source priority to a slot index.
388    #[inline]
389    fn idx(priority: SourcePriority) -> usize {
390        match priority {
391            SourcePriority::Raster => 0,
392            SourcePriority::Vector => 1,
393            SourcePriority::Terrain => 2,
394        }
395    }
396}
397
398impl Default for TileRequestCoordinator {
399    fn default() -> Self {
400        Self::new(CoordinatorConfig::default())
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Tests
406// ---------------------------------------------------------------------------
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn default_budget_allocation_respects_weights() {
414        let mut coord = TileRequestCoordinator::default();
415
416        // Simulate demand from all three source classes.
417        coord.slots[0].demand = 10; // raster
418        coord.slots[1].demand = 10; // vector
419        coord.slots[2].demand = 10; // terrain
420
421        coord.begin_frame();
422
423        // Default weights: raster=3, vector=2, terrain=1.  Budget=32.
424        // raster = 32 * 3/6 = 16, vector = 32 * 2/6 = 10,
425        // terrain = 32 * 1/6 = 5, remainder 1 -> raster.
426        assert_eq!(coord.budget_for(SourcePriority::Raster), 17);
427        assert_eq!(coord.budget_for(SourcePriority::Vector), 10);
428        assert_eq!(coord.budget_for(SourcePriority::Terrain), 5);
429
430        // Total should equal global budget.
431        let total = coord.budget_for(SourcePriority::Raster)
432            + coord.budget_for(SourcePriority::Vector)
433            + coord.budget_for(SourcePriority::Terrain);
434        assert_eq!(total, 32);
435    }
436
437    #[test]
438    fn unused_budget_redistributed_to_needy_sources() {
439        let mut coord = TileRequestCoordinator::default();
440
441        // Only raster has demand -- vector and terrain are idle.
442        coord.slots[0].demand = 20; // raster
443        coord.slots[1].demand = 0;  // vector (idle)
444        coord.slots[2].demand = 0;  // terrain (idle)
445
446        coord.begin_frame();
447
448        // Raster should get the entire budget.
449        assert_eq!(coord.budget_for(SourcePriority::Raster), 32);
450        assert_eq!(coord.budget_for(SourcePriority::Vector), 0);
451        assert_eq!(coord.budget_for(SourcePriority::Terrain), 0);
452    }
453
454    #[test]
455    fn zero_budget_disables_coordination() {
456        let config = CoordinatorConfig {
457            global_request_budget: 0,
458            ..Default::default()
459        };
460        let mut coord = TileRequestCoordinator::new(config);
461
462        coord.begin_frame();
463
464        assert_eq!(coord.budget_for(SourcePriority::Raster), usize::MAX);
465        assert_eq!(coord.budget_for(SourcePriority::Vector), usize::MAX);
466        assert_eq!(coord.budget_for(SourcePriority::Terrain), usize::MAX);
467    }
468
469    #[test]
470    fn shared_tile_count_tracks_cross_source_overlap() {
471        let mut coord = TileRequestCoordinator::default();
472        coord.begin_frame();
473
474        let tile_a = TileId::new(5, 10, 12);
475        let tile_b = TileId::new(5, 11, 12);
476        let tile_c = TileId::new(5, 12, 12);
477
478        // Raster wants {a, b}, vector wants {b, c} -- tile_b is shared.
479        coord.report(
480            SourcePriority::Raster,
481            [tile_a, tile_b].into_iter().collect(),
482            2,
483            0,
484        );
485        coord.report(
486            SourcePriority::Vector,
487            [tile_b, tile_c].into_iter().collect(),
488            2,
489            0,
490        );
491
492        coord.finish_frame();
493
494        let stats = coord.stats();
495        assert_eq!(stats.unique_desired_tiles, 3);
496        assert_eq!(stats.shared_tile_count, 1);
497    }
498
499    #[test]
500    fn finish_frame_updates_demand_for_next_frame() {
501        let mut coord = TileRequestCoordinator::default();
502
503        // Frame 1: all sources have demand.
504        coord.slots[0].demand = 10;
505        coord.slots[1].demand = 5;
506        coord.slots[2].demand = 3;
507        coord.begin_frame();
508
509        // Raster issued 8 requests, vector 3, terrain 0.
510        coord.report(SourcePriority::Raster, HashSet::new(), 8, 0);
511        coord.report(SourcePriority::Vector, HashSet::new(), 3, 0);
512        coord.report(SourcePriority::Terrain, HashSet::new(), 0, 0);
513        coord.finish_frame();
514
515        // Frame 2: demand should reflect issued counts from frame 1.
516        coord.begin_frame();
517
518        // Terrain had 0 issued -> 0 demand -> budget redistributed.
519        assert_eq!(coord.budget_for(SourcePriority::Terrain), 0);
520        // Raster and vector should share the full 32.
521        let raster_budget = coord.budget_for(SourcePriority::Raster);
522        let vector_budget = coord.budget_for(SourcePriority::Vector);
523        assert_eq!(raster_budget + vector_budget, 32);
524        // Raster (weight 3) should get more than vector (weight 2).
525        assert!(raster_budget > vector_budget);
526    }
527
528    #[test]
529    fn custom_weights_override_defaults() {
530        let config = CoordinatorConfig {
531            global_request_budget: 20,
532            weights: Some([1, 1, 1]), // equal weights
533        };
534        let mut coord = TileRequestCoordinator::new(config);
535
536        coord.slots[0].demand = 10;
537        coord.slots[1].demand = 10;
538        coord.slots[2].demand = 10;
539        coord.begin_frame();
540
541        // Equal weights: each gets ~6-7 (20/3 with remainder).
542        let r = coord.budget_for(SourcePriority::Raster);
543        let v = coord.budget_for(SourcePriority::Vector);
544        let t = coord.budget_for(SourcePriority::Terrain);
545        assert_eq!(r + v + t, 20);
546        // With equal weights, difference should be at most 1.
547        assert!(r.abs_diff(v) <= 1);
548        assert!(v.abs_diff(t) <= 1);
549    }
550}