Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: end a dead replay, clamp stale resume ids, test on a real server

A replay consumer now has a 5s idle heartbeat, and the row stream ends at
the first consumer error instead of skipping it. When the server drops the
consumer, as on a restart, two missed heartbeats end the SSE response and
the browser reconnects with Last-Event-ID. Before, the response stayed
open and delivered nothing.

A Last-Event-ID above the stream's newest sequence now replays as a fresh
reader. The server accepts a start past the end and delivers nothing
until the stream reaches it, so a stale id stalled the replay.

swarm-controller's stream tests run against a real nats-server on
loopback (`test_nats`, binary from HIVE_NATS_SERVER, exported by
checks.nix and devshell.nix). They cover: ensuring the stream twice, an
existing stream with other limits being opened as it is, the replay
arriving before live rows, a reconnect getting every later row exactly
once, and a dropped consumer ending the row stream.

The limits test now compares against the subagent stream's own config.
Two comments that described the agent's terminal route as live-only are
corrected.
This commit is contained in:
atlas 2026-10-11 23:25:27 +02:00
commit f08a9cadb6
10 changed files with 357 additions and 40 deletions

View file

@ -117,7 +117,7 @@ pub(crate) async fn stream_agent_term(
})?;
let stream = match replay(&client, &agent, last_event_id(&headers)).await {
Ok(stream) => stream,
Ok(rows) => rows.map(|row| Ok(row_event(&row))).boxed(),
Err(error) => {
tracing::warn!(%agent, %error, "term stream: no replay, live rows only");
let subscriber =
@ -130,13 +130,20 @@ pub(crate) async fn stream_agent_term(
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
/// One row read from the agent's terminal stream.
struct Row {
/// Its stream sequence, when the delivery names one.
seq: Option<u64>,
payload: bytes::Bytes,
}
/// The agent's rows from its terminal stream: the replay
/// [`agent_term::replay_start`] picks, then every new row.
async fn replay(
client: &async_nats::Client,
agent: &str,
resume_after: Option<u64>,
) -> Result<BoxStream<'static, Result<Event, Infallible>>, String> {
) -> Result<BoxStream<'static, Row>, String> {
let js = async_nats::jetstream::new(client.clone());
let name = agent_term::stream_name(agent);
let stream = js
@ -164,28 +171,39 @@ async fn replay(
.await
.map_err(|e| format!("subscribing to {name}: {}", swarm_queue_client::chain(&e)))?;
tracing::info!(stream = %name, start, "term stream: client attached");
let agent = agent.to_owned();
Ok(messages
.filter_map(move |msg| {
let event = match msg {
Ok(msg) => Some(Ok(row_event(&msg))),
Ok(rows_until_error(messages, agent.to_owned()))
}
/// The rows of a consumer's deliveries, ending at the first error. A missed
/// heartbeat is such an error, and ending there closes the SSE response, so
/// the browser reconnects with `Last-Event-ID` and gets a fresh consumer.
fn rows_until_error<E: std::fmt::Display>(
messages: impl Stream<Item = Result<async_nats::jetstream::Message, E>> + Send + 'static,
agent: String,
) -> BoxStream<'static, Row> {
messages
.scan((), move |(), msg| {
let row = match msg {
Ok(msg) => Some(Row {
seq: msg.info().ok().map(|info| info.stream_sequence),
payload: msg.message.payload,
}),
Err(e) => {
tracing::warn!(%agent, error = %e, "term stream: row skipped");
tracing::warn!(%agent, error = %e, "term stream: consumer failed, ending the stream");
None
}
};
std::future::ready(event)
std::future::ready(row)
})
.boxed())
.boxed()
}
/// One row as an SSE event, carrying its stream sequence as the event `id`
/// when the delivery names one.
fn row_event(msg: &async_nats::jetstream::Message) -> Event {
let event = Event::default().data(String::from_utf8_lossy(&msg.payload));
match msg.info() {
Ok(info) => event.id(info.stream_sequence.to_string()),
Err(_) => event,
/// One row as an SSE event, carrying its stream sequence as the event `id`.
fn row_event(row: &Row) -> Event {
let event = Event::default().data(String::from_utf8_lossy(&row.payload));
match row.seq {
Some(seq) => event.id(seq.to_string()),
None => event,
}
}
@ -222,6 +240,196 @@ pub(crate) async fn subscribe(
mod tests {
use super::*;
use std::time::Duration;
use crate::test_nats::TestNats;
const AGENT: &str = "iris";
async fn publish_rows(client: &async_nats::Client, rows: std::ops::RangeInclusive<u64>) {
for i in rows {
client
.publish(
swarm_queue_client::term_subject(AGENT),
format!("row{i}").into(),
)
.await
.expect("publish");
}
client.flush().await.expect("flush");
}
/// Wait until the stream holds row `last`, so the next step sees it.
async fn stored(client: &async_nats::Client, last: u64) {
let js = async_nats::jetstream::new(client.clone());
for _ in 0..100 {
let mut stream = js
.get_stream(agent_term::stream_name(AGENT))
.await
.expect("the stream");
if stream.info().await.expect("info").state.last_sequence >= last {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("the stream never stored row {last}");
}
async fn take(rows: &mut BoxStream<'static, Row>, n: usize) -> Vec<(u64, String)> {
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let row = tokio::time::timeout(Duration::from_secs(5), rows.next())
.await
.unwrap_or_else(|_| panic!("row {} of {n} never came; got {out:?}", out.len() + 1))
.expect("the row stream ended");
out.push((
row.seq.expect("a delivered row names its sequence"),
String::from_utf8_lossy(&row.payload).into_owned(),
));
}
out
}
fn rows(range: std::ops::RangeInclusive<u64>) -> Vec<(u64, String)> {
range.map(|i| (i, format!("row{i}"))).collect()
}
async fn nothing_more(rows: &mut BoxStream<'static, Row>) {
if let Ok(Some(row)) = tokio::time::timeout(Duration::from_millis(500), rows.next()).await {
panic!("an extra row: seq {:?}", row.seq);
}
}
/// A consumer the server dropped, as on a restart, ends the row stream
/// once its heartbeats stop, so the SSE response closes and the browser
/// reconnects instead of keeping a tail that never delivers.
#[tokio::test]
async fn a_dropped_consumer_ends_the_row_stream() {
use futures_util::TryStreamExt as _;
let nats = TestNats::start();
let client = nats.client().await;
let stream = agent_term::open_or_create(&client, AGENT)
.await
.expect("the stream");
publish_rows(&client, 1..=3).await;
stored(&client, 3).await;
let mut replayed = replay(&client, AGENT, None).await.expect("a replay");
assert_eq!(take(&mut replayed, 3).await, rows(1..=3));
let consumers: Vec<String> = stream
.consumer_names()
.try_collect()
.await
.expect("consumer names");
let [consumer] = consumers.as_slice() else {
panic!("expected the one replay consumer, got {consumers:?}");
};
stream
.delete_consumer(consumer)
.await
.expect("deleting the consumer");
let end = tokio::time::timeout(agent_term::IDLE_HEARTBEAT * 4, replayed.next())
.await
.expect("the row stream ends once heartbeats stop");
assert!(
end.is_none(),
"a row arrived after the consumer was deleted"
);
}
#[tokio::test]
async fn ensuring_the_terminal_stream_again_leaves_it_as_it_is() {
let nats = TestNats::start();
let client = nats.client().await;
let created = agent_term::open_or_create(&client, AGENT)
.await
.expect("the first ensure creates the stream");
publish_rows(&client, 1..=3).await;
stored(&client, 3).await;
agent_term::open_or_create(&client, AGENT)
.await
.expect("a second ensure succeeds");
// Read back from the server, not from what the ensure returned.
let after = async_nats::jetstream::new(client.clone())
.get_stream(agent_term::stream_name(AGENT))
.await
.expect("the stream")
.info()
.await
.expect("its info")
.clone();
assert_eq!(after.config, created.cached_info().config);
assert_eq!(after.state.messages, 3);
}
/// An existing stream is opened, not created again: one made when the
/// queue announced a different `max_payload` keeps its own config and
/// rows, where a second create would be refused for the mismatch.
#[tokio::test]
async fn an_existing_terminal_stream_with_other_limits_is_opened_as_it_is() {
let nats = TestNats::start();
let client = nats.client().await;
let js = async_nats::jetstream::new(client.clone());
js.create_stream(async_nats::jetstream::stream::Config {
name: agent_term::stream_name(AGENT),
subjects: vec![agent_term::stream_subject(AGENT)],
max_message_size: 4096,
..Default::default()
})
.await
.expect("an earlier stream");
publish_rows(&client, 1..=2).await;
stored(&client, 2).await;
let opened = agent_term::open_or_create(&client, AGENT)
.await
.expect("an existing stream opens");
assert_eq!(opened.cached_info().config.max_message_size, 4096);
assert_eq!(opened.cached_info().state.messages, 2);
}
/// Live rows published after the reader attached, before it read any
/// replayed row, still arrive after the whole replay.
#[tokio::test]
async fn the_replay_reaches_the_reader_before_the_live_tail() {
let nats = TestNats::start();
let client = nats.client().await;
agent_term::open_or_create(&client, AGENT)
.await
.expect("the stream");
publish_rows(&client, 1..=150).await;
stored(&client, 150).await;
let mut replayed = replay(&client, AGENT, None).await.expect("a replay");
publish_rows(&client, 151..=153).await;
assert_eq!(take(&mut replayed, 103).await, rows(51..=153));
nothing_more(&mut replayed).await;
}
/// A reader that reconnects with the last id it was sent gets every row
/// after it exactly once, including those published while it was away.
#[tokio::test]
async fn a_reconnect_gets_every_later_row_once() {
let nats = TestNats::start();
let client = nats.client().await;
agent_term::open_or_create(&client, AGENT)
.await
.expect("the stream");
publish_rows(&client, 1..=150).await;
stored(&client, 150).await;
let mut first = replay(&client, AGENT, None).await.expect("a replay");
let seen = take(&mut first, 100).await;
drop(first);
publish_rows(&client, 151..=160).await;
stored(&client, 160).await;
let last_id = seen.last().expect("rows were seen").0.to_string();
let resume_after = last_event_id(&with_last_event_id(&last_id));
let mut resumed = replay(&client, AGENT, resume_after)
.await
.expect("a resumed replay");
publish_rows(&client, 161..=163).await;
assert_eq!(take(&mut resumed, 13).await, rows(151..=163));
nothing_more(&mut resumed).await;
}
fn with_last_event_id(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(