rapina 0.11.0

A fast, type-safe web framework for Rust inspired by FastAPI
Documentation
use sea_orm::FromQueryResult;
use sea_orm::prelude::DateTimeWithTimeZone;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Status of a background job in the processing lifecycle.
///
/// Jobs move through these states:
/// `Pending` → `Running` → `Completed` or `Failed`.
///
/// A failed job may return to `Pending` if it has remaining retries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
    /// Queued and waiting to be claimed by a worker.
    Pending,
    /// Claimed by a worker and currently executing.
    Running,
    /// Finished successfully.
    Completed,
    /// Exhausted all retries or encountered a fatal error.
    Failed,
}

impl std::fmt::Display for JobStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            JobStatus::Pending => "pending",
            JobStatus::Running => "running",
            JobStatus::Completed => "completed",
            JobStatus::Failed => "failed",
        };
        f.write_str(s)
    }
}

impl std::str::FromStr for JobStatus {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "pending" => Ok(JobStatus::Pending),
            "running" => Ok(JobStatus::Running),
            "completed" => Ok(JobStatus::Completed),
            "failed" => Ok(JobStatus::Failed),
            other => Err(format!("unknown job status: {other}")),
        }
    }
}

/// A row from the `rapina_jobs` table, deserialized via SeaORM's
/// [`FromQueryResult`].
///
/// The `status` field is stored as a plain `String` for `FromQueryResult`
/// compatibility. Use [`parse_status()`](Self::parse_status) to convert
/// it into a typed [`JobStatus`].
#[derive(Debug, Clone, Serialize, Deserialize, FromQueryResult)]
pub struct JobRow {
    /// Unique job identifier (UUID v4, generated by the database).
    pub id: Uuid,
    /// Logical queue name. Workers subscribe to specific queues.
    pub queue: String,
    /// Fully-qualified type name used to dispatch the job to its handler.
    pub job_type: String,
    /// Arbitrary JSON payload passed to the job handler.
    pub payload: serde_json::Value,
    /// Current lifecycle state. Parse with [`parse_status()`](Self::parse_status).
    pub status: String,
    /// Number of times this job has been attempted.
    pub attempts: i32,
    /// Maximum number of retries before the job is marked as failed.
    pub max_retries: i32,
    /// Earliest time at which the job should be executed.
    pub run_at: DateTimeWithTimeZone,
    /// When a worker started processing this job.
    pub started_at: Option<DateTimeWithTimeZone>,
    /// Lease expiry for crash recovery. If a worker dies, jobs with
    /// expired `locked_until` can be reclaimed by another worker.
    pub locked_until: Option<DateTimeWithTimeZone>,
    /// When the job completed or permanently failed.
    pub finished_at: Option<DateTimeWithTimeZone>,
    /// Error message from the most recent failed attempt.
    pub last_error: Option<String>,
    /// Distributed trace identifier, propagated from the request that
    /// enqueued the job.
    pub trace_id: Option<String>,
    /// When the job was first inserted into the queue.
    pub created_at: DateTimeWithTimeZone,
}

impl JobRow {
    /// Parses the `status` field into a typed [`JobStatus`].
    pub fn parse_status(&self) -> Result<JobStatus, String> {
        self.status.parse()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const ALL_STATUSES: &[JobStatus] = &[
        JobStatus::Pending,
        JobStatus::Running,
        JobStatus::Completed,
        JobStatus::Failed,
    ];

    #[test]
    fn display_produces_lowercase() {
        let expected = [
            (JobStatus::Pending, "pending"),
            (JobStatus::Running, "running"),
            (JobStatus::Completed, "completed"),
            (JobStatus::Failed, "failed"),
        ];
        for (status, label) in expected {
            assert_eq!(status.to_string(), label);
        }
    }

    #[test]
    fn from_str_parses_all_variants() {
        for &status in ALL_STATUSES {
            let parsed: JobStatus = status.to_string().parse().unwrap();
            assert_eq!(parsed, status);
        }
    }

    #[test]
    fn display_from_str_roundtrip() {
        for &status in ALL_STATUSES {
            let s = status.to_string();
            let back: JobStatus = s.parse().unwrap();
            assert_eq!(back, status, "roundtrip failed for {s}");
        }
    }

    #[test]
    fn from_str_rejects_unknown() {
        let err = "nope".parse::<JobStatus>().unwrap_err();
        assert!(err.contains("nope"), "error should include the bad value");
    }

    #[test]
    fn from_str_rejects_uppercase() {
        assert!("Pending".parse::<JobStatus>().is_err());
        assert!("RUNNING".parse::<JobStatus>().is_err());
    }

    #[test]
    fn serde_roundtrip_all_variants() {
        for &status in ALL_STATUSES {
            let json = serde_json::to_string(&status).unwrap();
            let back: JobStatus = serde_json::from_str(&json).unwrap();
            assert_eq!(back, status, "serde roundtrip failed for {json}");
        }
    }

    #[test]
    fn serde_uses_lowercase() {
        assert_eq!(
            serde_json::to_string(&JobStatus::Pending).unwrap(),
            r#""pending""#
        );
        assert_eq!(
            serde_json::to_string(&JobStatus::Completed).unwrap(),
            r#""completed""#
        );
    }

    fn make_row(status: &str) -> JobRow {
        use sea_orm::prelude::DateTimeWithTimeZone;

        let now: DateTimeWithTimeZone = "2026-01-01T00:00:00+00:00".parse().unwrap();
        JobRow {
            id: Uuid::nil(),
            queue: "default".into(),
            job_type: "send_email".into(),
            payload: serde_json::json!({}),
            status: status.into(),
            attempts: 0,
            max_retries: 3,
            run_at: now,
            started_at: None,
            locked_until: None,
            finished_at: None,
            last_error: None,
            trace_id: None,
            created_at: now,
        }
    }

    #[test]
    fn parse_status_valid() {
        let row = make_row("completed");
        assert_eq!(row.parse_status().unwrap(), JobStatus::Completed);
    }

    #[test]
    fn parse_status_invalid() {
        let row = make_row("exploded");
        let err = row.parse_status().unwrap_err();
        assert!(err.contains("exploded"));
    }
}