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();
let _lock = match local_backend
.open_execution_exclusive(exec_id, ExecutionKind::DagRun)
.await
{
Ok((_, lock)) => lock,
Err(DurableError::ExecutionLocked {
execution_id,
holder_pid,
}) => {
tracing::info!(
execution_id = %execution_id,
holder_pid,
graph_id = %graph_id,
generation,
"orch.durable.budget_journal: execution already open in another process; \
skipping this budget snapshot"
);
return Ok(());
}
Err(e) => return Err(e),
};
let ctx = build_budget_ctx(exec_id, false, backend, writer, config);
let desc = StepDescriptor::idempotent(BUDGET_STEP_NAME, BUDGET_STEP_FP.to_vec());
let result = ctx
.step(desc, move |_handle| async move {
Ok::<ReplanBudgetSnapshot, zeph_durable::step::StepError>(snapshot)
})
.await;
let terminal_status = if result.is_ok() {
zeph_durable::ExecutionStatus::Completed
} else {
zeph_durable::ExecutionStatus::Failed
};
if let Err(finalize_err) = ctx.finalize(terminal_status).await {
tracing::warn!(
error = %finalize_err,
graph_id = %graph_id,
generation,
"orch.durable.budget_journal: failed to finalize budget execution"
);
}
result.map(|_| ())
}
#[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, backend, handle) = make_backend_with_local().await;
(backend, handle)
}
async fn make_backend_with_local() -> (
Arc<LocalBackend>,
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.clone(), &test_config());
tokio::spawn(async move { writer.run().await }); (local, backend, handle)
}
async fn execution_status(local: &LocalBackend, exec: ExecutionId) -> String {
let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
"SELECT status FROM durable_executions WHERE execution_id = ?"
))
.bind(exec.as_uuid().to_string())
.fetch_one(local.pool())
.await
.unwrap();
status
}
#[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"
);
}
#[tokio::test]
async fn journal_budget_finalizes_the_execution_as_completed() {
let (local, backend, writer) = make_backend_with_local().await;
let config = test_config();
let graph_id = GraphId::new();
let generation: u32 = 0;
journal_budget(
&graph_id,
generation,
backend,
writer.clone(),
&config,
ReplanBudgetSnapshot::default(),
)
.await
.unwrap();
writer.flush().await.unwrap();
let exec = budget_exec_id(&graph_id, generation);
assert_eq!(execution_status(&local, exec).await, "completed");
}
#[tokio::test]
async fn journal_budget_finalizes_the_execution_as_failed_on_step_error() {
let (local, backend, writer) = make_backend_with_local().await;
let config = DurableConfig {
max_payload_bytes: 1,
..test_config()
};
let graph_id = GraphId::new();
let generation: u32 = 0;
let err = journal_budget(
&graph_id,
generation,
backend,
writer.clone(),
&config,
ReplanBudgetSnapshot::default(),
)
.await
.expect_err("a 1-byte payload cap must reject the encoded snapshot");
assert!(
matches!(err, DurableError::PayloadTooLarge { .. }),
"unexpected error: {err:?}"
);
let exec = budget_exec_id(&graph_id, generation);
assert_eq!(execution_status(&local, exec).await, "failed");
}
#[tokio::test]
async fn journal_budget_skips_gracefully_when_execution_is_locked() {
let dir = tempfile::tempdir().unwrap();
let url = dir.path().join("durable.db").to_string_lossy().into_owned();
let owner = LocalBackend::open(&url, 1_048_576).await.unwrap();
owner.init().await.unwrap();
let owner = Arc::new(owner);
let contender = LocalBackend::open(&url, 1_048_576).await.unwrap();
let contender = Arc::new(contender);
let backend = Arc::new(DurableBackendEnum::Local(contender.clone()));
let (writer, handle) = JournalWriter::new(contender.clone(), &test_config());
tokio::spawn(async move { writer.run().await });
let graph_id = GraphId::new();
let generation: u32 = 0;
let exec_id = budget_exec_id(&graph_id, generation);
let (_, _lock) = owner
.open_execution_exclusive(exec_id, ExecutionKind::DagRun)
.await
.unwrap();
let config = test_config();
let result = journal_budget(
&graph_id,
generation,
backend,
handle,
&config,
ReplanBudgetSnapshot::default(),
)
.await;
assert!(
result.is_ok(),
"ExecutionLocked must degrade to a graceful Ok(()) skip, got {result:?}"
);
let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
"SELECT status FROM durable_executions WHERE execution_id = ?"
))
.bind(exec_id.as_uuid().to_string())
.fetch_one(owner.pool())
.await
.unwrap();
assert_eq!(
status, "running",
"the losing side must not have written a step or finalized the row"
);
}
}
}