⭐
Log in to mark this repository.
Gauges for the archive and the job pipeline
7ddc104 · Gabriel Morell · 2026-08-24 22:09
Message
{commit_body(@commit)}
Files changed
modified
Cargo.toml
+5
−1
@@ -26,6 +26,9 @@ anyhow = "1.0.100"
| 26 | 26 | tracing = "0.1.41" |
| 27 | 27 | axum-prometheus = "0.9.0" |
| 28 | 28 | metrics-process = "2.4.0" |
| 29 | +# Already in the tree under axum-prometheus; declared so the app can describe | |
| 30 | +# and set gauges of its own alongside the HTTP ones. | |
| 31 | +metrics = "0.24" | |
| 29 | 32 | axum-htmx = { version = "0.7.0", features = ["tokio"] } |
| 30 | 33 | minijinja = { version = "2.10.2", features = ["loader", "builtins", "json", "urlencode"] } |
| 31 | 34 | minijinja-embed = "2.12" |
@@ -35,13 +38,14 @@ sea-orm = { workspace = true }
| 35 | 38 | chrono = { workspace = true } |
| 36 | 39 | uuid = { workspace = true } |
| 37 | 40 | serde = { workspace = true } |
| 38 | −argon2 = "0.6.0-rc.1" | |
| 41 | +argon2 = { version = "0.6.0-rc.1", features = ["std"] } | |
| 39 | 42 | sha2 = "0.11.0-rc.2" |
| 40 | 43 | hex = "0.4.3" |
| 41 | 44 | migration = { path = "./migration" } |
| 42 | 45 | |
| 43 | 46 | # CLI dependencies |
| 44 | 47 | clap = { version = "4.5", features = ["derive"] } |
| 48 | +rpassword = "7.4" | |
| 45 | 49 | serde_json = "1.0.145" |
| 46 | 50 | |
| 47 | 51 | # Queue + storage + media |
modified
src/bin/webservice.rs
+10
−0
@@ -50,6 +50,16 @@ async fn main() -> Result<(), anyhow::Error> {
| 50 | 50 | collector.describe(); |
| 51 | 51 | let (prometheus_layer, metric_handle) = PrometheusMetricLayer::pair(); |
| 52 | 52 | |
| 53 | + // Domain gauges — the archive, and whether the job pipeline behind an | |
| 54 | + // upload is finishing. Described before the sampler starts so the names | |
| 55 | + // are in /metrics from the first scrape rather than appearing a minute in, | |
| 56 | + // which a panel reads as an outage. | |
| 57 | + // | |
| 58 | + // Every replica reports the same database-wide numbers, so anything | |
| 59 | + // graphing these has to aggregate with `max`, never `sum`. | |
| 60 | + dms::telemetry::describe(); | |
| 61 | + tokio::spawn(dms::telemetry::run(db.clone(), config.worker_max_attempts)); | |
| 62 | + | |
| 53 | 63 | let mut jinja = Environment::new(); |
| 54 | 64 | minijinja_embed::load_templates!(&mut jinja); |
| 55 | 65 | jinja.set_loader(minijinja::path_loader("templates")); |
modified
src/lib.rs
+1
−0
@@ -6,6 +6,7 @@ pub mod config;
| 6 | 6 | pub mod jobs; |
| 7 | 7 | pub mod queue; |
| 8 | 8 | pub mod storage; |
| 9 | +pub mod telemetry; | |
| 9 | 10 | pub mod tags; |
| 10 | 11 | pub mod workers; |
| 11 | 12 |
added
src/telemetry.rs
+211
−0
@@ -0,0 +1,211 @@
| 1 | +//! Gauges describing the archive itself, alongside the HTTP and process | |
| 2 | +//! metrics. | |
| 3 | +//! | |
| 4 | +//! These answer questions the request metrics cannot: how much has been | |
| 5 | +//! stored, and — the reason this module exists — whether the processing | |
| 6 | +//! pipeline behind an upload is actually finishing. They come from counting | |
| 7 | +//! rows, so they are sampled on a timer rather than updated on every write; a | |
| 8 | +//! gauge that every code path touching a table has to keep correct is a gauge | |
| 9 | +//! that drifts. | |
| 10 | +//! | |
| 11 | +//! **Every replica reports the same numbers.** They describe the database, not | |
| 12 | +//! the pod, so a dashboard must aggregate them with `max` and never `sum`; | |
| 13 | +//! summing counts the same uploads once per web pod. | |
| 14 | +//! | |
| 15 | +//! The job gauges are the point. An upload here fans out into up to five | |
| 16 | +//! independent jobs — thumbnail, ocr, describe, extract_tags, similarity — run | |
| 17 | +//! by four separate worker Deployments. When one of those stalls, the site | |
| 18 | +//! stays up, uploads keep succeeding, and the only visible symptom is that | |
| 19 | +//! thumbnails quietly stop appearing. [`sample`] breaks the job table down by | |
| 20 | +//! `kind` and `status` so the stalled one names itself. | |
| 21 | +use std::sync::Arc; | |
| 22 | +use std::time::Duration; | |
| 23 | + | |
| 24 | +use chrono::Utc; | |
| 25 | +use sea_orm::{ | |
| 26 | + ColumnTrait, ConnectionTrait, DatabaseBackend, DatabaseConnection, EntityTrait, PaginatorTrait, | |
| 27 | + QueryFilter, Statement, | |
| 28 | +}; | |
| 29 | + | |
| 30 | +use crate::entities::{ | |
| 31 | + ai_descriptions, api_tokens, ocr_results, tags, upload_jobs, uploads, user_sessions, users, | |
| 32 | +}; | |
| 33 | +use crate::jobs::{ | |
| 34 | + STATUS_COMPLETE, STATUS_FAILED, STATUS_PENDING, STATUS_PROCESSING, UPLOAD_STATUS_COMPLETE, | |
| 35 | + UPLOAD_STATUS_PARTIAL, UPLOAD_STATUS_PROCESSING, | |
| 36 | +}; | |
| 37 | + | |
| 38 | +/// How often the counts are refreshed. | |
| 39 | +const SAMPLE_EVERY: Duration = Duration::from_secs(60); | |
| 40 | + | |
| 41 | +/// How many ticks between refreshes of the one query that scans a big table. | |
| 42 | +/// | |
| 43 | +/// Summing `file_size_bytes` across every upload cannot use an index, so | |
| 44 | +/// Postgres reads the heap. Once a minute forever is not a reasonable price | |
| 45 | +/// for a number that is read as a slow-moving line; ten minutes is well below | |
| 46 | +/// the resolution anyone looks at it. | |
| 47 | +const SLOW_EVERY: u64 = 10; | |
| 48 | + | |
| 49 | +/// How long a job sits in `processing` before it is called stuck. | |
| 50 | +/// | |
| 51 | +/// Thumbnailing is seconds and a `describe` against the VLM is at worst a | |
| 52 | +/// couple of minutes, so an hour is an order of magnitude past anything | |
| 53 | +/// healthy. This is the signal for a worker killed mid-job: the row is claimed | |
| 54 | +/// and no longer queued, so nothing will ever pick it up again. | |
| 55 | +const STUCK_AFTER: chrono::TimeDelta = chrono::TimeDelta::hours(1); | |
| 56 | + | |
| 57 | +/// The job kinds, in the order `queue::JobKind` declares them. | |
| 58 | +/// | |
| 59 | +/// Listed rather than discovered so a kind that has never run still reports | |
| 60 | +/// zero instead of dropping its series — a missing line and a line at zero | |
| 61 | +/// mean very different things on the pipeline panel. | |
| 62 | +const KINDS: [&str; 5] = ["thumbnail", "ocr", "describe", "extract_tags", "similarity"]; | |
| 63 | + | |
| 64 | +const JOB_STATUSES: [&str; 4] = [STATUS_PENDING, STATUS_PROCESSING, STATUS_COMPLETE, STATUS_FAILED]; | |
| 65 | + | |
| 66 | +const UPLOAD_STATUSES: [&str; 3] = | |
| 67 | + [UPLOAD_STATUS_PROCESSING, UPLOAD_STATUS_COMPLETE, UPLOAD_STATUS_PARTIAL]; | |
| 68 | + | |
| 69 | +/// Register the metric names and what they mean. | |
| 70 | +/// | |
| 71 | +/// Called at boot so the names exist in `/metrics` from the first scrape. | |
| 72 | +/// Without this a fresh pod exposes nothing until the first sample lands, and | |
| 73 | +/// a panel over a just-restarted deployment reads as an outage. | |
| 74 | +pub fn describe() { | |
| 75 | + metrics::describe_gauge!( | |
| 76 | + "dms_uploads", | |
| 77 | + "Uploads held, by processing state. `partial` means every job finished but at least \ | |
| 78 | + one failed — the file is stored and usable, it just has no thumbnail or no OCR text" | |
| 79 | + ); | |
| 80 | + metrics::describe_gauge!( | |
| 81 | + "dms_uploads_bytes", | |
| 82 | + "Total stored bytes across every upload. Refreshed every ten minutes rather than every \ | |
| 83 | + minute; see SLOW_EVERY" | |
| 84 | + ); | |
| 85 | + metrics::describe_gauge!( | |
| 86 | + "dms_jobs", | |
| 87 | + "Processing jobs by kind and status. This is the pipeline: five kinds fan out from one \ | |
| 88 | + upload, and a worker Deployment stalling shows up as its kind stuck in `pending`" | |
| 89 | + ); | |
| 90 | + metrics::describe_gauge!( | |
| 91 | + "dms_jobs_stuck", | |
| 92 | + "Jobs claimed as `processing` more than an hour ago. The worker that took them is gone \ | |
| 93 | + and the row is no longer queued, so nothing will finish them without intervention" | |
| 94 | + ); | |
| 95 | + metrics::describe_gauge!( | |
| 96 | + "dms_jobs_exhausted", | |
| 97 | + "Failed jobs that have used every retry (attempts >= WORKER_MAX_ATTEMPTS). These will \ | |
| 98 | + not be retried again and are what the /failed page exists to show" | |
| 99 | + ); | |
| 100 | + metrics::describe_gauge!("dms_ocr_results", "Uploads with extracted text"); | |
| 101 | + metrics::describe_gauge!("dms_ai_descriptions", "Uploads the VLM has described"); | |
| 102 | + metrics::describe_gauge!("dms_tags", "Distinct tags across the archive"); | |
| 103 | + metrics::describe_gauge!("dms_users", "Accounts that exist"); | |
| 104 | + metrics::describe_gauge!("dms_sessions_active", "Sessions not yet expired"); | |
| 105 | + metrics::describe_gauge!("dms_api_tokens", "API tokens issued"); | |
| 106 | +} | |
| 107 | + | |
| 108 | +/// Sample the counts forever, on a timer. | |
| 109 | +/// | |
| 110 | +/// `max_attempts` is the app's own `WORKER_MAX_ATTEMPTS`, so the exhausted | |
| 111 | +/// count agrees with the retry limit the workers actually enforce rather than | |
| 112 | +/// with a second threshold invented here. | |
| 113 | +/// | |
| 114 | +/// Failures are logged and skipped rather than retried or fatal: a metrics | |
| 115 | +/// sampler that can take the process down, or that hammers a struggling | |
| 116 | +/// database, is worse than one that misses a minute. | |
| 117 | +pub async fn run(db: Arc<DatabaseConnection>, max_attempts: i32) { | |
| 118 | + let mut ticker = tokio::time::interval(SAMPLE_EVERY); | |
| 119 | + // The first tick fires immediately, so the numbers are right from boot | |
| 120 | + // rather than a minute after it. | |
| 121 | + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); | |
| 122 | + let mut tick: u64 = 0; | |
| 123 | + loop { | |
| 124 | + ticker.tick().await; | |
| 125 | + // Tick zero includes the slow gauge, so the archive size is present | |
| 126 | + // from the first scrape rather than ten minutes into the pod's life. | |
| 127 | + let include_slow = tick.is_multiple_of(SLOW_EVERY); | |
| 128 | + tick = tick.wrapping_add(1); | |
| 129 | + if let Err(e) = sample(db.as_ref(), max_attempts, include_slow).await { | |
| 130 | + tracing::warn!(error = %e, "could not sample metrics"); | |
| 131 | + } | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +async fn sample( | |
| 136 | + db: &DatabaseConnection, | |
| 137 | + max_attempts: i32, | |
| 138 | + include_slow: bool, | |
| 139 | +) -> Result<(), sea_orm::DbErr> { | |
| 140 | + let now = Utc::now(); | |
| 141 | + | |
| 142 | + for status in UPLOAD_STATUSES { | |
| 143 | + let n = uploads::Entity::find() | |
| 144 | + .filter(uploads::Column::ProcessingStatus.eq(status)) | |
| 145 | + .count(db) | |
| 146 | + .await?; | |
| 147 | + metrics::gauge!("dms_uploads", "status" => status).set(n as f64); | |
| 148 | + } | |
| 149 | + | |
| 150 | + // The pipeline, broken down the way it actually fails. Twenty cheap | |
| 151 | + // counted queries rather than one grouped one, because a kind or status | |
| 152 | + // with no rows has to report zero and a GROUP BY simply omits it. | |
| 153 | + for kind in KINDS { | |
| 154 | + for status in JOB_STATUSES { | |
| 155 | + let n = upload_jobs::Entity::find() | |
| 156 | + .filter(upload_jobs::Column::Kind.eq(kind)) | |
| 157 | + .filter(upload_jobs::Column::Status.eq(status)) | |
| 158 | + .count(db) | |
| 159 | + .await?; | |
| 160 | + metrics::gauge!("dms_jobs", "kind" => kind, "status" => status).set(n as f64); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + // Deliberately unwindowed: a job wedged since last week is exactly the one | |
| 165 | + // worth knowing about, and any recent-only window would hide it. | |
| 166 | + metrics::gauge!("dms_jobs_stuck").set( | |
| 167 | + upload_jobs::Entity::find() | |
| 168 | + .filter(upload_jobs::Column::Status.eq(STATUS_PROCESSING)) | |
| 169 | + .filter(upload_jobs::Column::StartedAt.lt(now - STUCK_AFTER)) | |
| 170 | + .count(db) | |
| 171 | + .await? as f64, | |
| 172 | + ); | |
| 173 | + metrics::gauge!("dms_jobs_exhausted").set( | |
| 174 | + upload_jobs::Entity::find() | |
| 175 | + .filter(upload_jobs::Column::Status.eq(STATUS_FAILED)) | |
| 176 | + .filter(upload_jobs::Column::Attempts.gte(max_attempts)) | |
| 177 | + .count(db) | |
| 178 | + .await? as f64, | |
| 179 | + ); | |
| 180 | + | |
| 181 | + metrics::gauge!("dms_ocr_results").set(ocr_results::Entity::find().count(db).await? as f64); | |
| 182 | + metrics::gauge!("dms_ai_descriptions") | |
| 183 | + .set(ai_descriptions::Entity::find().count(db).await? as f64); | |
| 184 | + metrics::gauge!("dms_tags").set(tags::Entity::find().count(db).await? as f64); | |
| 185 | + | |
| 186 | + metrics::gauge!("dms_users").set(users::Entity::find().count(db).await? as f64); | |
| 187 | + metrics::gauge!("dms_sessions_active").set( | |
| 188 | + user_sessions::Entity::find() | |
| 189 | + .filter(user_sessions::Column::ExpiresAt.gt(now)) | |
| 190 | + .count(db) | |
| 191 | + .await? as f64, | |
| 192 | + ); | |
| 193 | + metrics::gauge!("dms_api_tokens").set(api_tokens::Entity::find().count(db).await? as f64); | |
| 194 | + | |
| 195 | + if include_slow { | |
| 196 | + // Raw SQL rather than sea-orm's `sum()`: Postgres returns SUM(bigint) | |
| 197 | + // as numeric, which would need a Decimal round-trip to read back. The | |
| 198 | + // cast makes the type unambiguous and the gauge is an f64 anyway. | |
| 199 | + let row = db | |
| 200 | + .query_one(Statement::from_string( | |
| 201 | + DatabaseBackend::Postgres, | |
| 202 | + "SELECT COALESCE(SUM(file_size_bytes), 0)::float8 AS total FROM uploads", | |
| 203 | + )) | |
| 204 | + .await?; | |
| 205 | + if let Some(row) = row { | |
| 206 | + metrics::gauge!("dms_uploads_bytes").set(row.try_get::<f64>("", "total")?); | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + Ok(()) | |
| 211 | +} |
Parents: 2e5cc52