Skip to main content

headgate_postgres/
inspect.rs

1//! control plane the inspection/control surface on Postgres.
2//!
3//! Every read is BOUNDED (invariant 6): counts scan at most `SAMPLE_LIMIT` rows and set
4//! `approximate` instead of paying for exactness — asynq's GetQueueInfo pinned Redis CPU
5//! for seconds in production, and monitoring caused that outage.
6
7use headgate_core::{
8    AdmissionExplain, BlockedBy, BulkRequest, Checkpoint, CheckpointInspect,
9    ConcurrencyLimitConfig, HistoryBucket, Inspect, JobFilter, JobOutput, JobPage, JobProgress,
10    JobResult, JobSummary, MissedPolicy, OperationStatus, OutputInspect, PartitionState,
11    ProgressInspect, QuarantineEntry, QueueStats, QuietGroupMetrics, RateClassConfig,
12    RateClassState, ResultInspect, SCHEDULE_EVENT_LIMIT, SaturationStrategy, Schedule,
13    ScheduleEvent, ScheduleEventOutcome, StateCounts, StoreError, WorkerMeta, noisy_partition_keys,
14};
15use tokio_postgres::types::ToSql;
16
17use crate::{NOW_MS, PgStore, decode_headers, map_pg_err};
18
19/// The most rows any counting query may touch. Past this, counts are approximate.
20const SAMPLE_LIMIT: i64 = 50_000;
21/// Queue-position lookups cap here; "position >= 1000" is answer enough.
22const POSITION_LIMIT: i64 = 1_000;
23const QUIET_PARTITION_LIMIT: i64 = 1_000;
24pub(crate) const MAX_PAGE: u32 = 200;
25
26fn job_from_row(row: &tokio_postgres::Row, include_payload: bool) -> JobSummary {
27    JobSummary {
28        id: row.get("ulid"),
29        kind: row.get("kind"),
30        queue: row.get("queue"),
31        state: row.get("state_text"),
32        schema_version: row.get::<_, i32>("schema_version") as u32,
33        priority: row.get("priority"),
34        attempt: row.get::<_, i32>("attempt") as u32,
35        crash_attempt: row.get::<_, i32>("crash_attempt") as u32,
36        max_attempts: row.get::<_, i32>("max_attempts") as u32,
37        partition_key: row.get("partition_key"),
38        rate_class: row.get("rate_class"),
39        sticky_worker: row.get("sticky_worker"),
40        weight: row.get::<_, i32>("weight") as u32,
41        fingerprint: row.get("fingerprint"),
42        enqueued_at_ms: row.get("enqueued_at_ms"),
43        scheduled_at_ms: row.get("scheduled_at_ms"),
44        claimed_at_ms: row.get("claimed_at_ms"),
45        periodic_schedule_id: row.get("periodic_schedule_id"),
46        periodic_tick_ms: row.get("periodic_tick_ms"),
47        finalized_at_ms: row.get("finalized_at_ms"),
48        payload: if include_payload {
49            Some(row.get("payload"))
50        } else {
51            None
52        },
53        headers: if include_payload {
54            decode_headers(row.get("headers"))
55        } else {
56            Default::default()
57        },
58        errors_json: row.get("errors_text"),
59        tags: serde_json::from_str(row.get("tags_text")).unwrap_or_default(),
60    }
61}
62
63const JOB_COLS: &str = "j.ulid, j.kind, j.queue, j.state::text AS state_text, \
64     j.schema_version, j.priority, j.attempt, j.crash_attempt, j.max_attempts, \
65     j.partition_key, j.rate_class, j.sticky_worker, j.weight, j.fingerprint, j.enqueued_at_ms, j.scheduled_at_ms, j.claimed_at_ms, \
66     j.periodic_schedule_id, j.periodic_tick_ms, j.finalized_at_ms, j.payload, j.headers, \
67     j.errors::text AS errors_text, j.id, COALESCE((SELECT json_agg(t.tag ORDER BY t.tag) FROM headgate_job_tag t WHERE t.job_id=j.id),'[]')::text AS tags_text";
68
69#[async_trait::async_trait]
70impl Inspect for PgStore {
71    fn as_result_inspect(&self) -> Option<&dyn ResultInspect> {
72        Some(self)
73    }
74
75    fn as_output_inspect(&self) -> Option<&dyn OutputInspect> {
76        Some(self)
77    }
78
79    fn as_progress_inspect(&self) -> Option<&dyn ProgressInspect> {
80        Some(self)
81    }
82
83    fn as_checkpoint_inspect(&self) -> Option<&dyn CheckpointInspect> {
84        Some(self)
85    }
86
87    async fn get_job(
88        &self,
89        id: &str,
90        include_payload: bool,
91    ) -> Result<Option<JobSummary>, StoreError> {
92        let c = self.client().await?;
93        let row = c
94            .query_opt(
95                &format!("SELECT {JOB_COLS} FROM headgate_job j WHERE j.ulid = $1"),
96                &[&id],
97            )
98            .await
99            .map_err(map_pg_err)?;
100        Ok(row.map(|r| job_from_row(&r, include_payload)))
101    }
102
103    async fn list_jobs(
104        &self,
105        filter: &JobFilter,
106        cursor: Option<&str>,
107        limit: u32,
108    ) -> Result<JobPage, StoreError> {
109        let limit = limit.clamp(1, MAX_PAGE) as i64;
110        let c = self.client().await?;
111        let mut clauses: Vec<String> = Vec::new();
112        let mut params: Vec<&(dyn ToSql + Sync)> = Vec::new();
113        macro_rules! bind {
114            ($v:expr, $sql:expr) => {
115                if let Some(v) = $v {
116                    params.push(v);
117                    clauses.push(format!($sql, params.len()));
118                }
119            };
120        }
121        bind!(filter.queue.as_ref(), "j.queue = ${}");
122        bind!(filter.kind.as_ref(), "j.kind = ${}");
123        bind!(filter.kind_prefix.as_ref(), "starts_with(j.kind, ${})");
124        bind!(filter.partition_key.as_ref(), "j.partition_key = ${}");
125        bind!(filter.state.as_ref(), "j.state::text = ${}");
126        bind!(filter.id.as_ref(), "j.ulid = ${}");
127        bind!(filter.fingerprint.as_ref(), "j.fingerprint = ${}");
128        bind!(filter.rate_class.as_ref(), "j.rate_class = ${}");
129        bind!(filter.priority.as_ref(), "j.priority = ${}");
130        if !filter.tags_all.is_empty() {
131            params.push(&filter.tags_all);
132            clauses.push(format!("NOT EXISTS (SELECT 1 FROM unnest(${}::text[]) want(tag) WHERE NOT EXISTS (SELECT 1 FROM headgate_job_tag jt WHERE jt.job_id=j.id AND jt.tag=want.tag))", params.len()));
133        }
134        if !filter.tags_any.is_empty() {
135            params.push(&filter.tags_any);
136            clauses.push(format!("EXISTS (SELECT 1 FROM headgate_job_tag jt WHERE jt.job_id=j.id AND jt.tag=ANY(${}::text[]))", params.len()));
137        }
138        // Newest first; the cursor is the last row's internal id.
139        let cursor_id: i64 = match cursor {
140            Some(cur) => cur
141                .parse()
142                .map_err(|_| StoreError::Invalid("bad cursor".into()))?,
143            None => i64::MAX,
144        };
145        params.push(&cursor_id);
146        clauses.push(format!("j.id < ${}", params.len()));
147        params.push(&limit);
148        let sql = format!(
149            "SELECT {JOB_COLS} FROM headgate_job j WHERE {} ORDER BY j.id DESC LIMIT ${}",
150            clauses.join(" AND "),
151            params.len()
152        );
153        let rows = c.query(&sql, &params).await.map_err(map_pg_err)?;
154        let next_cursor = if rows.len() as i64 == limit {
155            rows.last().map(|r| r.get::<_, i64>("id").to_string())
156        } else {
157            None
158        };
159        Ok(JobPage {
160            jobs: rows.iter().map(|r| job_from_row(r, false)).collect(),
161            next_cursor,
162        })
163    }
164
165    async fn counts(&self, queue: Option<&str>) -> Result<StateCounts, StoreError> {
166        let c = self.client().await?;
167        let rows = c
168            .query(
169                "WITH sample AS (
170                   SELECT state FROM headgate_job
171                   WHERE ($1::text IS NULL OR queue = $1)
172                   LIMIT $2
173                 )
174                 SELECT state::text, count(*)::bigint FROM sample GROUP BY 1",
175                &[&queue, &SAMPLE_LIMIT],
176            )
177            .await
178            .map_err(map_pg_err)?;
179        let counts: Vec<(String, i64)> = rows.iter().map(|r| (r.get(0), r.get(1))).collect();
180        let total: i64 = counts.iter().map(|(_, n)| n).sum();
181        Ok(StateCounts {
182            counts,
183            approximate: total >= SAMPLE_LIMIT,
184        })
185    }
186
187    async fn queue_stats(&self) -> Result<Vec<QueueStats>, StoreError> {
188        let c = self.client().await?;
189        // Queue discovery is bounded: configured queues, recently active counters, and
190        // a bounded sample of job rows.
191        let sql = format!(
192            r#"
193            WITH p AS (SELECT {NOW_MS} AS now_ms),
194            sample AS (SELECT queue, state FROM headgate_job LIMIT $1),
195            names AS (
196              SELECT queue FROM headgate_queue_state
197              UNION SELECT queue FROM headgate_enqueue_policy
198              UNION SELECT queue FROM headgate_queue_counter, p
199                    WHERE bucket_ms >= p.now_ms - 3600000
200              UNION SELECT DISTINCT queue FROM sample
201            ),
202            by_state AS (
203              SELECT queue, state::text AS state, count(*)::bigint AS n
204              FROM sample GROUP BY 1, 2
205            ),
206            rates AS (
207              SELECT c.queue,
208                     sum(c.arrived)::float8 / 60.0 AS arrival,
209                     sum(c.completed)::float8 / 60.0 AS drain
210              FROM headgate_queue_counter c, p
211              WHERE c.bucket_ms >= (p.now_ms / 60000 * 60000) - 60000
212              GROUP BY 1
213            )
214            SELECT n.queue,
215                   p.now_ms,
216                   COALESCE(qs.paused, false) AS paused,
217                   COALESCE(qs.weight, 1) AS weight,
218                   COALESCE(r.arrival, 0) AS arrival,
219                   COALESCE(r.drain, 0) AS drain,
220                   COALESCE((SELECT json_agg(json_build_array(b.state, b.n))
221                             FROM by_state b WHERE b.queue = n.queue), '[]'::json)::text AS states,
222                   (SELECT count(*) FROM sample) >= $1 AS approx,
223                   (SELECT j.scheduled_at_ms FROM headgate_job j
224                    WHERE j.queue = n.queue AND j.state = 'available'
225                    ORDER BY j.scheduled_at_ms, j.id LIMIT 1) AS oldest_available_at_ms,
226                   ep.max_unfinished_jobs,
227                   COALESCE(ent.n, 0) AS entered,
228                   COALESCE(ext.n, 0) AS exited,
229                   samp.memory_bytes
230            FROM names n CROSS JOIN p
231            LEFT JOIN headgate_queue_state qs ON qs.queue = n.queue
232            LEFT JOIN rates r ON r.queue = n.queue
233            LEFT JOIN headgate_enqueue_policy ep ON ep.queue = n.queue
234            LEFT JOIN headgate_enqueue_counter ent
235              ON ent.queue = n.queue AND ent.counter_kind = 'entered'
236            LEFT JOIN headgate_enqueue_counter ext
237              ON ext.queue = n.queue AND ext.counter_kind = 'exited'
238            LEFT JOIN headgate_queue_sample samp ON samp.queue = n.queue
239            ORDER BY n.queue
240            "#
241        );
242        let rows = c.query(&sql, &[&SAMPLE_LIMIT]).await.map_err(map_pg_err)?;
243        let mut out = Vec::with_capacity(rows.len());
244        for row in &rows {
245            let queue: String = row.get("queue");
246            let arrival: f64 = row.get("arrival");
247            let drain: f64 = row.get("drain");
248            let now_ms: i64 = row.get("now_ms");
249            let oldest_available_ms = row
250                .get::<_, Option<i64>>("oldest_available_at_ms")
251                .map(|at| (now_ms - at).max(0));
252            let states: Vec<(String, i64)> =
253                serde_json::from_str::<serde_json::Value>(row.get("states"))
254                    .ok()
255                    .and_then(|v| v.as_array().cloned())
256                    .map(|arr| {
257                        arr.iter()
258                            .filter_map(|pair| {
259                                Some((pair.get(0)?.as_str()?.to_string(), pair.get(1)?.as_i64()?))
260                            })
261                            .collect()
262                    })
263                    .unwrap_or_default();
264            let entered: i64 = row.get("entered");
265            let exited: i64 = row.get("exited");
266            let unfinished_jobs = entered.saturating_sub(exited).max(0) as u64;
267            let backlog = unfinished_jobs.min(i64::MAX as u64) as i64;
268            // backlog metrics time-to-drain: null when arrival >= drain — the alert condition.
269            let ttd = if drain > arrival && drain > 0.0 {
270                Some(((backlog as f64) / (drain - arrival) * 1000.0) as i64)
271            } else {
272                None
273            };
274            // backlog metrics quiet-group metrics. The partition list and backlog sample are both
275            // hard bounded; each oldest lookup is a one-row probe on the per-partition
276            // partial index. A noisy tenant's depth therefore cannot become admin work.
277            let part_rows = c
278                .query(
279                    &format!(
280                        r#"
281                        WITH names AS (
282                          SELECT partition_key FROM headgate_active_partition WHERE queue = $1
283                          UNION SELECT partition_key FROM headgate_inflight
284                                WHERE queue = $1 AND n > 0
285                          UNION SELECT partition_key FROM headgate_partition_counter
286                                WHERE queue = $1 AND bucket_ms >= $2
287                          ORDER BY 1 LIMIT $3
288                        ), rates AS (
289                          SELECT partition_key, sum(arrived)::bigint AS arrived,
290                                 sum(completed)::bigint AS completed
291                          FROM headgate_partition_counter
292                          WHERE queue = $1 AND bucket_ms >= $2 GROUP BY 1
293                        )
294                        SELECT n.partition_key, COALESCE(i.n, 0)::bigint AS inflight,
295                               COALESCE(r.arrived, 0)::bigint AS arrived,
296                               COALESCE(r.completed, 0)::bigint AS completed,
297                               (SELECT j.scheduled_at_ms FROM headgate_job j
298                                WHERE j.queue = $1 AND j.partition_key = n.partition_key
299                                  AND j.state = 'available'
300                                ORDER BY j.scheduled_at_ms, j.id LIMIT 1) AS oldest_at
301                        FROM names n
302                        LEFT JOIN headgate_inflight i
303                          ON i.queue = $1 AND i.partition_key = n.partition_key
304                        LEFT JOIN rates r ON r.partition_key = n.partition_key
305                        ORDER BY n.partition_key
306                        "#
307                    ),
308                    &[
309                        &queue,
310                        &(now_ms / 60000 * 60000 - 60000),
311                        &(QUIET_PARTITION_LIMIT + 1),
312                    ],
313                )
314                .await
315                .map_err(map_pg_err)?;
316            let part_approx = part_rows.len() as i64 > QUIET_PARTITION_LIMIT;
317            let part_rows = &part_rows[..part_rows.len().min(QUIET_PARTITION_LIMIT as usize)];
318            let loads: Vec<(String, i64)> = part_rows
319                .iter()
320                .map(|r| (r.get("partition_key"), r.get("inflight")))
321                .collect();
322            let noisy = noisy_partition_keys(&loads);
323            let quiet_parts: Vec<String> = loads
324                .iter()
325                .filter(|(p, _)| !noisy.contains(p))
326                .map(|(p, _)| p.clone())
327                .collect();
328            let quiet_arrived: i64 = part_rows
329                .iter()
330                .filter(|r| !noisy.contains(&r.get::<_, String>("partition_key")))
331                .map(|r| r.get::<_, i64>("arrived"))
332                .sum();
333            let quiet_completed: i64 = part_rows
334                .iter()
335                .filter(|r| !noisy.contains(&r.get::<_, String>("partition_key")))
336                .map(|r| r.get::<_, i64>("completed"))
337                .sum();
338            let quiet_oldest_at = part_rows
339                .iter()
340                .filter(|r| !noisy.contains(&r.get::<_, String>("partition_key")))
341                .filter_map(|r| r.get::<_, Option<i64>>("oldest_at"))
342                .min();
343            let quiet_backlog: i64 = if quiet_parts.is_empty() {
344                0
345            } else {
346                c.query_one(
347                    "SELECT count(*)::bigint FROM (
348                       SELECT 1 FROM headgate_job
349                       WHERE queue = $1 AND partition_key = ANY($2)
350                         AND state = ANY(ARRAY['pending','scheduled','available','running','retryable']::headgate_state[])
351                       LIMIT $3
352                     ) bounded",
353                    &[&queue, &quiet_parts, &SAMPLE_LIMIT],
354                )
355                .await
356                .map_err(map_pg_err)?
357                .get(0)
358            };
359            let (quiet_arrival, quiet_drain) =
360                (quiet_arrived as f64 / 60.0, quiet_completed as f64 / 60.0);
361            let quiet_ttd = if quiet_drain > quiet_arrival && quiet_drain > 0.0 {
362                Some((quiet_backlog as f64 / (quiet_drain - quiet_arrival) * 1000.0) as i64)
363            } else {
364                None
365            };
366            let quiet_groups = QuietGroupMetrics {
367                arrival_rate: quiet_arrival,
368                drain_rate: quiet_drain,
369                time_to_drain_ms: quiet_ttd,
370                oldest_available_ms: quiet_oldest_at.map(|at| (now_ms - at).max(0)),
371                noisy_partitions: noisy.len() as u32,
372                approximate: part_approx || quiet_backlog >= SAMPLE_LIMIT,
373            };
374            out.push(QueueStats {
375                queue,
376                weight: row.get::<_, i32>("weight") as u32,
377                unfinished_jobs,
378                max_unfinished_jobs: row
379                    .get::<_, Option<i64>>("max_unfinished_jobs")
380                    .map(|n| n as u64),
381                by_state: states,
382                counts_approximate: row.get("approx"),
383                arrival_rate: arrival,
384                drain_rate: drain,
385                time_to_drain_ms: ttd,
386                oldest_available_ms,
387                quiet_groups,
388                paused: row.get("paused"),
389                memory_bytes: row.get::<_, Option<i64>>("memory_bytes").map(|n| n as u64),
390            });
391        }
392        Ok(out)
393    }
394
395    async fn set_queue_paused(&self, queue: &str, paused: bool) -> Result<(), StoreError> {
396        let c = self.client().await?;
397        c.execute(
398            "INSERT INTO headgate_queue_state (queue, paused) VALUES ($1, $2)
399             ON CONFLICT (queue) DO UPDATE SET paused = EXCLUDED.paused",
400            &[&queue, &paused],
401        )
402        .await
403        .map_err(map_pg_err)?;
404        Ok(())
405    }
406
407    async fn set_queue_weight(&self, queue: &str, weight: u32) -> Result<(), StoreError> {
408        if weight == 0 {
409            return Err(StoreError::Invalid("weight must be >= 1".into()));
410        }
411        let weight =
412            i32::try_from(weight).map_err(|_| StoreError::Invalid("weight is too large".into()))?;
413        let c = self.client().await?;
414        c.execute(
415            "INSERT INTO headgate_queue_state (queue, weight) VALUES ($1, $2)
416             ON CONFLICT (queue) DO UPDATE SET
417               dispatch_count = floor(headgate_queue_state.dispatch_count::numeric
418                                      * EXCLUDED.weight / headgate_queue_state.weight)::bigint,
419               weight = EXCLUDED.weight",
420            &[&queue, &weight],
421        )
422        .await
423        .map_err(map_pg_err)?;
424        Ok(())
425    }
426
427    async fn set_enqueue_limit(
428        &self,
429        queue: &str,
430        max_unfinished_jobs: Option<u64>,
431    ) -> Result<(), StoreError> {
432        let limit = max_unfinished_jobs
433            .map(i64::try_from)
434            .transpose()
435            .map_err(|_| StoreError::Invalid("max_unfinished_jobs is too large".into()))?;
436        let c = self.client().await?;
437        c.execute(
438            "INSERT INTO headgate_enqueue_policy (queue, max_unfinished_jobs)
439             VALUES ($1, $2)
440             ON CONFLICT (queue) DO UPDATE
441               SET max_unfinished_jobs = EXCLUDED.max_unfinished_jobs",
442            &[&queue, &limit],
443        )
444        .await
445        .map_err(map_pg_err)?;
446        Ok(())
447    }
448
449    async fn rate_classes(&self) -> Result<Vec<RateClassState>, StoreError> {
450        let c = self.client().await?;
451        let sql = format!(
452            r#"
453            WITH p AS (SELECT {NOW_MS} AS now_ms)
454            SELECT b.name, b.burst, b.limit_per_window, b.window_ms,
455                   CASE WHEN b.limit_per_window > 0 AND b.window_ms > 0
456                        THEN LEAST(b.burst, b.tokens +
457                             ((p.now_ms - b.refilled_at_ms) * b.limit_per_window / b.window_ms))
458                        ELSE b.tokens END AS avail,
459                   (SELECT count(*) FROM (
460                      SELECT 1 FROM headgate_job w
461                      WHERE w.state = 'available' AND w.rate_class = b.name LIMIT $1
462                   ) t)::bigint AS waiting
463            FROM headgate_rate_bucket b, p
464            ORDER BY b.name
465            "#
466        );
467        let rows = c
468            .query(&sql, &[&POSITION_LIMIT])
469            .await
470            .map_err(map_pg_err)?;
471        Ok(rows
472            .iter()
473            .map(|r| {
474                let limit: i64 = r.get("limit_per_window");
475                RateClassState {
476                    name: r.get("name"),
477                    tokens_available: r.get("avail"),
478                    burst: r.get("burst"),
479                    limit_per_window: limit,
480                    window_ms: r.get("window_ms"),
481                    jobs_waiting: r.get("waiting"),
482                    // The kill switch is limit 0 + empty bucket (see upsert): the gate
483                    // needs no new predicate to honor it.
484                    paused: limit == 0,
485                }
486            })
487            .collect())
488    }
489
490    async fn upsert_rate_class(&self, cfg: &RateClassConfig) -> Result<(), StoreError> {
491        if cfg.window_ms < 1 {
492            // boundary validation a window that rounds to zero is an error naming the minimum — and it
493            // would divide by zero in the refill.
494            return Err(StoreError::Invalid("window_ms must be >= 1".into()));
495        }
496        if cfg.limit < 0 || cfg.burst < 1 {
497            return Err(StoreError::Invalid(
498                "limit must be >= 0 and burst >= 1".into(),
499            ));
500        }
501        let c = self.client().await?;
502        // Invariant 16, and the `paused` kill switch: paused = limit 0 AND tokens 0, so
503        // refill adds nothing and rank_class <= 0 never admits. Unpausing restores the
504        // limit; tokens refill gradually from 0 rather than bursting on resume.
505        let (limit, tokens_insert) = if cfg.paused {
506            (0i64, 0i64)
507        } else {
508            (cfg.limit, cfg.burst)
509        };
510        let sql = format!(
511            r#"
512            INSERT INTO headgate_rate_bucket
513                   (name, tokens, burst, limit_per_window, window_ms, refilled_at_ms)
514            SELECT $1, $2, $3, $4, $5, {NOW_MS}
515            ON CONFLICT (name) DO UPDATE SET
516              burst = EXCLUDED.burst,
517              limit_per_window = EXCLUDED.limit_per_window,
518              window_ms = EXCLUDED.window_ms,
519              tokens = CASE WHEN $6 THEN 0
520                            ELSE LEAST(headgate_rate_bucket.tokens, EXCLUDED.burst) END,
521              refilled_at_ms = EXCLUDED.refilled_at_ms
522            "#
523        );
524        c.execute(
525            &sql,
526            &[
527                &cfg.name,
528                &tokens_insert,
529                &cfg.burst,
530                &limit,
531                &cfg.window_ms,
532                &cfg.paused,
533            ],
534        )
535        .await
536        .map_err(map_pg_err)?;
537        Ok(())
538    }
539
540    async fn concurrency_limits(&self) -> Result<Vec<ConcurrencyLimitConfig>, StoreError> {
541        let c = self.client().await?;
542        let rows = c
543            .query(
544                "SELECT name, queue, max_concurrent, on_saturated
545                 FROM headgate_concurrency_limit ORDER BY name",
546                &[],
547            )
548            .await
549            .map_err(map_pg_err)?;
550        rows.iter()
551            .map(|r| {
552                let strategy: String = r.get("on_saturated");
553                Ok(ConcurrencyLimitConfig {
554                    name: r.get("name"),
555                    queue: r.get("queue"),
556                    max_concurrent: r.get::<_, i64>("max_concurrent") as u64,
557                    on_saturated: SaturationStrategy::try_from(strategy.as_str())?,
558                })
559            })
560            .collect()
561    }
562
563    async fn upsert_concurrency_limit(
564        &self,
565        cfg: &ConcurrencyLimitConfig,
566    ) -> Result<(), StoreError> {
567        if cfg.name.is_empty() || cfg.queue.is_empty() {
568            return Err(StoreError::Invalid(
569                "name and queue must not be empty".into(),
570            ));
571        }
572        if cfg.max_concurrent == 0 {
573            return Err(StoreError::Invalid("max_concurrent must be >= 1".into()));
574        }
575        let max_concurrent = i64::try_from(cfg.max_concurrent)
576            .map_err(|_| StoreError::Invalid("max_concurrent is too large".into()))?;
577        let c = self.client().await?;
578        c.execute(
579            "INSERT INTO headgate_concurrency_limit
580                    (name, queue, max_concurrent, on_saturated)
581             VALUES ($1, $2, $3, $4)
582             ON CONFLICT (name) DO UPDATE SET
583               queue = EXCLUDED.queue,
584               max_concurrent = EXCLUDED.max_concurrent,
585               on_saturated = EXCLUDED.on_saturated",
586            &[
587                &cfg.name,
588                &cfg.queue,
589                &max_concurrent,
590                &cfg.on_saturated.as_str(),
591            ],
592        )
593        .await
594        .map_err(map_pg_err)?;
595        Ok(())
596    }
597
598    async fn partitions(&self, queue: &str) -> Result<Vec<PartitionState>, StoreError> {
599        let c = self.client().await?;
600        let rows = c
601            .query(
602                "WITH sample AS (
603                   SELECT partition_key FROM headgate_job
604                   WHERE queue = $1 AND state = 'available' LIMIT $2
605                 ),
606                 waiting AS (
607                   SELECT partition_key, count(*)::bigint AS n FROM sample GROUP BY 1
608                 )
609                 SELECT COALESCE(w.partition_key, d.partition_key) AS partition_key,
610                        COALESCE(d.deficit, 0) AS deficit,
611                        COALESCE(w.n, 0) AS waiting
612                 FROM waiting w
613                 FULL OUTER JOIN headgate_partition_deficit d
614                   ON d.queue = $1 AND d.partition_key = w.partition_key
615                 WHERE d.queue IS NULL OR d.queue = $1
616                 ORDER BY 1",
617                &[&queue, &SAMPLE_LIMIT],
618            )
619            .await
620            .map_err(map_pg_err)?;
621        Ok(rows
622            .iter()
623            .map(|r| PartitionState {
624                partition_key: r.get("partition_key"),
625                deficit: r.get("deficit"),
626                waiting: r.get("waiting"),
627            })
628            .collect())
629    }
630
631    async fn quarantine_list(&self) -> Result<Vec<QuarantineEntry>, StoreError> {
632        let c = self.client().await?;
633        let rows = c
634            .query(
635                "SELECT fingerprint, kind, crash_count, quarantined_at_ms,
636                        COALESCE(reason, '') AS reason
637                 FROM headgate_quarantine ORDER BY quarantined_at_ms DESC LIMIT $1",
638                &[&SAMPLE_LIMIT],
639            )
640            .await
641            .map_err(map_pg_err)?;
642        Ok(rows
643            .iter()
644            .map(|r| QuarantineEntry {
645                fingerprint: r.get("fingerprint"),
646                kind: r.get("kind"),
647                crash_count: r.get::<_, i32>("crash_count") as i64,
648                quarantined_at_ms: r.get("quarantined_at_ms"),
649                reason: r.get("reason"),
650            })
651            .collect())
652    }
653
654    async fn quarantine_release(&self, fingerprint: &str) -> Result<u64, StoreError> {
655        let c = self.client().await?;
656        let sql = format!(
657            r#"
658            WITH p AS (SELECT {NOW_MS} AS now_ms),
659            rel AS ( -- quarantined + operator_release -> available (the table's row)
660              UPDATE headgate_job j SET state = 'available', scheduled_at_ms = p.now_ms,
661                     finalized_at_ms = NULL
662              FROM p WHERE j.fingerprint = $1 AND j.state = 'quarantined'
663              RETURNING j.queue, j.partition_key
664            ),
665            -- tenant fairness/adaptive admission released jobs are available again, so their partitions rejoin the
666            -- gate's set — in this statement, never a follow-up one.
667            active AS (
668              INSERT INTO headgate_active_partition (queue, partition_key)
669              SELECT DISTINCT queue, partition_key FROM rel
670              ON CONFLICT (queue, partition_key) DO UPDATE SET queue = EXCLUDED.queue
671            ),
672            del AS (
673              DELETE FROM headgate_quarantine WHERE fingerprint = $1 RETURNING 1
674            )
675            SELECT (SELECT count(*) FROM rel)::bigint AS released,
676                   (SELECT count(*) FROM del)::bigint AS deleted
677            "#
678        );
679        let row = c
680            .query_one(&sql, &[&fingerprint])
681            .await
682            .map_err(map_pg_err)?;
683        let released: i64 = row.get("released");
684        let deleted: i64 = row.get("deleted");
685        if released == 0 && deleted == 0 {
686            return Err(StoreError::NotFound(format!(
687                "fingerprint {fingerprint} is not quarantined"
688            )));
689        }
690        Ok(released as u64)
691    }
692
693    async fn operator_retry(&self, id: &str) -> Result<(), StoreError> {
694        let c = self.client().await?;
695        let sql = format!(
696            r#"
697            WITH upd AS (
698              UPDATE headgate_job SET state = 'available', scheduled_at_ms = {NOW_MS},
699                     finalized_at_ms = NULL
700              WHERE ulid = $1 AND state = 'archived'
701              RETURNING queue, partition_key
702            ),
703            -- tenant fairness/adaptive admission retry-now makes the row available; list its partition here.
704            active AS (
705              INSERT INTO headgate_active_partition (queue, partition_key)
706              SELECT queue, partition_key FROM upd
707              ON CONFLICT (queue, partition_key) DO UPDATE SET queue = EXCLUDED.queue
708            )
709            SELECT count(*)::bigint FROM upd
710            "#
711        );
712        let retried: i64 = c.query_one(&sql, &[&id]).await.map_err(map_pg_err)?.get(0);
713        if retried == 1 {
714            return Ok(());
715        }
716        match self.job_state(&c, id).await? {
717            None => Err(StoreError::NotFound(format!("job {id}"))),
718            Some(state) => Err(StoreError::Invalid(format!(
719                "operator_retry is only defined from archived; job {id} is {state}"
720            ))),
721        }
722    }
723
724    async fn operator_cancel(&self, id: &str) -> Result<(), StoreError> {
725        let c = self.client().await?;
726        // adaptive admission the decrement must key off the PRE-update state, and an UPDATE's RETURNING
727        // reports the NEW row — by then state is 'cancelled' and lease_id is NULL, so
728        // "was it running?" is unanswerable from there. The row is therefore picked and
729        // locked first, in its own CTE, and `was_running` is read off THAT. Cancelling a
730        // scheduled or available job must not decrement a slot it never took.
731        let sql = format!(
732            "WITH pick AS (
733               SELECT j.id, j.queue, j.partition_key, (j.state = 'running') AS was_running
734               FROM headgate_job j
735               WHERE j.ulid = $1 AND j.state IN ('pending', 'scheduled', 'available', 'running')
736               FOR UPDATE
737             ),
738             upd AS (
739               UPDATE headgate_job j SET state = 'cancelled', lease_id = NULL,
740                      lease_expires_at_ms = NULL, claimed_by = NULL,
741                      finalized_at_ms = {NOW_MS}
742               WHERE j.id IN (SELECT id FROM pick)
743               RETURNING 1
744             ),
745             infl AS ({dec})
746             SELECT count(*)::bigint FROM upd",
747            dec = crate::inflight_dec_sql(
748                "(SELECT queue, partition_key FROM pick WHERE was_running)"
749            )
750        );
751        if c.query_one(&sql, &[&id])
752            .await
753            .map_err(map_pg_err)?
754            .get::<_, i64>(0)
755            == 1
756        {
757            return Ok(());
758        }
759        match self.job_state(&c, id).await? {
760            None => Err(StoreError::NotFound(format!("job {id}"))),
761            Some(state) => Err(StoreError::Invalid(format!(
762                "operator_cancel is not defined from {state}"
763            ))),
764        }
765    }
766
767    async fn delete_job(&self, id: &str) -> Result<(), StoreError> {
768        let c = self.client().await?;
769        if c.execute(
770            "DELETE FROM headgate_job WHERE ulid = $1 AND state <> 'running'",
771            &[&id],
772        )
773        .await
774        .map_err(map_pg_err)?
775            == 1
776        {
777            return Ok(());
778        }
779        match self.job_state(&c, id).await? {
780            None => Err(StoreError::NotFound(format!("job {id}"))),
781            Some(_) => Err(StoreError::Invalid(
782                "cannot delete a running job; cancel it first".into(),
783            )),
784        }
785    }
786
787    async fn explain_admission(&self, id: &str) -> Result<Option<AdmissionExplain>, StoreError> {
788        let c = self.client().await?;
789        let sql = format!(
790            r#"
791            SELECT j.state::text AS state, j.queue, j.scheduled_at_ms, j.priority,
792                   j.rate_class, j.partition_key, j.fingerprint, j.id,
793                   j.weight::bigint AS weight,
794                   {NOW_MS} AS now_ms,
795                   COALESCE(qs.paused, false) AS paused,
796                   (q.fingerprint IS NOT NULL) AS quarantined,
797                   b.burst, b.limit_per_window, b.window_ms,
798                   CASE WHEN b.name IS NULL THEN NULL
799                        WHEN b.limit_per_window > 0 AND b.window_ms > 0
800                        THEN LEAST(b.burst, b.tokens +
801                             (({NOW_MS} - b.refilled_at_ms) * b.limit_per_window / b.window_ms))
802                        ELSE b.tokens END AS avail,
803                   COALESCE(d.deficit, 0) AS deficit,
804                   cl.max_concurrent, cl.on_saturated,
805                   -- adaptive admission read the counter the GATE reads, not a fresh count of running
806                   -- rows. "Why is this job not running" must answer for the gate that
807                   -- is actually deciding: if headgate_inflight ever drifts, an explain
808                   -- that quietly recomputed the truth would report a ceiling as clear
809                   -- while admission kept refusing, which is the one failure this
810                   -- endpoint exists to make visible. Also O(1) instead of O(running).
811                   COALESCE((SELECT f.n FROM headgate_inflight f
812                             WHERE f.queue = j.queue
813                               AND f.partition_key = j.partition_key), 0) AS inflight,
814                   (SELECT COALESCE(sum(t.weight), 0)::bigint FROM (
815                      SELECT a.weight FROM headgate_job a
816                      WHERE a.state = 'available' AND a.queue = j.queue
817                        AND a.rate_class = j.rate_class
818                        AND (a.priority > j.priority
819                             OR (a.priority = j.priority
820                                 AND (a.scheduled_at_ms, a.id) < (j.scheduled_at_ms, j.id)))
821                      ORDER BY a.priority DESC, a.scheduled_at_ms, a.id
822                      LIMIT $2
823                   ) t) AS cost_ahead_in_class,
824                   (SELECT count(*) FROM (
825                      SELECT 1 FROM headgate_job a
826                      WHERE a.state = 'available' AND a.queue = j.queue
827                        AND a.partition_key = j.partition_key
828                        AND (a.priority > j.priority
829                             OR (a.priority = j.priority
830                                 AND (a.scheduled_at_ms, a.id) < (j.scheduled_at_ms, j.id)))
831                      LIMIT $2
832                   ) t)::bigint AS ahead_in_partition
833            FROM headgate_job j
834            LEFT JOIN headgate_queue_state qs ON qs.queue = j.queue
835            LEFT JOIN headgate_quarantine q ON q.fingerprint = j.fingerprint
836            LEFT JOIN headgate_rate_bucket b ON b.name = j.rate_class AND j.rate_class <> ''
837            LEFT JOIN headgate_partition_deficit d
838                   ON d.queue = j.queue AND d.partition_key = j.partition_key
839            LEFT JOIN headgate_concurrency_limit cl ON cl.queue = j.queue
840            WHERE j.ulid = $1
841            "#
842        );
843        let Some(row) = c
844            .query_opt(&sql, &[&id, &POSITION_LIMIT])
845            .await
846            .map_err(map_pg_err)?
847        else {
848            return Ok(None);
849        };
850        Ok(Some(assemble_explain(&row)))
851    }
852
853    async fn history(
854        &self,
855        queue: &str,
856        since_ms: i64,
857        bucket_ms: i64,
858    ) -> Result<Vec<HistoryBucket>, StoreError> {
859        if bucket_ms < 60_000 {
860            return Err(StoreError::Invalid(
861                "bucket_ms must be >= 60000 (the stored granularity)".into(),
862            ));
863        }
864        let c = self.client().await?;
865        let rows = c
866            .query(
867                "SELECT (bucket_ms / $2) * $2 AS at_ms,
868                        sum(arrived)::bigint AS arrived, sum(completed)::bigint AS completed
869                 FROM headgate_queue_counter
870                 WHERE queue = $1 AND bucket_ms >= $3
871                 GROUP BY 1 ORDER BY 1 LIMIT 10000",
872                &[&queue, &bucket_ms, &since_ms],
873            )
874            .await
875            .map_err(map_pg_err)?;
876        Ok(rows
877            .iter()
878            .map(|r| HistoryBucket {
879                at_ms: r.get("at_ms"),
880                arrived: r.get("arrived"),
881                completed: r.get("completed"),
882            })
883            .collect())
884    }
885
886    async fn quarantine_sweep(&self, limit: i64) -> Result<u64, StoreError> {
887        let c = self.client().await?;
888        // crash quarantine: quarantined is TERMINAL, so the lifecycle-unique partial index releases
889        // any keys these jobs held; quarantine_release flips them back to available.
890        let sql = format!(
891            r#"
892            WITH pick AS (
893              SELECT j.id FROM headgate_job j
894              WHERE j.state IN ('pending', 'available', 'scheduled', 'retryable')
895                AND j.fingerprint IN (SELECT fingerprint FROM headgate_quarantine)
896              LIMIT $1
897              FOR UPDATE SKIP LOCKED
898            )
899            UPDATE headgate_job j
900            SET state = 'quarantined', finalized_at_ms = {NOW_MS}
901            WHERE j.id IN (SELECT id FROM pick)
902            "#
903        );
904        c.execute(&sql, &[&limit]).await.map_err(map_pg_err)
905    }
906
907    async fn reschedule_job(&self, id: &str, at_ms: i64) -> Result<(), StoreError> {
908        let c = self.client().await?;
909        // Field-only update: scheduled/retryable keep their state, so no transition-
910        // table row is involved. Anything else is refused rather than reinterpreted.
911        let n = c
912            .execute(
913                "UPDATE headgate_job SET scheduled_at_ms = $2
914                 WHERE ulid = $1 AND state IN ('scheduled', 'retryable')",
915                &[&id, &at_ms],
916            )
917            .await
918            .map_err(map_pg_err)?;
919        if n == 1 {
920            return Ok(());
921        }
922        match self.job_state(&c, id).await? {
923            None => Err(StoreError::NotFound(format!("job {id}"))),
924            Some(state) => Err(StoreError::Invalid(format!(
925                "reschedule is only defined for scheduled/retryable; job {id} is {state}"
926            ))),
927        }
928    }
929
930    async fn edit_payload(
931        &self,
932        id: &str,
933        payload: &[u8],
934        schema_version: u32,
935        fingerprint: &str,
936    ) -> Result<(), StoreError> {
937        let c = self.client().await?;
938        let n = c
939            .execute(
940                "UPDATE headgate_job
941                 SET payload = $2, schema_version = $3, fingerprint = $4
942                 WHERE ulid = $1 AND state <> 'running'",
943                &[&id, &payload, &(schema_version as i32), &fingerprint],
944            )
945            .await
946            .map_err(map_pg_err)?;
947        if n == 1 {
948            return Ok(());
949        }
950        match self.job_state(&c, id).await? {
951            None => Err(StoreError::NotFound(format!("job {id}"))),
952            Some(_) => Err(StoreError::Invalid(
953                "cannot edit a running job's payload".into(),
954            )),
955        }
956    }
957
958    async fn upsert_schedule(&self, s: &Schedule) -> Result<(), StoreError> {
959        let c = self.client().await?;
960        let sql = format!(
961            r#"
962            INSERT INTO headgate_schedule AS d
963                   (id, kind, payload, queue, partition_key, rate_class, priority,
964                    max_attempts, retention_ms, spec, next_run_ms, on_missed,
965                    backfill_limit, paused, updated_at_ms)
966            SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, {NOW_MS}
967            ON CONFLICT (id) DO UPDATE SET
968              kind = EXCLUDED.kind, payload = EXCLUDED.payload, queue = EXCLUDED.queue,
969              partition_key = EXCLUDED.partition_key, rate_class = EXCLUDED.rate_class,
970              priority = EXCLUDED.priority, max_attempts = EXCLUDED.max_attempts,
971              retention_ms = EXCLUDED.retention_ms, spec = EXCLUDED.spec,
972              -- Idempotent (BullMQ upsertJobScheduler): an unchanged spec keeps its
973              -- phase; only a NEW spec resets next_run.
974              next_run_ms = CASE WHEN d.spec = EXCLUDED.spec
975                                 THEN d.next_run_ms ELSE EXCLUDED.next_run_ms END,
976              on_missed = EXCLUDED.on_missed, backfill_limit = EXCLUDED.backfill_limit,
977              paused = EXCLUDED.paused, updated_at_ms = EXCLUDED.updated_at_ms
978            "#
979        );
980        c.execute(
981            &sql,
982            &[
983                &s.id,
984                &s.kind,
985                &s.payload,
986                &s.queue,
987                &s.partition_key,
988                &s.rate_class,
989                &s.priority,
990                &(s.max_attempts as i32),
991                &s.retention_ms,
992                &s.spec,
993                &s.next_run_ms,
994                &s.on_missed.as_str(),
995                &(s.backfill_limit as i32),
996                &s.paused,
997            ],
998        )
999        .await
1000        .map_err(map_pg_err)?;
1001        Ok(())
1002    }
1003
1004    async fn delete_schedule(&self, id: &str) -> Result<(), StoreError> {
1005        let c = self.client().await?;
1006        if c.execute("DELETE FROM headgate_schedule WHERE id = $1", &[&id])
1007            .await
1008            .map_err(map_pg_err)?
1009            == 0
1010        {
1011            return Err(StoreError::NotFound(format!("schedule {id}")));
1012        }
1013        Ok(())
1014    }
1015
1016    async fn list_schedules(&self) -> Result<Vec<Schedule>, StoreError> {
1017        let c = self.client().await?;
1018        let rows = c
1019            .query(
1020                "SELECT * FROM headgate_schedule ORDER BY id LIMIT 10000",
1021                &[],
1022            )
1023            .await
1024            .map_err(map_pg_err)?;
1025        Ok(rows.iter().map(schedule_from_row).collect())
1026    }
1027
1028    async fn due_schedules(&self, limit: i64) -> Result<(Vec<Schedule>, i64), StoreError> {
1029        let c = self.client().await?;
1030        let sql = format!(
1031            "SELECT *, {NOW_MS} AS now_ms FROM headgate_schedule
1032             WHERE NOT paused AND next_run_ms <= {NOW_MS}
1033             ORDER BY next_run_ms LIMIT $1"
1034        );
1035        let rows = c.query(&sql, &[&limit]).await.map_err(map_pg_err)?;
1036        let now = rows.first().map(|r| r.get("now_ms")).unwrap_or(0);
1037        Ok((rows.iter().map(schedule_from_row).collect(), now))
1038    }
1039
1040    async fn advance_schedule(
1041        &self,
1042        id: &str,
1043        from_next_run_ms: i64,
1044        to_next_run_ms: i64,
1045    ) -> Result<bool, StoreError> {
1046        let c = self.client().await?;
1047        let sql = format!(
1048            "UPDATE headgate_schedule
1049             SET next_run_ms = $3, last_enqueued_ms = {NOW_MS}
1050             WHERE id = $1 AND next_run_ms = $2"
1051        );
1052        let n = c
1053            .execute(&sql, &[&id, &from_next_run_ms, &to_next_run_ms])
1054            .await
1055            .map_err(map_pg_err)?;
1056        Ok(n == 1)
1057    }
1058
1059    async fn record_schedule_event(&self, event: &ScheduleEvent) -> Result<(), StoreError> {
1060        if event.reason.len() > 64 {
1061            return Err(StoreError::Invalid(
1062                "schedule event reason exceeds 64 bytes".into(),
1063            ));
1064        }
1065        let mut c = self.client().await?;
1066        let tx = c.transaction().await.map_err(map_pg_err)?;
1067        // Two scheduler nodes may record the same tick concurrently. Serialize their
1068        // append-and-trim transactions on the schedule row so the 100-row bound remains
1069        // strict instead of briefly settling at 101 under READ COMMITTED snapshots.
1070        let _ = tx
1071            .query(
1072                "SELECT id FROM headgate_schedule WHERE id = $1 FOR UPDATE",
1073                &[&event.schedule_id],
1074            )
1075            .await
1076            .map_err(map_pg_err)?;
1077        let sql = format!(
1078            "INSERT INTO headgate_schedule_event
1079                    (schedule_id, tick_ms, job_id, outcome, reason, recorded_at_ms)
1080             VALUES ($1, $2, $3, $4, $5, {NOW_MS})"
1081        );
1082        tx.execute(
1083            &sql,
1084            &[
1085                &event.schedule_id,
1086                &event.tick_ms,
1087                &event.job_id,
1088                &event.outcome.as_str(),
1089                &event.reason,
1090            ],
1091        )
1092        .await
1093        .map_err(map_pg_err)?;
1094        tx.execute(
1095            "DELETE FROM headgate_schedule_event
1096             WHERE schedule_id = $1 AND id NOT IN (
1097               SELECT id FROM headgate_schedule_event WHERE schedule_id = $1
1098               ORDER BY id DESC LIMIT $2
1099             )",
1100            &[&event.schedule_id, &(SCHEDULE_EVENT_LIMIT as i64)],
1101        )
1102        .await
1103        .map_err(map_pg_err)?;
1104        tx.commit().await.map_err(map_pg_err)
1105    }
1106
1107    async fn list_schedule_events(
1108        &self,
1109        schedule_id: &str,
1110        before_event_id: Option<u64>,
1111        limit: u32,
1112    ) -> Result<Vec<ScheduleEvent>, StoreError> {
1113        if limit == 0 || limit > SCHEDULE_EVENT_LIMIT {
1114            return Err(StoreError::Invalid(
1115                "schedule event limit must be between 1 and 100".into(),
1116            ));
1117        }
1118        let c = self.client().await?;
1119        let rows = c
1120            .query(
1121                "SELECT id, schedule_id, tick_ms, job_id, outcome, reason, recorded_at_ms
1122             FROM headgate_schedule_event WHERE schedule_id = $1
1123               AND ($2::bigint IS NULL OR id < $2)
1124             ORDER BY id DESC LIMIT $3",
1125                &[
1126                    &schedule_id,
1127                    &before_event_id.map(|id| id as i64),
1128                    &(limit as i64),
1129                ],
1130            )
1131            .await
1132            .map_err(map_pg_err)?;
1133        rows.into_iter()
1134            .map(|row| {
1135                let raw: String = row.get("outcome");
1136                let outcome = ScheduleEventOutcome::parse(&raw).ok_or_else(|| {
1137                    StoreError::Invalid(format!("invalid stored schedule outcome {raw}"))
1138                })?;
1139                Ok(ScheduleEvent {
1140                    event_id: row.get::<_, i64>("id") as u64,
1141                    schedule_id: row.get("schedule_id"),
1142                    tick_ms: row.get("tick_ms"),
1143                    job_id: row.get("job_id"),
1144                    outcome,
1145                    reason: row.get("reason"),
1146                    recorded_at_ms: row.get("recorded_at_ms"),
1147                })
1148            })
1149            .collect()
1150    }
1151
1152    async fn heartbeat_worker(&self, w: &WorkerMeta) -> Result<Option<String>, StoreError> {
1153        let c = self.client().await?;
1154        let status = if w.status.is_empty() {
1155            "running"
1156        } else {
1157            &w.status
1158        };
1159        let sql = format!(
1160            r#"
1161            INSERT INTO headgate_worker
1162                   (worker_id, host, pid, queues, concurrency, started_at_ms, heartbeat_at_ms,
1163                    inflight, polls, empty_polls, status, duties_active)
1164            SELECT $1, $2, $3, $4, $5, $6, {NOW_MS}, $7, $8, $9, $10, $11
1165            ON CONFLICT (worker_id) DO UPDATE SET
1166              queues = EXCLUDED.queues, concurrency = EXCLUDED.concurrency,
1167              heartbeat_at_ms = EXCLUDED.heartbeat_at_ms,
1168              -- ADDITIVE: the cluster view's and backlog metrics's inputs are LEVELS,
1169              -- so the beat overwrites them rather than accumulating. A worker that
1170              -- stops beating keeps its last reported level and ages out as stale.
1171              inflight = EXCLUDED.inflight, polls = EXCLUDED.polls,
1172              empty_polls = EXCLUDED.empty_polls, status = EXCLUDED.status,
1173              duties_active = EXCLUDED.duties_active
1174            RETURNING command
1175            "#
1176        );
1177        let row = c
1178            .query_one(
1179                &sql,
1180                &[
1181                    &w.worker_id,
1182                    &w.host,
1183                    &w.pid,
1184                    &w.queues,
1185                    &(w.concurrency as i32),
1186                    &w.started_at_ms,
1187                    &(w.inflight as i32),
1188                    &(w.polls as i64),
1189                    &(w.empty_polls as i64),
1190                    &status,
1191                    &w.duties_active,
1192                ],
1193            )
1194            .await
1195            .map_err(map_pg_err)?;
1196        Ok(row.get(0))
1197    }
1198
1199    async fn signal_worker(
1200        &self,
1201        worker_id: &str,
1202        command: Option<&str>,
1203    ) -> Result<(), StoreError> {
1204        if let Some(cmd) = command {
1205            if !matches!(cmd, "quiet" | "resume" | "restart" | "terminate" | "resign") {
1206                return Err(StoreError::Invalid(
1207                    "command must be quiet, resume, restart, terminate, or resign".into(),
1208                ));
1209            }
1210        }
1211        let c = self.client().await?;
1212        let n = c
1213            .execute(
1214                "UPDATE headgate_worker SET command = $2 WHERE worker_id = $1",
1215                &[&worker_id, &command],
1216            )
1217            .await
1218            .map_err(map_pg_err)?;
1219        if n == 0 {
1220            return Err(StoreError::NotFound(format!("worker {worker_id}")));
1221        }
1222        Ok(())
1223    }
1224
1225    async fn distinct_kinds(&self, limit: i64) -> Result<Vec<String>, StoreError> {
1226        let c = self.client().await?;
1227        // Bounded (invariant 6): distinct over a sample, not the whole table.
1228        let rows = c
1229            .query(
1230                "SELECT DISTINCT kind FROM (
1231                   SELECT kind FROM headgate_job
1232                   WHERE state IN ('available', 'scheduled', 'retryable')
1233                   LIMIT $1
1234                 ) t ORDER BY kind",
1235                &[&limit],
1236            )
1237            .await
1238            .map_err(map_pg_err)?;
1239        Ok(rows.iter().map(|r| r.get(0)).collect())
1240    }
1241
1242    async fn list_workers(&self, stale_after_ms: i64) -> Result<Vec<WorkerMeta>, StoreError> {
1243        let c = self.client().await?;
1244        let sql = format!(
1245            "SELECT * FROM headgate_worker
1246             WHERE heartbeat_at_ms >= {NOW_MS} - $1
1247             ORDER BY worker_id LIMIT 10000"
1248        );
1249        let rows = c
1250            .query(&sql, &[&stale_after_ms])
1251            .await
1252            .map_err(map_pg_err)?;
1253        Ok(rows
1254            .iter()
1255            .map(|r| WorkerMeta {
1256                worker_id: r.get("worker_id"),
1257                host: r.get("host"),
1258                pid: r.get("pid"),
1259                queues: r.get("queues"),
1260                concurrency: r.get::<_, i32>("concurrency") as u32,
1261                started_at_ms: r.get("started_at_ms"),
1262                heartbeat_at_ms: r.get("heartbeat_at_ms"),
1263                inflight: r.get::<_, i32>("inflight") as u32,
1264                polls: r.get::<_, i64>("polls") as u64,
1265                empty_polls: r.get::<_, i64>("empty_polls") as u64,
1266                status: r.get("status"),
1267                duties_active: r.get("duties_active"),
1268                pending_command: r.get("command"),
1269            })
1270            .collect())
1271    }
1272
1273    async fn create_operation(&self, req: &BulkRequest) -> Result<(), StoreError> {
1274        if req.queue.is_none()
1275            && req.state.is_none()
1276            && req.kind.is_none()
1277            && req.partition_key.is_none()
1278            && req.older_than_ms.is_none()
1279        {
1280            // control API contract no accidental delete-everything.
1281            return Err(StoreError::Invalid("empty selector is rejected".into()));
1282        }
1283        let allowed = action_states(&req.action)
1284            .ok_or_else(|| StoreError::Invalid(format!("unknown action `{}`", req.action)))?;
1285        let c = self.client().await?;
1286        // Bounded estimate of the affected set — for dry runs it IS the answer.
1287        let (where_sql, params) = selector_where(req, allowed, 2);
1288        let est_sql = format!(
1289            "SELECT count(*)::bigint FROM (SELECT 1 FROM headgate_job j WHERE {where_sql} LIMIT $1) t"
1290        );
1291        let mut est_params: Vec<&(dyn ToSql + Sync)> = vec![&SAMPLE_LIMIT];
1292        est_params.extend(params.iter().map(|p| &**p as &(dyn ToSql + Sync)));
1293        let estimated: i64 = c
1294            .query_one(&est_sql, &est_params)
1295            .await
1296            .map_err(map_pg_err)?
1297            .get(0);
1298        let selector = serde_json::json!({
1299            "queue": req.queue, "state": req.state, "kind": req.kind,
1300            "partition_key": req.partition_key, "older_than_ms": req.older_than_ms,
1301        });
1302        let status = if req.dry_run { "completed" } else { "pending" };
1303        let sql = format!(
1304            "INSERT INTO headgate_operation
1305                    (id, action, selector, status, total_estimated, dry_run, created_at_ms)
1306             VALUES ($1, $2, $3, $4, $5, $6, {NOW_MS})"
1307        );
1308        c.execute(
1309            &sql,
1310            &[
1311                &req.id,
1312                &req.action,
1313                &selector,
1314                &status,
1315                &estimated,
1316                &req.dry_run,
1317            ],
1318        )
1319        .await
1320        .map_err(map_pg_err)?;
1321        Ok(())
1322    }
1323
1324    async fn get_operation(&self, id: &str) -> Result<Option<OperationStatus>, StoreError> {
1325        let c = self.client().await?;
1326        Ok(
1327            c.query_opt("SELECT * FROM headgate_operation WHERE id = $1", &[&id])
1328                .await
1329                .map_err(map_pg_err)?
1330                .map(|r| OperationStatus {
1331                    id: r.get("id"),
1332                    status: r.get("status"),
1333                    affected: r.get("affected"),
1334                    total_estimated: r.get("total_estimated"),
1335                    dry_run: r.get("dry_run"),
1336                    error: r.get("error"),
1337                }),
1338        )
1339    }
1340
1341    async fn run_pending_operations(&self, batch: i64) -> Result<u64, StoreError> {
1342        let c = self.client().await?;
1343        let ops = c
1344            .query(
1345                "SELECT id, action, selector FROM headgate_operation
1346                 WHERE status IN ('pending', 'running')
1347                 ORDER BY created_at_ms LIMIT 5",
1348                &[],
1349            )
1350            .await
1351            .map_err(map_pg_err)?;
1352        let mut total = 0u64;
1353        for op in &ops {
1354            let id: String = op.get("id");
1355            let action: String = op.get("action");
1356            let selector: serde_json::Value = op.get("selector");
1357            let req = BulkRequest {
1358                id: id.clone(),
1359                action: action.clone(),
1360                queue: selector
1361                    .get("queue")
1362                    .and_then(|v| v.as_str())
1363                    .map(String::from),
1364                state: selector
1365                    .get("state")
1366                    .and_then(|v| v.as_str())
1367                    .map(String::from),
1368                kind: selector
1369                    .get("kind")
1370                    .and_then(|v| v.as_str())
1371                    .map(String::from),
1372                partition_key: selector
1373                    .get("partition_key")
1374                    .and_then(|v| v.as_str())
1375                    .map(String::from),
1376                older_than_ms: selector.get("older_than_ms").and_then(|v| v.as_i64()),
1377                dry_run: false,
1378            };
1379            let n = match self.run_operation_batch(&c, &req, batch).await {
1380                Ok(n) => n,
1381                Err(e) => {
1382                    let _ = c
1383                        .execute(
1384                            "UPDATE headgate_operation SET status = 'failed', error = $2 WHERE id = $1",
1385                            &[&id, &e.to_string()],
1386                        )
1387                        .await;
1388                    continue;
1389                }
1390            };
1391            total += n;
1392            let done = (n as i64) < batch;
1393            let status = if done { "completed" } else { "running" };
1394            c.execute(
1395                "UPDATE headgate_operation SET status = $2, affected = affected + $3 WHERE id = $1",
1396                &[&id, &status, &(n as i64)],
1397            )
1398            .await
1399            .map_err(map_pg_err)?;
1400        }
1401        Ok(total)
1402    }
1403
1404    async fn promote_job(&self, id: &str) -> Result<(), StoreError> {
1405        let c = self.client().await?;
1406        let n: i64 = c
1407            .query_one(
1408                &format!(
1409                    "WITH moved AS (
1410                   UPDATE headgate_job SET state = 'available', scheduled_at_ms = {NOW_MS}
1411                   WHERE ulid = $1 AND state = 'pending'
1412                   RETURNING queue, partition_key
1413                 ), active AS (
1414                   INSERT INTO headgate_active_partition (queue, partition_key)
1415                   SELECT queue, partition_key FROM moved
1416                   ON CONFLICT (queue, partition_key) DO UPDATE SET queue = EXCLUDED.queue
1417                 ) SELECT count(*) FROM moved"
1418                ),
1419                &[&id],
1420            )
1421            .await
1422            .map_err(map_pg_err)?
1423            .get(0);
1424        if n == 0 {
1425            return Err(StoreError::Invalid(
1426                "operator_promote is defined only from pending".into(),
1427            ));
1428        }
1429        Ok(())
1430    }
1431
1432    async fn delete_queue(&self, queue: &str, force: bool) -> Result<Option<String>, StoreError> {
1433        let mut c = self.client().await?;
1434        let tx = c.transaction().await.map_err(map_pg_err)?;
1435        tx.execute(
1436            "INSERT INTO headgate_enqueue_policy(queue) VALUES($1) ON CONFLICT(queue) DO NOTHING",
1437            &[&queue],
1438        )
1439        .await
1440        .map_err(map_pg_err)?;
1441        tx.query(
1442            "SELECT queue FROM headgate_enqueue_policy WHERE queue=$1 FOR UPDATE",
1443            &[&queue],
1444        )
1445        .await
1446        .map_err(map_pg_err)?;
1447        let depth: i64 = tx.query_one(
1448            "SELECT GREATEST(0,
1449               COALESCE((SELECT n FROM headgate_enqueue_counter WHERE queue=$1 AND counter_kind='entered'),0) -
1450               COALESCE((SELECT n FROM headgate_enqueue_counter WHERE queue=$1 AND counter_kind='exited'),0))",
1451            &[&queue],
1452        ).await.map_err(map_pg_err)?.get(0);
1453        if depth > 0 && !force {
1454            return Err(StoreError::Invalid(
1455                "queue is not empty; retry with force=true".into(),
1456            ));
1457        }
1458        if depth == 0 {
1459            tx.execute("DELETE FROM headgate_queue_state WHERE queue=$1", &[&queue])
1460                .await
1461                .map_err(map_pg_err)?;
1462            tx.execute(
1463                "DELETE FROM headgate_enqueue_policy WHERE queue=$1",
1464                &[&queue],
1465            )
1466            .await
1467            .map_err(map_pg_err)?;
1468            tx.commit().await.map_err(map_pg_err)?;
1469            return Ok(None);
1470        }
1471        tx.execute(
1472            "UPDATE headgate_enqueue_policy SET max_unfinished_jobs=0 WHERE queue=$1",
1473            &[&queue],
1474        )
1475        .await
1476        .map_err(map_pg_err)?;
1477        let now: i64 = tx
1478            .query_one(&format!("SELECT {NOW_MS}"), &[])
1479            .await
1480            .map_err(map_pg_err)?
1481            .get(0);
1482        tx.commit().await.map_err(map_pg_err)?;
1483        let id = format!(
1484            "qdel-{now}-{}",
1485            queue.replace(|c: char| !c.is_ascii_alphanumeric(), "_")
1486        );
1487        self.create_operation(&BulkRequest {
1488            id: id.clone(),
1489            action: "delete".into(),
1490            queue: Some(queue.into()),
1491            state: None,
1492            kind: None,
1493            partition_key: None,
1494            older_than_ms: None,
1495            dry_run: false,
1496        })
1497        .await?;
1498        Ok(Some(id))
1499    }
1500
1501    async fn sample_queue_memory(&self, limit: u32) -> Result<u32, StoreError> {
1502        let limit = limit.clamp(1, 1_000) as i64;
1503        let c = self.client().await?;
1504        let rows = c.query(
1505            &format!(
1506                "WITH queues AS (SELECT queue FROM headgate_queue_state ORDER BY queue LIMIT 200),
1507                 samples AS (
1508                   SELECT q.queue, COALESCE(sum(pg_column_size(j.*)),0)::bigint AS bytes, count(*)::int AS n
1509                   FROM queues q LEFT JOIN LATERAL (
1510                     SELECT j FROM headgate_job j WHERE j.queue=q.queue ORDER BY j.id DESC LIMIT $1
1511                   ) x(j) ON TRUE GROUP BY q.queue
1512                 )
1513                 INSERT INTO headgate_queue_sample(queue,memory_bytes,sampled_jobs,sampled_at_ms)
1514                 SELECT queue,bytes,n,{NOW_MS} FROM samples
1515                 ON CONFLICT(queue) DO UPDATE SET memory_bytes=EXCLUDED.memory_bytes,
1516                   sampled_jobs=EXCLUDED.sampled_jobs,sampled_at_ms=EXCLUDED.sampled_at_ms
1517                 RETURNING queue"
1518            ), &[&limit]
1519        ).await.map_err(map_pg_err)?;
1520        Ok(rows.len() as u32)
1521    }
1522}
1523
1524#[async_trait::async_trait]
1525impl ResultInspect for PgStore {
1526    async fn get_job_result(&self, id: &str) -> Result<Option<JobResult>, StoreError> {
1527        let c = self.client().await?;
1528        let row = c
1529            .query_opt(
1530                "SELECT result_schema_version, result_bytes
1531                   FROM headgate_job
1532                  WHERE ulid = $1 AND result_schema_version IS NOT NULL",
1533                &[&id],
1534            )
1535            .await
1536            .map_err(map_pg_err)?;
1537        Ok(row.map(|row| JobResult {
1538            schema_version: row.get::<_, i32>(0) as u32,
1539            bytes: row.get(1),
1540        }))
1541    }
1542}
1543
1544#[async_trait::async_trait]
1545impl OutputInspect for PgStore {
1546    async fn get_job_output(&self, id: &str) -> Result<Option<JobOutput>, StoreError> {
1547        let c = self.client().await?;
1548        let row = c
1549            .query_opt(
1550                "SELECT output_schema_version, output_bytes, output_fence, output_updated_at_ms
1551                   FROM headgate_job
1552                  WHERE ulid = $1 AND output_schema_version IS NOT NULL",
1553                &[&id],
1554            )
1555            .await
1556            .map_err(map_pg_err)?;
1557        Ok(row.map(|row| JobOutput {
1558            schema_version: row.get::<_, i32>(0) as u32,
1559            bytes: row.get(1),
1560            fence: row.get::<_, i64>(2) as u64,
1561            updated_at_ms: row.get(3),
1562        }))
1563    }
1564}
1565
1566#[async_trait::async_trait]
1567impl ProgressInspect for PgStore {
1568    async fn get_job_progress(&self, id: &str) -> Result<Option<JobProgress>, StoreError> {
1569        let c = self.client().await?;
1570        let row = c
1571            .query_opt(
1572                "SELECT progress_current, progress_total, progress_message,
1573                        progress_fence, progress_updated_at_ms
1574                   FROM headgate_job
1575                  WHERE ulid = $1 AND progress_current IS NOT NULL",
1576                &[&id],
1577            )
1578            .await
1579            .map_err(map_pg_err)?;
1580        Ok(row.map(|row| JobProgress {
1581            current: row.get::<_, i64>(0) as u64,
1582            total: row.get::<_, i64>(1) as u64,
1583            message: row.get(2),
1584            fence: row.get::<_, i64>(3) as u64,
1585            updated_at_ms: row.get(4),
1586        }))
1587    }
1588}
1589
1590#[async_trait::async_trait]
1591impl CheckpointInspect for PgStore {
1592    async fn get_job_checkpoint(&self, id: &str) -> Result<Option<Checkpoint>, StoreError> {
1593        let c = self.client().await?;
1594        let row = c
1595            .query_opt(
1596                "SELECT checkpoint, cp_cursor FROM headgate_job WHERE ulid = $1",
1597                &[&id],
1598            )
1599            .await
1600            .map_err(map_pg_err)?;
1601        Ok(row.map(|row| {
1602            crate::decode_checkpoint(
1603                row.get::<_, Option<serde_json::Value>>(0),
1604                row.get::<_, Option<Vec<u8>>>(1),
1605            )
1606        }))
1607    }
1608}
1609
1610fn schedule_from_row(r: &tokio_postgres::Row) -> Schedule {
1611    Schedule {
1612        id: r.get("id"),
1613        kind: r.get("kind"),
1614        payload: r.get("payload"),
1615        queue: r.get("queue"),
1616        partition_key: r.get("partition_key"),
1617        rate_class: r.get("rate_class"),
1618        priority: r.get("priority"),
1619        max_attempts: r.get::<_, i32>("max_attempts") as u32,
1620        retention_ms: r.get("retention_ms"),
1621        spec: r.get("spec"),
1622        next_run_ms: r.get("next_run_ms"),
1623        last_enqueued_ms: r.get("last_enqueued_ms"),
1624        on_missed: MissedPolicy::parse(r.get("on_missed")).unwrap_or(MissedPolicy::Skip),
1625        backfill_limit: r.get::<_, i32>("backfill_limit") as u32,
1626        paused: r.get("paused"),
1627    }
1628}
1629
1630/// Which states each bulk action may touch — the transition table's rows, nothing more.
1631fn action_states(action: &str) -> Option<&'static str> {
1632    match action {
1633        "retry" => Some("('archived')"),
1634        "cancel" => Some("('scheduled', 'available', 'running')"),
1635        "delete" => Some(
1636            "('scheduled', 'available', 'retryable', 'completed', 'archived', 'cancelled', 'quarantined', 'undecodable')",
1637        ),
1638        _ => None,
1639    }
1640}
1641
1642/// Build the selector WHERE clause. Owned boxed params so callers can prepend their own.
1643fn selector_where(
1644    req: &BulkRequest,
1645    allowed_states: &'static str,
1646    first_param: usize,
1647) -> (String, Vec<Box<dyn ToSql + Sync + Send>>) {
1648    let mut clauses = vec![format!("j.state IN {allowed_states}")];
1649    let mut params: Vec<Box<dyn ToSql + Sync + Send>> = Vec::new();
1650    let push = |clauses: &mut Vec<String>,
1651                params: &mut Vec<Box<dyn ToSql + Sync + Send>>,
1652                sql: &str,
1653                v: Box<dyn ToSql + Sync + Send>| {
1654        params.push(v);
1655        clauses.push(sql.replace("{}", &(params.len() + first_param - 1).to_string()));
1656    };
1657    if let Some(q) = &req.queue {
1658        push(
1659            &mut clauses,
1660            &mut params,
1661            "j.queue = ${}",
1662            Box::new(q.clone()),
1663        );
1664    }
1665    if let Some(s) = &req.state {
1666        push(
1667            &mut clauses,
1668            &mut params,
1669            "j.state::text = ${}",
1670            Box::new(s.clone()),
1671        );
1672    }
1673    if let Some(k) = &req.kind {
1674        push(
1675            &mut clauses,
1676            &mut params,
1677            "j.kind = ${}",
1678            Box::new(k.clone()),
1679        );
1680    }
1681    if let Some(p) = &req.partition_key {
1682        push(
1683            &mut clauses,
1684            &mut params,
1685            "j.partition_key = ${}",
1686            Box::new(p.clone()),
1687        );
1688    }
1689    if let Some(age) = req.older_than_ms {
1690        push(
1691            &mut clauses,
1692            &mut params,
1693            &format!("j.enqueued_at_ms < {NOW_MS} - ${{}}"),
1694            Box::new(age),
1695        );
1696    }
1697    (clauses.join(" AND "), params)
1698}
1699
1700impl PgStore {
1701    /// One bounded batch of a bulk operation. The action's SQL mirrors the single-job
1702    /// operator methods exactly — same transitions, same lease handling.
1703    async fn run_operation_batch(
1704        &self,
1705        c: &crate::PgClient,
1706        req: &BulkRequest,
1707        batch: i64,
1708    ) -> Result<u64, StoreError> {
1709        let allowed = action_states(&req.action)
1710            .ok_or_else(|| StoreError::Invalid(format!("unknown action `{}`", req.action)))?;
1711        let (where_sql, params) = selector_where(req, allowed, 2);
1712        let pick = format!(
1713            "SELECT j.id FROM headgate_job j WHERE {where_sql} ORDER BY j.id LIMIT $1 FOR UPDATE SKIP LOCKED"
1714        );
1715        // adaptive admission the cancel action is the only bulk action whose allowed states include
1716        // 'running', so it is the only one that moves the inflight counter. It carries
1717        // the pre-update state out of the pick, for the same reason operator_cancel does.
1718        let pick_state = format!(
1719            "SELECT j.id, j.queue, j.partition_key, (j.state = 'running') AS was_running
1720             FROM headgate_job j WHERE {where_sql} ORDER BY j.id LIMIT $1 FOR UPDATE SKIP LOCKED"
1721        );
1722        let stmt = match req.action.as_str() {
1723            // tenant fairness/adaptive admission `act` is a DATA-MODIFYING CTE, so it runs unconditionally and in
1724            // this same statement — the partitions are listed before anything can observe
1725            // the rows as available. Keeping the UPDATE as the outer statement preserves
1726            // the row count this function returns as operation progress.
1727            "retry" => format!(
1728                "WITH picked AS ({pick}),
1729                 act AS (
1730                   INSERT INTO headgate_active_partition (queue, partition_key)
1731                   SELECT DISTINCT j.queue, j.partition_key FROM headgate_job j
1732                   WHERE j.id IN (SELECT id FROM picked)
1733                   ON CONFLICT (queue, partition_key) DO UPDATE SET queue = EXCLUDED.queue
1734                 )
1735                 UPDATE headgate_job j SET state = 'available', scheduled_at_ms = {NOW_MS},
1736                        finalized_at_ms = NULL
1737                 WHERE j.id IN (SELECT id FROM picked)"
1738            ),
1739            "cancel" => format!(
1740                "WITH picked AS ({pick_state}),
1741                 infl AS ({dec})
1742                 UPDATE headgate_job j SET state = 'cancelled', lease_id = NULL,
1743                        lease_expires_at_ms = NULL, claimed_by = NULL,
1744                        finalized_at_ms = {NOW_MS}
1745                 WHERE j.id IN (SELECT id FROM picked)",
1746                dec = crate::inflight_dec_sql(
1747                    "(SELECT queue, partition_key FROM picked WHERE was_running)"
1748                )
1749            ),
1750            "delete" => format!(
1751                "WITH picked AS ({pick})
1752                 DELETE FROM headgate_job j WHERE j.id IN (SELECT id FROM picked)"
1753            ),
1754            other => return Err(StoreError::Invalid(format!("unknown action `{other}`"))),
1755        };
1756        let mut all_params: Vec<&(dyn ToSql + Sync)> = vec![&batch];
1757        all_params.extend(params.iter().map(|p| &**p as &(dyn ToSql + Sync)));
1758        c.execute(&stmt, &all_params).await.map_err(map_pg_err)
1759    }
1760}
1761
1762impl PgStore {
1763    async fn job_state(&self, c: &crate::PgClient, id: &str) -> Result<Option<String>, StoreError> {
1764        Ok(c.query_opt(
1765            "SELECT state::text FROM headgate_job WHERE ulid = $1",
1766            &[&id],
1767        )
1768        .await
1769        .map_err(map_pg_err)?
1770        .map(|r| r.get(0)))
1771    }
1772}
1773
1774/// The gate's own evaluation order (admission policy), replayed read-only for one job.
1775fn assemble_explain(row: &tokio_postgres::Row) -> AdmissionExplain {
1776    let state: String = row.get("state");
1777    let now_ms: i64 = row.get("now_ms");
1778    let mut detail: Vec<(String, String)> = vec![("state".into(), state.clone())];
1779
1780    match state.as_str() {
1781        "running" => {
1782            return AdmissionExplain {
1783                state,
1784                admissible: true,
1785                blocked_by: None,
1786                detail,
1787                estimated_admission_ms: Some(0),
1788            };
1789        }
1790        "scheduled" | "retryable" => {
1791            let at: i64 = row.get("scheduled_at_ms");
1792            detail.push(("scheduled_at_ms".into(), at.to_string()));
1793            return AdmissionExplain {
1794                state,
1795                admissible: false,
1796                blocked_by: Some(BlockedBy::Schedule),
1797                detail,
1798                estimated_admission_ms: Some((at - now_ms).max(0)),
1799            };
1800        }
1801        "quarantined" => {
1802            return AdmissionExplain {
1803                state,
1804                admissible: false,
1805                blocked_by: Some(BlockedBy::Quarantine),
1806                detail,
1807                estimated_admission_ms: None, // will not clear on its own
1808            };
1809        }
1810        "available" => {}
1811        _terminal => {
1812            return AdmissionExplain {
1813                state,
1814                admissible: false,
1815                blocked_by: None,
1816                detail,
1817                estimated_admission_ms: None,
1818            };
1819        }
1820    }
1821
1822    // Available: walk the gate's clauses in the gate's order.
1823    if row.get::<_, bool>("paused") {
1824        return AdmissionExplain {
1825            state,
1826            admissible: false,
1827            blocked_by: Some(BlockedBy::QueuePaused),
1828            detail,
1829            estimated_admission_ms: None,
1830        };
1831    }
1832    let scheduled_at: i64 = row.get("scheduled_at_ms");
1833    if scheduled_at > now_ms {
1834        detail.push(("scheduled_at_ms".into(), scheduled_at.to_string()));
1835        return AdmissionExplain {
1836            state,
1837            admissible: false,
1838            blocked_by: Some(BlockedBy::Schedule),
1839            detail,
1840            estimated_admission_ms: Some(scheduled_at - now_ms),
1841        };
1842    }
1843    if row.get::<_, bool>("quarantined") {
1844        detail.push(("fingerprint".into(), row.get::<_, String>("fingerprint")));
1845        return AdmissionExplain {
1846            state,
1847            admissible: false,
1848            blocked_by: Some(BlockedBy::Quarantine),
1849            detail,
1850            estimated_admission_ms: None,
1851        };
1852    }
1853    let rate_class: String = row.get("rate_class");
1854    if !rate_class.is_empty() {
1855        let avail: Option<i64> = row.get("avail");
1856        let ahead: i64 = row.get("cost_ahead_in_class");
1857        let weight: i64 = row.get("weight");
1858        let required = ahead + weight;
1859        detail.push(("rate_class".into(), rate_class));
1860        detail.push(("weight".into(), weight.to_string()));
1861        detail.push(("tokens_ahead_in_class".into(), ahead.to_string()));
1862        match avail {
1863            // an unconfigured class is UNLIMITED, not blocking — the gate's
1864            // `b.name IS NULL OR ...` fail-open arm. Still reported, because "you named a
1865            // rate class that does not exist" is worth seeing even when nothing stalls.
1866            None => {
1867                detail.push((
1868                    "tokens_available".into(),
1869                    "unlimited (no such rate class)".into(),
1870                ));
1871            }
1872            Some(avail) => {
1873                detail.push(("tokens_available".into(), avail.to_string()));
1874                if avail < required {
1875                    let limit: i64 = row.get("limit_per_window");
1876                    let window: i64 = row.get("window_ms");
1877                    let est = if limit > 0 {
1878                        Some(((required - avail).max(1)) * window / limit)
1879                    } else {
1880                        None // paused class: will not clear on its own
1881                    };
1882                    return AdmissionExplain {
1883                        state,
1884                        admissible: false,
1885                        blocked_by: Some(BlockedBy::RateClass),
1886                        detail,
1887                        estimated_admission_ms: est,
1888                    };
1889                }
1890            }
1891        }
1892    }
1893    if let Some(max) = row.get::<_, Option<i64>>("max_concurrent") {
1894        let inflight: i64 = row.get("inflight");
1895        let strategy: String = row
1896            .get::<_, Option<String>>("on_saturated")
1897            .unwrap_or_else(|| "queue".into());
1898        detail.push(("max_concurrent".into(), max.to_string()));
1899        detail.push(("inflight".into(), inflight.to_string()));
1900        detail.push(("on_saturated".into(), strategy.clone()));
1901        if inflight >= max && strategy != SaturationStrategy::CancelRunning.as_str() {
1902            return AdmissionExplain {
1903                state,
1904                admissible: false,
1905                blocked_by: Some(BlockedBy::ConcurrencyLimit),
1906                detail,
1907                estimated_admission_ms: None, // clears when something finishes
1908            };
1909        }
1910    }
1911    // Fairness never blocks outright — it is work-conserving (invariant 11) — so a job
1912    // that passes everything above is admissible; position in its partition says when.
1913    detail.push((
1914        "position_in_partition".into(),
1915        row.get::<_, i64>("ahead_in_partition").to_string(),
1916    ));
1917    detail.push((
1918        "partition_deficit".into(),
1919        row.get::<_, i64>("deficit").to_string(),
1920    ));
1921    AdmissionExplain {
1922        state,
1923        admissible: true,
1924        blocked_by: None,
1925        detail,
1926        estimated_admission_ms: Some(0),
1927    }
1928}