nasaga_runtime_core/
scheduled.rs1use nasaga_core::{BusinessKey, SagaId, TenantId, TriggerKind};
13use sha2::{Digest as _, Sha256};
14use uuid::Uuid;
15
16use crate::orchestrator::{Orchestrator, StartOutcome, StartSagaRequest};
17
18pub fn derive_scheduled_business_key(
32 task_name: &str,
33 scheduled_at_ms: i64,
34 object_id: &str,
35) -> Result<BusinessKey, String> {
36 let digest = Sha256::digest(object_id.as_bytes());
37 let key = format!(
38 "sched:{task_name}@{scheduled_at_ms}:{}",
39 hex::encode(&digest[..16])
40 );
41 BusinessKey::new(&key).map_err(|violation| violation.code().to_string())
42}
43
44#[derive(Debug, Clone)]
46pub struct ScheduledBatchSpec<'a> {
47 pub task_name: &'a str,
49 pub scheduled_at_ms: i64,
51 pub workflow: &'a nasaga_core::WorkflowName,
53 pub version: nasaga_core::DefinitionVersion,
55 pub tenant: &'a TenantId,
57 pub max_items: u32,
59 pub time_budget_ms: i64,
61 pub now_ms: i64,
63}
64
65#[derive(Debug, Clone)]
67pub struct ScheduledItem {
68 pub object_id: String,
70 pub first_command_payload: Option<serde_json::Value>,
73 pub deadline_at_ms: Option<i64>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub struct ScheduledBatchReport {
80 pub started: u32,
82 pub duplicates: u32,
84 pub unprocessed: u32,
86 pub first_error: Option<&'static str>,
89}
90
91impl<B> Orchestrator<B>
92where
93 B: nasaga_backend::SagaBackend + nasaga_backend::SagaBackendFactory,
94{
95 pub async fn start_scheduled_batch(
110 &self,
111 spec: &ScheduledBatchSpec<'_>,
112 items: &[ScheduledItem],
113 mut still_leader: impl FnMut() -> bool,
114 ) -> anyhow::Result<ScheduledBatchReport> {
115 if spec.max_items == 0 || spec.time_budget_ms <= 0 {
116 anyhow::bail!("scheduled batch requires positive item and time budgets");
117 }
118 if spec.task_name.is_empty()
119 || spec.task_name.len() > 64
120 || !spec
121 .task_name
122 .bytes()
123 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
124 {
125 anyhow::bail!("scheduled task name must be a bounded canonical identifier");
126 }
127 let mut report = ScheduledBatchReport::default();
128 let started_at = std::time::Instant::now();
129 let budget = std::time::Duration::from_millis(spec.time_budget_ms as u64);
130 for (index, item) in items.iter().enumerate() {
131 if !still_leader() {
134 report.unprocessed = (items.len() - index) as u32;
135 return Ok(report);
136 }
137 if report.started + report.duplicates >= spec.max_items
138 || started_at.elapsed() >= budget
139 {
140 report.unprocessed = (items.len() - index) as u32;
142 return Ok(report);
143 }
144 let business_key = match derive_scheduled_business_key(
145 spec.task_name,
146 spec.scheduled_at_ms,
147 &item.object_id,
148 ) {
149 Ok(key) => key,
150 Err(_) => {
151 report.first_error = Some("scheduled_key_derivation_failed");
152 report.unprocessed = (items.len() - index) as u32;
153 return Ok(report);
154 }
155 };
156 let saga_id = SagaId::new(Uuid::new_v4().to_string())
160 .map_err(|violation| anyhow::anyhow!("saga id violation: {}", violation.code()))?;
161 let outcome = self
162 .start_saga(&StartSagaRequest {
163 saga_id: &saga_id,
164 tenant: spec.tenant,
165 workflow: spec.workflow,
166 version: spec.version,
167 business_key: &business_key,
168 deadline_at_ms: item.deadline_at_ms,
169 trigger_kind: TriggerKind::Timer,
170 trigger_id: business_key.as_str(),
171 first_command_payload: item.first_command_payload.clone(),
172 first_command_raw_payload: None,
173 now_ms: spec.now_ms,
174 })
175 .await;
176 match outcome {
177 Ok(StartOutcome::Started(_)) => report.started += 1,
178 Ok(StartOutcome::AlreadyExists(_)) => report.duplicates += 1,
179 Err(error) => {
180 let code = if crate::StartSagaError::from_error(&error)
183 == Some(crate::StartSagaError::RequestConflict)
184 {
185 "scheduled_start_digest_drift"
186 } else {
187 "scheduled_start_failed"
188 };
189 return Err(error.context(format!(
190 "scheduled batch stopped at object index {index}: {code}"
191 )));
192 }
193 }
194 }
195 Ok(report)
196 }
197}