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.

Overloaded or merely busy: the signals that distinguish themFour signals against what a busy domain looks like and what an overloaded one looks like. A busy domain has many consumers wanting the same thing faster and can state one freshness target. An overloaded domain has two consumer groups wanting incompatible things and cannot state a target without qualifying it by consumer. A busy domain SLO conversation converges; an overloaded one produces a compromise that serves neither group. The contradiction shows up in that conversation before it appears in any metric.BusyOverloadedConsumers wantthe same thing fasterincompatible thingsFreshness targetone numberqualified by consumerSLO conversationconvergesproduces a compromisePrecision requirementsingletwo, an order apart

bash
# 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:

bash
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

yaml
# 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:

A split adds products before it removes oneThe original product runs alone, then in parallel with two successors published alongside it, then with consumers migrating by name from telemetry, and finally the original is retired once its caller set is empty. The return path records the shortcut that fails: repointing the original identifier at one successor breaks every consumer whose queries fell in the other half, and does so silently because their requests keep succeeding and simply return fewer features.Original aloneall consumersParallelthree products liveMigratingby named callerOriginal retiredcaller set emptysuccessors publishedparity provenlast caller movedshortcut: repoint the identifier — half the queries silently return less

bash
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

sql
-- 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:

The parity check that stops a feature falling between the successorsEvery feature in the original partition is checked against both successors. A feature appearing in exactly one is correctly assigned. A feature appearing in neither is orphaned, which is a consumer missing row waiting to happen. A feature appearing in both is duplicated, which produces two claimants and non-deterministic routing. Both failure classes drop onto a rail that blocks the migration, because the parity check is run per partition rather than on a sample precisely so neither can slip through.Original featureper partitionIn exactly onecorrectly assignedcheckedin neither, or in bothMigration blockedorphans and duplicates

bash
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

bash
# 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.