Skip to main content

salvor_server/
sse.rs

1//! `GET /v1/runs/{id}/events`: the event stream. This is the control plane's
2//! headline feature, so its framing and its cursor are spelled out here.
3//!
4//! # Framing
5//!
6//! The response is `text/event-stream`. Every recorded event becomes one
7//! server-sent-event frame:
8//!
9//! ```text
10//! id: <seq>
11//! data: <the pinned EventEnvelope JSON, on one line>
12//!
13//! ```
14//!
15//! The `data` line is exactly the envelope wire JSON the store holds, the same
16//! bytes `salvor history --json` prints, so a client decodes stream frames and
17//! log rows with one parser. The frame's `id` is the event's sequence number.
18//! Envelope frames carry no `event:` field, so a browser `EventSource` receives
19//! them through `onmessage`. When the run reaches a resting point (completed,
20//! failed, suspended, budget-exceeded, or needs-reconciliation) the stream
21//! sends one final `event: end` frame carrying the final status, then closes.
22//!
23//! # Replay then live tail
24//!
25//! On connect the server reads the run's whole log and sends every event at or
26//! after the cursor, then polls the store for new events and sends them as they
27//! land, until the resting frame. A run's log is append-only with contiguous,
28//! ascending sequence numbers, so tracking one "next sequence to send" number
29//! makes the stream gap-free and duplicate-free by construction.
30//!
31//! # The cursor: resuming a dropped stream
32//!
33//! A dropped connection resumes without gaps or duplicates in one of two ways:
34//!
35//! - **`Last-Event-ID`.** A browser `EventSource` resends the last `id` it saw
36//!   as the `Last-Event-ID` header on reconnect. The server resumes from that
37//!   sequence plus one, so the first event not yet seen is the first replayed.
38//! - **`?from_seq=<n>`.** A non-browser client that tracks its own position
39//!   asks for events from sequence `n` onward. Used when there is no
40//!   `Last-Event-ID` to lean on.
41//!
42//! `Last-Event-ID` wins when both are present. With neither, the stream starts
43//! at sequence 0, a full replay.
44
45use std::convert::Infallible;
46use std::time::Duration;
47
48use axum::extract::{Path, Query, State};
49use axum::http::HeaderMap;
50use axum::response::IntoResponse;
51use axum::response::sse::{Event, KeepAlive, Sse};
52use salvor_core::{RunId, RunStatus, derive_state};
53use serde::Deserialize;
54use serde_json::json;
55use tokio::sync::mpsc;
56use tokio_stream::wrappers::ReceiverStream;
57use uuid::Uuid;
58
59use crate::error::ApiError;
60use crate::json;
61use crate::state::AppState;
62
63/// The `?from_seq=` cursor query.
64#[derive(Debug, Deserialize)]
65pub struct StreamParams {
66    /// Send events from this sequence number onward. Overridden by a
67    /// `Last-Event-ID` header when one is present.
68    #[serde(default)]
69    from_seq: Option<u64>,
70}
71
72/// The event-stream handler. See the module docs for the framing and cursor.
73pub async fn stream(
74    State(state): State<AppState>,
75    Path(run_id_text): Path<String>,
76    Query(params): Query<StreamParams>,
77    headers: HeaderMap,
78) -> Result<impl IntoResponse, ApiError> {
79    let run_id = Uuid::parse_str(&run_id_text)
80        .map(RunId::from_uuid)
81        .map_err(|_| {
82            ApiError::BadRequest(format!(
83                "`{run_id_text}` is not a valid run id (expected a UUID)"
84            ))
85        })?;
86
87    // A run that neither has history nor is being driven here does not exist.
88    let log = state
89        .store()
90        .read_log(run_id)
91        .await
92        .map_err(|error| ApiError::Internal(format!("store: {error}")))?;
93    if log.is_empty() && !state.is_run_active(run_id) {
94        return Err(ApiError::UnknownRun(format!(
95            "no run {} in this store",
96            run_id.as_uuid()
97        )));
98    }
99
100    let start = cursor(&headers, params.from_seq);
101    let poll = state.poll_interval();
102    let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(64);
103    tokio::spawn(produce(state, run_id, start, poll, tx));
104
105    Ok(Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()))
106}
107
108/// The starting sequence number: `Last-Event-ID` plus one when present, else
109/// the `from_seq` query, else 0 (a full replay).
110fn cursor(headers: &HeaderMap, from_seq: Option<u64>) -> u64 {
111    if let Some(last) = headers
112        .get("last-event-id")
113        .and_then(|value| value.to_str().ok())
114        .and_then(|text| text.parse::<u64>().ok())
115    {
116        return last + 1;
117    }
118    from_seq.unwrap_or(0)
119}
120
121/// Reads the log from `start`, sends each event, then polls for new ones until
122/// the run rests, and sends a final `end` frame.
123async fn produce(
124    state: AppState,
125    run_id: RunId,
126    start: u64,
127    poll: Duration,
128    tx: mpsc::Sender<Result<Event, Infallible>>,
129) {
130    let store = state.store();
131    let mut next = start;
132    loop {
133        let log = match store.read_log(run_id).await {
134            Ok(log) => log,
135            Err(error) => {
136                let frame = Event::default()
137                    .event("end")
138                    .data(json!({ "error": format!("store: {error}") }).to_string());
139                let _ = tx.send(Ok(frame)).await;
140                return;
141            }
142        };
143
144        let from = next;
145        for envelope in log.iter().filter(|envelope| envelope.seq.get() >= from) {
146            let data = serde_json::to_string(envelope).unwrap_or_default();
147            let frame = Event::default()
148                .id(envelope.seq.get().to_string())
149                .data(data);
150            if tx.send(Ok(frame)).await.is_err() {
151                // The client hung up; stop producing.
152                return;
153            }
154            next = envelope.seq.get() + 1;
155        }
156
157        let status = derive_state(&log).status;
158        if is_resting(&status) {
159            let frame = Event::default()
160                .event("end")
161                .data(json!({ "status": json::status(&status) }).to_string());
162            let _ = tx.send(Ok(frame)).await;
163            return;
164        }
165
166        // A run that is mid-step but no longer being driven in this process was
167        // detached (its task was aborted, or the server that drove it is gone).
168        // End the stream so the client does not wait forever; recovering the run
169        // opens a fresh stream that tails the continuation.
170        if !log.is_empty() && !state.is_run_active(run_id) {
171            let frame = Event::default()
172                .event("end")
173                .data(json!({ "status": json::status(&status), "detached": true }).to_string());
174            let _ = tx.send(Ok(frame)).await;
175            return;
176        }
177
178        tokio::time::sleep(poll).await;
179    }
180}
181
182/// Whether a status is a resting point at which driving has stopped.
183fn is_resting(status: &RunStatus) -> bool {
184    matches!(
185        status,
186        RunStatus::Completed { .. }
187            | RunStatus::Failed { .. }
188            | RunStatus::Abandoned { .. }
189            | RunStatus::Suspended { .. }
190            | RunStatus::BudgetExceeded { .. }
191            | RunStatus::NeedsReconciliation
192    )
193}