This guide is part of Monitoring & Observability for Spatial Pipelines, within the broader Orchestrating Spatial ETL Pipelines reference.
Alerting on Spatial Data Freshness SLAs
Freshness is the one alert that catches every way a spatial pipeline can quietly stop producing useful output — provided it is measured on the data rather than on the job, and from somewhere the job cannot take down with it.
Why the Job-Level Alert Is Not Enough
- A dead scheduler emits nothing. No failed task means no alert from anything that watches tasks.
- A stalled source produces successful runs. Conditional requests return 304, the load writes zero rows, everything is green.
- A partially failed backfill leaves gaps. The most recent partition is fresh while three older ones never materialised.
- A consumer notices before you do. Which is the actual failure being prevented — not the staleness, but learning about it from someone else.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| SQLAlchemy | >=2.0 |
Typed connection API used below |
| pyarrow | >=14 |
Reading partition metadata for object-store datasets |
| Python | 3.10+ | datetime.UTC-style timezone handling |
pip install "sqlalchemy>=2.0" "pyarrow>=14"Two Clocks, Not One
The freshness_report Recipe
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import sqlalchemy
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Freshness:
dataset: str
output_age: timedelta
source_age: timedelta | None
threshold: timedelta
partitions_missing: int
@property
def breached(self) -> bool:
return self.output_age > self.threshold or self.partitions_missing > 0
@property
def diagnosis(self) -> str:
if self.partitions_missing:
return "partial coverage — some partitions never materialised"
if not self.breached:
return "healthy"
if self.source_age is not None and self.source_age > self.threshold:
return "source has not published — escalate to the publisher"
return "pipeline has stalled — the source is current"
def freshness_report(
engine: sqlalchemy.Engine,
dataset: str,
table: str,
cadence: timedelta,
processing_budget: timedelta,
timestamp_column: str = "updated_at",
source_column: str = "source_published_at",
expected_partitions: int | None = None,
) -> Freshness:
"""Measure output and source age, and count partitions that never arrived."""
now = datetime.now(timezone.utc)
threshold = cadence + processing_budget + cadence # allow exactly one missed cycle
with engine.connect() as connection:
newest_output = connection.execute(
sqlalchemy.text(f"SELECT max({timestamp_column}) FROM {table}")
).scalar_one_or_none()
newest_source = connection.execute(
sqlalchemy.text(f"SELECT max({source_column}) FROM {table}")
).scalar_one_or_none()
partitions_present = connection.execute(
sqlalchemy.text(f"SELECT count(DISTINCT partition_key) FROM {table}")
).scalar_one()
if newest_output is None:
raise ValueError(f"{dataset}: serving table is empty — treat as a breach, not a gap")
def _age(value) -> timedelta:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return now - value
report = Freshness(
dataset=dataset,
output_age=_age(newest_output),
source_age=_age(newest_source) if newest_source else None,
threshold=threshold,
partitions_missing=max(0, (expected_partitions or partitions_present) - partitions_present),
)
logger.info("%s freshness: output %s, source %s, %s",
dataset, report.output_age, report.source_age, report.diagnosis)
return reportKey Implementation Notes
- The threshold allows exactly one missed cycle.
cadence + processing + cadencemeans a single late run is not an incident and two consecutive ones are, which matches how people actually want to be interrupted. - Source and output age come from the same table. Carrying
source_published_atthrough to the serving rows costs one column and turns “whose fault is it” into a computed field rather than a conversation. - An empty table is a breach, not an error case. A dataset with no rows has infinite staleness; treating it as a missing value is how a truncation goes unnoticed.
- Partition coverage is checked alongside age. The newest partition being fresh says nothing about the three that failed, which is the failure mode a single max timestamp cannot see.
- The diagnosis is computed, not written by the alert author. The responder gets “source has not published” rather than “freshness breach”, which is most of the triage already done.
- Naive timestamps are coerced to UTC. Mixed-awareness datetimes are endemic in spatial databases and produce comparisons that fail at 1 a.m. on a clock-change weekend.
Sources That Publish Irregularly
Not every source has a cadence. Satellite scenes arrive when the orbit and the cloud allow; a municipal portal updates when someone remembers. For these, a threshold derived from a nominal schedule fires constantly.
The workable approach is empirical. Record the arrival time of each new source version for a few months, take the distribution of inter-arrival gaps, and set the threshold above a high percentile — the 95th is a reasonable starting point. The alert then means “this gap is longer than 95% of the gaps we have seen”, which is a statement someone can act on.
Two refinements help. Seasonality matters for optical imagery: a threshold derived from summer gaps will fire every winter in cloudy latitudes, so bucket the distribution by month. And a hard ceiling regardless of the distribution catches a source that has genuinely been retired, which no percentile will flag because the retirement looks like an unusually long gap until it does not end.
Where the Check Should Run
The freshness check must not live inside the pipeline it watches. A scheduler that stopped running produces no tasks, so a check implemented as a pipeline task goes silent at exactly the moment it should fire.
Three placements work. A separate scheduled job on independent infrastructure is the simplest and covers a dead scheduler. A monitoring-system query against the metric emitted by the pipeline works if the metric store alerts on absence as well as on value — many do not by default, and “no data” must be configured as a breach rather than as unknown. A consumer-side check in the system that reads the data is the most honest of all, because it measures what the consumer actually experiences.
Whichever is chosen, the check needs read access to the serving table and nothing else, which makes it cheap to run somewhere the pipeline cannot break.
Troubleshooting Freshness Alerts
| Symptom | Likely cause | Fix |
|---|---|---|
| Alert fires every weekend | Threshold derived from weekday cadence | Bucket the cadence by day of week |
| No alert during a full outage | Check runs inside the stopped pipeline | Move it to independent infrastructure |
| Alert fires while data is current | Timestamp column records ingestion, not observation | Alert on the observation timestamp |
| Freshness fine, data incomplete | Only the max timestamp is checked | Add the partition-coverage count |
| Constant alerts on an irregular source | Nominal cadence rather than observed gaps | Derive the threshold from the arrival distribution |
Integration Note
Emit the freshness numbers from the same emitter used in emitting metrics for spatial record counts, so the alerting rule reads one series rather than querying the database directly on every evaluation. Keep the SLA itself — cadence, processing budget, owner — in the same configuration as the pipeline, because a freshness promise that lives only in a monitoring tool drifts from the pipeline that has to keep it.