Skip to content

Commit 42073de

Browse files
committed
feat(data-manager): wire live-traffic covered-way-id source + bootstrap way-to-edge map
The live-traffic writer needs /data/traffic/ways_to_edges.json, but getCoveredWayIds was never wired into setupCron, so refreshWaysToEdges was always skipped and the map never generated (writer failed ENOENT). Wire it to fetchCoveredWayIds (parsing way_ids from the same OpenConditions /segments/speed.csv the writer consumes), and bootstrap the map on startup when it is missing rather than only after a graph rebuild.
1 parent a25fec2 commit 42073de

6 files changed

Lines changed: 186 additions & 14 deletions

File tree

services/data-manager/__tests__/cron.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,82 @@ describe("setupCron", () => {
344344
expect(fetchLiveTrafficCsv).not.toHaveBeenCalled();
345345
handles.stop();
346346
});
347+
348+
it("bootstraps the way-to-edge map on startup when it is missing", async () => {
349+
const prev = process.env.DATA_DIR;
350+
// The map path derives from DATA_DIR; a fresh tmp dir has no traffic/ subdir.
351+
process.env.DATA_DIR = dataDir;
352+
try {
353+
const getCoveredWayIds = vi.fn().mockResolvedValue(new Set([123]));
354+
const refreshWaysToEdges = vi.fn().mockResolvedValue({ wayCount: 1, edgeCount: 2 });
355+
const handles = setupCron(
356+
baseOptions({
357+
openConditionsUrl: "http://openconditions-ingest:8080",
358+
ensureTrafficExtract: vi.fn().mockResolvedValue({ built: false }),
359+
getCoveredWayIds,
360+
refreshWaysToEdges,
361+
}),
362+
);
363+
364+
await handles.runTrafficExtractStartupNow();
365+
366+
expect(getCoveredWayIds).toHaveBeenCalledTimes(1);
367+
expect(refreshWaysToEdges).toHaveBeenCalledWith(new Set([123]), expect.anything());
368+
handles.stop();
369+
} finally {
370+
if (prev === undefined) delete process.env.DATA_DIR;
371+
else process.env.DATA_DIR = prev;
372+
}
373+
});
374+
375+
it("does not bootstrap the map on startup when it already exists", async () => {
376+
const prev = process.env.DATA_DIR;
377+
process.env.DATA_DIR = dataDir;
378+
try {
379+
mkdirSync(join(dataDir, "traffic"), { recursive: true });
380+
writeFileSync(join(dataDir, "traffic", "ways_to_edges.json"), "{}");
381+
const refreshWaysToEdges = vi.fn();
382+
const handles = setupCron(
383+
baseOptions({
384+
openConditionsUrl: "http://openconditions-ingest:8080",
385+
ensureTrafficExtract: vi.fn().mockResolvedValue({ built: false }),
386+
getCoveredWayIds: vi.fn().mockResolvedValue(new Set([123])),
387+
refreshWaysToEdges,
388+
}),
389+
);
390+
391+
await handles.runTrafficExtractStartupNow();
392+
393+
expect(refreshWaysToEdges).not.toHaveBeenCalled();
394+
handles.stop();
395+
} finally {
396+
if (prev === undefined) delete process.env.DATA_DIR;
397+
else process.env.DATA_DIR = prev;
398+
}
399+
});
400+
401+
it("skips the startup map bootstrap when no covered-way-id source is configured", async () => {
402+
const prev = process.env.DATA_DIR;
403+
process.env.DATA_DIR = dataDir;
404+
try {
405+
const refreshWaysToEdges = vi.fn();
406+
const handles = setupCron(
407+
baseOptions({
408+
openConditionsUrl: "http://openconditions-ingest:8080",
409+
ensureTrafficExtract: vi.fn().mockResolvedValue({ built: false }),
410+
refreshWaysToEdges,
411+
}),
412+
);
413+
414+
await handles.runTrafficExtractStartupNow();
415+
416+
expect(refreshWaysToEdges).not.toHaveBeenCalled();
417+
handles.stop();
418+
} finally {
419+
if (prev === undefined) delete process.env.DATA_DIR;
420+
else process.env.DATA_DIR = prev;
421+
}
422+
});
347423
});
348424
});
349425

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseCoveredWayIds } from "../jobs/traffic/covered-ways.js";
3+
4+
describe("parseCoveredWayIds", () => {
5+
const HEADER = "way_id,dir,current_kph,free_flow_kph,los";
6+
7+
it("extracts the way_id column, collapsing directions to unique way ids", () => {
8+
const csv = [
9+
HEADER,
10+
"100118219,f,45,59.4,heavy",
11+
"100118219,b,50,59.4,heavy",
12+
"1021132485,f,101,75,free_flow",
13+
].join("\n");
14+
expect(parseCoveredWayIds(csv)).toEqual(new Set([100118219, 1021132485]));
15+
});
16+
17+
it("ignores the header, blank lines, and a trailing newline", () => {
18+
const csv = `${HEADER}\n\n100118219,f,45,59.4,heavy\n\n1021132485,f,101,75,free_flow\n`;
19+
expect(parseCoveredWayIds(csv)).toEqual(new Set([100118219, 1021132485]));
20+
});
21+
22+
it("returns an empty set for a header-only feed", () => {
23+
expect(parseCoveredWayIds(`${HEADER}\n`)).toEqual(new Set());
24+
});
25+
26+
it("skips rows whose way_id is not an integer", () => {
27+
const csv = [HEADER, ",f,45,59.4,heavy", "abc,f,45,59.4,heavy", "42,f,45,59.4,heavy"].join(
28+
"\n",
29+
);
30+
expect(parseCoveredWayIds(csv)).toEqual(new Set([42]));
31+
});
32+
});

services/data-manager/src/cron.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type RefreshWaysToEdgesResult,
2525
refreshWaysToEdges as refreshWaysToEdgesDefault,
2626
type WayEdge,
27+
defaultOutputPath as waysToEdgesMapPath,
2728
} from "./jobs/traffic/ways-to-edges.js";
2829
import {
2930
type WriteLiveTrafficResult,
@@ -165,10 +166,11 @@ export interface CronSetupOptions {
165166
}) => Promise<boolean>;
166167
/**
167168
* Resolves the set of OSM way ids the live-speed writer currently covers.
168-
* No default is wired: until the live-speed writer job supplies this (it
169-
* already fetches the source feed, so it owns the covered-way-id list),
170-
* the guard cron logs and skips the way-to-edge refresh rather than
171-
* writing a map filtered down to nothing.
169+
* The entrypoint wires this to `fetchCoveredWayIds` (the same OpenConditions
170+
* speed feed the writer consumes) when OpenConditions is configured; when
171+
* absent, the startup/guard way-to-edge refresh logs and skips rather than
172+
* writing a map filtered down to nothing — the whole live-traffic chain then
173+
* stays disabled.
172174
*/
173175
getCoveredWayIds?: () => Promise<Set<number>>;
174176
/**
@@ -562,15 +564,6 @@ export function setupCron(options: CronSetupOptions): CronHandles {
562564
const ensureExtract = options.ensureTrafficExtract ?? ensureTrafficExtract;
563565
const checkExtractStale = options.isTrafficExtractStale ?? isTrafficExtractStale;
564566

565-
const runTrafficExtractStartup = async (): Promise<void> => {
566-
try {
567-
const result = await ensureExtract({ logger: log });
568-
log.info("traffic-extract: startup check complete", { built: result.built });
569-
} catch (err) {
570-
log.error("traffic-extract: startup ensure failed", { err: (err as Error).message });
571-
}
572-
};
573-
574567
const refreshWaysToEdges = options.refreshWaysToEdges ?? refreshWaysToEdgesDefault;
575568

576569
// The way_id → GraphId map only changes when the graph rebuilds (a rebuild
@@ -593,6 +586,23 @@ export function setupCron(options: CronSetupOptions): CronHandles {
593586
}
594587
};
595588

589+
const runTrafficExtractStartup = async (): Promise<void> => {
590+
try {
591+
const result = await ensureExtract({ logger: log });
592+
log.info("traffic-extract: startup check complete", { built: result.built });
593+
} catch (err) {
594+
log.error("traffic-extract: startup ensure failed", { err: (err as Error).message });
595+
}
596+
// A build (above, or the guard's rebuild) refreshes the way→edge map, but a
597+
// traffic.tar that already exists from a prior boot leaves the map ungenerated
598+
// on first run — the live writer then can't load it. Bootstrap it here when
599+
// it's missing so the chain works without waiting for the next graph rebuild.
600+
if (options.getCoveredWayIds && !existsSync(waysToEdgesMapPath())) {
601+
log.info("ways-to-edges: map missing at startup, bootstrapping");
602+
await runWaysToEdgesRefresh();
603+
}
604+
};
605+
596606
const runTrafficExtractGuard = async (): Promise<void> => {
597607
try {
598608
const stale = await checkExtractStale({ logger: log });

services/data-manager/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { type PoiSchedulerHandles, setupPoiIngestCron } from "./jobs/poi-ingest/scheduler.js";
1818
import { createPoiSingleFlight } from "./jobs/poi-ingest/single-flight.js";
1919
import { reconcileOrphanedJobs } from "./jobs/reconcile.js";
20+
import { fetchCoveredWayIds } from "./jobs/traffic/covered-ways.js";
2021
import { parseTransitousCountriesEnv } from "./jobs/transitous/internal.js";
2122
import { getSingleFlightController } from "./jobs/transitous/runtime.js";
2223
import { rootLogger } from "./logger.js";
@@ -138,13 +139,20 @@ app
138139
// fully ready before the first scheduled fire.
139140
const store = new StateStore(dataDir);
140141
const countries = parseTransitousCountriesEnv();
142+
// The way→edge refresh restricts `valhalla_ways_to_edges` to the ways the
143+
// live-traffic writer can update. Its covered-way-id source is the same
144+
// OpenConditions speed feed the writer consumes, so it's only wired when
145+
// OpenConditions is configured; otherwise the refresh (and the whole
146+
// live-traffic chain) stays disabled.
147+
const openConditionsUrl = process.env.OPENCONDITIONS_URL?.trim() ?? "";
141148
cronHandles = setupCron({
142149
dataDir,
143150
repoRoot,
144151
countries,
145152
store,
146153
singleFlight,
147154
logger: app.log,
155+
getCoveredWayIds: openConditionsUrl ? () => fetchCoveredWayIds(openConditionsUrl) : undefined,
148156
});
149157

150158
// Ensure the Valhalla traffic.tar extract exists before any job that
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { fetchWithTimeout } from "../transitous/motis-probe.js";
2+
3+
const COVERED_WAYS_FETCH_TIMEOUT_MS = 30_000;
4+
5+
/**
6+
* Parses the set of OSM way ids from an OpenConditions `/segments/speed.csv`
7+
* body (header `way_id,dir,current_kph,free_flow_kph,los`; the `way_id` is the
8+
* first column). Directions collapse to the underlying way id — the way→edge
9+
* map `refreshWaysToEdges` builds is keyed by way id, and `valhalla_ways_to_edges`
10+
* emits every directed edge for a way regardless of the sensor's direction.
11+
*/
12+
export function parseCoveredWayIds(csv: string): Set<number> {
13+
const ids = new Set<number>();
14+
const lines = csv.split("\n");
15+
// Skip the header row; a trailing newline yields an empty final entry.
16+
for (let i = 1; i < lines.length; i++) {
17+
const line = lines[i]?.trim();
18+
if (!line) continue;
19+
const comma = line.indexOf(",");
20+
const field = comma === -1 ? line : line.slice(0, comma);
21+
// Number("") is 0, so an empty way_id column would slip through Number.isInteger.
22+
if (field === "") continue;
23+
const wayId = Number(field);
24+
if (Number.isInteger(wayId)) ids.add(wayId);
25+
}
26+
return ids;
27+
}
28+
29+
/**
30+
* Resolves the covered-way-id list `refreshWaysToEdges` needs by fetching the
31+
* same `${openConditionsUrl}/segments/speed.csv` feed the live-traffic writer
32+
* consumes, then extracting its `way_id` column. Wired into `setupCron` as
33+
* `getCoveredWayIds` so the way→edge refresh restricts `valhalla_ways_to_edges`
34+
* to the ways the writer can actually update, instead of skipping the refresh.
35+
*/
36+
export async function fetchCoveredWayIds(openConditionsUrl: string): Promise<Set<number>> {
37+
const res = await fetchWithTimeout(
38+
`${openConditionsUrl}/segments/speed.csv`,
39+
COVERED_WAYS_FETCH_TIMEOUT_MS,
40+
);
41+
if (!res.ok) {
42+
throw new Error(`covered-ways: OpenConditions speed feed responded ${res.status}`);
43+
}
44+
return parseCoveredWayIds(await res.text());
45+
}

services/data-manager/src/jobs/traffic/ways-to-edges.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ async function* defaultReadWayEdgesLines(container: string, path: string): Async
8787
}
8888
}
8989

90-
function defaultOutputPath(): string {
90+
/** Path of the JSON way→edge map `refreshWaysToEdges` writes and `loadWaysToEdges` reads. */
91+
export function defaultOutputPath(): string {
9192
return join(process.env.DATA_DIR ?? "/data", "traffic", "ways_to_edges.json");
9293
}
9394

0 commit comments

Comments
 (0)