⭐
Log in to mark this repository.
q depth
7cd6e39 · gmorell · 2026-05-09 03:01
Files changed
modified
src/handlers/failed.rs
+97
−4
@@ -20,7 +20,7 @@ use uuid::Uuid;
| 20 | 20 | use crate::entities::{upload_jobs, uploads}; |
| 21 | 21 | use crate::jobs; |
| 22 | 22 | use crate::middleware::UserContext; |
| 23 | −use crate::queue::{publish_job, JobKind, JobMessage}; | |
| 23 | +use crate::queue::{publish_job, queue_depths, JobKind, JobMessage, QueueDepth}; | |
| 24 | 24 | use crate::AppState; |
| 25 | 25 | |
| 26 | 26 | #[derive(Debug, Serialize, Clone)] |
@@ -67,14 +67,95 @@ async fn load_failed(
| 67 | 67 | Ok(out) |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | +#[derive(Debug, Serialize, Clone)] | |
| 71 | +pub struct CompletedRow { | |
| 72 | + pub upload_id: Uuid, | |
| 73 | + pub kind: String, | |
| 74 | + pub completed_at: Option<chrono::DateTime<chrono::FixedOffset>>, | |
| 75 | + pub file_name_original: String, | |
| 76 | + pub item_path: String, | |
| 77 | +} | |
| 78 | + | |
| 79 | +/// Last N completed (terminal-success) jobs per kind, scoped to the user. | |
| 80 | +/// Pulled per kind so a single noisy queue doesn't crowd the others out; | |
| 81 | +/// 5 kinds × `per_kind` rows total. | |
| 82 | +async fn load_recent_completed( | |
| 83 | + db: &DatabaseConnection, | |
| 84 | + user_id: Uuid, | |
| 85 | + per_kind: u64, | |
| 86 | +) -> Result<std::collections::BTreeMap<String, Vec<CompletedRow>>, DbErr> { | |
| 87 | + let mut by_kind = std::collections::BTreeMap::new(); | |
| 88 | + for kind in [ | |
| 89 | + JobKind::Thumbnail, | |
| 90 | + JobKind::Ocr, | |
| 91 | + JobKind::Describe, | |
| 92 | + JobKind::ExtractTags, | |
| 93 | + JobKind::SimilaritySearch, | |
| 94 | + ] { | |
| 95 | + let kind_str = kind.as_str().to_string(); | |
| 96 | + // Pull a generous slice and filter by upload-owner in Rust — joining | |
| 97 | + // through find_also_related is the same shape as load_failed and | |
| 98 | + // keeps the post-filter step uniform across the page. | |
| 99 | + let rows = upload_jobs::Entity::find() | |
| 100 | + .filter(upload_jobs::Column::Status.eq(jobs::STATUS_COMPLETE)) | |
| 101 | + .filter(upload_jobs::Column::Kind.eq(kind_str.clone())) | |
| 102 | + .find_also_related(uploads::Entity) | |
| 103 | + .order_by_desc(upload_jobs::Column::CompletedAt) | |
| 104 | + .limit(per_kind * 5) // headroom for non-owner rows | |
| 105 | + .all(db) | |
| 106 | + .await?; | |
| 107 | + let mut taken: Vec<CompletedRow> = Vec::new(); | |
| 108 | + for (job, upload) in rows { | |
| 109 | + let Some(upload) = upload else { continue }; | |
| 110 | + if upload.user_id != user_id { | |
| 111 | + continue; | |
| 112 | + } | |
| 113 | + taken.push(CompletedRow { | |
| 114 | + upload_id: job.upload_id, | |
| 115 | + kind: job.kind, | |
| 116 | + completed_at: job.completed_at, | |
| 117 | + file_name_original: upload.file_name_original, | |
| 118 | + item_path: upload.item_path, | |
| 119 | + }); | |
| 120 | + if taken.len() as u64 >= per_kind { | |
| 121 | + break; | |
| 122 | + } | |
| 123 | + } | |
| 124 | + by_kind.insert(kind_str, taken); | |
| 125 | + } | |
| 126 | + Ok(by_kind) | |
| 127 | +} | |
| 128 | + | |
| 129 | +async fn load_queue_depths(state: &AppState) -> Vec<QueueDepth> { | |
| 130 | + match crate::queue::connect(&state.config.amqp_url).await { | |
| 131 | + Ok(conn) => match queue_depths(&conn).await { | |
| 132 | + Ok(d) => d, | |
| 133 | + Err(e) => { | |
| 134 | + error!(error = %e, "queue_depths failed"); | |
| 135 | + Vec::new() | |
| 136 | + } | |
| 137 | + }, | |
| 138 | + Err(e) => { | |
| 139 | + error!(error = %e, "amqp connect for queue_depths failed"); | |
| 140 | + Vec::new() | |
| 141 | + } | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 70 | 145 | pub async fn show_failed( |
| 71 | 146 | State(state): State<AppState>, |
| 72 | 147 | user: UserContext, |
| 73 | 148 | ) -> Result<Html<String>, StatusCode> { |
| 74 | − let rows = load_failed(state.db.as_ref(), user.user.id).await.map_err(|e| { | |
| 149 | + let user_id = user.user.id; | |
| 150 | + let rows = load_failed(state.db.as_ref(), user_id).await.map_err(|e| { | |
| 75 | 151 | error!(error = %e, "load failed jobs failed"); |
| 76 | 152 | StatusCode::INTERNAL_SERVER_ERROR |
| 77 | 153 | })?; |
| 154 | + let recent = load_recent_completed(state.db.as_ref(), user_id, 3).await.map_err(|e| { | |
| 155 | + error!(error = %e, "load recent completed jobs failed"); | |
| 156 | + StatusCode::INTERNAL_SERVER_ERROR | |
| 157 | + })?; | |
| 158 | + let depths = load_queue_depths(&state).await; | |
| 78 | 159 | let template = state.env.get_template("failed.html").map_err(|e| { |
| 79 | 160 | error!(error = %e, "load failed.html failed"); |
| 80 | 161 | StatusCode::INTERNAL_SERVER_ERROR |
@@ -84,6 +165,8 @@ pub async fn show_failed(
| 84 | 165 | title => "Failed jobs", |
| 85 | 166 | user => user.user, |
| 86 | 167 | rows => rows, |
| 168 | + recent => recent, | |
| 169 | + depths => depths, | |
| 87 | 170 | }) |
| 88 | 171 | .map_err(|e| { |
| 89 | 172 | error!(error = %e, "render failed.html failed"); |
@@ -96,11 +179,21 @@ pub async fn api_list_failed(
| 96 | 179 | State(state): State<AppState>, |
| 97 | 180 | user: UserContext, |
| 98 | 181 | ) -> Result<Json<serde_json::Value>, StatusCode> { |
| 99 | − let rows = load_failed(state.db.as_ref(), user.user.id).await.map_err(|e| { | |
| 182 | + let user_id = user.user.id; | |
| 183 | + let rows = load_failed(state.db.as_ref(), user_id).await.map_err(|e| { | |
| 100 | 184 | error!(error = %e, "load failed jobs failed"); |
| 101 | 185 | StatusCode::INTERNAL_SERVER_ERROR |
| 102 | 186 | })?; |
| 103 | − Ok(Json(serde_json::json!({ "total": rows.len(), "items": rows }))) | |
| 187 | + let recent = load_recent_completed(state.db.as_ref(), user_id, 3).await.map_err(|e| { | |
| 188 | + error!(error = %e, "load recent completed jobs failed"); | |
| 189 | + StatusCode::INTERNAL_SERVER_ERROR | |
| 190 | + })?; | |
| 191 | + let depths = load_queue_depths(&state).await; | |
| 192 | + Ok(Json(serde_json::json!({ | |
| 193 | + "failed": { "total": rows.len(), "items": rows }, | |
| 194 | + "recent_completed": recent, | |
| 195 | + "queues": depths, | |
| 196 | + }))) | |
| 104 | 197 | } |
| 105 | 198 | |
| 106 | 199 | /// Enqueue retries for every failed job belonging to the user. Returns the |
modified
src/queue/mod.rs
+57
−0
@@ -34,3 +34,60 @@ pub fn queue_name(kind: JobKind) -> String {
| 34 | 34 | pub fn dlq_name(kind: JobKind) -> String { |
| 35 | 35 | format!("dms.{}.dlq", routing_key(kind).replace('.', "_")) |
| 36 | 36 | } |
| 37 | + | |
| 38 | +/// Snapshot of a single RabbitMQ queue. Pulled via passive queue_declare so | |
| 39 | +/// the broker reports counts for an existing queue without redeclaring it | |
| 40 | +/// (a non-passive declare with mismatched args would error). | |
| 41 | +#[derive(Debug, Clone, serde::Serialize)] | |
| 42 | +pub struct QueueDepth { | |
| 43 | + pub kind: &'static str, | |
| 44 | + pub queue: String, | |
| 45 | + pub messages: u32, | |
| 46 | + pub consumers: u32, | |
| 47 | + pub dlq: String, | |
| 48 | + pub dlq_messages: u32, | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Read message + consumer counts for every job-kind queue and its DLQ. A | |
| 52 | +/// missing queue (404 from the broker) is reported as zero rather than | |
| 53 | +/// failing the whole snapshot — broker may not have seen this kind yet on | |
| 54 | +/// a fresh deploy. | |
| 55 | +pub async fn queue_depths(conn: &Connection) -> Result<Vec<QueueDepth>, lapin::Error> { | |
| 56 | + use lapin::options::QueueDeclareOptions; | |
| 57 | + use lapin::types::FieldTable; | |
| 58 | + | |
| 59 | + let opts = QueueDeclareOptions { passive: true, ..Default::default() }; | |
| 60 | + // A passive declare against a non-existent queue raises NOT_FOUND and | |
| 61 | + // closes the channel, so each probe opens a fresh channel. Cheap (no | |
| 62 | + // server round-trip beyond the channel open) and keeps the loop going | |
| 63 | + // when the broker hasn't seen a particular queue yet. | |
| 64 | + async fn probe(conn: &Connection, name: &str, opts: QueueDeclareOptions) -> (u32, u32) { | |
| 65 | + let Ok(ch) = conn.create_channel().await else { return (0, 0); }; | |
| 66 | + match ch.queue_declare(name, opts, FieldTable::default()).await { | |
| 67 | + Ok(q) => (q.message_count(), q.consumer_count()), | |
| 68 | + Err(_) => (0, 0), | |
| 69 | + } | |
| 70 | + } | |
| 71 | + let mut out = Vec::new(); | |
| 72 | + for kind in [ | |
| 73 | + JobKind::Thumbnail, | |
| 74 | + JobKind::Ocr, | |
| 75 | + JobKind::Describe, | |
| 76 | + JobKind::ExtractTags, | |
| 77 | + JobKind::SimilaritySearch, | |
| 78 | + ] { | |
| 79 | + let qname = queue_name(kind); | |
| 80 | + let dlq = dlq_name(kind); | |
| 81 | + let (msgs, consumers) = probe(conn, &qname, opts).await; | |
| 82 | + let (dlq_msgs, _) = probe(conn, &dlq, opts).await; | |
| 83 | + out.push(QueueDepth { | |
| 84 | + kind: kind.as_str(), | |
| 85 | + queue: qname, | |
| 86 | + messages: msgs, | |
| 87 | + consumers, | |
| 88 | + dlq, | |
| 89 | + dlq_messages: dlq_msgs, | |
| 90 | + }); | |
| 91 | + } | |
| 92 | + Ok(out) | |
| 93 | +} |
modified
templates/failed.html
+61
−0
@@ -2,6 +2,67 @@
| 2 | 2 | |
| 3 | 3 | {% block inner %} |
| 4 | 4 | <div class="space-y-6 text-left"> |
| 5 | + | |
| 6 | + {# Queue depth — single line per kind, "messages / consumers, dlq=N". #} | |
| 7 | + {% if depths %} | |
| 8 | + <div> | |
| 9 | + <h3 class="text-lg font-bold text-accent mb-2">Queue depth</h3> | |
| 10 | + <div class="overflow-x-auto"> | |
| 11 | + <table class="table table-sm"> | |
| 12 | + <thead> | |
| 13 | + <tr> | |
| 14 | + <th>Kind</th> | |
| 15 | + <th class="text-right">Pending</th> | |
| 16 | + <th class="text-right">Consumers</th> | |
| 17 | + <th class="text-right">DLQ</th> | |
| 18 | + </tr> | |
| 19 | + </thead> | |
| 20 | + <tbody> | |
| 21 | + {% for q in depths %} | |
| 22 | + <tr> | |
| 23 | + <td><span class="badge badge-ghost">{{ q.kind }}</span></td> | |
| 24 | + <td class="text-right font-mono">{{ q.messages }}</td> | |
| 25 | + <td class="text-right font-mono">{{ q.consumers }}</td> | |
| 26 | + <td class="text-right font-mono {% if q.dlq_messages > 0 %}text-error{% endif %}">{{ q.dlq_messages }}</td> | |
| 27 | + </tr> | |
| 28 | + {% endfor %} | |
| 29 | + </tbody> | |
| 30 | + </table> | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + {% endif %} | |
| 34 | + | |
| 35 | + {# Recent completions per kind. #} | |
| 36 | + {% if recent %} | |
| 37 | + <div> | |
| 38 | + <h3 class="text-lg font-bold text-accent mb-2">Last 3 completed per queue</h3> | |
| 39 | + <div class="grid grid-cols-1 md:grid-cols-2 gap-3"> | |
| 40 | + {% for kind, jobs in recent|items %} | |
| 41 | + <div class="border border-base-300 rounded-box p-3"> | |
| 42 | + <div class="flex items-center justify-between mb-2"> | |
| 43 | + <span class="badge badge-ghost">{{ kind }}</span> | |
| 44 | + <span class="text-xs opacity-60">{{ jobs|length }} shown</span> | |
| 45 | + </div> | |
| 46 | + {% if not jobs %} | |
| 47 | + <div class="text-xs opacity-60">— none yet —</div> | |
| 48 | + {% else %} | |
| 49 | + <ul class="space-y-1"> | |
| 50 | + {% for j in jobs %} | |
| 51 | + <li class="text-xs font-mono truncate"> | |
| 52 | + <a href="/uploads/{{ j.upload_id }}" class="link link-hover"> | |
| 53 | + {{ j.file_name_original }} | |
| 54 | + </a> | |
| 55 | + <span class="opacity-60"> — {{ j.completed_at }}</span> | |
| 56 | + </li> | |
| 57 | + {% endfor %} | |
| 58 | + </ul> | |
| 59 | + {% endif %} | |
| 60 | + </div> | |
| 61 | + {% endfor %} | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + {% endif %} | |
| 65 | + | |
| 5 | 66 | <div class="flex justify-between items-center mb-4"> |
| 6 | 67 | <h2 class="text-2xl font-bold text-accent">Failed jobs ({{ rows|length }})</h2> |
| 7 | 68 | {% if rows %} |
Parents: 94679c7