use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
use crate::{Error, Result, Table};
fn status_of(e: &Error) -> Option<u16> {
#[cfg(feature = "remote")]
{
match e {
Error::Http {
status_code: Some(status),
..
} => Some(status.as_u16()),
_ => None,
}
}
#[cfg(not(feature = "remote"))]
{
let _ = e;
None
}
}
fn is_retryable(e: &Error) -> bool {
matches!(status_of(e), Some(429 | 503))
}
fn is_lost_claim(e: &Error) -> bool {
status_of(e) == Some(421)
}
const POLL_INTERVAL: Duration = Duration::from_secs(5);
const MAX_REISSUES: usize = 3;
const MAX_RETRIES: usize = 8;
const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(100);
const RETRY_BACKOFF_MAX: Duration = Duration::from_secs(5);
async fn backoff(attempt: usize) {
let delay = RETRY_BACKOFF_BASE
.saturating_mul(1u32 << attempt.min(8) as u32)
.min(RETRY_BACKOFF_MAX);
tokio::time::sleep(delay).await;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CheckpointOutcome {
Done,
ReissueFromFlush,
}
enum Attempt<T> {
Ok(T),
ReissueFromFlush,
}
async fn issue<T, F, Fut>(mut call: F) -> Result<Attempt<T>>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>>,
{
let mut retries = 0;
loop {
let e = match call().await {
Ok(value) => return Ok(Attempt::Ok(value)),
Err(e) => e,
};
if is_lost_claim(&e) {
return Ok(Attempt::ReissueFromFlush);
}
if !is_retryable(&e) || retries >= MAX_RETRIES {
return Err(e);
}
backoff(retries).await;
retries += 1;
}
}
pub(crate) async fn checkpoint_lsm(table: &Table) -> Result<()> {
for reissue in 0..=MAX_REISSUES {
match issue(|| table.flush_lsm()).await? {
Attempt::Ok(()) => {}
Attempt::ReissueFromFlush => {
backoff(reissue).await;
continue;
}
}
let stats = match issue(|| table.get_lsm_stats(false)).await? {
Attempt::Ok(stats) => stats,
Attempt::ReissueFromFlush => {
backoff(reissue).await;
continue;
}
};
let Some(stats) = stats else {
return Ok(());
};
let targets: HashMap<String, u64> = stats
.buckets
.iter()
.filter_map(|b| Some((b.shard_id.clone(), b.newest_generation()?)))
.collect();
if targets.is_empty() {
return Ok(());
}
match drain_to_targets(table, &targets).await? {
CheckpointOutcome::Done => return Ok(()),
CheckpointOutcome::ReissueFromFlush => {
backoff(reissue).await;
continue;
}
}
}
Err(Error::Runtime {
message: "checkpoint_lsm: the owning node kept losing its claim; \
re-issued from flush the maximum number of times"
.into(),
})
}
async fn drain_to_targets(
table: &Table,
targets: &HashMap<String, u64>,
) -> Result<CheckpointOutcome> {
loop {
let stats = match issue(|| table.get_lsm_stats(false)).await? {
Attempt::Ok(stats) => stats,
Attempt::ReissueFromFlush => return Ok(CheckpointOutcome::ReissueFromFlush),
};
let Some(stats) = stats else {
return Ok(CheckpointOutcome::Done);
};
let mut outstanding = 0;
let mut all_compacting = true;
for b in &stats.buckets {
let Some(target) = targets.get(&b.shard_id) else {
continue;
};
let n = b.outstanding_generations(*target);
if n > 0 {
outstanding += n;
all_compacting &= b.compacting;
}
}
if outstanding == 0 {
return Ok(CheckpointOutcome::Done);
}
if !all_compacting {
match table.compact_lsm().await {
Ok(()) => {}
Err(e) if is_lost_claim(&e) => return Ok(CheckpointOutcome::ReissueFromFlush),
Err(e) if !is_retryable(&e) => return Err(e),
Err(_) => {}
}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[cfg(all(test, feature = "remote"))]
mod tests {
use super::*;
fn http(status: u16) -> Error {
Error::Http {
source: "server said no".into(),
request_id: "rid".into(),
status_code: reqwest::StatusCode::from_u16(status).ok(),
}
}
#[test]
fn taxonomy_round_trips() {
for status in [429, 503] {
assert!(is_retryable(&http(status)), "{status} must retry");
assert!(
!is_lost_claim(&http(status)),
"{status} is not a lost claim"
);
}
assert!(is_lost_claim(&http(421)), "a lost claim must re-claim");
assert!(
!is_retryable(&http(421)),
"retrying a lost claim in place only asks the same node again"
);
for status in [400, 404, 409, 500] {
assert!(!is_retryable(&http(status)), "{status} is terminal");
assert!(!is_lost_claim(&http(status)), "{status} is terminal");
}
}
#[test]
fn errors_without_a_status_are_terminal() {
let no_status = Error::Http {
source: "connection reset".into(),
request_id: "rid".into(),
status_code: None,
};
assert!(!is_retryable(&no_status));
assert!(!is_lost_claim(&no_status));
let translated = Error::TableNotFound {
name: "t".into(),
source: "gone".into(),
};
assert!(!is_retryable(&translated));
assert!(!is_lost_claim(&translated));
}
}