Deprecating a Spatial Data Product Safely
Most deprecations overrun because nobody knew who the consumers were. An announcement goes out, a sunset date is set, and on the date half the callers are still calling — because the consumer list came from a wiki rather than from telemetry, and the window came from a template rather than from the slowest affected release cycle. This guide runs a deprecation that lands: identify callers from access records, derive the window from their cadence, enforce in graduated stages, and retire only when the caller set is empty. It implements the deprecation discipline in Spatial Product Lifecycle Management within Geospatial Data Mesh Fundamentals, and it is the transition automated in Automating Lifecycle State Transitions.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | Platform CLI, Prometheus, jq |
Progress is a metric, not a status report |
| Identity | Every request carries an authenticated caller | Anonymous traffic cannot be migrated |
| Successor | A named successor version with a published diff | “Migrate to v2” is not actionable |
| Telemetry window | ≥ 90 days | A monthly consumer is invisible in 30 |
| Access roles | domain-owner initiates; the guard enforces |
Retirement is gated, not scheduled |
| Environment | PRODUCT, VERSION, SUCCESSOR |
Exported before running |
Step-by-Step Implementation
1. Identify the callers from telemetry
# Every identity that called this version in 90 days, with volume and last-seen.
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=sum by (caller) (increase(product_requests_total{product="'"$PRODUCT"'",version="'"$VERSION"'"}[90d]))' \
| jq -r '.data.result[] | "\(.metric.caller)\t\(.value[1]|tonumber|floor)"' \
| sort -k2 -rn | tee /tmp/callers.tsv
Verify the window is long enough by comparing caller counts at three windows — a set that keeps growing means slower consumers exist:
for d in 30 90 180; do
printf '%3dd: ' "$d"
curl -sS "$PROM/api/v1/query" --data-urlencode \
"query=count(count by (caller) (increase(product_requests_total{product=\"$PRODUCT\",version=\"$VERSION\"}[${d}d])))" \
| jq -r '.data.result[0].value[1]'
done
2. Derive the window from the slowest affected consumer
# window.py — a sunset date the affected teams can actually meet.
CADENCE_DAYS = { # published by each consuming team, not guessed
"logistics/router": 7,
"planning/reporting": 90,
"public/portal": 14,
}
CYCLES_REQUIRED = 2 # one to plan, one to ship
def sunset_days(callers: list[str]) -> tuple[int, str]:
"""Two release cycles of the slowest affected consumer. Publishing the
derivation is what makes the date credible rather than negotiable."""
known = {c: CADENCE_DAYS[c] for c in callers if c in CADENCE_DAYS}
if not known:
return 90, "no cadence published for any caller; defaulting to 90 days"
slowest, days = max(known.items(), key=lambda kv: kv[1])
return days * CYCLES_REQUIRED, f"{CYCLES_REQUIRED} cycles of {slowest} ({days}d)"
3. Enforce in graduated stages
A hard cutover concentrates all the risk into one moment. Each stage below is announced, reversible, and impossible to ignore in turn.
# stages.py — escalating enforcement across the window.
def stage_for(day: int, window: int) -> dict:
"""Header first, so a consumer reading their own logs finds out unprompted.
Throttling next, which is impossible to ignore and breaks nothing. Errors last."""
if day < 7:
return {"header": True, "throttle": None, "error": False}
if day < window * 0.6:
return {"header": True, "throttle": None, "error": False}
if day < window:
# Deliberate, well-labelled throttling: latency, never failure.
return {"header": True, "throttle": {"delay_ms": 2000}, "error": False}
return {"header": True, "throttle": None, "error": True}
def response_headers(version: str, successor: str, sunset_iso: str) -> dict:
return {
"Deprecation": "true",
"Sunset": sunset_iso, # RFC 8594
"Link": f'</products/{successor}>; rel="successor-version"',
"X-Spatial-Deprecated-Version": version,
}
Verify the header reaches consumers before the throttle stage begins:
curl -sS -o /dev/null -D - "https://api.internal/products/$PRODUCT/$VERSION/items?limit=1" \
| grep -iE '^(deprecation|sunset|link):'
4. Track progress and retire on an empty caller set
# The progress signal: call volume per caller against the deadline.
watch -n 3600 'curl -sS "$PROM/api/v1/query" --data-urlencode \
"query=sum by (caller) (rate(product_requests_total{product=\"'"$PRODUCT"'\",version=\"'"$VERSION"'\"}[24h]))" \
| jq -r ".data.result[] | \"\(.metric.caller) \(.value[1])\""'
# Retirement is a guarded transition: the guard fails while any caller remains.
mesh-platform transition --product "$PRODUCT" --version "$VERSION" --to Archived
# BLOCKED: 1 caller in the last 7 days: planning/reporting
Configuration Reference
| Element | Value | Rationale |
|---|---|---|
| Telemetry window | 90d minimum |
A monthly consumer is invisible in 30 days |
| Sunset window | 2 cycles of the slowest caller | A window nobody can meet is not a deadline |
Deprecation header |
From day 0 | Consumers reading their logs find out unprompted |
Sunset header |
RFC 8594 date | Machine-readable; tooling can alert on it |
Link successor |
Required | “Migrate to v2” without a target is not actionable |
| Throttle stage | From ~60% of the window | Impossible to ignore, breaks nothing |
| Error stage | After the window | Only with an empty caller set |
| Retirement gate | Caller set empty over 7 days | A date is not a precondition |
Common Failure Modes & Fixes
The sunset date passes with callers still active. Root cause: the window came from a template rather than from consumer cadence, or progress was never visible. Fix: derive and publish the window; publish per-caller volume against the deadline where both sides see it.
A consumer appears on the last day, having never been seen. Root cause: a telemetry window shorter than their cadence. Fix: 90 days minimum, and check the 180-day set before the error stage.
Throttling breaks a consumer. Root cause: added latency exceeding a client timeout, which turns a warning into an outage. Fix: keep the added delay well inside typical client timeouts — two seconds is noticeable and safe; twenty is a failure.
Consumers ignore the deprecation header.
Root cause: nothing consumes it. Fix: this is why the throttle stage exists; also worth adding a platform-side alert that fires for any consumer calling a version with a Sunset header inside 30 days.
Retirement blocks indefinitely on one caller. Root cause: the deprecation is working; a consumer genuinely cannot move. Fix: this is a conversation, not a technical problem — and it is happening before the outage rather than during it, which is the entire purpose.
FAQ
Why not just set a date and hold it?
Because a date held against a consumer who cannot meet it produces either a breach or an extension, and the extension teaches everyone that sunset dates are negotiable — which makes the next deprecation harder. Deriving the window from the slowest affected consumer’s actual release cadence, and publishing that derivation, produces a date that is defensible and therefore holdable. It is usually longer than the template would have given, and it is met.
What if a consumer will not migrate?
Then the deprecation surfaces a real dependency that needs a decision, which is far better than discovering it at cutover. The options are genuine: extend for that consumer specifically, help them migrate, or accept the breakage with their agreement. What the gated retirement prevents is the fourth option — breaking them by accident, on a date, because a guard was a calendar entry rather than a precondition.
Should the successor’s diff really be mandatory?
Yes, because it is the difference between a request and a work item. “Migrate to v2” cannot be estimated, prioritised, or scheduled; “v2 renames parcel_id to parcel_ref, adds a required survey_date, and changes the storage CRS from EPSG:32633 to EPSG:4326” can be. Where the contract diff is generated automatically at release, publishing it costs nothing and is the single cheapest thing a producer can do to make a deprecation land on time.
Does throttling count as breaking the contract?
It is a deliberate, announced degradation within a declared deprecation, which is why it belongs in the contract’s deprecation clause rather than being invented at the time. A contract that states what happens during deprecation — headers from day one, latency from a stated point, errors after the sunset — makes every stage something the consumer agreed to in advance. A throttle applied without that clause is a surprise, however well-intentioned.
What about consumers who are not services — an analyst with a saved query?
They are the hardest case and the reason the deprecation header alone is insufficient. An interactive consumer does not read response headers and does not appear as a stable service identity, so telemetry shows an identity that may be a shared gateway or a notebook environment. The workable approach is to treat those callers as one named consumer with a human owner, contact that owner directly, and rely on the throttle stage to reach anyone the contact missed — a two-second delay is noticed by a person in a way a header never is.
Should a deprecated version keep receiving data updates?
Yes, for the duration of the window, and stopping is a common mistake. A deprecated version that stops updating becomes stale while consumers are still legitimately using it, which converts an orderly migration into a data-quality incident affecting exactly the people who have not moved yet. The lifecycle state should reduce the cadence if the cost is material, and it should never freeze the data outright before the sunset — a product that is still serving is still a product.
Related
- Spatial Product Lifecycle Management — the parent topic and the lifecycle states
- Automating Lifecycle State Transitions for Spatial Products — the guard that enforces the empty caller set
- Detecting Breaking Changes in Spatial Contracts — the diff a successor must publish