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}