Detecting vessel rendezvous in milliseconds with geohash cells and Neo4j
The problem with checking every pair of ships
Vessel rendezvous detection sounds like a simple geometry problem until you look at the scale. A maritime anomaly detection engine we built to track this ingests AIS position reports from thousands of vessels, arriving continuously through Kafka. Checking every pair of vessels against every other pair is a quadratic operation, and at that volume it does not run anywhere close to milliseconds. Neither the naive query nor the naive schema scales.
Geohash cells instead of raw coordinates
Instead of comparing raw latitude and longitude, we bucket every position report into a geohash cell at precision 6, which gives cells roughly 1.2 kilometres across. Two vessels can only be close enough to matter for rendezvous detection if they share a cell or sit in adjacent ones. The search space collapses from every vessel against every vessel to every vessel against the handful of vessels in nearby cells. This is a design decision about the granularity this detection task needs, not a claim about GPS accuracy, and it is the single change that made the problem tractable at our ingest volume.
Why a graph, not just spatial SQL
We store vessels and their proximity events as a graph in Neo4j rather than rows in a relational table. A rendezvous is not two vessels near each other once. It is a relationship that persists, repeats or escalates over time, and that kind of relationship lives naturally in a graph. When two vessels share a geohash cell long enough to count as a rendezvous, we write a precomputed ENCOUNTERED edge between their :Vessel nodes, indexed on both vessel identifiers and the timestamp. Later questions, has this pair met before, how often, near which port, run as a Cypher traversal over indexed nodes instead of a join against a growing history table.
Keeping the stream honest
- Backpressure: the Kafka consumer processes AIS messages at the rate the geohash and graph writers can actually keep up with, instead of buffering unbounded messages in memory when a downstream write slows down.
- Idempotent projection: AIS feeds retransmit and duplicate messages, so every position update is written keyed by vessel identifier and message timestamp, and a duplicate updates the same row instead of creating a new one.
- Outbox: when a rendezvous is detected, the event downstream consumers subscribe to is written to an outbox table in the same transaction as the graph edge, then published asynchronously, so a consumer never sees an event for an edge a rollback later removed.
What Spring WebFlux buys us here
The ingestion path runs on Spring WebFlux on Java 21, because the workload is dominated by waiting on I/O, the Kafka consumer, the Neo4j writes, not by CPU-bound computation. A reactive pipeline lets a small number of threads handle a large number of concurrent position streams without one slow write blocking the next message behind it in the queue. This matters more than the framework choice in isolation, because it is what makes the backpressure handling above actually hold under load instead of only on a diagram.
What we would tell a team building something similar
Pick your spatial granularity as a deliberate design decision before touching the schema, do not default to raw coordinates because they feel more precise. Model relationships that persist or repeat as a graph, not as rows in a table you keep joining against itself. And treat message duplication and backpressure as certainties in any streaming pipeline, not edge cases, because at real ingest volumes they show up in the first week, not the first year.