Skip to content

Commit 9e86f1e

Browse files
ianbibbySuperQ
andauthored
Adds pg_stat_progress_vacuum collector (#1141)
Signed-off-by: Ian Bibby <ian.bibby@reddit.com> Co-authored-by: Ben Kochie <superq@gmail.com>
1 parent fca2ad8 commit 9e86f1e

File tree

3 files changed

+360
-0
lines changed

3 files changed

+360
-0
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ This will build the docker image as `prometheuscommunity/postgres_exporter:${bra
144144
* `[no-]collector.stat_database`
145145
Enable the `stat_database` collector (default: enabled).
146146

147+
* `[no-]collector.stat_progress_vacuum`
148+
Enable the `stat_progress_vacuum` collector (default: enabled).
149+
147150
* `[no-]collector.stat_statements`
148151
Enable the `stat_statements` collector (default: disabled).
149152

collector/pg_stat_progress_vacuum.go

+222
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright 2025 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package collector
15+
16+
import (
17+
"context"
18+
"database/sql"
19+
"log/slog"
20+
21+
"github.com/prometheus/client_golang/prometheus"
22+
)
23+
24+
const progressVacuumSubsystem = "stat_progress_vacuum"
25+
26+
func init() {
27+
registerCollector(progressVacuumSubsystem, defaultEnabled, NewPGStatProgressVacuumCollector)
28+
}
29+
30+
type PGStatProgressVacuumCollector struct {
31+
log *slog.Logger
32+
}
33+
34+
func NewPGStatProgressVacuumCollector(config collectorConfig) (Collector, error) {
35+
return &PGStatProgressVacuumCollector{log: config.logger}, nil
36+
}
37+
38+
var vacuumPhases = []string{
39+
"initializing",
40+
"scanning heap",
41+
"vacuuming indexes",
42+
"vacuuming heap",
43+
"cleaning up indexes",
44+
"truncating heap",
45+
"performing final cleanup",
46+
}
47+
48+
var (
49+
statProgressVacuumPhase = prometheus.NewDesc(
50+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "phase"),
51+
"Current vacuum phase (1 = active, 0 = inactive). Label 'phase' is human-readable.",
52+
[]string{"datname", "relname", "phase"},
53+
nil,
54+
)
55+
56+
statProgressVacuumHeapBlksTotal = prometheus.NewDesc(
57+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "heap_blks"),
58+
"Total number of heap blocks in the table being vacuumed.",
59+
[]string{"datname", "relname"},
60+
nil,
61+
)
62+
63+
statProgressVacuumHeapBlksScanned = prometheus.NewDesc(
64+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "heap_blks_scanned"),
65+
"Number of heap blocks scanned so far.",
66+
[]string{"datname", "relname"},
67+
nil,
68+
)
69+
70+
statProgressVacuumHeapBlksVacuumed = prometheus.NewDesc(
71+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "heap_blks_vacuumed"),
72+
"Number of heap blocks vacuumed so far.",
73+
[]string{"datname", "relname"},
74+
nil,
75+
)
76+
77+
statProgressVacuumIndexVacuumCount = prometheus.NewDesc(
78+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "index_vacuums"),
79+
"Number of completed index vacuum cycles.",
80+
[]string{"datname", "relname"},
81+
nil,
82+
)
83+
84+
statProgressVacuumMaxDeadTuples = prometheus.NewDesc(
85+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "max_dead_tuples"),
86+
"Maximum number of dead tuples that can be stored before cleanup is performed.",
87+
[]string{"datname", "relname"},
88+
nil,
89+
)
90+
91+
statProgressVacuumNumDeadTuples = prometheus.NewDesc(
92+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "num_dead_tuples"),
93+
"Current number of dead tuples found so far.",
94+
[]string{"datname", "relname"},
95+
nil,
96+
)
97+
98+
// This is the view definition of pg_stat_progress_vacuum, albeit without the conversion
99+
// of "phase" to a human-readable string. We will prefer the numeric representation.
100+
statProgressVacuumQuery = `SELECT
101+
d.datname,
102+
s.relid::regclass::text AS relname,
103+
s.param1 AS phase,
104+
s.param2 AS heap_blks_total,
105+
s.param3 AS heap_blks_scanned,
106+
s.param4 AS heap_blks_vacuumed,
107+
s.param5 AS index_vacuum_count,
108+
s.param6 AS max_dead_tuples,
109+
s.param7 AS num_dead_tuples
110+
FROM
111+
pg_stat_get_progress_info('VACUUM'::text)
112+
s(pid, datid, relid, param1, param2, param3, param4, param5, param6, param7, param8, param9, param10, param11, param12, param13, param14, param15, param16, param17, param18, param19, param20)
113+
LEFT JOIN
114+
pg_database d ON s.datid = d.oid`
115+
)
116+
117+
func (c *PGStatProgressVacuumCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error {
118+
db := instance.getDB()
119+
rows, err := db.QueryContext(ctx,
120+
statProgressVacuumQuery)
121+
122+
if err != nil {
123+
return err
124+
}
125+
defer rows.Close()
126+
127+
for rows.Next() {
128+
var (
129+
datname sql.NullString
130+
relname sql.NullString
131+
phase sql.NullInt64
132+
heapBlksTotal sql.NullInt64
133+
heapBlksScanned sql.NullInt64
134+
heapBlksVacuumed sql.NullInt64
135+
indexVacuumCount sql.NullInt64
136+
maxDeadTuples sql.NullInt64
137+
numDeadTuples sql.NullInt64
138+
)
139+
140+
if err := rows.Scan(
141+
&datname,
142+
&relname,
143+
&phase,
144+
&heapBlksTotal,
145+
&heapBlksScanned,
146+
&heapBlksVacuumed,
147+
&indexVacuumCount,
148+
&maxDeadTuples,
149+
&numDeadTuples,
150+
); err != nil {
151+
return err
152+
}
153+
154+
datnameLabel := "unknown"
155+
if datname.Valid {
156+
datnameLabel = datname.String
157+
}
158+
relnameLabel := "unknown"
159+
if relname.Valid {
160+
relnameLabel = relname.String
161+
}
162+
163+
labels := []string{datnameLabel, relnameLabel}
164+
165+
var phaseMetric *float64
166+
if phase.Valid {
167+
v := float64(phase.Int64)
168+
phaseMetric = &v
169+
}
170+
171+
for i, label := range vacuumPhases {
172+
v := 0.0
173+
// Only the current phase should be 1.0.
174+
if phaseMetric != nil && float64(i) == *phaseMetric {
175+
v = 1.0
176+
}
177+
labelsCopy := append(labels, label)
178+
ch <- prometheus.MustNewConstMetric(statProgressVacuumPhase, prometheus.GaugeValue, v, labelsCopy...)
179+
}
180+
181+
heapTotal := 0.0
182+
if heapBlksTotal.Valid {
183+
heapTotal = float64(heapBlksTotal.Int64)
184+
}
185+
ch <- prometheus.MustNewConstMetric(statProgressVacuumHeapBlksTotal, prometheus.GaugeValue, heapTotal, labels...)
186+
187+
heapScanned := 0.0
188+
if heapBlksScanned.Valid {
189+
heapScanned = float64(heapBlksScanned.Int64)
190+
}
191+
ch <- prometheus.MustNewConstMetric(statProgressVacuumHeapBlksScanned, prometheus.GaugeValue, heapScanned, labels...)
192+
193+
heapVacuumed := 0.0
194+
if heapBlksVacuumed.Valid {
195+
heapVacuumed = float64(heapBlksVacuumed.Int64)
196+
}
197+
ch <- prometheus.MustNewConstMetric(statProgressVacuumHeapBlksVacuumed, prometheus.GaugeValue, heapVacuumed, labels...)
198+
199+
indexCount := 0.0
200+
if indexVacuumCount.Valid {
201+
indexCount = float64(indexVacuumCount.Int64)
202+
}
203+
ch <- prometheus.MustNewConstMetric(statProgressVacuumIndexVacuumCount, prometheus.GaugeValue, indexCount, labels...)
204+
205+
maxDead := 0.0
206+
if maxDeadTuples.Valid {
207+
maxDead = float64(maxDeadTuples.Int64)
208+
}
209+
ch <- prometheus.MustNewConstMetric(statProgressVacuumMaxDeadTuples, prometheus.GaugeValue, maxDead, labels...)
210+
211+
numDead := 0.0
212+
if numDeadTuples.Valid {
213+
numDead = float64(numDeadTuples.Int64)
214+
}
215+
ch <- prometheus.MustNewConstMetric(statProgressVacuumNumDeadTuples, prometheus.GaugeValue, numDead, labels...)
216+
}
217+
218+
if err := rows.Err(); err != nil {
219+
return err
220+
}
221+
return nil
222+
}
+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright 2025 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
package collector
14+
15+
import (
16+
"context"
17+
"testing"
18+
19+
"github.com/DATA-DOG/go-sqlmock"
20+
"github.com/prometheus/client_golang/prometheus"
21+
dto "github.com/prometheus/client_model/go"
22+
"github.com/smartystreets/goconvey/convey"
23+
)
24+
25+
func TestPGStatProgressVacuumCollector(t *testing.T) {
26+
db, mock, err := sqlmock.New()
27+
if err != nil {
28+
t.Fatalf("Error opening a stub db connection: %s", err)
29+
}
30+
defer db.Close()
31+
32+
inst := &instance{db: db}
33+
34+
columns := []string{
35+
"datname", "relname", "phase", "heap_blks_total", "heap_blks_scanned",
36+
"heap_blks_vacuumed", "index_vacuum_count", "max_dead_tuples", "num_dead_tuples",
37+
}
38+
39+
rows := sqlmock.NewRows(columns).AddRow(
40+
"postgres", "a_table", 3, 3000, 400, 200, 2, 500, 123)
41+
42+
mock.ExpectQuery(sanitizeQuery(statProgressVacuumQuery)).WillReturnRows(rows)
43+
44+
ch := make(chan prometheus.Metric)
45+
go func() {
46+
defer close(ch)
47+
c := PGStatProgressVacuumCollector{}
48+
49+
if err := c.Update(context.Background(), inst, ch); err != nil {
50+
t.Errorf("Error calling PGStatProgressVacuumCollector.Update; %+v", err)
51+
}
52+
}()
53+
54+
expected := []MetricResult{
55+
{labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "initializing"}, metricType: dto.MetricType_GAUGE, value: 0},
56+
{labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "scanning heap"}, metricType: dto.MetricType_GAUGE, value: 0},
57+
{labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "vacuuming indexes"}, metricType: dto.MetricType_GAUGE, value: 0},
58+
{labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "vacuuming heap"}, metricType: dto.MetricType_GAUGE, value: 1},
59+
{labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "cleaning up indexes"}, metricType: dto.MetricType_GAUGE, value: 0},
60+
{labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "truncating heap"}, metricType: dto.MetricType_GAUGE, value: 0},
61+
{labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "performing final cleanup"}, metricType: dto.MetricType_GAUGE, value: 0},
62+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 3000},
63+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 400},
64+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 200},
65+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 2},
66+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 500},
67+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 123},
68+
}
69+
70+
convey.Convey("Metrics comparison", t, func() {
71+
for _, expect := range expected {
72+
m := readMetric(<-ch)
73+
convey.So(m, convey.ShouldResemble, expect)
74+
}
75+
})
76+
if err := mock.ExpectationsWereMet(); err != nil {
77+
t.Errorf("There were unfulfilled exceptions: %+v", err)
78+
}
79+
}
80+
81+
func TestPGStatProgressVacuumCollectorNullValues(t *testing.T) {
82+
db, mock, err := sqlmock.New()
83+
if err != nil {
84+
t.Fatalf("Error opening a stub db connection: %s", err)
85+
}
86+
defer db.Close()
87+
88+
inst := &instance{db: db}
89+
90+
columns := []string{
91+
"datname", "relname", "phase", "heap_blks_total", "heap_blks_scanned",
92+
"heap_blks_vacuumed", "index_vacuum_count", "max_dead_tuples", "num_dead_tuples",
93+
}
94+
95+
rows := sqlmock.NewRows(columns).AddRow(
96+
"postgres", nil, nil, nil, nil, nil, nil, nil, nil)
97+
98+
mock.ExpectQuery(sanitizeQuery(statProgressVacuumQuery)).WillReturnRows(rows)
99+
100+
ch := make(chan prometheus.Metric)
101+
go func() {
102+
defer close(ch)
103+
c := PGStatProgressVacuumCollector{}
104+
105+
if err := c.Update(context.Background(), inst, ch); err != nil {
106+
t.Errorf("Error calling PGStatProgressVacuumCollector.Update; %+v", err)
107+
}
108+
}()
109+
110+
expected := []MetricResult{
111+
{labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "initializing"}, metricType: dto.MetricType_GAUGE, value: 0},
112+
{labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "scanning heap"}, metricType: dto.MetricType_GAUGE, value: 0},
113+
{labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "vacuuming indexes"}, metricType: dto.MetricType_GAUGE, value: 0},
114+
{labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "vacuuming heap"}, metricType: dto.MetricType_GAUGE, value: 0},
115+
{labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "cleaning up indexes"}, metricType: dto.MetricType_GAUGE, value: 0},
116+
{labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "truncating heap"}, metricType: dto.MetricType_GAUGE, value: 0},
117+
{labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "performing final cleanup"}, metricType: dto.MetricType_GAUGE, value: 0},
118+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
119+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
120+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
121+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
122+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
123+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
124+
}
125+
126+
convey.Convey("Metrics comparison", t, func() {
127+
for _, expect := range expected {
128+
m := readMetric(<-ch)
129+
convey.So(expect, convey.ShouldResemble, m)
130+
}
131+
})
132+
if err := mock.ExpectationsWereMet(); err != nil {
133+
t.Errorf("There were unfulfilled exceptions: %+v", err)
134+
}
135+
}

0 commit comments

Comments
 (0)