Skip to main content

dtmrs_server/
api.rs

1//! TC 的对外操作,**与协议无关**。
2//!
3//! HTTP 和 gRPC 两套接口都只做「协议 ↔ 这一层」的转换,业务判断全在这里。
4//! 分成两处写迟早会漂移 —— 而这一层漂移的后果是「同一个请求走 HTTP 被拒、
5//! 走 gRPC 却受理了」,这种不一致在事务系统里是要命的。
6//!
7//! 错误用 [`ApiError`] 表达,由各协议层翻译成自己的表示:
8//!
9//! | ApiError | HTTP | gRPC |
10//! |---|---|---|
11//! | `BadRequest` | 400 | `INVALID_ARGUMENT` |
12//! | `NotFound` | 404 | `NOT_FOUND` |
13//! | `Conflict` | 200 + `dtm_result=FAILURE` | `FAILED_PRECONDITION` |
14//! | `Internal` | 500 | `INTERNAL` |
15//!
16//! `Conflict` 在 HTTP 上返回 200 是**刻意保留的历史行为**(已终结的事务再调
17//! abort),换成 4xx 会打破现有客户端。
18
19use crate::driver;
20use crate::{msg_rows, saga_rows, tcc_rows};
21use dtmrs_core::{BranchOp, GlobalStatus, SagaStep, TransType};
22use dtmrs_store::{Store, SubmitOutcome};
23use serde::Serialize;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ApiError {
27    BadRequest(String),
28    NotFound(String),
29    /// 请求本身合法,但当前状态下做不了
30    Conflict(String),
31    Internal(String),
32}
33
34impl ApiError {
35    pub fn message(&self) -> &str {
36        match self {
37            Self::BadRequest(m) | Self::NotFound(m) | Self::Conflict(m) | Self::Internal(m) => m,
38        }
39    }
40}
41
42impl std::fmt::Display for ApiError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(self.message())
45    }
46}
47
48pub type Result<T> = std::result::Result<T, ApiError>;
49
50fn internal(e: impl std::fmt::Display) -> ApiError {
51    ApiError::Internal(e.to_string())
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct BranchView {
56    pub branch_id: String,
57    pub op: String,
58    pub url: String,
59    pub status: String,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct TransView {
64    pub gid: String,
65    pub trans_type: String,
66    pub status: String,
67    pub rollback_reason: String,
68    pub create_time: i64,
69    pub finish_time: Option<i64>,
70    pub branches: Vec<BranchView>,
71}
72
73/// 分支登记请求。TCC 用 confirm/cancel,XA 用 commit/rollback。
74#[derive(Debug, Clone, Default)]
75pub struct RegisterBranch {
76    pub gid: String,
77    pub branch_id: String,
78    pub confirm: String,
79    pub cancel: String,
80    pub r#try: String,
81    pub commit: String,
82    pub rollback: String,
83}
84
85#[derive(Clone)]
86pub struct Api {
87    pub store: Store,
88    /// 提交后**直接开推**用的推进器。`None` 就是老行为:写完就返回,
89    /// 等推进器自己抢到再推。见 [`Api::with_inline_driver`]
90    inline: Option<crate::driver::Driver>,
91}
92
93impl Api {
94    pub fn new(store: Store) -> Self {
95        Self {
96            store,
97            inline: None,
98        }
99    }
100
101    /// 开启「提交后直接开推」。
102    ///
103    /// # 省掉的是那次抢占往返
104    ///
105    /// 老流程:提交方写完事务就返回,推进器再 `lock_one_due` 抢一次才能推。
106    /// 那次抢占**每笔事务都要付**,在 Redis 上是一次 Lua 往返 —— 实测它就是
107    /// saga 落后 DTM 的主要原因(saga 只有一次客户端请求,摊不薄)。
108    ///
109    /// 新流程:建事务的那条写入里**顺便把租约占在自己手上**
110    /// (`owner=自己`、`next_cron_time=现在+租约`),写成功就等于抢到了,
111    /// 直接推。零额外往返。
112    ///
113    /// # 跟 DTM 的差别:我们不阻塞提交
114    ///
115    /// DTM 是在 submit 请求里同步把事务推完,客户端要一直等。这里是
116    /// **spawn 出去推,提交立刻返回** —— 省掉往返的同时保住了提交延迟。
117    ///
118    /// # 代价
119    ///
120    /// 租约一占就是 `lease` 秒。如果进程在「写完」和「推完」之间挂了,
121    /// 这笔要等租约到期才会被别的实例接手,而不是下一个 tick。
122    /// 这跟「推进器抢到之后崩了」是同一种情形,不是新引入的风险。
123    pub fn with_inline_driver(mut self, d: crate::driver::Driver) -> Self {
124        self.inline = Some(d);
125        self
126    }
127
128    /// 建事务前把租约字段填上。返回是否真的占了 —— 没开内联就不占。
129    fn claim_for_inline(&self, g: &mut dtmrs_store::GlobalRow) -> bool {
130        let Some(d) = &self.inline else { return false };
131        g.owner = d.owner.clone();
132        g.next_cron_time = dtmrs_store::now() + d.lease;
133        true
134    }
135
136    /// 把 prepared 推成 submitted,开了内联就**顺便占下租约**。
137    ///
138    /// 返回 `Advanced` 时事务体一起带回来了,调用方可以直接 [`Self::drive_detached`],
139    /// 不用再读一次(Redis 是脚本尾巴上的 HGETALL,SQL 是本来就要发的那条 SELECT)
140    async fn claim_and_submit(&self, gid: &str) -> Result<SubmitOutcome> {
141        let (owner, nct) = match &self.inline {
142            Some(d) => (d.owner.clone(), dtmrs_store::now() + d.lease),
143            None => (String::new(), dtmrs_store::now()),
144        };
145        self.store
146            .submit_prepared(gid, &owner, nct)
147            .await
148            .map_err(internal)
149    }
150
151    /// 把已经拿到租约的事务扔出去推。**不等它跑完** —— 提交要立刻返回。
152    fn drive_detached(&self, g: dtmrs_store::GlobalRow) {
153        let Some(d) = self.inline.clone() else { return };
154        tokio::spawn(async move {
155            if let Err(e) = d.process(&g).await {
156                // 推失败不影响提交的结果,租约到期后会被重新捞起来
157                tracing::warn!(gid = %g.gid, error = %e, "提交后直接推进出错,等租约到期重试");
158            }
159        });
160    }
161
162    /// 时间戳 + 进程内计数。生产建议客户端直接用业务单号当 gid ——
163    /// 那样天然幂等,重试不会变成两笔
164    pub fn new_gid(&self) -> String {
165        use std::sync::atomic::{AtomicU64, Ordering};
166        static SEQ: AtomicU64 = AtomicU64::new(0);
167        let n = SEQ.fetch_add(1, Ordering::Relaxed);
168        format!("{}-{}", dtmrs_store::now(), n)
169    }
170
171    /// 提交。
172    ///
173    /// **重复提交同一个 gid 必须成功而不是报错** —— 客户端网络抖动重试时
174    /// 返回错误会让它以为没受理,然后换个 gid 再来一次,就成了两笔。
175    pub async fn submit(&self, gid: &str, trans_type: &str, steps: &[SagaStep]) -> Result<()> {
176        if gid.is_empty() {
177            return Err(ApiError::BadRequest("gid 不能为空".into()));
178        }
179        let Some(tt) = TransType::parse(trans_type) else {
180            return Err(ApiError::BadRequest("未知 trans_type".into()));
181        };
182
183        match tt {
184            TransType::Saga => {
185                if steps.is_empty() {
186                    // ⚠ 不带步骤的重复提交**必须幂等成功**,不能因为 steps 为空
187                    // 就报错 —— 客户端重试时经常只带 gid。只有事务压根不存在,
188                    // 才是真的参数错误。(`tc的grpc_api与http同源` 钉着这条)
189                    return match self.claim_and_submit(gid).await? {
190                        SubmitOutcome::Advanced(g) => {
191                            self.drive_detached(*g);
192                            Ok(())
193                        }
194                        SubmitOutcome::Already => Ok(()),
195                        SubmitOutcome::Missing => {
196                            Err(ApiError::BadRequest("saga 的 steps 不能为空".into()))
197                        }
198                    };
199                }
200                let (mut g, branches) = saga_rows(gid, steps);
201                // 开了内联推进的话,这条写入顺便把租约占下来,写成功就直接推,
202                // 不用再走一次抢占(见 `with_inline_driver`)
203                let claimed = self.claim_for_inline(&mut g);
204                // **先建,不先查。** `create_global` 本身就是幂等的(已存在返回
205                // false 且不覆盖),所以正常路径一次往返就够 —— 这是 saga 提交的
206                // 热路径,先查一次等于白付一次往返,而且那还是个 Lua 脚本调用,
207                // 比普通命令贵得多(实测多这一次让 saga 吞吐掉了 16%)
208                if self
209                    .store
210                    .create_global(&g, &branches)
211                    .await
212                    .map_err(internal)?
213                {
214                    if claimed {
215                        self.drive_detached(g);
216                    }
217                    return Ok(());
218                }
219                // 已存在。可能是重复提交(幂等返回成功就行),也可能是这个 gid
220                // 其实是 prepare 过的 tcc/msg/xa —— 客户端没传 trans_type 时
221                // 会被当成 saga。后一种要真的把它推成 submitted,交给下面决断
222                if let SubmitOutcome::Advanced(g) = self.claim_and_submit(gid).await? {
223                    self.drive_detached(*g);
224                }
225                Ok(())
226            }
227            TransType::Tcc | TransType::Msg | TransType::Xa => {
228                // prepare 已经建过事务,submit 只是把它推成 submitted。
229                //
230                // **一次存储调用做完**(原来是 get_global + set_global_status
231                // + schedule_now 三次)。Redis 上这三次是 11 条命令,现在 3 条
232                match self.claim_and_submit(gid).await? {
233                    // 推成 submitted 了。开了内联就直接推 —— 跟 saga 一样,
234                    // 省掉那次抢占往返。事务体是 submit_prepared 顺带返回的,
235                    // 没有多付一次读
236                    SubmitOutcome::Advanced(g) => {
237                        self.drive_detached(*g);
238                        Ok(())
239                    }
240                    // 已经提交过 —— 幂等返回成功
241                    SubmitOutcome::Already => Ok(()),
242                    SubmitOutcome::Missing => {
243                        Err(ApiError::BadRequest("tcc/xa/msg 要先调 prepare".into()))
244                    }
245                }
246            }
247            // workflow 的「步骤」是**代码**,没法表示成 URL 存进库里,
248            // 所以只能在嵌入式形态下提交(Embedded::workflow + submit_workflow)。
249            // 这不是暂未实现,是这个模式的本质决定的
250            TransType::Workflow => Err(ApiError::BadRequest(
251                "workflow 模式只能在嵌入式形态下提交(步骤是进程内的函数,不是 URL)".into(),
252            )),
253        }
254    }
255
256    /// 第一阶段。msg 建 prepared 事务 + 正向分支;tcc / xa 只建空事务。
257    pub async fn prepare(
258        &self,
259        gid: &str,
260        trans_type: &str,
261        actions: &[String],
262        query_prepared: &str,
263        grace_secs: Option<i64>,
264    ) -> Result<()> {
265        if gid.is_empty() {
266            return Err(ApiError::BadRequest("gid 不能为空".into()));
267        }
268        match TransType::parse(trans_type) {
269            Some(TransType::Msg) => {
270                if actions.is_empty() {
271                    return Err(ApiError::BadRequest("msg 的 actions 不能为空".into()));
272                }
273                if query_prepared.is_empty() {
274                    // 没有回查地址,客户端崩在 prepare 和 submit 之间就没人能
275                    // 决断这单了。猜「已提交」会重复扣款,猜「没提交」会丢单
276                    return Err(ApiError::BadRequest(
277                        "msg 必须提供 query_prepared,否则崩溃后无法决断".into(),
278                    ));
279                }
280                let (g, br) = msg_rows(gid, actions, query_prepared, grace_secs.unwrap_or(10));
281                self.store.create_global(&g, &br).await.map_err(internal)?;
282                Ok(())
283            }
284            Some(tt @ (TransType::Tcc | TransType::Xa)) => {
285                let mut g = tcc_rows(gid);
286                g.trans_type = tt;
287                self.store.create_global(&g, &[]).await.map_err(internal)?;
288                Ok(())
289            }
290            _ => Err(ApiError::BadRequest(
291                "prepare 支持 tcc / xa / msg;saga 直接 submit".into(),
292            )),
293        }
294    }
295
296    /// 分支登记。**必须先登记再做一阶段**:反过来的话一阶段成功但登记失败,
297    /// TC 就不知道有这个分支,回滚时不会处理它 —— TCC 是预留资源永久泄漏,
298    /// XA 更糟,会留下一个永久持锁的 prepared 事务。
299    pub async fn register_branch(&self, r: &RegisterBranch) -> Result<()> {
300        if r.gid.is_empty() || r.branch_id.is_empty() {
301            return Err(ApiError::BadRequest("gid / branch_id 不能为空".into()));
302        }
303        // ⚠ 分支号的格式必须在**入口**挡住,放进去之后就来不及了。
304        //
305        // 这里原先只校验非空,于是客户端随手写个 branch_id="inventory" 就能
306        // 触发两个都很难查的故障(都实测过,不是推演):
307        //
308        //   · 解析不出下标 → 推进器把整笔事务当成「空事务」直接判 succeed,
309        //     confirm 一次都不会调,那份已经 try 冻结的资源永久泄漏;
310        //   · 写个 "2000000000" → 推进时按下标开数组,一次 submit 把 RSS
311        //     从 38 MB 顶到 3.4 GB,而且这行留在库里,每轮轮询再来一遍。
312        //
313        // 判据见 `is_canonical_branch_id`:driver 推进时是拿下标**重新生成**
314        // 分支号去反查行的,所以还原不出原样的写法一律不收 —— 包括 "1"、"001"
315        // 这种「看起来对但少了/多了补零」的,它们会让状态更新静默落空。
316        if !driver::is_canonical_branch_id(&r.branch_id) {
317            return Err(ApiError::BadRequest(format!(
318                "branch_id \"{}\" 格式不对:必须是从 01 开始、至少两位补零的十进制序号\
319                 (01、02 …… 99、100),且不超过 {}",
320                r.branch_id,
321                driver::MAX_BRANCH_INDEX + 1
322            )));
323        }
324        let tt = match self.store.get_global(&r.gid).await {
325            // ⚠ 必须挡住「事务已经不可能再推进新分支」的状态。
326            //
327            // TCC / XA 的正确顺序是**先登记分支再做一阶段**(见 CLAUDE.md
328            // 「绝对不能破坏的语义」第 5 条)。如果这里放行,客户端拿到 SUCCESS
329            // 之后就会去执行 try / XA PREPARE —— 而 TC 这边事务已经终结或正在
330            // 回滚,那份资源**永远不会有人 confirm 或 cancel**:
331            //   TCC 是资源永久泄漏,XA 更糟 —— 留下永久持锁的 prepared 事务。
332            //
333            // 真实触发路径不需要客户端有 bug:多分支 TCC 登记完分支 1、做完 try、
334            // 正要登记分支 2 时,这笔事务**超时了**,TC 已经回滚并落终态。
335            //
336            // 只放行 Prepared(正常流程)和 Submitted(容忍重试 ——
337            // register_branch 本身是幂等的,见 `分支登记是幂等的`)。
338            Ok(Some(g)) if matches!(g.status, GlobalStatus::Aborting) || g.status.is_final() => {
339                return Err(ApiError::Conflict(format!(
340                    "事务处于 {} 状态,不能再登记分支(登记后的一阶段将无人收尾)",
341                    g.status.as_str()
342                )));
343            }
344            Ok(Some(g)) => g.trans_type,
345            Ok(None) => return Err(ApiError::NotFound("gid 不存在,先 prepare".into())),
346            Err(e) => return Err(internal(e)),
347        };
348
349        let mut ops = Vec::new();
350        match tt {
351            TransType::Tcc => {
352                if r.confirm.is_empty() || r.cancel.is_empty() {
353                    return Err(ApiError::BadRequest(
354                        "tcc 分支必须提供 confirm 和 cancel".into(),
355                    ));
356                }
357                ops.push((BranchOp::Confirm, r.confirm.clone()));
358                ops.push((BranchOp::Cancel, r.cancel.clone()));
359                if !r.r#try.is_empty() {
360                    ops.push((BranchOp::Try, r.r#try.clone()));
361                }
362            }
363            TransType::Xa => {
364                if r.commit.is_empty() || r.rollback.is_empty() {
365                    // 缺任一个都可能留下永久持锁的 prepared 事务
366                    return Err(ApiError::BadRequest(
367                        "xa 分支必须提供 commit 和 rollback".into(),
368                    ));
369                }
370                ops.push((BranchOp::Commit, r.commit.clone()));
371                ops.push((BranchOp::Rollback, r.rollback.clone()));
372            }
373            _ => return Err(ApiError::BadRequest("只有 tcc 和 xa 需要登记分支".into())),
374        }
375
376        // ⚠ 重号必须拒绝,不能沿用「登记是幂等的」那条放行。
377        //
378        // 两种冲突长得一样但结论相反:URL 完全一致是客户端重试(放行),
379        // URL 不同则是**两个不同的分支编成了同一个号** —— 存储层是冲突忽略,
380        // 第二个分支的 URL 根本没写进去。原先这里照样返回 SUCCESS,
381        // 于是客户端接着去调那个分支的 try 把资源冻结上,而 TC 不知道有它,
382        // confirm / cancel 都不会调,那份资源永久泄漏。
383        //
384        // 判定放在存储层(见 `RegisterOutcome`)是为了拿到事务/脚本的原子性:
385        // 两个请求同时登记同一个号时,输的那个也能看见赢家的 URL。
386        match self
387            .store
388            .register_branch(&r.gid, &r.branch_id, &ops)
389            .await
390            .map_err(internal)?
391        {
392            dtmrs_store::RegisterOutcome::Registered => Ok(()),
393            dtmrs_store::RegisterOutcome::Conflict { op, existing } => {
394                Err(ApiError::Conflict(format!(
395                    "branch_id \"{}\" 的 {} 已经登记成 {existing},不能改成另一个地址。\
396                     每个分支要用**各自**的分支号(01、02、03…),重号会让后面那个分支\
397                     没人 confirm/cancel,资源永久泄漏",
398                    r.branch_id,
399                    op.as_str()
400                )))
401            }
402        }
403    }
404
405    /// 主动中止,触发逆序补偿
406    pub async fn abort(&self, gid: &str) -> Result<()> {
407        match self.store.get_global(gid).await {
408            Ok(Some(g)) if !g.status.is_final() => {
409                self.store
410                    .set_global_status(gid, GlobalStatus::Aborting, g.trans_type, "调用方主动中止")
411                    .await
412                    .map_err(internal)?;
413                let _ = self.store.schedule_now(gid).await;
414                Ok(())
415            }
416            Ok(Some(_)) => Err(ApiError::Conflict("事务已终结,无法中止".into())),
417            Ok(None) => Err(ApiError::NotFound("gid 不存在".into())),
418            Err(e) => Err(internal(e)),
419        }
420    }
421
422    /// 立刻重试:把下次调度时间提到现在,并清掉退避累积。
423    ///
424    /// 只是「排到队首」,不跳过任何安全检查 —— 分支该幂等还是要幂等。
425    /// 终态事务不能重试(没意义,而且会让它重新变成活跃事务)。
426    pub async fn retry(&self, gid: &str) -> Result<()> {
427        match self.store.get_global(gid).await {
428            Ok(Some(g)) if !g.status.is_final() => {
429                self.store.schedule_now(gid).await.map_err(internal)?;
430                Ok(())
431            }
432            Ok(Some(_)) => Err(ApiError::Conflict("事务已终结,无需重试".into())),
433            Ok(None) => Err(ApiError::NotFound("gid 不存在".into())),
434            Err(e) => Err(internal(e)),
435        }
436    }
437
438    pub async fn query(&self, gid: &str) -> Result<TransView> {
439        let g = self
440            .store
441            .get_global(gid)
442            .await
443            .map_err(internal)?
444            .ok_or_else(|| ApiError::NotFound("gid 不存在".into()))?;
445        let branches = self.store.list_branches(gid).await.map_err(internal)?;
446        Ok(TransView {
447            gid: g.gid,
448            trans_type: g.trans_type.to_string(),
449            status: g.status.as_str().into(),
450            rollback_reason: g.rollback_reason,
451            create_time: g.create_time,
452            finish_time: g.finish_time,
453            branches: branches
454                .into_iter()
455                .map(|b| BranchView {
456                    branch_id: b.branch_id,
457                    op: b.op.as_str().into(),
458                    url: b.url,
459                    status: b.status.as_str().into(),
460                })
461                .collect(),
462        })
463    }
464
465    pub async fn list_recent(&self, limit: i64) -> Vec<TransView> {
466        self.store
467            .list_recent(limit)
468            .await
469            .unwrap_or_default()
470            .into_iter()
471            .map(|g| TransView {
472                gid: g.gid,
473                trans_type: g.trans_type.to_string(),
474                status: g.status.as_str().into(),
475                rollback_reason: g.rollback_reason,
476                create_time: g.create_time,
477                finish_time: g.finish_time,
478                branches: Vec::new(),
479            })
480            .collect()
481    }
482}