sse: seq plumbing + subscribe-first dedupe dance

This commit is contained in:
müde 2026-05-17 12:26:00 +02:00
parent 8c186d4fb7
commit 1340a654e7
5 changed files with 197 additions and 37 deletions

View file

@ -144,6 +144,14 @@ async fn serve_shared_js() -> impl IntoResponse {
#[derive(Serialize)]
struct StateSnapshot {
/// Broker seq at the moment this snapshot was assembled. Clients
/// dedupe their buffered SSE traffic against this value: any
/// `MessageEvent` with `seq <= snapshot.seq` is already reflected in
/// the snapshot (or pre-dates it); anything with `seq > snapshot.seq`
/// is post-snapshot and should be applied. Set to 0 in the
/// pre-emit case (no events ever fired) — clients treat that as
/// "apply everything you've buffered".
seq: u64,
hostname: String,
manager_port: u16,
any_stale: bool,
@ -285,6 +293,14 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
.unwrap_or("localhost");
let hostname = host.split(':').next().unwrap_or(host).to_owned();
// Capture the broker seq *before* any read so the dedupe contract
// is "events with seq > snapshot.seq are post-snapshot, never
// missed." A broker event landing during snapshot construction may
// be doubly applied (snapshot caught the write + client also
// applies the SSE event) — that's a renderer's problem to make
// idempotent, not ours to avoid here.
let seq = state.coord.broker.current_seq();
let raw_containers = log_default("nixos-container list", lifecycle::list().await);
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
let transient_snapshot = state.coord.transient_snapshot();
@ -319,6 +335,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
log_default("questions.recent_answered", state.coord.questions.recent_answered(20));
axum::Json(StateSnapshot {
seq,
hostname,
manager_port: MANAGER_PORT,
any_stale,
@ -711,15 +728,22 @@ fn dir_size_bytes(root: &Path) -> u64 {
async fn messages_history(State(state): State<AppState>) -> Response {
// Backfill source for the dashboard message-flow terminal. Returns
// up to ~200 historical broker messages as `MessageEvent::Sent` JSON
// — same shape as the live `/messages/stream`, so the renderer
// doesn't branch on history vs. live.
// wrapped in `{ seq, events }`. The seq is the broker's high water
// mark at fetch time; clients use it to dedupe their buffered live
// SSE traffic (drop anything with `seq <= history_seq`) so a message
// that lands between SSE-subscribe and history-fetch isn't shown
// twice and isn't lost.
const HISTORY_LIMIT: u64 = 200;
// Capture seq *before* the query so the dedupe contract is
// "drop buffered events you've already seen in history" — never
// "lose an event that fired between the read and the timestamp."
let seq = state.coord.broker.current_seq();
match state.coord.broker.recent_all(HISTORY_LIMIT) {
Ok(mut events) => {
// recent_all returns newest-first; reverse so the replay
// builds chronologically (matches the agent /events/history).
events.reverse();
axum::Json(events).into_response()
axum::Json(serde_json::json!({ "seq": seq, "events": events })).into_response()
}
Err(e) => error_response(&format!("messages/history failed: {e:#}")),
}