Replication Monitoring & Lag Alerting Jump to heading
A diff-sync pipeline is unusually good at failing quietly. The loop described in Building a Minutely Update Pipeline runs every minute, writes a log line every minute, and exits zero every minute, and none of that says whether the data it manages is current. A pipeline whose lock is permanently held, whose upstream stream has stalled, or whose apply step silently no-ops will produce exactly the same log stream as a healthy one. The gap between “the process is running” and “the data is fresh” is where replication incidents live, and closing it is a monitoring problem rather than a coding one.
This topic sets out what to measure, where each measurement is blind, how to choose thresholds that survive an ordinary day, and how to wire the result into an alerting system without producing an alert people mute. It assumes the sequence-number model from Replication Sequence Numbers & State Tracking — every signal below is derived from that integer and its timestamp.
Prerequisite concepts Jump to heading
Two things need to be in place before any of this is measurable. The checkpoint must be readable from outside the loop, because a metrics exporter that has to interrupt the pipeline to ask its state will eventually interrupt it at the wrong moment; a state file or a database row satisfies this and an in-memory variable does not. And the upstream stream has to be identified, because sequence lag is meaningless without knowing which state.txt the local sequence should be compared against — which is exactly the osmosis_replication_base_url header discussed in PBF File Structure Deep Dive.
The four signals Jump to heading
Sequence lag is the upstream head sequence minus the locally applied sequence. It is an integer, it is exact, and it is the best measure of whether the loop is keeping up. Its blind spot is that both terms come from the same conceptual place: if the upstream stream stops advancing, the local sequence catches up to it and the lag falls to zero while the data grows steadily staler.
Timestamp lag is the current wall-clock time minus the timestamp of the most recently applied diff. It catches the upstream stall that sequence lag misses, and it is the number that actually answers “how old is my data”. Its cost is noise: replication files are not published on an exact cadence, so this signal has a naturally wide distribution.
Loop heartbeat is the time since the last successful iteration. It catches a crashed, wedged or lock-blocked process, and it says nothing at all about correctness — a loop spinning fast and applying nothing has a perfect heartbeat.
Data-level checks are row counts, object counts by type, and spot checks of known objects against the live API. They are the only end-to-end signal, they are the most expensive, and they are the only thing that catches a loop that is healthy by every other measure and wrong.
The matrix is the reason to export all three cheap signals rather than picking one. Read individually each is ambiguous; read together they identify the fault.
Computing the signals Jump to heading
import logging
import time
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
STATE_URL = "https://planet.osm.org/replication/minute/state.txt"
@dataclass(frozen=True)
class ReplicationState:
sequence: int
timestamp: datetime
def parse_state(text: str) -> ReplicationState:
"""Parse an Osmosis state.txt. Colons in the timestamp are backslash-escaped."""
fields: dict[str, str] = {}
for line in text.splitlines():
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
fields[key.strip()] = value.strip().replace("\\:", ":")
return ReplicationState(
sequence=int(fields["sequenceNumber"]),
timestamp=datetime.fromisoformat(fields["timestamp"].replace("Z", "+00:00")),
)
def fetch_head(url: str = STATE_URL, timeout: float = 10.0) -> ReplicationState:
with urllib.request.urlopen(url, timeout=timeout) as response:
return parse_state(response.read().decode("utf-8"))
def signals(local: ReplicationState, last_success_monotonic: float) -> dict[str, float]:
"""The three cheap signals, computed together so they are always consistent."""
head = fetch_head()
now = datetime.now(timezone.utc)
out = {
"sequence_lag": head.sequence - local.sequence,
"timestamp_lag_seconds": (now - local.timestamp).total_seconds(),
"heartbeat_age_seconds": time.monotonic() - last_success_monotonic,
"upstream_age_seconds": (now - head.timestamp).total_seconds(),
}
logger.info("replication signals: %s", out)
return out
The fourth value, upstream_age_seconds, is what makes the upstream-stall case unambiguous. When it rises alongside timestamp lag while sequence lag stays at zero, the problem is not yours, and knowing that in the first minute of an incident rather than the fortieth is worth the extra HTTP field.
Note the monotonic clock for the heartbeat. Wall-clock time goes backwards across an NTP correction, and a heartbeat computed from it will occasionally report a negative age and, depending on the comparison, either alert or never alert again.
Choosing thresholds Jump to heading
Thresholds derived from the nominal cadence are wrong in both directions. A minutely stream does not publish every sixty seconds; publication jitter routinely pushes the observed lag past three minutes with nothing wrong. Setting a warning at two minutes produces a pager that fires on normal operation, and the reliable consequence of that is a muted alert channel.
Collect thirty days of the signal, take the observed distribution, and place the warning threshold comfortably above the 99th percentile and the paging threshold at the point where the staleness genuinely matters to a consumer. For most pipelines those are two very different numbers, and the gap between them is the useful part: a warning that a human looks at during working hours, and a page that means a downstream system is serving stale data.
One threshold should not be derived from the distribution at all. Sequence lag has a hard meaning — anything above a handful means the loop is not keeping up — and a fixed threshold of five is appropriate regardless of what the last month looked like.
Validation and error-handling matrix Jump to heading
| Condition | Signal pattern | Root cause | Action |
|---|---|---|---|
| Data stale, all signals green | lag 0, heartbeat fresh | Apply step is a no-op | Compare row counts; check the lock |
| Sequence lag 0, timestamp lag rising | upstream age rising too | Upstream stream stalled | Wait; alert at a longer threshold |
| Both lags rising in step | heartbeat fresh | Loop slower than the stream | Batch diffs, or move to hourly |
| Heartbeat stale, lags frozen | last iteration long ago | Process crashed or lock held | Restart; inspect the lock holder |
| Lag negative | local sequence ahead of head | Checkpoint written before apply | Rebuild — the checkpoint is lying |
| Alerts fire nightly at the same time | lag spikes on a schedule | A competing job saturates I/O | Stagger the schedule |
The negative-lag row deserves attention because it is a symptom of the checkpoint-ordering bug described in Recovering from a Replication Sequence Gap. A local sequence ahead of the published head cannot happen through normal operation; it means the checkpoint records work that was never done, and no amount of replaying will fix it.
Performance and scale considerations Jump to heading
The metrics themselves are cheap: one HTTP fetch of a file under two hundred bytes, one read of a state file, and some arithmetic. The only real cost is the upstream fetch, and the only real risk is doing it too often. Scraping state.txt every fifteen seconds from a fleet of hosts is a meaningful load on the replication server and gains nothing over a per-minute fetch on a stream that publishes per minute. Fetch once per loop iteration, cache the result for the exporter, and never let the metrics endpoint trigger a fetch of its own — a scrape storm should not become an outbound request storm.
Data-level checks are the expensive ones and should be scheduled rather than continuous. An hourly count of rows by feature class against the previous hour is enough to catch a no-op loop within an hour, and costs one aggregate query.
Failure modes and gotchas Jump to heading
The most common instrumentation bug is exporting a metric from inside the loop only on the success path. When the loop starts failing, the metric stops being updated, and a gauge that stops updating looks — to most alerting systems — exactly like a gauge holding a healthy value. Export from a separate path that runs regardless of outcome, or use a metric type where staleness is visible, and always alert on the absence of data as well as on its value.
A second is timezone handling in the timestamp comparison. state.txt timestamps are UTC with a trailing Z; comparing them against a naive local datetime produces a lag that is wrong by the UTC offset and, in a location with summer time, changes by an hour twice a year. Parse to an aware UTC datetime and compare against datetime.now(timezone.utc).
Third, alerting on the derivative rather than the value catches problems earlier but fires on catch-up: after a legitimate outage the lag falls rapidly, and a rate-of-change alert will interpret recovery as an anomaly. Alert on the value; use the derivative for dashboards.
Integration points Jump to heading
The signals belong in whatever the rest of the platform uses. For Prometheus, expose them as gauges from the loop process; for a push-based system, emit them at the end of each iteration. Either way the metric names should distinguish the stream, because a host syncing two regions has two independent lags and one alert that merges them is unactionable.
from prometheus_client import Gauge
SEQ_LAG = Gauge("osm_replication_sequence_lag", "Head sequence minus applied sequence", ["stream"])
TS_LAG = Gauge("osm_replication_timestamp_lag_seconds", "Age of the newest applied diff", ["stream"])
HEARTBEAT = Gauge("osm_replication_last_success_timestamp", "Unix time of the last good iteration", ["stream"])
def publish(stream: str, values: dict[str, float], last_success_epoch: float) -> None:
SEQ_LAG.labels(stream).set(values["sequence_lag"])
TS_LAG.labels(stream).set(values["timestamp_lag_seconds"])
HEARTBEAT.labels(stream).set(last_success_epoch)
Exporting the heartbeat as an absolute timestamp rather than an age is deliberate: a timestamp that stops advancing is unambiguous to an alerting rule, whereas an age gauge that stops updating holds whatever value it had when the process died.
Dashboards versus alerts Jump to heading
The signals above serve two audiences with opposite needs, and building one artefact for both produces something neither can use.
An alert exists to interrupt someone, so it must be unambiguous, actionable and rare. That argues for very few alerting rules — realistically two: timestamp lag beyond a threshold that matters to a consumer, and a heartbeat that has stopped. Everything else is context that helps once someone is already looking.
A dashboard exists to be read while nothing is wrong, so it can afford to be dense and can show things that are interesting rather than actionable. The rate of change of lag, the distribution of per-diff apply durations, the size of each diff, and the ratio of creates to modifies to deletes all belong here. None of them should page anyone, and all of them shorten an investigation, because the first question in any replication incident is what changed and the dashboard is where that is visible.
The pairing worth building deliberately is an alert that links to the dashboard filtered to the affected stream. It sounds like a small thing and it is the difference between an on-call engineer starting from a symptom and starting from a picture.
In this section Jump to heading
- Measuring OSM Replication Lag in Seconds — the lag calculation itself, including the timezone and escaped-colon traps.
- Exporting Diff-Sync Metrics to Prometheus — a metrics endpoint that does not fetch upstream on scrape.
- Alerting on a Stalled OSM Update Pipeline — alert rules that distinguish your fault from upstream’s.
Frequently Asked Questions Jump to heading
Which single metric should I alert on if I can only have one?
Timestamp lag. It is the only one of the three cheap signals that answers the question a consumer actually cares about — how old is this data — and it degrades under both a stalled loop and a stalled upstream. It is noisier than sequence lag, so set the threshold from an observed distribution rather than from the nominal cadence.
Why is my sequence lag zero while the data is clearly stale?
Two possibilities, and the upstream timestamp distinguishes them. If the upstream head timestamp is also old, the replication stream itself has stopped publishing and there is nothing for you to do but wait. If the upstream head is current and your applied sequence somehow equals it, your checkpoint is being advanced without the diffs being applied — check whether the apply step is silently exiting on a held lock.
How often should the pipeline fetch state.txt?
Once per loop iteration, and never from the metrics endpoint. A minutely loop fetching once a minute is proportionate; a fleet of exporters each fetching on every scrape is not, and it turns a monitoring system into a source of load on a shared community server.
Should replication lag page someone at night?
Only if a downstream consumer is genuinely harmed by data that is thirty minutes old. For most analytics workloads it is not, and the right configuration is a warning that is picked up in the morning. Reserve paging for pipelines feeding something with a real-time contract, and set the threshold at the point that contract breaks.
Do I need data-level checks if the three cheap signals are green?
Yes, at a low frequency. The one failure mode that all three cheap signals miss — a loop that runs, commits and applies nothing — is also the one that goes unnoticed longest. An hourly row-count comparison is enough to bound the damage to an hour, and costs one aggregate query.
Related Jump to heading
- OSM Replication & Diff Sync — the section this monitoring layer watches.
- Building a Minutely Update Pipeline — the loop that produces these signals.
- Replication Sequence Numbers & State Tracking — the checkpoint every signal is derived from.
- Recovering from a Replication Sequence Gap — what to do when the lag says something is wrong.
- Scheduling OSM Diff Sync with systemd Timers — the unit model that supplies the heartbeat.
- Applying .osc Change Files with osmium — the apply step whose silence these signals detect.
Up one level: OSM Replication & Diff Sync.