diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5f203c6..8315f54e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
## [Unreleased]
+### Fixed
+
+- **Queue-age metrics no longer count vt-parked (scheduled/retrying) messages — one delayed job stops reading as a degraded queue (issue #389).** ⚠️ **Behavior change on the AppSignal `pgbus_queue_latency` gauge.** pgmq's `oldest_msg_age_sec` is computed from `enqueued_at` and ignores `vt`, but a job enqueued with `wait:` or parked on a long retry backoff lives in the queue table with a future `vt` — that *is* the delayed-delivery mechanism. So a single parked message made the age metric grow at wall-clock rate for hours on an otherwise drained queue, and any latency alert thresholding on it fired continuously ("oldest message is 17045s old" on a healthy queue with depth 1, `read_ct` 0). Every metrics surface now also exposes **`oldest_claimable_age_sec`** — `now() - min(vt)` over rows with `vt <= now()`, i.e. the age of the oldest message actually *eligible for pickup*: an immediately-enqueued message contributes from enqueue time (matching the old number on a plain backlog), a scheduled/backoff-parked message contributes nothing until due, an in-flight message (vt pushed forward) is excluded, and nil means "no claimable backlog" even when the table is non-empty. Surfaces: `Web::DataSource` (dashboard, JSON API, MCP `pgbus_queues` tool), a new Prometheus gauge `pgbus_queue_oldest_claimable_age_seconds`, a new AppSignal gauge `pgbus_queue_oldest_claimable_age_seconds`, `Pgbus::Client#oldest_claimable_ages` (raw-SQL reader, since pgmq's `metrics_result` type is frozen upstream), and a CLAIMABLE column in `pgbus queues`. The AppSignal **`pgbus_queue_latency` gauge now derives from the claimable age** and always emits — `(claimable_age || 0) * 1000`, 0 = no claimable backlog — so existing latency alerts stop false-firing with no dashboard changes; the raw `pgbus_queue_oldest_message_age_seconds` gauge keeps its enqueue-time semantics everywhere. The dashboard queue tables additionally split depth into **Parked** (`depth − visible`) and show the claimable age in place of the raw age, so a queue holding only backoff retries reads visibly healthy. Refs #389.
+
### Added
- **Health-checked rolling restarts for the job container (issue #386).** ⚠️ **Behavior change on the standalone `/readyz`.** The supervisor's `health_port` server previously answered `/readyz` with the cluster-wide HealthAnalyzer verdict — so during a rolling deploy a freshly-booted container could pass an orchestrator's health gate on the strength of the *old* container's still-heartbeating workers, and the old container (with all its capacity) was stopped before the new one had forked a single child. The standalone `/readyz` is now **container-local**: 200 only when *this* supervisor verified its connection, bootstrapped queues, forked every configured child, and all of them are currently alive — with 503 bodies `BOOTING` (pre-boot), `DEGRADED` (a child died and is waiting out crash-restart backoff — precisely the state a deploy gate must fail on, keeping the old container running), and `DRAINING` (stop signal received). No database access on the probe path; the supervisor publishes an immutable snapshot per monitor pass and the accept thread reads it. The Rails-mounted `Pgbus::Web::HealthApp` keeps the cluster-wide verdict unchanged. Alongside it: **`pgbus-health`**, a shipped executable probe for docker `HEALTHCHECK` blocks (plain Ruby + stdlib sockets, loads neither Bundler nor the gem — cheap at 1–5s intervals, works in curl-less images; exit 0/1/2 = healthy/unhealthy/usage), and a README "Rolling restarts (Kamal, docker)" guide covering the healthcheck block, stop-timeout alignment, overlap-window duplicate-supervisor safety, and the `read_ct`-vs-deploy-kill DLQ caveat. Refs #386.
diff --git a/README.md b/README.md
index cfeb82ab..709ccf75 100644
--- a/README.md
+++ b/README.md
@@ -991,6 +991,7 @@ When `config.metrics_enabled = true` (default), the dashboard exposes Prometheus
| Metric | Description |
|--------|-------------|
+| `pgbus_queue_oldest_claimable_age_seconds` | Age of the oldest message eligible for pickup (visibility timeout elapsed) — safe to alert on: scheduled/backoff-parked messages don't count until due; the series is omitted entirely when no claimable backlog exists (while the raw `pgbus_queue_oldest_message_age_seconds` gauge may still report a parked message's age) |
| `pgbus_table_dead_tuples` | Dead tuple count per PGMQ table |
| `pgbus_table_live_tuples` | Live tuple count per PGMQ table |
| `pgbus_table_bloat_ratio` | Dead / (dead + live) per table |
diff --git a/app/views/pgbus/dashboard/_queues_table.html.erb b/app/views/pgbus/dashboard/_queues_table.html.erb
index 29a41bdf..624d0116 100644
--- a/app/views/pgbus/dashboard/_queues_table.html.erb
+++ b/app/views/pgbus/dashboard/_queues_table.html.erb
@@ -12,7 +12,8 @@
<%= t("pgbus.dashboard.queues_table.headers.queue") %> |
<%= t("pgbus.dashboard.queues_table.headers.depth") %> |
<%= t("pgbus.dashboard.queues_table.headers.visible") %> |
- <%= t("pgbus.dashboard.queues_table.headers.oldest") %> |
+ <%= t("pgbus.dashboard.queues_table.headers.parked") %> |
+ <%= t("pgbus.dashboard.queues_table.headers.oldest_claimable") %> |
<%= t("pgbus.dashboard.queues_table.headers.total") %> |
@@ -25,12 +26,13 @@
<%= pgbus_number(q[:queue_length]) %> |
<%= pgbus_number(q[:queue_visible_length]) %> |
- <%= q[:oldest_msg_age_sec] || "—" %> |
+ <%= pgbus_number(q[:parked_length]) %> |
+ <%= q[:oldest_claimable_age_sec] || "—" %> |
<%= pgbus_number(q[:total_messages]) %> |
<% end %>
<% if @queues.empty? %>
- | <%= t("pgbus.dashboard.queues_table.empty") %> |
+ | <%= t("pgbus.dashboard.queues_table.empty") %> |
<% end %>
diff --git a/app/views/pgbus/queues/_queues_list.html.erb b/app/views/pgbus/queues/_queues_list.html.erb
index ddad678f..1e0835c0 100644
--- a/app/views/pgbus/queues/_queues_list.html.erb
+++ b/app/views/pgbus/queues/_queues_list.html.erb
@@ -6,7 +6,8 @@
<%= t("pgbus.queues.queues_list.headers.queue") %> |
<%= t("pgbus.queues.queues_list.headers.depth") %> |
<%= t("pgbus.queues.queues_list.headers.visible") %> |
- <%= t("pgbus.queues.queues_list.headers.oldest") %> |
+ <%= t("pgbus.queues.queues_list.headers.parked") %> |
+ <%= t("pgbus.queues.queues_list.headers.oldest_claimable") %> |
<%= t("pgbus.queues.queues_list.headers.newest") %> |
<%= t("pgbus.queues.queues_list.headers.total_ever") %> |
<%= t("pgbus.queues.queues_list.headers.actions") %> |
@@ -24,7 +25,8 @@
<%= pgbus_number(q[:queue_length]) %> |
<%= pgbus_number(q[:queue_visible_length]) %> |
- <%= q[:oldest_msg_age_sec] || "—" %> |
+ <%= pgbus_number(q[:parked_length]) %> |
+ <%= q[:oldest_claimable_age_sec] || "—" %> |
<%= q[:newest_msg_age_sec] || "—" %> |
<%= pgbus_number(q[:total_messages]) %> |
@@ -51,7 +53,7 @@
<% end %>
<% if @queues.empty? %>
- | | <%= t("pgbus.queues.queues_list.empty") %> |
+ | <%= t("pgbus.queues.queues_list.empty") %> |
<% end %>
diff --git a/app/views/pgbus/queues/show.html.erb b/app/views/pgbus/queues/show.html.erb
index 1225593b..8065cc46 100644
--- a/app/views/pgbus/queues/show.html.erb
+++ b/app/views/pgbus/queues/show.html.erb
@@ -13,6 +13,9 @@
<%= t("pgbus.queues.show.depth") %> <%= @queue[:queue_length] %> |
<%= t("pgbus.queues.show.visible") %> <%= @queue[:queue_visible_length] %> |
+ <%= t("pgbus.queues.show.parked") %> <%= @queue[:parked_length] %> |
+ <%= t("pgbus.queues.show.oldest") %> <%= @queue[:oldest_msg_age_sec] || "—" %> |
+ <%= t("pgbus.queues.show.oldest_claimable") %> <%= @queue[:oldest_claimable_age_sec] || "—" %> |
<%= t("pgbus.queues.show.total") %> <%= pgbus_number(@queue[:total_messages]) %>
<% end %>
diff --git a/config/locales/da.yml b/config/locales/da.yml
index 4c77443b..0c9b344e 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -65,7 +65,8 @@ da:
empty: Ingen køer fundet
headers:
depth: Dybde
- oldest: Ældste (s)
+ oldest_claimable: Ældste tilgængelig (s)
+ parked: Parkeret
queue: Kø
total: Total
visible: Synlig
@@ -475,7 +476,8 @@ da:
actions: Handlinger
depth: Dybde
newest: Nyeste (s)
- oldest: Ældste (s)
+ oldest_claimable: Ældste tilgængelig (s)
+ parked: Parkeret
queue: Kø
total_ever: Total nogensinde
visible: Synlig
@@ -516,6 +518,9 @@ da:
scheduled: 'Planlagt:'
timezone: 'Tidszone:'
visible_at: 'Synlig fra:'
+ oldest: 'Ældste:'
+ oldest_claimable: 'Ældste tilgængelig:'
+ parked: 'Parkeret:'
pause: Pause
pause_confirm: Pause behandling?
purge_confirm: Rens alle beskeder?
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 13fd0da0..e02aad16 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -65,7 +65,8 @@ de:
empty: Keine Warteschlangen gefunden
headers:
depth: Tiefe
- oldest: Älteste (s)
+ oldest_claimable: Älteste verfügbar (s)
+ parked: Geparkt
queue: Warteschlange
total: Gesamt
visible: Sichtbar
@@ -475,7 +476,8 @@ de:
actions: Aktionen
depth: Tiefe
newest: Neueste (s)
- oldest: Älteste (s)
+ oldest_claimable: Älteste verfügbar (s)
+ parked: Geparkt
queue: Warteschlange
total_ever: Insgesamt jemals
visible: Sichtbar
@@ -516,6 +518,9 @@ de:
scheduled: 'Geplant:'
timezone: 'Zeitzone:'
visible_at: 'Sichtbar ab:'
+ oldest: 'Älteste:'
+ oldest_claimable: 'Älteste verfügbar:'
+ parked: 'Geparkt:'
pause: Pause
pause_confirm: Verarbeitung pausieren?
purge_confirm: Alle Nachrichten löschen?
diff --git a/config/locales/en.yml b/config/locales/en.yml
index d510e2cc..df456900 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -65,7 +65,8 @@ en:
empty: No queues found
headers:
depth: Depth
- oldest: Oldest (s)
+ oldest_claimable: Oldest claimable (s)
+ parked: Parked
queue: Queue
total: Total
visible: Visible
@@ -475,7 +476,8 @@ en:
actions: Actions
depth: Depth
newest: Newest (s)
- oldest: Oldest (s)
+ oldest_claimable: Oldest claimable (s)
+ parked: Parked
queue: Queue
total_ever: Total Ever
visible: Visible
@@ -516,6 +518,9 @@ en:
scheduled: 'Scheduled at:'
timezone: 'Timezone:'
visible_at: 'Visible at:'
+ oldest: 'Oldest:'
+ oldest_claimable: 'Oldest claimable:'
+ parked: 'Parked:'
pause: Pause
pause_confirm: Pause processing?
purge_confirm: Purge all messages?
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 4ee6de0d..dc26fb54 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -65,7 +65,8 @@ es:
empty: No se encontraron colas
headers:
depth: Profundidad
- oldest: El más antiguo (s)
+ oldest_claimable: Más antiguo disponible (s)
+ parked: Aparcados
queue: Cola
total: Total
visible: Visible
@@ -475,7 +476,8 @@ es:
actions: Acciones
depth: Profundidad
newest: Más nuevo (s)
- oldest: Más antiguo (s)
+ oldest_claimable: Más antiguo disponible (s)
+ parked: Aparcados
queue: Cola
total_ever: Total acumulado
visible: Visible
@@ -516,6 +518,9 @@ es:
scheduled: 'Programado:'
timezone: 'Zona horaria:'
visible_at: 'Visible en:'
+ oldest: 'Más antiguo:'
+ oldest_claimable: 'Más antiguo disponible:'
+ parked: 'Aparcados:'
pause: Pausar
pause_confirm: "¿Pausar el procesamiento?"
purge_confirm: "¿Purgar todos los mensajes?"
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 1cc22ccc..57b0ea4b 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -65,7 +65,8 @@ fi:
empty: Jonot eivät löytyneet
headers:
depth: Syvyys
- oldest: Vanhin (s)
+ oldest_claimable: Vanhin saatavilla (s)
+ parked: Pysäköidyt
queue: Jono
total: Yhteensä
visible: Näkyvissä
@@ -475,7 +476,8 @@ fi:
actions: Toiminnot
depth: Syvyys
newest: Uusimmat (s)
- oldest: Vanhimmat (s)
+ oldest_claimable: Vanhin saatavilla (s)
+ parked: Pysäköidyt
queue: Jono
total_ever: Yhteensä koskaan
visible: Näkyvissä
@@ -516,6 +518,9 @@ fi:
scheduled: 'Aikataulutettu:'
timezone: 'Aikavyöhyke:'
visible_at: 'Näkyvissä:'
+ oldest: 'Vanhin:'
+ oldest_claimable: 'Vanhin saatavilla:'
+ parked: 'Pysäköidyt:'
pause: Tauko
pause_confirm: Keskeytetäänkö käsittely?
purge_confirm: Tyhjennetäänkö kaikki viestit?
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index d5aa9ec6..a9c61663 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -65,7 +65,8 @@ fr:
empty: Aucune file d'attente trouvée
headers:
depth: Profondeur
- oldest: Le plus ancien (s)
+ oldest_claimable: Plus ancien disponible (s)
+ parked: Différés
queue: File d'attente
total: Total
visible: Visible
@@ -475,7 +476,8 @@ fr:
actions: Actions
depth: Profondeur
newest: Le plus récent (s)
- oldest: Le plus ancien (s)
+ oldest_claimable: Plus ancien disponible (s)
+ parked: Différés
queue: File d'attente
total_ever: Total jamais
visible: Visible
@@ -516,6 +518,9 @@ fr:
scheduled: 'Planifié :'
timezone: 'Fuseau horaire :'
visible_at: 'Visible à :'
+ oldest: 'Plus ancien :'
+ oldest_claimable: 'Plus ancien disponible :'
+ parked: 'Différés :'
pause: Pause
pause_confirm: Mettre en pause le traitement ?
purge_confirm: Purger tous les messages ?
diff --git a/config/locales/it.yml b/config/locales/it.yml
index 46717511..4d8adc53 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -65,7 +65,8 @@ it:
empty: Nessuna coda trovata
headers:
depth: Profondità
- oldest: Più vecchio (s)
+ oldest_claimable: Più vecchio disponibile (s)
+ parked: Posticipati
queue: Coda
total: Totale
visible: Visibile
@@ -475,7 +476,8 @@ it:
actions: Azioni
depth: Profondità
newest: Più recente (s)
- oldest: Più vecchio (s)
+ oldest_claimable: Più vecchio disponibile (s)
+ parked: Posticipati
queue: Coda
total_ever: Totale mai
visible: Visibile
@@ -516,6 +518,9 @@ it:
scheduled: 'Programmato:'
timezone: 'Fuso orario:'
visible_at: 'Visibile alle:'
+ oldest: 'Più vecchio:'
+ oldest_claimable: 'Più vecchio disponibile:'
+ parked: 'Posticipati:'
pause: Pausa
pause_confirm: Mettere in pausa l'elaborazione?
purge_confirm: Eliminare tutti i messaggi?
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 6b08c43c..7082ab75 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -65,7 +65,8 @@ ja:
empty: キューが見つかりません
headers:
depth: 深さ
- oldest: 最古 (秒)
+ oldest_claimable: 取得可能な最古 (秒)
+ parked: 待機中
queue: キュー
total: 合計
visible: 表示中
@@ -475,7 +476,8 @@ ja:
actions: アクション
depth: 深さ
newest: 最新 (秒)
- oldest: 最古 (秒)
+ oldest_claimable: 取得可能な最古 (秒)
+ parked: 待機中
queue: キュー
total_ever: 合計数
visible: 表示中
@@ -516,6 +518,9 @@ ja:
scheduled: スケジュール済み:
timezone: タイムゾーン:
visible_at: 表示可能日時:
+ oldest: '最古:'
+ oldest_claimable: '取得可能な最古:'
+ parked: '待機中:'
pause: 一時停止
pause_confirm: 処理を一時停止しますか?
purge_confirm: すべてのメッセージを削除しますか?
diff --git a/config/locales/nb.yml b/config/locales/nb.yml
index 8ec75aed..51957ca7 100644
--- a/config/locales/nb.yml
+++ b/config/locales/nb.yml
@@ -65,7 +65,8 @@ nb:
empty: Ingen køer funnet
headers:
depth: Dybde
- oldest: Eldste (s)
+ oldest_claimable: Eldste tilgjengelig (s)
+ parked: Parkert
queue: Kø
total: Totalt
visible: Synlig
@@ -475,7 +476,8 @@ nb:
actions: Handlinger
depth: Dybde
newest: Nyeste (s)
- oldest: Eldste (s)
+ oldest_claimable: Eldste tilgjengelig (s)
+ parked: Parkert
queue: Kø
total_ever: Totalt noensinne
visible: Synlig
@@ -516,6 +518,9 @@ nb:
scheduled: 'Planlagt:'
timezone: 'Tidssone:'
visible_at: 'Synlig fra:'
+ oldest: 'Eldste:'
+ oldest_claimable: 'Eldste tilgjengelig:'
+ parked: 'Parkert:'
pause: Pause
pause_confirm: Pause behandling?
purge_confirm: Rens alle meldinger?
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index b45df4bb..8b3aab68 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -65,7 +65,8 @@ nl:
empty: Geen wachtrijen gevonden
headers:
depth: Diepte
- oldest: Oudste (s)
+ oldest_claimable: Oudste beschikbaar (s)
+ parked: Geparkeerd
queue: Wachtrij
total: Totaal
visible: Zichtbaar
@@ -475,7 +476,8 @@ nl:
actions: Acties
depth: Diepte
newest: Nieuwste (s)
- oldest: Oudste (s)
+ oldest_claimable: Oudste beschikbaar (s)
+ parked: Geparkeerd
queue: Wachtrij
total_ever: Totaal ooit
visible: Zichtbaar
@@ -516,6 +518,9 @@ nl:
scheduled: 'Gepland:'
timezone: 'Tijdzone:'
visible_at: 'Zichtbaar op:'
+ oldest: 'Oudste:'
+ oldest_claimable: 'Oudste beschikbaar:'
+ parked: 'Geparkeerd:'
pause: Pauzeren
pause_confirm: Verwerking pauzeren?
purge_confirm: Alle berichten verwijderen?
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index 8f824c6e..70aa6ac2 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -65,7 +65,8 @@ pt:
empty: Nenhuma fila encontrada
headers:
depth: Profundidade
- oldest: Mais antigo (s)
+ oldest_claimable: Mais antigo disponível (s)
+ parked: Adiados
queue: Fila
total: Total
visible: Visível
@@ -475,7 +476,8 @@ pt:
actions: Ações
depth: Profundidade
newest: Mais novo (s)
- oldest: Mais antigo (s)
+ oldest_claimable: Mais antigo disponível (s)
+ parked: Adiados
queue: Fila
total_ever: Total de todos os tempos
visible: Visível
@@ -516,6 +518,9 @@ pt:
scheduled: 'Agendado:'
timezone: 'Fuso horário:'
visible_at: 'Visível em:'
+ oldest: 'Mais antigo:'
+ oldest_claimable: 'Mais antigo disponível:'
+ parked: 'Adiados:'
pause: Pausar
pause_confirm: Pausar processamento?
purge_confirm: Limpar todas as mensagens?
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index b0c617a5..ee67eda8 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -65,7 +65,8 @@ sv:
empty: Inga köer hittades
headers:
depth: Djup
- oldest: Äldst (s)
+ oldest_claimable: Äldsta tillgänglig (s)
+ parked: Parkerade
queue: Kö
total: Totalt
visible: Synliga
@@ -475,7 +476,8 @@ sv:
actions: Åtgärder
depth: Djup
newest: Nyaste (s)
- oldest: Äldsta (s)
+ oldest_claimable: Äldsta tillgänglig (s)
+ parked: Parkerade
queue: Kö
total_ever: Totalt någonsin
visible: Synliga
@@ -516,6 +518,9 @@ sv:
scheduled: 'Schemalagt:'
timezone: 'Tidszon:'
visible_at: 'Synlig vid:'
+ oldest: 'Äldsta:'
+ oldest_claimable: 'Äldsta tillgänglig:'
+ parked: 'Parkerade:'
pause: Pausa
pause_confirm: Pausa bearbetning?
purge_confirm: Rensa alla meddelanden?
diff --git a/docs/app/views/docs/pages/observability.rb b/docs/app/views/docs/pages/observability.rb
index efe9f384..e3ac0799 100644
--- a/docs/app/views/docs/pages/observability.rb
+++ b/docs/app/views/docs/pages/observability.rb
@@ -144,7 +144,13 @@ def appsignal
Load the `appsignal` gem and pgbus auto-installs a subscriber and a minutely
probe — background-job transactions for every job and handler, `pgbus_`
counters and distributions, and gauges for queue depth, oldest-message age,
- DLQ depth, dead tuples, and MVCC horizon. Four importable dashboards ship
+ DLQ depth, dead tuples, and MVCC horizon. `pgbus_queue_latency` is computed
+ from the oldest *claimable* message (visibility timeout elapsed), so a
+ queue holding only scheduled or backoff-parked jobs reads 0 — alert on it
+ without false positives from one delayed job; the raw
+ `pgbus_queue_oldest_message_age_seconds` gauge keeps enqueue-time
+ semantics, and `pgbus_queue_oldest_claimable_age_seconds` reports the
+ claimable age itself. Four importable dashboards ship
with the gem — `pgbus dashboard --list` enumerates them and
`pgbus dashboard ` prints import-ready JSON for AppSignal's
"Import dashboard" dialog. Opt out with `config.appsignal_enabled = false`.
diff --git a/lib/pgbus/cli.rb b/lib/pgbus/cli.rb
index 0ab6843b..13d0c6e7 100644
--- a/lib/pgbus/cli.rb
+++ b/lib/pgbus/cli.rb
@@ -177,14 +177,16 @@ def run_doctor
def list_queues
Pgbus.client.list_queues
metrics = Pgbus.client.metrics
+ claimable_ages = Pgbus.client.oldest_claimable_ages
- puts "QUEUE DEPTH VISIBLE OLDEST (s) TOTAL "
- puts "-" * 95
+ puts "QUEUE DEPTH VISIBLE OLDEST (s) CLAIMABLE (s) TOTAL "
+ puts "-" * 111
Array(metrics).each do |m|
- puts format("%-40s %-10s %-10s %-15s %-15s",
+ puts format("%-40s %-10s %-10s %-15s %-15s %-15s",
m.queue_name, m.queue_length, m.queue_visible_length,
- m.oldest_msg_age_sec || "-", m.total_messages)
+ m.oldest_msg_age_sec || "-", claimable_ages[m.queue_name] || "-",
+ m.total_messages)
end
end
diff --git a/lib/pgbus/client.rb b/lib/pgbus/client.rb
index 7b10900a..f43a59e9 100644
--- a/lib/pgbus/client.rb
+++ b/lib/pgbus/client.rb
@@ -543,6 +543,38 @@ def metrics(queue_name = nil)
end
end
+ # Age (seconds) of the oldest message actually eligible for pickup, i.e.
+ # whose visibility timeout has elapsed. Unlike pgmq's oldest_msg_age_sec
+ # (computed from enqueued_at), a scheduled or backoff-parked message —
+ # future vt — contributes nothing until it comes due, so a queue holding
+ # only parked messages reads nil ("no claimable backlog") instead of an
+ # age growing at wall-clock rate (issue #389). pgmq's metrics_result type
+ # is frozen upstream, so this lives here rather than in the SQL function.
+ #
+ # With a queue name: the age for that (prefixed) queue, or nil.
+ # Without: a hash of every physical queue in pgmq.meta to its age.
+ #
+ # Routes through the pooled @pgmq.with_connection (health-checked, bounded
+ # by the statement/socket timeouts applied at Client#initialize) rather
+ # than a fresh unbounded PG.connect per call — same rationale as
+ # notify_trigger_current?. synchronized: on the shared-Proc path @pgmq
+ # rides the AR raw connection, so the query must serialize against
+ # concurrent PGMQ operations. One checkout spans all per-queue queries;
+ # nothing nests inside it, so the shared pool_size=1 path is safe.
+ def oldest_claimable_ages(queue_name = nil)
+ synchronized do
+ @pgmq.with_connection do |conn|
+ if queue_name
+ claimable_age_for(conn, config.queue_name(queue_name))
+ else
+ names = conn.exec("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
+ .map { |row| row["queue_name"] }
+ names.to_h { |name| [name, claimable_age_for(conn, name)] }
+ end
+ end
+ end
+ end
+
# Snapshot of the PGMQ connection pool: {size:, available:, pool_timeout:}.
#
# Reads pgmq-ruby's own pool counters (@pgmq.stats -> {size:, available:})
@@ -948,6 +980,18 @@ def install_pgmq_schema(conn)
end
end
+ # queue_name is a physical (already prefixed) queue name; sanitized to a
+ # bare identifier before interpolation, same as the dashboard's DataSource.
+ def claimable_age_for(conn, queue_name)
+ qtable = "q_#{QueueNameValidator.sanitize!(queue_name)}"
+ row = conn.exec(<<~SQL).first
+ SELECT EXTRACT(epoch FROM (NOW() - min(vt)))::int AS age_sec
+ FROM pgmq.#{qtable}
+ WHERE vt <= NOW()
+ SQL
+ row && row["age_sec"]&.to_i
+ end
+
def with_raw_connection
opts = config.connection_options
owned = false
diff --git a/lib/pgbus/integrations/appsignal/probe.rb b/lib/pgbus/integrations/appsignal/probe.rb
index 9b155af8..ce5ea321 100644
--- a/lib/pgbus/integrations/appsignal/probe.rb
+++ b/lib/pgbus/integrations/appsignal/probe.rb
@@ -85,10 +85,14 @@ def track_queues
gauge "queue_visible_depth", q[:queue_visible_length], tags
gauge "queue_paused", q[:paused] ? 1 : 0, tags
age = q[:oldest_msg_age_sec]
- if age
- gauge "queue_oldest_message_age_seconds", age, tags
- gauge "queue_latency", age * 1_000, tags
- end
+ gauge "queue_oldest_message_age_seconds", age, tags if age
+ claimable_age = q[:oldest_claimable_age_sec]
+ gauge "queue_oldest_claimable_age_seconds", claimable_age, tags if claimable_age
+ # Latency = time the oldest *claimable* message has waited for
+ # pickup; a queue holding only vt-parked (scheduled/backoff)
+ # messages is healthy, so 0 — not the raw enqueued_at age, which
+ # grows at wall-clock rate on a parked message (issue #389).
+ gauge "queue_latency", (claimable_age || 0) * 1_000, tags
end
rescue StandardError => e
log_failure("queue metrics", e)
diff --git a/lib/pgbus/mcp/tools/queues_tool.rb b/lib/pgbus/mcp/tools/queues_tool.rb
index 261c2d7d..8ba31fae 100644
--- a/lib/pgbus/mcp/tools/queues_tool.rb
+++ b/lib/pgbus/mcp/tools/queues_tool.rb
@@ -14,6 +14,12 @@ class QueuesTool < BaseTool
(messages whose visibility timeout has expired and are ready to be
claimed), oldest/newest message age in seconds, lifetime total, and
paused state. Use this to answer "are any queues backed up?".
+ oldest_claimable_age_sec is the age of the oldest message actually
+ eligible for pickup — nil means no message is currently claimable:
+ every remaining message is scheduled, backoff-parked, or in flight
+ with a future visibility timeout. A queue whose oldest_msg_age_sec
+ keeps growing while oldest_claimable_age_sec stays nil has no
+ starving backlog — nothing is waiting for a worker.
DESC
input_schema(properties: {}, required: [])
diff --git a/lib/pgbus/web/data_source.rb b/lib/pgbus/web/data_source.rb
index c9271834..d97ca4ed 100644
--- a/lib/pgbus/web/data_source.rb
+++ b/lib/pgbus/web/data_source.rb
@@ -1177,7 +1177,8 @@ def batched_queue_metrics(queue_names)
(SELECT EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int FROM pgmq.#{qtable}) AS oldest_msg_age_sec,
(SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM pgmq.#{seq_name}) AS total_messages,
(SELECT max(read_ct) FROM pgmq.#{qtable}) AS max_read_ct,
- (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW() AND read_ct = 0) AS visible_unread_length
+ (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW() AND read_ct = 0) AS visible_unread_length,
+ (SELECT EXTRACT(epoch FROM (NOW() - min(vt)))::int FROM pgmq.#{qtable} WHERE vt <= NOW()) AS oldest_claimable_age_sec
SQL
rescue StandardError => e
Pgbus.logger.debug { "[Pgbus::Web] Skipping queue metrics for #{name}: #{e.message}" }
@@ -1193,7 +1194,9 @@ def batched_queue_metrics(queue_names)
name: row["queue_name"],
queue_length: row["queue_length"].to_i,
queue_visible_length: row["queue_visible_length"].to_i,
+ parked_length: row["queue_length"].to_i - row["queue_visible_length"].to_i,
oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
+ oldest_claimable_age_sec: row["oldest_claimable_age_sec"]&.to_i,
newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
total_messages: row["total_messages"].to_i,
max_read_ct: row["max_read_ct"]&.to_i,
@@ -1216,6 +1219,7 @@ def queue_metrics_via_sql(queue_name)
count(CASE WHEN vt <= NOW() THEN 1 END) AS queue_visible_length,
EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int AS newest_msg_age_sec,
EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int AS oldest_msg_age_sec,
+ EXTRACT(epoch FROM (NOW() - min(vt) FILTER (WHERE vt <= NOW())))::int AS oldest_claimable_age_sec,
max(read_ct) AS max_read_ct,
count(CASE WHEN vt <= NOW() AND read_ct = 0 THEN 1 END) AS visible_unread_length
FROM pgmq.#{qtable}
@@ -1229,6 +1233,7 @@ def queue_metrics_via_sql(queue_name)
q_summary.queue_visible_length,
q_summary.newest_msg_age_sec,
q_summary.oldest_msg_age_sec,
+ q_summary.oldest_claimable_age_sec,
q_summary.max_read_ct,
q_summary.visible_unread_length,
all_metrics.total_messages
@@ -1241,7 +1246,9 @@ def queue_metrics_via_sql(queue_name)
name: queue_name,
queue_length: row["queue_length"].to_i,
queue_visible_length: row["queue_visible_length"].to_i,
+ parked_length: row["queue_length"].to_i - row["queue_visible_length"].to_i,
oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
+ oldest_claimable_age_sec: row["oldest_claimable_age_sec"]&.to_i,
newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
total_messages: row["total_messages"].to_i,
max_read_ct: row["max_read_ct"]&.to_i,
diff --git a/lib/pgbus/web/metrics_serializer.rb b/lib/pgbus/web/metrics_serializer.rb
index adbd85c5..aa6347dc 100644
--- a/lib/pgbus/web/metrics_serializer.rb
+++ b/lib/pgbus/web/metrics_serializer.rb
@@ -54,6 +54,15 @@ def append_queue_metrics(lines)
end
end
+ gauge(lines, "pgbus_queue_oldest_claimable_age_seconds",
+ "Age of the oldest message eligible for pickup (visibility timeout elapsed)") do
+ queues.filter_map do |q|
+ next unless q[:oldest_claimable_age_sec]
+
+ [q[:oldest_claimable_age_sec], { queue: q[:name] }]
+ end
+ end
+
gauge(lines, "pgbus_queue_paused", "Whether the queue is paused (1) or active (0)") do
queues.map { |q| [q[:paused] ? 1 : 0, { queue: q[:name] }] }
end
diff --git a/spec/dummy/lib/stub_data_source.rb b/spec/dummy/lib/stub_data_source.rb
index c28ce911..87cac869 100644
--- a/spec/dummy/lib/stub_data_source.rb
+++ b/spec/dummy/lib/stub_data_source.rb
@@ -18,14 +18,18 @@ def summary_stats
def queues_with_metrics
[
- { name: "pgbus_default", queue_length: 85, queue_visible_length: 62,
- oldest_msg_age_sec: 300, newest_msg_age_sec: 2, total_messages: 12_450 },
- { name: "pgbus_mailers", queue_length: 22, queue_visible_length: 18,
- oldest_msg_age_sec: 45, newest_msg_age_sec: 1, total_messages: 8_320 },
- { name: "pgbus_events", queue_length: 15, queue_visible_length: 13,
- oldest_msg_age_sec: 120, newest_msg_age_sec: 5, total_messages: 45_000 },
- { name: "pgbus_default_dlq", queue_length: 3, queue_visible_length: 3,
- oldest_msg_age_sec: 7200, newest_msg_age_sec: 3600, total_messages: 47 }
+ { name: "pgbus_default", queue_length: 85, queue_visible_length: 62, parked_length: 23,
+ oldest_msg_age_sec: 300, oldest_claimable_age_sec: 240,
+ newest_msg_age_sec: 2, total_messages: 12_450 },
+ { name: "pgbus_mailers", queue_length: 22, queue_visible_length: 18, parked_length: 4,
+ oldest_msg_age_sec: 45, oldest_claimable_age_sec: 30,
+ newest_msg_age_sec: 1, total_messages: 8_320 },
+ { name: "pgbus_events", queue_length: 15, queue_visible_length: 13, parked_length: 2,
+ oldest_msg_age_sec: 120, oldest_claimable_age_sec: 100,
+ newest_msg_age_sec: 5, total_messages: 45_000 },
+ { name: "pgbus_default_dlq", queue_length: 3, queue_visible_length: 3, parked_length: 0,
+ oldest_msg_age_sec: 7200, oldest_claimable_age_sec: 7200,
+ newest_msg_age_sec: 3600, total_messages: 47 }
]
end
diff --git a/spec/integration/claimable_age_metrics_spec.rb b/spec/integration/claimable_age_metrics_spec.rb
new file mode 100644
index 00000000..b4aec46d
--- /dev/null
+++ b/spec/integration/claimable_age_metrics_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require_relative "../integration_helper"
+
+RSpec.describe "Claimable age metrics (integration)", :integration do
+ let(:client) { Pgbus.client }
+
+ before do
+ client.ensure_queue("claimable_test")
+ client.purge_queue("claimable_test")
+ end
+
+ describe "#oldest_claimable_ages" do
+ it "reports nil for a queue holding only a delayed message, while the raw age counts it" do
+ client.send_message("claimable_test", { "delayed" => true }, delay: 3600)
+
+ expect(client.oldest_claimable_ages("claimable_test")).to be_nil
+
+ metrics = client.metrics("claimable_test")
+ expect(metrics.queue_length.to_i).to eq(1)
+ expect(metrics.oldest_msg_age_sec.to_i).to be >= 0
+ end
+
+ it "reports the age once a message is eligible for pickup" do
+ client.send_message("claimable_test", { "now" => true })
+
+ age = client.oldest_claimable_ages("claimable_test")
+ expect(age).to be_an(Integer)
+ expect(age).to be >= 0
+
+ # The age is wall-clock-relative and only grows between the two calls;
+ # an exact match would flake when connecting to a contended server.
+ all = client.oldest_claimable_ages
+ expect(all.fetch(client.config.queue_name("claimable_test"))).to be >= age
+ end
+
+ it "excludes an in-flight message whose visibility timeout was pushed forward" do
+ client.send_message("claimable_test", { "in_flight" => true })
+ client.read_batch("claimable_test", qty: 1, vt: 60)
+
+ expect(client.oldest_claimable_ages("claimable_test")).to be_nil
+ end
+ end
+end
diff --git a/spec/pgbus/cli_spec.rb b/spec/pgbus/cli_spec.rb
index 57f1e0fc..0199683d 100644
--- a/spec/pgbus/cli_spec.rb
+++ b/spec/pgbus/cli_spec.rb
@@ -310,20 +310,37 @@
end
describe ".list_queues" do
- it "prints formatted table with queue metrics" do
+ it "prints formatted table with queue metrics including the claimable age" do
metric = double("metric",
queue_name: "pgbus_test_default",
queue_length: 10,
queue_visible_length: 8,
oldest_msg_age_sec: 42,
total_messages: 100)
- allow(mock_client).to receive(:metrics).and_return([metric])
+ allow(mock_client).to receive_messages(metrics: [metric],
+ oldest_claimable_ages: { "pgbus_test_default" => 12 })
output = capture_stdout { described_class.list_queues }
expect(output).to include("QUEUE")
+ expect(output).to include("CLAIMABLE")
expect(output).to include("pgbus_test_default")
- expect(output).to include("10")
+ expect(output).to include("12")
+ end
+
+ it "prints a dash for a queue holding only vt-parked messages" do
+ metric = double("metric",
+ queue_name: "pgbus_test_webhooks",
+ queue_length: 1,
+ queue_visible_length: 0,
+ oldest_msg_age_sec: 17_045,
+ total_messages: 500)
+ allow(mock_client).to receive_messages(metrics: [metric],
+ oldest_claimable_ages: { "pgbus_test_webhooks" => nil })
+
+ output = capture_stdout { described_class.list_queues }
+
+ expect(output).to match(/pgbus_test_webhooks.*17045.*-/)
end
end
diff --git a/spec/pgbus/client_spec.rb b/spec/pgbus/client_spec.rb
index 6677fda5..49486bb7 100644
--- a/spec/pgbus/client_spec.rb
+++ b/spec/pgbus/client_spec.rb
@@ -468,6 +468,47 @@ def initialize(*args, **kwargs); end
end
end
+ describe "#oldest_claimable_ages" do
+ let(:raw_conn) { double("PG::Connection") }
+
+ before { allow(mock_pgmq).to receive(:with_connection).and_yield(raw_conn) }
+
+ context "with a queue_name" do
+ it "returns the vt-aware age of the oldest claimable message in the prefixed queue" do
+ allow(raw_conn).to receive(:exec)
+ .with(/min\(vt\).*FROM pgmq\.q_pgbus_test_default.*WHERE vt <= NOW\(\)/m)
+ .and_return([{ "age_sec" => "42" }])
+
+ expect(client.oldest_claimable_ages("default")).to eq(42)
+ end
+
+ it "returns nil when only vt-parked (scheduled/retrying) messages remain" do
+ allow(raw_conn).to receive(:exec).and_return([{ "age_sec" => nil }])
+
+ expect(client.oldest_claimable_ages("default")).to be_nil
+ end
+ end
+
+ context "without a queue_name" do
+ it "maps every queue in pgmq.meta to its claimable age" do
+ allow(raw_conn).to receive(:exec)
+ .with(/FROM pgmq\.meta/)
+ .and_return([{ "queue_name" => "pgbus_test_default" }, { "queue_name" => "pgbus_test_mailers" }])
+ allow(raw_conn).to receive(:exec)
+ .with(/FROM pgmq\.q_pgbus_test_default/m)
+ .and_return([{ "age_sec" => "10" }])
+ allow(raw_conn).to receive(:exec)
+ .with(/FROM pgmq\.q_pgbus_test_mailers/m)
+ .and_return([{ "age_sec" => nil }])
+
+ expect(client.oldest_claimable_ages).to eq(
+ "pgbus_test_default" => 10,
+ "pgbus_test_mailers" => nil
+ )
+ end
+ end
+ end
+
describe "#pool_stats" do
it "returns pgmq pool stats merged with the configured pool_timeout" do
allow(mock_pgmq).to receive(:stats).and_return({ size: 5, available: 3 })
diff --git a/spec/pgbus/integrations/appsignal/probe_spec.rb b/spec/pgbus/integrations/appsignal/probe_spec.rb
index d47676b5..9fc05c98 100644
--- a/spec/pgbus/integrations/appsignal/probe_spec.rb
+++ b/spec/pgbus/integrations/appsignal/probe_spec.rb
@@ -20,8 +20,14 @@ def self.set_gauge(name, value, tags = {})
Class.new do
def queues_with_metrics
[
- { name: "pgbus_default", queue_length: 42, queue_visible_length: 30, oldest_msg_age_sec: 5.0, paused: false },
- { name: "pgbus_critical", queue_length: 0, queue_visible_length: 0, oldest_msg_age_sec: nil, paused: true }
+ { name: "pgbus_default", queue_length: 42, queue_visible_length: 30, oldest_msg_age_sec: 5.0,
+ oldest_claimable_age_sec: 3.0, paused: false },
+ { name: "pgbus_critical", queue_length: 0, queue_visible_length: 0, oldest_msg_age_sec: nil,
+ oldest_claimable_age_sec: nil, paused: true },
+ # The issue #389 incident shape: one backoff-parked message (future vt) —
+ # raw age grows at wall-clock rate while nothing is claimable.
+ { name: "pgbus_webhooks", queue_length: 1, queue_visible_length: 0, oldest_msg_age_sec: 17_045.0,
+ oldest_claimable_age_sec: nil, paused: false }
]
end
@@ -95,22 +101,45 @@ def pool_stats
expect(depth_gauge[2]).to eq(queue: "pgbus_default")
end
- it "records queue latency gauge per queue without a hostname tag" do
+ it "records queue latency from the claimable age, not the raw message age" do
runner = Pgbus::Integrations::Appsignal::Probe::Runner.new(data_source: fake_data_source)
runner.call
latency = appsignal_class.gauges.find { |g| g[0] == "pgbus_queue_latency" && g[2][:queue] == "pgbus_default" }
expect(latency).not_to be_nil
- expect(latency[1]).to eq(5000.0)
+ expect(latency[1]).to eq(3000.0)
expect(latency[2]).to eq(queue: "pgbus_default")
end
- it "skips queue latency when oldest_msg_age_sec is nil" do
+ it "reports zero queue latency when nothing is claimable, even with a vt-parked message" do
runner = Pgbus::Integrations::Appsignal::Probe::Runner.new(data_source: fake_data_source)
runner.call
- critical_latency = appsignal_class.gauges.find { |g| g[0] == "pgbus_queue_latency" && g[2][:queue] == "pgbus_critical" }
- expect(critical_latency).to be_nil
+ parked_latency = appsignal_class.gauges.find { |g| g[0] == "pgbus_queue_latency" && g[2][:queue] == "pgbus_webhooks" }
+ expect(parked_latency).not_to be_nil
+ expect(parked_latency[1]).to eq(0)
+
+ empty_latency = appsignal_class.gauges.find { |g| g[0] == "pgbus_queue_latency" && g[2][:queue] == "pgbus_critical" }
+ expect(empty_latency).not_to be_nil
+ expect(empty_latency[1]).to eq(0)
+ end
+
+ it "records the claimable age gauge only when a claimable backlog exists" do
+ runner = Pgbus::Integrations::Appsignal::Probe::Runner.new(data_source: fake_data_source)
+ runner.call
+
+ claimable = appsignal_class.gauges.select { |g| g[0] == "pgbus_queue_oldest_claimable_age_seconds" }
+ expect(claimable.map { |g| g[2][:queue] }).to eq(["pgbus_default"])
+ expect(claimable.first[1]).to eq(3.0)
+ end
+
+ it "keeps the raw oldest message age gauge for vt-parked messages" do
+ runner = Pgbus::Integrations::Appsignal::Probe::Runner.new(data_source: fake_data_source)
+ runner.call
+
+ raw = appsignal_class.gauges.find { |g| g[0] == "pgbus_queue_oldest_message_age_seconds" && g[2][:queue] == "pgbus_webhooks" }
+ expect(raw).not_to be_nil
+ expect(raw[1]).to eq(17_045.0)
end
it "records active_processes scoped to the current host with hostname tag" do
diff --git a/spec/pgbus/web/data_source_batched_metrics_spec.rb b/spec/pgbus/web/data_source_batched_metrics_spec.rb
index 357e1757..40e8e598 100644
--- a/spec/pgbus/web/data_source_batched_metrics_spec.rb
+++ b/spec/pgbus/web/data_source_batched_metrics_spec.rb
@@ -90,6 +90,58 @@
expect(result.first[:visible_unread_length]).to eq(4)
end
+ it "selects a vt-aware oldest_claimable_age_sec scoped to claimable rows" do
+ allow(conn).to receive(:select_values)
+ .with(a_string_matching(/pgmq\.meta/))
+ .and_return(%w[pgbus_default])
+
+ allow(conn).to receive(:quote) { |v| "'#{v}'" }
+
+ captured_sql = nil
+ allow(conn).to receive(:select_all) do |sql, _label|
+ captured_sql = sql
+ double(to_a: [{ "queue_name" => "pgbus_default", "queue_length" => "5",
+ "queue_visible_length" => "3", "newest_msg_age_sec" => "10",
+ "oldest_msg_age_sec" => "100", "total_messages" => "500",
+ "max_read_ct" => "2", "visible_unread_length" => "0",
+ "oldest_claimable_age_sec" => "42" }])
+ end
+
+ result = data_source.queues_with_metrics
+
+ # Age of the oldest message eligible for pickup: min(vt) over claimable
+ # rows, so a scheduled/backoff-parked message (future vt) contributes
+ # nothing until it comes due (issue #389).
+ expect(captured_sql).to include("min(vt)")
+ expect(captured_sql).to include("oldest_claimable_age_sec")
+ expect(result.first[:oldest_claimable_age_sec]).to eq(42)
+ expect(result.first[:parked_length]).to eq(2)
+ end
+
+ it "reports nil claimable age for a queue holding only vt-parked messages" do
+ allow(conn).to receive(:select_values)
+ .with(a_string_matching(/pgmq\.meta/))
+ .and_return(%w[pgbus_default])
+
+ allow(conn).to receive(:quote) { |v| "'#{v}'" }
+
+ # The issue #389 incident: one vt-parked message — an ActiveJob retry
+ # re-enqueued with wait:, so read_ct is 0 and vt is hours in the future.
+ # The raw age grows at wall-clock rate while nothing is eligible for pickup.
+ allow(conn).to receive(:select_all)
+ .with(anything, "Pgbus Batched Queue Metrics")
+ .and_return(double(to_a: [{ "queue_name" => "pgbus_default", "queue_length" => "1",
+ "queue_visible_length" => "0", "newest_msg_age_sec" => "17045",
+ "oldest_msg_age_sec" => "17045", "total_messages" => "500",
+ "max_read_ct" => "0", "visible_unread_length" => "0",
+ "oldest_claimable_age_sec" => nil }]))
+
+ result = data_source.queues_with_metrics
+
+ expect(result.first[:oldest_msg_age_sec]).to eq(17_045)
+ expect(result.first[:oldest_claimable_age_sec]).to be_nil
+ end
+
it "maps a NULL max_read_ct (empty queue) to nil, not 0" do
allow(conn).to receive(:select_values)
.with(a_string_matching(/pgmq\.meta/))
diff --git a/spec/pgbus/web/data_source_spec.rb b/spec/pgbus/web/data_source_spec.rb
index 2101606f..af7bef95 100644
--- a/spec/pgbus/web/data_source_spec.rb
+++ b/spec/pgbus/web/data_source_spec.rb
@@ -158,6 +158,28 @@
expect(data_source.queue_detail("missing")).to be_nil
end
+
+ it "exposes a vt-aware oldest_claimable_age_sec, nil when only parked messages remain" do
+ captured_sql = nil
+ allow(mock_connection).to receive(:select_one) do |sql, _label|
+ captured_sql = sql
+ {
+ "queue_length" => 1,
+ "queue_visible_length" => 0,
+ "oldest_msg_age_sec" => 17_045,
+ "newest_msg_age_sec" => 17_045,
+ "oldest_claimable_age_sec" => nil,
+ "total_messages" => 500
+ }
+ end
+
+ result = data_source.queue_detail("pgbus_critical")
+
+ expect(captured_sql).to include("oldest_claimable_age_sec")
+ expect(captured_sql).to include("min(vt)")
+ expect(result[:oldest_msg_age_sec]).to eq(17_045)
+ expect(result[:oldest_claimable_age_sec]).to be_nil
+ end
end
describe "#summary_stats" do
diff --git a/spec/pgbus/web/metrics_serializer_spec.rb b/spec/pgbus/web/metrics_serializer_spec.rb
index 8fc9df29..91542407 100644
--- a/spec/pgbus/web/metrics_serializer_spec.rb
+++ b/spec/pgbus/web/metrics_serializer_spec.rb
@@ -10,11 +10,14 @@
let(:queue_metrics) do
[
{ name: "pgbus_default", queue_length: 42, queue_visible_length: 40,
- total_messages: 1000, oldest_msg_age_sec: 120, newest_msg_age_sec: 1, paused: false },
+ total_messages: 1000, oldest_msg_age_sec: 120, oldest_claimable_age_sec: 90,
+ newest_msg_age_sec: 1, paused: false },
{ name: "pgbus_default_dlq", queue_length: 3, queue_visible_length: 3,
- total_messages: 50, oldest_msg_age_sec: 3600, newest_msg_age_sec: 60, paused: false },
+ total_messages: 50, oldest_msg_age_sec: 3600, oldest_claimable_age_sec: 3600,
+ newest_msg_age_sec: 60, paused: false },
{ name: "pgbus_critical", queue_length: 0, queue_visible_length: 0,
- total_messages: 500, oldest_msg_age_sec: nil, newest_msg_age_sec: nil, paused: true }
+ total_messages: 500, oldest_msg_age_sec: nil, oldest_claimable_age_sec: nil,
+ newest_msg_age_sec: nil, paused: true }
]
end
@@ -110,6 +113,14 @@
expect(output).not_to include('pgbus_queue_oldest_message_age_seconds{queue="pgbus_critical"}')
end
+ it "includes vt-aware oldest claimable age" do
+ expect(output).to include('pgbus_queue_oldest_claimable_age_seconds{queue="pgbus_default"} 90')
+ end
+
+ it "omits oldest claimable age when no claimable backlog exists" do
+ expect(output).not_to include('pgbus_queue_oldest_claimable_age_seconds{queue="pgbus_critical"}')
+ end
+
it "includes queue paused gauge (1 for paused, 0 for active)" do
expect(output).to include('pgbus_queue_paused{queue="pgbus_default"} 0')
expect(output).to include('pgbus_queue_paused{queue="pgbus_critical"} 1')
diff --git a/spec/support/pgbus/stub_data_source.rb b/spec/support/pgbus/stub_data_source.rb
index f33cc9bd..4fc1cce3 100644
--- a/spec/support/pgbus/stub_data_source.rb
+++ b/spec/support/pgbus/stub_data_source.rb
@@ -202,10 +202,12 @@ def default_health_stats
def default_queues
[
- { name: "pgbus_default", queue_length: 10, queue_visible_length: 8,
- oldest_msg_age_sec: 120, newest_msg_age_sec: 5, total_messages: 500 },
- { name: "pgbus_default_dlq", queue_length: 2, queue_visible_length: 2,
- oldest_msg_age_sec: 3600, newest_msg_age_sec: 1800, total_messages: 5 }
+ { name: "pgbus_default", queue_length: 10, queue_visible_length: 8, parked_length: 2,
+ oldest_msg_age_sec: 120, oldest_claimable_age_sec: 90,
+ newest_msg_age_sec: 5, total_messages: 500 },
+ { name: "pgbus_default_dlq", queue_length: 2, queue_visible_length: 2, parked_length: 0,
+ oldest_msg_age_sec: 3600, oldest_claimable_age_sec: 3600,
+ newest_msg_age_sec: 1800, total_messages: 5 }
]
end