Reported symptoms
A replication lag alert fires most Sundays between 03:00 and 07:00 and clears on its own by Monday. The reported lag climbs steadily to several hundred seconds.
Every investigation has found the standby healthy, fully caught up, and streaming normally.
The alert was muted for weekends six months ago, then muted entirely three months ago.
On a Tuesday the standby fell four hours behind during a bulk load and nobody was paged. It was discovered when a report run against the standby returned stale data.
A second standby, added last year for a delayed-recovery use case, has always shown lag and is excluded from alerting entirely.
Evidence provided
The alert is built on now() - pg_last_xact_replay_timestamp(), collected
from the standby.
$ watch the standby's apply lag and its caught-up flag together apply_lag_time | fully_caught_up
-----------------+-----------------
00:00:00.951159 | t
00:00:09.003664 | t
00:00:17.054691 | t$ psql -c "SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS bytes_behind, replay_lag FROM pg_stat_replication;" bytes_behind | replay_lag
--------------+------------
0 bytes |
(1 row)The Sunday window is the quietest period of the week.
During the Tuesday incident, byte lag reached 3.1 GB while the time-based metric looked normal.
The second standby runs recovery_min_apply_delay = 30s:
$ insert on the primary, then query the standby every nine seconds after 9s: ERROR: relation "delayed_marker" does not exist
after 18s: ERROR: relation "delayed_marker" does not exist
after 27s: ERROR: relation "delayed_marker" does not exist
after 36s: 1
bytes_behind | replay_lag
--------------+------------
0 bytes |
(1 row)Work the evidence before reading on
- The apply lag climbed from 0.95 s to 17 s while byte lag stayed at 0. What is the metric actually measuring?
- Why does it climb hardest on Sunday mornings?
- On Tuesday the standby was 3.1 GB behind and the metric was normal. Why?
- The delayed standby shows lag and byte lag zero. Which is right?
Root cause
The metric is anti-correlated with the thing it detects
Sunday small hours are the quietest period of the week, so that is when it climbed highest and the pages arrived.
A year of false alarms did what a year of false alarms does
The delayed standby is behind on purpose
recovery_min_apply_delay = 30s is a defence against a bad DELETE: it
gives an operator a window to stop replay before the damage arrives. The
measurement above shows the mechanism exactly — the row was invisible at
9, 18 and 27 seconds and visible at 36, while byte lag stayed 0. The
WAL had arrived and was deliberately not yet applied.
Excluding that standby from alerting means nobody is watching whether it is receiving WAL at all, which is the thing that can actually go wrong with it.
Resolution
Replace the metric. Byte lag is meaningful whether or not the primary is committing:
-- on the primary
SELECT application_name, state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind,
replay_lag,
CASE
WHEN state <> 'streaming' THEN 'not streaming'
WHEN pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) > 256*1024*1024 THEN 'behind'
ELSE 'ok'
END AS verdict
FROM pg_stat_replication
ORDER BY application_name;
replay_lag from pg_stat_replication is also usable — it is measured
from WAL arrival rather than wall clock, and it is NULL rather than
misleading when there is nothing to measure.
Add the standby-side check, which survives losing the primary:
-- on the standby
SELECT pg_is_in_recovery() AS in_recovery,
pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() AS caught_up,
pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) AS unreplayed_bytes,
(SELECT count(*) FROM pg_stat_wal_receiver) AS receiver_running;
unreplayed_bytes separates two very different failures — WAL arriving
but not applied, versus WAL not arriving — which a single lag number
cannot express.
Handle the delayed standby explicitly rather than by exclusion: alert on
its unreplayed_bytes and on its receiver being connected, and set its
apply-lag threshold to its configured delay plus a margin.
Unmute only once the alerts have been tested, and tell the team the pages they get from now on are real. That statement has to be earned.
Verification
The new alert stays quiet through a full quiet period. Watch a Sunday deliberately and confirm no page arrives while byte lag stays zero.
The new alert fires when the standby is genuinely behind. Test it:
-- on the standby
SELECT pg_wal_replay_pause();
-- generate write traffic on the primary, wait for the alert, then:
SELECT pg_wal_replay_resume();
An alert that has never fired has never been shown to work, and that is what the last year consisted of.
The absence alert fires when a standby is stopped — stop it, confirm the page, start it again.
The delayed standby produces no routine alerts but does produce one when its receiver disconnects. Confirm both halves.
On a healthy pair, byte lag and the standby-side check agree. Measured:
application_name | state | bytes_behind | replay_lag | verdict
------------------+-----------+--------------+-----------------+---------
lab21-standby | streaming | 0 | 00:00:00.002621 | ok
oldprimary | streaming | 0 | 00:00:00.00193 | ok
Prevention
Alert on byte lag, not on time since the last replayed commit.
Alert on the standby being absent before alerting on it being slow.
Never render NULL as zero. A NULL sample is missing data; rendered as zero it becomes an assertion of health nobody made.
Collect from both sides. The primary-side view disappears when the primary does.
Treat a muted alert as an open incident, with an owner and a date.
Test every alert by causing the condition. pg_wal_replay_pause()
makes a standby genuinely lag, on demand, harmlessly. There is no excuse
for an untested replication alert.
Model a delayed standby as delayed, not as excluded.
Review alerts by their false-positive rate. One that fires weekly and is right once a year is training people to ignore it — and that training is what turned a four-hour lag into a discovery made by a stale report.