pgbus 0.13.2 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 295404bb7b5b6d289a2bfd4f10d9e5bc6df442f8d949454cf1dd78adf620a25f
4
- data.tar.gz: 2ee9c8e2c40f47bb900e10d2ef4c55b5e21e3cd4d997c614c628b696ac759183
3
+ metadata.gz: 2e9af048ef1aa2d0fecab62f5471ca9ab6c3e2595353c7eb1a13cde57c37564a
4
+ data.tar.gz: 29a35e789733ac394136eaea8fc2b7f2e709617ceee818dd2b2752d9b017d45d
5
5
  SHA512:
6
- metadata.gz: 184484168fd458dbdb09b98d12ae5d906ebb510a05abc626fd3812ee4cfebb5cd575c090b8014b9524a9aa19f65e99d1737df50b191ce79367e77858f72c5024
7
- data.tar.gz: 92170d5077128d507968989f8d9d789bdc9afa7b4ccf92ecdc3b39b357a82608de39a83b4e9f2f899078466714bfb30f3f0c357a9dc977f35c9c21254c8b7dbd
6
+ metadata.gz: 392a67ed233feb5ffe0a98cf597efe3962b9e7c81d07a7e351f6c4e8aab8c03b3edc8e55204c3f0d0a4b82287b7f523cada5ca79e0684752f50c8f3272c9bdc0
7
+ data.tar.gz: 95b48a0ae66aaa239da08fa3c235a412241ddcfacea8384d84a66ca298308678062dc0192592b07484596fe12e27145a71474d5547b5046370bfca2785ae0f03
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Fixed
4
+
5
+ - **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.
6
+
3
7
  ### Added
4
8
 
5
9
  - **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.
data/README.md CHANGED
@@ -991,6 +991,7 @@ When `config.metrics_enabled = true` (default), the dashboard exposes Prometheus
991
991
 
992
992
  | Metric | Description |
993
993
  |--------|-------------|
994
+ | `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) |
994
995
  | `pgbus_table_dead_tuples` | Dead tuple count per PGMQ table |
995
996
  | `pgbus_table_live_tuples` | Live tuple count per PGMQ table |
996
997
  | `pgbus_table_bloat_ratio` | Dead / (dead + live) per table |
@@ -12,7 +12,8 @@
12
12
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dashboard.queues_table.headers.queue") %></th>
13
13
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dashboard.queues_table.headers.depth") %></th>
14
14
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dashboard.queues_table.headers.visible") %></th>
15
- <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dashboard.queues_table.headers.oldest") %></th>
15
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dashboard.queues_table.headers.parked") %></th>
16
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dashboard.queues_table.headers.oldest_claimable") %></th>
16
17
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dashboard.queues_table.headers.total") %></th>
17
18
  </tr>
18
19
  </thead>
@@ -25,12 +26,13 @@
25
26
  </td>
26
27
  <td data-label="Depth" class="px-4 py-3 text-sm text-right text-gray-700"><%= pgbus_number(q[:queue_length]) %></td>
27
28
  <td data-label="Visible" class="px-4 py-3 text-sm text-right text-gray-700"><%= pgbus_number(q[:queue_visible_length]) %></td>
28
- <td data-label="Oldest" class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:oldest_msg_age_sec] || "—" %></td>
29
+ <td data-label="Parked" class="px-4 py-3 text-sm text-right text-gray-500"><%= pgbus_number(q[:parked_length]) %></td>
30
+ <td data-label="Oldest claimable" class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:oldest_claimable_age_sec] || "—" %></td>
29
31
  <td data-label="Total" class="px-4 py-3 text-sm text-right text-gray-500"><%= pgbus_number(q[:total_messages]) %></td>
30
32
  </tr>
31
33
  <% end %>
32
34
  <% if @queues.empty? %>
33
- <tr><td colspan="5" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.dashboard.queues_table.empty") %></td></tr>
35
+ <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.dashboard.queues_table.empty") %></td></tr>
34
36
  <% end %>
35
37
  </tbody>
36
38
  </table>
@@ -6,7 +6,8 @@
6
6
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.queue") %></th>
7
7
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.depth") %></th>
8
8
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.visible") %></th>
9
- <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.oldest") %></th>
9
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.parked") %></th>
10
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.oldest_claimable") %></th>
10
11
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.newest") %></th>
11
12
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.total_ever") %></th>
12
13
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500"><%= t("pgbus.queues.queues_list.headers.actions") %></th>
@@ -24,7 +25,8 @@
24
25
  </td>
25
26
  <td data-label="Depth" class="px-4 py-3 text-sm text-right font-mono text-gray-700"><%= pgbus_number(q[:queue_length]) %></td>
26
27
  <td data-label="Visible" class="px-4 py-3 text-sm text-right font-mono text-gray-700"><%= pgbus_number(q[:queue_visible_length]) %></td>
27
- <td data-label="Oldest" class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:oldest_msg_age_sec] || "—" %></td>
28
+ <td data-label="Parked" class="px-4 py-3 text-sm text-right font-mono text-gray-500"><%= pgbus_number(q[:parked_length]) %></td>
29
+ <td data-label="Oldest claimable" class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:oldest_claimable_age_sec] || "—" %></td>
28
30
  <td data-label="Newest" class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:newest_msg_age_sec] || "—" %></td>
29
31
  <td data-label="Total" class="px-4 py-3 text-sm text-right text-gray-500"><%= pgbus_number(q[:total_messages]) %></td>
30
32
  <td data-label="Actions" class="px-4 py-3 text-sm text-right space-x-2">
@@ -51,7 +53,7 @@
51
53
  </tr>
52
54
  <% end %>
53
55
  <% if @queues.empty? %>
54
- <tr><td colspan="7" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.queues.queues_list.empty") %></td></tr>
56
+ <tr><td colspan="8" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.queues.queues_list.empty") %></td></tr>
55
57
  <% end %>
56
58
  </tbody>
57
59
  </table>
@@ -13,6 +13,9 @@
13
13
  <p class="text-sm text-gray-500 mt-1">
14
14
  <%= t("pgbus.queues.show.depth") %> <span class="font-mono"><%= @queue[:queue_length] %></span> |
15
15
  <%= t("pgbus.queues.show.visible") %> <span class="font-mono"><%= @queue[:queue_visible_length] %></span> |
16
+ <%= t("pgbus.queues.show.parked") %> <span class="font-mono"><%= @queue[:parked_length] %></span> |
17
+ <%= t("pgbus.queues.show.oldest") %> <span class="font-mono"><%= @queue[:oldest_msg_age_sec] || "—" %></span> |
18
+ <%= t("pgbus.queues.show.oldest_claimable") %> <span class="font-mono"><%= @queue[:oldest_claimable_age_sec] || "—" %></span> |
16
19
  <%= t("pgbus.queues.show.total") %> <span class="font-mono"><%= pgbus_number(@queue[:total_messages]) %></span>
17
20
  </p>
18
21
  <% end %>
@@ -65,7 +65,8 @@ da:
65
65
  empty: Ingen køer fundet
66
66
  headers:
67
67
  depth: Dybde
68
- oldest: Ældste (s)
68
+ oldest_claimable: Ældste tilgængelig (s)
69
+ parked: Parkeret
69
70
  queue: Kø
70
71
  total: Total
71
72
  visible: Synlig
@@ -475,7 +476,8 @@ da:
475
476
  actions: Handlinger
476
477
  depth: Dybde
477
478
  newest: Nyeste (s)
478
- oldest: Ældste (s)
479
+ oldest_claimable: Ældste tilgængelig (s)
480
+ parked: Parkeret
479
481
  queue: Kø
480
482
  total_ever: Total nogensinde
481
483
  visible: Synlig
@@ -516,6 +518,9 @@ da:
516
518
  scheduled: 'Planlagt:'
517
519
  timezone: 'Tidszone:'
518
520
  visible_at: 'Synlig fra:'
521
+ oldest: 'Ældste:'
522
+ oldest_claimable: 'Ældste tilgængelig:'
523
+ parked: 'Parkeret:'
519
524
  pause: Pause
520
525
  pause_confirm: Pause behandling?
521
526
  purge_confirm: Rens alle beskeder?
@@ -65,7 +65,8 @@ de:
65
65
  empty: Keine Warteschlangen gefunden
66
66
  headers:
67
67
  depth: Tiefe
68
- oldest: Älteste (s)
68
+ oldest_claimable: Älteste verfügbar (s)
69
+ parked: Geparkt
69
70
  queue: Warteschlange
70
71
  total: Gesamt
71
72
  visible: Sichtbar
@@ -475,7 +476,8 @@ de:
475
476
  actions: Aktionen
476
477
  depth: Tiefe
477
478
  newest: Neueste (s)
478
- oldest: Älteste (s)
479
+ oldest_claimable: Älteste verfügbar (s)
480
+ parked: Geparkt
479
481
  queue: Warteschlange
480
482
  total_ever: Insgesamt jemals
481
483
  visible: Sichtbar
@@ -516,6 +518,9 @@ de:
516
518
  scheduled: 'Geplant:'
517
519
  timezone: 'Zeitzone:'
518
520
  visible_at: 'Sichtbar ab:'
521
+ oldest: 'Älteste:'
522
+ oldest_claimable: 'Älteste verfügbar:'
523
+ parked: 'Geparkt:'
519
524
  pause: Pause
520
525
  pause_confirm: Verarbeitung pausieren?
521
526
  purge_confirm: Alle Nachrichten löschen?
@@ -65,7 +65,8 @@ en:
65
65
  empty: No queues found
66
66
  headers:
67
67
  depth: Depth
68
- oldest: Oldest (s)
68
+ oldest_claimable: Oldest claimable (s)
69
+ parked: Parked
69
70
  queue: Queue
70
71
  total: Total
71
72
  visible: Visible
@@ -475,7 +476,8 @@ en:
475
476
  actions: Actions
476
477
  depth: Depth
477
478
  newest: Newest (s)
478
- oldest: Oldest (s)
479
+ oldest_claimable: Oldest claimable (s)
480
+ parked: Parked
479
481
  queue: Queue
480
482
  total_ever: Total Ever
481
483
  visible: Visible
@@ -516,6 +518,9 @@ en:
516
518
  scheduled: 'Scheduled at:'
517
519
  timezone: 'Timezone:'
518
520
  visible_at: 'Visible at:'
521
+ oldest: 'Oldest:'
522
+ oldest_claimable: 'Oldest claimable:'
523
+ parked: 'Parked:'
519
524
  pause: Pause
520
525
  pause_confirm: Pause processing?
521
526
  purge_confirm: Purge all messages?
@@ -65,7 +65,8 @@ es:
65
65
  empty: No se encontraron colas
66
66
  headers:
67
67
  depth: Profundidad
68
- oldest: El más antiguo (s)
68
+ oldest_claimable: Más antiguo disponible (s)
69
+ parked: Aparcados
69
70
  queue: Cola
70
71
  total: Total
71
72
  visible: Visible
@@ -475,7 +476,8 @@ es:
475
476
  actions: Acciones
476
477
  depth: Profundidad
477
478
  newest: Más nuevo (s)
478
- oldest: Más antiguo (s)
479
+ oldest_claimable: Más antiguo disponible (s)
480
+ parked: Aparcados
479
481
  queue: Cola
480
482
  total_ever: Total acumulado
481
483
  visible: Visible
@@ -516,6 +518,9 @@ es:
516
518
  scheduled: 'Programado:'
517
519
  timezone: 'Zona horaria:'
518
520
  visible_at: 'Visible en:'
521
+ oldest: 'Más antiguo:'
522
+ oldest_claimable: 'Más antiguo disponible:'
523
+ parked: 'Aparcados:'
519
524
  pause: Pausar
520
525
  pause_confirm: "¿Pausar el procesamiento?"
521
526
  purge_confirm: "¿Purgar todos los mensajes?"
@@ -65,7 +65,8 @@ fi:
65
65
  empty: Jonot eivät löytyneet
66
66
  headers:
67
67
  depth: Syvyys
68
- oldest: Vanhin (s)
68
+ oldest_claimable: Vanhin saatavilla (s)
69
+ parked: Pysäköidyt
69
70
  queue: Jono
70
71
  total: Yhteensä
71
72
  visible: Näkyvissä
@@ -475,7 +476,8 @@ fi:
475
476
  actions: Toiminnot
476
477
  depth: Syvyys
477
478
  newest: Uusimmat (s)
478
- oldest: Vanhimmat (s)
479
+ oldest_claimable: Vanhin saatavilla (s)
480
+ parked: Pysäköidyt
479
481
  queue: Jono
480
482
  total_ever: Yhteensä koskaan
481
483
  visible: Näkyvissä
@@ -516,6 +518,9 @@ fi:
516
518
  scheduled: 'Aikataulutettu:'
517
519
  timezone: 'Aikavyöhyke:'
518
520
  visible_at: 'Näkyvissä:'
521
+ oldest: 'Vanhin:'
522
+ oldest_claimable: 'Vanhin saatavilla:'
523
+ parked: 'Pysäköidyt:'
519
524
  pause: Tauko
520
525
  pause_confirm: Keskeytetäänkö käsittely?
521
526
  purge_confirm: Tyhjennetäänkö kaikki viestit?
@@ -65,7 +65,8 @@ fr:
65
65
  empty: Aucune file d'attente trouvée
66
66
  headers:
67
67
  depth: Profondeur
68
- oldest: Le plus ancien (s)
68
+ oldest_claimable: Plus ancien disponible (s)
69
+ parked: Différés
69
70
  queue: File d'attente
70
71
  total: Total
71
72
  visible: Visible
@@ -475,7 +476,8 @@ fr:
475
476
  actions: Actions
476
477
  depth: Profondeur
477
478
  newest: Le plus récent (s)
478
- oldest: Le plus ancien (s)
479
+ oldest_claimable: Plus ancien disponible (s)
480
+ parked: Différés
479
481
  queue: File d'attente
480
482
  total_ever: Total jamais
481
483
  visible: Visible
@@ -516,6 +518,9 @@ fr:
516
518
  scheduled: 'Planifié :'
517
519
  timezone: 'Fuseau horaire :'
518
520
  visible_at: 'Visible à :'
521
+ oldest: 'Plus ancien :'
522
+ oldest_claimable: 'Plus ancien disponible :'
523
+ parked: 'Différés :'
519
524
  pause: Pause
520
525
  pause_confirm: Mettre en pause le traitement ?
521
526
  purge_confirm: Purger tous les messages ?
@@ -65,7 +65,8 @@ it:
65
65
  empty: Nessuna coda trovata
66
66
  headers:
67
67
  depth: Profondità
68
- oldest: Più vecchio (s)
68
+ oldest_claimable: Più vecchio disponibile (s)
69
+ parked: Posticipati
69
70
  queue: Coda
70
71
  total: Totale
71
72
  visible: Visibile
@@ -475,7 +476,8 @@ it:
475
476
  actions: Azioni
476
477
  depth: Profondità
477
478
  newest: Più recente (s)
478
- oldest: Più vecchio (s)
479
+ oldest_claimable: Più vecchio disponibile (s)
480
+ parked: Posticipati
479
481
  queue: Coda
480
482
  total_ever: Totale mai
481
483
  visible: Visibile
@@ -516,6 +518,9 @@ it:
516
518
  scheduled: 'Programmato:'
517
519
  timezone: 'Fuso orario:'
518
520
  visible_at: 'Visibile alle:'
521
+ oldest: 'Più vecchio:'
522
+ oldest_claimable: 'Più vecchio disponibile:'
523
+ parked: 'Posticipati:'
519
524
  pause: Pausa
520
525
  pause_confirm: Mettere in pausa l'elaborazione?
521
526
  purge_confirm: Eliminare tutti i messaggi?
@@ -65,7 +65,8 @@ ja:
65
65
  empty: キューが見つかりません
66
66
  headers:
67
67
  depth: 深さ
68
- oldest: 最古 (秒)
68
+ oldest_claimable: 取得可能な最古 (秒)
69
+ parked: 待機中
69
70
  queue: キュー
70
71
  total: 合計
71
72
  visible: 表示中
@@ -475,7 +476,8 @@ ja:
475
476
  actions: アクション
476
477
  depth: 深さ
477
478
  newest: 最新 (秒)
478
- oldest: 最古 (秒)
479
+ oldest_claimable: 取得可能な最古 (秒)
480
+ parked: 待機中
479
481
  queue: キュー
480
482
  total_ever: 合計数
481
483
  visible: 表示中
@@ -516,6 +518,9 @@ ja:
516
518
  scheduled: スケジュール済み:
517
519
  timezone: タイムゾーン:
518
520
  visible_at: 表示可能日時:
521
+ oldest: '最古:'
522
+ oldest_claimable: '取得可能な最古:'
523
+ parked: '待機中:'
519
524
  pause: 一時停止
520
525
  pause_confirm: 処理を一時停止しますか?
521
526
  purge_confirm: すべてのメッセージを削除しますか?
@@ -65,7 +65,8 @@ nb:
65
65
  empty: Ingen køer funnet
66
66
  headers:
67
67
  depth: Dybde
68
- oldest: Eldste (s)
68
+ oldest_claimable: Eldste tilgjengelig (s)
69
+ parked: Parkert
69
70
  queue: Kø
70
71
  total: Totalt
71
72
  visible: Synlig
@@ -475,7 +476,8 @@ nb:
475
476
  actions: Handlinger
476
477
  depth: Dybde
477
478
  newest: Nyeste (s)
478
- oldest: Eldste (s)
479
+ oldest_claimable: Eldste tilgjengelig (s)
480
+ parked: Parkert
479
481
  queue: Kø
480
482
  total_ever: Totalt noensinne
481
483
  visible: Synlig
@@ -516,6 +518,9 @@ nb:
516
518
  scheduled: 'Planlagt:'
517
519
  timezone: 'Tidssone:'
518
520
  visible_at: 'Synlig fra:'
521
+ oldest: 'Eldste:'
522
+ oldest_claimable: 'Eldste tilgjengelig:'
523
+ parked: 'Parkert:'
519
524
  pause: Pause
520
525
  pause_confirm: Pause behandling?
521
526
  purge_confirm: Rens alle meldinger?
@@ -65,7 +65,8 @@ nl:
65
65
  empty: Geen wachtrijen gevonden
66
66
  headers:
67
67
  depth: Diepte
68
- oldest: Oudste (s)
68
+ oldest_claimable: Oudste beschikbaar (s)
69
+ parked: Geparkeerd
69
70
  queue: Wachtrij
70
71
  total: Totaal
71
72
  visible: Zichtbaar
@@ -475,7 +476,8 @@ nl:
475
476
  actions: Acties
476
477
  depth: Diepte
477
478
  newest: Nieuwste (s)
478
- oldest: Oudste (s)
479
+ oldest_claimable: Oudste beschikbaar (s)
480
+ parked: Geparkeerd
479
481
  queue: Wachtrij
480
482
  total_ever: Totaal ooit
481
483
  visible: Zichtbaar
@@ -516,6 +518,9 @@ nl:
516
518
  scheduled: 'Gepland:'
517
519
  timezone: 'Tijdzone:'
518
520
  visible_at: 'Zichtbaar op:'
521
+ oldest: 'Oudste:'
522
+ oldest_claimable: 'Oudste beschikbaar:'
523
+ parked: 'Geparkeerd:'
519
524
  pause: Pauzeren
520
525
  pause_confirm: Verwerking pauzeren?
521
526
  purge_confirm: Alle berichten verwijderen?
@@ -65,7 +65,8 @@ pt:
65
65
  empty: Nenhuma fila encontrada
66
66
  headers:
67
67
  depth: Profundidade
68
- oldest: Mais antigo (s)
68
+ oldest_claimable: Mais antigo disponível (s)
69
+ parked: Adiados
69
70
  queue: Fila
70
71
  total: Total
71
72
  visible: Visível
@@ -475,7 +476,8 @@ pt:
475
476
  actions: Ações
476
477
  depth: Profundidade
477
478
  newest: Mais novo (s)
478
- oldest: Mais antigo (s)
479
+ oldest_claimable: Mais antigo disponível (s)
480
+ parked: Adiados
479
481
  queue: Fila
480
482
  total_ever: Total de todos os tempos
481
483
  visible: Visível
@@ -516,6 +518,9 @@ pt:
516
518
  scheduled: 'Agendado:'
517
519
  timezone: 'Fuso horário:'
518
520
  visible_at: 'Visível em:'
521
+ oldest: 'Mais antigo:'
522
+ oldest_claimable: 'Mais antigo disponível:'
523
+ parked: 'Adiados:'
519
524
  pause: Pausar
520
525
  pause_confirm: Pausar processamento?
521
526
  purge_confirm: Limpar todas as mensagens?
@@ -65,7 +65,8 @@ sv:
65
65
  empty: Inga köer hittades
66
66
  headers:
67
67
  depth: Djup
68
- oldest: Äldst (s)
68
+ oldest_claimable: Äldsta tillgänglig (s)
69
+ parked: Parkerade
69
70
  queue: Kö
70
71
  total: Totalt
71
72
  visible: Synliga
@@ -475,7 +476,8 @@ sv:
475
476
  actions: Åtgärder
476
477
  depth: Djup
477
478
  newest: Nyaste (s)
478
- oldest: Äldsta (s)
479
+ oldest_claimable: Äldsta tillgänglig (s)
480
+ parked: Parkerade
479
481
  queue: Kö
480
482
  total_ever: Totalt någonsin
481
483
  visible: Synliga
@@ -516,6 +518,9 @@ sv:
516
518
  scheduled: 'Schemalagt:'
517
519
  timezone: 'Tidszon:'
518
520
  visible_at: 'Synlig vid:'
521
+ oldest: 'Äldsta:'
522
+ oldest_claimable: 'Äldsta tillgänglig:'
523
+ parked: 'Parkerade:'
519
524
  pause: Pausa
520
525
  pause_confirm: Pausa bearbetning?
521
526
  purge_confirm: Rensa alla meddelanden?
data/lib/pgbus/cli.rb CHANGED
@@ -177,14 +177,16 @@ module Pgbus
177
177
  def list_queues
178
178
  Pgbus.client.list_queues
179
179
  metrics = Pgbus.client.metrics
180
+ claimable_ages = Pgbus.client.oldest_claimable_ages
180
181
 
181
- puts "QUEUE DEPTH VISIBLE OLDEST (s) TOTAL "
182
- puts "-" * 95
182
+ puts "QUEUE DEPTH VISIBLE OLDEST (s) CLAIMABLE (s) TOTAL "
183
+ puts "-" * 111
183
184
 
184
185
  Array(metrics).each do |m|
185
- puts format("%-40s %-10s %-10s %-15s %-15s",
186
+ puts format("%-40s %-10s %-10s %-15s %-15s %-15s",
186
187
  m.queue_name, m.queue_length, m.queue_visible_length,
187
- m.oldest_msg_age_sec || "-", m.total_messages)
188
+ m.oldest_msg_age_sec || "-", claimable_ages[m.queue_name] || "-",
189
+ m.total_messages)
188
190
  end
189
191
  end
190
192
 
data/lib/pgbus/client.rb CHANGED
@@ -543,6 +543,38 @@ module Pgbus
543
543
  end
544
544
  end
545
545
 
546
+ # Age (seconds) of the oldest message actually eligible for pickup, i.e.
547
+ # whose visibility timeout has elapsed. Unlike pgmq's oldest_msg_age_sec
548
+ # (computed from enqueued_at), a scheduled or backoff-parked message —
549
+ # future vt — contributes nothing until it comes due, so a queue holding
550
+ # only parked messages reads nil ("no claimable backlog") instead of an
551
+ # age growing at wall-clock rate (issue #389). pgmq's metrics_result type
552
+ # is frozen upstream, so this lives here rather than in the SQL function.
553
+ #
554
+ # With a queue name: the age for that (prefixed) queue, or nil.
555
+ # Without: a hash of every physical queue in pgmq.meta to its age.
556
+ #
557
+ # Routes through the pooled @pgmq.with_connection (health-checked, bounded
558
+ # by the statement/socket timeouts applied at Client#initialize) rather
559
+ # than a fresh unbounded PG.connect per call — same rationale as
560
+ # notify_trigger_current?. synchronized: on the shared-Proc path @pgmq
561
+ # rides the AR raw connection, so the query must serialize against
562
+ # concurrent PGMQ operations. One checkout spans all per-queue queries;
563
+ # nothing nests inside it, so the shared pool_size=1 path is safe.
564
+ def oldest_claimable_ages(queue_name = nil)
565
+ synchronized do
566
+ @pgmq.with_connection do |conn|
567
+ if queue_name
568
+ claimable_age_for(conn, config.queue_name(queue_name))
569
+ else
570
+ names = conn.exec("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
571
+ .map { |row| row["queue_name"] }
572
+ names.to_h { |name| [name, claimable_age_for(conn, name)] }
573
+ end
574
+ end
575
+ end
576
+ end
577
+
546
578
  # Snapshot of the PGMQ connection pool: {size:, available:, pool_timeout:}.
547
579
  #
548
580
  # Reads pgmq-ruby's own pool counters (@pgmq.stats -> {size:, available:})
@@ -948,6 +980,18 @@ module Pgbus
948
980
  end
949
981
  end
950
982
 
983
+ # queue_name is a physical (already prefixed) queue name; sanitized to a
984
+ # bare identifier before interpolation, same as the dashboard's DataSource.
985
+ def claimable_age_for(conn, queue_name)
986
+ qtable = "q_#{QueueNameValidator.sanitize!(queue_name)}"
987
+ row = conn.exec(<<~SQL).first
988
+ SELECT EXTRACT(epoch FROM (NOW() - min(vt)))::int AS age_sec
989
+ FROM pgmq.#{qtable}
990
+ WHERE vt <= NOW()
991
+ SQL
992
+ row && row["age_sec"]&.to_i
993
+ end
994
+
951
995
  def with_raw_connection
952
996
  opts = config.connection_options
953
997
  owned = false
@@ -85,10 +85,14 @@ module Pgbus
85
85
  gauge "queue_visible_depth", q[:queue_visible_length], tags
86
86
  gauge "queue_paused", q[:paused] ? 1 : 0, tags
87
87
  age = q[:oldest_msg_age_sec]
88
- if age
89
- gauge "queue_oldest_message_age_seconds", age, tags
90
- gauge "queue_latency", age * 1_000, tags
91
- end
88
+ gauge "queue_oldest_message_age_seconds", age, tags if age
89
+ claimable_age = q[:oldest_claimable_age_sec]
90
+ gauge "queue_oldest_claimable_age_seconds", claimable_age, tags if claimable_age
91
+ # Latency = time the oldest *claimable* message has waited for
92
+ # pickup; a queue holding only vt-parked (scheduled/backoff)
93
+ # messages is healthy, so 0 — not the raw enqueued_at age, which
94
+ # grows at wall-clock rate on a parked message (issue #389).
95
+ gauge "queue_latency", (claimable_age || 0) * 1_000, tags
92
96
  end
93
97
  rescue StandardError => e
94
98
  log_failure("queue metrics", e)
@@ -14,6 +14,12 @@ module Pgbus
14
14
  (messages whose visibility timeout has expired and are ready to be
15
15
  claimed), oldest/newest message age in seconds, lifetime total, and
16
16
  paused state. Use this to answer "are any queues backed up?".
17
+ oldest_claimable_age_sec is the age of the oldest message actually
18
+ eligible for pickup — nil means no message is currently claimable:
19
+ every remaining message is scheduled, backoff-parked, or in flight
20
+ with a future visibility timeout. A queue whose oldest_msg_age_sec
21
+ keeps growing while oldest_claimable_age_sec stays nil has no
22
+ starving backlog — nothing is waiting for a worker.
17
23
  DESC
18
24
 
19
25
  input_schema(properties: {}, required: [])
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.13.2"
4
+ VERSION = "0.13.3"
5
5
  end
@@ -1177,7 +1177,8 @@ module Pgbus
1177
1177
  (SELECT EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int FROM pgmq.#{qtable}) AS oldest_msg_age_sec,
1178
1178
  (SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM pgmq.#{seq_name}) AS total_messages,
1179
1179
  (SELECT max(read_ct) FROM pgmq.#{qtable}) AS max_read_ct,
1180
- (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW() AND read_ct = 0) AS visible_unread_length
1180
+ (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW() AND read_ct = 0) AS visible_unread_length,
1181
+ (SELECT EXTRACT(epoch FROM (NOW() - min(vt)))::int FROM pgmq.#{qtable} WHERE vt <= NOW()) AS oldest_claimable_age_sec
1181
1182
  SQL
1182
1183
  rescue StandardError => e
1183
1184
  Pgbus.logger.debug { "[Pgbus::Web] Skipping queue metrics for #{name}: #{e.message}" }
@@ -1193,7 +1194,9 @@ module Pgbus
1193
1194
  name: row["queue_name"],
1194
1195
  queue_length: row["queue_length"].to_i,
1195
1196
  queue_visible_length: row["queue_visible_length"].to_i,
1197
+ parked_length: row["queue_length"].to_i - row["queue_visible_length"].to_i,
1196
1198
  oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
1199
+ oldest_claimable_age_sec: row["oldest_claimable_age_sec"]&.to_i,
1197
1200
  newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
1198
1201
  total_messages: row["total_messages"].to_i,
1199
1202
  max_read_ct: row["max_read_ct"]&.to_i,
@@ -1216,6 +1219,7 @@ module Pgbus
1216
1219
  count(CASE WHEN vt <= NOW() THEN 1 END) AS queue_visible_length,
1217
1220
  EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int AS newest_msg_age_sec,
1218
1221
  EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int AS oldest_msg_age_sec,
1222
+ EXTRACT(epoch FROM (NOW() - min(vt) FILTER (WHERE vt <= NOW())))::int AS oldest_claimable_age_sec,
1219
1223
  max(read_ct) AS max_read_ct,
1220
1224
  count(CASE WHEN vt <= NOW() AND read_ct = 0 THEN 1 END) AS visible_unread_length
1221
1225
  FROM pgmq.#{qtable}
@@ -1229,6 +1233,7 @@ module Pgbus
1229
1233
  q_summary.queue_visible_length,
1230
1234
  q_summary.newest_msg_age_sec,
1231
1235
  q_summary.oldest_msg_age_sec,
1236
+ q_summary.oldest_claimable_age_sec,
1232
1237
  q_summary.max_read_ct,
1233
1238
  q_summary.visible_unread_length,
1234
1239
  all_metrics.total_messages
@@ -1241,7 +1246,9 @@ module Pgbus
1241
1246
  name: queue_name,
1242
1247
  queue_length: row["queue_length"].to_i,
1243
1248
  queue_visible_length: row["queue_visible_length"].to_i,
1249
+ parked_length: row["queue_length"].to_i - row["queue_visible_length"].to_i,
1244
1250
  oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
1251
+ oldest_claimable_age_sec: row["oldest_claimable_age_sec"]&.to_i,
1245
1252
  newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
1246
1253
  total_messages: row["total_messages"].to_i,
1247
1254
  max_read_ct: row["max_read_ct"]&.to_i,
@@ -54,6 +54,15 @@ module Pgbus
54
54
  end
55
55
  end
56
56
 
57
+ gauge(lines, "pgbus_queue_oldest_claimable_age_seconds",
58
+ "Age of the oldest message eligible for pickup (visibility timeout elapsed)") do
59
+ queues.filter_map do |q|
60
+ next unless q[:oldest_claimable_age_sec]
61
+
62
+ [q[:oldest_claimable_age_sec], { queue: q[:name] }]
63
+ end
64
+ end
65
+
57
66
  gauge(lines, "pgbus_queue_paused", "Whether the queue is paused (1) or active (0)") do
58
67
  queues.map { |q| [q[:paused] ? 1 : 0, { queue: q[:name] }] }
59
68
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.2
4
+ version: 0.13.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson