runledger-postgres 0.4.0

PostgreSQL persistence layer for the Runledger durable job and workflow system
Documentation
use crate::{Error, QueryError, QueryErrorCategory, Result};

use super::types::JOB_LIST_PAGE_LIMIT_MAX;

fn invalid_pagination_error(detail: String) -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.invalid_pagination",
        "Pagination limit and offset are invalid.",
        detail,
    ))
}

pub(super) fn validate_page_limit(limit: i64) -> Result<()> {
    if limit <= 0 {
        return Err(invalid_pagination_error(format!(
            "limit must be greater than zero, got {limit}"
        )));
    }

    if limit > JOB_LIST_PAGE_LIMIT_MAX {
        return Err(invalid_pagination_error(format!(
            "limit must be less than or equal to {JOB_LIST_PAGE_LIMIT_MAX}, got {limit}"
        )));
    }

    Ok(())
}

pub(super) fn validate_pagination(limit: i64, offset: i64) -> Result<()> {
    validate_page_limit(limit)?;

    if offset < 0 {
        return Err(invalid_pagination_error(format!(
            "offset must be greater than or equal to zero, got {offset}"
        )));
    }

    Ok(())
}

pub(super) fn invalid_job_state_error() -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.invalid_state_transition",
        "Job cannot transition from its current state.",
        "job state transition precondition failed",
    ))
}

pub(super) fn job_not_found_error() -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.not_found",
        "Job was not found.",
        "job lookup failed",
    ))
}

pub(super) fn lease_owner_mismatch_error() -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Forbidden,
        "job.lease_owner_mismatch",
        "Job lease is not currently held by this worker.",
        "job lease owner mismatch, missing lease, or expired lease",
    ))
}

pub(super) fn validate_positive_lease_duration(lease_duration_seconds: i32) -> Result<()> {
    if lease_duration_seconds > 0 {
        return Ok(());
    }

    Err(Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.invalid_lease_duration",
        "Job lease duration must be positive.",
        format!("lease_duration_seconds must be greater than zero, got {lease_duration_seconds}"),
    )))
}

fn invalid_retry_delay_error(detail: String) -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.invalid_retry_delay",
        "Job retry delay must be positive.",
        detail,
    ))
}

pub(super) fn validate_positive_retry_delay(retry_delay_ms: i32) -> Result<()> {
    if retry_delay_ms > 0 {
        return Ok(());
    }

    Err(invalid_retry_delay_error(format!(
        "retry_delay_ms must be greater than zero, got {retry_delay_ms}"
    )))
}

pub(super) fn require_positive_retry_delay(retry_delay_ms: Option<i32>) -> Result<i32> {
    let Some(retry_delay_ms) = retry_delay_ms else {
        return Err(invalid_retry_delay_error(
            "retry_delay_ms is required and must be greater than zero".to_string(),
        ));
    };

    validate_positive_retry_delay(retry_delay_ms)?;
    Ok(retry_delay_ms)
}

fn invalid_completion_progress_error(detail: String) -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.invalid_completion_progress",
        "Job completion progress is invalid.",
        detail,
    ))
}

pub(super) fn validate_completion_progress(
    progress_done: Option<i64>,
    progress_total: Option<i64>,
) -> Result<()> {
    if let Some(progress_done) = progress_done
        && progress_done < 0
    {
        return Err(invalid_completion_progress_error(format!(
            "progress_done must be greater than or equal to zero, got {progress_done}"
        )));
    }

    if let Some(progress_total) = progress_total
        && progress_total < 0
    {
        return Err(invalid_completion_progress_error(format!(
            "progress_total must be greater than or equal to zero, got {progress_total}"
        )));
    }

    if let (Some(progress_done), Some(progress_total)) = (progress_done, progress_total)
        && progress_done > progress_total
    {
        return Err(invalid_completion_progress_error(format!(
            "progress_done must not exceed progress_total, got progress_done={progress_done}, progress_total={progress_total}"
        )));
    }

    Ok(())
}

pub(super) fn unstarted_claim_release_not_applicable_error() -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.unstarted_claim_release_not_applicable",
        "Job claim cannot be released as unstarted.",
        "job claim is not eligible for unstarted release",
    ))
}

pub(super) fn runtime_config_not_found_error(job_type: &str) -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.runtime_config_not_found",
        "Job runtime configuration was not found.",
        format!("required runtime config missing for job type: {job_type}"),
    ))
}

pub(super) fn workflow_requeue_not_supported_error() -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job.workflow_requeue_not_supported",
        "Workflow-managed jobs cannot be requeued directly.",
        "workflow managed job requeue is not supported",
    ))
}