use std::{collections::HashMap, sync::Arc};
use crate::nq_core::client::{Direction, ThroughputClient};
use crate::nq_core::{
BodyEvent, ConnectionType, EstablishedConnection, InflightBody, Network, OneshotResult,
ScopedHeaders, Time, Timestamp, oneshot_result,
};
use crate::nq_stats::CounterSeries;
use anyhow::Context;
use http::{HeaderMap, HeaderName, HeaderValue, Uri};
use rand::seq::SliceRandom;
use serde::Deserialize;
use tokio::sync::RwLock;
use tokio::sync::mpsc;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::error::TryRecvError;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
#[derive(Debug, Deserialize)]
pub struct LoadConfig {
pub headers: HashMap<String, String>,
#[serde(skip)]
pub scoped_headers: Option<ScopedHeaders>,
pub download_url: url::Url,
pub upload_url: url::Url,
}
pub struct LoadGenerator {
headers: HeaderMap<HeaderValue>,
scoped_headers: Option<ScopedHeaders>,
config: LoadConfig,
loads: Vec<LoadedConnection>,
}
impl LoadGenerator {
pub fn new(config: LoadConfig) -> anyhow::Result<Self> {
let mut headers = HeaderMap::new();
for (key, value) in config.headers.iter() {
headers.insert(
HeaderName::from_bytes(key.as_bytes())?,
HeaderValue::from_bytes(value.as_bytes())?,
);
}
Ok(Self {
headers,
scoped_headers: config.scoped_headers.clone(),
config,
loads: Vec::new(),
})
}
#[tracing::instrument(skip(self, network, time, shutdown))]
pub fn new_loaded_connection(
&self,
direction: Direction,
conn_type: ConnectionType,
network: Arc<dyn Network>,
time: Arc<dyn Time>,
shutdown: CancellationToken,
) -> anyhow::Result<OneshotResult<LoadedConnection>> {
let (tx, rx) = oneshot_result();
let uri: Uri = match direction {
Direction::Up(_) => self.config.upload_url.as_str().parse()?,
Direction::Down => self.config.download_url.as_str().parse()?,
};
let client = match direction {
Direction::Down => ThroughputClient::download(),
Direction::Up(size) => ThroughputClient::upload(size),
};
let client = client
.new_connection(conn_type)
.headers(self.headers.clone())
.scoped_headers(self.scoped_headers.clone());
let response_fut = client.send(
uri.clone(),
Arc::clone(&network),
Arc::clone(&time),
shutdown.clone(),
)?;
tracing::debug!("got loaded connection response future");
let reissue = match direction {
Direction::Up(bound) => Some(UploadReissue {
bound,
uri,
headers: self.headers.clone(),
scoped_headers: self.scoped_headers.clone(),
network,
time,
shutdown,
}),
Direction::Down => None,
};
tokio::spawn(
async move {
let inflight_body = response_fut
.await
.context("could not await response for loaded connection")?;
tracing::debug!("sending loaded connection");
let Some(reissue) = reissue else {
let _ = tx.send(Ok(LoadedConnection {
connection: inflight_body.connection,
events_rx: inflight_body.events,
state: LoadState::default(),
}));
return Ok(());
};
let (events_tx, events_rx) = mpsc::unbounded_channel();
let connection = Arc::clone(&inflight_body.connection);
let _ = tx.send(Ok(LoadedConnection {
connection: Arc::clone(&connection),
events_rx,
state: LoadState::default(),
}));
reissue
.run(connection, inflight_body.events, events_tx)
.await;
Ok::<_, anyhow::Error>(())
}
.in_current_span(),
);
Ok(rx)
}
pub fn connections(&self) -> impl Iterator<Item = &LoadedConnection> {
self.loads.iter()
}
pub fn random_connection(&self) -> Option<Arc<RwLock<EstablishedConnection>>> {
let loads: Vec<_> = self.ongoing_loads().collect();
loads
.choose(&mut rand::thread_rng())
.map(|c| c.connection.clone())
}
pub fn push(&mut self, loaded_connection: LoadedConnection) {
self.loads.push(loaded_connection);
}
pub fn update(&mut self) {
for load in &mut self.loads {
load.update();
}
}
pub fn ongoing_loads(&self) -> impl Iterator<Item = &LoadedConnection> {
self.loads.iter().filter(|load| load.is_ongoing())
}
pub fn count_loads(&self) -> usize {
self.ongoing_loads().count()
}
pub fn count_failed_loads(&self) -> usize {
self.loads.iter().filter(|load| load.has_failed()).count()
}
pub fn into_connections(self) -> Vec<LoadedConnection> {
self.loads
}
}
struct UploadReissue {
bound: usize,
uri: Uri,
headers: HeaderMap<HeaderValue>,
scoped_headers: Option<ScopedHeaders>,
network: Arc<dyn Network>,
time: Arc<dyn Time>,
shutdown: CancellationToken,
}
#[derive(Debug, PartialEq, Eq)]
enum RequestEnd {
Finished,
Died,
}
impl UploadReissue {
async fn run(
self,
connection: Arc<RwLock<EstablishedConnection>>,
first: UnboundedReceiver<BodyEvent>,
events_tx: mpsc::UnboundedSender<BodyEvent>,
) {
let mut current = first;
let mut relay = CumulativeRelay::default();
let mut requests = 1usize;
loop {
let ended = loop {
let event = tokio::select! {
_ = self.shutdown.cancelled() => return,
event = current.recv() => event,
};
let Some(event) = event else {
break RequestEnd::Died;
};
match relay.on_event(event) {
RelayAction::Forward(event) => {
if events_tx.send(event).is_err() {
return;
}
}
RelayAction::RequestFinished => break RequestEnd::Finished,
RelayAction::Fail(event) => {
let _ = events_tx.send(event);
return;
}
}
};
if ended == RequestEnd::Died {
let _ = events_tx.send(BodyEvent::Failed {
at: self.time.now(),
reason: format!(
"upload terminated early after {} request(s), {} bytes",
requests,
relay.total()
),
});
return;
}
if events_tx.is_closed() {
return;
}
let next = match self.issue(&connection) {
Ok(next) => next,
Err(error) => {
let _ = events_tx.send(BodyEvent::Failed {
at: self.time.now(),
reason: format!("could not start upload request {requests}: {error:#}"),
});
return;
}
};
let next = match next.await {
Ok(inflight) => inflight.events,
Err(error) => {
let _ = events_tx.send(BodyEvent::Failed {
at: self.time.now(),
reason: format!("upload request {requests} failed to start: {error:#}"),
});
return;
}
};
requests += 1;
tracing::debug!(
requests,
total_bytes = relay.total(),
"re-issued bounded upload request"
);
let finished = std::mem::replace(&mut current, next);
tokio::spawn(watch_tail(finished, events_tx.clone()).in_current_span());
}
}
fn issue(
&self,
connection: &Arc<RwLock<EstablishedConnection>>,
) -> anyhow::Result<OneshotResult<InflightBody>> {
ThroughputClient::upload(self.bound)
.with_connection(Arc::clone(connection))
.headers(self.headers.clone())
.scoped_headers(self.scoped_headers.clone())
.send(
self.uri.clone(),
Arc::clone(&self.network),
Arc::clone(&self.time),
self.shutdown.clone(),
)
}
}
async fn watch_tail(
mut events: UnboundedReceiver<BodyEvent>,
events_tx: mpsc::UnboundedSender<BodyEvent>,
) {
while let Some(event) = events.recv().await {
if matches!(event, BodyEvent::Failed { .. }) {
let _ = events_tx.send(event);
return;
}
}
}
#[derive(Debug, Default)]
struct CumulativeRelay {
base: usize,
last: usize,
}
#[derive(Debug)]
enum RelayAction {
Forward(BodyEvent),
RequestFinished,
Fail(BodyEvent),
}
impl CumulativeRelay {
fn on_event(&mut self, event: BodyEvent) -> RelayAction {
match event {
BodyEvent::ByteCount { at, total } => {
self.last = total;
RelayAction::Forward(BodyEvent::ByteCount {
at,
total: self.base + total,
})
}
BodyEvent::Finished { .. } => {
self.base += self.last;
self.last = 0;
RelayAction::RequestFinished
}
BodyEvent::Failed { at, reason } => RelayAction::Fail(BodyEvent::Failed { at, reason }),
}
}
fn total(&self) -> usize {
self.base + self.last
}
}
#[derive(Debug, Default)]
struct LoadState {
total_bytes_series: CounterSeries,
finished_at: Option<Timestamp>,
failed: bool,
failure_reason: Option<String>,
stopping: bool,
}
impl LoadState {
fn apply(&mut self, event: BodyEvent) {
match event {
BodyEvent::ByteCount { at, total } => self.total_bytes_series.add(at, total as f64),
BodyEvent::Finished { at } => self.finished_at = Some(at),
BodyEvent::Failed { reason, .. } => {
self.failed = true;
self.failure_reason = Some(reason);
}
}
}
fn on_disconnected(&mut self) {
if self.finished_at.is_none() && !self.stopping {
self.failed = true;
}
}
fn is_ongoing(&self) -> bool {
self.finished_at.is_none() && !self.failed
}
fn drain(&mut self, events_rx: &mut UnboundedReceiver<BodyEvent>) {
loop {
match events_rx.try_recv() {
Ok(event) => self.apply(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.on_disconnected();
break;
}
}
}
}
}
#[derive(Debug)]
pub struct LoadedConnection {
connection: Arc<RwLock<EstablishedConnection>>,
events_rx: UnboundedReceiver<BodyEvent>,
state: LoadState,
}
impl LoadedConnection {
pub fn update(&mut self) {
self.state.drain(&mut self.events_rx);
}
pub fn total_bytes_series(&self) -> &CounterSeries {
&self.state.total_bytes_series
}
pub fn is_ongoing(&self) -> bool {
self.state.is_ongoing()
}
pub fn has_failed(&self) -> bool {
self.state.failed
}
pub fn failure_reason(&self) -> Option<&str> {
self.state.failure_reason.as_deref()
}
pub fn stop(&mut self) {
self.state.stopping = true;
self.events_rx.close();
self.update();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tokio::sync::mpsc;
fn channel() -> (
mpsc::UnboundedSender<BodyEvent>,
mpsc::UnboundedReceiver<BodyEvent>,
) {
mpsc::unbounded_channel()
}
fn forward_bytes(relay: &mut CumulativeRelay, at: Timestamp, total: usize) -> usize {
match relay.on_event(BodyEvent::ByteCount { at, total }) {
RelayAction::Forward(BodyEvent::ByteCount { total, .. }) => total,
other => panic!("a ByteCount must be forwarded, got {other:?}"),
}
}
#[test]
fn totals_accumulate_across_request_boundaries() {
let at = Timestamp::now();
let mut relay = CumulativeRelay::default();
assert_eq!(forward_bytes(&mut relay, at, 40), 40);
assert_eq!(forward_bytes(&mut relay, at, 100), 100);
relay.on_event(BodyEvent::Finished { at });
assert_eq!(forward_bytes(&mut relay, at, 0), 100);
assert_eq!(forward_bytes(&mut relay, at, 30), 130);
relay.on_event(BodyEvent::Finished { at });
assert_eq!(forward_bytes(&mut relay, at, 5), 135);
assert_eq!(relay.total(), 135);
}
#[test]
fn request_finished_is_never_forwarded() {
let at = Timestamp::now();
let mut relay = CumulativeRelay::default();
assert!(matches!(
relay.on_event(BodyEvent::Finished { at }),
RelayAction::RequestFinished
));
}
#[test]
fn failure_is_terminal_and_forwarded() {
let at = Timestamp::now();
let mut relay = CumulativeRelay::default();
let action = relay.on_event(BodyEvent::Failed {
at,
reason: "upload rejected with status 413 Payload Too Large".to_owned(),
});
match action {
RelayAction::Fail(BodyEvent::Failed { reason, .. }) => {
assert!(reason.contains("413"));
}
other => panic!("a Failed must be forwarded as terminal, got {other:?}"),
}
}
#[test]
fn relayed_totals_never_produce_negative_goodput() {
let start = Timestamp::now();
let step = Duration::from_millis(50);
let mut relay = CumulativeRelay::default();
let mut series = CounterSeries::default();
let mut at = start;
for _ in 0..3 {
for total in [0usize, 25, 50, 75, 100] {
at = at + step;
let forwarded = forward_bytes(&mut relay, at, total);
series.add(at, forwarded as f64);
}
at = at + step;
relay.on_event(BodyEvent::Finished { at });
}
assert_eq!(relay.total(), 300, "three 100-byte requests");
let mut window = start;
while window < at {
let next = window + step;
let bytes = series.interval_sum(window, next);
assert!(
bytes >= 0.0,
"negative goodput ({bytes}) in one window -- a request boundary leaked a reset"
);
window = next;
}
assert_eq!(
series.interval_sum(start, at),
300.0,
"the whole run must account for every byte exactly once"
);
}
#[test]
fn open_channel_leaves_transfer_ongoing() {
let (tx, mut rx) = channel();
tx.send(BodyEvent::ByteCount {
at: Timestamp::now(),
total: 1024,
})
.unwrap();
let mut state = LoadState::default();
state.drain(&mut rx);
assert!(state.is_ongoing());
assert!(!state.failed);
drop(tx);
}
#[test]
fn disconnect_without_finished_marks_failed() {
let (tx, mut rx) = channel();
tx.send(BodyEvent::ByteCount {
at: Timestamp::now(),
total: 10 * 1024 * 1024,
})
.unwrap();
drop(tx);
let mut state = LoadState::default();
state.drain(&mut rx);
assert!(state.failed, "early termination must be flagged");
assert!(!state.is_ongoing(), "a failed load must not stay ongoing");
}
#[test]
fn finished_then_disconnect_is_not_a_failure() {
let (tx, mut rx) = channel();
let at = Timestamp::now();
tx.send(BodyEvent::ByteCount { at, total: 512 }).unwrap();
tx.send(BodyEvent::Finished { at }).unwrap();
drop(tx);
let mut state = LoadState::default();
state.drain(&mut rx);
assert!(!state.failed, "a completed transfer must not be a failure");
assert_eq!(state.finished_at, Some(at));
assert!(!state.is_ongoing(), "a completed load is no longer ongoing");
}
#[test]
fn teardown_disconnect_is_not_a_failure() {
let (tx, mut rx) = channel();
drop(tx);
let mut state = LoadState::default();
state.stopping = true;
state.drain(&mut rx);
assert!(!state.failed, "teardown must not be flagged as a failure");
}
#[test]
fn explicit_failed_event_retires_the_load_with_a_reason() {
let (tx, mut rx) = channel();
let at = Timestamp::now();
tx.send(BodyEvent::ByteCount { at, total: 1024 }).unwrap();
tx.send(BodyEvent::Failed {
at,
reason: "upload rejected with status 413 Payload Too Large".to_owned(),
})
.unwrap();
let mut state = LoadState::default();
state.drain(&mut rx);
assert!(state.failed);
assert!(!state.is_ongoing());
assert_eq!(
state.failure_reason.as_deref(),
Some("upload rejected with status 413 Payload Too Large")
);
drop(tx);
}
#[test]
fn bytes_seen_before_failure_are_retained() {
let (tx, mut rx) = channel();
let at = Timestamp::now();
tx.send(BodyEvent::ByteCount { at, total: 4096 }).unwrap();
drop(tx);
let mut state = LoadState::default();
state.drain(&mut rx);
assert!(state.failed);
assert_eq!(state.total_bytes_series.sum(), 4096.0);
}
}