Skip to content

Commit 828d96a

Browse files
committed
Adds pg_stat_progress_vacuum collector
1 parent 2ce65c3 commit 828d96a

File tree

3 files changed

+328
-0
lines changed

3 files changed

+328
-0
lines changed

README.md

Lines changed: 3 additions & 0 deletions
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: disabled).
149+
147150
* `[no-]collector.stat_statements`
148151
Enable the `stat_statements` collector (default: disabled).
149152

collector/pg_stat_progress_vacuum.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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, defaultDisabled, 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 (
39+
statProgressVacuumPhase = prometheus.NewDesc(
40+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "phase"),
41+
"Current vacuum phase (numeric). Use label mapping externally for human-readable values.",
42+
[]string{"datname", "relname"},
43+
nil,
44+
)
45+
46+
statProgressVacuumHeapBlksTotal = prometheus.NewDesc(
47+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "heap_blks_total"),
48+
"Total number of heap blocks in the table being vacuumed.",
49+
[]string{"datname", "relname"},
50+
nil,
51+
)
52+
53+
statProgressVacuumHeapBlksScanned = prometheus.NewDesc(
54+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "heap_blks_scanned"),
55+
"Number of heap blocks scanned so far.",
56+
[]string{"datname", "relname"},
57+
nil,
58+
)
59+
60+
statProgressVacuumHeapBlksVacuumed = prometheus.NewDesc(
61+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "heap_blks_vacuumed"),
62+
"Number of heap blocks vacuumed so far.",
63+
[]string{"datname", "relname"},
64+
nil,
65+
)
66+
67+
statProgressVacuumIndexVacuumCount = prometheus.NewDesc(
68+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "index_vacuum_count"),
69+
"Number of completed index vacuum cycles.",
70+
[]string{"datname", "relname"},
71+
nil,
72+
)
73+
74+
statProgressVacuumMaxDeadTuples = prometheus.NewDesc(
75+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "max_dead_tuples"),
76+
"Maximum number of dead tuples that can be stored before cleanup is performed.",
77+
[]string{"datname", "relname"},
78+
nil,
79+
)
80+
81+
statProgressVacuumNumDeadTuples = prometheus.NewDesc(
82+
prometheus.BuildFQName(namespace, progressVacuumSubsystem, "num_dead_tuples"),
83+
"Current number of dead tuples found so far.",
84+
[]string{"datname", "relname"},
85+
nil,
86+
)
87+
88+
// This is the view definition of pg_stat_progress_vacuum, albeit without the conversion
89+
// of "phase" to a human-readable string. We will prefer the numeric representation.
90+
statProgressVacuumQuery = `SELECT
91+
d.datname,
92+
s.relid::regclass::text AS relname,
93+
s.param1 AS phase,
94+
s.param2 AS heap_blks_total,
95+
s.param3 AS heap_blks_scanned,
96+
s.param4 AS heap_blks_vacuumed,
97+
s.param5 AS index_vacuum_count,
98+
s.param6 AS max_dead_tuples,
99+
s.param7 AS num_dead_tuples
100+
FROM
101+
pg_stat_get_progress_info('VACUUM'::text)
102+
s(pid, datid, relid, param1, param2, param3, param4, param5, param6, param7, param8, param9, param10, param11, param12, param13, param14, param15, param16, param17, param18, param19, param20)
103+
LEFT JOIN
104+
pg_database d ON s.datid = d.oid`
105+
)
106+
107+
func (c *PGStatProgressVacuumCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error {
108+
db := instance.getDB()
109+
rows, err := db.QueryContext(ctx,
110+
statProgressVacuumQuery)
111+
112+
if err != nil {
113+
return err
114+
}
115+
defer rows.Close()
116+
117+
for rows.Next() {
118+
var (
119+
datname sql.NullString
120+
relname sql.NullString
121+
phase sql.NullInt64
122+
heapBlksTotal sql.NullInt64
123+
heapBlksScanned sql.NullInt64
124+
heapBlksVacuumed sql.NullInt64
125+
indexVacuumCount sql.NullInt64
126+
maxDeadTuples sql.NullInt64
127+
numDeadTuples sql.NullInt64
128+
)
129+
130+
if err := rows.Scan(
131+
&datname,
132+
&relname,
133+
&phase,
134+
&heapBlksTotal,
135+
&heapBlksScanned,
136+
&heapBlksVacuumed,
137+
&indexVacuumCount,
138+
&maxDeadTuples,
139+
&numDeadTuples,
140+
); err != nil {
141+
return err
142+
}
143+
144+
datnameLabel := "unknown"
145+
if datname.Valid {
146+
datnameLabel = datname.String
147+
}
148+
relnameLabel := "unknown"
149+
if relname.Valid {
150+
relnameLabel = relname.String
151+
}
152+
153+
labels := []string{datnameLabel, relnameLabel}
154+
155+
phaseMetric := 0.0
156+
if phase.Valid {
157+
phaseMetric = float64(phase.Int64)
158+
}
159+
ch <- prometheus.MustNewConstMetric(statProgressVacuumPhase, prometheus.GaugeValue, phaseMetric, labels...)
160+
161+
heapTotal := 0.0
162+
if heapBlksTotal.Valid {
163+
heapTotal = float64(heapBlksTotal.Int64)
164+
}
165+
ch <- prometheus.MustNewConstMetric(statProgressVacuumHeapBlksTotal, prometheus.GaugeValue, heapTotal, labels...)
166+
167+
heapScanned := 0.0
168+
if heapBlksScanned.Valid {
169+
heapScanned = float64(heapBlksScanned.Int64)
170+
}
171+
ch <- prometheus.MustNewConstMetric(statProgressVacuumHeapBlksScanned, prometheus.GaugeValue, heapScanned, labels...)
172+
173+
heapVacuumed := 0.0
174+
if heapBlksVacuumed.Valid {
175+
heapVacuumed = float64(heapBlksVacuumed.Int64)
176+
}
177+
ch <- prometheus.MustNewConstMetric(statProgressVacuumHeapBlksVacuumed, prometheus.GaugeValue, heapVacuumed, labels...)
178+
179+
indexCount := 0.0
180+
if indexVacuumCount.Valid {
181+
indexCount = float64(indexVacuumCount.Int64)
182+
}
183+
ch <- prometheus.MustNewConstMetric(statProgressVacuumIndexVacuumCount, prometheus.GaugeValue, indexCount, labels...)
184+
185+
maxDead := 0.0
186+
if maxDeadTuples.Valid {
187+
maxDead = float64(maxDeadTuples.Int64)
188+
}
189+
ch <- prometheus.MustNewConstMetric(statProgressVacuumMaxDeadTuples, prometheus.GaugeValue, maxDead, labels...)
190+
191+
numDead := 0.0
192+
if numDeadTuples.Valid {
193+
numDead = float64(numDeadTuples.Int64)
194+
}
195+
ch <- prometheus.MustNewConstMetric(statProgressVacuumNumDeadTuples, prometheus.GaugeValue, numDead, labels...)
196+
}
197+
198+
if err := rows.Err(); err != nil {
199+
return err
200+
}
201+
return nil
202+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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"}, metricType: dto.MetricType_GAUGE, value: 3},
56+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 3000},
57+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 400},
58+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 200},
59+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 2},
60+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 500},
61+
{labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 123},
62+
}
63+
64+
convey.Convey("Metrics comparison", t, func() {
65+
for _, expect := range expected {
66+
m := readMetric(<-ch)
67+
convey.So(expect, convey.ShouldResemble, m)
68+
}
69+
})
70+
if err := mock.ExpectationsWereMet(); err != nil {
71+
t.Errorf("There were unfulfilled exceptions: %+v", err)
72+
}
73+
}
74+
75+
func TestPGStatProgressVacuumCollectorNullValues(t *testing.T) {
76+
db, mock, err := sqlmock.New()
77+
if err != nil {
78+
t.Fatalf("Error opening a stub db connection: %s", err)
79+
}
80+
defer db.Close()
81+
82+
inst := &instance{db: db}
83+
84+
columns := []string{
85+
"datname", "relname", "phase", "heap_blks_total", "heap_blks_scanned",
86+
"heap_blks_vacuumed", "index_vacuum_count", "max_dead_tuples", "num_dead_tuples",
87+
}
88+
89+
rows := sqlmock.NewRows(columns).AddRow(
90+
"postgres", nil, nil, nil, nil, nil, nil, nil, nil)
91+
92+
mock.ExpectQuery(sanitizeQuery(statProgressVacuumQuery)).WillReturnRows(rows)
93+
94+
ch := make(chan prometheus.Metric)
95+
go func() {
96+
defer close(ch)
97+
c := PGStatProgressVacuumCollector{}
98+
99+
if err := c.Update(context.Background(), inst, ch); err != nil {
100+
t.Errorf("Error calling PGStatProgressVacuumCollector.Update; %+v", err)
101+
}
102+
}()
103+
104+
expected := []MetricResult{
105+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
106+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
107+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
108+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
109+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
110+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
111+
{labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0},
112+
}
113+
114+
convey.Convey("Metrics comparison", t, func() {
115+
for _, expect := range expected {
116+
m := readMetric(<-ch)
117+
convey.So(expect, convey.ShouldResemble, m)
118+
}
119+
})
120+
if err := mock.ExpectationsWereMet(); err != nil {
121+
t.Errorf("There were unfulfilled exceptions: %+v", err)
122+
}
123+
}

0 commit comments

Comments
 (0)