Optimizing async execution for spatial joins

Cross-boundary polygon intersections and point-in-polygon validations across federated domains routinely exhaust synchronous connection pools and trigger cascading ingress timeouts. This guide is a concrete operation under Async Execution for Heavy Spatial Queries, itself part of the Federated Ownership & Routing Architecture: it walks through tuning a bounded work-queue pipeline that materializes spatial join results off the request path while preserving deterministic ordering and exactly-once delivery. The worker pool consumes only payloads that have already cleared Schema Contracts for Vector/Tile Data, so every step below assumes a validated, EPSG:4326 geometry contract at the edge.

Async spatial-join pipeline, from job submission to materialized result A validated EPSG:4326 job carrying a trace_id is submitted to a broker topic with twelve partitions, one per spatial domain bounding box. A bounded worker pool with concurrency eight consumes partitions and runs the ST_Intersects join. Successful results are written to Parquet with an exactly-once conditional write, then polled by the caller in deterministic order. Payloads that fail geometry validation or carry a schema-version mismatch branch off the worker pool into a dead-letter queue for a steward to reconcile. Submit job validated geometry EPSG:4326 bbox + trace_id Broker topic 12 partitions 1 per domain bbox Worker pool concurrency 8 ST_Intersects bounded join Materialize exactly-once ON CONFLICT Parquet · zstd Poll result deterministic order from S3 Dead-letter queue invalid geometry schema mismatch reject

Prerequisites

Requirement Value / Constraint
Spatial engine PostGIS 3.4+ with GiST indexes on every joined geom column
Broker Kafka 3.5+ (or Redpanda), one partition per spatial domain bounding box
CLI tools psql, kubectl, kafka-topics, kafka-consumer-groups, jq
Storage CRS Geometries stored in EPSG:4326; web mosaicking reprojected to EPSG:3857 on egress only
Access roles spatial-worker service account (consume + write); GIS Data Steward for dead-letter reconciliation
Env vars BROKER_BOOTSTRAP, SPATIAL_PARTITION, MAX_HEAP_MB, IDEMPOTENCY_TTL_SECONDS

Step-by-Step Implementation

1. Provision a spatially-partitioned topic

Each partition maps to one domain’s bounding box so join fan-out stays localized and a single hot domain cannot starve its neighbours. Partition count is the upper bound on worker parallelism.

bash
# One partition per spatial domain; durable acks for zero loss on failover.
kafka-topics --bootstrap-server "$BROKER_BOOTSTRAP" \
  --create --topic spatial-join-jobs \
  --partitions 12 --replication-factor 3 \
  --config retention.ms=3600000 --config min.insync.replicas=2

Verify the partition layout matches the domain registry before routing any jobs:

bash
kafka-topics --bootstrap-server "$BROKER_BOOTSTRAP" \
  --describe --topic spatial-join-jobs | grep -E 'PartitionCount|Leader'

2. Configure the worker pool orchestrator

The worker manifest pins memory caps, concurrency, and the join template. acks: all with min.insync.replicas=2 guarantees no message loss during a broker election; linger_ms and batch_size raise throughput for geometry-heavy payloads.

yaml
async_spatial_join:
  broker:
    type: kafka
    partitions: 12
    retention_ms: 3600000
    acks: all
    linger_ms: 5
    batch_size: 16384
  worker_pool:
    concurrency: 8
    max_heap_mb: 4096
    spatial_engine: postgis
    query_template: |
      SELECT a.domain_id, b.feature_id, ST_Intersection(a.geom, b.geom) AS intersect_geom
      FROM domain_a_polygons a
      JOIN domain_b_points b ON ST_Intersects(a.geom, b.geom)
      WHERE a.spatial_partition = $1
      AND a.geom && ST_MakeEnvelope($2, $3, $4, $5, 4326)
      ORDER BY a.spatial_partition, a.geom
  materialization:
    strategy: parquet_s3
    compression: zstd
    schema_version: v2.1
    idempotency_key: trace_id
    retry_policy: exponential_backoff
    max_retries: 3

Confirm the pool rolled out and is consuming its partitions:

bash
kubectl get deploy spatial-join-worker -n geospatial-mesh -o jsonpath='{.status.readyReplicas}'
kafka-consumer-groups --bootstrap-server "$BROKER_BOOTSTRAP" \
  --group spatial-join-workers --describe

3. Enforce bounding-box pre-filtering in the join

The && operator applies a GiST bounding-box test before the expensive ST_Intersection topology calculation, which keeps the planner off full-table scans across federated tables. Never ship a join template without it.

The order the join evaluates predicates, and why the cheap one has to come firstA candidate feature pair is tested first by the index-backed bounding-box overlap, which compares four numbers and eliminates the overwhelming majority of pairs. Survivors are tested against the exact geometry predicate, whose cost scales with vertex counts. Only pairs passing both are materialized. The rail shows what defeats the ordering: a function wrapping the geometry column or a mismatched SRID forcing an on-the-fly transform makes the index unreachable, and the planner falls back to evaluating the exact predicate on every pair — at which point runtime scales with the product of both table sizes.Bbox overlapindex-backed, 4 numbersExact predicateST_Intersects, vertex-boundMaterializedexactly-oncesurvivors onlytrueindex unreachableFull pairwise scanruntime = |A| × |B|

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT a.domain_id, b.feature_id, ST_Intersection(a.geom, b.geom) AS intersect_geom
FROM domain_a_polygons a
JOIN domain_b_points b ON ST_Intersects(a.geom, b.geom)
WHERE a.spatial_partition = 'us-east-1'
AND a.geom && ST_MakeEnvelope(-74.5, 40.1, -73.9, 40.9, 4326);

A healthy plan shows an Index Scan using idx_domain_a_geom. A Seq Scan means the GiST index is missing or ANALYZE statistics are stale — fix it before tuning anything else.

4. Guarantee exactly-once materialization

Bind every submission to a trace_id fingerprint and use a conditional write so a retried or rebalanced job can never double-insert. This mirrors the idempotency guarantees used in Cross-Domain Routing Strategies.

Two ways to handle a feature that crosses a partition edge, and why only one is exactly-onceDeterministic ownership assigns each straddling feature to exactly one partition, chosen by a rule such as the partition containing its centroid, and gives neighbouring workers read-only access to a halo region. Processing in both partitions and deduplicating afterwards emits a result from each worker and relies on a deduplication step keyed differently from the join, which is where exactly-once quietly becomes at-least-once. The rows compare replay stability, result count and what happens when the deduplication key and the join key disagree.Deterministic ownerBoth + dedupResults emittedonetwo, then filteredStable across replayyesdepends on timingNeeds a halo readyesnoDedup key mismatchnot possibleduplicate survives

sql
INSERT INTO spatial_join_results (trace_id, partition_key, result_blob, materialized_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (trace_id, partition_key) DO NOTHING;

Verify that replaying the same trace_id yields exactly one row:

sql
SELECT trace_id, COUNT(*) FROM spatial_join_results
GROUP BY trace_id HAVING COUNT(*) > 1;  -- must return zero rows

5. Route invalid payloads to a dead-letter queue

Materialized output adheres to schema_version: v2.1; any geometry that fails validation is diverted rather than corrupting downstream tile servers. Pre-filter heavy candidates with ST_DWithin before ST_Intersection to shrink the compute surface, and gate egress through API Gateway Mapping for GIS Services.

bash
# Inspect what the worker quarantined and why.
kafka-console-consumer --bootstrap-server "$BROKER_BOOTSTRAP" \
  --topic spatial-join-jobs.DLQ --from-beginning --max-messages 20 | \
  jq '{trace_id, partition_key, reject_reason}'

Configuration Reference

Parameter Tier Value Rationale
partitions broker 12 One per spatial domain bbox; caps worker parallelism
acks broker all Zero message loss during broker failover
linger_ms / batch_size broker 5 / 16384 Batches geometry payloads for throughput
concurrency worker_pool 8 Parallel joins per pod; raise to 12 under P3 latency
max_heap_mb worker_pool 4096 Hard cap to prevent OOM on geometry serialization
compression materialization zstd 40–60% smaller egress than Snappy for geometry blobs
schema_version materialization v2.1 Locks the Parquet metadata footprint
idempotency_key materialization trace_id Deduplication key for exactly-once writes
IDEMPOTENCY_TTL_SECONDS env 3600 Retention of the dedup record; align to retention.ms

Common Failure Modes & Fixes

  • Symptom: join latency spikes above the 90s SLA. Root cause: Seq Scan on domain_a_polygons from a missing or fragmented GiST index. Fix: REINDEX INDEX CONCURRENTLY idx_domain_a_geom; during an off-peak window, then re-run ANALYZE domain_a_polygons;.
  • Symptom: worker pods restart with OutOfMemoryError. Root cause: unbounded geometry serialization on a dense partition. Fix: lower concurrency, confirm max_heap_mb is enforced, and pre-filter with ST_DWithin before ST_Intersection.
  • Symptom: consumer lag climbs past 10k messages. Root cause: broker backpressure or partition skew. Fix: add consumer instances and tune max.poll.records; rebalance hot partitions across the domain registry.
  • Symptom: downstream tile server rejects results. Root cause: schema drift from a v2.0 producer writing a v2.1 topic. Fix: roll the producer back to the v2.0 contract, validate geometries with ST_IsValid, and replay from the DLQ.
  • Symptom: index bloat degrades ST_Intersects. Root cause: pg_indexes_size exceeds 60% of pg_total_relation_size. Fix: run pg_repack (or VACUUM FULL in a maintenance window) to reclaim space without a long table lock.

FAQ

Why partition the broker topic by bounding box instead of by feature count?

Bounding-box partitioning keeps every join localized to one domain’s geometry, which prevents cross-partition fan-out and the thread-pool contention that a count-based scheme would reintroduce. It also lets a single hot domain be scaled or throttled independently without disturbing its neighbours.

How is exactly-once delivery preserved when a worker rebalances mid-job?

The ON CONFLICT (trace_id, partition_key) DO NOTHING write makes materialization idempotent, so a job that is re-delivered after a rebalance commits at most one result row. The trace_id is a fingerprint of the request, not a per-attempt id, so retries collapse to the same key.

What belongs in the dead-letter queue versus a retry?

Transient faults — broker timeouts, 5xx from object storage — go through exponential_backoff up to max_retries: 3. Deterministic faults — invalid geometry, schema-version mismatch — are routed straight to the DLQ for a GIS Data Steward to reconcile, because retrying them only burns compute.

Should results be reprojected to EPSG:3857 before materialization?

No. Store and join in EPSG:4326 and reproject to EPSG:3857 only on egress to a web tile consumer. Reprojecting before materialization bakes a presentation CRS into the canonical artifact and breaks downstream analytical engines that expect the contract CRS.

How should the join handle a feature that straddles two bounding-box partitions?

Assign it to exactly one partition deterministically and let the other partition read it as a boundary input. The tempting alternative — processing it in both partitions and deduplicating afterwards — is where exactly-once semantics quietly become at-least-once: the two workers each materialize a result, the deduplication step runs on a different key than the join did, and one copy survives that the other partition also emitted. Choose an owning partition from a deterministic rule such as the partition containing the feature’s centroid or its lowest-ordered corner, so the assignment is identical on every replay, and give each worker read access to the halo region just outside its own extent so the join has the neighbouring geometry it needs without owning it.

Why does bounding-box pre-filtering matter so much when the join predicate is exact anyway?

Because the exact predicate is expensive and the bounding-box test is nearly free, and the ratio between them decides whether the join finishes. ST_Intersects on two complex polygons evaluates edge intersections proportional to their vertex counts; a bounding-box overlap test compares four numbers. PostGIS will use the index-backed bounding-box test automatically when the geometry column is indexed and the query is written to allow it, but a join expressed so that the planner cannot reach the index — a function wrapping the geometry column, a mismatched SRID forcing an on-the-fly transform — silently falls back to evaluating the exact predicate against every candidate pair. The symptom is a join whose runtime scales with the product of both table sizes rather than with the number of genuinely overlapping features, and the fix is almost always to make the index reachable rather than to add workers.