sse: seq plumbing + subscribe-first dedupe dance
This commit is contained in:
parent
8c186d4fb7
commit
1340a654e7
5 changed files with 197 additions and 37 deletions
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -50,12 +51,14 @@ pub type DueReminder = (String, i64, String, Option<String>);
|
|||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum MessageEvent {
|
||||
Sent {
|
||||
seq: u64,
|
||||
from: String,
|
||||
to: String,
|
||||
body: String,
|
||||
at: i64,
|
||||
},
|
||||
Delivered {
|
||||
seq: u64,
|
||||
from: String,
|
||||
to: String,
|
||||
body: String,
|
||||
|
|
@ -66,6 +69,13 @@ pub enum MessageEvent {
|
|||
pub struct Broker {
|
||||
conn: Mutex<Connection>,
|
||||
events: broadcast::Sender<MessageEvent>,
|
||||
/// Monotonic per-process counter stamped onto every emitted
|
||||
/// `MessageEvent`. Persisted nowhere — clients always treat a hive-c0re
|
||||
/// restart as "everything is new" (fresh snapshot, fresh stream of
|
||||
/// seqs starting at 1). Historical rows replayed via `recent_all`
|
||||
/// carry `seq = 0` since they predate the live stream the seq is
|
||||
/// meant to dedupe against.
|
||||
event_seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl Broker {
|
||||
|
|
@ -81,6 +91,7 @@ impl Broker {
|
|||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
events,
|
||||
event_seq: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +99,20 @@ impl Broker {
|
|||
self.events.subscribe()
|
||||
}
|
||||
|
||||
/// Current high-water seq. Snapshot endpoints read this *before*
|
||||
/// gathering state so the resulting (snapshot.seq, snapshot) pair
|
||||
/// satisfies: any live event with seq > snapshot.seq is post-snapshot
|
||||
/// (not yet reflected); any with seq <= snapshot.seq either pre-dates
|
||||
/// the snapshot or was already captured by it. Clients dedupe their
|
||||
/// buffered SSE traffic against this value.
|
||||
pub fn current_seq(&self) -> u64 {
|
||||
self.event_seq.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn next_seq(&self) -> u64 {
|
||||
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
pub fn send(&self, message: &Message) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
|
|
@ -96,6 +121,7 @@ impl Broker {
|
|||
)?;
|
||||
drop(conn);
|
||||
let _ = self.events.send(MessageEvent::Sent {
|
||||
seq: self.next_seq(),
|
||||
from: message.from.clone(),
|
||||
to: message.to.clone(),
|
||||
body: message.body.clone(),
|
||||
|
|
@ -149,6 +175,11 @@ impl Broker {
|
|||
)?;
|
||||
let rows = stmt.query_map(params![limit_i], |row| {
|
||||
Ok(MessageEvent::Sent {
|
||||
// Historical events: seq=0 (never compared against live
|
||||
// seqs). Live dedupe windows close against
|
||||
// history_seq = broker.current_seq() captured at fetch
|
||||
// time, not against per-row seqs.
|
||||
seq: 0,
|
||||
from: row.get(0)?,
|
||||
to: row.get(1)?,
|
||||
body: row.get(2)?,
|
||||
|
|
@ -256,6 +287,7 @@ impl Broker {
|
|||
)?;
|
||||
drop(conn);
|
||||
let _ = self.events.send(MessageEvent::Delivered {
|
||||
seq: self.next_seq(),
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
body: body.clone(),
|
||||
|
|
@ -332,6 +364,7 @@ impl Broker {
|
|||
tx.commit()?;
|
||||
drop(conn);
|
||||
let _ = self.events.send(MessageEvent::Sent {
|
||||
seq: self.next_seq(),
|
||||
from: "reminder".to_owned(),
|
||||
to: agent.to_owned(),
|
||||
body: message.to_owned(),
|
||||
|
|
|
|||
|
|
@ -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:#}")),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue