Splitting an Overloaded Spatial Domain
A domain becomes overloaded when two consumer groups with different cadences and precision needs are being served by one product, and the team can satisfy neither without compromising the other. Splitting it is the right answer and the naive execution — repointing the original identifier at one half — silently breaks every consumer whose queries fell in the other. This guide performs the split so that no consumer’s request ever fails without warning: publish both successors alongside the original, run all three in parallel, migrate consumers by name from telemetry, and retire the original only when its caller set is empty. It applies the boundary patterns from Spatial Domain Boundary Design within Geospatial Data Mesh Fundamentals.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | Platform CLI, psql ≥ 14, Prometheus |
Migration progress comes from telemetry |
| Telemetry | Per-caller access records, ≥ 30 days | A consumer list from a wiki will be wrong |
| Registry | Extent-overlap checking at registration | Two successors must not both claim a region |
| Access roles | domain-owner for the original and both successors |
One owner during the split |
| Environment | ORIGINAL, SUCCESSOR_A, SUCCESSOR_B |
Exported before running |
Step-by-Step Implementation
1. Establish who the consumers actually are
The split’s whole risk is a consumer nobody knew about, and the only reliable source is the access log.
# Callers of the original in the last 30 days, with the extent each actually queries.
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=sum by (caller) (increase(product_requests_total{product="'"$ORIGINAL"'"}[30d]))' \
| jq -r '.data.result[] | "\(.metric.caller) \(.value[1])"' | sort -k2 -rn
Verify the caller set is stable rather than still growing — a set that gains new names weekly means 30 days is too short a window:
for d in 7 30 90; do
printf '%3dd: ' "$d"
curl -sS "$PROM/api/v1/query" --data-urlencode \
"query=count(count by (caller) (increase(product_requests_total{product=\"$ORIGINAL\"}[${d}d])))" \
| jq -r '.data.result[0].value[1]'
done
2. Publish both successors without touching the original
# Two new products, each with its own cadence and precision. The original keeps
# serving unchanged — a split adds products before it removes one.
- apiVersion: mesh.geospatial/v1
kind: SpatialProduct
metadata: { domain: cadastral, name: parcels_survey }
spec:
storage: { crs: "EPSG:4326" }
quality: { blocking: [geometry_validity_rate, crs_conformance], precision_bound: 8 }
slo: { freshness: P90D } # survey-grade, quarterly
- apiVersion: mesh.geospatial/v1
kind: SpatialProduct
metadata: { domain: cadastral, name: parcels_operational }
spec:
storage: { crs: "EPSG:4326" }
quality: { blocking: [geometry_validity_rate, crs_conformance], precision_bound: 6 }
slo: { freshness: P1D } # operational derivative, daily
Verify the successors do not overlap each other’s declared extent, which would make routing non-deterministic:
mesh-platform check-overlap --product "$SUCCESSOR_A" --product "$SUCCESSOR_B"
# Any intersection is a rejection, not a warning.
3. Run all three in parallel and prove equivalence
-- parity.sql — every feature in the original must appear in exactly one successor.
-- Run per partition; a non-zero count in either column blocks the migration.
SELECT
count(*) FILTER (WHERE a.feature_id IS NULL AND b.feature_id IS NULL) AS orphaned,
count(*) FILTER (WHERE a.feature_id IS NOT NULL AND b.feature_id IS NOT NULL) AS duplicated
FROM original_features o
LEFT JOIN successor_a_features a ON a.feature_id = o.feature_id
LEFT JOIN successor_b_features b ON b.feature_id = o.feature_id
WHERE o.partition_key = :'partition';
Verify parity across every partition, not a sample — an orphaned feature is a consumer’s missing row:
for p in $(mesh-platform partitions --product "$ORIGINAL"); do
printf '%-16s ' "$p"
psql --csv -f parity.sql -v partition="$p" | tail -1
done
4. Migrate consumers by name, then retire
# Each caller is contacted with the successor that matches their observed usage:
# high-precision, low-cadence callers to the survey product; the rest to operational.
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=avg by (caller) (product_request_precision_digits{product="'"$ORIGINAL"'"})' \
| jq -r '.data.result[] | "\(.metric.caller) -> \(if (.value[1]|tonumber) > 6
then "'"$SUCCESSOR_A"'" else "'"$SUCCESSOR_B"'" end)"'
# Retirement is gated on the caller set being empty, never on a date.
mesh-platform transition --product "$ORIGINAL" --to Archived
# The guard fails while any caller remains, and reports who.
Configuration Reference
| Step | Requirement | Gate |
|---|---|---|
| Consumer discovery | ≥ 30 days of access telemetry | Caller set must be stable |
| Successor extents | No mutual overlap | Registry check; rejection, not warning |
| Parity | Every feature in exactly one successor | Zero orphans, zero duplicates |
| Parallel run | ≥ one full cycle of the slowest consumer | Not a fixed duration |
| Migration | Per named caller | Announcement alone is insufficient |
| Retirement | Caller set empty | Guard precondition, not a date |
Common Failure Modes & Fixes
A consumer breaks despite being on the migration list. Root cause: they queried an extent that fell between the two successors, because the split was drawn on attributes rather than on geometry. Fix: the parity query catches orphans before migration — run it per partition, not on a sample.
Both successors return the same feature. Root cause: overlapping extents at the boundary. Fix: the overlap check at registration; a feature straddling the split needs a deterministic owner and a halo read, not duplication.
Migration stalls at eighty percent. Root cause: no deadline pressure and no visibility. Fix: publish per-caller call volume against the deadline where both sides can see it; a shared, visible number moves migrations that a request does not.
The original is retired and a caller reappears. Root cause: a consumer that runs monthly and was invisible in a 30-day window. Fix: size the telemetry window to the slowest known consumer cadence, and check the 90-day set before retiring.
Successor SLOs are copied from the original. Root cause: the split existed because one SLO could not serve both groups; copying it recreates the problem. Fix: derive each successor’s SLO from its own consumers’ measured needs.
FAQ
Why not repoint the original identifier at one successor?
Because it silently breaks the other half of the consumers. Their requests keep succeeding — they simply return fewer features, or none — and an empty result is indistinguishable from a genuine absence of data. That failure mode is far worse than an error: it produces wrong answers rather than visible faults, and it is typically discovered weeks later by someone acting on an incomplete result. Keeping the original addressable until its callers have moved is the only approach where every failure is loud.
How long should the parallel run last?
At least one full release cycle of the slowest affected consumer, which is a derived number rather than a chosen one. A consumer shipping quarterly under change control cannot migrate in a month, and setting a deadline they cannot meet guarantees either a breach or an extension that teaches everyone deadlines are negotiable. Publishing the derivation — “this window is two cycles of the slowest affected consumer, which is the reporting service” — makes the date credible in a way an arbitrary ninety days is not.
What if a feature genuinely belongs to both successors?
It belongs to one, with the other reading it as a boundary input. Duplicating it across both is the pattern that always fails: the copies diverge within an update cycle and nothing detects it. Assign ownership by a deterministic rule — the successor containing the feature’s centroid, say — and give the other read access to a halo region so its joins have the neighbouring geometry without owning it.
Should the split be a MAJOR bump on the original?
Yes, and the original’s contract should say so from the moment the successors publish. The original is not changing shape, but it is entering a deprecation whose end is its removal, and consumers need that in the contract rather than in an announcement. The successors start at their own v1.0.0 with their own contracts, since they are new products rather than versions of the old one.
How do you know a domain is overloaded rather than merely busy?
The signal is a contradiction in requirements rather than a volume of them. A domain is busy when it has many consumers who want the same thing faster; it is overloaded when two groups want incompatible things — one needs quarterly survey-grade precision, the other needs daily operational currency, and every cadence or precision decision makes one of them worse. That contradiction shows up in the SLO conversation before it shows up in any metric: a team unable to state a single freshness target without qualifying it by consumer is describing two products.
Can a split be reversed?
Technically yes and organisationally rarely, which is an argument for being confident before starting. Merging two products back means a MAJOR change for both consumer groups and re-establishing a single set of promises that the split existed to avoid. The situation where it is genuinely right is when the two consumer groups’ requirements converge — a survey cadence that becomes daily, an operational precision requirement that tightens — at which point maintaining two pipelines is pure overhead. Treat a split as a one-way door and the pre-split analysis gets the attention it deserves.
Related
- Spatial Domain Boundary Design — the parent topic and the cross-boundary ownership patterns
- Deprecating a Spatial Data Product Safely — the retirement half of the split
- Checking Registry-Wide Extent Overlap — the gate that keeps two successors from both claiming a region