1
0
mirror of https://github.com/prometheus-community/postgres_exporter.git synced 2026-10-23 12:08:57 +03:00

stat_replication: fix "slot_name does not exist" by joining pg_replication_slots (#1313)

The stat_replication collector currently fails every scrape with:

  pq: column "slot_name" does not exist

slot_name is not a column of pg_stat_replication on any supported PostgreSQL version (verified on PG 17.10.0 Docker, PG 18 Docker, and the official PG 17 and PG 18 docs). It lives on pg_replication_slots and has to be pulled in via a JOIN on active_pid = pid.

Before #1306 the same collector ran via the legacy yaml-driven exporter, which used SELECT * and silently produced an empty slot_name label when the column was missing. #1306 promoted slot_name into an explicit SQL column list, turning that silent no-op into a hard parse-time error. CI did not catch it because the unit tests use sqlmock and do not validate against the real Postgres schema.

stat_replication is defaultEnabled, so every user on main after #1306 sees the collector dead on every scrape, regardless of whether they run Aurora or vanilla Postgres.

Fix: LEFT JOIN pg_replication_slots s ON s.active_pid = pg_stat_replication.pid and read s.slot_name. The label is populated when a named slot is in use, empty otherwise (matching the previous yaml behavior).

Since pg_replication_slots.active_pid only exists on PostgreSQL 9.5+, versions 9.2-9.4 fall back to a query without the join and leave the slot_name label empty, matching the legacy yaml behavior. Verified against live postgres:9.4, 9.6 and 17 (with a streaming standby on a named slot) in Docker.

Fixes #1310.

Signed-off-by: Pavel K <megativ3@gmail.com>
This commit is contained in:
Pavel K
2026-07-07 11:10:06 +02:00
committed by GitHub
parent a79aec2d18
commit 73106e7028
3 changed files with 102 additions and 4 deletions

View File

@@ -1,5 +1,7 @@
## main / (unreleased)
* [BUGFIX] stat_replication: fix "slot_name does not exist" by joining `pg_replication_slots` (closes #1310)
## 0.20.0 / 2026-06-29
BREAKING CHANGES:
@@ -44,7 +46,6 @@ its `--no-collector.*` flag.
* [BUGFIX] Fix duplicate `pg_stat_progress_vacuum` metrics for cross-database vacuums by @steinbrueckri in https://github.com/prometheus-community/postgres_exporter/pull/1262
* [BUGFIX] Fix `pg_settings` collection when `short_desc` is NULL by @jun-gradial in https://github.com/prometheus-community/postgres_exporter/pull/1327
## 0.19.1 / 2026-02-25
* [CHANGE] Filter and warn about duplicates in pg_stat_statements (#998) by @Nudin in https://github.com/prometheus-community/postgres_exporter/pull/1259

View File

@@ -55,15 +55,20 @@ var (
prometheus.Labels{},
)
// slot_name is not a column of pg_stat_replication; it lives on
// pg_replication_slots and is linked via the standby's WAL sender
// PID. LEFT JOIN so clients that do not use a named slot still get
// a row with an empty slot_name label.
statReplicationQuery = `
SELECT
application_name,
client_addr::text,
state,
slot_name,
s.slot_name,
(case pg_is_in_recovery() when 't' then pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_lsn('0/0'))::float else pg_wal_lsn_diff(pg_current_wal_lsn(), pg_lsn('0/0'))::float end) AS pg_current_wal_lsn_bytes,
(case pg_is_in_recovery() when 't' then pg_wal_lsn_diff(pg_last_wal_receive_lsn(), replay_lsn)::float else pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::float end) AS pg_wal_lsn_diff
FROM pg_stat_replication
LEFT JOIN pg_replication_slots s ON s.active_pid = pg_stat_replication.pid
`
statReplicationQueryBefore10 = `
@@ -71,7 +76,20 @@ var (
application_name,
client_addr::text,
state,
slot_name,
s.slot_name,
(case pg_is_in_recovery() when 't' then pg_xlog_location_diff(pg_last_xlog_receive_location(), replay_location)::float else pg_xlog_location_diff(pg_current_xlog_location(), replay_location)::float end) AS pg_xlog_location_diff
FROM pg_stat_replication
LEFT JOIN pg_replication_slots s ON s.active_pid = pg_stat_replication.pid
`
// pg_replication_slots.active_pid only exists on PostgreSQL 9.5+, so
// on older versions slot_name cannot be resolved; the label is left
// empty (matching the legacy yaml exporter behavior).
statReplicationQueryBefore95 = `
SELECT
application_name,
client_addr::text,
state,
(case pg_is_in_recovery() when 't' then pg_xlog_location_diff(pg_last_xlog_receive_location(), replay_location)::float else pg_xlog_location_diff(pg_current_xlog_location(), replay_location)::float end) AS pg_xlog_location_diff
FROM pg_stat_replication
`
@@ -81,8 +99,10 @@ func (PGStatReplicationCollector) Update(ctx context.Context, instance *instance
switch {
case instance.version.GTE(semver.MustParse("10.0.0")):
return updateStatReplication(ctx, instance, ch)
case instance.version.GTE(semver.MustParse("9.2.0")):
case instance.version.GTE(semver.MustParse("9.5.0")):
return updateStatReplicationBefore10(ctx, instance, ch)
case instance.version.GTE(semver.MustParse("9.2.0")):
return updateStatReplicationBefore95(ctx, instance, ch)
default:
return nil
}
@@ -153,6 +173,34 @@ func updateStatReplicationBefore10(ctx context.Context, instance *instance, ch c
return rows.Err()
}
func updateStatReplicationBefore95(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error {
db := instance.getDB()
rows, err := db.QueryContext(ctx, statReplicationQueryBefore95)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var applicationName, clientAddr, state sql.NullString
var xlogLocationDiff sql.NullFloat64
if err := rows.Scan(&applicationName, &clientAddr, &state, &xlogLocationDiff); err != nil {
return err
}
if xlogLocationDiff.Valid {
ch <- prometheus.MustNewConstMetric(
statReplicationXlogLocationDiffDesc,
prometheus.GaugeValue,
xlogLocationDiff.Float64,
statReplicationLabelValues(applicationName, clientAddr, state, sql.NullString{})...,
)
}
}
return rows.Err()
}
func statReplicationLabelValues(applicationName, clientAddr, state, slotName sql.NullString) []string {
return []string{
nullStringValue(applicationName),

View File

@@ -123,6 +123,55 @@ func TestPGStatReplicationCollectorBefore10(t *testing.T) {
}
}
func TestPGStatReplicationCollectorBefore95(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("Error opening a stub db connection: %s", err)
}
defer db.Close()
inst := &instance{db: db, version: semver.MustParse("9.4.0")}
rows := sqlmock.NewRows([]string{
"application_name",
"client_addr",
"state",
"pg_xlog_location_diff",
}).AddRow("standby", "10.0.0.1", "streaming", 32.0)
mock.ExpectQuery(sanitizeQuery(statReplicationQueryBefore95)).WillReturnRows(rows)
ch := make(chan prometheus.Metric)
go func() {
defer close(ch)
c := PGStatReplicationCollector{}
if err := c.Update(context.Background(), inst, ch); err != nil {
t.Errorf("Error calling PGStatReplicationCollector.Update: %s", err)
}
}()
expected := []MetricResult{
{
labels: labelMap{
"application_name": "standby",
"client_addr": "10.0.0.1",
"state": "streaming",
"slot_name": "",
},
value: 32,
metricType: dto.MetricType_GAUGE,
},
}
convey.Convey("Metrics comparison", t, func() {
for _, expect := range expected {
m := readMetric(<-ch)
convey.So(expect, convey.ShouldResemble, m)
}
})
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled exceptions: %s", err)
}
}
func TestPGStatReplicationCollectorNullValues(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {