max / makenotwork
19 files changed,
+463 insertions,
-63 deletions
| @@ -4318,7 +4318,7 @@ dependencies = [ | |||
| 4318 | 4318 | ||
| 4319 | 4319 | [[package]] | |
| 4320 | 4320 | name = "makenotwork" | |
| 4321 | - | version = "0.10.7" | |
| 4321 | + | version = "0.10.8" | |
| 4322 | 4322 | dependencies = [ | |
| 4323 | 4323 | "ammonia", | |
| 4324 | 4324 | "anyhow", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.10.7" | |
| 3 | + | version = "0.10.8" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | -- Liveness heartbeat for the scan-job reaper. | |
| 2 | + | -- | |
| 3 | + | -- Before this, `reap_stuck` reset any `running` job whose `started_at` was | |
| 4 | + | -- older than STUCK_JOB_SECS (300s). But `started_at` is stamped at claim time | |
| 5 | + | -- and spans the full S3 download (up to the spool ceiling) plus the scan, so a | |
| 6 | + | -- legitimately slow large scan crossed the threshold *while still running*: a | |
| 7 | + | -- second worker re-claimed it, both scanned the same object, and each claim | |
| 8 | + | -- incremented `attempts` toward MAX_SCAN_ATTEMPTS -- force-retiring a valid file | |
| 9 | + | -- to `failed` and stranding the entity at HeldForReview (audit Run 22). | |
| 10 | + | -- | |
| 11 | + | -- The running worker now bumps `heartbeat_at` on a cadence well under | |
| 12 | + | -- STUCK_JOB_SECS; the reaper and the PoM stuck-count key off | |
| 13 | + | -- COALESCE(heartbeat_at, started_at), so only a job whose worker has actually | |
| 14 | + | -- stopped touching it (a crashed or hung process) is reaped. A slow-but- | |
| 15 | + | -- progressing scan keeps its heartbeat fresh and is never reclaimed. The COALESCE | |
| 16 | + | -- fallback to started_at keeps pre-migration in-flight rows (heartbeat_at NULL) | |
| 17 | + | -- reapable on the old clock. | |
| 18 | + | ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; | |
| 19 | + | ||
| 20 | + | -- No new index: the existing partial index `idx_scan_jobs_running` | |
| 21 | + | -- (WHERE status = 'running') already bounds the reaper's candidate set to the | |
| 22 | + | -- handful of in-flight rows, which it then filters by heartbeat. |
| @@ -38,7 +38,7 @@ pub mod passkeys; // pub so the integration test crate can exercise the layer di | |||
| 38 | 38 | pub(crate) mod health; | |
| 39 | 39 | pub(crate) mod monitor; | |
| 40 | 40 | pub(crate) mod scanning; | |
| 41 | - | pub(crate) mod scan_jobs; | |
| 41 | + | pub mod scan_jobs; // pub so the integration test crate can exercise the reaper/heartbeat layer directly | |
| 42 | 42 | pub(crate) mod scan_admin_actions; | |
| 43 | 43 | pub(crate) mod content_insertions; | |
| 44 | 44 | pub mod invites; // pub so the integration test crate can exercise redemption races directly |
| @@ -119,6 +119,11 @@ pub struct ScanJob { | |||
| 119 | 119 | pub attempts: i32, | |
| 120 | 120 | pub enqueued_at: DateTime<Utc>, | |
| 121 | 121 | pub started_at: Option<DateTime<Utc>>, | |
| 122 | + | /// Last liveness beat from the worker running this job. Bumped on a cadence | |
| 123 | + | /// well under STUCK_JOB_SECS; the reaper keys off this (falling back to | |
| 124 | + | /// `started_at`) so a slow-but-progressing scan isn't mistaken for a | |
| 125 | + | /// crashed worker. See `bump_heartbeat` and migration 167. | |
| 126 | + | pub heartbeat_at: Option<DateTime<Utc>>, | |
| 122 | 127 | pub completed_at: Option<DateTime<Utc>>, | |
| 123 | 128 | pub last_error: Option<String>, | |
| 124 | 129 | } | |
| @@ -191,7 +196,8 @@ pub async fn claim_next(db: &PgPool) -> Result<Option<ScanJob>, sqlx::Error> { | |||
| 191 | 196 | UPDATE scan_jobs | |
| 192 | 197 | SET status = 'running', | |
| 193 | 198 | attempts = attempts + 1, | |
| 194 | - | started_at = NOW() | |
| 199 | + | started_at = NOW(), | |
| 200 | + | heartbeat_at = NOW() | |
| 195 | 201 | WHERE id = (SELECT id FROM next) | |
| 196 | 202 | RETURNING * | |
| 197 | 203 | "#, | |
| @@ -240,14 +246,36 @@ pub async fn mark_failed(db: &PgPool, job_id: Uuid, err: &str) -> Result<(), sql | |||
| 240 | 246 | Ok(()) | |
| 241 | 247 | } | |
| 242 | 248 | ||
| 243 | - | /// Reset jobs that have been stuck in `running` longer than `max_age_secs`. | |
| 249 | + | /// Refresh a running job's liveness heartbeat. | |
| 250 | + | /// | |
| 251 | + | /// Called periodically by the worker that owns the job so `reap_stuck` (and the | |
| 252 | + | /// PoM stuck-count) can distinguish a slow-but-progressing scan (fresh beat) | |
| 253 | + | /// from a crashed or hung worker (stale beat). The `status = 'running'` guard | |
| 254 | + | /// makes a late beat a no-op once the job has already finished or been reaped — | |
| 255 | + | /// it never resurrects a terminal row's timestamp. | |
| 256 | + | #[tracing::instrument(skip_all, fields(%job_id))] | |
| 257 | + | pub async fn bump_heartbeat(db: &PgPool, job_id: Uuid) -> Result<(), sqlx::Error> { | |
| 258 | + | sqlx::query("UPDATE scan_jobs SET heartbeat_at = NOW() WHERE id = $1 AND status = 'running'") | |
| 259 | + | .bind(job_id) | |
| 260 | + | .execute(db) | |
| 261 | + | .await?; | |
| 262 | + | Ok(()) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// Reset jobs whose worker has gone silent for longer than `max_age_secs`. | |
| 244 | 266 | /// | |
| 245 | 267 | /// Run on worker startup and on a timer to recover from a previous-process crash | |
| 246 | 268 | /// mid-scan: the row would otherwise stay `running` forever and never be | |
| 247 | - | /// re-claimed. A job whose `attempts` has already reached `MAX_SCAN_ATTEMPTS` is | |
| 248 | - | /// retired to `failed` (each claim increments `attempts`, so a scan that | |
| 249 | - | /// reliably wedges the process trips the budget); otherwise it returns to | |
| 250 | - | /// `queued` for another attempt. | |
| 269 | + | /// re-claimed. Liveness is measured from the job's last heartbeat | |
| 270 | + | /// (`COALESCE(heartbeat_at, started_at)`), NOT from `started_at` alone — a | |
| 271 | + | /// legitimately slow scan (large object over a slow S3 link) can run past | |
| 272 | + | /// `max_age_secs` while still making progress, and reaping *that* let a second | |
| 273 | + | /// worker double-process the same object and inflate `attempts` toward | |
| 274 | + | /// MAX_SCAN_ATTEMPTS until a valid file was force-retired (audit Run 22). The | |
| 275 | + | /// running worker bumps `heartbeat_at` on a cadence well under `max_age_secs`, | |
| 276 | + | /// so only a crashed/hung process (no fresh beat) crosses the threshold. A job | |
| 277 | + | /// whose `attempts` has already reached `MAX_SCAN_ATTEMPTS` is retired to | |
| 278 | + | /// `failed`; otherwise it returns to `queued` for another attempt. | |
| 251 | 279 | #[tracing::instrument(skip_all)] | |
| 252 | 280 | pub async fn reap_stuck(db: &PgPool, max_age_secs: i64) -> Result<u64, sqlx::Error> { | |
| 253 | 281 | let affected = sqlx::query( | |
| @@ -255,12 +283,13 @@ pub async fn reap_stuck(db: &PgPool, max_age_secs: i64) -> Result<u64, sqlx::Err | |||
| 255 | 283 | UPDATE scan_jobs | |
| 256 | 284 | SET status = CASE WHEN attempts >= $2 THEN 'failed' ELSE 'queued' END, | |
| 257 | 285 | started_at = NULL, | |
| 286 | + | heartbeat_at = NULL, | |
| 258 | 287 | completed_at = CASE WHEN attempts >= $2 THEN NOW() ELSE completed_at END, | |
| 259 | 288 | last_error = CASE WHEN attempts >= $2 | |
| 260 | 289 | THEN 'exceeded max scan attempts (reaped from running)' | |
| 261 | 290 | ELSE last_error END | |
| 262 | 291 | WHERE status = 'running' | |
| 263 | - | AND started_at < NOW() - ($1 || ' seconds')::interval | |
| 292 | + | AND COALESCE(heartbeat_at, started_at) < NOW() - ($1 || ' seconds')::interval | |
| 264 | 293 | "#, | |
| 265 | 294 | ) | |
| 266 | 295 | .bind(max_age_secs.to_string()) | |
| @@ -318,11 +347,13 @@ pub async fn running_count(db: &PgPool) -> Result<i64, sqlx::Error> { | |||
| 318 | 347 | .await | |
| 319 | 348 | } | |
| 320 | 349 | ||
| 321 | - | /// Count of running jobs that have been in flight longer than `max_age_secs`. | |
| 322 | - | /// Used by PoM to alert on stuck workers. | |
| 350 | + | /// Count of running jobs whose worker has gone silent longer than | |
| 351 | + | /// `max_age_secs`. Used by PoM to alert on stuck workers. Keyed off the | |
| 352 | + | /// heartbeat (falling back to `started_at`) so a slow-but-alive scan isn't | |
| 353 | + | /// counted as stuck — matching `reap_stuck`'s liveness definition. | |
| 323 | 354 | pub async fn stuck_count(db: &PgPool, max_age_secs: i64) -> Result<i64, sqlx::Error> { | |
| 324 | 355 | sqlx::query_scalar::<_, i64>( | |
| 325 | - | "SELECT COUNT(*) FROM scan_jobs WHERE status = 'running' AND started_at < NOW() - ($1 || ' seconds')::interval", | |
| 356 | + | "SELECT COUNT(*) FROM scan_jobs WHERE status = 'running' AND COALESCE(heartbeat_at, started_at) < NOW() - ($1 || ' seconds')::interval", | |
| 326 | 357 | ) | |
| 327 | 358 | .bind(max_age_secs.to_string()) | |
| 328 | 359 | .fetch_one(db) |
| @@ -161,7 +161,7 @@ pub(in crate::routes::api) async fn update_password( | |||
| 161 | 161 | ||
| 162 | 162 | // Validate new password | |
| 163 | 163 | let password_len = req.new_password.chars().count(); | |
| 164 | - | if password_len < 8 { | |
| 164 | + | if password_len < crate::validation::limits::PASSWORD_MIN { | |
| 165 | 165 | if is_htmx { | |
| 166 | 166 | return Ok(Html(SaveStatusTemplate { | |
| 167 | 167 | success: false, | |
| @@ -172,7 +172,7 @@ pub(in crate::routes::api) async fn update_password( | |||
| 172 | 172 | "New password must be at least 8 characters".to_string(), | |
| 173 | 173 | )); | |
| 174 | 174 | } | |
| 175 | - | if password_len > 128 { | |
| 175 | + | if crate::validation::password_too_long(&req.new_password) { | |
| 176 | 176 | if is_htmx { | |
| 177 | 177 | return Ok(Html(SaveStatusTemplate { | |
| 178 | 178 | success: false, |
| @@ -177,8 +177,10 @@ async fn login_handler( | |||
| 177 | 177 | )); | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | - | // Cap password length to match signup validation (prevents Argon2 DoS with huge inputs) | |
| 181 | - | if form.password.len() > 128 { | |
| 180 | + | // Cap password length to match signup validation (prevents Argon2 DoS with | |
| 181 | + | // huge inputs). MUST use the same char-count metric as signup, or a valid | |
| 182 | + | // multibyte password (<=128 chars, >128 bytes) is silently rejected here. | |
| 183 | + | if crate::validation::password_too_long(&form.password) { | |
| 182 | 184 | return return_error("Invalid username/email or password"); | |
| 183 | 185 | } | |
| 184 | 186 |
| @@ -498,8 +498,10 @@ async fn authorize_post( | |||
| 498 | 498 | )); | |
| 499 | 499 | } | |
| 500 | 500 | ||
| 501 | - | // Cap password length to prevent DoS via Argon2 on very long inputs | |
| 502 | - | if password.len() > 128 { | |
| 501 | + | // Cap password length to prevent DoS via Argon2 on very long inputs. | |
| 502 | + | // Same char-count metric as signup (bytes would lock out multibyte | |
| 503 | + | // passwords that were accepted at signup). | |
| 504 | + | if crate::validation::password_too_long(password) { | |
| 503 | 505 | return Ok(render_authorize_error( | |
| 504 | 506 | Some(csrf_token), | |
| 505 | 507 | session_user, |
| @@ -196,10 +196,10 @@ pub(super) async fn reset_password_handler( | |||
| 196 | 196 | ||
| 197 | 197 | // Validate password length | |
| 198 | 198 | let password_len = form.password.chars().count(); | |
| 199 | - | if password_len < 8 { | |
| 199 | + | if password_len < crate::validation::limits::PASSWORD_MIN { | |
| 200 | 200 | return return_error("Password must be at least 8 characters"); | |
| 201 | 201 | } | |
| 202 | - | if password_len > 128 { | |
| 202 | + | if crate::validation::password_too_long(&form.password) { | |
| 203 | 203 | return return_error("Password must be 128 characters or fewer"); | |
| 204 | 204 | } | |
| 205 | 205 |
| @@ -1,7 +1,10 @@ | |||
| 1 | 1 | //! System health dashboard and JSON monitoring endpoint. | |
| 2 | 2 | //! | |
| 3 | 3 | //! Two layers: | |
| 4 | - | //! - `GET /health` (HTML); runs full live checks (DB queries, S3 probe, endpoint self-tests) | |
| 4 | + | //! - `GET /health` (HTML); the full live fan-out (DB queries, S3 probe, endpoint | |
| 5 | + | //! self-tests) is admin-only. Anonymous/non-admin callers get a minimal page | |
| 6 | + | //! whose status is read from the cached monitor snapshot — the admin gate runs | |
| 7 | + | //! before the fan-out so an unauthenticated request can't trigger it. | |
| 5 | 8 | //! - `GET /api/health` (JSON); reads cached results from the background monitor's database. | |
| 6 | 9 | //! Fast (<10ms), no live probes. This is what PoM and other external services should poll. | |
| 7 | 10 | ||
| @@ -24,6 +27,11 @@ use crate::{ | |||
| 24 | 27 | AppState, | |
| 25 | 28 | }; | |
| 26 | 29 | ||
| 30 | + | /// Per-subsystem ceiling for the outbound probes in the admin health fan-out, | |
| 31 | + | /// so one wedged dependency can't stall the dashboard on a longer client-level | |
| 32 | + | /// timeout. | |
| 33 | + | const HEALTH_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); | |
| 34 | + | ||
| 27 | 35 | /// Format a [`std::time::Duration`] as a human-readable uptime string. | |
| 28 | 36 | fn format_uptime(d: std::time::Duration) -> String { | |
| 29 | 37 | let total_secs = d.as_secs(); | |
| @@ -297,10 +305,17 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 297 | 305 | let pool_idle = state.db.num_idle(); | |
| 298 | 306 | let pool_active = pool_max.saturating_sub(pool_idle as u32); | |
| 299 | 307 | ||
| 300 | - | // S3 storage status with connectivity check | |
| 308 | + | // S3 storage status with connectivity check. Bound the one outbound network | |
| 309 | + | // probe in this fan-out so a wedged S3 endpoint degrades to "unreachable" in | |
| 310 | + | // a few seconds rather than stalling the admin dashboard on the client-level | |
| 311 | + | // 60s operation timeout (audit Run 22). The DB probes are already bounded by | |
| 312 | + | // the pooled-connection `statement_timeout`, and the PoM fetch has its own. | |
| 301 | 313 | let storage_configured = state.s3.is_some(); | |
| 302 | 314 | let s3_reachable = if let Some(ref s3) = state.s3 { | |
| 303 | - | s3.check_connectivity().await.is_ok() | |
| 315 | + | matches!( | |
| 316 | + | tokio::time::timeout(HEALTH_PROBE_TIMEOUT, s3.check_connectivity()).await, | |
| 317 | + | Ok(res) if res.is_ok() | |
| 318 | + | ) | |
| 304 | 319 | } else { | |
| 305 | 320 | false | |
| 306 | 321 | }; | |
| @@ -517,20 +532,27 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 517 | 532 | /// Render the system health dashboard with database, storage, and service status. | |
| 518 | 533 | /// | |
| 519 | 534 | /// The detailed dashboard — infra config, DB pool internals, business counts, | |
| 520 | - | /// Stripe mode, S3 bucket/region — is admin-only. Anonymous and non-admin callers | |
| 521 | - | /// get a minimal, reachable status page (HTTP 200) so uptime monitors (PoM polls | |
| 522 | - | /// this URL) stay green without the operational-intel disclosure. `/api/health` | |
| 523 | - | /// remains the minimal JSON probe. | |
| 535 | + | /// Stripe mode, S3 bucket/region — is admin-only and runs the live subsystem | |
| 536 | + | /// fan-out (`collect_health`). Anonymous and non-admin callers get a minimal, | |
| 537 | + | /// reachable status page (HTTP 200) whose status comes from the background | |
| 538 | + | /// monitor's cached snapshot, NOT a live probe. | |
| 539 | + | /// | |
| 540 | + | /// The admin gate runs BEFORE `collect_health` so an unauthenticated request | |
| 541 | + | /// cannot trigger the ~10-op sweep (DB counts, S3 connectivity, session probe, | |
| 542 | + | /// outbound PoM fetch) — that was an anon-reachable amplifier (audit Run 22). | |
| 543 | + | /// Uptime monitors (including PoM) should poll `GET /api/health`, the cheap | |
| 544 | + | /// cached JSON probe; this HTML page is a human-facing status view, and its | |
| 545 | + | /// non-admin branch now reads the same cached snapshot `/api/health` does. | |
| 524 | 546 | #[tracing::instrument(skip_all, name = "health::health")] | |
| 525 | 547 | pub(super) async fn health( | |
| 526 | 548 | State(state): State<AppState>, | |
| 527 | 549 | session: Session, | |
| 528 | 550 | crate::auth::MaybeUserVerified(maybe_user): crate::auth::MaybeUserVerified, | |
| 529 | 551 | ) -> Result<axum::response::Response> { | |
| 530 | - | let data = collect_health(&state).await; | |
| 531 | - | ||
| 532 | 552 | let is_admin = maybe_user.as_ref().is_some_and(|u| u.is_admin); | |
| 533 | 553 | if !is_admin { | |
| 554 | + | // Cheap cached status only — no live fan-out for unauthenticated callers. | |
| 555 | + | let (overall, _db_ok) = cached_health_status(&state).await; | |
| 534 | 556 | // `label()` is a controlled &'static str from OverallStatus — no user input. | |
| 535 | 557 | let body = axum::response::Html(format!( | |
| 536 | 558 | "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\ | |
| @@ -538,11 +560,13 @@ pub(super) async fn health( | |||
| 538 | 560 | <title>MakeNotWork status</title></head>\ | |
| 539 | 561 | <body style=\"font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem\">\ | |
| 540 | 562 | <h1>MakeNotWork</h1><p>{}</p></body></html>", | |
| 541 | - | data.overall.label() | |
| 563 | + | overall.label() | |
| 542 | 564 | )); | |
| 543 | 565 | return Ok(body.into_response()); | |
| 544 | 566 | } | |
| 545 | 567 | ||
| 568 | + | let data = collect_health(&state).await; | |
| 569 | + | ||
| 546 | 570 | let now = chrono::Utc::now(); | |
| 547 | 571 | ||
| 548 | 572 | let pool_utilization = if data.db_pool_max > 0 { | |
| @@ -667,39 +691,46 @@ pub(super) async fn health( | |||
| 667 | 691 | // short git sha is a public commit ref (the server source is git-public), not a | |
| 668 | 692 | // secret. | |
| 669 | 693 | ||
| 670 | - | /// `GET /api/health`: fast JSON health endpoint. | |
| 694 | + | /// Overall status from the background monitor's most recent snapshot, with a | |
| 695 | + | /// single `SELECT 1` liveness fallback before the first monitor tick has landed. | |
| 671 | 696 | /// | |
| 672 | - | /// Reads the latest snapshot from the background monitor's database instead of | |
| 673 | - | /// running live probes. Returns 200 if operational or degraded, 503 if error. | |
| 674 | - | #[tracing::instrument(skip_all, name = "health::health_json")] | |
| 675 | - | pub(super) async fn health_json( | |
| 676 | - | State(state): State<AppState>, | |
| 677 | - | ) -> impl IntoResponse { | |
| 678 | - | // Read the latest snapshot from the background monitor (single DB row) | |
| 697 | + | /// This is the cheap read shared by `GET /api/health` and the anonymous | |
| 698 | + | /// `/health` HTML page, so both agree on status without either running the live | |
| 699 | + | /// `collect_health` subsystem fan-out. Returns `(overall, db_ok)`. | |
| 700 | + | async fn cached_health_status(state: &AppState) -> (OverallStatus, bool) { | |
| 679 | 701 | let latest = db::monitor::get_recent_health_history(&state.db, 1) | |
| 680 | 702 | .await | |
| 681 | 703 | .unwrap_or_default(); | |
| 682 | - | ||
| 683 | - | // Use cached monitor data only — no live probes (fast <10ms as documented). | |
| 684 | - | // Falls back to a single DB probe only when no monitor snapshots exist yet | |
| 685 | - | // (fresh startup before the first monitor tick). | |
| 686 | - | let (overall, db_ok) = if let Some(snap) = latest.first() { | |
| 704 | + | if let Some(snap) = latest.first() { | |
| 687 | 705 | let status = match snap.status.as_str() { | |
| 688 | 706 | "operational" => OverallStatus::Operational, | |
| 689 | 707 | "degraded" => OverallStatus::Degraded, | |
| 690 | 708 | _ => OverallStatus::Error, | |
| 691 | 709 | }; | |
| 692 | - | let db_healthy = status != OverallStatus::Error; | |
| 693 | - | (status, db_healthy) | |
| 710 | + | let db_ok = status != OverallStatus::Error; | |
| 711 | + | (status, db_ok) | |
| 694 | 712 | } else { | |
| 695 | - | // No monitor data yet — single minimal probe | |
| 713 | + | // No monitor data yet — single minimal probe. | |
| 696 | 714 | let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1") | |
| 697 | 715 | .fetch_one(&state.db) | |
| 698 | 716 | .await | |
| 699 | 717 | .is_ok(); | |
| 700 | 718 | let status = if db_ok { OverallStatus::Operational } else { OverallStatus::Error }; | |
| 701 | 719 | (status, db_ok) | |
| 702 | - | }; | |
| 720 | + | } | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | /// `GET /api/health`: fast JSON health endpoint. | |
| 724 | + | /// | |
| 725 | + | /// Reads the latest snapshot from the background monitor's database instead of | |
| 726 | + | /// running live probes. Returns 200 if operational or degraded, 503 if error. | |
| 727 | + | #[tracing::instrument(skip_all, name = "health::health_json")] | |
| 728 | + | pub(super) async fn health_json( | |
| 729 | + | State(state): State<AppState>, | |
| 730 | + | ) -> impl IntoResponse { | |
| 731 | + | // Cached monitor data only — no live probes (fast <10ms as documented), | |
| 732 | + | // with a single-`SELECT 1` fallback before the first monitor tick. | |
| 733 | + | let (overall, db_ok) = cached_health_status(&state).await; | |
| 703 | 734 | ||
| 704 | 735 | let http_status = if overall == OverallStatus::Error { | |
| 705 | 736 | StatusCode::SERVICE_UNAVAILABLE |
| @@ -156,10 +156,10 @@ pub async fn step_account_create( | |||
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | 158 | let password_len = form.password.chars().count(); | |
| 159 | - | if password_len < 8 { | |
| 159 | + | if password_len < crate::validation::limits::PASSWORD_MIN { | |
| 160 | 160 | return return_error(Some("password"), "Password must be at least 8 characters"); | |
| 161 | 161 | } | |
| 162 | - | if password_len > 128 { | |
| 162 | + | if crate::validation::password_too_long(&form.password) { | |
| 163 | 163 | return return_error(Some("password"), "Password must be 128 characters or fewer"); | |
| 164 | 164 | } | |
| 165 | 165 |
| @@ -39,7 +39,12 @@ async fn escalate_if_orphaned_session( | |||
| 39 | 39 | let sid = session_id.to_string(); | |
| 40 | 40 | let pi = payment_intent_id.to_string(); | |
| 41 | 41 | let label = label.to_string(); | |
| 42 | - | tokio::spawn(async move { | |
| 42 | + | // Route through the shutdown-drained background pool, NOT a raw | |
| 43 | + | // tokio::spawn: this ticket is the only thing that pages a human | |
| 44 | + | // about a charged-but-undelivered buyer, so it must not be dropped | |
| 45 | + | // when the process is restarted mid-flight during a deploy (Run 9 | |
| 46 | + | // pattern; audit Run 22). | |
| 47 | + | state.bg.spawn("stripe orphaned-session ticket", async move { | |
| 43 | 48 | let body = format!( | |
| 44 | 49 | "Checkout session {sid} (payment_intent {pi}, {label}) completed at Stripe but has \ | |
| 45 | 50 | NO transaction — the pending row(s) were never created. The buyer was charged and \ | |
| @@ -56,9 +61,11 @@ async fn escalate_if_orphaned_session( | |||
| 56 | 61 | /// reported session totals. Our line items are server-built, so the credited | |
| 57 | 62 | /// total should equal Stripe's pre-tax subtotal and the session should be USD. | |
| 58 | 63 | /// A currency mismatch or an amount mismatch is logged loudly and escalated to | |
| 59 | - | /// WAM (fire-and-forget so it never runs inside an open DB transaction); the | |
| 60 | - | /// server-recorded amount stays authoritative either way. Shared by the | |
| 61 | - | /// purchase, cart, and guest completion handlers so the three can't drift. | |
| 64 | + | /// WAM via the shutdown-drained background pool (so it never runs inside an open | |
| 65 | + | /// DB transaction, yet survives a mid-deploy restart rather than being dropped | |
| 66 | + | /// like a raw tokio::spawn — audit Run 22); the server-recorded amount stays | |
| 67 | + | /// authoritative either way. Shared by the purchase, cart, and guest completion | |
| 68 | + | /// handlers so the three can't drift. | |
| 62 | 69 | fn reconcile_checkout_amount( | |
| 63 | 70 | state: &AppState, | |
| 64 | 71 | session_id: &str, | |
| @@ -79,7 +86,7 @@ fn reconcile_checkout_amount( | |||
| 79 | 86 | let session_id = session_id.to_string(); | |
| 80 | 87 | let currency = currency.to_string(); | |
| 81 | 88 | let label = label.to_string(); | |
| 82 | - | tokio::spawn(async move { | |
| 89 | + | state.bg.spawn("stripe non-usd-session ticket", async move { | |
| 83 | 90 | let body = format!( | |
| 84 | 91 | "Checkout session {session_id} ({label}) settled in {currency}, not USD. The \ | |
| 85 | 92 | server credits its own USD amount, but investigate how a non-USD session was created." | |
| @@ -100,7 +107,7 @@ fn reconcile_checkout_amount( | |||
| 100 | 107 | if let Some(wam) = state.wam.clone() { | |
| 101 | 108 | let session_id = session_id.to_string(); | |
| 102 | 109 | let label = label.to_string(); | |
| 103 | - | tokio::spawn(async move { | |
| 110 | + | state.bg.spawn("stripe amount-mismatch ticket", async move { | |
| 104 | 111 | let body = format!( | |
| 105 | 112 | "Credited amount {credited_cents} cents != Stripe session subtotal {subtotal} cents \ | |
| 106 | 113 | (session {session_id}, {label}). The server amount is authoritative; investigate a \ | |
| @@ -678,7 +685,10 @@ pub(super) async fn handle_tip_checkout_completed( | |||
| 678 | 685 | if let Some(wam) = state.wam.clone() { | |
| 679 | 686 | let sid = session_id.clone(); | |
| 680 | 687 | let pi = pi.to_string(); | |
| 681 | - | tokio::spawn(async move { | |
| 688 | + | // Drained background pool, not a raw tokio::spawn: a | |
| 689 | + | // charged-but-undelivered tip ticket must survive a | |
| 690 | + | // mid-deploy restart (audit Run 22). | |
| 691 | + | state.bg.spawn("stripe orphaned-tip ticket", async move { | |
| 682 | 692 | let body = format!( | |
| 683 | 693 | "Tip checkout session {sid} (payment_intent {pi}) completed at Stripe but has \ | |
| 684 | 694 | NO tip row — the pending tip was never created. The tipper was charged and the \ |
| @@ -58,8 +58,9 @@ pub(super) async fn sync_auth( | |||
| 58 | 58 | .ok_or(AppError::Unauthorized)?; | |
| 59 | 59 | ||
| 60 | 60 | // Reject oversized passwords early (before user lookup — no timing leak | |
| 61 | - | // since this branch doesn't touch the DB or run Argon2). | |
| 62 | - | if req.password.len() > 128 { | |
| 61 | + | // since this branch doesn't touch the DB or run Argon2). Same char-count | |
| 62 | + | // metric as signup so a valid multibyte password isn't rejected here. | |
| 63 | + | if crate::validation::password_too_long(&req.password) { | |
| 63 | 64 | let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; | |
| 64 | 65 | return Err(AppError::Unauthorized); | |
| 65 | 66 | } |
| @@ -24,14 +24,58 @@ use super::{LayerResult, LayerVerdict, ScanPipeline, ScanResult}; | |||
| 24 | 24 | /// Worker poll interval when the queue is empty. | |
| 25 | 25 | const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(500); | |
| 26 | 26 | ||
| 27 | - | /// How long a `running` job can sit before the reaper resets it to `queued`. | |
| 28 | - | /// A scan that legitimately takes longer than this is an outlier and warrants | |
| 29 | - | /// admin attention anyway. | |
| 27 | + | /// How long a `running` job can go without a heartbeat before the reaper resets | |
| 28 | + | /// it. This is measured from the worker's last liveness beat (see | |
| 29 | + | /// [`HEARTBEAT_INTERVAL`]), not from claim time, so a genuinely slow-but- | |
| 30 | + | /// progressing scan is never reaped — only a crashed or hung worker crosses it. | |
| 30 | 31 | const STUCK_JOB_SECS: i64 = 300; | |
| 31 | 32 | ||
| 33 | + | /// Cadence at which the worker running a job refreshes its `heartbeat_at`. Must | |
| 34 | + | /// be comfortably smaller than [`STUCK_JOB_SECS`] (here ~10x) so a live job that | |
| 35 | + | /// is merely slow keeps beating well inside the reaper's window. | |
| 36 | + | const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); | |
| 37 | + | ||
| 32 | 38 | /// Cadence at which any worker tries to reap stuck jobs. | |
| 33 | 39 | const REAPER_INTERVAL: Duration = Duration::from_secs(60); | |
| 34 | 40 | ||
| 41 | + | /// Aborts a spawned task when dropped, so a job's heartbeat companion never | |
| 42 | + | /// outlives the job — including when `process_job` returns early with an error. | |
| 43 | + | struct AbortOnDrop(tokio::task::JoinHandle<()>); | |
| 44 | + | ||
| 45 | + | impl Drop for AbortOnDrop { | |
| 46 | + | fn drop(&mut self) { | |
| 47 | + | self.0.abort(); | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Run `process_job` while a companion task keeps the job's `heartbeat_at` | |
| 52 | + | /// fresh. The reaper distinguishes a slow-but-alive scan (recent beat) from a | |
| 53 | + | /// crashed worker (stale beat) purely by this heartbeat, so a large scan that | |
| 54 | + | /// runs past `STUCK_JOB_SECS` is no longer reclaimed and double-processed. The | |
| 55 | + | /// companion is aborted the moment the job finishes (via `AbortOnDrop`), | |
| 56 | + | /// error or not, so it cannot bump a job that has already left `running`. | |
| 57 | + | async fn process_job_with_heartbeat( | |
| 58 | + | ctx: &WorkerContext, | |
| 59 | + | job: ScanJob, | |
| 60 | + | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | |
| 61 | + | let job_id = job.id; | |
| 62 | + | let hb_db = ctx.db.clone(); | |
| 63 | + | let heartbeat = tokio::spawn(async move { | |
| 64 | + | let mut ticker = tokio::time::interval(HEARTBEAT_INTERVAL); | |
| 65 | + | // The claim already stamped heartbeat_at, so skip the immediate first | |
| 66 | + | // tick and beat one interval from now. | |
| 67 | + | ticker.tick().await; | |
| 68 | + | loop { | |
| 69 | + | ticker.tick().await; | |
| 70 | + | if let Err(e) = db::scan_jobs::bump_heartbeat(&hb_db, job_id).await { | |
| 71 | + | tracing::warn!(%job_id, error = %e, "scan heartbeat bump failed"); | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | }); | |
| 75 | + | let _guard = AbortOnDrop(heartbeat); | |
| 76 | + | process_job(ctx, job).await | |
| 77 | + | } | |
| 78 | + | ||
| 35 | 79 | /// Shared dependencies the worker pool needs. | |
| 36 | 80 | pub struct WorkerContext { | |
| 37 | 81 | pub db: PgPool, | |
| @@ -116,7 +160,7 @@ pub fn spawn_pool(n: usize, ctx: Arc<WorkerContext>, shutdown_rx: tokio::sync::w | |||
| 116 | 160 | match db::scan_jobs::claim_next(&ctx.db).await { | |
| 117 | 161 | Ok(Some(job)) => { | |
| 118 | 162 | let job_id = job.id; | |
| 119 | - | if let Err(e) = process_job(&ctx, job).await { | |
| 163 | + | if let Err(e) = process_job_with_heartbeat(&ctx, job).await { | |
| 120 | 164 | tracing::error!(worker_id, %job_id, error = %e, "scan job failed"); | |
| 121 | 165 | if let Err(e2) = db::scan_jobs::mark_failed(&ctx.db, job_id, &e.to_string()).await { | |
| 122 | 166 | tracing::error!(worker_id, %job_id, error = %e2, "failed to mark job failed"); |
| @@ -94,6 +94,25 @@ pub mod limits { | |||
| 94 | 94 | // Item sections | |
| 95 | 95 | pub const SECTION_TITLE_MAX: usize = 100; | |
| 96 | 96 | pub const SECTION_BODY_MAX: usize = 100_000; | |
| 97 | + | // Passwords. Measured in Unicode scalar values (`chars().count()`), NOT | |
| 98 | + | // bytes. Every password path — signup, reset, profile change, login, OAuth | |
| 99 | + | // login, and SyncKit auth — MUST cap by this same metric and value; a | |
| 100 | + | // byte-based cap on the login side rejects (permanently locks out) any | |
| 101 | + | // account whose password is <=128 chars but >128 bytes, which multibyte | |
| 102 | + | // passphrases routinely are. Route the upper-bound check through | |
| 103 | + | // `super::password_too_long`. | |
| 104 | + | pub const PASSWORD_MIN: usize = 8; | |
| 105 | + | pub const PASSWORD_MAX: usize = 128; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// True when a password exceeds the maximum length. | |
| 109 | + | /// | |
| 110 | + | /// Measured in Unicode scalar values (`chars().count()`), never bytes — see | |
| 111 | + | /// [`limits::PASSWORD_MAX`]. This is the single upper-bound check shared by the | |
| 112 | + | /// set-side paths (signup / reset / profile) and the credential-verification | |
| 113 | + | /// paths (login / OAuth / SyncKit) so the metric can never drift back apart. | |
| 114 | + | pub fn password_too_long(password: &str) -> bool { | |
| 115 | + | password.chars().count() > limits::PASSWORD_MAX | |
| 97 | 116 | } | |
| 98 | 117 | ||
| 99 | 118 | /// Validate a slug: 2-100 chars, alphanumeric + hyphens. | |
| @@ -294,6 +313,26 @@ mod tests { | |||
| 294 | 313 | assert!(validate_sync_row_id(&"a".repeat(256)).is_err()); | |
| 295 | 314 | } | |
| 296 | 315 | ||
| 316 | + | #[test] | |
| 317 | + | fn test_password_too_long_measures_chars_not_bytes() { | |
| 318 | + | // Regression (audit Run 22): the cap is Unicode scalar count, not bytes. | |
| 319 | + | // A 128-char password is accepted even when its UTF-8 encoding exceeds | |
| 320 | + | // 128 bytes — otherwise multibyte passphrases that pass signup would be | |
| 321 | + | // rejected at login (permanent self-lockout). | |
| 322 | + | assert!(!password_too_long(&"a".repeat(limits::PASSWORD_MAX))); | |
| 323 | + | assert!(password_too_long(&"a".repeat(limits::PASSWORD_MAX + 1))); | |
| 324 | + | ||
| 325 | + | // 128 CJK chars = 128 chars (at the cap) but 384 bytes — must NOT be | |
| 326 | + | // flagged too long. | |
| 327 | + | let cjk = "\u{4f60}".repeat(limits::PASSWORD_MAX); | |
| 328 | + | assert_eq!(cjk.chars().count(), limits::PASSWORD_MAX); | |
| 329 | + | assert!(cjk.len() > limits::PASSWORD_MAX, "test string must exceed the byte cap"); | |
| 330 | + | assert!(!password_too_long(&cjk)); | |
| 331 | + | ||
| 332 | + | // One char over the cap is rejected regardless of byte width. | |
| 333 | + | assert!(password_too_long(&"\u{4f60}".repeat(limits::PASSWORD_MAX + 1))); | |
| 334 | + | } | |
| 335 | + | ||
| 297 | 336 | // ── Edge cases (test-fuzz) ── | |
| 298 | 337 | ||
| 299 | 338 | #[test] |
| @@ -41,6 +41,34 @@ async fn admin_gate_rejects_anonymous_and_non_admin() { | |||
| 41 | 41 | assert_eq!(resp.status.as_u16(), 200, "health JSON stays public for non-admins"); | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | /// `/health` stays publicly reachable (200) but the live subsystem fan-out is | |
| 45 | + | /// admin-only, gated BEFORE the probes run. An anonymous caller gets a minimal | |
| 46 | + | /// cached-status page — never the admin dashboard — so an unauthenticated | |
| 47 | + | /// request can't amplify into the ~10-op DB/S3/PoM sweep (audit Run 22). | |
| 48 | + | #[tokio::test] | |
| 49 | + | async fn health_gates_full_dashboard_to_admins() { | |
| 50 | + | let (mut h, _admin_id) = TestHarness::with_admin().await; | |
| 51 | + | ||
| 52 | + | // Anonymous: minimal reachable status page, no admin fan-out content. | |
| 53 | + | h.client.post_form("/logout", "").await; | |
| 54 | + | let anon = h.client.get("/health").await; | |
| 55 | + | assert_eq!(anon.status.as_u16(), 200, "health must stay publicly reachable"); | |
| 56 | + | assert!(anon.text.contains("MakeNotWork"), "anon gets the minimal status page"); | |
| 57 | + | assert!( | |
| 58 | + | !anon.text.contains("System Health"), | |
| 59 | + | "anon must NOT receive the admin fan-out dashboard" | |
| 60 | + | ); | |
| 61 | + | ||
| 62 | + | // Admin: full live dashboard. | |
| 63 | + | h.login("admin", "password123").await; | |
| 64 | + | let admin = h.client.get("/health").await; | |
| 65 | + | assert_eq!(admin.status.as_u16(), 200); | |
| 66 | + | assert!( | |
| 67 | + | admin.text.contains("System Health"), | |
| 68 | + | "admin gets the full health dashboard" | |
| 69 | + | ); | |
| 70 | + | } | |
| 71 | + | ||
| 44 | 72 | // ── User Suspension ── | |
| 45 | 73 | ||
| 46 | 74 | #[tokio::test] |
| @@ -3,6 +3,37 @@ | |||
| 3 | 3 | use crate::harness::TestHarness; | |
| 4 | 4 | ||
| 5 | 5 | #[tokio::test] | |
| 6 | + | async fn multibyte_password_register_then_login() { | |
| 7 | + | // Regression (audit Run 22): signup capped password length by char count | |
| 8 | + | // (`chars().count()`) while login/OAuth/SyncKit capped by BYTE count | |
| 9 | + | // (`.len()`). A password of <=128 chars but >128 bytes — any multibyte | |
| 10 | + | // passphrase near the cap — registered fine, then was silently rejected at | |
| 11 | + | // every subsequent login: a permanent, undiagnosable self-lockout. | |
| 12 | + | // | |
| 13 | + | // 65 CJK chars = 65 chars (well under the 128-char signup cap) = 195 bytes | |
| 14 | + | // (over the old 128-byte login cap). Under the bug, signup succeeds but the | |
| 15 | + | // login below never establishes a session. | |
| 16 | + | let password = "\u{4f60}".repeat(65); // 你 x65 | |
| 17 | + | assert_eq!(password.chars().count(), 65); | |
| 18 | + | assert!(password.len() > 128, "test password must exceed 128 bytes"); | |
| 19 | + | ||
| 20 | + | let mut h = TestHarness::new().await; | |
| 21 | + | let _user_id = h.signup("mb_user", "mb@example.com", &password).await; | |
| 22 | + | h.client.post_form("/logout", "").await; | |
| 23 | + | ||
| 24 | + | // Log back in with the identical multibyte password. | |
| 25 | + | h.login("mb_user", &password).await; | |
| 26 | + | ||
| 27 | + | // A failed login renders 200 with an error but sets no session, so the | |
| 28 | + | // definitive check is that the authenticated dashboard is reachable. | |
| 29 | + | let resp = h.client.get("/dashboard").await; | |
| 30 | + | assert_eq!( | |
| 31 | + | resp.status, 200, | |
| 32 | + | "multibyte-password account must be able to log back in" | |
| 33 | + | ); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[tokio::test] | |
| 6 | 37 | async fn signup_login_logout_flow() { | |
| 7 | 38 | let mut h = TestHarness::new().await; | |
| 8 | 39 |
| @@ -0,0 +1,158 @@ | |||
| 1 | + | //! DB-layer contract tests for the scan-job reaper's liveness heartbeat. | |
| 2 | + | //! | |
| 3 | + | //! Audit Run 22: `reap_stuck` keyed off claim-time `started_at`, which spans the | |
| 4 | + | //! full S3 download + scan, so a slow-but-progressing large scan was reaped and | |
| 5 | + | //! double-processed — each re-claim inflating `attempts` until a valid file was | |
| 6 | + | //! force-retired to `failed` and the entity stranded at HeldForReview. The | |
| 7 | + | //! reaper now keys off `heartbeat_at` (falling back to `started_at`). These pin | |
| 8 | + | //! that a fresh beat spares a long-running job, a stale beat is still reaped, the | |
| 9 | + | //! NULL fallback works, and `bump_heartbeat` refreshes liveness without | |
| 10 | + | //! resurrecting a terminal row. | |
| 11 | + | ||
| 12 | + | use crate::harness::TestHarness; | |
| 13 | + | use makenotwork::db::scan_jobs::{self, ScanTargetKind}; | |
| 14 | + | use makenotwork::db::UserId; | |
| 15 | + | use makenotwork::storage::FileType; | |
| 16 | + | use uuid::Uuid; | |
| 17 | + | ||
| 18 | + | async fn seed_user(h: &TestHarness, name: &str) -> UserId { | |
| 19 | + | let hash = makenotwork::auth::hash_password("password123").expect("hash"); | |
| 20 | + | sqlx::query_scalar::<_, UserId>( | |
| 21 | + | "INSERT INTO users (username, email, password_hash, email_verified) | |
| 22 | + | VALUES ($1, $2, $3, true) RETURNING id", | |
| 23 | + | ) | |
| 24 | + | .bind(name) | |
| 25 | + | .bind(format!("{name}@test.com")) | |
| 26 | + | .bind(&hash) | |
| 27 | + | .fetch_one(&h.db) | |
| 28 | + | .await | |
| 29 | + | .expect("seed user") | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | async fn status_of(h: &TestHarness, job: Uuid) -> String { | |
| 33 | + | sqlx::query_scalar::<_, String>("SELECT status FROM scan_jobs WHERE id = $1") | |
| 34 | + | .bind(job) | |
| 35 | + | .fetch_one(&h.db) | |
| 36 | + | .await | |
| 37 | + | .expect("status") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Enqueue one job and claim it (→ running, `started_at` and `heartbeat_at` | |
| 41 | + | /// both stamped NOW()). | |
| 42 | + | async fn enqueue_and_claim(h: &TestHarness, user: UserId, key: &str) -> Uuid { | |
| 43 | + | scan_jobs::enqueue( | |
| 44 | + | &h.db, | |
| 45 | + | ScanTargetKind::Item, | |
| 46 | + | Uuid::new_v4(), | |
| 47 | + | key, | |
| 48 | + | FileType::Download, | |
| 49 | + | user, | |
| 50 | + | 1000, | |
| 51 | + | ) | |
| 52 | + | .await | |
| 53 | + | .expect("enqueue"); | |
| 54 | + | let job = scan_jobs::claim_next(&h.db) | |
| 55 | + | .await | |
| 56 | + | .expect("claim") | |
| 57 | + | .expect("a queued job to claim"); | |
| 58 | + | assert_eq!(job.status, "running"); | |
| 59 | + | assert!(job.heartbeat_at.is_some(), "claim stamps heartbeat_at"); | |
| 60 | + | job.id | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[tokio::test] | |
| 64 | + | async fn reaper_spares_slow_but_alive_job() { | |
| 65 | + | let h = TestHarness::new().await; | |
| 66 | + | let user = seed_user(&h, "reaper_alive").await; | |
| 67 | + | let job = enqueue_and_claim(&h, user, "k/alive").await; | |
| 68 | + | ||
| 69 | + | // A job running far past the stuck window but whose worker is still beating: | |
| 70 | + | // old started_at, fresh heartbeat. This is the exact case that used to be | |
| 71 | + | // reaped and double-processed. | |
| 72 | + | sqlx::query( | |
| 73 | + | "UPDATE scan_jobs SET started_at = NOW() - interval '1 hour', heartbeat_at = NOW() WHERE id = $1", | |
| 74 | + | ) | |
| 75 | + | .bind(job) | |
| 76 | + | .execute(&h.db) | |
| 77 | + | .await | |
| 78 | + | .expect("backdate started, fresh beat"); | |
| 79 | + | ||
| 80 | + | let reaped = scan_jobs::reap_stuck(&h.db, 300).await.expect("reap"); | |
| 81 | + | assert_eq!(reaped, 0, "a job with a fresh heartbeat must not be reaped"); | |
| 82 | + | assert_eq!(status_of(&h, job).await, "running"); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | #[tokio::test] | |
| 86 | + | async fn reaper_reaps_crashed_worker() { | |
| 87 | + | let h = TestHarness::new().await; | |
| 88 | + | let user = seed_user(&h, "reaper_dead").await; | |
| 89 | + | let job = enqueue_and_claim(&h, user, "k/dead").await; | |
| 90 | + | ||
| 91 | + | // Worker crashed: no more beats. | |
| 92 | + | sqlx::query("UPDATE scan_jobs SET heartbeat_at = NOW() - interval '1 hour' WHERE id = $1") | |
| 93 | + | .bind(job) | |
| 94 | + | .execute(&h.db) | |
| 95 | + | .await | |
| 96 | + | .expect("stale beat"); | |
| 97 | + | ||
| 98 | + | let reaped = scan_jobs::reap_stuck(&h.db, 300).await.expect("reap"); | |
| 99 | + | assert_eq!(reaped, 1, "a stale-heartbeat job must be reaped"); | |
| 100 | + | // attempts after a single claim = 1 < MAX_SCAN_ATTEMPTS, so it returns to | |
| 101 | + | // the queue for another attempt rather than being retired to failed. | |
| 102 | + | assert_eq!(status_of(&h, job).await, "queued"); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | #[tokio::test] | |
| 106 | + | async fn reaper_falls_back_to_started_at_when_no_heartbeat() { | |
| 107 | + | // Any row with heartbeat_at NULL (e.g. one in flight across the migration) | |
| 108 | + | // stays reapable on the old started_at clock via COALESCE. | |
| 109 | + | let h = TestHarness::new().await; | |
| 110 | + | let user = seed_user(&h, "reaper_null").await; | |
| 111 | + | let job = enqueue_and_claim(&h, user, "k/null").await; | |
| 112 | + | ||
| 113 | + | sqlx::query( | |
| 114 | + | "UPDATE scan_jobs SET heartbeat_at = NULL, started_at = NOW() - interval '1 hour' WHERE id = $1", | |
| 115 | + | ) | |
| 116 | + | .bind(job) | |
| 117 | + | .execute(&h.db) | |
| 118 | + | .await | |
| 119 | + | .expect("null beat, old start"); | |
| 120 | + | ||
| 121 | + | let reaped = scan_jobs::reap_stuck(&h.db, 300).await.expect("reap"); | |
| 122 | + | assert_eq!(reaped, 1, "null heartbeat falls back to started_at"); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[tokio::test] | |
| 126 | + | async fn bump_heartbeat_refreshes_and_never_resurrects() { | |
| 127 | + | let h = TestHarness::new().await; | |
| 128 | + | let user = seed_user(&h, "reaper_bump").await; | |
| 129 | + | let job = enqueue_and_claim(&h, user, "k/bump").await; | |
| 130 | + | ||
| 131 | + | // A stale beat would be reaped... | |
| 132 | + | sqlx::query("UPDATE scan_jobs SET heartbeat_at = NOW() - interval '1 hour' WHERE id = $1") | |
| 133 | + | .bind(job) | |
| 134 | + | .execute(&h.db) | |
| 135 | + | .await | |
| 136 | + | .expect("stale"); | |
| 137 | + | // ...but a bump refreshes it, so the reaper leaves it alone. | |
| 138 | + | scan_jobs::bump_heartbeat(&h.db, job).await.expect("bump"); | |
| 139 | + | let reaped = scan_jobs::reap_stuck(&h.db, 300).await.expect("reap"); | |
| 140 | + | assert_eq!(reaped, 0, "bump_heartbeat must refresh liveness"); | |
| 141 | + | assert_eq!(status_of(&h, job).await, "running"); | |
| 142 | + | ||
| 143 | + | // A late bump after the job leaves `running` is a no-op — it must never | |
| 144 | + | // resurrect a terminal row's heartbeat. | |
| 145 | + | sqlx::query("UPDATE scan_jobs SET status = 'done', heartbeat_at = NULL WHERE id = $1") | |
| 146 | + | .bind(job) | |
| 147 | + | .execute(&h.db) | |
| 148 | + | .await | |
| 149 | + | .expect("mark done"); | |
| 150 | + | scan_jobs::bump_heartbeat(&h.db, job).await.expect("late bump"); | |
| 151 | + | let still_null: bool = | |
| 152 | + | sqlx::query_scalar("SELECT heartbeat_at IS NULL FROM scan_jobs WHERE id = $1") | |
| 153 | + | .bind(job) | |
| 154 | + | .fetch_one(&h.db) | |
| 155 | + | .await | |
| 156 | + | .expect("hb null check"); | |
| 157 | + | assert!(still_null, "bump must not touch a job that has left running"); | |
| 158 | + | } |
| @@ -98,6 +98,7 @@ mod cart; | |||
| 98 | 98 | mod bundles; | |
| 99 | 99 | mod idempotency; | |
| 100 | 100 | mod db_items_layer; | |
| 101 | + | mod db_scan_jobs_layer; | |
| 101 | 102 | mod db_payments_layer; | |
| 102 | 103 | mod db_synckit_billing_layer; | |
| 103 | 104 | mod db_transactions_layer; |