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.

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.

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.