use std::time::Duration;
use super::error::StateError;
use super::row::Row;
use super::store::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReapAttempt {
pub instance: String,
pub attempts: i64,
pub last_error: String,
pub next_retry_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReapDecision {
Reap,
SkipLocked,
WaitBackoff { until: i64 },
}
const BACKOFF_BASE: Duration = Duration::from_secs(60);
const BACKOFF_CAP: Duration = Duration::from_secs(3600);
pub const TOMBSTONE_GC_WINDOW: Duration = Duration::from_secs(7 * 24 * 3600);
impl TryFrom<&Row> for ReapAttempt {
type Error = StateError;
fn try_from(row: &Row) -> Result<Self, Self::Error> {
Ok(Self {
instance: row.get_string(0)?,
attempts: row.get_i64(1)?,
last_error: row.get_string(2)?,
next_retry_at: row.get_i64(3)?,
})
}
}
impl ReapAttempt {
pub fn backoff_after(attempts: i64) -> Duration {
let shift = attempts.saturating_sub(1).clamp(0, 16) as u32;
let secs = BACKOFF_BASE
.as_secs()
.saturating_mul(1u64.checked_shl(shift).unwrap_or(u64::MAX));
Duration::from_secs(secs.min(BACKOFF_CAP.as_secs()))
}
}
impl ReapDecision {
pub fn decide(now: i64, lock_held: bool, prior: Option<&ReapAttempt>) -> Self {
if lock_held {
return Self::SkipLocked;
}
match prior {
Some(attempt) if attempt.next_retry_at > now => Self::WaitBackoff {
until: attempt.next_retry_at,
},
_ => Self::Reap,
}
}
}
impl Store {
pub fn record_reap_failure(&self, instance: &str, error: &str) -> Result<(), StateError> {
let now = Self::now();
let attempts = self
.reap_attempt(instance)?
.map(|a| a.attempts + 1)
.unwrap_or(1);
let next_retry_at = now + ReapAttempt::backoff_after(attempts).as_secs() as i64;
self.execute(
"INSERT INTO reap_attempts (instance, attempts, last_error, next_retry_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(instance) DO UPDATE SET
attempts = excluded.attempts,
last_error = excluded.last_error,
next_retry_at = excluded.next_retry_at",
&[
instance.into(),
attempts.into(),
error.into(),
next_retry_at.into(),
],
)?;
Ok(())
}
pub fn clear_reap_failure(&self, instance: &str) -> Result<(), StateError> {
self.execute(
"DELETE FROM reap_attempts WHERE instance = ?1",
&[instance.into()],
)?;
Ok(())
}
pub fn reap_attempt(&self, instance: &str) -> Result<Option<ReapAttempt>, StateError> {
self.query_row(
"SELECT instance, attempts, last_error, next_retry_at
FROM reap_attempts WHERE instance = ?1",
&[instance.into()],
|row| ReapAttempt::try_from(row),
)
}
pub fn gc_due_tombstones(&self) -> Result<Vec<String>, StateError> {
let cutoff = Self::now() - TOMBSTONE_GC_WINDOW.as_secs() as i64;
self.query_map(
"SELECT name FROM instances
WHERE status = 'tombstoned' AND tombstoned_at IS NOT NULL
AND tombstoned_at <= ?1 ORDER BY name",
&[cutoff.into()],
|row: &Row| row.get_string(0),
)
}
pub fn delete_instance(&self, instance: &str) -> Result<(), StateError> {
self.execute("DELETE FROM instances WHERE name = ?1", &[instance.into()])?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locked_instance_is_skipped() {
assert_eq!(
ReapDecision::decide(100, true, None),
ReapDecision::SkipLocked
);
}
#[test]
fn unlocked_with_no_prior_is_reaped() {
assert_eq!(ReapDecision::decide(100, false, None), ReapDecision::Reap);
}
#[test]
fn backoff_not_elapsed_waits() {
let prior = ReapAttempt {
instance: "x".into(),
attempts: 1,
last_error: "boom".into(),
next_retry_at: 200,
};
assert_eq!(
ReapDecision::decide(100, false, Some(&prior)),
ReapDecision::WaitBackoff { until: 200 }
);
}
#[test]
fn backoff_elapsed_reaps_again() {
let prior = ReapAttempt {
instance: "x".into(),
attempts: 3,
last_error: "boom".into(),
next_retry_at: 50,
};
assert_eq!(
ReapDecision::decide(100, false, Some(&prior)),
ReapDecision::Reap
);
}
#[test]
fn backoff_doubles_and_caps() {
assert_eq!(ReapAttempt::backoff_after(1), Duration::from_secs(60));
assert_eq!(ReapAttempt::backoff_after(2), Duration::from_secs(120));
assert_eq!(ReapAttempt::backoff_after(3), Duration::from_secs(240));
assert_eq!(ReapAttempt::backoff_after(100), Duration::from_secs(3600));
}
}