Skip to main content

sz_rust_orm_facade/
jobs.rs

1//! 可靠任务队列(Reliable Job Queue)
2//!
3//! `tokio::spawn` 只解决并发,不解决可靠性:进程重启丢任务、失败无人重试、
4//! 重试无上限打爆下游、重复执行产生副作用。本模块提供基于数据库表的
5//! 持久化任务队列,对应工程实践要点:
6//!
7//! - **任务先变成数据**:`sz_jobs` 表(kind/payload/status/attempts/run_after/...)
8//! - **状态机**:pending(含延迟重试)/ running(含租约)/ succeeded / dead(可重放)
9//! - **领取靠数据库裁决**:事务内 `SELECT ... FOR UPDATE SKIP LOCKED` + UPDATE
10//!   抢占(MySQL 8.0.1+),多实例 worker 安全,不重复领取
11//! - **退避重试有上限**:指数退避 + 随机抖动,Temporary/Permanent 错误分类
12//! - **幂等**:`dedupe_key` 唯一约束,重复入队返回已有任务,不重复投递
13//! - **崩溃自愈**:`locked_until` 租约超时的 running 任务自动回收重跑
14//! - **死信可查看可重放**:`queue_snapshot()` + `retry_dead()`
15//! - **观测看队列健康**:pending/running/dead/最老 pending 等待时间
16//!
17//! 时间统一用 BIGINT 毫秒时间戳(UTC),避免 DATETIME 时区歧义。
18//! SQL 全部参数化绑定(`execute_with_params` / `query_with_params`),
19//! 列投影显式声明,无 `SELECT *`。
20//!
21//! # 并发领取的实现取舍(2026-08-15 修正)
22//!
23//! v1 曾用"单条 `UPDATE ... WHERE id IN (SELECT ...)` 抢占",实测发现并发缺陷:
24//! MySQL 默认 REPEATABLE READ 下,子查询是快照读(返回另一 worker 尚未提交的
25//! pending 行),且 InnoDB 的 UPDATE 锁等待后不重新评估 WHERE(semi-consistent
26//! read 仅 READ COMMITTED 启用),导致两个 worker 重复领取同一任务(集成测试
27//! 实测 calls=2×)。修正为事务内 `SELECT ... FOR UPDATE SKIP LOCKED`——在锁定
28//! 阶段就跳过他人已锁的行,是 MySQL/PostgreSQL 通用的标准做法。
29
30use std::collections::HashMap;
31use std::sync::Arc;
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33
34use async_trait::async_trait;
35use sz_orm_core::{DbError, Pool, PoolError, Value};
36use thiserror::Error;
37
38/// 任务表名
39pub const JOBS_TABLE: &str = "sz_jobs";
40
41/// 建表 SQL(幂等,MySQL 方言;sz300 主数据源为 MySQL)
42const SCHEMA_SQL: &str = "CREATE TABLE IF NOT EXISTS sz_jobs (
43  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
44  kind VARCHAR(64) NOT NULL,
45  payload TEXT NOT NULL,
46  status VARCHAR(16) NOT NULL DEFAULT 'pending',
47  attempts INT NOT NULL DEFAULT 0,
48  run_after BIGINT NOT NULL,
49  locked_until BIGINT NULL,
50  last_error TEXT NULL,
51  dedupe_key VARCHAR(255) NULL,
52  created_at BIGINT NOT NULL,
53  updated_at BIGINT NOT NULL,
54  UNIQUE KEY uq_sz_jobs_dedupe (kind, dedupe_key),
55  KEY idx_sz_jobs_status_run_after (status, run_after)
56) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
57
58const STATUS_PENDING: &str = "pending";
59const STATUS_RUNNING: &str = "running";
60const STATUS_SUCCEEDED: &str = "succeeded";
61const STATUS_DEAD: &str = "dead";
62
63/// 任务状态机
64///
65/// - `Pending`:待执行;`run_after` 表达延迟与退避(未到时间不执行)
66/// - `Running`:执行中;`locked_until` 为租约,超时后会被回收重新入队
67/// - `Succeeded`:已完成
68/// - `Dead`:永久失败(重试超限或 Permanent 错误),可人工重放
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum JobStatus {
71    /// 待执行(含延迟重试)
72    Pending,
73    /// 执行中(租约内)
74    Running,
75    /// 已完成
76    Succeeded,
77    /// 永久失败,可重放
78    Dead,
79}
80
81impl JobStatus {
82    /// 状态 → 数据库字符串(pending/running/succeeded/dead)
83    pub fn as_str(&self) -> &'static str {
84        match self {
85            JobStatus::Pending => STATUS_PENDING,
86            JobStatus::Running => STATUS_RUNNING,
87            JobStatus::Succeeded => STATUS_SUCCEEDED,
88            JobStatus::Dead => STATUS_DEAD,
89        }
90    }
91
92    /// 数据库字符串 → 状态
93    pub fn parse_status(s: &str) -> Option<JobStatus> {
94        match s {
95            STATUS_PENDING => Some(JobStatus::Pending),
96            STATUS_RUNNING => Some(JobStatus::Running),
97            STATUS_SUCCEEDED => Some(JobStatus::Succeeded),
98            STATUS_DEAD => Some(JobStatus::Dead),
99            _ => None,
100        }
101    }
102}
103
104/// 任务错误分类:决定失败后是否重试
105///
106/// - `Temporary`:可重试(下游 503、数据库短暂故障、SMTP 不可用)
107/// - `Permanent`:不可重试(参数缺失、用户不存在、模板配置错误)
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum JobErrorKind {
110    /// 临时失败,可重试
111    Temporary,
112    /// 永久失败,不应重试
113    Permanent,
114}
115
116/// 任务处理器错误
117#[derive(Debug, Error)]
118pub enum JobError {
119    /// 临时失败(将按退避策略重试)
120    #[error("temporary job failure: {0}")]
121    Temporary(String),
122    /// 永久失败(进入死信)
123    #[error("permanent job failure: {0}")]
124    Permanent(String),
125}
126
127impl JobError {
128    /// 错误分类(决定重试还是进死信)
129    pub fn kind(&self) -> JobErrorKind {
130        match self {
131            JobError::Temporary(_) => JobErrorKind::Temporary,
132            JobError::Permanent(_) => JobErrorKind::Permanent,
133        }
134    }
135}
136
137/// 任务处理器(注册到队列,按 `kind` 分发)
138#[async_trait]
139pub trait TaskHandler: Send + Sync + 'static {
140    /// 处理任务;返回 `JobError::Temporary` 将退避重试,
141    /// 返回 `JobError::Permanent` 直接进入死信
142    async fn handle(&self, payload: &serde_json::Value) -> Result<(), JobError>;
143}
144
145/// 队列操作错误
146#[derive(Debug, Error)]
147pub enum JobQueueError {
148    /// 数据库错误
149    #[error("database error: {0}")]
150    Db(#[from] DbError),
151    /// 连接池错误
152    #[error("pool error: {0}")]
153    Pool(#[from] PoolError),
154    /// 任务行数据非法
155    #[error("invalid job row: {0}")]
156    InvalidRow(String),
157    /// 序列化错误
158    #[error("json error: {0}")]
159    Json(#[from] serde_json::Error),
160}
161
162/// worker 配置
163#[derive(Debug, Clone)]
164pub struct JobQueueConfig {
165    /// 每轮最多领取的任务数
166    pub batch_size: u32,
167    /// 轮询间隔
168    pub poll_interval: Duration,
169    /// 单任务最大重试次数(超过进入死信)
170    pub max_attempts: u32,
171    /// 指数退避基数(秒):`base * 2^min(attempts, 6)`
172    pub backoff_base_secs: u64,
173    /// 退避上限(秒)
174    pub backoff_cap_secs: u64,
175    /// running 租约时长(秒):worker 崩溃后超时回收
176    pub lease_seconds: u64,
177    /// 退避随机抖动比例(0~1),避免批量失败任务同时冲击下游
178    pub jitter_ratio: f64,
179    /// 单次 handler 执行超时
180    pub handler_timeout: Duration,
181}
182
183impl Default for JobQueueConfig {
184    fn default() -> Self {
185        Self {
186            batch_size: 10,
187            poll_interval: Duration::from_secs(1),
188            max_attempts: 8,
189            backoff_base_secs: 1,
190            backoff_cap_secs: 64,
191            lease_seconds: 60,
192            jitter_ratio: 0.3,
193            handler_timeout: Duration::from_secs(30),
194        }
195    }
196}
197
198/// 任务(数据库行的内存表示)
199#[derive(Debug, Clone)]
200pub struct Job {
201    /// 任务 ID
202    pub id: u64,
203    /// 任务类型(对应注册的 handler)
204    pub kind: String,
205    /// 任务负载(JSON)
206    pub payload: serde_json::Value,
207    /// 当前状态
208    pub status: JobStatus,
209    /// 已尝试次数(领取即 +1)
210    pub attempts: u32,
211    /// 最早可执行时间(毫秒时间戳,UTC)
212    pub run_after: i64,
213    /// 最近一次错误(用于死信排查)
214    pub last_error: Option<String>,
215    /// 幂等键(同 kind 下唯一)
216    pub dedupe_key: Option<String>,
217    /// 创建时间(毫秒时间戳,UTC)
218    pub created_at: i64,
219}
220
221/// 队列健康快照(观测:看队列是否追得上生产,而非单任务成败)
222#[derive(Debug, Clone, Copy, Default)]
223pub struct QueueSnapshot {
224    /// 待执行任务数
225    pub pending: u64,
226    /// 执行中任务数
227    pub running: u64,
228    /// 死信任务数
229    pub dead: u64,
230    /// 累计完成任务数
231    pub succeeded: u64,
232    /// 最老 pending 任务等待秒数(>300s 说明消费追不上生产)
233    pub oldest_pending_seconds: u64,
234}
235
236/// 可靠任务队列
237///
238/// 基于 sz-orm `Pool` 实现,不绑定具体数据库后端(MySQL/PostgreSQL 均可,
239/// 领取用单条 UPDATE 原子抢占,不依赖 `FOR UPDATE SKIP LOCKED` 方言)。
240#[derive(Clone)]
241pub struct JobQueue {
242    pool: Arc<Pool>,
243}
244
245impl JobQueue {
246    /// 创建任务队列
247    pub fn new(pool: Arc<Pool>) -> Self {
248        Self { pool }
249    }
250
251    /// 底层连接池引用(观测/测试用)
252    pub fn pool(&self) -> &Arc<Pool> {
253        &self.pool
254    }
255
256    /// 幂等建表(可安全重复调用)
257    pub async fn init_schema(&self) -> Result<(), JobQueueError> {
258        let mut conn = self.pool.acquire().await?;
259        conn.execute(SCHEMA_SQL).await?;
260        Ok(())
261    }
262
263    /// 入队任务(立即执行)。`dedupe_key` 同 kind 下重复时返回已存在任务 ID,不重复入队。
264    pub async fn enqueue(
265        &self,
266        kind: &str,
267        payload: serde_json::Value,
268        dedupe_key: Option<&str>,
269    ) -> Result<u64, JobQueueError> {
270        self.enqueue_at(kind, payload, dedupe_key, now_ms()).await
271    }
272
273    /// 入队延迟任务(`delay` 后执行)——退避/定时不靠 worker sleep,靠 `run_after`
274    pub async fn enqueue_delayed(
275        &self,
276        kind: &str,
277        payload: serde_json::Value,
278        dedupe_key: Option<&str>,
279        delay: Duration,
280    ) -> Result<u64, JobQueueError> {
281        self.enqueue_at(
282            kind,
283            payload,
284            dedupe_key,
285            now_ms() + delay.as_millis() as i64,
286        )
287        .await
288    }
289
290    /// 入队核心:INSERT + 唯一约束幂等(重复返回已有 ID)
291    async fn enqueue_at(
292        &self,
293        kind: &str,
294        payload: serde_json::Value,
295        dedupe_key: Option<&str>,
296        run_after: i64,
297    ) -> Result<u64, JobQueueError> {
298        let payload_str = serde_json::to_string(&payload)?;
299        let now = now_ms();
300        let mut conn = self.pool.acquire().await?;
301        conn.execute_with_params(
302            "INSERT INTO sz_jobs (kind, payload, status, attempts, run_after, dedupe_key, created_at, updated_at) \
303             VALUES (?, ?, ?, 0, ?, ?, ?, ?) \
304             ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)",
305            &[
306                Value::String(kind.into()),
307                Value::String(payload_str),
308                Value::String(STATUS_PENDING.into()),
309                Value::I64(run_after),
310                dedupe_key.map_or(Value::Null, |k| Value::String(k.into())),
311                Value::I64(now),
312                Value::I64(now),
313            ],
314        )
315        .await?;
316        let rows = conn
317            .query_with_params("SELECT LAST_INSERT_ID() AS id", &[])
318            .await?;
319        rows.first()
320            .and_then(|r| r.get("id"))
321            .and_then(Value::as_i64)
322            .map(|v| v as u64)
323            .ok_or_else(|| JobQueueError::InvalidRow("LAST_INSERT_ID() 返回空".into()))
324    }
325
326    /// 死信重放:将 dead 任务重新置为 pending(保留 attempts 与错误历史)
327    pub async fn retry_dead(&self, job_id: u64) -> Result<(), JobQueueError> {
328        let now = now_ms();
329        let mut conn = self.pool.acquire().await?;
330        conn.execute_with_params(
331            "UPDATE sz_jobs SET status = ?, run_after = ?, locked_until = NULL, updated_at = ? WHERE id = ? AND status = ?",
332            &[
333                Value::String(STATUS_PENDING.into()),
334                Value::I64(now),
335                Value::I64(now),
336                Value::I64(job_id as i64),
337                Value::String(STATUS_DEAD.into()),
338            ],
339        )
340        .await?;
341        Ok(())
342    }
343
344    /// 队列健康快照(pending/running/dead/最老等待/累计完成)
345    pub async fn queue_snapshot(&self) -> Result<QueueSnapshot, JobQueueError> {
346        let mut conn = self.pool.acquire().await?;
347        let rows = conn
348            .query("SELECT status, COUNT(*) AS cnt FROM sz_jobs GROUP BY status")
349            .await?;
350        let mut snap = QueueSnapshot::default();
351        for row in rows {
352            let status = row.get("status").and_then(Value::as_str).unwrap_or("");
353            let cnt = row.get("cnt").and_then(Value::as_i64).unwrap_or(0).max(0) as u64;
354            match status {
355                STATUS_PENDING => snap.pending = cnt,
356                STATUS_RUNNING => snap.running = cnt,
357                STATUS_SUCCEEDED => snap.succeeded = cnt,
358                STATUS_DEAD => snap.dead = cnt,
359                _ => {}
360            }
361        }
362        let rows = conn
363            .query("SELECT MIN(run_after) AS oldest FROM sz_jobs WHERE status = 'pending'")
364            .await?;
365        if let Some(oldest) = rows
366            .first()
367            .and_then(|r| r.get("oldest"))
368            .and_then(Value::as_i64)
369        {
370            snap.oldest_pending_seconds = ((now_ms() - oldest).max(0) / 1000) as u64;
371        }
372        Ok(snap)
373    }
374
375    /// 启动 worker:轮询领取 → 分发 handler → 成功/退避重试/死信。
376    /// `shutdown` 为 true 时退出(优雅关闭)。
377    pub async fn run_worker(
378        &self,
379        handlers: HashMap<String, Arc<dyn TaskHandler>>,
380        config: JobQueueConfig,
381        shutdown: tokio::sync::watch::Receiver<bool>,
382    ) -> Result<(), JobQueueError> {
383        let mut interval = tokio::time::interval(config.poll_interval);
384        loop {
385            interval.tick().await;
386            if *shutdown.borrow() {
387                tracing::info!(target: "sz_orm::jobs", "job worker shutting down");
388                return Ok(());
389            }
390            // 崩溃自愈:租约超时的 running 任务回收重跑
391            if let Err(e) = self.reclaim_stale(&config).await {
392                tracing::error!(target: "sz_orm::jobs", "reclaim stale jobs failed: {e}");
393                continue;
394            }
395            let jobs = match self.claim_batch(&config).await {
396                Ok(jobs) => jobs,
397                Err(e) => {
398                    tracing::error!(target: "sz_orm::jobs", "claim jobs failed: {e}");
399                    continue;
400                }
401            };
402            if jobs.is_empty() {
403                // 队列健康观测(每轮无任务时仅 debug)
404                if let Ok(snap) = self.queue_snapshot().await {
405                    tracing::debug!(
406                        target: "sz_orm::jobs",
407                        "queue snapshot: pending={}, running={}, dead={}, oldest_pending_secs={}",
408                        snap.pending, snap.running, snap.dead, snap.oldest_pending_seconds
409                    );
410                }
411                continue;
412            }
413            for job in jobs {
414                let handler = handlers.get(&job.kind);
415                let outcome = match handler {
416                    Some(h) => {
417                        match tokio::time::timeout(config.handler_timeout, h.handle(&job.payload))
418                            .await
419                        {
420                            Ok(Ok(())) => Ok(()),
421                            Ok(Err(e)) => Err(e),
422                            Err(_) => Err(JobError::Temporary(format!(
423                                "handler timeout after {:?}",
424                                config.handler_timeout
425                            ))),
426                        }
427                    }
428                    None => Err(JobError::Permanent(format!(
429                        "no handler registered for kind '{}'",
430                        job.kind
431                    ))),
432                };
433                match outcome {
434                    Ok(()) => {
435                        self.mark_succeeded(job.id).await?;
436                        tracing::debug!(target: "sz_orm::jobs", "job {} (kind={}) succeeded", job.id, job.kind);
437                    }
438                    Err(e) => {
439                        self.handle_failure(
440                            job.id,
441                            job.attempts,
442                            &e.to_string(),
443                            e.kind(),
444                            &config,
445                        )
446                        .await?;
447                        tracing::warn!(
448                            target: "sz_orm::jobs",
449                            "job {} (kind={}) failed: {} (kind={:?}), attempts={}",
450                            job.id, job.kind, e, e.kind(), job.attempts
451                        );
452                    }
453                }
454            }
455        }
456    }
457
458    /// 崩溃自愈:将租约超时的 running 任务回收为 pending(不丢失、不卡死)
459    async fn reclaim_stale(&self, config: &JobQueueConfig) -> Result<(), JobQueueError> {
460        let now = now_ms();
461        let lease_deadline = now - config.lease_seconds as i64 * 1000;
462        let mut conn = self.pool.acquire().await?;
463        conn.execute_with_params(
464            "UPDATE sz_jobs SET status = ?, locked_until = NULL, updated_at = ? \
465             WHERE status = ? AND locked_until < ?",
466            &[
467                Value::String(STATUS_PENDING.into()),
468                Value::I64(now),
469                Value::String(STATUS_RUNNING.into()),
470                Value::I64(lease_deadline),
471            ],
472        )
473        .await?;
474        Ok(())
475    }
476
477    /// 原子领取一批任务(多 worker 安全:SELECT FOR UPDATE SKIP LOCKED + 事务内 UPDATE)
478    ///
479    /// 为什么不用"单条 UPDATE 抢占":MySQL 默认 REPEATABLE READ 下 InnoDB 的
480    /// UPDATE 锁等待后不重新评估 WHERE(semi-consistent read 仅 READ COMMITTED
481    /// 启用),并发 worker 会更新到另一 worker 已领取的行(实测重复执行 2x)。
482    /// `FOR UPDATE SKIP LOCKED`(MySQL 8.0.1+)在锁定阶段就跳过他人已锁的行,
483    /// 是官方推荐的多 worker 领取方式(同 PostgreSQL 的 SKIP LOCKED 语义)。
484    async fn claim_batch(&self, config: &JobQueueConfig) -> Result<Vec<Job>, JobQueueError> {
485        let now = now_ms();
486        let locked_until = now + config.lease_seconds as i64 * 1000;
487        let mut conn = self.pool.acquire().await?;
488        conn.begin_transaction().await?;
489        // 1. 锁定候选行:SKIP LOCKED 跳过其他 worker 已锁定的行(不等待)
490        let rows = conn
491            .query_with_params(
492                "SELECT id FROM sz_jobs WHERE status = ? AND run_after <= ? \
493                 ORDER BY created_at LIMIT ? FOR UPDATE SKIP LOCKED",
494                &[
495                    Value::String(STATUS_PENDING.into()),
496                    Value::I64(now),
497                    Value::I64(config.batch_size as i64),
498                ],
499            )
500            .await?;
501        let ids: Vec<Value> = rows
502            .iter()
503            .filter_map(|r| r.get("id").and_then(Value::as_i64).map(Value::I64))
504            .collect();
505        // 2. 事务内抢占(行已被本事务锁定,无竞争);IN 占位符数量由代码生成,值全参数化
506        if !ids.is_empty() {
507            let placeholders = vec!["?"; ids.len()].join(",");
508            let mut params = Vec::with_capacity(ids.len() + 3);
509            params.push(Value::String(STATUS_RUNNING.into()));
510            params.push(Value::I64(locked_until));
511            params.push(Value::I64(now));
512            params.extend(ids);
513            conn.execute_with_params(
514                &format!(
515                    "UPDATE sz_jobs SET status = ?, locked_until = ?, attempts = attempts + 1, updated_at = ? \
516                     WHERE id IN ({placeholders})"
517                ),
518                &params,
519            )
520            .await?;
521        }
522        conn.commit().await?;
523        // 3. 读回本 worker 领取的任务(按 locked_until 精确过滤,不捞其他 worker 的)
524        let rows = conn
525            .query_with_params(
526                "SELECT id, kind, payload, status, attempts, run_after, last_error, dedupe_key, created_at \
527                 FROM sz_jobs WHERE status = ? AND locked_until = ? ORDER BY created_at",
528                &[Value::String(STATUS_RUNNING.into()), Value::I64(locked_until)],
529            )
530            .await?;
531        rows.into_iter().map(row_to_job).collect()
532    }
533
534    /// 标记成功
535    async fn mark_succeeded(&self, job_id: u64) -> Result<(), JobQueueError> {
536        let mut conn = self.pool.acquire().await?;
537        conn.execute_with_params(
538            "UPDATE sz_jobs SET status = ?, locked_until = NULL, updated_at = ? WHERE id = ?",
539            &[
540                Value::String(STATUS_SUCCEEDED.into()),
541                Value::I64(now_ms()),
542                Value::I64(job_id as i64),
543            ],
544        )
545        .await?;
546        Ok(())
547    }
548
549    /// 失败处理:Temporary 且未超限 → 退避重试;否则 → 死信
550    async fn handle_failure(
551        &self,
552        job_id: u64,
553        attempts: u32,
554        error: &str,
555        kind: JobErrorKind,
556        config: &JobQueueConfig,
557    ) -> Result<(), JobQueueError> {
558        let now = now_ms();
559        let (status, run_after) = match kind {
560            JobErrorKind::Temporary if attempts <= config.max_attempts => (
561                STATUS_PENDING,
562                now + backoff_delay_ms(config, attempts) as i64,
563            ),
564            _ => (STATUS_DEAD, now),
565        };
566        let mut conn = self.pool.acquire().await?;
567        conn.execute_with_params(
568            "UPDATE sz_jobs SET status = ?, run_after = ?, locked_until = NULL, last_error = ?, updated_at = ? WHERE id = ?",
569            &[
570                Value::String(status.into()),
571                Value::I64(run_after),
572                Value::String(error.into()),
573                Value::I64(now),
574                Value::I64(job_id as i64),
575            ],
576        )
577        .await?;
578        Ok(())
579    }
580}
581
582/// 指数退避(毫秒):`base * 2^min(attempts, 6)`,封顶 `cap`,含随机抖动
583///
584/// `attempts` 为已尝试次数(领取时 +1 后的值)。
585/// 抖动目的:批量失败任务不会同时恢复、同时冲击下游
586pub fn backoff_delay_ms(config: &JobQueueConfig, attempts: u32) -> u64 {
587    let exp = (attempts as i32).min(6);
588    let delay_secs =
589        (config.backoff_base_secs as f64 * 2f64.powi(exp)).min(config.backoff_cap_secs as f64);
590    let jitter = delay_secs * config.jitter_ratio.clamp(0.0, 1.0) * rand::random::<f64>();
591    ((delay_secs + jitter) * 1000.0) as u64
592}
593
594/// 当前毫秒时间戳(UTC)
595pub fn now_ms() -> i64 {
596    SystemTime::now()
597        .duration_since(UNIX_EPOCH)
598        .map(|d| d.as_millis() as i64)
599        .unwrap_or(0)
600}
601
602/// 行 → Job 转换(显式列投影读取)
603fn row_to_job(row: HashMap<String, Value>) -> Result<Job, JobQueueError> {
604    let id = row
605        .get("id")
606        .and_then(Value::as_i64)
607        .ok_or_else(|| JobQueueError::InvalidRow("id".into()))?;
608    let kind = row
609        .get("kind")
610        .and_then(Value::as_str)
611        .ok_or_else(|| JobQueueError::InvalidRow("kind".into()))?
612        .to_string();
613    let payload_str = row
614        .get("payload")
615        .and_then(Value::as_str)
616        .ok_or_else(|| JobQueueError::InvalidRow("payload".into()))?;
617    let payload = serde_json::from_str(payload_str)?;
618    let status = row
619        .get("status")
620        .and_then(Value::as_str)
621        .and_then(JobStatus::parse_status)
622        .ok_or_else(|| JobQueueError::InvalidRow("status".into()))?;
623    let attempts = row
624        .get("attempts")
625        .and_then(Value::as_i64)
626        .unwrap_or(0)
627        .max(0) as u32;
628    let run_after = row.get("run_after").and_then(Value::as_i64).unwrap_or(0);
629    let last_error = row
630        .get("last_error")
631        .and_then(Value::as_str)
632        .map(str::to_string);
633    let dedupe_key = row
634        .get("dedupe_key")
635        .and_then(Value::as_str)
636        .map(str::to_string);
637    let created_at = row.get("created_at").and_then(Value::as_i64).unwrap_or(0);
638    Ok(Job {
639        id: id as u64,
640        kind,
641        payload,
642        status,
643        attempts,
644        run_after,
645        last_error,
646        dedupe_key,
647        created_at,
648    })
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn test_job_status_roundtrip() {
657        for s in [
658            JobStatus::Pending,
659            JobStatus::Running,
660            JobStatus::Succeeded,
661            JobStatus::Dead,
662        ] {
663            assert_eq!(JobStatus::parse_status(s.as_str()), Some(s));
664        }
665        assert_eq!(JobStatus::parse_status("unknown"), None);
666    }
667
668    #[test]
669    fn test_backoff_delay_respects_cap() {
670        let config = JobQueueConfig {
671            max_attempts: 10,
672            backoff_base_secs: 1,
673            backoff_cap_secs: 64,
674            jitter_ratio: 0.0,
675            ..JobQueueConfig::default()
676        };
677        // attempts=10 → exp 封顶 6 → 2^6 = 64s;抖动 0 时精确
678        let delay = backoff_delay_ms(&config, 10);
679        assert!((delay as f64 - 64_000.0).abs() < 1.0, "delay={delay}");
680    }
681
682    #[test]
683    fn test_backoff_delay_jitter_range() {
684        let config = JobQueueConfig {
685            max_attempts: 8,
686            backoff_base_secs: 1,
687            backoff_cap_secs: 64,
688            jitter_ratio: 0.5,
689            ..JobQueueConfig::default()
690        };
691        // attempts=2 → 2^2 = 4s,抖动 0~50% 上浮 → 4~6s
692        for _ in 0..50 {
693            let delay = backoff_delay_ms(&config, 2);
694            assert!(delay >= 4_000, "delay={delay}");
695            assert!(delay <= 6_000, "delay={delay}");
696        }
697    }
698
699    #[test]
700    fn test_backoff_delay_escalation() {
701        let config = JobQueueConfig {
702            max_attempts: 8,
703            backoff_base_secs: 1,
704            backoff_cap_secs: 64,
705            jitter_ratio: 0.0,
706            ..JobQueueConfig::default()
707        };
708        // attempts=3 → 2^3 = 8s
709        let delay = backoff_delay_ms(&config, 3);
710        assert!((delay as f64 - 8_000.0).abs() < 1.0, "delay={delay}");
711    }
712
713    #[test]
714    fn test_now_ms_monotonic() {
715        let a = now_ms();
716        std::thread::sleep(Duration::from_millis(5));
717        let b = now_ms();
718        assert!(b > a);
719    }
720
721    #[test]
722    fn test_job_error_kind() {
723        let t = JobError::Temporary("downstream 503".into());
724        assert_eq!(t.kind(), JobErrorKind::Temporary);
725        let p = JobError::Permanent("user not found".into());
726        assert_eq!(p.kind(), JobErrorKind::Permanent);
727    }
728
729    fn make_row() -> HashMap<String, Value> {
730        let mut row = HashMap::new();
731        row.insert("id".into(), Value::I64(42));
732        row.insert("kind".into(), Value::String("email".into()));
733        row.insert("payload".into(), Value::String(r#"{"to":"a@b"}"#.into()));
734        row.insert("status".into(), Value::String("pending".into()));
735        row.insert("attempts".into(), Value::I64(3));
736        row.insert("run_after".into(), Value::I64(1000));
737        row.insert("last_error".into(), Value::String("timeout".into()));
738        row.insert("dedupe_key".into(), Value::String("k1".into()));
739        row.insert("created_at".into(), Value::I64(500));
740        row
741    }
742
743    #[test]
744    fn test_row_to_job_success() {
745        let job = row_to_job(make_row()).unwrap();
746        assert_eq!(job.id, 42);
747        assert_eq!(job.kind, "email");
748        assert_eq!(job.status, JobStatus::Pending);
749        assert_eq!(job.attempts, 3);
750        assert_eq!(job.run_after, 1000);
751        assert_eq!(job.last_error.as_deref(), Some("timeout"));
752        assert_eq!(job.dedupe_key.as_deref(), Some("k1"));
753        assert_eq!(job.created_at, 500);
754    }
755
756    #[test]
757    fn test_row_to_job_missing_id() {
758        let mut row = make_row();
759        row.remove("id");
760        let err = row_to_job(row).unwrap_err();
761        assert!(matches!(err, JobQueueError::InvalidRow(_)));
762    }
763
764    #[test]
765    fn test_row_to_job_missing_kind() {
766        let mut row = make_row();
767        row.remove("kind");
768        let err = row_to_job(row).unwrap_err();
769        assert!(matches!(err, JobQueueError::InvalidRow(_)));
770    }
771
772    #[test]
773    fn test_row_to_job_missing_payload() {
774        let mut row = make_row();
775        row.remove("payload");
776        let err = row_to_job(row).unwrap_err();
777        assert!(matches!(err, JobQueueError::InvalidRow(_)));
778    }
779
780    #[test]
781    fn test_row_to_job_invalid_status() {
782        let mut row = make_row();
783        row.insert("status".into(), Value::String("unknown".into()));
784        let err = row_to_job(row).unwrap_err();
785        assert!(matches!(err, JobQueueError::InvalidRow(_)));
786    }
787
788    #[test]
789    fn test_row_to_job_invalid_payload_json() {
790        let mut row = make_row();
791        row.insert("payload".into(), Value::String("{bad json".into()));
792        let err = row_to_job(row).unwrap_err();
793        assert!(matches!(err, JobQueueError::Json(_)));
794    }
795
796    #[test]
797    fn test_row_to_job_optional_fields_default() {
798        let mut row = make_row();
799        row.remove("last_error");
800        row.remove("dedupe_key");
801        row.remove("attempts");
802        row.remove("run_after");
803        row.remove("created_at");
804        let job = row_to_job(row).unwrap();
805        assert_eq!(job.attempts, 0);
806        assert_eq!(job.run_after, 0);
807        assert!(job.last_error.is_none());
808        assert!(job.dedupe_key.is_none());
809        assert_eq!(job.created_at, 0);
810    }
811
812    #[test]
813    fn test_job_queue_config_default() {
814        let config = JobQueueConfig::default();
815        assert_eq!(config.batch_size, 10);
816        assert_eq!(config.max_attempts, 8);
817        assert_eq!(config.backoff_base_secs, 1);
818        assert_eq!(config.backoff_cap_secs, 64);
819        assert_eq!(config.lease_seconds, 60);
820    }
821
822    // ---- mock Pool 用于覆盖 JobQueue 构造与访问器 ----
823
824    use std::future::Future;
825    use std::pin::Pin;
826    use sz_orm_core::{Connection, ConnectionFactory, PoolConfig, QueryRows};
827
828    struct MockConnection;
829
830    impl Connection for MockConnection {
831        fn execute<'a>(
832            &'a mut self,
833            _sql: &'a str,
834        ) -> Pin<Box<dyn Future<Output = Result<u64, DbError>> + Send + 'a>> {
835            Box::pin(async { Ok(0) })
836        }
837        fn query<'a>(
838            &'a mut self,
839            _sql: &'a str,
840        ) -> Pin<Box<dyn Future<Output = Result<QueryRows, DbError>> + Send + 'a>> {
841            Box::pin(async { Ok(vec![]) })
842        }
843        fn execute_with_params<'a>(
844            &'a mut self,
845            _sql: &'a str,
846            _params: &'a [Value],
847        ) -> Pin<Box<dyn Future<Output = Result<u64, DbError>> + Send + 'a>> {
848            Box::pin(async { Ok(1) })
849        }
850        fn query_with_params<'a>(
851            &'a mut self,
852            sql: &'a str,
853            _params: &'a [Value],
854        ) -> Pin<Box<dyn Future<Output = Result<QueryRows, DbError>> + Send + 'a>> {
855            Box::pin(async move {
856                if sql.contains("LAST_INSERT_ID") {
857                    let mut row = HashMap::new();
858                    row.insert("id".into(), Value::I64(1));
859                    Ok(vec![row])
860                } else {
861                    Ok(vec![])
862                }
863            })
864        }
865        fn begin_transaction<'a>(
866            &'a mut self,
867        ) -> Pin<Box<dyn Future<Output = Result<(), DbError>> + Send + 'a>> {
868            Box::pin(async { Ok(()) })
869        }
870        fn commit<'a>(
871            &'a mut self,
872        ) -> Pin<Box<dyn Future<Output = Result<(), DbError>> + Send + 'a>> {
873            Box::pin(async { Ok(()) })
874        }
875        fn rollback<'a>(
876            &'a mut self,
877        ) -> Pin<Box<dyn Future<Output = Result<(), DbError>> + Send + 'a>> {
878            Box::pin(async { Ok(()) })
879        }
880        fn is_connected(&self) -> bool {
881            true
882        }
883        fn ping<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
884            Box::pin(async { true })
885        }
886        fn close<'a>(
887            &'a mut self,
888        ) -> Pin<Box<dyn Future<Output = Result<(), DbError>> + Send + 'a>> {
889            Box::pin(async { Ok(()) })
890        }
891    }
892
893    struct MockConnectionFactory;
894
895    #[async_trait]
896    impl ConnectionFactory for MockConnectionFactory {
897        async fn create(&self) -> Result<Box<dyn Connection>, DbError> {
898            Ok(Box::new(MockConnection))
899        }
900    }
901
902    fn make_mock_pool() -> Arc<Pool> {
903        let config = PoolConfig::default();
904        let factory: Arc<dyn ConnectionFactory> = Arc::new(MockConnectionFactory);
905        Arc::new(Pool::new(config, factory).expect("mock pool creation should not fail"))
906    }
907
908    #[test]
909    fn test_job_queue_new_and_pool() {
910        let pool = make_mock_pool();
911        let queue = JobQueue::new(pool.clone());
912        assert!(Arc::ptr_eq(queue.pool(), &pool));
913    }
914
915    #[tokio::test]
916    async fn test_job_queue_init_schema() {
917        let queue = JobQueue::new(make_mock_pool());
918        let result = queue.init_schema().await;
919        assert!(result.is_ok());
920    }
921
922    #[tokio::test]
923    async fn test_job_queue_enqueue() {
924        let queue = JobQueue::new(make_mock_pool());
925        let id = queue
926            .enqueue("email", serde_json::json!({"to": "a@b"}), None)
927            .await
928            .unwrap();
929        assert_eq!(id, 1);
930    }
931
932    #[tokio::test]
933    async fn test_job_queue_enqueue_with_dedupe() {
934        let queue = JobQueue::new(make_mock_pool());
935        let id = queue
936            .enqueue("email", serde_json::json!({}), Some("k1"))
937            .await
938            .unwrap();
939        assert_eq!(id, 1);
940    }
941
942    #[tokio::test]
943    async fn test_job_queue_enqueue_delayed() {
944        let queue = JobQueue::new(make_mock_pool());
945        let id = queue
946            .enqueue_delayed(
947                "email",
948                serde_json::json!({}),
949                None,
950                Duration::from_secs(60),
951            )
952            .await
953            .unwrap();
954        assert_eq!(id, 1);
955    }
956
957    #[tokio::test]
958    async fn test_job_queue_retry_dead() {
959        let queue = JobQueue::new(make_mock_pool());
960        let result = queue.retry_dead(42).await;
961        assert!(result.is_ok());
962    }
963
964    #[tokio::test]
965    async fn test_job_queue_snapshot() {
966        let queue = JobQueue::new(make_mock_pool());
967        let snap = queue.queue_snapshot().await.unwrap();
968        assert_eq!(snap.pending, 0);
969        assert_eq!(snap.running, 0);
970    }
971}