lock_waits metric: duplicate Prometheus series under concurrent same-table contention (session pid emitted as value, not tag)
## Problem `lock_waits` metric emits two distinct lock-wait events as **duplicate Prometheus series** when both events share the same `(blocked_user, blocked_appname, blocked_mode, blocked_locktype, blocked_table, blocked_query_id, blocker_user, blocker_appname, blocker_mode, blocker_locktype, blocker_table, blocker_query_id)` tuple but involve different sessions (different `blocked_pid` / `blocker_pid`). The Prometheus client library in the pgwatch-prometheus sink rejects this: ``` [ERROR] [sink:prometheus] error gathering metrics: 4 error(s) occurred: * collected metric "pgwatch_lock_waits_blocked_ms" {... label:{name:"blocked_query_id" value:"2580257173810903337"} label:{name:"blocker_query_id" value:"4593154642546346557"} label:{name:"blocked_table" value:"loadtest.pgbench_accounts"} ...} was collected before with the same name and label values * collected metric "pgwatch_lock_waits_blocked_pid" ... was collected before with the same name and label values * collected metric "pgwatch_lock_waits_blocker_pid" ... was collected before with the same name and label values * collected metric "pgwatch_lock_waits_blocker_tx_ms" ... was collected before with the same name and label values ``` Effect: the entire 4 lock_waits series for that scrape are silently dropped from the response. (Independent metrics in the same scrape still land — this is per-metric not per-scrape.) ## Root cause In the rc.4 SQL (`config/pgwatch-prometheus/metrics.yml`, `lock_waits` block ~line 878), session pids are exported as **integer metric values** rather than tags: ```sql select blocked.pid as blocked_pid, -- integer → becomes a metric VALUE ... blocker.pid as blocker_pid, -- integer → becomes a metric VALUE ... ``` But the pgwatch-prometheus sink ([`internal/sinks/prometheus.go::MetricStoreMessageToPromMetrics`](https://github.com/cybertec-postgresql/pgwatch/blob/v3.7.0/internal/sinks/prometheus.go#L191)) only labels are: - columns whose name starts with `tag_`, OR - any `string`-typed column Integer columns become metric values, not labels. So `blocked_pid` and `blocker_pid` don't disambiguate the series. Meanwhile the SQL has 12+ other columns that already do become labels (some explicitly via `tag_*`, others implicitly because their type is `text` — e.g. `blocked_mode`, `blocked_locktype` are strings from `pg_locks.mode` / `pg_locks.locktype`). So when two concurrent transactions land in a lock-wait pair against the same table, with the same query templates and the same mode/locktype pairing, they produce identical label sets → duplicate-series collision. ## Reproduction 1. Stock `postgresai mon local-install --tag 0.15.0-rc.4 --db-url <supabase-pooler-url>` install monitoring a Supabase target with the postgres-ai-mon role. 2. Connect as a writable role (e.g. project `postgres` role) and run a TPC-B-style write workload that creates concurrent same-table contention. Reproduced cleanly with: ```bash PGOPTIONS="-c search_path=loadtest,public" pgbench \ -h <pooler> -U postgres.<project-ref> -d postgres \ -i -s 1 --no-vacuum --partitions=0 PGOPTIONS="-c search_path=loadtest,public" pgbench \ -h <pooler> -U postgres.<project-ref> -d postgres \ -n -c 5 -j 2 -T 600 ``` 3. Within ~30 s — 2 min of starting the write workload, `docker logs pgwatch-prometheus | grep "collected metric"` shows the duplicate-series errors. (Verified empirically on `78.47.229.109` during the validation of !267.) ## Suggested fix Promote `blocked_pid` and `blocker_pid` to tags (labels) by renaming with the `tag_` prefix and casting to text in the SQL — `pid` is identity, not a time-series value: ```diff select - blocked.pid as blocked_pid, + blocked.pid::text as tag_blocked_pid, ... - blocker.pid as blocker_pid, + blocker.pid::text as tag_blocker_pid, ``` `blocked_pid` / `blocker_pid` as numeric gauges aren't dashboard-relevant anyway — nobody graphs `rate(blocked_pid)`; the useful question is "which session pids are blocking which." Moving them to labels makes them disambiguators *and* keeps them queryable from Grafana/PromQL. Same fix probably needs review for other metrics that expose pid as a value — at minimum `xmin_horizon_blockers` (haven't checked, just guessing from the pattern). ## Distinction from other open issues - **Not #195** (cap shape on per-relation metrics — !267 covers that). - **Not #198** (Prometheus sink drain wipe — !266 covers that). - Independent bug, only surfaces under concurrent-write load against the same target table. Likely latent on monitored production DBs that have any concurrent writers and lock contention. ## Refs - Discovered during empirical validation of !267 against the Supabase pooler under `pgbench` TPC-B load on `78.47.229.109`. - pgwatch-prometheus duplicate-series rejection: a Prometheus client library hard error, not a config tuning issue. ## Acceptance 1. Two concurrent lock-wait events between different session pids no longer produce duplicate Prometheus series. 2. The `pgwatch_lock_waits_blocked_pid` / `pgwatch_lock_waits_blocker_pid` PromQL/Grafana behaviour preserved (or made better) — pids queryable via `pgwatch_lock_waits_blocked_ms{tag_blocked_pid="…"}` instead of needing the value-side `_blocked_pid` series. 3. Repro from above runs for 5+ min with the patched metric and no `collected metric … was collected before` errors in `docker logs pgwatch-prometheus`.
issue