flysim: FLY_FEED_VIA=bus publishes every snapshot on an embedded flybus router
feed.via (FLY_FEED_VIA, default direct) and feed.bus_dir (FLY_BUS_DIR, default /run/fly/bus). In bus mode flysim does not bind the feed port: it starts a router on its own two-thread runtime with a closed policy (flysim may publish fly.feed.snapshots, fly-edge may only subscribe to it), listens for the edge on <bus_dir>/edge.sock, and a publisher task copies each snapshot out of the watch slot onto the latest-retained topic: frame, audio and spikes as sealed artifacts, the header as the envelope payload, or as a header artifact past 48 KiB. The sim thread still only writes its watch slot, so a slow bus costs snapshots on the bus and never a frame of the loop. feedbus holds both halves of the encoding, publish and receive, and the limits sized for the real 122,367-byte snapshot. Two counters are new: fly_bus_published_total and fly_bus_publish_failures_total.
This commit is contained in:
parent
acf7c2ebb8
commit
d3fa7908ec
6 changed files with 489 additions and 8 deletions
1
services/flysim/Cargo.lock
generated
1
services/flysim/Cargo.lock
generated
|
|
@ -484,6 +484,7 @@ dependencies = [
|
||||||
"fly-session-types",
|
"fly-session-types",
|
||||||
"flybrain-core",
|
"flybrain-core",
|
||||||
"flybrain-gb",
|
"flybrain-gb",
|
||||||
|
"flybus",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ cuda = ["flybrain-core/cuda"]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
flybrain-core = { path = "../flybrain-core" }
|
flybrain-core = { path = "../flybrain-core" }
|
||||||
flybrain-gb = { path = "../flybrain-gb" }
|
flybrain-gb = { path = "../flybrain-gb" }
|
||||||
|
flybus = { path = "../flybus" }
|
||||||
|
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//! - the `FLY_*` names the systemd units already set (`FLY_GAME`, `FLY_ROM`, `FLY_DATASET`,
|
//! - the `FLY_*` names the systemd units already set (`FLY_GAME`, `FLY_ROM`, `FLY_DATASET`,
|
||||||
//! `FLY_STATE`, `FLY_STATE_HOT`, `FLY_FEED_BIND`, `FLY_CONTROL_BIND`, `FLY_METRICS_ADDR`,
|
//! `FLY_STATE`, `FLY_STATE_HOT`, `FLY_FEED_BIND`, `FLY_CONTROL_BIND`, `FLY_METRICS_ADDR`,
|
||||||
//! `FLY_ROM_SHA256`, `FLY_ROM_PLATFORMER_SHA256`, `FLY_CHAT_ENABLED`, `FLY_CHAT_DENY_LIST`,
|
//! `FLY_ROM_SHA256`, `FLY_ROM_PLATFORMER_SHA256`, `FLY_CHAT_ENABLED`, `FLY_CHAT_DENY_LIST`,
|
||||||
//! `FLY_MACRO_MODE`, `RAYON_NUM_THREADS`);
|
//! `FLY_MACRO_MODE`, `FLY_FEED_VIA`, `FLY_BUS_DIR`, `RAYON_NUM_THREADS`);
|
||||||
//! - `FLYSIM_<SECTION>_<KEY>` for everything, e.g. `FLYSIM_LOOP_SPEED=0`.
|
//! - `FLYSIM_<SECTION>_<KEY>` for everything, e.g. `FLYSIM_LOOP_SPEED=0`.
|
||||||
//!
|
//!
|
||||||
//! Nothing here is secret (`docs/control-api.md`: "No secrets live in this service or its
|
//! Nothing here is secret (`docs/control-api.md`: "No secrets live in this service or its
|
||||||
|
|
@ -104,6 +104,35 @@ pub struct Feed {
|
||||||
pub bind: SocketAddr,
|
pub bind: SocketAddr,
|
||||||
/// Audio attachment sample rate. The page wants Web Audio's native 48 kHz.
|
/// Audio attachment sample rate. The page wants Web Audio's native 48 kHz.
|
||||||
pub audio_hz: u32,
|
pub audio_hz: u32,
|
||||||
|
/// Who serves `:7400/feed` (`docs/design/flybus.md`, "Feed over the bus").
|
||||||
|
pub via: FeedVia,
|
||||||
|
/// The bus runtime directory in `bus` mode: the router's socket and its artifact store.
|
||||||
|
/// Belongs on tmpfs; a store here holds a few snapshots, never history.
|
||||||
|
pub bus_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the feed WebSocket is served from.
|
||||||
|
///
|
||||||
|
/// `direct` is the default and is the behaviour that predates the bus, byte for byte: flysim
|
||||||
|
/// binds `feed.bind` itself. `bus` starts an embedded flybus router, publishes every snapshot
|
||||||
|
/// on it, and leaves `feed.bind` to the `fly-edge` process. The control API stays in flysim
|
||||||
|
/// either way. Nothing about the fly changes with this knob: it is outside the simulation loop
|
||||||
|
/// and outside the compatibility string.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FeedVia {
|
||||||
|
#[default]
|
||||||
|
Direct,
|
||||||
|
Bus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedVia {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Direct => "direct",
|
||||||
|
Self::Bus => "bus",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -197,6 +226,8 @@ impl Default for Feed {
|
||||||
Self {
|
Self {
|
||||||
bind: "127.0.0.1:7400".parse().expect("literal address"),
|
bind: "127.0.0.1:7400".parse().expect("literal address"),
|
||||||
audio_hz: 48_000,
|
audio_hz: 48_000,
|
||||||
|
via: FeedVia::Direct,
|
||||||
|
bus_dir: PathBuf::from("/run/fly/bus"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +286,12 @@ impl Config {
|
||||||
if let Some(value) = get("FLY_FEED_BIND") {
|
if let Some(value) = get("FLY_FEED_BIND") {
|
||||||
self.feed.bind = parse_addr("FLY_FEED_BIND", value)?;
|
self.feed.bind = parse_addr("FLY_FEED_BIND", value)?;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = get("FLY_FEED_VIA") {
|
||||||
|
self.feed.via = parse_feed_via("FLY_FEED_VIA", value)?;
|
||||||
|
}
|
||||||
|
if let Some(value) = get("FLY_BUS_DIR") {
|
||||||
|
self.feed.bus_dir = PathBuf::from(value);
|
||||||
|
}
|
||||||
if let Some(value) = get("FLY_CONTROL_BIND") {
|
if let Some(value) = get("FLY_CONTROL_BIND") {
|
||||||
self.control.bind = parse_addr("FLY_CONTROL_BIND", value)?;
|
self.control.bind = parse_addr("FLY_CONTROL_BIND", value)?;
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +367,12 @@ impl Config {
|
||||||
if let Some(value) = get("FLYSIM_FEED_AUDIO_HZ") {
|
if let Some(value) = get("FLYSIM_FEED_AUDIO_HZ") {
|
||||||
self.feed.audio_hz = parse("FLYSIM_FEED_AUDIO_HZ", value)?;
|
self.feed.audio_hz = parse("FLYSIM_FEED_AUDIO_HZ", value)?;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = get("FLYSIM_FEED_VIA") {
|
||||||
|
self.feed.via = parse_feed_via("FLYSIM_FEED_VIA", value)?;
|
||||||
|
}
|
||||||
|
if let Some(value) = get("FLYSIM_FEED_BUS_DIR") {
|
||||||
|
self.feed.bus_dir = PathBuf::from(value);
|
||||||
|
}
|
||||||
if let Some(value) = get("FLYSIM_CONTROL_BIND") {
|
if let Some(value) = get("FLYSIM_CONTROL_BIND") {
|
||||||
self.control.bind = parse_addr("FLYSIM_CONTROL_BIND", value)?;
|
self.control.bind = parse_addr("FLYSIM_CONTROL_BIND", value)?;
|
||||||
}
|
}
|
||||||
|
|
@ -464,6 +507,14 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_feed_via(name: &str, value: &str) -> Result<FeedVia> {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"direct" => Ok(FeedVia::Direct),
|
||||||
|
"bus" => Ok(FeedVia::Bus),
|
||||||
|
_ => bail!("{name}: {value:?} is not a feed path; expected \"direct\" or \"bus\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_addr(name: &str, value: &str) -> Result<SocketAddr> {
|
fn parse_addr(name: &str, value: &str) -> Result<SocketAddr> {
|
||||||
value
|
value
|
||||||
.parse()
|
.parse()
|
||||||
|
|
@ -687,6 +738,29 @@ mod tests {
|
||||||
assert_eq!(config, Config::default());
|
assert_eq!(config, Config::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_feed_path_is_direct_unless_the_environment_says_bus() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.feed.via, FeedVia::Direct);
|
||||||
|
assert_eq!(config.feed.bus_dir, PathBuf::from("/run/fly/bus"));
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config
|
||||||
|
.apply_env(&env(&[("FLY_FEED_VIA", "bus"), ("FLY_BUS_DIR", "/tmp/fly-bus")]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.feed.via, FeedVia::Bus);
|
||||||
|
assert_eq!(config.feed.bus_dir, PathBuf::from("/tmp/fly-bus"));
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.apply_env(&env(&[("FLYSIM_FEED_VIA", "DIRECT")])).unwrap();
|
||||||
|
assert_eq!(config.feed.via, FeedVia::Direct);
|
||||||
|
|
||||||
|
// A typo is a refusal, not a silent fallback to one of the two.
|
||||||
|
let error = Config::default().apply_env(&env(&[("FLY_FEED_VIA", "buss")])).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("FLY_FEED_VIA"), "{error}");
|
||||||
|
assert_eq!(toml::from_str::<Config>("[feed]\nvia = \"bus\"\n").unwrap().feed.via, FeedVia::Bus);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_example_file_parses_and_validates() {
|
fn the_example_file_parses_and_validates() {
|
||||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../flysim.toml.example");
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../flysim.toml.example");
|
||||||
|
|
|
||||||
347
services/flysim/crates/flysim/src/feedbus.rs
Normal file
347
services/flysim/crates/flysim/src/feedbus.rs
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
//! The feed over flybus (`FLY_FEED_VIA=bus`, `docs/design/flybus.md` "Feed over the bus").
|
||||||
|
//!
|
||||||
|
//! Both halves of the bus encoding live here, so the publisher in flysim and the subscriber in
|
||||||
|
//! `fly-edge` cannot drift apart:
|
||||||
|
//!
|
||||||
|
//! - [`publish`] turns one [`Snapshot`] into one publication on [`TOPIC`]: every attachment the
|
||||||
|
//! header lists as a sealed artifact named after its kind (`frame`, `audio`, `spikes`), and the
|
||||||
|
//! header itself as the envelope payload `{"header": {...}}`. A header too large for an
|
||||||
|
//! envelope travels as a `header` artifact instead, so no snapshot is ever unpublishable.
|
||||||
|
//! - [`receive`] turns that publication back into the same [`Snapshot`], which `fly-edge` hands to
|
||||||
|
//! [`crate::feed`] exactly as flysim does. The WebSocket bytes are therefore produced by the same
|
||||||
|
//! `Snapshot::encode` on both paths.
|
||||||
|
//!
|
||||||
|
//! The simulation thread never sees any of this. It publishes into its `watch` slot as it
|
||||||
|
//! always has; [`run_publisher`] is a task on the bus's own runtime that reads that slot and
|
||||||
|
//! skips whatever it was too slow to see, the same drop-oldest rule every feed client gets.
|
||||||
|
//! A stalled router, a full store or an absent edge can cost snapshots on the bus, never a
|
||||||
|
//! frame of the loop.
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use flybus::{
|
||||||
|
Artifact, BusError, Client, ClientConfig, ErrorCode, Grants, Limits, Message, Pattern, Policy,
|
||||||
|
PublishReceipt, Retained, Router, RouterConfig, UnixListenerHandle,
|
||||||
|
};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use crate::metrics::Metrics;
|
||||||
|
use crate::snapshot::{AttachmentKind, FeedHeader, Snapshot};
|
||||||
|
|
||||||
|
/// The one topic: `latest` retention, so a subscriber that joins late starts from the newest
|
||||||
|
/// snapshot and one that falls behind is coalesced rather than queued.
|
||||||
|
pub const TOPIC: &str = "fly.feed.snapshots";
|
||||||
|
/// The publisher's participant id (in-process, launcher-bound).
|
||||||
|
pub const PUBLISHER: &str = "flysim";
|
||||||
|
/// The edge's participant id; the Unix socket is bound to it.
|
||||||
|
pub const EDGE: &str = "fly-edge";
|
||||||
|
/// Socket file under `feed.bus_dir`, bound to [`EDGE`] only.
|
||||||
|
pub const SOCKET: &str = "edge.sock";
|
||||||
|
/// Store root under `feed.bus_dir`; the router makes its per-incarnation directory inside it.
|
||||||
|
pub const STORE: &str = "store";
|
||||||
|
/// Headers up to this many JSON bytes ride in the envelope; anything larger becomes an artifact.
|
||||||
|
/// Well under flybus's 65,536-byte envelope limit, leaving room for the attachment references
|
||||||
|
/// and the router's ids. A real header is 2 to 8 KB.
|
||||||
|
pub const HEADER_INLINE_MAX: usize = 48 * 1024;
|
||||||
|
/// The attachment name of an out-of-line header.
|
||||||
|
pub const HEADER_ARTIFACT: &str = "header";
|
||||||
|
|
||||||
|
/// `<bus_dir>/edge.sock`.
|
||||||
|
pub fn socket_path(bus_dir: &Path) -> PathBuf {
|
||||||
|
bus_dir.join(SOCKET)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<bus_dir>/store`.
|
||||||
|
pub fn store_root(bus_dir: &Path) -> PathBuf {
|
||||||
|
bus_dir.join(STORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The router limits for the feed (`docs/design/flybus.md`, amendment "Feed sizing").
|
||||||
|
///
|
||||||
|
/// One snapshot with attachments is 122,367 bytes on the live fly: a 92,160-byte 160x144 RGBA
|
||||||
|
/// frame, a 17,407-byte spike bitset (139,255 neurons) and about 12,800 bytes of audio (1,600
|
||||||
|
/// stereo f32 frames at 48 kHz per 30 Hz snapshot). A `latest` subscriber pins at most its one
|
||||||
|
/// queued slot plus its in-flight credits, the topic pins one retained value, and the publisher
|
||||||
|
/// holds one snapshot of staging plus the sealed copy while it seals. With [`Limits::max_clients`]
|
||||||
|
/// at 8 and in-flight credits capped at 2, the worst case is 7 subscribers that never consume:
|
||||||
|
/// `7 * 3 + 1 + 2 = 24` snapshots, about 3 MB. The store cap is ten times that so a burst of
|
||||||
|
/// catch-up audio after a stall still fits, and it is RAM (tmpfs), so it is kept small on purpose.
|
||||||
|
pub fn limits() -> Limits {
|
||||||
|
Limits {
|
||||||
|
max_clients: 8,
|
||||||
|
max_services: 8,
|
||||||
|
max_topics: 8,
|
||||||
|
max_subscriptions_per_client: 4,
|
||||||
|
max_subscriptions: 16,
|
||||||
|
max_latest_in_flight: 2,
|
||||||
|
max_owners_per_client: 64,
|
||||||
|
reserved_owners_per_client: 8,
|
||||||
|
// Audio accumulates while the loop is behind its publish deadline; 4 MiB is ten seconds
|
||||||
|
// of it, far past anything the pacer allows before it logs lag.
|
||||||
|
max_artifact_bytes: 4 << 20,
|
||||||
|
max_store_bytes: 32 << 20,
|
||||||
|
max_retained_bytes: 8 << 20,
|
||||||
|
..Limits::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// flysim may declare and publish the feed topic; the edge may only subscribe to it.
|
||||||
|
pub fn policy() -> Policy {
|
||||||
|
Policy::closed()
|
||||||
|
.client(
|
||||||
|
PUBLISHER,
|
||||||
|
Grants {
|
||||||
|
publish: vec![Pattern::exact(TOPIC)],
|
||||||
|
manage_topics: vec![Pattern::exact(TOPIC)],
|
||||||
|
..Grants::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.client(
|
||||||
|
EDGE,
|
||||||
|
Grants {
|
||||||
|
subscribe: vec![Pattern::exact(TOPIC)],
|
||||||
|
..Grants::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running router and the edge's socket. Dropping it stops listening; the router stops with
|
||||||
|
/// the runtime it was started on.
|
||||||
|
pub struct BusFeed {
|
||||||
|
pub router: Router,
|
||||||
|
_listener: UnixListenerHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the embedded router under `bus_dir` and listen for the edge on `<bus_dir>/edge.sock`.
|
||||||
|
///
|
||||||
|
/// Must run inside a Tokio runtime. `Router::new` removes store directories a previous flysim
|
||||||
|
/// left behind (their `flock` is free once that process is gone); a stale socket file is removed
|
||||||
|
/// here, because a socket outlives its listener on disk.
|
||||||
|
pub async fn start_router(bus_dir: &Path) -> anyhow::Result<BusFeed> {
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use std::os::unix::fs::DirBuilderExt as _;
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(bus_dir)
|
||||||
|
.with_context(|| format!("creating the bus directory {}", bus_dir.display()))?;
|
||||||
|
let root = store_root(bus_dir);
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&root)
|
||||||
|
.with_context(|| format!("creating the bus store root {}", root.display()))?;
|
||||||
|
let socket = socket_path(bus_dir);
|
||||||
|
match std::fs::remove_file(&socket) {
|
||||||
|
Ok(()) => tracing::info!(socket = %socket.display(), "removed a stale bus socket"),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error).with_context(|| format!("removing {}", socket.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut config = RouterConfig::new(root);
|
||||||
|
config.limits = limits();
|
||||||
|
config.policy = policy();
|
||||||
|
let router = Router::new(config).context("starting the flybus router")?;
|
||||||
|
let listener = router
|
||||||
|
.listen_unix_as(&socket, EDGE)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("listening on {}", socket.display()))?;
|
||||||
|
tracing::info!(
|
||||||
|
socket = %socket.display(),
|
||||||
|
store = %router.store_dir().display(),
|
||||||
|
router = router.router_id(),
|
||||||
|
"feed bus listening"
|
||||||
|
);
|
||||||
|
Ok(BusFeed {
|
||||||
|
router,
|
||||||
|
_listener: listener,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_name(kind: AttachmentKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => "frame",
|
||||||
|
AttachmentKind::Audio => "audio",
|
||||||
|
AttachmentKind::Spikes => "spikes",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type(kind: AttachmentKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => "image/x-rgba",
|
||||||
|
AttachmentKind::Audio => "audio/x-f32le",
|
||||||
|
AttachmentKind::Spikes => "application/x-spike-bitset",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_of(snapshot: &Snapshot, kind: AttachmentKind) -> &[u8] {
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => &snapshot.frame,
|
||||||
|
AttachmentKind::Audio => &snapshot.audio,
|
||||||
|
AttachmentKind::Spikes => &snapshot.spikes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seal(client: &Client, bytes: &[u8], content_type: &str) -> Result<Artifact, BusError> {
|
||||||
|
let mut writer = client
|
||||||
|
.artifacts()
|
||||||
|
.allocate(bytes.len() as u64, content_type)
|
||||||
|
.await?;
|
||||||
|
writer
|
||||||
|
.write_all(bytes)
|
||||||
|
.map_err(|error| BusError::new(ErrorCode::StoreFailure, format!("staging: {error}")))?;
|
||||||
|
writer.seal().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish one snapshot: its attachments as artifacts, its header as the payload.
|
||||||
|
pub async fn publish(client: &Client, snapshot: &Snapshot) -> Result<PublishReceipt, BusError> {
|
||||||
|
let header = &snapshot.header;
|
||||||
|
let json = serde_json::to_vec(header).expect("a FeedHeader always serializes");
|
||||||
|
|
||||||
|
let mut kinds: Vec<AttachmentKind> = Vec::with_capacity(3);
|
||||||
|
for kind in header.attachments.iter().copied() {
|
||||||
|
if !kinds.contains(&kind) {
|
||||||
|
kinds.push(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut artifacts: Vec<(&'static str, Artifact)> = Vec::with_capacity(4);
|
||||||
|
for kind in kinds {
|
||||||
|
let artifact = seal(client, bytes_of(snapshot, kind), content_type(kind)).await?;
|
||||||
|
artifacts.push((attachment_name(kind), artifact));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = Map::new();
|
||||||
|
if json.len() <= HEADER_INLINE_MAX {
|
||||||
|
let value: Value = serde_json::from_slice(&json).expect("a serialized header re-parses");
|
||||||
|
payload.insert("header".into(), value);
|
||||||
|
} else {
|
||||||
|
artifacts.push((
|
||||||
|
HEADER_ARTIFACT,
|
||||||
|
seal(client, &json, "application/json").await?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let attachments: Vec<(&str, &Artifact)> = artifacts
|
||||||
|
.iter()
|
||||||
|
.map(|(name, artifact)| (*name, artifact))
|
||||||
|
.collect();
|
||||||
|
client.publish(TOPIC, payload, &attachments).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the snapshot one publication carries. The message's delivery is released when the
|
||||||
|
/// caller drops it; every byte has been copied out by then.
|
||||||
|
pub async fn receive(message: &Message) -> Result<Snapshot, BusError> {
|
||||||
|
let invalid = |what: String| BusError::new(ErrorCode::InvalidEnvelope, what);
|
||||||
|
let header: FeedHeader = match message.payload().get("header") {
|
||||||
|
Some(value) => serde_json::from_value(value.clone())
|
||||||
|
.map_err(|error| invalid(format!("feed header: {error}")))?,
|
||||||
|
None => {
|
||||||
|
let bytes = message.artifact(HEADER_ARTIFACT)?.read_all().await?;
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|error| invalid(format!("feed header artifact: {error}")))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut snapshot = Snapshot {
|
||||||
|
header,
|
||||||
|
frame: Arc::new(Vec::new()),
|
||||||
|
audio: Arc::new(Vec::new()),
|
||||||
|
spikes: Arc::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let kinds = snapshot.header.attachments.clone();
|
||||||
|
for kind in kinds {
|
||||||
|
let bytes = Arc::new(message.artifact(attachment_name(kind))?.read_all().await?);
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => snapshot.frame = bytes,
|
||||||
|
AttachmentKind::Audio => snapshot.audio = bytes,
|
||||||
|
AttachmentKind::Spikes => snapshot.spikes = bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often a failing publisher repeats its warning.
|
||||||
|
const WARN_EVERY: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Publish every snapshot the sim puts in its watch slot until the sim is gone.
|
||||||
|
///
|
||||||
|
/// Connects in process as [`PUBLISHER`], declares [`TOPIC`] with `latest` retention and
|
||||||
|
/// publishes the current snapshot first, so an edge that connects at once still sees the boot
|
||||||
|
/// state. A refused publication (a full store, say) is counted and skipped; a lost connection
|
||||||
|
/// is re-made after a second. Borrows of the watch slot end before any await, exactly as in
|
||||||
|
/// [`crate::feed`]: a held borrow is a lock the sim thread's next publish would wait on.
|
||||||
|
pub async fn run_publisher(
|
||||||
|
router: Router,
|
||||||
|
mut snapshots: watch::Receiver<Arc<Snapshot>>,
|
||||||
|
metrics: Arc<Metrics>,
|
||||||
|
) {
|
||||||
|
let store_root = router.store_root().to_path_buf();
|
||||||
|
let mut last_warning: Option<Instant> = None;
|
||||||
|
let mut warn = |error: &BusError, what: &str| {
|
||||||
|
if last_warning.is_none_or(|at| at.elapsed() >= WARN_EVERY) {
|
||||||
|
tracing::warn!(%error, "feed bus: {what}");
|
||||||
|
last_warning = Some(Instant::now());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let transport = router.connect_in_memory_as(PUBLISHER);
|
||||||
|
let client =
|
||||||
|
match Client::connect(transport, ClientConfig::new(PUBLISHER, &store_root)).await {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(error) => {
|
||||||
|
warn(&error, "the publisher could not connect");
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = client.declare_topic(TOPIC, Retained::Latest).await {
|
||||||
|
warn(&error, "the feed topic could not be declared");
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut current = snapshots.borrow_and_update().clone();
|
||||||
|
loop {
|
||||||
|
match publish(&client, ¤t).await {
|
||||||
|
Ok(_) => Metrics::incr(&metrics.bus_published),
|
||||||
|
Err(error) => {
|
||||||
|
Metrics::incr(&metrics.bus_publish_failures);
|
||||||
|
warn(&error, "a snapshot was not published");
|
||||||
|
if client.closed().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshots.changed().await.is_err() {
|
||||||
|
// The sim thread is gone; so is the service.
|
||||||
|
client.close().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = snapshots.borrow_and_update().clone();
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_limits_validate_and_hold_the_worst_case_with_room() {
|
||||||
|
let limits = limits();
|
||||||
|
limits.validate().unwrap();
|
||||||
|
// A full snapshot on the live fly (see `limits`).
|
||||||
|
let snapshot_bytes = crate::snapshot::FRAME_BYTES + 139_255usize.div_ceil(8) + 12_800;
|
||||||
|
assert_eq!(snapshot_bytes, 122_367);
|
||||||
|
let subscribers = limits.max_clients as u64 - 1;
|
||||||
|
let pinned = subscribers * (1 + limits.max_latest_in_flight) + 1 + 2;
|
||||||
|
assert!(
|
||||||
|
pinned * snapshot_bytes as u64 * 10 <= limits.max_store_bytes,
|
||||||
|
"{pinned}"
|
||||||
|
);
|
||||||
|
assert!(limits.max_artifact_bytes >= crate::snapshot::FRAME_BYTES as u64 * 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! +-- watch<Snapshot> --> feed :7400/feed (axum + ws)
|
//! +-- watch<Snapshot> --> feed :7400/feed (axum + ws)
|
||||||
//! sim thread ---------+
|
//! sim thread ---------+ \-> feedbus -> flybus -> fly-edge :7400/feed
|
||||||
|
//! | (FLY_FEED_VIA=bus instead of the line above)
|
||||||
//! agent +-- Shared ------------> api :7401 (axum)
|
//! agent +-- Shared ------------> api :7401 (axum)
|
||||||
//! emulator | /status /stimulate /reward /checkpoint
|
//! emulator | /status /stimulate /reward /checkpoint
|
||||||
//! adapter | /pause /resume /events /healthz /metrics
|
//! adapter | /pause /resume /events /healthz /metrics
|
||||||
|
|
@ -25,6 +26,7 @@ pub mod chat;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod eventlog;
|
pub mod eventlog;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
|
pub mod feedbus;
|
||||||
pub mod macros;
|
pub mod macros;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod pacing;
|
pub mod pacing;
|
||||||
|
|
@ -41,7 +43,7 @@ use std::sync::Arc;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::{Config, FeedVia};
|
||||||
use crate::eventlog::{EventRing, now_wall_ms};
|
use crate::eventlog::{EventRing, now_wall_ms};
|
||||||
use crate::simloop::{COMMAND_QUEUE, Command, Shared, Sim, booting_snapshot};
|
use crate::simloop::{COMMAND_QUEUE, Command, Shared, Sim, booting_snapshot};
|
||||||
use crate::snapshot::Snapshot;
|
use crate::snapshot::Snapshot;
|
||||||
|
|
@ -95,10 +97,17 @@ pub fn run(config: Config) -> Result<()> {
|
||||||
let feed_addr = config.feed.bind;
|
let feed_addr = config.feed.bind;
|
||||||
let control_addr = config.control.bind;
|
let control_addr = config.control.bind;
|
||||||
let metrics_addr = config.control.metrics_bind;
|
let metrics_addr = config.control.metrics_bind;
|
||||||
|
let via = config.feed.via;
|
||||||
let listeners = runtime.block_on(async {
|
let listeners = runtime.block_on(async {
|
||||||
let feed = tokio::net::TcpListener::bind(feed_addr)
|
// In bus mode the feed port belongs to `fly-edge`; binding it here would take it away.
|
||||||
.await
|
let feed = match via {
|
||||||
.with_context(|| format!("binding the feed listener on {feed_addr}"))?;
|
FeedVia::Direct => Some(
|
||||||
|
tokio::net::TcpListener::bind(feed_addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding the feed listener on {feed_addr}"))?,
|
||||||
|
),
|
||||||
|
FeedVia::Bus => None,
|
||||||
|
};
|
||||||
let control = tokio::net::TcpListener::bind(control_addr)
|
let control = tokio::net::TcpListener::bind(control_addr)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("binding the control listener on {control_addr}"))?;
|
.with_context(|| format!("binding the control listener on {control_addr}"))?;
|
||||||
|
|
@ -113,9 +122,15 @@ pub fn run(config: Config) -> Result<()> {
|
||||||
Ok::<_, anyhow::Error>((feed, control, metrics))
|
Ok::<_, anyhow::Error>((feed, control, metrics))
|
||||||
})?;
|
})?;
|
||||||
let (feed_listener, control_listener, metrics_listener) = listeners;
|
let (feed_listener, control_listener, metrics_listener) = listeners;
|
||||||
tracing::info!(feed = %feed_addr, control = %control_addr, metrics = ?metrics_addr, "listening");
|
tracing::info!(
|
||||||
|
feed = %feed_addr,
|
||||||
|
feed_via = via.as_str(),
|
||||||
|
control = %control_addr,
|
||||||
|
metrics = ?metrics_addr,
|
||||||
|
"listening"
|
||||||
|
);
|
||||||
|
|
||||||
{
|
if let Some(feed_listener) = feed_listener {
|
||||||
let state = state.feed();
|
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 {
|
||||||
|
|
@ -123,6 +138,26 @@ pub fn run(config: Config) -> Result<()> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The bus gets a runtime of its own, so neither its router nor the artifact copies can take
|
||||||
|
// a worker from the control API; and it is fed from the watch slot, never from the sim thread.
|
||||||
|
let bus_runtime = match via {
|
||||||
|
FeedVia::Direct => None,
|
||||||
|
FeedVia::Bus => {
|
||||||
|
let bus_runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.thread_name("flysim-bus")
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building the bus runtime")?;
|
||||||
|
let bus = bus_runtime.block_on(feedbus::start_router(&config.feed.bus_dir))?;
|
||||||
|
bus_runtime.spawn(feedbus::run_publisher(
|
||||||
|
bus.router.clone(),
|
||||||
|
state.snapshots.clone(),
|
||||||
|
Arc::clone(&state.shared.metrics),
|
||||||
|
));
|
||||||
|
Some((bus_runtime, bus))
|
||||||
|
}
|
||||||
|
};
|
||||||
{
|
{
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
|
|
@ -148,6 +183,11 @@ pub fn run(config: Config) -> Result<()> {
|
||||||
let result = sim.run(¬ifier);
|
let result = sim.run(¬ifier);
|
||||||
notifier.notify("STOPPING=1\n");
|
notifier.notify("STOPPING=1\n");
|
||||||
drop(sim);
|
drop(sim);
|
||||||
|
if let Some((bus_runtime, bus)) = bus_runtime {
|
||||||
|
bus.router.shutdown();
|
||||||
|
drop(bus);
|
||||||
|
bus_runtime.shutdown_timeout(std::time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
runtime.shutdown_timeout(std::time::Duration::from_secs(2));
|
runtime.shutdown_timeout(std::time::Duration::from_secs(2));
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ pub struct Metrics {
|
||||||
pub lag_ms: AtomicU64,
|
pub lag_ms: AtomicU64,
|
||||||
/// 1 when the restore fell back past the newest candidate.
|
/// 1 when the restore fell back past the newest candidate.
|
||||||
pub restore_fallback: AtomicU64,
|
pub restore_fallback: AtomicU64,
|
||||||
|
/// Snapshots published on the feed bus (`FLY_FEED_VIA=bus`); 0 in direct mode.
|
||||||
|
pub bus_published: AtomicU64,
|
||||||
|
/// Snapshots the feed bus refused or could not take; each one is skipped, never retried.
|
||||||
|
pub bus_publish_failures: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metrics {
|
impl Metrics {
|
||||||
|
|
@ -114,6 +118,20 @@ pub fn render(metrics: &Metrics, snapshot: &Snapshot, now_wall_ms: u64) -> Strin
|
||||||
"Snapshots superseded before a slow client could be sent them.",
|
"Snapshots superseded before a slow client could be sent them.",
|
||||||
Metrics::get(&metrics.feed_dropped),
|
Metrics::get(&metrics.feed_dropped),
|
||||||
);
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_bus_published_total",
|
||||||
|
"counter",
|
||||||
|
"Snapshots published on the feed bus (FLY_FEED_VIA=bus).",
|
||||||
|
Metrics::get(&metrics.bus_published),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_bus_publish_failures_total",
|
||||||
|
"counter",
|
||||||
|
"Snapshots the feed bus did not take; skipped, like any superseded snapshot.",
|
||||||
|
Metrics::get(&metrics.bus_publish_failures),
|
||||||
|
);
|
||||||
metric(
|
metric(
|
||||||
&mut out,
|
&mut out,
|
||||||
"fly_snapshots_published_total",
|
"fly_snapshots_published_total",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue