fifteen/worker/src/jobs/compute-routing.ts
2026-03-01 21:59:44 +01:00

217 lines
6.7 KiB
TypeScript

import type { Job } from "bullmq";
import { getSql } from "../db.js";
import type { JobProgress } from "@transportationer/shared";
import { fetchMatrix } from "../valhalla.js";
export type ComputeRoutingData = {
type: "compute-routing";
citySlug: string;
mode: "walking" | "cycling" | "driving";
category: string;
};
/** Number of nearest POI candidates per grid point. */
const K = 6;
/** Grid points per Valhalla matrix call. */
const BATCH_SIZE = 20;
/** Concurrent Valhalla calls within this job. */
const BATCH_CONCURRENCY = 4;
/** Rows per INSERT. */
const INSERT_CHUNK = 2000;
async function asyncPool<T>(
concurrency: number,
items: T[],
fn: (item: T) => Promise<void>,
): Promise<void> {
const queue = [...items];
async function worker(): Promise<void> {
while (queue.length > 0) await fn(queue.shift()!);
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
}
/**
* Fetch Valhalla routing times from every grid point to the K nearest POIs
* in the given category, then persist nearest-per-subcategory into grid_poi_details.
*/
export async function handleComputeRouting(job: Job<ComputeRoutingData>): Promise<void> {
const { citySlug, mode, category } = job.data;
const sql = getSql();
const gridPoints = await Promise.resolve(sql<{ id: string; lat: number; lng: number }[]>`
SELECT id::text AS id, ST_Y(geom) AS lat, ST_X(geom) AS lng
FROM grid_points
WHERE city_slug = ${citySlug}
ORDER BY id
`);
if (gridPoints.length === 0) return;
const [{ count }] = await Promise.resolve(sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM raw_pois
WHERE city_slug = ${citySlug} AND category = ${category}
`);
if (count === 0) return;
// Nearest POI per (gridPointId, subcategory).
const result = new Map<string, Map<string, {
poiId: string;
poiName: string | null;
distM: number;
timeS: number | null;
}>>();
for (const gp of gridPoints) result.set(gp.id, new Map());
const batches: Array<{ id: string; lat: number; lng: number }[]> = [];
for (let i = 0; i < gridPoints.length; i += BATCH_SIZE) {
batches.push(gridPoints.slice(i, i + BATCH_SIZE));
}
let batchesDone = 0;
await asyncPool(BATCH_CONCURRENCY, batches, async (batch) => {
const batchIds = batch.map((gp) => gp.id);
const knnRows = await Promise.resolve(sql<{
grid_point_id: string;
poi_id: string;
poi_lat: number;
poi_lng: number;
poi_name: string | null;
dist_m: number;
subcategory: string;
}[]>`
SELECT
gp.id::text AS grid_point_id,
p.osm_id::text AS poi_id,
ST_Y(p.geom) AS poi_lat,
ST_X(p.geom) AS poi_lng,
p.poi_name,
ST_Distance(gp.geom::geography, p.geom::geography) AS dist_m,
p.subcategory
FROM grid_points gp
CROSS JOIN LATERAL (
SELECT p.osm_id, p.geom, p.subcategory, p.name AS poi_name
FROM raw_pois p
WHERE p.city_slug = ${citySlug} AND p.category = ${category}
ORDER BY gp.geom <-> p.geom
LIMIT ${K}
) p
WHERE gp.id = ANY(${batchIds}::bigint[])
ORDER BY gp.id, dist_m
`);
batchesDone++;
if (knnRows.length === 0) return;
const targetIdx = new Map<string, number>();
const targets: { lat: number; lng: number }[] = [];
for (const row of knnRows) {
if (!targetIdx.has(row.poi_id)) {
targetIdx.set(row.poi_id, targets.length);
targets.push({ lat: row.poi_lat, lng: row.poi_lng });
}
}
const sources = batch.map((gp) => ({ lat: gp.lat, lng: gp.lng }));
let matrix: (number | null)[][];
try {
matrix = await fetchMatrix(sources, targets, mode);
} catch (err) {
console.error(
`[compute-routing] Valhalla failed (${mode}/${category}, batch ${batchesDone}):`,
(err as Error).message,
);
return;
}
type KnnRow = (typeof knnRows)[number];
const gpKnn = new Map<string, KnnRow[]>();
for (const row of knnRows) {
const list = gpKnn.get(row.grid_point_id) ?? [];
list.push(row);
gpKnn.set(row.grid_point_id, list);
}
for (let bi = 0; bi < batch.length; bi++) {
const gp = batch[bi];
const knn = gpKnn.get(gp.id);
if (!knn || knn.length === 0) continue;
const subcatMap = result.get(gp.id)!;
for (const row of knn) {
if (!subcatMap.has(row.subcategory)) {
const idx = targetIdx.get(row.poi_id);
subcatMap.set(row.subcategory, {
poiId: row.poi_id,
poiName: row.poi_name,
distM: row.dist_m,
timeS: idx !== undefined ? (matrix[bi]?.[idx] ?? null) : null,
});
}
}
}
await job.updateProgress({
stage: `Routing ${mode}/${category}`,
pct: Math.round((batchesDone / batches.length) * 100),
message: `Batch ${batchesDone}/${batches.length}`,
} satisfies JobProgress);
});
// Bulk-insert nearest POI per subcategory into grid_poi_details.
const gpIdArr: string[] = [];
const subcatArr: string[] = [];
const poiIdArr: (string | null)[] = [];
const poiNameArr: (string | null)[] = [];
const distArr: (number | null)[] = [];
const timeArr: (number | null)[] = [];
for (const [gpId, subcatMap] of result) {
for (const [subcategory, detail] of subcatMap) {
gpIdArr.push(gpId);
subcatArr.push(subcategory);
poiIdArr.push(detail.poiId);
poiNameArr.push(detail.poiName);
distArr.push(detail.distM);
timeArr.push(detail.timeS);
}
}
for (let i = 0; i < gpIdArr.length; i += INSERT_CHUNK) {
const end = Math.min(i + INSERT_CHUNK, gpIdArr.length);
await Promise.resolve(sql`
INSERT INTO grid_poi_details (
grid_point_id, category, subcategory, travel_mode,
nearest_poi_id, nearest_poi_name, distance_m, travel_time_s
)
SELECT
gp_id::bigint,
${category},
subcat,
${mode},
CASE WHEN poi_id IS NULL THEN NULL ELSE poi_id::bigint END,
poi_name,
dist,
time_s
FROM unnest(
${gpIdArr.slice(i, end)}::text[],
${subcatArr.slice(i, end)}::text[],
${poiIdArr.slice(i, end)}::text[],
${poiNameArr.slice(i, end)}::text[],
${distArr.slice(i, end)}::float8[],
${timeArr.slice(i, end)}::float8[]
) AS t(gp_id, subcat, poi_id, poi_name, dist, time_s)
ON CONFLICT (grid_point_id, category, subcategory, travel_mode)
DO UPDATE SET
nearest_poi_id = EXCLUDED.nearest_poi_id,
nearest_poi_name = EXCLUDED.nearest_poi_name,
distance_m = EXCLUDED.distance_m,
travel_time_s = EXCLUDED.travel_time_s,
computed_at = now()
`);
}
}