routing: a slow route is not a down relay, and the relay takes whole days

A request to the TREK Valhalla that runs out its deadline (a long bike
route) is answered 504 and no longer rests the relay for every user or a
tab for ten minutes; that one day goes to OSRM. Only an unreachable host
or a server error rests it.

A day goes to the relay with up to twenty stops per request and no pause
between pieces; a request that ends up at the public FOSSGIS Valhalla is
cut to ten and paced, as before. The 1.1 s spacing of the server applies
to the public hosts only, not to an admin's own OSRM or Valhalla. The
relay keeps its connections open and asks for maneuvers only, which the
relay contract now accepts.
This commit is contained in:
Maurice
2026-10-30 16:16:09 +01:00
parent a55d4e786f
commit c508bb9758
11 changed files with 505 additions and 76 deletions
@@ -192,6 +192,23 @@ describe('a day where Valhalla routes the days', () => {
expect([traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 1]);
});
it('VDAY-004c: a day the relay ran out of time on goes to OSRM alone, and the next day asks the relay again', async () => {
let slow = true;
const traffic = listen((body) =>
slow
? HttpResponse.json({ error: 'The TREK routing service took too long for this route' }, { status: 504 })
: answered(body)
);
const fallen = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
expect([fallen.distance, fallen.engine]).toEqual([2000, 'osrm']);
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([1, 0, 1]);
slow = false;
const next = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
expect([next.distance, next.engine]).toEqual([4000, 'valhalla']);
expect([traffic.relay.length, traffic.osrm.length]).toEqual([2, 1]);
});
it('VDAY-004b: an own Valhalla that does not answer in time hands the day to OSRM without a second wait', async () => {
choose({ valhalla_base_url: OWN_VALHALLA });
const timeout = AbortSignal.timeout.bind(AbortSignal);
@@ -364,6 +364,7 @@ describe('valhallaRun over a whole day', () => {
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: difficulty, use_ferry: 0 } },
directions_options: { units: 'kilometers' },
directions_type: 'maneuvers',
})
})
@@ -469,6 +470,53 @@ describe('valhallaRun over a whole day', () => {
expect(calls).toBe(3)
})
it('VALHALLA-RUN-009: asks the relay twenty locations at a time with no pause between the pieces', async () => {
const sizes: number[] = []
let fossgis = 0
server.use(
http.post('/api/routing/valhalla/:action', async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}),
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(chain(1)) }),
)
const started = Date.now()
const avoiding = await valhallaRun(stops(14), 'driving', ['toll'])
const long = await valhallaRun(stops(25), 'driving', ['toll'])
expect(avoiding?.legs).toHaveLength(13)
expect(long?.legs).toHaveLength(24)
expect(sizes).toEqual([14, 20, 6])
// The pause between pieces was 1.1 s; the relay and an own Valhalla are not paced.
expect(Date.now() - started).toBeLessThan(1000)
expect(fossgis).toBe(0)
})
it('VALHALLA-RUN-010: a day cut for the relay that the relay cannot answer is cut again in tens for FOSSGIS', async () => {
const relayed: number[] = []
const sizes: number[] = []
server.use(
http.post('/api/routing/valhalla/:action', async ({ request }) => {
relayed.push(((await request.json()) as { locations: unknown[] }).locations.length)
return HttpResponse.json({ error: 'down' }, { status: 502 })
}),
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}),
)
const run = await valhallaRun(stops(14), 'driving', ['toll'])
expect(run?.legs).toHaveLength(13)
expect(relayed).toEqual([14])
// FOSSGIS never saw the piece of fourteen it would have refused with error 150.
expect(sizes).toEqual([10, 5])
})
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
@@ -735,10 +783,34 @@ describe('through the TREK services (routing relay)', () => {
expect(leg?.distance).toBeCloseTo(295700, 0)
expect(relayed).toHaveLength(1)
expect(relayed[0].action).toBe('route')
expect(relayed[0].body).toMatchObject({ costing: 'auto', costing_options: { auto: { use_tolls: 0 } } })
expect(relayed[0].body).toMatchObject({
costing: 'auto',
costing_options: { auto: { use_tolls: 0 } },
directions_type: 'maneuvers',
})
expect(fossgis).toBe(0)
})
it('VALHALLA-RELAY-009: a question the relay ran out of time on has no answer, without resting the relay or asking FOSSGIS', async () => {
let relayCalls = 0
let fossgis = 0
server.use(
http.post(RELAY, () => {
relayCalls++
return HttpResponse.json({ error: 'The TREK routing service took too long for this route' }, { status: 504 })
}),
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
)
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')).resolves.toBeNull()
// Asked once: the relay would only run out of time on it again.
expect([relayCalls, fossgis]).toEqual([1, 0])
expect(valhallaBase()).toBe(TREK_VALHALLA)
server.use(http.post(RELAY, () => { relayCalls++; return HttpResponse.json(answer()) }))
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
expect([relayCalls, fossgis]).toEqual([2, 0])
})
it('VALHALLA-RELAY-002: turns to FOSSGIS without a word where the relay cannot answer, and leaves it alone a while', async () => {
for (const status of [401, 429, 503]) {
resetValhallaTurns()
+56 -20
View File
@@ -70,7 +70,7 @@ const RELAY_MAX_WAIT_MS = 60 * 60_000
/**
* How long a route or a height question may take through the relay or at an own Valhalla.
* The server gives the TREK Valhalla ten seconds; this leaves room for its 502 to arrive
* The server gives the TREK Valhalla ten seconds; this leaves room for its 504 to arrive
* first. Where the relay itself hangs, FOSSGIS answers soon after; where an own Valhalla
* does, OSRM routes the day, rather than the day waiting as long as the browser would.
*/
@@ -115,11 +115,11 @@ export type AvoidClass = RouteAvoidClass
const MAX_LOCATIONS = 10
/**
* Pause between the pieces of a split day, in milliseconds.
* The pace of the public instance, in milliseconds between two questions.
*
* The public instance allows about one request a second per client, and a day long
* enough to be split is exactly the case that would fire two back to back. Matches the
* 1100 ms the road trip rail already leaves between its own days.
* It allows about one request a second per client, and a day long enough to be split is
* exactly the case that would fire two back to back; `valhallaTurn` holds every question
* to it there. Matches the 1100 ms the road trip rail already leaves between its own days.
*/
const CHUNK_PAUSE_MS = 1100
@@ -307,9 +307,9 @@ export async function valhallaRouteAvoiding(
* came back within two metres and under a second of the same day asked whole, because
* the shared point is routed to and from identically.
*
* The pieces go out one after another with a pause, since a day long enough to need
* splitting is exactly the one that would otherwise fire two requests inside a second
* at a host that allows one.
* The pieces go out one after another. Those that reach the public instance wait their
* turn there (`valhallaTurn`), since a day long enough to need splitting is exactly the
* one that would otherwise fire two requests inside a second at a host that allows one.
*/
export async function valhallaRun(
waypoints: Waypoint[],
@@ -372,12 +372,12 @@ interface RunAsk {
}
/**
* Locations one request carries: ten wherever it may end up at the public instance,
* twenty at the TREK Valhalla asked alone and at an own one.
* Locations one request carries: ten at the public instance, twenty at the TREK
* Valhalla and at an own one. A day cut for the relay that the relay then cannot
* answer is cut again for FOSSGIS (`chainRun`).
*/
function locationsPerRequest(base: string, publicFallback: boolean): number {
const mayReachPublic = base === FOSSGIS_VALHALLA || (base === TREK_VALHALLA && publicFallback)
return mayReachPublic ? MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS
function locationsPerRequest(base: string): number {
return base === FOSSGIS_VALHALLA ? MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS
}
async function chainRun(
@@ -389,18 +389,38 @@ async function chainRun(
): Promise<ValhallaRun | ValhallaNoPath | null> {
const base = valhallaBase()
if (!mayAsk(base, ask.publicFallback) || waypoints.length < 2) return null
const size = locationsPerRequest(base)
const run = await chainRunSized(base, waypoints, size, profile, avoid, signal, ask)
// The relay fell over during a day cut for it. It rests now, so the base reads as FOSSGIS
// and the day is asked again there in pieces of ten.
const recut = run === null && base === TREK_VALHALLA && ask.publicFallback && waypoints.length > MAX_LOCATIONS
if (recut && Date.now() < relayRestingUntil && !signal?.aborted) {
return chainRun(waypoints, profile, avoid, signal, ask)
}
return run
}
async function chainRunSized(
base: string,
waypoints: Waypoint[],
size: number,
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal: AbortSignal | undefined,
ask: RunAsk,
): Promise<ValhallaRun | ValhallaNoPath | null> {
const legs: ValhallaLeg[] = []
const chunks = chunkWaypoints(waypoints, locationsPerRequest(base, ask.publicFallback))
for (const [index, chunk] of chunks.entries()) {
// No pause between the pieces: a question that reaches FOSSGIS waits its turn in
// valhallaTurn, and the relay and an own Valhalla are not paced.
for (const chunk of chunkWaypoints(waypoints, size)) {
if (signal?.aborted) return null
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
// Retried per piece rather than per day. The caller retries the whole task, so a
// rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a
// host that allows one request a second.
const request = () => requestRun(base, chunk, profile, avoid, signal, ask)
const run = await askWithRetry(base, request, signal, ask.publicFallback)
// FOSSGIS stands in only for a piece of at most ten; a longer one is cut again instead.
const run = await askWithRetry(base, request, signal, ask.publicFallback && chunk.length <= MAX_LOCATIONS)
// A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap.
@@ -488,6 +508,9 @@ export function resetValhallaTurns(): void {
* relay refusing the body.
*/
async function relayCannotAnswer(response: Response): Promise<boolean> {
// 504: this one question took too long. The relay still answers the next one, and
// FOSSGIS would take as long over this one.
if (response.status === 504) return false
if (response.status === 400) {
// A body that is not JSON cannot be Valhalla's own refusal, so it reads as the relay's.
const data: unknown = await response.clone().json().catch(() => ({}))
@@ -543,7 +566,7 @@ export async function valhallaPost(
const relayed = await askRelay(action, body, signal)
if (relayed) return relayed
}
if (base === TREK_VALHALLA && !publicFallback) return new Response(null, { status: 503 })
if (base === TREK_VALHALLA && (!publicFallback || overPublicLimit(action, body))) return new Response(null, { status: 503 })
const host = base === TREK_VALHALLA ? FOSSGIS_VALHALLA : base
await valhallaTurn(host, signal)
return fetch(`${host}/${action}`, {
@@ -557,6 +580,12 @@ export async function valhallaPost(
})
}
/** Whether a route carries more locations than the public instance takes, so it cannot stand in for the relay. */
function overPublicLimit(action: ValhallaRelayAction, body: object): boolean {
const locations = (body as { locations?: unknown }).locations
return action === 'route' && Array.isArray(locations) && locations.length > MAX_LOCATIONS
}
/**
* Waits out the host's one request a second before the next question is put.
*
@@ -575,7 +604,8 @@ export function valhallaSpacing(signal?: AbortSignal): Promise<void> {
* on a second ask, and asking anyway is the request the memo exists to save. Nor, where
* nothing may stand in for this engine, when the relay now rests or the host used up its
* whole deadline: the second ask would not leave this tab or would most likely wait as
* long again, and OSRM is waiting for the day.
* long again, and OSRM is waiting for the day. Nor when the relay ran out of time on
* it: the relay does not rest for that, so the second ask would wait at the same host.
*/
async function askWithRetry<T>(
base: string,
@@ -587,6 +617,8 @@ async function askWithRetry<T>(
for (let attempt = 0; (!answer || tooSlow(answer)) && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
if (notValhalla.has(base)) break
if (!publicFallback && (tooSlow(answer) || (base === TREK_VALHALLA && Date.now() < relayRestingUntil))) break
// A question the relay ran out of time on would go to the same host again.
if (tooSlow(answer) && base === TREK_VALHALLA) break
await pause(CHUNK_PAUSE_MS, signal)
answer = await ask()
}
@@ -618,6 +650,8 @@ function routeBody(
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
// The maneuvers, where the ferry spans come from, without the narrative nothing reads.
directions_type: 'maneuvers',
}
}
@@ -640,6 +674,8 @@ async function postRoute(
// is the public instance's one-per-second.
if (response.status === 404 || response.status === 405) notValhalla.add(base)
if (response.status === 400 && (await refusesTheWay(response))) return NO_PATH
// The relay's word that this question ran out its deadline, read as one that timed out here.
if (response.status === 504) return TOO_SLOW
return null
}
return await response.json()
@@ -727,7 +763,7 @@ export async function valhallaTraceAttributes(
}
}
/** One request, at most ten locations. */
/** One request, at most ten locations at the public instance and twenty elsewhere. */
async function requestRun(
base: string,
waypoints: Waypoint[],