use crate::{
ClientError, Error, ErrorKind, Result,
client::{BackpressureConfig, Client, Config, IntoConfig, ReconnectionConfig},
commands::{
BlockingCommands, ClientTrackingOptions, ClientTrackingStatus, ConnectionCommands,
GenericCommands, StringCommands,
},
network::{QueueMetricsTestHook, timeout},
resp::cmd,
spawn,
tests::{
fault_injection_proxy::{Action, FaultProxy},
get_default_addr, get_default_config, get_test_client, log_try_init, resident_bytes,
},
};
use serial_test::serial;
use std::time::Duration;
const MESSAGE_OVERHEAD: usize = 1024;
fn storm_config(addr: std::net::SocketAddr, max_queued_bytes: usize) -> Result<Config> {
let mut config = format!("redis://{addr}").into_config()?;
config.retry_on_error = true;
config.reconnection = ReconnectionConfig::new_constant(0, 50);
config.connect_timeout = Duration::from_millis(200);
config.command_timeout = Duration::ZERO;
config.backpressure = BackpressureConfig {
max_queued_bytes,
..Default::default()
};
Ok(config)
}
#[tokio::test]
#[serial]
async fn the_send_queue_stops_growing_at_its_memory_budget() -> Result<()> {
log_try_init();
const VALUE_BYTES: usize = 1024;
const BUDGET: usize = 1024 * 1024;
const OFFERED: usize = 50_000;
let proxy = FaultProxy::start_multi(get_default_addr(), vec![vec![], vec![Action::Drop]])
.await
.unwrap();
let metrics = QueueMetricsTestHook::new();
let mut config = storm_config(proxy.addr, BUDGET)?;
config.queue_metrics_test_hook = Some(metrics.clone());
let client = Client::connect(config).await?;
client.send_and_forget(cmd("PING").kill_connection_on_read(1), None)?;
let baseline_rss = resident_bytes();
let value = "v".repeat(VALUE_BYTES);
timeout(Duration::from_secs(60), async {
while proxy.connections_accepted() < 3 {
tokio::task::yield_now().await;
}
for i in 0..OFFERED {
client.send_and_forget(
cmd("SET")
.arg(format!("budget_key_{i}"))
.arg(value.as_str()),
Some(true),
)?;
if i % 1000 == 0 {
tokio::task::yield_now().await;
}
}
for _ in 0..1000 {
tokio::task::yield_now().await;
}
Ok::<(), Error>(())
})
.await??;
let peak = metrics.messages_to_send_high_water();
let rss_delta = match (baseline_rss, resident_bytes()) {
(Some(before), Some(after)) => Some(after.saturating_sub(before)),
_ => None,
};
let min_charge = VALUE_BYTES + MESSAGE_OVERHEAD;
let max_depth = BUDGET / min_charge + 1;
let report =
format!("peak={peak} max_depth={max_depth} offered={OFFERED} rss_delta={rss_delta:?}");
println!("send queue budget: {report}");
assert!(
peak <= max_depth,
"the send queue must stay within its budget plus one message: {report}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn a_command_refused_by_a_full_send_queue_reports_it() -> Result<()> {
log_try_init();
const BUDGET: usize = 4 * 1024;
const FILLER_BYTES: usize = 8 * 1024;
let proxy = FaultProxy::start_multi(get_default_addr(), vec![vec![], vec![Action::Drop]])
.await
.unwrap();
let client = Client::connect(storm_config(proxy.addr, BUDGET)?).await?;
client.send_and_forget(cmd("PING").kill_connection_on_read(1), None)?;
let error = timeout(Duration::from_secs(30), async {
while proxy.connections_accepted() < 3 {
tokio::task::yield_now().await;
}
let value = "v".repeat(FILLER_BYTES);
for _ in 0..10 {
client.send_and_forget(
cmd("SET").arg("refused_filler").arg(value.as_str()),
Some(true),
)?;
}
for _ in 0..100 {
tokio::task::yield_now().await;
}
let result: Result<String> = client.send(cmd("PING"), Some(true)).await;
match result {
Err(e) => Ok::<Error, Error>(e),
Ok(_) => panic!("a command offered to a full send queue must not succeed"),
}
})
.await??;
assert!(
matches!(error.kind(), ErrorKind::Client(ClientError::SendQueueFull)),
"a command shed by a full queue must report SendQueueFull, got {error:?}"
);
assert_eq!(
Some("PING"),
error.command(),
"a shed command must say what was shed, got {error:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn a_command_already_queued_survives_the_reconnection_that_replays_it() -> Result<()> {
log_try_init();
const BUDGET: usize = 4 * 1024;
const COMMANDS: usize = 20;
let proxy = FaultProxy::start_multi(
get_default_addr(),
vec![vec![], vec![Action::Drop], vec![Action::Drop], vec![]],
)
.await
.unwrap();
let client = Client::connect(storm_config(proxy.addr, BUDGET)?).await?;
let control = Client::connect(get_default_addr()).await?;
for i in 0..COMMANDS {
control.del(format!("replayed_key_{i}")).await?;
}
client.send_and_forget(cmd("PING").kill_connection_on_read(1), None)?;
let mut handles = Vec::new();
timeout(Duration::from_secs(30), async {
while proxy.connections_accepted() < 2 {
tokio::task::yield_now().await;
}
for i in 0..COMMANDS {
let client = client.clone();
handles.push(spawn(async move {
let result: Result<String> = client
.send(
cmd("SET").arg(format!("replayed_key_{i}")).arg(i),
Some(true),
)
.await;
result.map(|_| ())
}));
tokio::task::yield_now().await;
}
Ok::<(), Error>(())
})
.await??;
let mut accepted = 0usize;
let mut shed = 0usize;
for handle in handles {
match timeout(Duration::from_secs(30), handle).await {
Ok(Ok(Ok(()))) => accepted += 1,
Ok(Ok(Err(e))) if matches!(e.kind(), ErrorKind::Client(ClientError::SendQueueFull)) => {
shed += 1
}
other => panic!("unexpected outcome for a queued command: {other:?}"),
}
}
let mut stored = 0usize;
for i in 0..COMMANDS {
let value: Option<usize> = control.get(format!("replayed_key_{i}")).await?;
if value.is_some() {
stored += 1;
}
}
let report = format!("accepted={accepted} shed={shed} stored={stored} of {COMMANDS}");
println!("replay invariant: {report}");
assert!(
accepted > 0,
"the test proves nothing unless some commands were accepted: {report}"
);
assert_eq!(
accepted, stored,
"every accepted command must have reached the server: {report}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn the_send_queue_budget_is_released_when_the_queue_drains() -> Result<()> {
log_try_init();
const BUDGET: usize = 16 * 1024;
let mut config = get_default_addr().into_config()?;
config.backpressure = BackpressureConfig {
max_queued_bytes: BUDGET,
..Default::default()
};
let client = Client::connect(config).await?;
let value = "v".repeat(4096);
for wave in 0..20 {
for i in 0..2 {
client
.set(format!("drain_key_{wave}_{i}"), value.as_str())
.await?;
}
let _: String = client.send(cmd("PING"), None).await?;
}
let stored: String = client.get("drain_key_19_1").await?;
assert_eq!(
value, stored,
"a drained queue must keep accepting commands"
);
Ok(())
}
async fn offered_once_quiet(metrics: &QueueMetricsTestHook) -> usize {
let mut last = metrics.push_delivered();
let mut still = 0;
while still < 500 {
tokio::task::yield_now().await;
let now = metrics.push_delivered();
if now == last {
still += 1;
} else {
still = 0;
last = now;
}
}
last
}
#[tokio::test]
#[serial]
async fn a_paused_monitor_is_bounded_by_its_memory_budget() -> Result<()> {
log_try_init();
const VALUE_BYTES: usize = 4096;
const BUDGET: usize = 256 * 1024;
const OFFERED: usize = 5_000;
let metrics = QueueMetricsTestHook::new();
let mut config = get_default_config()?;
config.queue_metrics_test_hook = Some(metrics.clone());
config.backpressure.max_push_bytes = BUDGET;
let monitored = Client::connect(config).await?.into_exclusive()?;
let writer = get_test_client().await?;
let held_stream = monitored.monitor().await?;
let baseline_rss = resident_bytes();
let value = "v".repeat(VALUE_BYTES);
timeout(Duration::from_secs(60), async {
for i in 0..OFFERED {
writer.send_and_forget(
cmd("SET")
.arg(format!("monitor_budget_key_{i}"))
.arg(value.as_str()),
None,
)?;
if i % 500 == 0 {
let _: String = writer.send(cmd("PING"), None).await?;
}
}
let _: String = writer.send(cmd("PING"), None).await?;
while metrics.push_delivered() < OFFERED {
tokio::task::yield_now().await;
}
Ok::<(), Error>(())
})
.await??;
let delivered = offered_once_quiet(&metrics).await;
let dropped = held_stream.dropped_messages();
let offered_bytes = metrics.push_delivered_bytes();
let rss_delta = match (baseline_rss, resident_bytes()) {
(Some(before), Some(after)) => Some(after.saturating_sub(before)),
_ => None,
};
let held = delivered.saturating_sub(dropped);
let max_held = BUDGET / VALUE_BYTES + 1;
let report = format!(
"delivered={delivered} dropped={dropped} held={held} max_held={max_held} \
offered={offered_bytes} B budget={BUDGET} B rss_delta={rss_delta:?}"
);
println!("paused monitor: {report}");
assert!(
delivered >= OFFERED,
"the feed must carry at least the commands that were issued: {report}"
);
assert_eq!(
0,
metrics.push_delivery_failed(),
"a live sink must never have a delivery refused: {report}"
);
assert!(
dropped > 0,
"far more was monitored than the budget allows, so lines must have been \
dropped and counted: {report}"
);
assert!(
held <= max_held,
"the stream must hold no more than its budget allows: {report}"
);
drop(held_stream);
Ok(())
}
#[tokio::test]
#[serial]
async fn a_paused_invalidation_reader_is_bounded_by_its_memory_budget() -> Result<()> {
log_try_init();
const BUDGET: usize = 16 * 1024;
const KEY_PADDING: usize = 128;
const OFFERED_KEYS: usize = 5_000;
let metrics = QueueMetricsTestHook::new();
let mut config = get_default_config()?;
config.queue_metrics_test_hook = Some(metrics.clone());
config.backpressure.max_push_bytes = BUDGET;
let tracked = Client::connect(config).await?;
let writer = get_test_client().await?;
let held_stream = tracked.create_client_tracking_invalidation_stream()?;
tracked
.client_tracking(
ClientTrackingStatus::On,
ClientTrackingOptions::default()
.prefix("invalidation_budget_key_")
.broadcasting(),
)
.await?;
let baseline_rss = resident_bytes();
let padding = "p".repeat(KEY_PADDING);
timeout(Duration::from_secs(60), async {
for i in 0..OFFERED_KEYS {
writer.send_and_forget(
cmd("SET")
.arg(format!("invalidation_budget_key_{padding}_{i}"))
.arg("v"),
None,
)?;
if i % 500 == 0 {
let _: String = writer.send(cmd("PING"), None).await?;
}
}
let _: String = writer.send(cmd("PING"), None).await?;
while metrics.push_delivered() == 0 {
tokio::task::yield_now().await;
}
Ok::<(), Error>(())
})
.await??;
let delivered = offered_once_quiet(&metrics).await;
let dropped = held_stream.dropped_messages();
let offered_bytes = metrics.push_delivered_bytes();
let rss_delta = match (baseline_rss, resident_bytes()) {
(Some(before), Some(after)) => Some(after.saturating_sub(before)),
_ => None,
};
let held = delivered.saturating_sub(dropped);
let average_bytes = offered_bytes.max(1) / delivered.max(1);
let max_held = 2 * BUDGET / average_bytes.max(1) + 1;
let report = format!(
"delivered={delivered} dropped={dropped} held={held} max_held={max_held} \
offered={offered_bytes} B average={average_bytes} B budget={BUDGET} B \
rss_delta={rss_delta:?}"
);
println!("paused invalidation reader: {report}");
assert_eq!(
0,
metrics.push_delivery_failed(),
"a live sink must never have a delivery refused: {report}"
);
assert!(
dropped > 0,
"far more was invalidated than the budget allows, so invalidations must \
have been dropped and counted: {report}"
);
assert!(
held <= max_held,
"the stream must hold no more than its budget allows: {report}"
);
drop(held_stream);
tracked
.client_tracking(ClientTrackingStatus::Off, ClientTrackingOptions::default())
.await?;
Ok(())
}