1use 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
38pub const JOBS_TABLE: &str = "sz_jobs";
40
41const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum JobStatus {
71 Pending,
73 Running,
75 Succeeded,
77 Dead,
79}
80
81impl JobStatus {
82 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum JobErrorKind {
110 Temporary,
112 Permanent,
114}
115
116#[derive(Debug, Error)]
118pub enum JobError {
119 #[error("temporary job failure: {0}")]
121 Temporary(String),
122 #[error("permanent job failure: {0}")]
124 Permanent(String),
125}
126
127impl JobError {
128 pub fn kind(&self) -> JobErrorKind {
130 match self {
131 JobError::Temporary(_) => JobErrorKind::Temporary,
132 JobError::Permanent(_) => JobErrorKind::Permanent,
133 }
134 }
135}
136
137#[async_trait]
139pub trait TaskHandler: Send + Sync + 'static {
140 async fn handle(&self, payload: &serde_json::Value) -> Result<(), JobError>;
143}
144
145#[derive(Debug, Error)]
147pub enum JobQueueError {
148 #[error("database error: {0}")]
150 Db(#[from] DbError),
151 #[error("pool error: {0}")]
153 Pool(#[from] PoolError),
154 #[error("invalid job row: {0}")]
156 InvalidRow(String),
157 #[error("json error: {0}")]
159 Json(#[from] serde_json::Error),
160}
161
162#[derive(Debug, Clone)]
164pub struct JobQueueConfig {
165 pub batch_size: u32,
167 pub poll_interval: Duration,
169 pub max_attempts: u32,
171 pub backoff_base_secs: u64,
173 pub backoff_cap_secs: u64,
175 pub lease_seconds: u64,
177 pub jitter_ratio: f64,
179 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#[derive(Debug, Clone)]
200pub struct Job {
201 pub id: u64,
203 pub kind: String,
205 pub payload: serde_json::Value,
207 pub status: JobStatus,
209 pub attempts: u32,
211 pub run_after: i64,
213 pub last_error: Option<String>,
215 pub dedupe_key: Option<String>,
217 pub created_at: i64,
219}
220
221#[derive(Debug, Clone, Copy, Default)]
223pub struct QueueSnapshot {
224 pub pending: u64,
226 pub running: u64,
228 pub dead: u64,
230 pub succeeded: u64,
232 pub oldest_pending_seconds: u64,
234}
235
236#[derive(Clone)]
241pub struct JobQueue {
242 pool: Arc<Pool>,
243}
244
245impl JobQueue {
246 pub fn new(pool: Arc<Pool>) -> Self {
248 Self { pool }
249 }
250
251 pub fn pool(&self) -> &Arc<Pool> {
253 &self.pool
254 }
255
256 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 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 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 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 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 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 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 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 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 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 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 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 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 ¶ms,
519 )
520 .await?;
521 }
522 conn.commit().await?;
523 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 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 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
582pub 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
594pub 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
602fn 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 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 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 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 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}