use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tracing::instrument;
use zeph_durable::DurableError;
use zeph_durable::{
DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind, Journal as _,
JournalWriterHandle, StepDescriptor,
};
use super::graph::{GraphId, TaskId};
use super::lineage::{ErrorLineage, LineageEntry, LineageKind};
const BUDGET_DOMAIN: &[u8] = b"zeph.orch.budget.v1";
const BUDGET_STEP_NAME: &str = "replan_budget";
const BUDGET_STEP_FP: &[u8] = b"orch:replan_budget:v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineageEntryDto {
pub task_id: TaskId,
pub error_class: String,
pub ts_ms: u64,
}
impl From<&LineageEntry> for LineageEntryDto {
fn from(e: &LineageEntry) -> Self {
let error_class = match &e.kind {
LineageKind::Failed { error_class } => error_class.clone(),
};
Self {
task_id: e.task_id,
error_class,
ts_ms: e.ts_ms,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ErrorLineageDto {
pub entries: Vec<LineageEntryDto>,
}
impl From<&ErrorLineage> for ErrorLineageDto {
fn from(src: &ErrorLineage) -> Self {
Self {
entries: src.entries().iter().map(LineageEntryDto::from).collect(),
}
}
}
impl ErrorLineage {
#[must_use]
pub fn from_dto(dto: ErrorLineageDto) -> Self {
let mut chain = ErrorLineage::default();
for d in dto.entries {
chain.push(LineageEntry {
task_id: d.task_id,
kind: LineageKind::Failed {
error_class: d.error_class,
},
ts_ms: d.ts_ms,
});
}
chain
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReplanBudgetSnapshot {
pub task_replan_counts: std::collections::HashMap<TaskId, u32>,
pub global_replan_count: u32,
pub predicate_replans_used: u32,
pub predicate_reasons: std::collections::HashMap<TaskId, String>,
pub lineage_chains: std::collections::HashMap<TaskId, ErrorLineageDto>,
}
fn budget_exec_id(graph_id: &GraphId, generation: u32) -> ExecutionId {
let mut payload = graph_id.to_string().into_bytes();
payload.push(b'\0');
payload.extend_from_slice(&generation.to_le_bytes());
ExecutionId::derive(BUDGET_DOMAIN, &payload)
}
fn build_budget_ctx(
exec_id: ExecutionId,
is_resume: bool,
backend: Arc<DurableBackendEnum>,
writer: JournalWriterHandle,
config: &DurableConfig,
) -> DurableContext {
DurableContext::new(
exec_id,
ExecutionKind::DagRun,
is_resume,
backend,
writer,
config,
)
}
#[instrument(
level = "info",
name = "orch.durable.budget_journal",
skip(backend, writer, config, snapshot),
fields(graph_id = %graph_id, generation)
)]
pub async fn journal_budget(
graph_id: &GraphId,
generation: u32,
backend: Arc<DurableBackendEnum>,
writer: JournalWriterHandle,
config: &DurableConfig,
snapshot: ReplanBudgetSnapshot,
) -> Result<(), DurableError> {
tracing::Span::current().record("generation", generation);
let exec_id = budget_exec_id(graph_id, generation);
let DurableBackendEnum::Local(local_backend) = &*backend else {
return Err(DurableError::Storage {
op: "open",
source: "journal_budget: only Local backend is supported"
.to_string()
.into(),
});
};
let local_backend = local_backend.clone();
local_backend
.open_execution(exec_id, ExecutionKind::DagRun)
.await?;
let ctx = build_budget_ctx(exec_id, false, backend, writer, config);
let desc = StepDescriptor::idempotent(BUDGET_STEP_NAME, BUDGET_STEP_FP.to_vec());
ctx.step(desc, move |_handle| async move {
Ok::<ReplanBudgetSnapshot, zeph_durable::step::StepError>(snapshot)
})
.await?;
Ok(())
}
#[instrument(
level = "info",
name = "orch.durable.budget_restore",
skip(backend),
fields(graph_id = %graph_id, generation)
)]
pub async fn restore_budget(
graph_id: &GraphId,
generation: u32,
backend: Arc<DurableBackendEnum>,
) -> Result<Option<ReplanBudgetSnapshot>, DurableError> {
tracing::Span::current().record("generation", generation);
for gn in (0..=generation).rev() {
let exec_id = budget_exec_id(graph_id, gn);
let entries = backend.read_execution(exec_id).await?;
if let Some(snapshot) = find_budget_snapshot(entries) {
return Ok(Some(snapshot));
}
}
Ok(None)
}
fn find_budget_snapshot(entries: Vec<zeph_durable::JournalEntry>) -> Option<ReplanBudgetSnapshot> {
use zeph_durable::journal::EntryKind;
for entry in entries {
if let EntryKind::StepResult { payload, .. } = &entry.entry
&& let Ok(snapshot) = serde_json::from_slice::<ReplanBudgetSnapshot>(payload.as_ref())
{
return Some(snapshot);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::GraphId;
#[test]
fn snapshot_serde_round_trip() {
let mut snap = ReplanBudgetSnapshot {
global_replan_count: 3,
predicate_replans_used: 1,
..Default::default()
};
snap.task_replan_counts.insert(TaskId(0), 2);
snap.predicate_reasons
.insert(TaskId(1), "too slow".to_string());
snap.lineage_chains.insert(
TaskId(2),
ErrorLineageDto {
entries: vec![LineageEntryDto {
task_id: TaskId(2),
error_class: "timeout".to_string(),
ts_ms: 1_000_000,
}],
},
);
let json = serde_json::to_string(&snap).unwrap();
let decoded: ReplanBudgetSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.global_replan_count, 3);
assert_eq!(decoded.predicate_replans_used, 1);
assert_eq!(*decoded.task_replan_counts.get(&TaskId(0)).unwrap(), 2);
assert_eq!(
decoded.predicate_reasons.get(&TaskId(1)).unwrap(),
"too slow"
);
let chain = decoded.lineage_chains.get(&TaskId(2)).unwrap();
assert_eq!(chain.entries.len(), 1);
assert_eq!(chain.entries[0].error_class, "timeout");
}
#[test]
fn error_lineage_dto_round_trip() {
let mut lineage = ErrorLineage::default();
lineage.push(LineageEntry {
task_id: TaskId(5),
kind: LineageKind::Failed {
error_class: "llm_error".to_string(),
},
ts_ms: 42_000,
});
let dto = ErrorLineageDto::from(&lineage);
assert_eq!(dto.entries.len(), 1);
assert_eq!(dto.entries[0].task_id, TaskId(5));
assert_eq!(dto.entries[0].error_class, "llm_error");
let restored = ErrorLineage::from_dto(dto);
assert_eq!(restored.entries().len(), 1);
assert_eq!(restored.entries()[0].task_id, TaskId(5));
}
#[tokio::test]
async fn generation_counter_produces_distinct_exec_ids() {
let graph_id = GraphId::new();
let id0 = budget_exec_id(&graph_id, 0);
let id1 = budget_exec_id(&graph_id, 1);
assert_ne!(
id0, id1,
"different generations must have different execution ids"
);
}
#[cfg(feature = "sqlite")]
mod with_backend {
use zeph_durable::{DurableBackendEnum, DurableConfig, JournalWriter, LocalBackend};
use super::*;
fn test_config() -> DurableConfig {
DurableConfig {
enabled: true,
encrypt_payload: false,
journal_flush_interval_ms: 1,
journal_ack_timeout_ms: 1000,
..DurableConfig::default()
}
}
async fn make_backend() -> (Arc<DurableBackendEnum>, JournalWriterHandle) {
let local = LocalBackend::open(":memory:", 1_048_576).await.unwrap();
local.init().await.unwrap();
let local = Arc::new(local);
let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
let (writer, handle) = JournalWriter::new(local, &test_config());
tokio::spawn(async move { writer.run().await }); (backend, handle)
}
#[tokio::test]
async fn journal_then_restore_returns_snapshot() {
let (backend, writer) = make_backend().await;
let config = test_config();
let graph_id = GraphId::new();
let generation: u32 = 0;
let snap = ReplanBudgetSnapshot {
global_replan_count: 7,
task_replan_counts: [(TaskId(0), 3)].into(),
..Default::default()
};
journal_budget(
&graph_id,
generation,
backend.clone(),
writer.clone(),
&config,
snap.clone(),
)
.await
.unwrap();
writer.flush().await.unwrap();
let restored = restore_budget(&graph_id, generation, backend)
.await
.unwrap();
let restored = restored.expect("snapshot should be present");
assert_eq!(restored.global_replan_count, 7);
assert_eq!(*restored.task_replan_counts.get(&TaskId(0)).unwrap(), 3);
}
#[tokio::test]
async fn restore_returns_none_when_no_snapshot() {
let (backend, _writer) = make_backend().await;
let graph_id = GraphId::new();
let result = restore_budget(&graph_id, 0, backend).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn restore_picks_latest_generation() {
let (backend, writer) = make_backend().await;
let config = test_config();
let graph_id = GraphId::new();
let snap0 = ReplanBudgetSnapshot {
global_replan_count: 1,
..Default::default()
};
journal_budget(
&graph_id,
0,
backend.clone(),
writer.clone(),
&config,
snap0,
)
.await
.unwrap();
let snap1 = ReplanBudgetSnapshot {
global_replan_count: 2,
..Default::default()
};
journal_budget(
&graph_id,
1,
backend.clone(),
writer.clone(),
&config,
snap1,
)
.await
.unwrap();
writer.flush().await.unwrap();
let restored = restore_budget(&graph_id, 1, backend)
.await
.unwrap()
.expect("snapshot must be present");
assert_eq!(
restored.global_replan_count, 2,
"restore should pick the latest generation"
);
}
}
}