perf: batch county-crossing detection into one query per activity

geo::crossings_between issued one DB round trip per consecutive GPS
sample pair (tens of thousands per multi-hour activity), even though
almost every segment stays inside a single county and produces no
crossing at all.

geo::crossings_for_track batches all segments of a track into a
single query via UNNEST ... WITH ORDINALITY. The per-segment geometry
logic (endpoint county lookup via the GiST index, boundary
intersection, ST_LineLocatePoint for the fractional position) is
unchanged, it now just runs once per activity instead of once per
segment.
This commit is contained in:
Claude 2026-08-13 02:09:10 +00:00 committed by Jonas Rabenstein
commit 5a49624435
2 changed files with 73 additions and 46 deletions

View file

@ -12,43 +12,62 @@ pub fn interpolate_time(a: DateTime<Utc>, b: DateTime<Utc>, fraction: f64) -> Da
a + Duration::milliseconds((duration_ms as f64 * fraction).round() as i64)
}
/// Detects all county crossings between two consecutive GPS samples.
/// Detects all county crossings along an entire GPS track in a single
/// database round trip.
///
/// The geometry itself is handled by PostGIS. Because the endpoint counties
/// are used to constrain the query, the GiST geometry index remains useful.
/// ST_LineLocatePoint gives the position of the intersection on the original
/// track segment; that fraction is then used for millisecond timestamp
/// interpolation.
pub async fn crossings_between(
/// This used to run once per consecutive point pair, i.e. one round trip
/// per GPS sample (tens of thousands for a multi-hour activity), each
/// paying full network/connection overhead for what is usually a no-op
/// (most segments stay inside a single county). This version batches every
/// segment of the track into one query via `UNNEST ... WITH ORDINALITY`,
/// so the per-segment geometry logic is unchanged (endpoint county lookup
/// via the GiST index, boundary intersection, `ST_LineLocatePoint` for the
/// fractional position) but it now runs once per activity instead of once
/// per segment.
pub async fn crossings_for_track(
db: &PgPool,
a: &TrackPoint,
b: &TrackPoint,
points: &[TrackPoint],
) -> Result<Vec<DetectedCrossing>> {
if points.len() < 2 {
return Ok(Vec::new());
}
let segment_count = points.len() - 1;
let mut lon1 = Vec::with_capacity(segment_count);
let mut lat1 = Vec::with_capacity(segment_count);
let mut lon2 = Vec::with_capacity(segment_count);
let mut lat2 = Vec::with_capacity(segment_count);
for pair in points.windows(2) {
lon1.push(pair[0].lon);
lat1.push(pair[0].lat);
lon2.push(pair[1].lon);
lat2.push(pair[1].lat);
}
let rows = sqlx::query(
r#"
WITH points AS (
WITH segments AS (
SELECT
ST_SetSRID(
ST_MakePoint($1, $2),
4326
) AS p1,
ST_SetSRID(
ST_MakePoint($3, $4),
4326
) AS p2
ordinality AS idx,
ST_SetSRID(ST_MakePoint(lon1, lat1), 4326) AS p1,
ST_SetSRID(ST_MakePoint(lon2, lat2), 4326) AS p2
FROM UNNEST($1::float8[], $2::float8[], $3::float8[], $4::float8[])
WITH ORDINALITY AS t(lon1, lat1, lon2, lat2, ordinality)
),
track AS (
SELECT
idx,
p1,
p2,
ST_MakeLine(p1, p2) AS line
FROM points
FROM segments
),
endpoint_counties AS (
SELECT
t.idx,
a.id AS from_id,
b.id AS to_id,
t.line
@ -65,6 +84,7 @@ pub async fn crossings_between(
intersections AS (
SELECT
e.idx,
e.from_id,
e.to_id,
e.line,
@ -86,6 +106,7 @@ pub async fn crossings_between(
)
SELECT
idx,
from_id,
to_id,
@ -102,13 +123,13 @@ pub async fn crossings_between(
WHERE
ST_Dimension(intersection) = 0
ORDER BY fraction
ORDER BY idx, fraction
"#,
)
.bind(a.lon)
.bind(a.lat)
.bind(b.lon)
.bind(b.lat)
.bind(&lon1)
.bind(&lat1)
.bind(&lon2)
.bind(&lat2)
.fetch_all(db)
.await?;
@ -125,6 +146,14 @@ pub async fn crossings_between(
continue;
}
// idx is the 1-based ordinality of the segment within the
// batched UNNEST, i.e. segment i is (points[i - 1], points[i]).
let idx: i64 = row.try_get("idx")?;
let segment = (idx - 1) as usize;
let a = &points[segment];
let b = &points[segment + 1];
let from_county_id: i64 = row.try_get("from_id")?;
let to_county_id: i64 = row.try_get("to_id")?;

View file

@ -1031,27 +1031,25 @@ pub async fn process_activity_with_client(
let mut crossing_count = 0usize;
for pair in points.windows(2) {
let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?;
let crossings = geo::crossings_for_track(&state.db, &points).await?;
for crossing in crossings {
let interval = IntervalsClient::matching_interval(&activity, crossing.track_index);
for crossing in crossings {
let interval = IntervalsClient::matching_interval(&activity, crossing.track_index);
db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?;
db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?;
crossing_count += 1;
crossing_count += 1;
tracing::info!(
activity_id = %activity_id,
time = %crossing.crossing_time,
from = crossing.from_county_id,
to = crossing.to_county_id,
track_index = crossing.track_index,
lat = crossing.lat,
lon = crossing.lon,
"county crossing detected"
);
}
tracing::info!(
activity_id = %activity_id,
time = %crossing.crossing_time,
from = crossing.from_county_id,
to = crossing.to_county_id,
track_index = crossing.track_index,
lat = crossing.lat,
lon = crossing.lon,
"county crossing detected"
);
}
db::mark_activity_processed(&mut tx, activity_db_id).await?;