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}