Async Processing for Heavy Geometries

Use async dispatch to a ProcessPoolExecutor to acknowledge a spatial webhook in milliseconds while heavy geometry parsing, validation, and projection happen off the event loop in a separate OS process.

This implementation guide is part of Spatial Payload Routing & Parsing, the section covering how GeoJSON and binary geometry payloads are ingested, classified, validated, and forwarded to spatial consumers.


Prerequisites

Before building an asynchronous geometry pipeline, confirm your environment meets these baseline requirements:


Architecture Overview

The pipeline separates concerns into four layers. The webhook endpoint only performs lightweight I/O; all CPU work happens downstream in isolated processes.

Async Geometry Pipeline Architecture Four-layer pipeline showing data flow from webhook HTTP ingestion through a message queue, into a ProcessPoolExecutor worker pool for spatial validation and projection, then to a PostGIS database and event callback. 1 · Ingestion 2 · Queue 3 · Worker Pool 4 · Persistence POST /webhook /spatial Header validate HMAC · size guard 202 Accepted <100 ms Redis Stream or RabbitMQ Async consumer pulls raw bytes Dead-letter queue on max retries ProcessPoolExecutor max_workers = CPU count Shapely topology repair + pyproj CRS transform Pydantic schema validation ijson streaming for large payloads PostGIS / MongoDB spatial index write Completion event to consumers Status callback to client

Layer 1 — Ingestion: The FastAPI endpoint receives raw bytes, validates the HMAC signature and Content-Length, publishes the payload to the queue, and responds 202 Accepted in under 100 ms.

Layer 2 — Queue: An async consumer reads from Redis Streams or RabbitMQ. This buffer absorbs traffic spikes and decouples ingestion throughput from worker capacity. Tasks that exceed the retry ceiling move to a dead-letter queue for inspection.

Layer 3 — Worker Pool: A ProcessPoolExecutor runs topology repair (make_valid), CRS normalization to EPSG:4326 (WGS 84), Pydantic schema validation, and coordinate transformation in separate OS processes, bypassing the GIL entirely.

Layer 4 — Persistence: Validated geometries are written to a spatially indexed store. A completion event notifies downstream consumers and can trigger a status callback to the originating client.


Step-by-Step Implementation

Step 1 — Non-Blocking Ingestion and Immediate Acknowledgment

The endpoint streams raw bytes without deserializing them. Validation is limited to the HTTP layer: check the Content-Type, enforce a Content-Length ceiling, and verify the HMAC-SHA256 signature. For details on constructing the signature check, see Securing Webhook Endpoints with Spatial Token Validation.

python
import asyncio
import hashlib
import hmac
import logging
import os
from concurrent.futures import ProcessPoolExecutor

from fastapi import FastAPI, HTTPException, Request, BackgroundTasks

app = FastAPI()
logger = logging.getLogger(__name__)

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()
MAX_PAYLOAD_BYTES = 100 * 1024 * 1024  # 100 MB ceiling

# Pre-allocate the pool at startup to avoid cold-start latency per request.
WORKER_POOL = ProcessPoolExecutor(max_workers=os.cpu_count() or 4)


def _verify_hmac(body: bytes, signature_header: str | None) -> None:
    """Raise HTTP 401 if the HMAC-SHA256 signature does not match."""
    if not signature_header:
        raise HTTPException(status_code=401, detail="Missing X-Signature header")
    expected = "sha256=" + hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature_header):
        raise HTTPException(status_code=401, detail="Invalid signature")


@app.post("/webhook/spatial")
async def receive_spatial_payload(
    request: Request,
    background_tasks: BackgroundTasks,
) -> dict:
    content_length = int(request.headers.get("content-length", 0))
    if content_length > MAX_PAYLOAD_BYTES:
        raise HTTPException(status_code=413, detail="Payload exceeds 100 MB limit")

    raw_bytes = await request.body()
    _verify_hmac(raw_bytes, request.headers.get("x-signature"))

    # Offload heavy geometry work; respond immediately.
    background_tasks.add_task(_dispatch_to_pool, raw_bytes)
    return {"status": "queued"}

Step 2 — Dispatch to the Process Pool

loop.run_in_executor submits the CPU-bound worker to the pre-allocated ProcessPoolExecutor. The await suspends only this coroutine — the event loop remains free to handle other incoming requests.

python
async def _dispatch_to_pool(payload: bytes) -> None:
    """Submit geometry processing to a separate OS process."""
    loop = asyncio.get_running_loop()
    try:
        result = await asyncio.wait_for(
            loop.run_in_executor(WORKER_POOL, _process_geometry, payload),
            timeout=30.0,  # seconds; adjust for expected geometry complexity
        )
        logger.info("Worker finished: status=%s", result.get("status"))
    except TimeoutError:
        logger.error("Worker timed out — routing to dead-letter queue")
        await _send_to_dlq(payload)
    except Exception as exc:
        logger.exception("Worker raised unexpected exception: %s", exc)
        await _send_to_dlq(payload)

Step 3 — Topology Validation and CRS Normalization

Each worker function is a plain top-level function (required by pickle serialization between processes). It validates the GeoJSON structure against RFC 7946, repairs invalid topologies, and normalizes coordinates to EPSG:4326 (WGS 84) using pyproj. This mirrors the CRS Normalization Strategies pattern for enforcing a canonical projection before any downstream operation.

python
import json
from typing import Any

from pyproj import CRS, Transformer
from shapely.geometry import mapping, shape
from shapely.ops import transform as shapely_transform
from shapely.validation import make_valid


def _reproject(geom, source_epsg: int):
    """Reproject a whole Shapely geometry to EPSG:4326 (WGS 84)."""
    transformer = Transformer.from_crs(
        CRS.from_epsg(source_epsg),
        CRS.from_epsg(4326),
        always_xy=True,  # GeoJSON is lon/lat, so force x=lon, y=lat ordering
    )
    # shapely.ops.transform walks every coordinate ring for us, so MultiPolygons
    # and GeometryCollections are reprojected in full — not just the bounds.
    return shapely_transform(transformer.transform, geom)


def _process_geometry(payload: bytes) -> dict[str, Any]:
    """CPU-bound worker: validate, repair, and normalize a GeoJSON feature."""
    try:
        data = json.loads(payload)
        if data.get("type") != "Feature" or "geometry" not in data:
            return {"status": "error", "message": "Payload is not a GeoJSON Feature"}

        geom = shape(data["geometry"])

        # Topology repair — make_valid returns a valid geometry without data loss.
        if not geom.is_valid:
            geom = make_valid(geom)

        # Detect a non-WGS84 source CRS from an optional vendor property and
        # reproject before persistence so every downstream index is EPSG:4326.
        source_epsg = data.get("properties", {}).get("source_epsg", 4326)
        if source_epsg != 4326:
            geom = _reproject(geom, source_epsg)

        return {
            "status": "success",
            "result": {
                "type": "Feature",
                "geometry": mapping(geom),
                "properties": {
                    **data.get("properties", {}),
                    "crs": "EPSG:4326",
                    "bbox": list(geom.bounds),
                    "is_valid": geom.is_valid,
                },
            },
        }
    except Exception as exc:  # noqa: BLE001
        return {"status": "error", "message": str(exc)}

Step 4 — Pydantic Schema Validation

Before the worker passes results to the persistence layer, validate the output schema with Pydantic v2. This catches malformed geometry objects that passed Shapely’s topology check but violate the application-level contract — for example, MultiPolygon features that should have been split, or missing required property fields. This pairs naturally with Geometry Validation Pipelines for a defense-in-depth validation strategy.

python
from pydantic import BaseModel, Field, model_validator


class SpatialFeatureOut(BaseModel):
    type: str = Field(pattern="^Feature$")
    geometry: dict
    properties: dict

    @model_validator(mode="after")
    def check_geometry_type(self) -> "SpatialFeatureOut":
        allowed = {"Point", "LineString", "Polygon", "MultiPoint",
                   "MultiLineString", "MultiPolygon", "GeometryCollection"}
        if self.geometry.get("type") not in allowed:
            raise ValueError(f"Unsupported geometry type: {self.geometry.get('type')}")
        return self


def validate_output(result: dict) -> SpatialFeatureOut:
    """Raise ValidationError if the processed feature does not meet schema."""
    return SpatialFeatureOut.model_validate(result["result"])

Step 5 — Streaming Deserialization for Large Feature Collections

For payloads exceeding 10 MB, replace json.loads with ijson to avoid heap spikes. The iterator emits one feature at a time, letting the worker dispatch each feature to a separate sub-task rather than holding the entire collection in memory.

python
import io
import ijson


def _iter_features(payload: bytes):
    """Yield individual GeoJSON features without loading the full collection."""
    f = io.BytesIO(payload)
    parser = ijson.items(f, "features.item")
    for feature in parser:
        yield feature


def _process_feature_collection(payload: bytes) -> list[dict]:
    results = []
    for feature in _iter_features(payload):
        feature_bytes = json.dumps(feature).encode()
        results.append(_process_geometry(feature_bytes))
    return results

Spatial Validation and Error Handling

Shapely’s is_valid flag catches self-intersections, unclosed rings, and duplicate vertices — but it does not validate coordinate range or ring orientation under RFC 7946. Add explicit guards:

python
from shapely.geometry import shape
from shapely.validation import explain_validity


def full_validation_report(raw_geometry: dict) -> dict:
    """Return a structured validity report for a GeoJSON geometry object."""
    geom = shape(raw_geometry)
    coords = list(geom.geoms) if hasattr(geom, "geoms") else [geom]

    min_x, min_y, max_x, max_y = geom.bounds
    bbox_valid = (
        -180 <= min_x <= max_x <= 180  # longitude range, west <= east
        and -90 <= min_y <= max_y <= 90  # latitude range, south <= north
    )

    report = {
        "is_valid": geom.is_valid,
        "explanation": explain_validity(geom) if not geom.is_valid else None,
        "bbox_valid": bbox_valid,
        "geometry_type": geom.geom_type,
        "coordinate_count": sum(
            len(list(g.exterior.coords)) if hasattr(g, "exterior") else 0
            for g in coords
        ),
    }
    return report

Route features that fail bbox_valid directly to the dead-letter queue — they indicate corrupt coordinate data that topology repair cannot fix. Log the explanation field for every invalid geometry to enable rapid root-cause analysis without replaying the entire payload. This complements the at-least-once delivery pattern where retries must not reprocess already-persisted features.


Retry, Backoff, and Delivery Guarantees

Spatial workloads fail transiently (GEOS internal errors, database timeouts) and permanently (corrupt coordinate rings). Distinguish between these cases in the retry policy:

python
import random
import asyncio


async def retry_with_backoff(
    payload: bytes,
    max_attempts: int = 4,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
) -> dict | None:
    """
    Retry geometry processing with exponential backoff and jitter.
    Returns None and routes to DLQ after max_attempts.
    """
    for attempt in range(1, max_attempts + 1):
        result = await _dispatch_to_pool(payload)
        if result and result.get("status") == "success":
            return result

        if attempt == max_attempts:
            logger.error(
                "Max retries reached after %d attempts — sending to DLQ", attempt
            )
            await _send_to_dlq(payload)
            return None

        delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
        jitter = random.uniform(0, delay * 0.2)
        logger.warning(
            "Attempt %d failed, retrying in %.2f s", attempt, delay + jitter
        )
        await asyncio.sleep(delay + jitter)

    return None

At-least-once vs exactly-once: The async pipeline above is at-least-once by default — a worker crash after processing but before acknowledgment will cause a re-delivery. To upgrade to effectively-once semantics, assign each payload a deterministic idempotency key (based on a hash of the raw bytes or a vendor-supplied event ID) and store it in Redis before writing to PostGIS. The Event Key Generation for Spatial Data and Cache-Backed Idempotency Checks pages cover this in detail.


Verification

Run this integration test against a local FastAPI instance to confirm the end-to-end pipeline behaves correctly:

python
import json
import time
import httpx
import pytest

SAMPLE_FEATURE = {
    "type": "Feature",
    "geometry": {
        "type": "Polygon",
        "coordinates": [[
            [-122.4194, 37.7749],
            [-122.4094, 37.7749],
            [-122.4094, 37.7849],
            [-122.4194, 37.7849],
            [-122.4194, 37.7749],
        ]]
    },
    "properties": {"source_epsg": 4326, "name": "test-polygon"}
}


@pytest.mark.asyncio
async def test_webhook_returns_202_immediately():
    payload = json.dumps(SAMPLE_FEATURE).encode()
    async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
        t0 = time.monotonic()
        resp = await client.post(
            "/webhook/spatial",
            content=payload,
            headers={
                "content-type": "application/json",
                "x-signature": _compute_test_hmac(payload),
            },
        )
        elapsed = time.monotonic() - t0

    assert resp.status_code == 202
    assert resp.json()["status"] == "queued"
    # Endpoint must acknowledge before any geometry processing begins.
    assert elapsed < 0.5, f"Acknowledgment too slow: {elapsed:.3f} s"


def _compute_test_hmac(body: bytes) -> str:
    import hashlib, hmac, os
    secret = os.environ.get("WEBHOOK_SECRET", "test-secret").encode()
    digest = hmac.new(secret, body, hashlib.sha256).hexdigest()
    return f"sha256={digest}"

Check the application logs after sending a test payload to confirm the worker completed successfully:

text
INFO     Worker finished: status=success

If you see Worker timed out, reduce max_workers or lower the Content-Length ceiling — a flooded process pool starves individual workers of CPU time.


Troubleshooting

Symptom Likely spatial cause Fix
Endpoint latency spikes above 500 ms Synchronous JSON parsing blocking the event loop Replace json.loads in the handler with await request.body() only; defer all parsing to the worker
Worker returns TopologicalError Self-intersecting polygon rings in source data Call make_valid(geom) before any spatial operation; log explain_validity(geom) to identify the ring
CRS mismatch after transformation Source payload uses EPSG:3857 (Web Mercator) but worker assumes EPSG:4326 Read the source_epsg property or a X-Source-CRS vendor header; reject payloads without explicit CRS declaration
PicklingError in ProcessPoolExecutor Lambda or closure passed as worker function Worker function must be a module-level def; closures cannot be serialized across process boundaries
Memory grows unbounded under load Full feature collection loaded into RAM per request Switch to ijson streaming deserialization; enforce the MAX_PAYLOAD_BYTES ceiling in the handler
Dead-letter queue fills faster than expected Retry policy not distinguishing transient from permanent errors Inspect the message field in failed results; route ValidationError outcomes directly to DLQ without retry
Worker pool exhausted under burst traffic max_workers set too low for concurrent payload volume Scale max_workers to os.cpu_count() and add a semaphore to cap concurrent in-flight dispatch calls

FAQ

When should I use ProcessPoolExecutor instead of ThreadPoolExecutor for geometry work?

Use ProcessPoolExecutor whenever geometry operations are CPU-bound — topology repair with make_valid, coordinate projection loops, or spatial joins over thousands of features. These operations are blocked by Python’s GIL and need true OS-level parallelism. ThreadPoolExecutor is suitable only for I/O-bound steps like writing results to PostGIS or publishing to Redis Streams.

How do I prevent memory spikes when deserializing large GeoJSON payloads?

Use ijson for iterative streaming deserialization — it emits geometry objects one at a time without loading the full document into RAM. Pair this with chunked dispatch: split feature collections into coordinate-bounded batches before sending to the worker pool. For the GeoJSON-to-Protobuf mapping path, binary serialization also shrinks wire payload size by 60–80% before it reaches the ingestion endpoint.

How do I handle mixed CRS payloads in an async pipeline?

Detect the source CRS from the payload’s crs property or a X-Source-CRS vendor header, then normalize to EPSG:4326 (WGS 84) inside the worker function before any topology checks. Refusing to dispatch until CRS is resolved prevents silent coordinate corruption in downstream spatial indexes. The full normalization workflow is covered in CRS Normalization Strategies.

What is a safe timeout for a geometry worker process?

Set a per-task timeout via asyncio.wait_for wrapping run_in_executor. A ceiling of 30 seconds works for most polygon simplification and validation workloads; complex union/intersection operations over dense point clouds may need up to 120 seconds. Route timed-out tasks to a dead-letter queue rather than retrying synchronously — a timeout usually signals a data pathology, not a transient failure.


Explore this section