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.
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.
# 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:
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.
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:
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.
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.
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:
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.
# 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 Scanondomain_a_polygonsfrom a missing or fragmented GiST index. Fix:REINDEX INDEX CONCURRENTLY idx_domain_a_geom;during an off-peak window, then re-runANALYZE domain_a_polygons;. - Symptom: worker pods restart with
OutOfMemoryError. Root cause: unbounded geometry serialization on a dense partition. Fix: lowerconcurrency, confirmmax_heap_mbis enforced, and pre-filter withST_DWithinbeforeST_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.0producer writing av2.1topic. Fix: roll the producer back to thev2.0contract, validate geometries withST_IsValid, and replay from the DLQ. - Symptom: index bloat degrades
ST_Intersects. Root cause:pg_indexes_sizeexceeds 60% ofpg_total_relation_size. Fix: runpg_repack(orVACUUM FULLin 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.
Related
- Async Execution for Heavy Spatial Queries — parent reference for the broker and worker-pool pattern
- Schema Contracts for Vector/Tile Data — the validation gate every payload must clear first
- API Gateway Mapping for GIS Services — synchronous-fallback routing for degraded requests