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}