Skip to main content

nasaga_runtime_core/
scheduled.rs

1//! 调度驱动的 Saga 发起:把周期任务(对账、清算、批量处置)安全接到幂等创建。
2//!
3//! 分工边界:集群调度器(leader gate + FireLog claim)只解决"谁来触发本名义时刻",
4//! **不是 exactly-once**;真正的去重由三件事共同承担——稳定的名义调度时刻、由
5//! `任务名 + 名义调度时刻 + 对象稳定身份` 派生的业务幂等键,以及创建请求 canonical
6//! 摘要的不变性(同 key 摘要漂移 fail-closed,绝不把另一批输入伪装成重复)。
7//!
8//! 崩溃恢复模型:claim 成功但进程随后崩溃时,**由下一次触发重扫已提交业务事实补漏**;
9//! 本模块不在进程内保留任何跨周期状态——已创建的实例被业务幂等键吸收,漏掉的对象在
10//! 重扫的条目列表里自然重新出现。
11
12use nasaga_core::{BusinessKey, SagaId, TenantId, TriggerKind};
13use sha2::{Digest as _, Sha256};
14use uuid::Uuid;
15
16use crate::orchestrator::{Orchestrator, StartOutcome, StartSagaRequest};
17
18/// 业务作用:从"任务名 + 名义调度时刻 + 对象稳定身份"确定性派生批次内业务幂等键。
19///
20/// 对象身份参与 SHA-256 后取前 32 位十六进制:无论业务对象 id 多长、含何种字符,
21/// 派生键都有界且落在 `BusinessKey` 封闭字符集内;同一批次重复触发得到完全相同的键,
22/// 由创建入口的业务唯一键把重复吸收成同一实例。
23///
24/// 参数说明:
25/// - `task_name`: 调度任务稳定名称。
26/// - `scheduled_at_ms`: 名义调度时刻(epoch 毫秒)——用调度表的名义时刻,不用实际
27///   触发时刻;否则重试触发会派生出第二个批次身份。
28/// - `object_id`: 批次内业务对象的稳定身份。
29///
30/// 返回:确定性业务幂等键;任务名越界导致整体键非法时返回错误文本。
31pub 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/// 业务作用:描述一次名义调度时刻批次的固定输入与有界预算。
45#[derive(Debug, Clone)]
46pub struct ScheduledBatchSpec<'a> {
47    /// 调度任务稳定名称,参与业务幂等键派生。
48    pub task_name: &'a str,
49    /// 名义调度时刻(epoch 毫秒),批次身份的一部分。
50    pub scheduled_at_ms: i64,
51    /// 目标 workflow 名称。
52    pub workflow: &'a nasaga_core::WorkflowName,
53    /// 目标 definition 版本。
54    pub version: nasaga_core::DefinitionVersion,
55    /// 租户身份。
56    pub tenant: &'a TenantId,
57    /// 单批次发起数量上限;未处理对象由下一次触发的重扫继续。
58    pub max_items: u32,
59    /// 单批次时间预算(毫秒);超出即停止,不在进程内滞留。
60    pub time_budget_ms: i64,
61    /// 当前时刻(epoch 毫秒),由调用方注入统一时钟。
62    pub now_ms: i64,
63}
64
65/// 业务作用:批次内单个业务对象的发起输入。
66#[derive(Debug, Clone)]
67pub struct ScheduledItem {
68    /// 对象稳定身份,参与业务幂等键派生。
69    pub object_id: String,
70    /// 首步 execute 命令的业务输入;同一对象在同一批次内重试必须给出相同 payload,
71    /// 创建摘要漂移会 fail-closed。
72    pub first_command_payload: Option<serde_json::Value>,
73    /// 实例级业务 deadline(epoch 毫秒);为空不设全局期限。
74    pub deadline_at_ms: Option<i64>,
75}
76
77/// 业务作用:一次批次发起的低基数结果,供任务层指标与告警。
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub struct ScheduledBatchReport {
80    /// 本批真实创建的实例数。
81    pub started: u32,
82    /// 命中业务幂等键、被吸收为既有实例的重复数。
83    pub duplicates: u32,
84    /// 因数量上限、时间预算或失去领导权而未处理的对象数;由下一次触发重扫继续。
85    pub unprocessed: u32,
86    /// 键派生失败等可继续观察的首个稳定原因;无失败为空。创建路径的失败以带稳定
87    /// 原因码的错误返回(本报告不随 `Err` 返回)。
88    pub first_error: Option<&'static str>,
89}
90
91impl<B> Orchestrator<B>
92where
93    B: nasaga_backend::SagaBackend + nasaga_backend::SagaBackendFactory,
94{
95    /// 业务作用:在一次已取得触发权的名义调度时刻内,按预算幂等发起一批 Saga。
96    ///
97    /// 触发权合同:调用方必须已持有领导权与该名义时刻的 FireLog claim;`still_leader`
98    /// 在**每个对象之间**复验——失去领导权立即停止领取新项,已提交创建的实例由 Saga
99    /// 自身推进,不随领导权变化回滚。重复触发(重试、换届重扫、崩溃后补漏)由派生
100    /// 业务幂等键吸收;同 key 的创建摘要漂移由创建入口 fail-closed,以带稳定原因码的
101    /// 错误停止本批(输入口径漂移是配置事故,不是可跳过的单行失败)。
102    ///
103    /// 参数说明:
104    /// - `spec`: 批次身份、目标 workflow 与有界预算。
105    /// - `items`: 本名义时刻扫描出的业务对象列表(来自已提交业务事实)。
106    /// - `still_leader`: 领导权探针;返回假即停止领取新项。
107    ///
108    /// 返回:批次结果;创建路径的基础设施失败返回错误(已创建实例保持已提交)。
109    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            // 失去领导权立即停止领取新项:触发权威已经易主,继续发起会与新 leader 的
132            // 同批次重扫并发;已创建实例交由业务幂等键与状态机保证安全。
133            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                // 预算耗尽:未处理对象不在进程内滞留,由下一次触发的业务事实重扫继续。
141                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            // saga_id 每次随机:幂等完全由业务键承担,重复触发命中唯一键即返回既有实例,
157            // 不产生第二次首步命令。触发身份复用派生键文本,重放的初始 transition 触发
158            // 也保持稳定。
159            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                    // 同 key 摘要漂移是输入口径事故:继续逐对象重试只会重复失败,
181                    // 停止本批;稳定原因并入错误链交给任务层告警(Err 路径不返回报告)。
182                    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}