Skip to main content

sl_map_web/
jobs.rs

1//! In-memory store of render jobs and the per-job state used by handlers.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use bytes::Bytes;
8use serde::{Deserialize, Serialize};
9use sl_map_apis::map_tiles::{MapProgressEvent, TileOutcome};
10use sl_types::map::MapTileDescriptor;
11use tokio::sync::{Mutex, broadcast, watch};
12use uuid::Uuid;
13
14/// Unique identifier for a render job.
15pub type JobId = Uuid;
16
17/// Serializable view of a [`MapProgressEvent`] for the SSE stream.
18///
19/// The `MapProgressEvent` from `sl-map-apis` is not `Serialize`; this DTO
20/// translates each variant into a JSON-friendly shape.
21#[derive(Debug, Clone, Serialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum ProgressDto {
24    /// the rendering plan has been computed.
25    PlanComputed {
26        /// chosen zoom level for the render.
27        zoom_level: u8,
28        /// total number of tiles that will be fetched / processed.
29        total_tiles: u32,
30    },
31    /// processing of a tile has started.
32    TileStarted {
33        /// zoom level of the tile.
34        zoom: u8,
35        /// lower-left x grid coordinate of the tile.
36        x: u32,
37        /// lower-left y grid coordinate of the tile.
38        y: u32,
39    },
40    /// processing of a tile has finished.
41    TileFinished {
42        /// zoom level of the tile.
43        zoom: u8,
44        /// lower-left x grid coordinate of the tile.
45        x: u32,
46        /// lower-left y grid coordinate of the tile.
47        y: u32,
48        /// where the tile came from (memory, disk, network, missing).
49        outcome: &'static str,
50    },
51    /// the renderer will check per-region existence (triggered by the
52    /// `missing_region_color` option). Often slower than the main tile
53    /// fetch because each check may pull several higher-zoom tiles.
54    RegionCheckPlanned {
55        /// upper bound on the number of region checks.
56        total_regions: u32,
57    },
58    /// a region's existence has been determined.
59    RegionChecked {
60        /// x grid coordinate of the region.
61        x: u32,
62        /// y grid coordinate of the region.
63        y: u32,
64        /// whether the region exists.
65        exists: bool,
66    },
67    /// the route drawing phase has started.
68    RoutePlanned {
69        /// number of waypoints in the route.
70        total_waypoints: usize,
71    },
72    /// a waypoint in the route has been resolved.
73    RouteWaypointResolved {
74        /// 0-based index of the waypoint.
75        index: usize,
76        /// total number of waypoints.
77        total: usize,
78        /// resolved region name.
79        region: String,
80    },
81    /// resolving region names for the per-region annotation overlay has
82    /// started.
83    RegionNamesPlanned {
84        /// number of regions whose names will be resolved.
85        total_regions: u32,
86    },
87    /// one region's name has been resolved for the overlay.
88    RegionNameResolved {
89        /// 0-based index of the region.
90        index: u32,
91        /// total number of regions whose names will be resolved.
92        total: u32,
93    },
94    /// the job has finished successfully.
95    Done,
96    /// the job has finished with an error.
97    Error {
98        /// human-readable error message.
99        message: String,
100    },
101}
102
103impl From<MapProgressEvent> for ProgressDto {
104    fn from(value: MapProgressEvent) -> Self {
105        match value {
106            MapProgressEvent::PlanComputed {
107                zoom_level,
108                total_tiles,
109            } => Self::PlanComputed {
110                zoom_level: zoom_level.into_inner(),
111                total_tiles,
112            },
113            MapProgressEvent::TileStarted { descriptor } => {
114                let (zoom, x, y) = split_descriptor(&descriptor);
115                Self::TileStarted { zoom, x, y }
116            }
117            MapProgressEvent::TileFinished {
118                descriptor,
119                outcome,
120            } => {
121                let (zoom, x, y) = split_descriptor(&descriptor);
122                Self::TileFinished {
123                    zoom,
124                    x,
125                    y,
126                    outcome: outcome_str(outcome),
127                }
128            }
129            MapProgressEvent::RegionCheckPlanned { total_regions } => {
130                Self::RegionCheckPlanned { total_regions }
131            }
132            MapProgressEvent::RegionChecked { x, y, exists } => {
133                Self::RegionChecked { x, y, exists }
134            }
135            MapProgressEvent::RoutePlanned { total_waypoints } => {
136                Self::RoutePlanned { total_waypoints }
137            }
138            MapProgressEvent::RouteWaypointResolved {
139                index,
140                total,
141                region,
142            } => Self::RouteWaypointResolved {
143                index,
144                total,
145                region: region.into_inner(),
146            },
147            MapProgressEvent::RegionNamesPlanned { total_regions } => {
148                Self::RegionNamesPlanned { total_regions }
149            }
150            MapProgressEvent::RegionNameResolved { index, total } => {
151                Self::RegionNameResolved { index, total }
152            }
153        }
154    }
155}
156
157/// Extract `(zoom, x, y)` from a `MapTileDescriptor` for JSON output.
158fn split_descriptor(descriptor: &MapTileDescriptor) -> (u8, u32, u32) {
159    let zoom = (*descriptor.zoom_level()).into_inner();
160    let corner = descriptor.lower_left_corner();
161    (zoom, corner.x(), corner.y())
162}
163
164/// Map a [`TileOutcome`] to a short stable string for the JSON payload.
165const fn outcome_str(outcome: TileOutcome) -> &'static str {
166    match outcome {
167        TileOutcome::LoadedFromMemoryCache => "memory",
168        TileOutcome::LoadedFromDiskCache => "disk",
169        TileOutcome::FetchedFromNetwork => "network",
170        TileOutcome::Missing => "missing",
171    }
172}
173
174/// Metadata returned alongside a finished render (mirrors what the CLI
175/// prints to stdout / writes to the metadata file).
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Metadata {
178    /// the x size of the rendered grid rectangle (in regions).
179    pub aspect_x: u32,
180    /// the y size of the rendered grid rectangle (in regions).
181    pub aspect_y: u32,
182    /// width / height as a float.
183    pub aspect_ratio: f64,
184    /// the PPS HUD config string (suitable for the dot prim description).
185    pub pps_hud_config: String,
186}
187
188/// The terminal state of a job once the render task has finished.
189#[derive(Debug, Clone)]
190pub enum JobOutcome {
191    /// the render succeeded; the bytes and metadata are stored in memory.
192    Ok {
193        /// the encoded primary image.
194        image: Bytes,
195        /// optional encoded image without the route overlay.
196        image_without_route: Option<Bytes>,
197        /// the MIME type matching the encoded format.
198        content_type: &'static str,
199        /// metadata about the render (aspect ratio, PPS HUD config).
200        metadata: Metadata,
201        /// id of the `saved_glw_data` row referenced by the render, if a
202        /// GLW overlay was actually drawn (None when no GLW was
203        /// requested or the server returned no event).
204        glw_data_id: Option<uuid::Uuid>,
205    },
206    /// the render failed.
207    Err(String),
208}
209
210/// Per-job runtime state.
211#[derive(Debug)]
212pub struct JobState {
213    /// instant the job was created (used for TTL eviction).
214    pub created: Instant,
215    /// recorded progress events; the SSE handler reads from this and the
216    /// render task appends to it as events arrive on the mpsc channel.
217    pub events: Mutex<Vec<ProgressDto>>,
218    /// broadcast "ping" channel used to wake up SSE handlers; the actual
219    /// event content is read from `events`, this is just a signal.
220    pub ping: broadcast::Sender<()>,
221    /// the terminal outcome, set once the render task finishes.
222    pub outcome: watch::Sender<Option<Arc<JobOutcome>>>,
223}
224
225impl JobState {
226    /// Create a fresh job state with empty buffers and a `None` outcome.
227    #[must_use]
228    pub fn new() -> Self {
229        // a generous broadcast capacity so that a slow SSE handler does
230        // not lose pings; the actual events live in `events` so a lag here
231        // just causes a re-poll, not data loss.
232        let (ping, _) = broadcast::channel(64);
233        let (outcome, _) = watch::channel(None);
234        Self {
235            created: Instant::now(),
236            events: Mutex::new(Vec::new()),
237            ping,
238            outcome,
239        }
240    }
241}
242
243impl Default for JobState {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249/// In-memory map of job ids to job state.
250#[derive(Debug, Default)]
251pub struct JobStore {
252    /// inner map; mutex-wrapped so handlers can mutate concurrently.
253    inner: Mutex<HashMap<JobId, Arc<JobState>>>,
254}
255
256impl JobStore {
257    /// Create an empty store.
258    #[must_use]
259    pub fn new() -> Self {
260        Self::default()
261    }
262
263    /// Register a new job, returning its id and shared state handle.
264    pub async fn create(&self) -> (JobId, Arc<JobState>) {
265        self.create_with_id(Uuid::new_v4()).await
266    }
267
268    /// Register a new job using a caller-supplied id. Used so the persisted
269    /// `saved_renders.render_id` and the in-memory job id are the same UUID,
270    /// keeping the existing `/api/render/{id}/...` endpoints addressing the
271    /// same render as the new `/api/renders/{id}/...` ones.
272    pub async fn create_with_id(&self, id: JobId) -> (JobId, Arc<JobState>) {
273        let state = Arc::new(JobState::new());
274        {
275            let mut guard = self.inner.lock().await;
276            drop(guard.insert(id, Arc::clone(&state)));
277        }
278        (id, state)
279    }
280
281    /// Look up the job state for a given id.
282    pub async fn get(&self, id: JobId) -> Option<Arc<JobState>> {
283        let guard = self.inner.lock().await;
284        guard.get(&id).map(Arc::clone)
285    }
286
287    /// Evict all jobs whose age exceeds `max_age` and which have already
288    /// finished. Running jobs are never evicted so an in-flight render can
289    /// complete.
290    pub async fn evict_older_than(&self, max_age: Duration) {
291        let now = Instant::now();
292        let mut guard = self.inner.lock().await;
293        guard.retain(|_, state| {
294            let age = now.saturating_duration_since(state.created);
295            let finished = state.outcome.borrow().is_some();
296            !finished || age <= max_age
297        });
298    }
299}
300
301/// Helper for the render task: push an event into the job's history and
302/// signal the SSE handlers.
303pub async fn record_event(state: &JobState, event: ProgressDto) {
304    {
305        let mut events = state.events.lock().await;
306        events.push(event);
307    }
308    // best-effort: no receivers is fine, that just means no-one is
309    // listening for live updates yet.
310    drop(state.ping.send(()));
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use pretty_assertions::assert_eq;
317
318    /// The region-name overlay progress events must serialise to the exact
319    /// `type` tags and field names the client's `handleProgress` switch reads.
320    #[test]
321    fn region_name_progress_events_serialise_for_the_client()
322    -> Result<(), Box<dyn std::error::Error>> {
323        let planned = ProgressDto::from(MapProgressEvent::RegionNamesPlanned { total_regions: 42 });
324        assert_eq!(
325            serde_json::to_value(&planned)?,
326            serde_json::json!({ "type": "region_names_planned", "total_regions": 42 })
327        );
328
329        let resolved = ProgressDto::from(MapProgressEvent::RegionNameResolved {
330            index: 7,
331            total: 42,
332        });
333        assert_eq!(
334            serde_json::to_value(&resolved)?,
335            serde_json::json!({ "type": "region_name_resolved", "index": 7, "total": 42 })
336        );
337        Ok(())
338    }
339}