use core::fmt;
use core::future::Future;
use std::sync::Arc;
use shep_client::{EventStream, LinkLost, RECONNECT_MIN_DELAY, ReconnectingClient, RequestError};
use shep_core::protocol::{BusEvent, ProcessInfo};
use crate::dog::SHEPHERD_RETURN_BUDGET;
pub trait EventSource: Send {
fn next(&mut self) -> impl Future<Output = Option<Result<BusEvent, u64>>> + Send;
fn resubscribe(&mut self) -> impl Future<Output = Result<(), Resubscribe>> + Send;
}
#[derive(Debug)]
#[must_use = "which of the two it was decides how the dog exits"]
pub enum Resubscribe {
Lost(LinkLost),
Request(RequestError),
}
impl fmt::Display for Resubscribe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lost(lost) => lost.fmt(f),
Self::Request(err) => write!(f, "the subscription request failed: {err}"),
}
}
}
impl core::error::Error for Resubscribe {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Lost(lost) => Some(lost),
Self::Request(err) => Some(err),
}
}
}
pub trait FlockSource: Send + Sync {
fn flock(&self) -> impl Future<Output = Result<Vec<ProcessInfo>, RequestError>> + Send;
}
pub trait ConfigSource: Send + Sync {
fn section(&self) -> impl Future<Output = Result<String, RequestError>> + Send;
}
pub(super) async fn subscribe(
client: ReconnectingClient,
dog: String,
topics: Vec<String>,
) -> Result<(ClientEvents, Arc<ClientShepherd>), RequestError> {
let stream = client.subscribe(topics.clone()).await?;
let shepherd = Arc::new(ClientShepherd { client, dog });
let events = ClientEvents {
shepherd: Arc::clone(&shepherd),
topics,
stream,
};
Ok((events, shepherd))
}
pub(super) struct ClientEvents {
shepherd: Arc<ClientShepherd>,
topics: Vec<String>,
stream: EventStream,
}
impl EventSource for ClientEvents {
async fn next(&mut self) -> Option<Result<BusEvent, u64>> {
self.stream
.next()
.await
.map(|item| item.map_err(|lagged| lagged.count))
}
async fn resubscribe(&mut self) -> Result<(), Resubscribe> {
let started = tokio::time::Instant::now();
let remaining = |elapsed| SHEPHERD_RETURN_BUDGET.saturating_sub(elapsed);
loop {
let left = remaining(started.elapsed());
if left.is_zero() {
return Err(Resubscribe::Lost(LinkLost::Budget {
waited: started.elapsed(),
}));
}
self.shepherd
.client
.connected_within(left)
.await
.map_err(Resubscribe::Lost)?;
let left = remaining(started.elapsed());
if left.is_zero() {
return Err(Resubscribe::Lost(LinkLost::Budget {
waited: started.elapsed(),
}));
}
let asked =
tokio::time::timeout(left, self.shepherd.client.subscribe(self.topics.clone()));
match asked.await {
Ok(Ok(stream)) => {
self.stream = stream;
return Ok(());
}
Ok(Err(RequestError::Closed)) => {}
Ok(Err(other)) => return Err(Resubscribe::Request(other)),
Err(_elapsed) => {
return Err(Resubscribe::Lost(LinkLost::Budget {
waited: started.elapsed(),
}));
}
}
tokio::time::sleep(RECONNECT_MIN_DELAY.min(remaining(started.elapsed()))).await;
}
}
}
pub(super) struct ClientShepherd {
client: ReconnectingClient,
dog: String,
}
impl FlockSource for ClientShepherd {
async fn flock(&self) -> Result<Vec<ProcessInfo>, RequestError> {
self.client.list_flock().await
}
}
impl ConfigSource for ClientShepherd {
async fn section(&self) -> Result<String, RequestError> {
let section = self.client.dog_config(&self.dog).await?;
Ok(section.as_str().to_owned())
}
}
impl FlockSource for Arc<ClientShepherd> {
async fn flock(&self) -> Result<Vec<ProcessInfo>, RequestError> {
FlockSource::flock(&**self).await
}
}
impl ConfigSource for Arc<ClientShepherd> {
async fn section(&self) -> Result<String, RequestError> {
ConfigSource::section(&**self).await
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_client::testing::{Handshake, fake_daemon_across_handovers, sample_ack};
use shep_core::protocol::{Request, RpcError, RpcErrorCode};
use super::*;
#[tokio::test]
async fn the_bark_adapter_arms_a_second_subscription_after_a_handover() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let shepherds = fake_daemon_across_handovers(
&socket,
vec![
Handshake::Accept(sample_ack()),
Handshake::Accept(sample_ack()),
],
);
let client = ReconnectingClient::connect_as_dog(&socket, "bark")
.await
.unwrap();
let topics = vec!["process.*".to_owned(), "config.dog.bark".to_owned()];
let (mut events, _shepherd) = subscribe(client, "bark".to_owned(), topics.clone())
.await
.unwrap();
shepherds.cut().await;
let armed = tokio::time::timeout(Duration::from_secs(10), events.resubscribe())
.await
.expect("a re-subscribe must not outlive its own budget");
assert!(
armed.is_ok(),
"a successor was there to subscribe to: {armed:?}"
);
let ended = tokio::time::timeout(Duration::from_millis(250), events.next()).await;
assert!(
ended.is_err(),
"the armed stream ended straight away, so it is the dead one: {ended:?}"
);
assert_eq!(
shepherds.accepted(),
2,
"one connection before the handover and one after"
);
let asked: Vec<_> = shepherds
.hellos()
.iter()
.map(|hello| hello.dog_name.clone())
.collect();
assert_eq!(
asked,
vec![Some("bark".to_owned()), Some("bark".to_owned())],
"the second handshake must name the dog too, or a refusal is unactionable"
);
let subscribed: Vec<_> = shepherds
.envelopes()
.into_iter()
.filter_map(|(generation, envelope)| match envelope.body {
Request::Subscribe { topics } => Some((generation, topics)),
_ => None,
})
.collect();
assert_eq!(
subscribed,
vec![(1, topics.clone()), (2, topics)],
"a re-subscribe asking for fewer topics leaves the dog deaf to the rest"
);
}
#[tokio::test]
async fn the_bark_adapter_keeps_a_refusal_rather_than_retrying_it() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let shepherds = fake_daemon_across_handovers(
&socket,
vec![
Handshake::Accept(sample_ack()),
Handshake::Accept(sample_ack()),
],
);
let client = ReconnectingClient::connect_as_dog(&socket, "bark")
.await
.unwrap();
let topics = vec!["process.*".to_owned()];
let (mut events, _shepherd) = subscribe(client, "bark".to_owned(), topics).await.unwrap();
shepherds.refuse_next_subscribe(RpcError {
code: RpcErrorCode::Unsupported,
message: "this shepherd does not serve that topic".into(),
daemon_version: None,
});
shepherds.cut().await;
let started = tokio::time::Instant::now();
let refused = tokio::time::timeout(SHEPHERD_RETURN_BUDGET * 2, events.resubscribe())
.await
.expect("a refusal must end the wait, not hang it");
let waited = started.elapsed();
let Err(Resubscribe::Request(err)) = refused else {
panic!("expected a kept refusal, got {refused:?}");
};
assert!(
matches!(&err, RequestError::Rpc(rpc) if rpc.code == RpcErrorCode::Unsupported),
"the shepherd's own error must survive: {err:?}"
);
assert!(
waited < SHEPHERD_RETURN_BUDGET,
"spent {waited:?} of the {SHEPHERD_RETURN_BUDGET:?} budget, so it retried a \
refusal instead of keeping it"
);
}
mod slow {
use super::*;
#[tokio::test]
async fn the_bark_adapter_gives_up_once_its_budget_is_spent() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let shepherds =
fake_daemon_across_handovers(&socket, vec![Handshake::Accept(sample_ack())]);
let client = ReconnectingClient::connect_as_dog(&socket, "bark")
.await
.unwrap();
let topics = vec!["process.*".to_owned()];
let (mut events, _shepherd) =
subscribe(client, "bark".to_owned(), topics).await.unwrap();
drop(shepherds);
let started = tokio::time::Instant::now();
let gave_up = tokio::time::timeout(SHEPHERD_RETURN_BUDGET * 3, events.resubscribe())
.await
.expect("a spent budget must end the wait, not hang it");
let waited = started.elapsed();
assert!(
matches!(gave_up, Err(Resubscribe::Lost(LinkLost::Budget { .. }))),
"expected a spent budget, got {gave_up:?}"
);
assert!(
waited >= SHEPHERD_RETURN_BUDGET,
"gave up after {waited:?}, inside the {SHEPHERD_RETURN_BUDGET:?} a handover \
is allowed to take, which is the restart-per-reload this rule exists to avoid"
);
}
}
}