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