13.4 KiB · text History 79bf34b
//! Failed-job retry surface.
//!
//! Lists every `upload_jobs` row in `failed` state for the authenticated
//! user and exposes a "retry all" + per-job retry. Retry is mechanically
//! identical to the existing `enqueue_job` flow: flip the row back to
//! pending and republish a `JobMessage` to RabbitMQ.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{Html, Redirect},
    Json,
};
use minijinja::context;
use sea_orm::*;
use serde::Serialize;
use tracing::{error, info};
use uuid::Uuid;

use crate::entities::{upload_jobs, uploads};
use crate::jobs;
use crate::middleware::UserContext;
use crate::queue::{
    publish_job, queue_depths, requeue_all_dlqs, requeue_dlq, JobKind, JobMessage, QueueDepth,
};
use crate::AppState;

#[derive(Debug, Serialize, Clone)]
pub struct FailedRow {
    pub job_id: Uuid,
    pub upload_id: Uuid,
    pub kind: String,
    pub attempts: i32,
    pub error: Option<String>,
    pub updated_at: chrono::DateTime<chrono::FixedOffset>,
    pub file_name_original: String,
    pub item_path: String,
}

/// Pull every failed job for the user, joined to the parent upload so the
/// listing has filenames + thumbnails to render.
async fn load_failed(
    db: &DatabaseConnection,
    user_id: Uuid,
) -> Result<Vec<FailedRow>, DbErr> {
    let rows = upload_jobs::Entity::find()
        .filter(upload_jobs::Column::Status.eq(jobs::STATUS_FAILED))
        .find_also_related(uploads::Entity)
        .order_by_desc(upload_jobs::Column::UpdatedAt)
        .all(db)
        .await?;
    let mut out = Vec::with_capacity(rows.len());
    for (job, upload) in rows {
        let Some(upload) = upload else { continue };
        if upload.user_id != user_id {
            continue;
        }
        out.push(FailedRow {
            job_id: job.id,
            upload_id: job.upload_id,
            kind: job.kind,
            attempts: job.attempts,
            error: job.error,
            updated_at: job.updated_at,
            file_name_original: upload.file_name_original,
            item_path: upload.item_path,
        });
    }
    Ok(out)
}

#[derive(Debug, Serialize, Clone)]
pub struct CompletedRow {
    pub upload_id: Uuid,
    pub kind: String,
    pub completed_at: Option<chrono::DateTime<chrono::FixedOffset>>,
    pub file_name_original: String,
    pub item_path: String,
}

/// Last N completed (terminal-success) jobs per kind, scoped to the user.
/// Pulled per kind so a single noisy queue doesn't crowd the others out;
/// 5 kinds × `per_kind` rows total.
async fn load_recent_completed(
    db: &DatabaseConnection,
    user_id: Uuid,
    per_kind: u64,
) -> Result<std::collections::BTreeMap<String, Vec<CompletedRow>>, DbErr> {
    let mut by_kind = std::collections::BTreeMap::new();
    for kind in crate::queue::ALL_KINDS {
        let kind_str = kind.as_str().to_string();
        // Pull a generous slice and filter by upload-owner in Rust — joining
        // through find_also_related is the same shape as load_failed and
        // keeps the post-filter step uniform across the page.
        let rows = upload_jobs::Entity::find()
            .filter(upload_jobs::Column::Status.eq(jobs::STATUS_COMPLETE))
            .filter(upload_jobs::Column::Kind.eq(kind_str.clone()))
            .find_also_related(uploads::Entity)
            .order_by_desc(upload_jobs::Column::CompletedAt)
            .limit(per_kind * 5) // headroom for non-owner rows
            .all(db)
            .await?;
        let mut taken: Vec<CompletedRow> = Vec::new();
        for (job, upload) in rows {
            let Some(upload) = upload else { continue };
            if upload.user_id != user_id {
                continue;
            }
            taken.push(CompletedRow {
                upload_id: job.upload_id,
                kind: job.kind,
                completed_at: job.completed_at,
                file_name_original: upload.file_name_original,
                item_path: upload.item_path,
            });
            if taken.len() as u64 >= per_kind {
                break;
            }
        }
        by_kind.insert(kind_str, taken);
    }
    Ok(by_kind)
}

async fn load_queue_depths(state: &AppState) -> Vec<QueueDepth> {
    match crate::queue::connect(&state.config.amqp_url).await {
        Ok(conn) => match queue_depths(&conn).await {
            Ok(d) => d,
            Err(e) => {
                error!(error = %e, "queue_depths failed");
                Vec::new()
            }
        },
        Err(e) => {
            error!(error = %e, "amqp connect for queue_depths failed");
            Vec::new()
        }
    }
}

pub async fn show_failed(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Html<String>, StatusCode> {
    let user_id = user.user.id;
    let rows = load_failed(state.db.as_ref(), user_id).await.map_err(|e| {
        error!(error = %e, "load failed jobs failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let recent = load_recent_completed(state.db.as_ref(), user_id, 3).await.map_err(|e| {
        error!(error = %e, "load recent completed jobs failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let depths = load_queue_depths(&state).await;
    let template = state.env.get_template("failed.html").map_err(|e| {
        error!(error = %e, "load failed.html failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let rendered = template
        .render(context! {
            title => "Failed jobs",
            user => user.user,
            rows => rows,
            recent => recent,
            depths => depths,
        })
        .map_err(|e| {
            error!(error = %e, "render failed.html failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
    Ok(Html(rendered))
}

pub async fn api_list_failed(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let user_id = user.user.id;
    let rows = load_failed(state.db.as_ref(), user_id).await.map_err(|e| {
        error!(error = %e, "load failed jobs failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let recent = load_recent_completed(state.db.as_ref(), user_id, 3).await.map_err(|e| {
        error!(error = %e, "load recent completed jobs failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let depths = load_queue_depths(&state).await;
    Ok(Json(serde_json::json!({
        "failed": { "total": rows.len(), "items": rows },
        "recent_completed": recent,
        "queues": depths,
    })))
}

/// Enqueue retries for every failed job belonging to the user. Returns the
/// number of jobs that were queued; failures to publish individual messages
/// are logged but don't fail the whole batch.
async fn retry_failed_for(
    state: &AppState,
    user_id: Uuid,
) -> Result<usize, StatusCode> {
    let rows = load_failed(state.db.as_ref(), user_id).await.map_err(|e| {
        error!(error = %e, "load failed jobs failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    if rows.is_empty() {
        return Ok(0);
    }
    let conn = crate::queue::connect(&state.config.amqp_url).await.map_err(|e| {
        error!(error = %e, "amqp connect failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let mut requeued = 0usize;
    for row in rows {
        let Some(kind) = JobKind::from_name(&row.kind) else {
            error!(kind = %row.kind, "unknown job kind on failed row, skipping");
            continue;
        };
        if let Err(e) = jobs::upsert_pending(state.db.as_ref(), row.upload_id, kind).await {
            error!(error = %e, upload_id = %row.upload_id, kind = ?kind, "upsert pending failed during retry");
            continue;
        }
        let msg = JobMessage::new(kind, row.upload_id, user_id);
        if let Err(e) = publish_job(&conn, &msg).await {
            error!(error = %e, upload_id = %row.upload_id, kind = ?kind, "publish failed during retry");
            continue;
        }
        requeued += 1;
    }
    info!(user_id = %user_id, requeued, "retry-all-failed completed");
    Ok(requeued)
}

pub async fn retry_all_failed(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Redirect, StatusCode> {
    let n = retry_failed_for(&state, user.user.id).await?;
    let msg = if n == 0 {
        "No failed jobs to retry.".to_string()
    } else {
        format!("Re-queued {n} failed job(s).")
    };
    let encoded = urlencode(&msg);
    Ok(Redirect::to(&format!("/failed?notice={encoded}")))
}

pub async fn api_retry_all_failed(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let n = retry_failed_for(&state, user.user.id).await?;
    Ok(Json(serde_json::json!({ "requeued": n })))
}

/// Retry one specific failed (upload, kind) row. The upload must belong to
/// the caller; non-failed rows still requeue (idempotent — same effect as
/// the existing /uploads/{id}/jobs endpoint with no `force` flag).
pub async fn retry_one(
    State(state): State<AppState>,
    user: UserContext,
    Path((upload_id, kind_str)): Path<(Uuid, String)>,
) -> Result<Redirect, StatusCode> {
    let upload = uploads::Entity::find_by_id(upload_id)
        .filter(uploads::Column::UserId.eq(user.user.id))
        .one(state.db.as_ref())
        .await
        .map_err(|e| {
            error!(error = %e, "retry_one: find upload failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?
        .ok_or(StatusCode::NOT_FOUND)?;
    let kind = JobKind::from_name(&kind_str).ok_or(StatusCode::BAD_REQUEST)?;
    if let Err(e) = jobs::upsert_pending(state.db.as_ref(), upload.id, kind).await {
        error!(error = %e, "upsert pending failed");
        return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
    let conn = crate::queue::connect(&state.config.amqp_url).await.map_err(|e| {
        error!(error = %e, "amqp connect failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let msg = JobMessage::new(kind, upload.id, upload.user_id);
    publish_job(&conn, &msg).await.map_err(|e| {
        error!(error = %e, "publish failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    info!(upload_id = %upload.id, kind = ?kind, "single-failed retry queued");
    Ok(Redirect::to("/failed?notice=Retry+queued."))
}

/// Drain every per-kind DLQ back into its main queue and redirect to the
/// Work page. Operates broker-globally (DLQs aren't user-scoped — neither
/// is the depth display).
pub async fn requeue_all_dlqs_html(
    State(state): State<AppState>,
    _user: UserContext,
) -> Result<Redirect, StatusCode> {
    let conn = crate::queue::connect(&state.config.amqp_url).await.map_err(|e| {
        error!(error = %e, "amqp connect failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let n = requeue_all_dlqs(&conn).await.map_err(|e| {
        error!(error = %e, "requeue_all_dlqs failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    info!(moved = n, "requeue all DLQs");
    let msg = if n == 0 {
        "No DLQ messages to requeue.".to_string()
    } else {
        format!("Requeued {n} DLQ message(s).")
    };
    Ok(Redirect::to(&format!("/failed?notice={}", urlencode(&msg))))
}

pub async fn api_requeue_all_dlqs(
    State(state): State<AppState>,
    _user: UserContext,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let conn = crate::queue::connect(&state.config.amqp_url).await.map_err(|e| {
        error!(error = %e, "amqp connect failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let n = requeue_all_dlqs(&conn).await.map_err(|e| {
        error!(error = %e, "requeue_all_dlqs failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    Ok(Json(serde_json::json!({ "requeued": n })))
}

pub async fn requeue_dlq_kind_html(
    State(state): State<AppState>,
    _user: UserContext,
    Path(kind_str): Path<String>,
) -> Result<Redirect, StatusCode> {
    let kind = JobKind::from_name(&kind_str).ok_or(StatusCode::BAD_REQUEST)?;
    let conn = crate::queue::connect(&state.config.amqp_url).await.map_err(|e| {
        error!(error = %e, "amqp connect failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let n = requeue_dlq(&conn, kind).await.map_err(|e| {
        error!(error = %e, kind = ?kind, "requeue_dlq failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    info!(moved = n, kind = ?kind, "requeue one DLQ");
    let msg = if n == 0 {
        format!("No DLQ messages for {} to requeue.", kind.as_str())
    } else {
        format!("Requeued {n} {} DLQ message(s).", kind.as_str())
    };
    Ok(Redirect::to(&format!("/failed?notice={}", urlencode(&msg))))
}

pub async fn api_requeue_dlq_kind(
    State(state): State<AppState>,
    _user: UserContext,
    Path(kind_str): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let kind = JobKind::from_name(&kind_str).ok_or(StatusCode::BAD_REQUEST)?;
    let conn = crate::queue::connect(&state.config.amqp_url).await.map_err(|e| {
        error!(error = %e, "amqp connect failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    let n = requeue_dlq(&conn, kind).await.map_err(|e| {
        error!(error = %e, kind = ?kind, "requeue_dlq failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    Ok(Json(serde_json::json!({ "kind": kind.as_str(), "requeued": n })))
}

fn urlencode(s: &str) -> String {
    s.bytes()
        .map(|b| match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                (b as char).to_string()
            }
            b' ' => "+".to_string(),
            _ => format!("%{:02X}", b),
        })
        .collect()
}