flysim: the feed server takes a FeedState, so another process can run it

feed::router needed the whole AppState for three things: the watch slot, the
feed counters and the idle cadence. It now takes exactly those, and Shared's
Metrics sits behind an Arc so the counters can be handed over. Nothing about
what the feed writes changes.
This commit is contained in:
acamilo 2026-09-23 08:16:31 +00:00
parent 174dc7eabd
commit acf7c2ebb8
4 changed files with 43 additions and 15 deletions

View file

@ -9,8 +9,14 @@
//! - 30 snapshots a second while running, 2 while paused or booting (header only); //! - 30 snapshots a second while running, 2 while paused or booting (header only);
//! - drop-oldest, never queue: the sim publishes into a `watch` slot, so a slow client misses //! - drop-oldest, never queue: the sim publishes into a `watch` slot, so a slow client misses
//! snapshots instead of slowing the loop down. Those misses are counted. //! snapshots instead of slowing the loop down. Those misses are counted.
//!
//! The server only needs a [`FeedState`]: a watch slot of snapshots, the counters and the idle
//! cadence. flysim builds one from its own state when it serves the feed itself
//! (`FLY_FEED_VIA=direct`), and `fly-edge` builds one from the snapshots it takes off the bus
//! (`FLY_FEED_VIA=bus`), so both paths run this same code and write the same bytes.
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use axum::Router; use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
@ -19,9 +25,22 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::any; use axum::routing::any;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::watch;
use crate::metrics::Metrics;
use crate::snapshot::{AttachmentKind, FeedStatus, PROTOCOL, Snapshot, Wants}; use crate::snapshot::{AttachmentKind, FeedStatus, PROTOCOL, Snapshot, Wants};
use crate::{AppState, metrics::Metrics};
/// Everything the feed server reads.
#[derive(Clone)]
pub struct FeedState {
/// The newest snapshot. Dropping its sender ends every client's stream.
pub snapshots: watch::Receiver<Arc<Snapshot>>,
/// `frames_sent`, `feed_clients` and `feed_dropped` are the ones this module moves.
pub metrics: Arc<Metrics>,
/// The protocol's idle cadence: how long a paused or booting stream waits before it
/// repeats the current header (`config.publish_periods().1`).
pub idle_period: Duration,
}
/// The one JSON text message a client sends on connect. /// The one JSON text message a client sends on connect.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -37,7 +56,7 @@ pub struct ClientHello {
/// Close code for a protocol violation, as the reference server uses. /// Close code for a protocol violation, as the reference server uses.
const CLOSE_PROTOCOL_ERROR: u16 = 1002; const CLOSE_PROTOCOL_ERROR: u16 = 1002;
pub fn router(state: AppState) -> Router { pub fn router(state: FeedState) -> Router {
Router::new() Router::new()
.route("/feed", any(upgrade)) .route("/feed", any(upgrade))
.fallback(not_found) .fallback(not_found)
@ -48,11 +67,11 @@ async fn not_found() -> Response {
(StatusCode::NOT_FOUND, "not found").into_response() (StatusCode::NOT_FOUND, "not found").into_response()
} }
async fn upgrade(upgrade: WebSocketUpgrade, State(state): State<AppState>) -> Response { async fn upgrade(upgrade: WebSocketUpgrade, State(state): State<FeedState>) -> Response {
upgrade.on_upgrade(move |socket| serve_client(socket, state)) upgrade.on_upgrade(move |socket| serve_client(socket, state))
} }
async fn serve_client(mut socket: WebSocket, state: AppState) { async fn serve_client(mut socket: WebSocket, state: FeedState) {
let Some(hello) = read_hello(&mut socket).await else { let Some(hello) = read_hello(&mut socket).await else {
return; return;
}; };
@ -64,9 +83,9 @@ async fn serve_client(mut socket: WebSocket, state: AppState) {
spikes = wants.spikes, spikes = wants.spikes,
"feed client connected" "feed client connected"
); );
state.shared.metrics.client_joined(); state.metrics.client_joined();
let result = pump(&mut socket, &state, wants).await; let result = pump(&mut socket, &state, wants).await;
state.shared.metrics.client_left(); state.metrics.client_left();
match result { match result {
Ok(()) => tracing::info!("feed client disconnected"), Ok(()) => tracing::info!("feed client disconnected"),
Err(error) => tracing::info!(%error, "feed client dropped"), Err(error) => tracing::info!(%error, "feed client dropped"),
@ -117,9 +136,9 @@ async fn read_hello(socket: &mut WebSocket) -> Option<ClientHello> {
None None
} }
async fn pump(socket: &mut WebSocket, state: &AppState, wants: Wants) -> Result<(), axum::Error> { async fn pump(socket: &mut WebSocket, state: &FeedState, wants: Wants) -> Result<(), axum::Error> {
let mut receiver = state.snapshots.clone(); let mut receiver = state.snapshots.clone();
let (_, idle_period) = state.shared.config.publish_periods(); let idle_period = state.idle_period;
let mut last_seq = 0u64; let mut last_seq = 0u64;
// The current snapshot first, so a client that connects while paused or booting sees the // The current snapshot first, so a client that connects while paused or booting sees the
@ -161,18 +180,18 @@ async fn pump(socket: &mut WebSocket, state: &AppState, wants: Wants) -> Result<
async fn send( async fn send(
socket: &mut WebSocket, socket: &mut WebSocket,
state: &AppState, state: &FeedState,
snapshot: &Arc<Snapshot>, snapshot: &Arc<Snapshot>,
wants: Wants, wants: Wants,
last_seq: &mut u64, last_seq: &mut u64,
) -> Result<(), axum::Error> { ) -> Result<(), axum::Error> {
let seq = snapshot.header.seq; let seq = snapshot.header.seq;
if seq > *last_seq + 1 && *last_seq != 0 { if seq > *last_seq + 1 && *last_seq != 0 {
Metrics::add(&state.shared.metrics.feed_dropped, seq - *last_seq - 1); Metrics::add(&state.metrics.feed_dropped, seq - *last_seq - 1);
} }
*last_seq = seq; *last_seq = seq;
socket.send(Message::Binary(snapshot.encode(wants).into())).await?; socket.send(Message::Binary(snapshot.encode(wants).into())).await?;
Metrics::incr(&state.shared.metrics.frames_sent); Metrics::incr(&state.metrics.frames_sent);
Ok(()) Ok(())
} }

View file

@ -59,6 +59,15 @@ impl AppState {
pub fn snapshot(&self) -> Arc<Snapshot> { pub fn snapshot(&self) -> Arc<Snapshot> {
Arc::clone(&self.snapshots.borrow()) Arc::clone(&self.snapshots.borrow())
} }
/// What the feed server needs, when flysim serves the feed itself.
pub fn feed(&self) -> feed::FeedState {
feed::FeedState {
snapshots: self.snapshots.clone(),
metrics: Arc::clone(&self.shared.metrics),
idle_period: self.shared.config.publish_periods().1,
}
}
} }
/// Run the service until a signal or a fatal simulation error. /// Run the service until a signal or a fatal simulation error.
@ -107,7 +116,7 @@ pub fn run(config: Config) -> Result<()> {
tracing::info!(feed = %feed_addr, control = %control_addr, metrics = ?metrics_addr, "listening"); tracing::info!(feed = %feed_addr, control = %control_addr, metrics = ?metrics_addr, "listening");
{ {
let state = state.clone(); let state = state.feed();
runtime.spawn(async move { runtime.spawn(async move {
if let Err(error) = axum::serve(feed_listener, feed::router(state)).await { if let Err(error) = axum::serve(feed_listener, feed::router(state)).await {
tracing::error!(%error, "the feed listener stopped"); tracing::error!(%error, "the feed listener stopped");

View file

@ -81,7 +81,7 @@ impl Metrics {
} }
/// One metric line plus its help and type headers. /// One metric line plus its help and type headers.
fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) { pub fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) {
use std::fmt::Write as _; use std::fmt::Write as _;
let _ = writeln!(out, "# HELP {name} {help}"); let _ = writeln!(out, "# HELP {name} {help}");
let _ = writeln!(out, "# TYPE {name} {kind}"); let _ = writeln!(out, "# TYPE {name} {kind}");

View file

@ -149,7 +149,7 @@ pub struct DecoderChannelStatus {
#[derive(Debug)] #[derive(Debug)]
pub struct Shared { pub struct Shared {
pub config: Config, pub config: Config,
pub metrics: Metrics, pub metrics: Arc<Metrics>,
pub events: EventRing, pub events: EventRing,
/// `Date.now()` at the top of the most recent loop iteration. `GET /healthz` is 200 while /// `Date.now()` at the top of the most recent loop iteration. `GET /healthz` is 200 while
/// this is less than two seconds old, which is true while paused as well: a paused loop is /// this is less than two seconds old, which is true while paused as well: a paused loop is
@ -173,7 +173,7 @@ impl Shared {
pub fn new(config: Config, events: EventRing) -> Self { pub fn new(config: Config, events: EventRing) -> Self {
Self { Self {
config, config,
metrics: Metrics::default(), metrics: Arc::default(),
events, events,
heartbeat_ms: AtomicU64::new(0), heartbeat_ms: AtomicU64::new(0),
versions: OnceLock::new(), versions: OnceLock::new(),