use std::time::Duration;
use serde::Serialize;
use super::compile::CompiledRendering;
use crate::backend::BackendKind;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Leg {
pub backend: BackendKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
pub rendering: CompiledRendering,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline: Option<Duration>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub consistency_fence: String,
pub label: String,
}
impl Leg {
pub fn new(label: impl Into<String>, rendering: CompiledRendering) -> Self {
Self {
backend: rendering.backend(),
instance: None,
rendering,
deadline: None,
consistency_fence: String::new(),
label: label.into(),
}
}
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.instance = Some(instance.into());
self
}
pub fn with_deadline(mut self, deadline: Duration) -> Self {
self.deadline = Some(deadline);
self
}
pub fn with_fence(mut self, fence: impl Into<String>) -> Self {
self.consistency_fence = fence.into();
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PartialFailurePolicy {
FailFast,
Continue,
Quorum {
required: usize,
},
}
impl Default for PartialFailurePolicy {
fn default() -> Self {
Self::FailFast
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ExecutionPlan {
pub legs: Vec<Leg>,
#[serde(default)]
pub policy: PartialFailurePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overall_deadline: Option<Duration>,
}
impl ExecutionPlan {
pub fn single(leg: Leg) -> Self {
Self {
legs: vec![leg],
policy: PartialFailurePolicy::FailFast,
overall_deadline: None,
}
}
pub fn fanout(legs: Vec<Leg>) -> Self {
Self {
legs,
policy: PartialFailurePolicy::Continue,
overall_deadline: None,
}
}
pub fn with_policy(mut self, policy: PartialFailurePolicy) -> Self {
self.policy = policy;
self
}
pub fn with_overall_deadline(mut self, deadline: Duration) -> Self {
self.overall_deadline = Some(deadline);
self
}
}
#[derive(Debug, Clone)]
pub struct LegResult<T> {
pub label: String,
pub backend: BackendKind,
pub instance: Option<String>,
pub outcome: Result<T, LegError>,
}
#[derive(Debug, Clone)]
pub enum LegError {
Executor(String),
DeadlineExceeded,
Cancelled,
}
impl std::fmt::Display for LegError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Executor(m) => write!(f, "executor error: {m}"),
Self::DeadlineExceeded => write!(f, "leg deadline exceeded"),
Self::Cancelled => write!(f, "leg cancelled"),
}
}
}
impl std::error::Error for LegError {}
#[derive(Debug, Clone)]
pub struct PlanVerdict<T> {
pub legs: Vec<LegResult<T>>,
pub satisfied: bool,
}
pub async fn execute_plan<T, F, Fut>(plan: &ExecutionPlan, run: F) -> PlanVerdict<T>
where
T: Send + 'static,
F: Fn(Leg) -> Fut + Send + Sync,
Fut: std::future::Future<Output = Result<T, String>> + Send,
{
let mut handles = Vec::with_capacity(plan.legs.len());
for leg in &plan.legs {
let owned_leg = leg.clone();
let leg_label = leg.label.clone();
let leg_backend = leg.backend.clone();
let leg_instance = leg.instance.clone();
let leg_deadline = leg.deadline;
let plan_deadline = plan.overall_deadline;
let fut = run(owned_leg);
handles.push(async move {
let timeout = match (leg_deadline, plan_deadline) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(d), None) | (None, Some(d)) => Some(d),
(None, None) => None,
};
let outcome = match timeout {
Some(t) => match tokio::time::timeout(t, fut).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(LegError::Executor(e)),
Err(_) => Err(LegError::DeadlineExceeded),
},
None => match fut.await {
Ok(v) => Ok(v),
Err(e) => Err(LegError::Executor(e)),
},
};
LegResult {
label: leg_label,
backend: leg_backend,
instance: leg_instance,
outcome,
}
});
}
let mut results: Vec<LegResult<T>> = futures::future::join_all(handles).await;
results.sort_by_key(|r| {
plan.legs
.iter()
.position(|l| l.label == r.label)
.unwrap_or(usize::MAX)
});
let ok_count = results.iter().filter(|r| r.outcome.is_ok()).count();
let satisfied = match plan.policy {
PartialFailurePolicy::FailFast => ok_count == results.len(),
PartialFailurePolicy::Continue => true,
PartialFailurePolicy::Quorum { required } => ok_count >= required,
};
PlanVerdict {
legs: results,
satisfied,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::BackendKind;
use crate::ir::compile::CompiledRendering;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn fake_sql(label: &str) -> Leg {
Leg::new(
label,
CompiledRendering::Sql {
backend: BackendKind::Postgres,
statement: "SELECT 1".into(),
params: vec![],
},
)
}
#[tokio::test]
async fn fanout_returns_one_result_per_leg() {
let plan = ExecutionPlan::fanout(vec![fake_sql("a"), fake_sql("b"), fake_sql("c")]);
let verdict = execute_plan(
&plan,
|leg| async move { Ok::<_, String>(leg.label.clone()) },
)
.await;
assert!(verdict.satisfied);
assert_eq!(verdict.legs.len(), 3);
let labels: Vec<_> = verdict.legs.iter().map(|r| r.label.as_str()).collect();
assert_eq!(labels, vec!["a", "b", "c"]);
}
#[tokio::test]
async fn fail_fast_marks_plan_unsatisfied_on_one_leg_failure() {
let plan = ExecutionPlan::single(fake_sql("only"));
let verdict = execute_plan(
&plan,
|_leg| async move { Err::<String, _>("boom".to_string()) },
)
.await;
assert!(!verdict.satisfied);
assert!(matches!(
verdict.legs[0].outcome,
Err(LegError::Executor(_))
));
}
#[tokio::test]
async fn quorum_satisfied_when_enough_legs_succeed() {
let plan = ExecutionPlan::fanout(vec![fake_sql("a"), fake_sql("b"), fake_sql("c")])
.with_policy(PartialFailurePolicy::Quorum { required: 2 });
let counter = Arc::new(AtomicUsize::new(0));
let verdict = execute_plan(&plan, |_leg| {
let c = counter.clone();
async move {
let n = c.fetch_add(1, Ordering::SeqCst);
if n < 2 {
Ok::<_, String>(n as i32)
} else {
Err("boom".into())
}
}
})
.await;
assert!(verdict.satisfied);
}
#[tokio::test]
async fn leg_deadline_triggers_deadline_exceeded() {
let plan = ExecutionPlan::single(fake_sql("slow").with_deadline(Duration::from_millis(10)));
let verdict = execute_plan(&plan, |_leg| async move {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, String>(42)
})
.await;
assert!(!verdict.satisfied);
assert!(matches!(
verdict.legs[0].outcome,
Err(LegError::DeadlineExceeded)
));
}
#[tokio::test]
async fn results_are_in_declaration_order() {
let plan = ExecutionPlan::fanout(vec![
fake_sql("a").with_deadline(Duration::from_millis(20)),
fake_sql("b").with_deadline(Duration::from_millis(5)),
fake_sql("c").with_deadline(Duration::from_millis(15)),
]);
let verdict = execute_plan(&plan, |leg| {
let lbl = leg.label.clone();
async move {
let ms = match lbl.as_str() {
"a" => 10,
"b" => 1,
"c" => 5,
_ => 0,
};
tokio::time::sleep(Duration::from_millis(ms)).await;
Ok::<_, String>(lbl)
}
})
.await;
let labels: Vec<_> = verdict.legs.iter().map(|r| r.label.as_str()).collect();
assert_eq!(labels, vec!["a", "b", "c"]);
}
}