use std::collections::BTreeSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::quarantine::{QuarantineKey, QuarantineState};
pub const QUARANTINE_RETRY_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryConfig {
#[serde(default)]
pub max_attempts: Option<usize>,
#[serde(default = "default_eligible_only")]
pub eligible_only: bool,
}
fn default_eligible_only() -> bool {
true
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: None,
eligible_only: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlannedDisposition {
Retry,
SkipIrreducible,
SkipSourceMissing,
SkipBudgetExhausted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedEntry {
pub conversation_id: String,
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cass_version_at_quarantine: Option<String>,
pub disposition: PlannedDisposition,
pub attempt_count: u64,
pub last_reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryPlan {
pub current_version: String,
pub total_quarantined: usize,
pub eligible_total: usize,
pub planned_attempts: usize,
pub skip_irreducible: usize,
pub skip_source_missing: usize,
pub skip_budget_exhausted: usize,
pub entries: Vec<PlannedEntry>,
pub resume_recommended: bool,
pub summary: String,
pub next_safe_command: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttemptResult {
Reindexed,
OutOfMemory,
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetryOutcome {
RetriedCleared,
ReQuarantinedOom,
ReQuarantinedFailed,
SkippedSourceMissing,
SkippedIrreducible,
SkippedBudgetExhausted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryEntryResult {
pub conversation_id: String,
pub schema_version: u32,
pub outcome: RetryOutcome,
pub attempt_count_before: u64,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryReport {
pub current_version: String,
pub total_quarantined_before: usize,
pub attempted: usize,
pub cleared: usize,
pub re_quarantined_oom: usize,
pub re_quarantined_failed: usize,
pub skipped_source_missing: usize,
pub skipped_irreducible: usize,
pub skipped_budget_exhausted: usize,
pub remaining_quarantined: usize,
pub made_progress: bool,
pub stalled: bool,
pub resume_recommended: bool,
pub entries: Vec<RetryEntryResult>,
pub summary: String,
pub next_safe_command: String,
}
fn entry_is_attemptable(record_is_stale: bool, eligible_only: bool) -> bool {
!eligible_only || record_is_stale
}
fn budget_reached(planned_so_far: usize, max_attempts: Option<usize>) -> bool {
matches!(max_attempts, Some(max) if planned_so_far >= max)
}
#[must_use]
pub fn plan_retry(
state: &QuarantineState,
current_version: &str,
config: &RetryConfig,
source_missing_ids: &BTreeSet<String>,
) -> RetryPlan {
let mut entries = Vec::new();
let mut planned_attempts = 0usize;
let mut skip_irreducible = 0usize;
let mut skip_source_missing = 0usize;
let mut skip_budget_exhausted = 0usize;
let mut eligible_total = 0usize;
let mut total = 0usize;
for (key, record) in state.iter() {
total += 1;
let is_stale = record.is_version_stale_for_retry(current_version);
if is_stale {
eligible_total += 1;
}
let source_missing = source_missing_ids.contains(&key.conversation_id);
let disposition = if source_missing {
skip_source_missing += 1;
PlannedDisposition::SkipSourceMissing
} else if !entry_is_attemptable(is_stale, config.eligible_only) {
skip_irreducible += 1;
PlannedDisposition::SkipIrreducible
} else if budget_reached(planned_attempts, config.max_attempts) {
skip_budget_exhausted += 1;
PlannedDisposition::SkipBudgetExhausted
} else {
planned_attempts += 1;
PlannedDisposition::Retry
};
entries.push(PlannedEntry {
conversation_id: key.conversation_id.clone(),
schema_version: key.schema_version,
cass_version_at_quarantine: record.cass_version_at_quarantine.clone(),
disposition,
attempt_count: record.attempt_count,
last_reason: record.last_reason.clone(),
});
}
let resume_recommended = skip_budget_exhausted > 0;
let summary = plan_summary(
total,
planned_attempts,
skip_irreducible,
skip_source_missing,
skip_budget_exhausted,
);
let next_safe_command = plan_next_command(total, planned_attempts, skip_irreducible);
RetryPlan {
current_version: current_version.to_string(),
total_quarantined: total,
eligible_total,
planned_attempts,
skip_irreducible,
skip_source_missing,
skip_budget_exhausted,
entries,
resume_recommended,
summary,
next_safe_command,
}
}
pub fn execute_retry<F>(
state: &mut QuarantineState,
current_version: &str,
config: &RetryConfig,
source_missing_ids: &BTreeSet<String>,
now: DateTime<Utc>,
mut attempt: F,
) -> RetryReport
where
F: FnMut(&QuarantineKey) -> AttemptResult,
{
let plan = plan_retry(state, current_version, config, source_missing_ids);
let total_before = plan.total_quarantined;
let mut entries = Vec::with_capacity(plan.entries.len());
let mut attempted = 0usize;
let mut cleared = 0usize;
let mut re_quarantined_oom = 0usize;
let mut re_quarantined_failed = 0usize;
for planned in &plan.entries {
let key = QuarantineKey::new(planned.conversation_id.clone(), planned.schema_version);
let (outcome, reason) = match planned.disposition {
PlannedDisposition::SkipSourceMissing => (
RetryOutcome::SkippedSourceMissing,
planned.last_reason.clone(),
),
PlannedDisposition::SkipIrreducible => (
RetryOutcome::SkippedIrreducible,
planned.last_reason.clone(),
),
PlannedDisposition::SkipBudgetExhausted => (
RetryOutcome::SkippedBudgetExhausted,
planned.last_reason.clone(),
),
PlannedDisposition::Retry => {
attempted += 1;
match attempt(&key) {
AttemptResult::Reindexed => {
state.clear(&key);
cleared += 1;
(RetryOutcome::RetriedCleared, planned.last_reason.clone())
}
AttemptResult::OutOfMemory => {
state.record_attempt(&key, "ingest_oom", now);
re_quarantined_oom += 1;
(RetryOutcome::ReQuarantinedOom, "ingest_oom".to_string())
}
AttemptResult::Failed(failure_reason) => {
state.record_attempt(&key, failure_reason.clone(), now);
re_quarantined_failed += 1;
(RetryOutcome::ReQuarantinedFailed, failure_reason)
}
}
}
};
entries.push(RetryEntryResult {
conversation_id: planned.conversation_id.clone(),
schema_version: planned.schema_version,
outcome,
attempt_count_before: planned.attempt_count,
reason,
});
}
let remaining_quarantined = state.len();
let made_progress = cleared > 0;
let stalled = attempted > 0 && cleared == 0;
let resume_recommended = plan.skip_budget_exhausted > 0;
let summary = exec_summary(
attempted,
cleared,
re_quarantined_oom,
re_quarantined_failed,
remaining_quarantined,
);
let next_safe_command = exec_next_command(remaining_quarantined, stalled, resume_recommended);
RetryReport {
current_version: current_version.to_string(),
total_quarantined_before: total_before,
attempted,
cleared,
re_quarantined_oom,
re_quarantined_failed,
skipped_source_missing: plan.skip_source_missing,
skipped_irreducible: plan.skip_irreducible,
skipped_budget_exhausted: plan.skip_budget_exhausted,
remaining_quarantined,
made_progress,
stalled,
resume_recommended,
entries,
summary,
next_safe_command,
}
}
fn plan_summary(
total: usize,
planned_attempts: usize,
skip_irreducible: usize,
skip_source_missing: usize,
skip_budget_exhausted: usize,
) -> String {
if total == 0 {
return "no quarantined conversations to retry".to_string();
}
format!(
"{planned_attempts} of {total} entries planned for retry; \
{skip_irreducible} irreducible, {skip_source_missing} source-missing, \
{skip_budget_exhausted} deferred by budget"
)
}
fn plan_next_command(total: usize, planned_attempts: usize, skip_irreducible: usize) -> String {
if total == 0 {
"cass status --json".to_string()
} else if planned_attempts > 0 {
"cass index".to_string()
} else if skip_irreducible > 0 {
"cass diag --json --quarantine".to_string()
} else {
"cass diag --json --quarantine".to_string()
}
}
fn exec_summary(
attempted: usize,
cleared: usize,
re_quarantined_oom: usize,
re_quarantined_failed: usize,
remaining: usize,
) -> String {
format!(
"attempted {attempted}, cleared {cleared}, re-quarantined \
{re_quarantined_oom} (oom) + {re_quarantined_failed} (other); \
{remaining} still quarantined"
)
}
fn exec_next_command(remaining: usize, stalled: bool, resume_recommended: bool) -> String {
if remaining == 0 {
"cass status --json".to_string()
} else if stalled {
"cass diag --json --quarantine".to_string()
} else if resume_recommended {
"cass index".to_string()
} else {
"cass diag --json --quarantine".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indexer::quarantine::QuarantineRecord;
use chrono::DateTime;
const CURRENT: &str = env!("CARGO_PKG_VERSION");
fn ts(secs: i64) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp")
}
fn record(version: Option<&str>, reason: &str, attempts: u64) -> QuarantineRecord {
QuarantineRecord {
first_attempt_at: ts(1_700_000_000),
last_attempt_at: ts(1_700_000_500),
attempt_count: attempts,
last_reason: reason.to_string(),
cass_version_at_quarantine: version.map(str::to_string),
}
}
fn insert(state: &mut QuarantineState, conv: &str, schema: u32, rec: QuarantineRecord) {
state.entries.insert(format!("{conv}::v{schema}"), rec);
}
fn mixed_state() -> QuarantineState {
let mut s = QuarantineState::default();
insert(
&mut s,
"c-same-1",
3,
record(Some(CURRENT), "ingest_oom", 4),
);
insert(
&mut s,
"c-same-2",
3,
record(Some(CURRENT), "ingest_oom", 7),
);
insert(&mut s, "c-legacy", 1, record(None, "ingest_oom", 1));
insert(
&mut s,
"c-stale",
2,
record(Some("0.5.1"), "validation_failed", 2),
);
s
}
fn no_missing() -> BTreeSet<String> {
BTreeSet::new()
}
#[test]
fn plan_classifies_eligible_irreducible_and_source_missing() {
let plan = plan_retry(
&mixed_state(),
CURRENT,
&RetryConfig::default(),
&no_missing(),
);
assert_eq!(plan.total_quarantined, 4);
assert_eq!(plan.eligible_total, 2);
assert_eq!(plan.planned_attempts, 2);
assert_eq!(plan.skip_irreducible, 2);
assert_eq!(plan.skip_source_missing, 0);
assert_eq!(plan.skip_budget_exhausted, 0);
assert!(!plan.resume_recommended);
}
#[test]
fn plan_skips_source_missing_before_eligibility() {
let mut missing = BTreeSet::new();
missing.insert("c-legacy".to_string());
let plan = plan_retry(&mixed_state(), CURRENT, &RetryConfig::default(), &missing);
assert_eq!(plan.skip_source_missing, 1);
assert_eq!(plan.planned_attempts, 1);
let legacy = plan
.entries
.iter()
.find(|e| e.conversation_id == "c-legacy")
.expect("legacy entry present");
assert_eq!(legacy.disposition, PlannedDisposition::SkipSourceMissing);
}
#[test]
fn plan_budget_defers_eligible_entries_and_recommends_resume() {
let config = RetryConfig {
max_attempts: Some(1),
eligible_only: true,
};
let plan = plan_retry(&mixed_state(), CURRENT, &config, &no_missing());
assert_eq!(plan.planned_attempts, 1);
assert_eq!(plan.skip_budget_exhausted, 1);
assert!(plan.resume_recommended);
}
#[test]
fn plan_force_override_attempts_irreducible_entries() {
let config = RetryConfig {
max_attempts: None,
eligible_only: false,
};
let plan = plan_retry(&mixed_state(), CURRENT, &config, &no_missing());
assert_eq!(plan.planned_attempts, 4);
assert_eq!(plan.skip_irreducible, 0);
}
#[test]
fn plan_empty_state_recommends_status() {
let plan = plan_retry(
&QuarantineState::default(),
CURRENT,
&RetryConfig::default(),
&no_missing(),
);
assert_eq!(plan.total_quarantined, 0);
assert_eq!(plan.next_safe_command, "cass status --json");
assert!(plan.summary.contains("no quarantined"));
}
#[test]
fn plan_all_irreducible_recommends_inspection_not_retry() {
let mut s = QuarantineState::default();
for i in 0..133 {
insert(
&mut s,
&format!("c{i}"),
3,
record(Some(CURRENT), "ingest_oom", 5),
);
}
let plan = plan_retry(&s, CURRENT, &RetryConfig::default(), &no_missing());
assert_eq!(plan.planned_attempts, 0);
assert_eq!(plan.skip_irreducible, 133);
assert_eq!(plan.next_safe_command, "cass diag --json --quarantine");
}
#[test]
fn plan_entries_are_in_deterministic_storage_key_order() {
let plan = plan_retry(
&mixed_state(),
CURRENT,
&RetryConfig::default(),
&no_missing(),
);
let ids: Vec<&str> = plan
.entries
.iter()
.map(|e| e.conversation_id.as_str())
.collect();
assert_eq!(ids, vec!["c-legacy", "c-same-1", "c-same-2", "c-stale"]);
}
#[test]
fn execute_clears_eligible_entry_on_success() {
let mut state = mixed_state();
let report = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&no_missing(),
ts(1_800_000_000),
|_key| AttemptResult::Reindexed,
);
assert_eq!(report.attempted, 2);
assert_eq!(report.cleared, 2);
assert!(report.made_progress);
assert!(!report.stalled);
assert_eq!(report.remaining_quarantined, 2);
assert!(!state.entries.contains_key("c-legacy::v1"));
assert!(!state.entries.contains_key("c-stale::v2"));
assert!(state.entries.contains_key("c-same-1::v3"));
}
#[test]
fn execute_suppresses_irreducible_same_version() {
let mut state = mixed_state();
let mut attempted_ids: Vec<String> = Vec::new();
let report = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&no_missing(),
ts(1_800_000_000),
|key| {
attempted_ids.push(key.conversation_id.clone());
AttemptResult::Reindexed
},
);
assert!(!attempted_ids.iter().any(|c| c == "c-same-1"));
assert!(!attempted_ids.iter().any(|c| c == "c-same-2"));
assert_eq!(report.skipped_irreducible, 2);
}
#[test]
fn execute_skips_source_missing_without_attempting() {
let mut state = mixed_state();
let mut missing = BTreeSet::new();
missing.insert("c-legacy".to_string());
let mut attempted_ids: Vec<String> = Vec::new();
let report = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&missing,
ts(1_800_000_000),
|key| {
attempted_ids.push(key.conversation_id.clone());
AttemptResult::Reindexed
},
);
assert!(!attempted_ids.iter().any(|c| c == "c-legacy"));
assert_eq!(report.skipped_source_missing, 1);
assert!(state.entries.contains_key("c-legacy::v1"));
}
#[test]
fn execute_re_quarantines_on_repeat_oom() {
let mut state = QuarantineState::default();
insert(
&mut state,
"c-oom",
2,
record(Some("0.5.1"), "ingest_oom", 3),
);
let report = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&no_missing(),
ts(1_800_000_000),
|_key| AttemptResult::OutOfMemory,
);
assert_eq!(report.attempted, 1);
assert_eq!(report.cleared, 0);
assert_eq!(report.re_quarantined_oom, 1);
assert!(report.stalled, "attempted-but-nothing-cleared is a stall");
assert!(!report.made_progress);
assert_eq!(report.remaining_quarantined, 1);
let rec = state.entries.get("c-oom::v2").expect("entry still present");
assert_eq!(rec.cass_version_at_quarantine.as_deref(), Some(CURRENT));
assert_eq!(rec.attempt_count, 4);
assert!(!rec.is_version_stale_for_retry(CURRENT));
assert_eq!(report.next_safe_command, "cass diag --json --quarantine");
let mut pass2_attempts: Vec<String> = Vec::new();
let report2 = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&no_missing(),
ts(1_800_000_100),
|key| {
pass2_attempts.push(key.conversation_id.clone());
AttemptResult::Reindexed
},
);
assert!(
pass2_attempts.is_empty(),
"must not attempt an irreducible same-version entry"
);
assert_eq!(report2.attempted, 0);
assert_eq!(report2.skipped_irreducible, 1);
}
#[test]
fn execute_resume_drains_backlog_without_double_attempt() {
let mut state = QuarantineState::default();
insert(&mut state, "c-a", 1, record(None, "ingest_oom", 1));
insert(&mut state, "c-b", 1, record(None, "ingest_oom", 1));
let config = RetryConfig {
max_attempts: Some(1),
eligible_only: true,
};
let mut all_attempts: Vec<String> = Vec::new();
let r1 = execute_retry(
&mut state,
CURRENT,
&config,
&no_missing(),
ts(1_800_000_000),
|key| {
all_attempts.push(key.conversation_id.clone());
AttemptResult::Reindexed
},
);
assert_eq!(r1.attempted, 1);
assert_eq!(r1.cleared, 1);
assert_eq!(r1.skipped_budget_exhausted, 1);
assert!(r1.resume_recommended);
assert_eq!(r1.next_safe_command, "cass index");
assert_eq!(state.len(), 1, "one eligible entry remains for the resume");
let r2 = execute_retry(
&mut state,
CURRENT,
&config,
&no_missing(),
ts(1_800_000_100),
|key| {
all_attempts.push(key.conversation_id.clone());
AttemptResult::Reindexed
},
);
assert_eq!(r2.attempted, 1);
assert_eq!(r2.cleared, 1);
assert_eq!(r2.skipped_budget_exhausted, 0);
assert!(!r2.resume_recommended);
assert_eq!(state.len(), 0, "backlog fully drained across two passes");
assert_eq!(all_attempts, vec!["c-a".to_string(), "c-b".to_string()]);
assert_eq!(r2.next_safe_command, "cass status --json");
}
#[test]
fn execute_failed_attempt_re_quarantines_with_reason() {
let mut state = QuarantineState::default();
insert(&mut state, "c-fail", 2, record(None, "ingest_oom", 1));
let report = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&no_missing(),
ts(1_800_000_000),
|_key| AttemptResult::Failed("schema_decode_error".to_string()),
);
assert_eq!(report.re_quarantined_failed, 1);
assert!(report.stalled);
let entry = report
.entries
.iter()
.find(|e| e.conversation_id == "c-fail")
.expect("entry present");
assert_eq!(entry.outcome, RetryOutcome::ReQuarantinedFailed);
assert_eq!(entry.reason, "schema_decode_error");
assert_eq!(entry.attempt_count_before, 1);
}
#[test]
fn execute_mixed_outcomes_count_correctly() {
let mut state = QuarantineState::default();
insert(&mut state, "c-a", 1, record(None, "ingest_oom", 1));
insert(&mut state, "c-b", 1, record(Some("0.5.1"), "ingest_oom", 2));
insert(
&mut state,
"c-same",
1,
record(Some(CURRENT), "ingest_oom", 9),
);
let mut attempts: Vec<String> = Vec::new();
let report = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&no_missing(),
ts(1_800_000_000),
|key| {
attempts.push(key.conversation_id.clone());
match key.conversation_id.as_str() {
"c-b" => AttemptResult::OutOfMemory,
_ => AttemptResult::Reindexed,
}
},
);
attempts.sort();
assert_eq!(
attempts,
vec!["c-a".to_string(), "c-b".to_string()],
"only the two eligible entries are attempted; c-same is suppressed"
);
assert_eq!(report.attempted, 2);
assert_eq!(report.cleared, 1);
assert_eq!(report.re_quarantined_oom, 1);
assert_eq!(report.skipped_irreducible, 1);
assert!(report.made_progress, "one cleared => progress, not stalled");
assert!(!report.stalled);
assert_eq!(report.remaining_quarantined, 2);
}
#[test]
fn enums_serialize_snake_case() {
assert_eq!(
serde_json::to_string(&PlannedDisposition::SkipBudgetExhausted).unwrap(),
"\"skip_budget_exhausted\""
);
assert_eq!(
serde_json::to_string(&RetryOutcome::ReQuarantinedOom).unwrap(),
"\"re_quarantined_oom\""
);
}
#[test]
fn plan_round_trips_through_json() {
let plan = plan_retry(
&mixed_state(),
CURRENT,
&RetryConfig::default(),
&no_missing(),
);
let json = serde_json::to_string(&plan).unwrap();
let parsed: RetryPlan = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, plan);
}
#[test]
fn report_round_trips_through_json() {
let mut state = mixed_state();
let report = execute_retry(
&mut state,
CURRENT,
&RetryConfig::default(),
&no_missing(),
ts(1_800_000_000),
|_key| AttemptResult::Reindexed,
);
let json = serde_json::to_string(&report).unwrap();
let parsed: RetryReport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, report);
}
#[test]
fn config_deserializes_with_defaults() {
let config: RetryConfig = serde_json::from_str("{}").unwrap();
assert_eq!(config, RetryConfig::default());
assert!(config.eligible_only);
assert_eq!(config.max_attempts, None);
}
#[test]
fn next_commands_are_never_destructive() {
let mut commands: Vec<String> = Vec::new();
commands.push(plan_next_command(0, 0, 0));
commands.push(plan_next_command(4, 2, 2));
commands.push(plan_next_command(4, 1, 0));
commands.push(plan_next_command(3, 0, 3));
commands.push(exec_next_command(0, false, false));
commands.push(exec_next_command(2, true, false));
commands.push(exec_next_command(2, false, true));
commands.push(exec_next_command(2, false, false));
for cmd in &commands {
assert!(cmd.starts_with("cass "), "must be a cass command: {cmd}");
for bad in [
"rm ",
"--force-clean",
"--purge",
"delete ",
"DROP ",
"--delete",
">",
] {
assert!(!cmd.contains(bad), "command must stay safe: {cmd}");
}
}
}
}