use nasaga_core::{BusinessKey, SagaId, TenantId, TriggerKind};
use sha2::{Digest as _, Sha256};
use uuid::Uuid;
use crate::orchestrator::{Orchestrator, StartOutcome, StartSagaRequest};
pub fn derive_scheduled_business_key(
task_name: &str,
scheduled_at_ms: i64,
object_id: &str,
) -> Result<BusinessKey, String> {
let digest = Sha256::digest(object_id.as_bytes());
let key = format!(
"sched:{task_name}@{scheduled_at_ms}:{}",
hex::encode(&digest[..16])
);
BusinessKey::new(&key).map_err(|violation| violation.code().to_string())
}
#[derive(Debug, Clone)]
pub struct ScheduledBatchSpec<'a> {
pub task_name: &'a str,
pub scheduled_at_ms: i64,
pub workflow: &'a nasaga_core::WorkflowName,
pub version: nasaga_core::DefinitionVersion,
pub tenant: &'a TenantId,
pub max_items: u32,
pub time_budget_ms: i64,
pub now_ms: i64,
}
#[derive(Debug, Clone)]
pub struct ScheduledItem {
pub object_id: String,
pub first_command_payload: Option<serde_json::Value>,
pub deadline_at_ms: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ScheduledBatchReport {
pub started: u32,
pub duplicates: u32,
pub unprocessed: u32,
pub first_error: Option<&'static str>,
}
impl<B> Orchestrator<B>
where
B: nasaga_backend::SagaBackend + nasaga_backend::SagaBackendFactory,
{
pub async fn start_scheduled_batch(
&self,
spec: &ScheduledBatchSpec<'_>,
items: &[ScheduledItem],
mut still_leader: impl FnMut() -> bool,
) -> anyhow::Result<ScheduledBatchReport> {
if spec.max_items == 0 || spec.time_budget_ms <= 0 {
anyhow::bail!("scheduled batch requires positive item and time budgets");
}
if spec.task_name.is_empty()
|| spec.task_name.len() > 64
|| !spec
.task_name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
anyhow::bail!("scheduled task name must be a bounded canonical identifier");
}
let mut report = ScheduledBatchReport::default();
let started_at = std::time::Instant::now();
let budget = std::time::Duration::from_millis(spec.time_budget_ms as u64);
for (index, item) in items.iter().enumerate() {
if !still_leader() {
report.unprocessed = (items.len() - index) as u32;
return Ok(report);
}
if report.started + report.duplicates >= spec.max_items
|| started_at.elapsed() >= budget
{
report.unprocessed = (items.len() - index) as u32;
return Ok(report);
}
let business_key = match derive_scheduled_business_key(
spec.task_name,
spec.scheduled_at_ms,
&item.object_id,
) {
Ok(key) => key,
Err(_) => {
report.first_error = Some("scheduled_key_derivation_failed");
report.unprocessed = (items.len() - index) as u32;
return Ok(report);
}
};
let saga_id = SagaId::new(Uuid::new_v4().to_string())
.map_err(|violation| anyhow::anyhow!("saga id violation: {}", violation.code()))?;
let outcome = self
.start_saga(&StartSagaRequest {
saga_id: &saga_id,
tenant: spec.tenant,
workflow: spec.workflow,
version: spec.version,
business_key: &business_key,
deadline_at_ms: item.deadline_at_ms,
trigger_kind: TriggerKind::Timer,
trigger_id: business_key.as_str(),
first_command_payload: item.first_command_payload.clone(),
first_command_raw_payload: None,
now_ms: spec.now_ms,
})
.await;
match outcome {
Ok(StartOutcome::Started(_)) => report.started += 1,
Ok(StartOutcome::AlreadyExists(_)) => report.duplicates += 1,
Err(error) => {
let code = if crate::StartSagaError::from_error(&error)
== Some(crate::StartSagaError::RequestConflict)
{
"scheduled_start_digest_drift"
} else {
"scheduled_start_failed"
};
return Err(error.context(format!(
"scheduled batch stopped at object index {index}: {code}"
)));
}
}
}
Ok(report)
}
}