use std::time::Duration;
use crate::attempt::{AttemptOutcome, HintUnusableReason, attempt};
use crate::budget::Budget;
use crate::channel_pool::ChannelPool;
use crate::error::ClientError;
use crate::response::TimestampRange;
use crate::retry_policy::{is_transport_failure, jittered_backoff, should_backoff};
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),
}
}
#[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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
struct HintlessFollower;
#[tonic::async_trait]
impl TsoService for HintlessFollower {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
Err(tonic::Status::failed_precondition("not leader"))
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(HintlessFollower))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
struct StaleHintingFollower;
#[tonic::async_trait]
impl TsoService for StaleHintingFollower {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
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 }),
}))
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(StaleHintingFollower))
.serve_with_incoming(incoming)
.await
.ok();
});
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;
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
const REDIRECTS: usize = 3;
struct RedirectingLeaderChain {
calls: Arc<AtomicUsize>,
}
#[tonic::async_trait]
impl TsoService for RedirectingLeaderChain {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
let n = self.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(tonic::Response::new(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
}))
}
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(RedirectingLeaderChain {
calls: server_calls,
}))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
const CHURN: usize = MAX_LEADER_REDIRECTS as usize + 3;
struct ChurnsThenServes {
calls: Arc<AtomicUsize>,
}
#[tonic::async_trait]
impl TsoService for ChurnsThenServes {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
let n = self.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(tonic::Response::new(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
}))
}
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(ChurnsThenServes {
calls: server_calls,
}))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
struct AlwaysRedirecting {
calls: Arc<AtomicUsize>,
}
#[tonic::async_trait]
impl TsoService for AlwaysRedirecting {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
let n = self.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,
}))
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(AlwaysRedirecting {
calls: server_calls,
}))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
struct RedirectsExactlyToCap {
calls: Arc<AtomicUsize>,
}
#[tonic::async_trait]
impl TsoService for RedirectsExactlyToCap {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
let n = self.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(tonic::Response::new(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
}))
}
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(RedirectsExactlyToCap {
calls: server_calls,
}))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
const NO_LEADER_REPLIES: usize = 4;
struct ElectingThenServing {
calls: Arc<AtomicUsize>,
}
#[tonic::async_trait]
impl TsoService for ElectingThenServing {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n < NO_LEADER_REPLIES {
Err(tonic::Status::failed_precondition("no leader yet"))
} else {
Ok(tonic::Response::new(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
}))
}
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
let calls = Arc::new(AtomicUsize::new(0));
let server_calls = calls.clone();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(ElectingThenServing {
calls: server_calls,
}))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
struct AlwaysMalformed;
#[tonic::async_trait]
impl TsoService for AlwaysMalformed {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
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)
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(AlwaysMalformed))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
struct AlwaysElecting;
#[tonic::async_trait]
impl TsoService for AlwaysElecting {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
Err(tonic::Status::failed_precondition("no leader yet"))
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(AlwaysElecting))
.serve_with_incoming(incoming)
.await
.ok();
});
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() {
use tsoracle_proto::v1::tso_service_server::{TsoService, TsoServiceServer};
struct LiveLeader;
#[tonic::async_trait]
impl TsoService for LiveLeader {
async fn get_ts(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetTsRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetTsResponse>, tonic::Status>
{
Ok(tonic::Response::new(tsoracle_proto::v1::GetTsResponse {
physical_ms: 1,
logical_start: 0,
count: 1,
epoch_hi: 0,
epoch_lo: 0,
}))
}
async fn get_current_max_safe(
&self,
_request: tonic::Request<tsoracle_proto::v1::GetCurrentMaxSafeRequest>,
) -> Result<tonic::Response<tsoracle_proto::v1::GetCurrentMaxSafeResponse>, tonic::Status>
{
Ok(tonic::Response::new(
tsoracle_proto::v1::GetCurrentMaxSafeResponse::default(),
))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr = listener.local_addr().expect("local_addr");
let incoming = tonic::transport::server::TcpIncoming::from(listener);
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(TsoServiceServer::new(LiveLeader))
.serve_with_incoming(incoming)
.await
.ok();
});
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:?}"),
}
}
}