Bounding Cross-Domain Join Cost with Query Budgets

A federated join whose cost is discovered by running it will eventually be the query that takes down the estate. Budgets move that discovery to admission time: the query declares an extent, the planner estimates what it will scan, and anything above the ceiling is refused with a diagnostic instead of attempted with hope. This guide implements admission, enforcement during execution, and the refusal semantics that send a consumer to the right path rather than merely away. It operationalises the budget specification in Federated Query and Cross-Domain Spatial Joins within Federated Ownership & Routing Architecture, and refused queries land on the path described in Async Execution for Heavy Spatial Queries.

Prerequisites

Requirement Value / Assumption Notes
Tools python3 ≥ 3.11, the federation engine’s explain API Estimation needs a plan, not a run
Statistics Current row counts and extents per published product A stale estimate under-counts and admits the query
CRS convention All budgets computed in EPSG:4326; areas via geography Degree-based area comparisons are meaningless
Access roles Budgets set per consuming role, not per query A per-query budget is a suggestion
Environment CATALOG_API, ENGINE_URL Exported before running

Step-by-Step Implementation

1. Refuse the queries that have no bounded form at admission

Some queries cannot be budgeted because their cost is not a function of anything supplied. Those are refused before estimation, and the refusal names the async path.

python
# admission.py — the checks that precede any estimation.
UNBOUNDED_PREDICATES = {"ST_Union", "ST_ClusterDBSCAN", "ST_ClusterKMeans"}


class Refused(Exception):
    """Carries the reason and the alternative, so a consumer knows what to do next."""

    def __init__(self, reason: str, alternative: str):
        super().__init__(f"{reason}{alternative}")
        self.reason, self.alternative = reason, alternative


def admit(query: dict) -> None:
    if not query.get("extent"):
        raise Refused(
            "no extent supplied; an unbounded join has no bounded cost",
            "supply an extent, or submit to the async path",
        )
    if not query.get("crs"):
        raise Refused(
            "no join CRS declared; mismatched SRIDs return an empty result silently",
            "declare crs explicitly on the request",
        )
    used = set(query.get("functions", []))
    if used & UNBOUNDED_PREDICATES:
        raise Refused(
            f"{sorted(used & UNBOUNDED_PREDICATES)} has no bounded form over two domains",
            "submit to the async path with a job budget",
        )
    if query.get("nearest_neighbour") and not query.get("max_distance_m"):
        raise Refused(
            "unbounded nearest-neighbour scans both sides entirely",
            "supply max_distance_m, or submit to the async path",
        )

Admission, estimation, enforcement — and the refusals that name an alternativeA federated query passes three gates before it runs. Admission refuses shapes with no bounded form: a missing extent, an undeclared join CRS, an unbounded clustering function, an unbounded nearest-neighbour search. Estimation computes conservative candidate counts from published feature density over the requested area. Enforcement compares them against the ceilings. Only then does the query execute, with runtime enforcement catching whatever the estimate missed. Each refusal drops onto a rail leading to the async path rather than to a dead end.Admissionextent · crs · shapeEstimationdensity × areaEnforcementagainst ceilingsExecutesruntime budget tooadmissibleestimatedwithin ceilingno bounded formcandidates too hightransfer too largeAsync path, with a job budgetrefused, not abandoned

Verify each refusal names an alternative — a refusal without one converts into a support conversation:

bash
python3 - <<'PY'
import admission
for q in [{}, {"extent": "POLYGON(...)"},
          {"extent": "P", "crs": "EPSG:4326", "functions": ["ST_Union"]}]:
    try:
        admission.admit(q)
    except admission.Refused as e:
        print(f"{e.reason[:44]:46} -> {e.alternative}")
PY

2. Estimate the scan from catalog statistics before planning

The estimate does not need to be accurate; it needs to be conservative and cheap. Feature density over the requested extent gets there.

python
# estimate.py — conservative pre-flight cost, from catalog statistics only.
import math


def area_km2(extent_bbox: tuple[float, float, float, float]) -> float:
    """Approximate area of a lat/lon bbox. Conservative: overestimates near poles,
    which is the safe direction for an admission check."""
    minx, miny, maxx, maxy = extent_bbox
    mid_lat = math.radians((miny + maxy) / 2)
    km_per_deg_lat = 110.574
    km_per_deg_lon = 111.320 * math.cos(mid_lat)
    return abs(maxx - minx) * km_per_deg_lon * abs(maxy - miny) * km_per_deg_lat


def estimate_candidates(product_stats: dict, extent_bbox) -> int:
    """Rows a side is expected to contribute. Density is published by each product
    as features per square kilometre, refreshed on every publish."""
    return int(product_stats["feature_density_per_km2"] * area_km2(extent_bbox))


def estimate_pairs(left: dict, right: dict, extent_bbox) -> dict:
    l = estimate_candidates(left, extent_bbox)
    r = estimate_candidates(right, extent_bbox)
    return {
        "left_candidates": l,
        "right_candidates": r,
        # Envelope pairing is the dominant cost before the exact predicate runs.
        "envelope_pairs": l * r,
        "estimated_bytes": (l * left["avg_bytes"]) + (r * right["avg_bytes"]),
    }

How candidate count scales with the requested extent, against the per-side ceilingFive extents and the estimated candidates one side contributes, against the 250 thousand per-side ceiling. A city block yields about 90 candidates and a city about 12 thousand, both comfortably inside. A county yields around 180 thousand, close to the ceiling. A region yields 940 thousand and a national extent about 4.1 million, both refused at admission with the narrowing factor reported so the consumer can tighten their bound themselves.city block0.09kcity12kcounty180knear the ceilingregion940krefused: narrow 3.8×national1150k≈4,100k, refusedper-side ceiling

Verify the estimate against a handful of real queries — it should over-predict, never under-predict:

bash
python3 - <<'PY'
import estimate, json
left  = {"feature_density_per_km2": 41.2, "avg_bytes": 780}
right = {"feature_density_per_km2": 3.1,  "avg_bytes": 2100}
print(json.dumps(estimate.estimate_pairs(left, right, (-1, 50, 1, 52)), indent=2))
PY

3. Enforce the ceiling and refuse with the observed value

A refusal that says “too expensive” teaches nothing. One that says what was estimated, against which limit, lets a consumer narrow their extent themselves.

What each refusal actually tells the consumer to doFive refusal conditions against the diagnostic returned and the action it implies. A missing extent asks for one. Candidates above the per-side ceiling report the observed count and the narrowing factor needed. An estimated transfer above the byte ceiling usually means pushdown is not engaging, so the diagnostic points at the plan rather than at the extent. A runtime row abort means the density statistics were stale. A duration abort means the shape does not fit a request at all.Diagnostic reportsConsumer actionNo extentextent is requiredsupply oneCandidates too highcount + narrowing factortighten the bboxTransfer too largepushdown may be failingcheck the planRows aborted at runtimeestimate was lowrepublish densityDuration abortedelapsed at abortuse the async path

python
# enforce.py — the ceiling, and a refusal a consumer can act on.
CEILINGS = {
    "max_candidates_per_side": 250_000,
    "max_envelope_pairs": 20_000_000_000,
    "max_bytes_transferred": 256 * 1024 * 1024,
}


def enforce(est: dict) -> None:
    for side in ("left_candidates", "right_candidates"):
        if est[side] > CEILINGS["max_candidates_per_side"]:
            raise Refused(
                f"{side}={est[side]:,} exceeds {CEILINGS['max_candidates_per_side']:,}",
                f"narrow the extent by about {est[side] / CEILINGS['max_candidates_per_side']:.1f}×",
            )
    if est["envelope_pairs"] > CEILINGS["max_envelope_pairs"]:
        raise Refused(
            f"envelope_pairs={est['envelope_pairs']:,.0f}",
            "narrow the extent, or submit to the async path",
        )
    if est["estimated_bytes"] > CEILINGS["max_bytes_transferred"]:
        raise Refused(
            f"estimated transfer {est['estimated_bytes'] / 1e6:.0f}MB",
            "predicate pushdown may not be engaging — check the plan",
        )

4. Enforce again during execution, because estimates are estimates

Pre-flight estimation prevents the obviously impossible; runtime enforcement catches the case where statistics were stale.

python
# runtime.py — a progress callback that aborts with an actionable diagnostic.
import time


def run_with_budget(execute, query: dict, ceilings: dict) -> dict:
    started = time.monotonic()
    seen = {"rows": 0, "bytes": 0}

    def progress(rows: int, byts: int) -> None:
        seen["rows"] += rows
        seen["bytes"] += byts
        elapsed = time.monotonic() - started
        if seen["rows"] > ceilings["max_rows_scanned"]:
            raise Refused(
                f"aborted after {seen['rows']:,} rows — the estimate was low",
                "statistics may be stale; re-publish density, then retry",
            )
        if elapsed * 1000 > ceilings["max_duration_ms"]:
            raise Refused(
                f"aborted after {elapsed:.0f}s",
                "submit to the async path — this shape does not fit a request",
            )

    rows, provenance = execute(query, progress)
    return {"rows": rows, "provenance": provenance,
            "budget": {**seen, "duration_s": round(time.monotonic() - started, 1)}}

Verify an over-budget query aborts quickly rather than running to a timeout:

bash
curl -sS -X POST "$ENGINE_URL/federated-query" -H 'content-type: application/json' \
  -d '{"extent":"POLYGON((-10 45,10 45,10 60,-10 60,-10 45))","crs":"EPSG:4326",
       "left":"cadastral/parcels","right":"hydrology/flood_extents"}' \
  | jq '{refused: .error, alternative: .alternative}'

Configuration Reference

Ceiling Default Set by Breach behaviour
max_candidates_per_side 250000 Platform, per role Refuse at admission with the required narrowing factor
max_envelope_pairs 2e10 Platform Refuse at admission
max_bytes_transferred 256MB Platform Refuse; usually indicates pushdown failure
max_rows_scanned 5000000 Platform Abort at runtime; statistics were stale
max_duration_ms 30000 Platform Abort; suggest the async path
max_skew Per consumer Consumer Refuse a result whose sides disagree in age
Density statistics Per publish Producing domain Stale density under-estimates and admits too much

Common Failure Modes & Fixes

Everything is refused after a bulk import. Root cause: feature density was republished and is now much higher, so estimates scale accordingly. Fix: this is the system working — consumers narrow extents. If it is genuinely wrong, the density statistic is being computed over the wrong extent.

Queries are admitted and then abort at runtime. Root cause: stale density statistics under-estimating. Fix: refresh density on every publish rather than on a schedule; the estimate is only as good as its input.

Consumers work around budgets by issuing many small queries. Root cause: entirely rational, and usually fine — many small bounded queries are cheaper than one large one. Fix: watch for pathological cases where the tiling overhead exceeds the saving, and offer those consumers the async path explicitly.

A refusal gives no usable guidance. Root cause: the message reports a limit without the observed value or a narrowing factor. Fix: the messages above; “narrow by about 3.4×” is actionable in a way “too large” is not.

A budget breach is really a pushdown failure. Root cause: bytes transferred is the tell — a query whose predicate is not pushing down transfers everything regardless of extent. Fix: check the plan before adjusting any ceiling.

FAQ

Should budgets be per query or per consumer?

Per consuming role, applied to every query that role submits. A per-query budget that the consumer supplies is a suggestion — the consumer who most needs the limit is the one who will raise it — and a per-query budget the platform supplies is the same number written repeatedly. Attaching ceilings to the role means they are enforced uniformly, they can be tuned for a consumer with a demonstrated need, and a consumer cannot opt out by omitting a field.

What should happen when a legitimate consumer needs more?

Move them to the async path rather than raising the synchronous ceiling. A synchronous budget protects shared capacity from any single request, so raising it for one consumer degrades the latency every other consumer sees. The async path exists precisely for work whose cost is real and unbounded: the query is admitted, acknowledged, executed against a job budget, and the result is fetched when ready. Raising a synchronous ceiling moves the failure from the consumer who asked to everyone who did not.

How accurate does the pre-flight estimate need to be?

Conservative rather than accurate. Its job is to refuse the obviously impossible cheaply, and over-estimating costs a consumer a narrower extent while under-estimating costs the cluster an incident. Feature density over bbox area is crude, systematically over-predicts near the poles, and is entirely adequate — it is within an order of magnitude, which is all that matters for a decision whose alternative is running the query to find out. Runtime enforcement catches whatever the estimate misses.

Does the extent requirement break legitimate whole-estate queries?

It makes them explicit rather than accidental, which is the point. A genuine estate-wide analysis is a real requirement and a poor fit for a synchronous request: it belongs on the async path, where it can be partitioned, scheduled off-peak, and given a job budget proportional to its value. What the extent requirement prevents is the far more common case of a consumer who meant to query one city, omitted a bound by mistake, and would otherwise have scanned a continent before noticing.