use std::time::Duration;
use crate::attempt::{AttemptOutcome, HintUnusableReason, attempt};
use crate::budget::{Budget, PairBudget};
use crate::channel_pool::ChannelPool;
use crate::error::ClientError;
use crate::leader_hint::classify_not_leader_hint;
use crate::response::TimestampRange;
use crate::retry_policy::{is_transport_failure, jittered_backoff, should_backoff};
use crate::seq_attempt::{SeqAttemptOutcome, SeqBlock, classify_seq_status};
use crate::worklist::Worklist;
const MAX_LEADER_REDIRECTS: u32 = 16;
const MAX_TOTAL_LEADER_REDIRECTS: u32 = MAX_LEADER_REDIRECTS * 4;
pub(crate) async fn issue_rpc(
pool: &ChannelPool,
count: u32,
) -> Result<TimestampRange, ClientError> {
let policy = pool.retry_policy().clone();
let budget = Budget::start(&policy);
let mut last_err: Option<ClientError> = None;
let mut election_signal: Option<tonic::Status> = None;
let mut failed_attempts: u32 = 0;
let mut total_redirects: u32 = 0;
let mut attempt_cap: usize = 0;
let mut pass: u32 = 0;
'passes: loop {
let initial_endpoints = pool.iter_round_robin();
if pass == 0 {
attempt_cap = policy.max_attempts.max(initial_endpoints.len());
}
let mut worklist = Worklist::new(initial_endpoints);
let mut redirects: u32 = 0;
let mut saw_election_signal = false;
while let Some(endpoint) = worklist.next() {
if failed_attempts as usize >= attempt_cap {
break;
}
let Some(attempt_budget) = budget.next_attempt() else {
break;
};
#[cfg(feature = "tracing")]
tracing::debug!(
endpoint = %endpoint,
count,
failed_attempts,
pass,
budget_ms = attempt_budget.as_millis() as u64,
"tsoracle-client: dispatching GetTs to endpoint",
);
match attempt(pool, &endpoint, count, attempt_budget).await {
AttemptOutcome::Ok { range, epoch } => {
pool.record_success(&endpoint, epoch);
return Ok(range);
}
AttemptOutcome::LeaderHint {
endpoint: hinted_endpoint,
epoch: hint_epoch,
} => {
let _ = hint_epoch;
if total_redirects >= MAX_TOTAL_LEADER_REDIRECTS {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_redirect_total_cap.total")
.increment(1);
#[cfg(feature = "tracing")]
tracing::warn!(
from = %endpoint,
to = %hinted_endpoint,
max_total_redirects = MAX_TOTAL_LEADER_REDIRECTS,
"tsoracle-client: absolute leader-hint redirect cap reached; failing fast",
);
last_err = Some(ClientError::Rpc(tonic::Status::failed_precondition(
format!(
"absolute leader-hint redirect cap ({MAX_TOTAL_LEADER_REDIRECTS}) \
reached across passes before finding the live leader"
),
)));
break 'passes;
}
if redirects >= MAX_LEADER_REDIRECTS {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_redirect_cap.total").increment(1);
#[cfg(feature = "tracing")]
tracing::warn!(
from = %endpoint,
to = %hinted_endpoint,
max_redirects = MAX_LEADER_REDIRECTS,
"tsoracle-client: leader-hint redirect cap reached this pass",
);
let status = tonic::Status::failed_precondition(format!(
"leader-hint redirect cap ({MAX_LEADER_REDIRECTS}) reached \
before finding the live leader"
));
election_signal = Some(status.clone());
last_err = Some(ClientError::Rpc(status));
saw_election_signal = true;
break;
}
redirects += 1;
total_redirects = total_redirects.saturating_add(1);
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_pivots.total").increment(1);
#[cfg(feature = "tracing")]
tracing::debug!(
from = %endpoint,
to = %hinted_endpoint,
hint_epoch = ?hint_epoch,
"tsoracle-client: pivoting to hinted leader",
);
worklist.redirect_to(hinted_endpoint);
continue;
}
AttemptOutcome::NoLeaderYet(status) => {
saw_election_signal = true;
election_signal = Some(status.clone());
last_err = Some(ClientError::Rpc(status));
continue;
}
AttemptOutcome::HintUnusable { status, reason } => {
if matches!(reason, HintUnusableReason::StaleEpoch) {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_hint.stale.total").increment(1);
saw_election_signal = true;
election_signal = Some(status.clone());
}
last_err = Some(ClientError::Rpc(status));
continue;
}
AttemptOutcome::Err(err) => {
let should_sleep = should_backoff(&err);
last_err = Some(err);
failed_attempts = failed_attempts.saturating_add(1);
if should_sleep {
let backoff = jittered_backoff(policy.base_backoff, failed_attempts - 1);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
}
continue;
}
}
}
if saw_election_signal && budget.next_attempt().is_some() {
let backoff = jittered_backoff(policy.base_backoff, pass);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
pass = pass.saturating_add(1);
continue;
}
break;
}
Err(surface_error(election_signal, last_err))
}
fn surface_error(
election_signal: Option<tonic::Status>,
last_err: Option<ClientError>,
) -> ClientError {
match last_err {
Some(err) if !is_transport_failure(&err) => err,
last_err => election_signal
.map(ClientError::Rpc)
.or(last_err)
.unwrap_or(ClientError::NoReachableEndpoints),
}
}
pub(crate) async fn issue_seq_rpc(
pool: &ChannelPool,
key: &str,
count: u32,
) -> Result<SeqBlock, ClientError> {
let policy = pool.retry_policy().clone();
let budget = Budget::start(&policy);
let mut last_err: Option<ClientError> = None;
let mut election_signal: Option<tonic::Status> = None;
let mut failed_attempts: u32 = 0;
let mut total_redirects: u32 = 0;
let mut attempt_cap: usize = 0;
let mut pass: u32 = 0;
let mut issued_one = false;
'passes: loop {
let initial_endpoints = pool.iter_round_robin();
if pass == 0 {
attempt_cap = policy.max_attempts.max(initial_endpoints.len());
}
let mut worklist = Worklist::new(initial_endpoints);
let mut redirects: u32 = 0;
let mut saw_election_signal = false;
while let Some(endpoint) = worklist.next() {
if failed_attempts as usize >= attempt_cap {
break;
}
let Some(attempt_budget) = budget.next_attempt() else {
break;
};
if issued_one && attempt_budget < policy.per_attempt_deadline {
break 'passes;
}
issued_one = true;
#[cfg(feature = "tracing")]
tracing::debug!(
endpoint = %endpoint,
key,
count,
failed_attempts,
pass,
budget_ms = attempt_budget.as_millis() as u64,
"tsoracle-client: dispatching GetSeq to endpoint",
);
let pair = PairBudget::start(attempt_budget);
let (mut client, cell) = match tokio::time::timeout(
attempt_budget,
pool.client_with_cell(&endpoint),
)
.await
{
Ok(Ok(leased)) => leased,
Ok(Err(err)) => {
let do_backoff = should_backoff(&err);
last_err = Some(err);
failed_attempts = failed_attempts.saturating_add(1);
if do_backoff {
let backoff = jittered_backoff(policy.base_backoff, failed_attempts - 1);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
}
continue;
}
Err(_) => {
let err = ClientError::Rpc(tonic::Status::deadline_exceeded(format!(
"connect exceeded per_attempt_deadline of {attempt_budget:?}"
)));
last_err = Some(err);
failed_attempts = failed_attempts.saturating_add(1);
continue;
}
};
let rpc_budget = pair.remaining();
let rpc = client.get_seq(tsoracle_proto::v1::GetSeqRequest {
key: key.to_string(),
count,
});
let outcome = match tokio::time::timeout(rpc_budget, rpc).await {
Ok(Ok(response)) => {
let inner = response.into_inner();
if inner.key != key || inner.count != count {
return Err(ClientError::SeqUncertain);
}
let epoch = match inner.epoch {
Some(ew) => tsoracle_core::Epoch::from_wire(ew.hi, ew.lo).0,
None => {
return Err(ClientError::SeqUncertain);
}
};
pool.record_success(&endpoint, epoch);
return Ok(SeqBlock {
start: inner.start,
count: inner.count,
epoch,
});
}
Ok(Err(status)) if status.code() == tonic::Code::FailedPrecondition => {
use crate::attempt::{AttemptOutcome as AO, HintUnusableReason};
use crate::channel_pool::{LeaderHintLookup, decode_leader_hint};
if matches!(decode_leader_hint(&status), LeaderHintLookup::Absent) {
classify_seq_status(status, false)
} else {
match classify_not_leader_hint(pool, &endpoint, status) {
AO::LeaderHint {
endpoint: hinted,
epoch,
} => SeqAttemptOutcome::LeaderHint {
endpoint: hinted,
epoch,
},
AO::NoLeaderYet(status)
| AO::HintUnusable {
status,
reason: HintUnusableReason::StaleEpoch,
} => {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_hint.stale.total")
.increment(1);
saw_election_signal = true;
election_signal = Some(status.clone());
last_err = Some(ClientError::Rpc(status));
continue;
}
AO::HintUnusable {
status,
reason: HintUnusableReason::Rejected,
} => SeqAttemptOutcome::Err(ClientError::Rpc(status)),
AO::Ok { .. } | AO::Err(_) => {
unreachable!(
"classify_not_leader_hint on FailedPrecondition \
cannot produce Ok/Err"
)
}
}
}
}
Ok(Err(status)) => {
if is_transport_failure(&ClientError::Rpc(status.clone())) {
pool.evict_if_current(&endpoint, &cell);
}
classify_seq_status(status, true)
}
Err(_) => {
pool.evict_if_current(&endpoint, &cell);
SeqAttemptOutcome::Uncertain
}
};
match outcome {
SeqAttemptOutcome::Uncertain => {
return Err(ClientError::SeqUncertain);
}
SeqAttemptOutcome::LeaderHint {
endpoint: hinted,
epoch: _hint_epoch,
} => {
if total_redirects >= MAX_TOTAL_LEADER_REDIRECTS {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_redirect_total_cap.total")
.increment(1);
last_err = Some(ClientError::Rpc(tonic::Status::failed_precondition(
format!(
"absolute leader-hint redirect cap ({MAX_TOTAL_LEADER_REDIRECTS}) \
reached across passes before finding the live leader"
),
)));
break 'passes;
}
if redirects >= MAX_LEADER_REDIRECTS {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_redirect_cap.total").increment(1);
let status = tonic::Status::failed_precondition(format!(
"leader-hint redirect cap ({MAX_LEADER_REDIRECTS}) reached \
before finding the live leader"
));
election_signal = Some(status.clone());
last_err = Some(ClientError::Rpc(status));
saw_election_signal = true;
break;
}
redirects += 1;
total_redirects = total_redirects.saturating_add(1);
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_pivots.total").increment(1);
worklist.redirect_to(hinted);
continue;
}
SeqAttemptOutcome::Err(err) => {
let should_sleep = should_backoff(&err);
last_err = Some(err);
failed_attempts = failed_attempts.saturating_add(1);
if should_sleep {
let backoff = jittered_backoff(policy.base_backoff, failed_attempts - 1);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
}
continue;
}
}
}
if saw_election_signal && budget.next_attempt().is_some() {
let backoff = jittered_backoff(policy.base_backoff, pass);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
pass = pass.saturating_add(1);
continue;
}
break;
}
Err(surface_error(election_signal, last_err))
}
pub(crate) async fn issue_seq_batch_rpc(
pool: &ChannelPool,
entries: &[(&str, u32)],
) -> Result<Vec<SeqBlock>, ClientError> {
let policy = pool.retry_policy().clone();
let budget = Budget::start(&policy);
let mut last_err: Option<ClientError> = None;
let mut election_signal: Option<tonic::Status> = None;
let mut failed_attempts: u32 = 0;
let mut total_redirects: u32 = 0;
let mut attempt_cap: usize = 0;
let mut pass: u32 = 0;
let mut issued_one = false;
'passes: loop {
let initial_endpoints = pool.iter_round_robin();
if pass == 0 {
attempt_cap = policy.max_attempts.max(initial_endpoints.len());
}
let mut worklist = Worklist::new(initial_endpoints);
let mut redirects: u32 = 0;
let mut saw_election_signal = false;
while let Some(endpoint) = worklist.next() {
if failed_attempts as usize >= attempt_cap {
break;
}
let Some(attempt_budget) = budget.next_attempt() else {
break;
};
if issued_one && attempt_budget < policy.per_attempt_deadline {
break 'passes;
}
issued_one = true;
#[cfg(feature = "tracing")]
tracing::debug!(
endpoint = %endpoint,
keys = entries.len(),
failed_attempts,
pass,
budget_ms = attempt_budget.as_millis() as u64,
"tsoracle-client: dispatching GetSeqBatch to endpoint",
);
let pair = PairBudget::start(attempt_budget);
let (mut client, cell) = match tokio::time::timeout(
attempt_budget,
pool.client_with_cell(&endpoint),
)
.await
{
Ok(Ok(leased)) => leased,
Ok(Err(err)) => {
let do_backoff = should_backoff(&err);
last_err = Some(err);
failed_attempts = failed_attempts.saturating_add(1);
if do_backoff {
let backoff = jittered_backoff(policy.base_backoff, failed_attempts - 1);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
}
continue;
}
Err(_) => {
let err = ClientError::Rpc(tonic::Status::deadline_exceeded(format!(
"connect exceeded per_attempt_deadline of {attempt_budget:?}"
)));
last_err = Some(err);
failed_attempts = failed_attempts.saturating_add(1);
continue;
}
};
let rpc_budget = pair.remaining();
let request_entries: Vec<tsoracle_proto::v1::SeqRequestEntry> = entries
.iter()
.map(|(key, count)| tsoracle_proto::v1::SeqRequestEntry {
key: key.to_string(),
count: *count,
})
.collect();
let rpc = client.get_seq_batch(tsoracle_proto::v1::GetSeqBatchRequest {
entries: request_entries,
});
let outcome = match tokio::time::timeout(rpc_budget, rpc).await {
Ok(Ok(response)) => {
let inner = response.into_inner();
if inner.grants.len() != entries.len() {
return Err(ClientError::SeqUncertain);
}
let epoch = match inner.epoch {
Some(ew) => tsoracle_core::Epoch::from_wire(ew.hi, ew.lo).0,
None => return Err(ClientError::SeqUncertain),
};
let mut blocks = Vec::with_capacity(inner.grants.len());
for ((req_key, req_count), grant) in entries.iter().zip(inner.grants.iter()) {
if grant.key != *req_key || grant.count != *req_count {
return Err(ClientError::SeqUncertain);
}
blocks.push(SeqBlock {
start: grant.start,
count: grant.count,
epoch,
});
}
pool.record_success(&endpoint, epoch);
return Ok(blocks);
}
Ok(Err(status)) if status.code() == tonic::Code::FailedPrecondition => {
use crate::attempt::{AttemptOutcome as AO, HintUnusableReason};
use crate::channel_pool::{LeaderHintLookup, decode_leader_hint};
if matches!(decode_leader_hint(&status), LeaderHintLookup::Absent) {
classify_seq_status(status, false)
} else {
match classify_not_leader_hint(pool, &endpoint, status) {
AO::LeaderHint {
endpoint: hinted,
epoch,
} => SeqAttemptOutcome::LeaderHint {
endpoint: hinted,
epoch,
},
AO::NoLeaderYet(status)
| AO::HintUnusable {
status,
reason: HintUnusableReason::StaleEpoch,
} => {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_hint.stale.total")
.increment(1);
saw_election_signal = true;
election_signal = Some(status.clone());
last_err = Some(ClientError::Rpc(status));
continue;
}
AO::HintUnusable {
status,
reason: HintUnusableReason::Rejected,
} => SeqAttemptOutcome::Err(ClientError::Rpc(status)),
AO::Ok { .. } | AO::Err(_) => {
unreachable!(
"classify_not_leader_hint on FailedPrecondition \
cannot produce Ok/Err"
)
}
}
}
}
Ok(Err(status)) => {
if is_transport_failure(&ClientError::Rpc(status.clone())) {
pool.evict_if_current(&endpoint, &cell);
}
classify_seq_status(status, true)
}
Err(_) => {
pool.evict_if_current(&endpoint, &cell);
SeqAttemptOutcome::Uncertain
}
};
match outcome {
SeqAttemptOutcome::Uncertain => {
return Err(ClientError::SeqUncertain);
}
SeqAttemptOutcome::LeaderHint {
endpoint: hinted,
epoch: _hint_epoch,
} => {
if total_redirects >= MAX_TOTAL_LEADER_REDIRECTS {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_redirect_total_cap.total")
.increment(1);
last_err = Some(ClientError::Rpc(tonic::Status::failed_precondition(
format!(
"absolute leader-hint redirect cap ({MAX_TOTAL_LEADER_REDIRECTS}) \
reached across passes before finding the live leader"
),
)));
break 'passes;
}
if redirects >= MAX_LEADER_REDIRECTS {
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_redirect_cap.total").increment(1);
let status = tonic::Status::failed_precondition(format!(
"leader-hint redirect cap ({MAX_LEADER_REDIRECTS}) reached \
before finding the live leader"
));
election_signal = Some(status.clone());
last_err = Some(ClientError::Rpc(status));
saw_election_signal = true;
break;
}
redirects += 1;
total_redirects = total_redirects.saturating_add(1);
#[cfg(feature = "metrics")]
metrics::counter!("tsoracle.client.leader_pivots.total").increment(1);
worklist.redirect_to(hinted);
continue;
}
SeqAttemptOutcome::Err(err) => {
let should_sleep = should_backoff(&err);
last_err = Some(err);
failed_attempts = failed_attempts.saturating_add(1);
if should_sleep {
let backoff = jittered_backoff(policy.base_backoff, failed_attempts - 1);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
}
continue;
}
}
}
if saw_election_signal && budget.next_attempt().is_some() {
let backoff = jittered_backoff(policy.base_backoff, pass);
let sleep_for = budget.clamp_backoff(backoff);
if sleep_for > Duration::ZERO {
tokio::time::sleep(sleep_for).await;
}
pass = pass.saturating_add(1);
continue;
}
break;
}
Err(surface_error(election_signal, last_err))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RetryPolicy;
use crate::test_support::{enable_tracing, make_status_with_hint, short_policy};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::time::Instant;
#[test]
fn sticky_election_signal_outranks_a_transport_straggler() {
let election = tonic::Status::failed_precondition("no leader yet");
let timeout = ClientError::Rpc(tonic::Status::deadline_exceeded("rpc budget exhausted"));
let surfaced = surface_error(Some(election), Some(timeout));
match surfaced {
ClientError::Rpc(status) => assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"election signal must outrank the transport timeout"
),
other => panic!("expected the election FAILED_PRECONDITION, got {other:?}"),
}
}
#[test]
fn deterministic_rejection_outranks_a_stale_election_signal() {
let election = tonic::Status::failed_precondition("no leader yet");
let rejection = ClientError::Rpc(tonic::Status::internal("malformed leader hint"));
let surfaced = surface_error(Some(election), Some(rejection));
match surfaced {
ClientError::Rpc(status) => assert_eq!(
status.code(),
tonic::Code::Internal,
"a non-transport rejection must win over the election signal"
),
other => panic!("expected the Internal rejection, got {other:?}"),
}
}
#[test]
fn no_election_signal_falls_back_to_last_err_then_no_reachable_endpoints() {
let timeout = ClientError::Rpc(tonic::Status::deadline_exceeded("budget exhausted"));
match surface_error(None, Some(timeout)) {
ClientError::Rpc(status) => {
assert_eq!(status.code(), tonic::Code::DeadlineExceeded)
}
other => panic!("expected the transport timeout, got {other:?}"),
}
assert!(
matches!(surface_error(None, None), ClientError::NoReachableEndpoints),
"no signal and no attempt must fall back to NoReachableEndpoints"
);
}
#[tokio::test]
async fn duplicate_endpoints_are_visited_once() {
let pool = ChannelPool::new(
vec!["http://127.0.0.1:1".into(), "http://127.0.0.1:1".into()],
None,
false,
short_policy(),
);
let result = issue_rpc(&pool, 1).await;
assert!(result.is_err(), "no live endpoint must surface as Err");
}
#[tokio::test]
async fn unreachable_endpoints_surface_last_error() {
enable_tracing();
let pool = ChannelPool::new(
vec!["http://127.0.0.1:1".into()],
None,
false,
short_policy(),
);
let result = issue_rpc(&pool, 1).await;
assert!(result.is_err(), "expected Err from unreachable pool");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn overall_deadline_caps_total_wall_clock() {
let policy = RetryPolicy {
max_attempts: 5,
per_attempt_deadline: Duration::from_millis(100),
overall_deadline: Duration::from_millis(200),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(
vec![
"http://127.0.0.1:1".into(),
"http://127.0.0.1:2".into(),
"http://127.0.0.1:3".into(),
"http://127.0.0.1:4".into(),
"http://127.0.0.1:5".into(),
],
None,
false,
policy,
);
let start = std::time::Instant::now();
let result = issue_rpc(&pool, 1).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "expected Err from all-unreachable pool");
assert!(
elapsed < Duration::from_secs(2),
"must return within ~overall_deadline; took {elapsed:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_attempt_budget_is_floored_to_a_full_sweep() {
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_millis(50),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let dials = Arc::new(AtomicUsize::new(0));
let dials_for_connector = dials.clone();
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
dials_for_connector.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
Err(ClientError::Rpc(tonic::Status::unavailable(
"simulated dead endpoint",
)))
})
});
let pool = ChannelPool::new(
vec![
"dead-1:1".into(),
"dead-2:1".into(),
"dead-3:1".into(),
"dead-4:1".into(),
],
Some(connector),
false,
policy,
);
let result = issue_rpc(&pool, 1).await;
assert!(result.is_err(), "expected Err from all-unreachable pool");
assert_eq!(
dials.load(Ordering::SeqCst),
4,
"max_attempts=2 must not cut the cold sweep short: every configured \
endpoint must be dialed at least once (the floor)",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hint_rejected_preserves_cached_leader() {
let addr = crate::test_support::FakeTso::new()
.on_get_ts(|_req| async { Err(tonic::Status::failed_precondition("not leader")) })
.spawn()
.await;
let endpoint = format!("http://{addr}");
let pool = ChannelPool::new(vec![endpoint.clone()], None, false, short_policy());
let ready_deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(mut client) = pool.client(&endpoint).await {
let replied_not_leader = client
.get_ts(tsoracle_proto::v1::GetTsRequest { count: 1 })
.await
.err()
.is_some_and(|status| status.code() == tonic::Code::FailedPrecondition);
if replied_not_leader {
break;
}
}
assert!(
Instant::now() < ready_deadline,
"fake follower never came up",
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
pool.record_success(&endpoint, 1);
assert_eq!(pool.cached_leader().as_deref(), Some(endpoint.as_str()));
let result = issue_rpc(&pool, 1).await;
assert!(result.is_err(), "hintless NOT_LEADER must surface as Err");
assert_eq!(
pool.cached_leader().as_deref(),
Some(endpoint.as_str()),
"NoLeaderYet (absent hint) must not invalidate the cached leader",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stale_leader_hint_surfaces_failed_precondition_not_no_reachable_endpoints() {
let addr = crate::test_support::FakeTso::new()
.on_get_ts(|_req| async {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 5 }),
}))
})
.spawn()
.await;
let endpoint = format!("http://{addr}");
let pool = ChannelPool::new(vec![endpoint.clone()], None, false, short_policy());
let ready_deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(mut client) = pool.client(&endpoint).await {
let replied_not_leader = client
.get_ts(tsoracle_proto::v1::GetTsRequest { count: 1 })
.await
.err()
.is_some_and(|status| status.code() == tonic::Code::FailedPrecondition);
if replied_not_leader {
break;
}
}
assert!(
Instant::now() < ready_deadline,
"fake follower never came up",
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
pool.record_success(&endpoint, 10);
let err = issue_rpc(&pool, 1)
.await
.expect_err("a stale-hint-only worklist must surface an error");
match err {
ClientError::Rpc(status) => assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"stale redirect must surface as FAILED_PRECONDITION",
),
other => panic!(
"expected ClientError::Rpc(FailedPrecondition), got {other:?} \
(NoReachableEndpoints means the HintUnusable {{ reason: StaleEpoch }} arm dropped last_err)"
),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn redirect_chain_longer_than_max_attempts_reaches_leader() {
use std::future::Future;
use std::pin::Pin;
const REDIRECTS: usize = 3;
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_ts(move |_req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < REDIRECTS {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
} else {
Ok(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
})
}
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
}) as Pin<Box<dyn Future<Output = Result<_, _>> + Send>>
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let range = issue_rpc(&pool, 1)
.await
.expect("a redirect chain that ends at a live leader must yield a timestamp");
assert_eq!(
range.count(),
1,
"the leader returned exactly one timestamp"
);
assert_eq!(
calls.load(Ordering::SeqCst),
REDIRECTS + 1,
"the loop must dial through all {REDIRECTS} redirects to the leader",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn redirect_cap_then_settles_reaches_leader() {
const CHURN: usize = MAX_LEADER_REDIRECTS as usize + 3;
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_ts(move |_req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < CHURN {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
} else {
Ok(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
})
}
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
})
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::from_millis(5),
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let range = issue_rpc(&pool, 1)
.await
.expect("a cluster that settles after churn must be ridden out to the leader");
assert_eq!(
range.count(),
1,
"the settled leader returned one timestamp"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn endless_redirect_chain_is_bounded_by_absolute_cap() {
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_ts(move |_req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
})
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::from_millis(2),
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let start = std::time::Instant::now();
let err = issue_rpc(&pool, 1)
.await
.expect_err("an endless redirect chain must surface an error, not a timestamp");
let elapsed = start.elapsed();
let dials = calls.load(Ordering::SeqCst);
match err {
ClientError::Rpc(status) => {
assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"the absolute cap must surface FailedPrecondition, got {:?}",
status.code(),
);
assert!(
status
.message()
.contains("absolute leader-hint redirect cap"),
"the surfaced status must be the absolute-cap rejection, got {:?}",
status.message(),
);
}
other => panic!(
"expected a bounded ClientError::Rpc, not {other:?} \
(e.g. the misleading NoReachableEndpoints)"
),
}
assert!(
dials >= MAX_TOTAL_LEADER_REDIRECTS as usize,
"the chain must churn up to the absolute cap; only {dials} dials",
);
assert!(
dials <= (MAX_TOTAL_LEADER_REDIRECTS + 2 * MAX_LEADER_REDIRECTS) as usize,
"dials must be bounded by the absolute cap, not the deadline; got {dials}",
);
assert!(
elapsed < Duration::from_secs(5),
"the cap (not the 10s deadline) must terminate the churn; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn redirect_chain_at_cap_still_reaches_leader() {
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_ts(move |_req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < MAX_LEADER_REDIRECTS as usize {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
} else {
Ok(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
})
}
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
})
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let range = issue_rpc(&pool, 1)
.await
.expect("a chain of exactly MAX_LEADER_REDIRECTS hops must reach the leader");
assert_eq!(
range.count(),
1,
"the leader returned exactly one timestamp"
);
assert_eq!(
calls.load(Ordering::SeqCst),
MAX_LEADER_REDIRECTS as usize + 1,
"the loop must dial through all MAX_LEADER_REDIRECTS redirects to the leader",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rides_out_election_until_leader_appears() {
const NO_LEADER_REPLIES: usize = 4;
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_ts(move |_req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < NO_LEADER_REPLIES {
Err(tonic::Status::failed_precondition("no leader yet"))
} else {
Ok(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
})
}
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
})
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::from_millis(5),
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["follower:1".into()], Some(connector), false, policy);
let range = issue_rpc(&pool, 1)
.await
.expect("the client must ride out the election and reach the leader");
assert_eq!(
range.count(),
1,
"the leader returned exactly one timestamp"
);
assert!(
calls.load(Ordering::SeqCst) > NO_LEADER_REPLIES,
"the loop must re-poll through every no-leader reply to the serving call",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dead_pool_does_not_ride_out() {
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_millis(100),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(
vec!["http://127.0.0.1:1".into(), "http://127.0.0.1:2".into()],
None,
false,
policy,
);
let start = std::time::Instant::now();
let result = issue_rpc(&pool, 1).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "all-dead pool must surface an error");
assert!(
elapsed < Duration::from_secs(2),
"a dead pool must fail fast, not ride out the full overall_deadline; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn malformed_hint_does_not_ride_out() {
let addr = crate::test_support::FakeTso::new()
.on_get_ts(|_req| async {
let mut status = tonic::Status::failed_precondition("not leader");
let key = tonic::metadata::MetadataKey::from_bytes(
tsoracle_proto::v1::LEADER_HINT_TRAILER_KEY.as_bytes(),
)
.expect("trailer key is ascii");
let garbage: &[u8] = &[0x0a, 0x05, b'h', b'i'];
status.metadata_mut().insert_bin(
key,
tonic::metadata::BinaryMetadataValue::from_bytes(garbage),
);
Err(status)
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
})
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["peer:1".into()], Some(connector), false, policy);
let start = std::time::Instant::now();
let result = issue_rpc(&pool, 1).await;
let elapsed = start.elapsed();
assert!(
result.is_err(),
"malformed-hint NOT_LEADER must surface an error"
);
assert!(
elapsed < Duration::from_secs(2),
"a deterministic malformed-hint rejection must not ride out; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exhausted_ride_out_surfaces_not_leader() {
let addr = crate::test_support::FakeTso::new()
.on_get_ts(|_req| async { Err(tonic::Status::failed_precondition("no leader yet")) })
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
})
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_millis(200),
overall_deadline: Duration::from_millis(300),
base_backoff: Duration::from_millis(5),
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["follower:1".into()], Some(connector), false, policy);
let ready_deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(mut client) = pool.client("follower:1").await {
let replied_not_leader = client
.get_ts(tsoracle_proto::v1::GetTsRequest { count: 1 })
.await
.err()
.is_some_and(|status| status.code() == tonic::Code::FailedPrecondition);
if replied_not_leader {
break;
}
}
assert!(
Instant::now() < ready_deadline,
"fake AlwaysElecting peer never became ready"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let err = issue_rpc(&pool, 1)
.await
.expect_err("a cluster that never elects must surface an error at the deadline");
match err {
ClientError::Rpc(status) => assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"must surface the NOT_LEADER status, not NoReachableEndpoints",
),
other => panic!("expected ClientError::Rpc(FailedPrecondition), got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotation_offset_cannot_strand_the_only_live_endpoint() {
let addr = crate::test_support::FakeTso::new()
.on_get_ts(|_req| async {
Ok(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
})
})
.spawn()
.await;
let dialed: Arc<std::sync::Mutex<Vec<String>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let dialed_for_connector = dialed.clone();
let connector: Arc<crate::transport::ChannelConnector> = Arc::new(move |endpoint: &str| {
dialed_for_connector
.lock()
.unwrap()
.push(endpoint.to_string());
let is_live = endpoint.contains("live");
Box::pin(async move {
if is_live {
tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
} else {
Err(ClientError::Rpc(tonic::Status::unavailable(
"simulated dead endpoint",
)))
}
})
});
let endpoints = vec![
"live:1".to_string(),
"dead-1:1".to_string(),
"dead-2:1".to_string(),
"dead-3:1".to_string(),
"dead-4:1".to_string(),
"dead-5:1".to_string(),
];
let policy = RetryPolicy {
max_attempts: 5,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(endpoints, Some(connector), false, policy);
pool.pin_rotation_for_test(1);
let range = issue_rpc(&pool, 1).await.expect(
"a reachable configured endpoint must be dialed even when it \
sits past max_attempts in the rotated worklist",
);
assert_eq!(range.count(), 1, "the live leader returned one timestamp");
assert!(
dialed
.lock()
.unwrap()
.iter()
.any(|endpoint| endpoint.contains("live")),
"the live endpoint must be dialed; dialed = {:?}",
dialed.lock().unwrap(),
);
}
#[tokio::test(start_paused = true)]
async fn connect_exceeding_per_attempt_deadline_surfaces_deadline_exceeded() {
enable_tracing();
let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
Box::pin(async move {
tokio::time::sleep(Duration::from_secs(3600)).await;
unreachable!("the per-attempt timeout must cancel this connect")
})
});
let policy = RetryPolicy {
max_attempts: 1,
per_attempt_deadline: Duration::from_millis(100),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["slow:1".into()], Some(connector), false, policy);
match issue_rpc(&pool, 1).await {
Err(ClientError::Rpc(status)) => assert_eq!(
status.code(),
tonic::Code::DeadlineExceeded,
"a connect that overran the per-attempt budget must surface DeadlineExceeded",
),
other => panic!("expected an RPC DeadlineExceeded error, got {other:?}"),
}
}
#[tokio::test(start_paused = true)]
async fn overall_deadline_stops_loop_between_attempts() {
enable_tracing();
let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
Box::pin(async move { Err(ClientError::Rpc(tonic::Status::unavailable("dead"))) })
});
let policy = RetryPolicy {
max_attempts: 10,
per_attempt_deadline: Duration::from_millis(50),
overall_deadline: Duration::from_millis(100),
base_backoff: Duration::from_secs(60),
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(
vec!["a:1".into(), "b:1".into(), "c:1".into()],
Some(connector),
false,
policy,
);
match issue_rpc(&pool, 1).await {
Err(ClientError::Rpc(status)) => assert_eq!(
status.code(),
tonic::Code::Unavailable,
"the loop must surface the last transport error once the overall \
deadline cuts it short",
),
other => panic!("expected the last transport error, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_follows_leader_hint_chain_to_leader() {
use std::future::Future;
use std::pin::Pin;
const REDIRECTS: usize = 3;
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_seq(move |req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < REDIRECTS {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
} else {
let (hi, lo) = tsoracle_core::Epoch(9).to_wire();
Ok(tsoracle_proto::v1::GetSeqResponse {
key: req.key,
start: 100,
count: req.count,
epoch: Some(tsoracle_proto::v1::EpochWire { hi, lo }),
})
}
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
}) as Pin<Box<dyn Future<Output = Result<_, _>> + Send>>
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let block = issue_seq_rpc(&pool, "orders", 5)
.await
.expect("a redirect chain ending at a live leader must yield a block");
assert_eq!(block.start, 100);
assert_eq!(block.count, 5);
assert_eq!(block.epoch, 9);
assert_eq!(
calls.load(Ordering::SeqCst),
REDIRECTS + 1,
"the loop must dial through all {REDIRECTS} redirects to the leader",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_endpointless_hint_fails_fast() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq(|_req| async {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: None,
leader_epoch: None,
}))
})
.spawn()
.await;
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec![format!("http://{addr}")], None, false, policy);
let start = Instant::now();
let result = issue_seq_rpc(&pool, "orders", 1).await;
let elapsed = start.elapsed();
assert!(
matches!(result, Err(ClientError::Rpc(_))),
"an unfollowable hint must surface a definitive Rpc error, got {result:?}",
);
assert!(
elapsed < Duration::from_secs(2),
"an unfollowable hint must fail fast, not ride out the deadline; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_dead_pool_surfaces_error_fast() {
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_millis(100),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(
vec!["http://127.0.0.1:1".into(), "http://127.0.0.1:2".into()],
None,
false,
policy,
);
let start = Instant::now();
let result = issue_seq_rpc(&pool, "orders", 1).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "all-dead pool must surface an error");
assert!(
elapsed < Duration::from_secs(2),
"a dead pool must fail fast, not ride out the full deadline; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_success_without_epoch_is_uncertain() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq(|req| async move {
Ok(tsoracle_proto::v1::GetSeqResponse {
key: req.key,
start: 0,
count: req.count,
epoch: None,
})
})
.spawn()
.await;
let pool = ChannelPool::new(vec![format!("http://{addr}")], None, false, short_policy());
match issue_seq_rpc(&pool, "orders", 1).await {
Err(ClientError::SeqUncertain) => {}
other => panic!("epoch-less success must be SeqUncertain, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_endless_redirect_is_bounded_by_cap() {
use std::future::Future;
use std::pin::Pin;
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_seq(move |_req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
}) as Pin<Box<dyn Future<Output = Result<_, _>> + Send>>
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let start = Instant::now();
let err = issue_seq_rpc(&pool, "orders", 1)
.await
.expect_err("an endless redirect chain must surface an error, not a block");
let elapsed = start.elapsed();
let dials = calls.load(Ordering::SeqCst);
match err {
ClientError::Rpc(status) => {
assert_eq!(status.code(), tonic::Code::FailedPrecondition);
assert!(
status
.message()
.contains("absolute leader-hint redirect cap"),
"must surface the absolute-cap rejection, got {:?}",
status.message(),
);
}
other => panic!("expected a bounded ClientError::Rpc, got {other:?}"),
}
assert!(
dials >= MAX_TOTAL_LEADER_REDIRECTS as usize
&& dials <= (MAX_TOTAL_LEADER_REDIRECTS + 2 * MAX_LEADER_REDIRECTS) as usize,
"dials must be bounded by the absolute cap, not the deadline; got {dials}",
);
assert!(
elapsed < Duration::from_secs(5),
"the cap (not the 10s deadline) must terminate the churn; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_stale_epoch_hint_rides_out_then_surfaces_election() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq(|_req| async {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 5 }),
}))
})
.spawn()
.await;
let endpoint = format!("http://{addr}");
let pool = ChannelPool::new(vec![endpoint.clone()], None, false, short_policy());
let ready_deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(mut client) = pool.client(&endpoint).await {
let replied = client
.get_seq(tsoracle_proto::v1::GetSeqRequest {
key: "orders".into(),
count: 1,
})
.await
.err()
.is_some_and(|s| s.code() == tonic::Code::FailedPrecondition);
if replied {
break;
}
}
assert!(
Instant::now() < ready_deadline,
"fake follower never came up"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
pool.record_success(&endpoint, 10);
match issue_seq_rpc(&pool, "orders", 1).await {
Err(ClientError::Rpc(status)) => assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"a stale-hint ride-out must surface the election FAILED_PRECONDITION",
),
other => panic!("expected a ridden-out FAILED_PRECONDITION, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_ride_out_skips_squeezed_attempt_and_surfaces_election() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq(|_req| async {
tokio::time::sleep(Duration::from_millis(300)).await;
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 5 }),
}))
})
.spawn()
.await;
let endpoint = format!("http://{addr}");
let policy = RetryPolicy {
max_attempts: 5,
per_attempt_deadline: Duration::from_millis(500),
overall_deadline: Duration::from_millis(500),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec![endpoint.clone()], None, false, policy);
pool.record_success(&endpoint, 10);
match issue_seq_rpc(&pool, "orders", 1).await {
Err(ClientError::Rpc(status)) => assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"a budget-squeezed ride-out must surface the election \
FAILED_PRECONDITION, not a manufactured SeqUncertain",
),
other => panic!(
"a squeezed ride-out attempt must not be issued and time out; \
expected the election FAILED_PRECONDITION, got {other:?}"
),
}
}
fn batch_ok_response(
req: tsoracle_proto::v1::GetSeqBatchRequest,
) -> tsoracle_proto::v1::GetSeqBatchResponse {
let (hi, lo) = tsoracle_core::Epoch(9).to_wire();
let grants = req
.entries
.iter()
.scan(100u64, |start, entry| {
let grant = tsoracle_proto::v1::SeqGrantEntry {
key: entry.key.clone(),
start: *start,
count: entry.count,
};
*start += u64::from(entry.count);
Some(grant)
})
.collect();
tsoracle_proto::v1::GetSeqBatchResponse {
grants,
epoch: Some(tsoracle_proto::v1::EpochWire { hi, lo }),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_batch_follows_leader_hint_chain_to_leader() {
use std::future::Future;
use std::pin::Pin;
const REDIRECTS: usize = 3;
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_seq_batch(move |req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < REDIRECTS {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
} else {
Ok(batch_ok_response(req))
}
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
}) as Pin<Box<dyn Future<Output = Result<_, _>> + Send>>
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let blocks = issue_seq_batch_rpc(&pool, &[("orders", 1), ("invoices", 2)])
.await
.expect("a redirect chain ending at a live leader must yield blocks");
assert_eq!(blocks.len(), 2, "one block per batch entry");
assert_eq!(blocks[0].start, 100, "orders block start");
assert_eq!(blocks[0].count, 1);
assert_eq!(blocks[0].epoch, 9);
assert_eq!(blocks[1].start, 101, "invoices block start after orders");
assert_eq!(blocks[1].count, 2);
assert_eq!(
calls.load(Ordering::SeqCst),
REDIRECTS + 1,
"the loop must dial through all {REDIRECTS} redirects to the leader",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_batch_endpointless_hint_fails_fast() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq_batch(|_req| async {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: None,
leader_epoch: None,
}))
})
.spawn()
.await;
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec![format!("http://{addr}")], None, false, policy);
let start = Instant::now();
let result = issue_seq_batch_rpc(&pool, &[("orders", 1), ("invoices", 2)]).await;
let elapsed = start.elapsed();
assert!(
matches!(result, Err(ClientError::Rpc(_))),
"an unfollowable hint must surface a definitive Rpc error, got {result:?}",
);
assert!(
elapsed < Duration::from_secs(2),
"an unfollowable hint must fail fast, not ride out the deadline; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_batch_dead_pool_surfaces_error_fast() {
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_millis(100),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(
vec!["http://127.0.0.1:1".into(), "http://127.0.0.1:2".into()],
None,
false,
policy,
);
let start = Instant::now();
let result = issue_seq_batch_rpc(&pool, &[("orders", 1), ("invoices", 2)]).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "all-dead pool must surface an error");
assert!(
elapsed < Duration::from_secs(2),
"a dead pool must fail fast, not ride out the full deadline; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_batch_success_without_epoch_is_uncertain() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq_batch(|req| async move {
let grants = req
.entries
.iter()
.map(|e| tsoracle_proto::v1::SeqGrantEntry {
key: e.key.clone(),
start: 0,
count: e.count,
})
.collect();
Ok(tsoracle_proto::v1::GetSeqBatchResponse {
grants,
epoch: None, })
})
.spawn()
.await;
let pool = ChannelPool::new(vec![format!("http://{addr}")], None, false, short_policy());
match issue_seq_batch_rpc(&pool, &[("orders", 1), ("invoices", 2)]).await {
Err(ClientError::SeqUncertain) => {}
other => panic!("epoch-less batch success must be SeqUncertain, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_batch_endless_redirect_is_bounded_by_cap() {
use std::future::Future;
use std::pin::Pin;
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
let addr = crate::test_support::FakeTso::new()
.on_get_seq_batch(move |_req| {
let calls = server_calls.clone();
async move {
let n = calls.fetch_add(1, Ordering::SeqCst);
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some(format!("redirect-{}:1", n + 1)),
leader_epoch: None,
}))
}
})
.spawn()
.await;
let connector: Arc<crate::transport::ChannelConnector> =
Arc::new(move |_endpoint: &str| {
let target = format!("http://{addr}");
Box::pin(async move {
tonic::transport::Endpoint::from_shared(target)
.map_err(ClientError::from)?
.connect()
.await
.map_err(ClientError::from)
}) as Pin<Box<dyn Future<Output = Result<_, _>> + Send>>
});
let policy = RetryPolicy {
max_attempts: 2,
per_attempt_deadline: Duration::from_secs(2),
overall_deadline: Duration::from_secs(10),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec!["redirect-0:1".into()], Some(connector), false, policy);
let start = Instant::now();
let err = issue_seq_batch_rpc(&pool, &[("orders", 1), ("invoices", 2)])
.await
.expect_err("an endless redirect chain must surface an error, not blocks");
let elapsed = start.elapsed();
let dials = calls.load(Ordering::SeqCst);
match err {
ClientError::Rpc(status) => {
assert_eq!(status.code(), tonic::Code::FailedPrecondition);
assert!(
status
.message()
.contains("absolute leader-hint redirect cap"),
"must surface the absolute-cap rejection, got {:?}",
status.message(),
);
}
other => panic!("expected a bounded ClientError::Rpc, got {other:?}"),
}
assert!(
dials >= MAX_TOTAL_LEADER_REDIRECTS as usize
&& dials <= (MAX_TOTAL_LEADER_REDIRECTS + 2 * MAX_LEADER_REDIRECTS) as usize,
"dials must be bounded by the absolute cap, not the deadline; got {dials}",
);
assert!(
elapsed < Duration::from_secs(5),
"the cap (not the 10s deadline) must terminate the churn; took {elapsed:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_batch_stale_epoch_hint_rides_out_then_surfaces_election() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq_batch(|_req| async {
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 5 }),
}))
})
.spawn()
.await;
let endpoint = format!("http://{addr}");
let pool = ChannelPool::new(vec![endpoint.clone()], None, false, short_policy());
let ready_deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(mut client) = pool.client(&endpoint).await {
let replied = client
.get_seq_batch(tsoracle_proto::v1::GetSeqBatchRequest {
entries: vec![tsoracle_proto::v1::SeqRequestEntry {
key: "orders".into(),
count: 1,
}],
})
.await
.err()
.is_some_and(|s| s.code() == tonic::Code::FailedPrecondition);
if replied {
break;
}
}
assert!(
Instant::now() < ready_deadline,
"fake follower never came up"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
pool.record_success(&endpoint, 10);
match issue_seq_batch_rpc(&pool, &[("orders", 1), ("invoices", 2)]).await {
Err(ClientError::Rpc(status)) => assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"a stale-hint batch ride-out must surface the election FAILED_PRECONDITION",
),
other => panic!("expected a ridden-out FAILED_PRECONDITION, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seq_batch_ride_out_skips_squeezed_attempt_and_surfaces_election() {
let addr = crate::test_support::FakeTso::new()
.on_get_seq_batch(|_req| async {
tokio::time::sleep(Duration::from_millis(300)).await;
Err(make_status_with_hint(tsoracle_proto::v1::LeaderHint {
leader_endpoint: Some("b:1".into()),
leader_epoch: Some(tsoracle_proto::v1::EpochWire { hi: 0, lo: 5 }),
}))
})
.spawn()
.await;
let endpoint = format!("http://{addr}");
let policy = RetryPolicy {
max_attempts: 5,
per_attempt_deadline: Duration::from_millis(500),
overall_deadline: Duration::from_millis(500),
base_backoff: Duration::ZERO,
leader_ttl: Duration::from_secs(30),
};
let pool = ChannelPool::new(vec![endpoint.clone()], None, false, policy);
pool.record_success(&endpoint, 10);
match issue_seq_batch_rpc(&pool, &[("orders", 1), ("invoices", 2)]).await {
Err(ClientError::Rpc(status)) => assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"a budget-squeezed batch ride-out must surface the election \
FAILED_PRECONDITION, not a manufactured SeqUncertain",
),
other => panic!(
"a squeezed batch ride-out attempt must not be issued and time out; \
expected the election FAILED_PRECONDITION, got {other:?}"
),
}
}
}