diff --git a/server/src/nest/maps/community-overlay.ts b/server/src/nest/maps/community-overlay.ts index 4c70a3c4b..d6f3c47d2 100644 --- a/server/src/nest/maps/community-overlay.ts +++ b/server/src/nest/maps/community-overlay.ts @@ -32,13 +32,21 @@ export function withCommunityValues( return out; } +/** Records built from TREK's own details layer, which carry the community answer already. */ +const asked = new WeakSet(); + +export function markCommunityAsked(details: Record): Record { + asked.add(details); + return details; +} + /** OSM details as Overpass and Photon gave them, with the community's values for the same element. */ export async function osmDetailsWithCommunity( placeId: string, details: Record | null, ): Promise | null> { const key = communityKeyOf(placeId); - if (!details || !key) return details; + if (!details || !key || asked.has(details)) return details; const published = await trekCommunityByKeys([key]); return withCommunityValues(details, published[key]); } diff --git a/server/src/nest/maps/providers/osm.client.ts b/server/src/nest/maps/providers/osm.client.ts index a32ac7728..39df89ce6 100644 --- a/server/src/nest/maps/providers/osm.client.ts +++ b/server/src/nest/maps/providers/osm.client.ts @@ -26,6 +26,7 @@ */ import { logWarn } from '../../audit/audit-log.logger'; import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client'; +import { markCommunityAsked, withCommunityValues } from '../community-overlay'; import { OVERPASS_ALONG_QUERY_TIMEOUT_S, aroundPath, overpassAlongQuery } from '../maps-along.helpers'; import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers'; import { @@ -47,6 +48,7 @@ import { } from '../maps.helpers'; import { overpassSelector, tagsMatchFilter } from '../osm-filter.helpers'; import { OverpassBusy, hedged, lastAnsweredFirst, retryAfterMs, soonestRetryMs } from '../overpass-hedge.helpers'; +import { asOverpassElement, trekOsmElement } from '../trek-osm-element'; import { photonElementDetails, photonIdentity, @@ -732,10 +734,17 @@ export class OsmClient { const [osmType, osmId] = placeId.split(':'); // Where Photon is the geocoder, Overpass places the element and Photon // names it; Nominatim is asked only when Overpass could not place it. - const located = this.photon.inUse() ? await this.fetchOverpassDetails(osmType, osmId, true) : undefined; + // TREK's own copy of OSM first: one read on the Places API instead of the + // public Overpass servers, which take seconds and are often down. + const own = this.photon.inUse() ? await trekOsmElement(osmType, osmId) : null; + const located = own + ? asOverpassElement(own) + : this.photon.inUse() + ? await this.fetchOverpassDetails(osmType, osmId, true) + : undefined; if (located) { const answer = await photonElementDetails(this.photon, located, { osmType, osmId, placeId, lang }); - if (answer) return answer; + if (answer) return own ? markCommunityAsked(withCommunityValues(answer, own)) : answer; } // buildOsmDetails never yields name/address/coordinates — Nominatim is // the source for those (Overpass contributes the tag-derived rest). diff --git a/server/src/nest/maps/trek-osm-element.ts b/server/src/nest/maps/trek-osm-element.ts new file mode 100644 index 000000000..a7ed354ca --- /dev/null +++ b/server/src/nest/maps/trek-osm-element.ts @@ -0,0 +1,37 @@ +import { getJson, type TrekCommunityValues } from './trek-places.client'; + +/** An OSM element as the TREK Places API's details layer answers it: the tags, the point, and the community values. */ +interface TrekOsmElement extends TrekCommunityValues { + name: string; + lat: number; + lng: number; + tags: Record; +} + +/** The details layer answers in well under a second or not at all; Overpass is the fallback, not this. */ +const TIMEOUT_MS = 3_000; + +/** + * An element's detail tags from TREK's own copy of OpenStreetMap + * (`/v1/osm/{type}/{id}`), or null when the layer does not hold it, the index + * is switched off or the service does not answer. Then Overpass is asked, as + * before. + */ +export async function trekOsmElement(osmType: string, osmId: string): Promise { + try { + const body = await getJson<{ place?: TrekOsmElement }>( + `/v1/osm/${encodeURIComponent(osmType)}/${encodeURIComponent(osmId)}`, + {}, + { timeoutMs: TIMEOUT_MS }, + ); + const place = body.place; + return place && typeof place.lat === 'number' && typeof place.lng === 'number' && place.tags ? place : null; + } catch { + return null; + } +} + +/** The element as Overpass would have placed it (the shape of OverpassElement), for the code that reads Overpass answers. */ +export function asOverpassElement(element: TrekOsmElement): { tags: Record; lat: number; lon: number } { + return { tags: element.tags, lat: element.lat, lon: element.lng }; +} diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts index fb50adec0..d78e25214 100644 --- a/server/src/nest/maps/trek-places.client.ts +++ b/server/src/nest/maps/trek-places.client.ts @@ -241,7 +241,7 @@ function statusError(status: number): Error & { status: number } { return err; } -async function getJson( +export async function getJson( path: string, params: Record, opts: { maxBytes?: number; timeoutMs?: number; breaker?: Breaker } = {}, diff --git a/server/tests/unit/nest/maps.community-overlay.test.ts b/server/tests/unit/nest/maps.community-overlay.test.ts index f987028fc..7368c4c17 100644 --- a/server/tests/unit/nest/maps.community-overlay.test.ts +++ b/server/tests/unit/nest/maps.community-overlay.test.ts @@ -73,3 +73,12 @@ describe('community values on OSM details', () => { expect(await osmDetailsWithCommunity('node:7', DETAILS)).toEqual(DETAILS); }); }); + +describe('records that carry the community answer already', () => { + it('COMOV-006: are not asked about a second time', async () => { + const { markCommunityAsked } = await import('../../../src/nest/maps/community-overlay'); + const record = markCommunityAsked({ ...DETAILS }); + expect(await osmDetailsWithCommunity('relation:3389505', record)).toBe(record); + expect(ask).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/osm.photon.test.ts b/server/tests/unit/nest/osm.photon.test.ts index fff14a4c4..3564478bb 100644 --- a/server/tests/unit/nest/osm.photon.test.ts +++ b/server/tests/unit/nest/osm.photon.test.ts @@ -34,6 +34,7 @@ const NOMINATIM_REVERSE = { let identity: FakeServiceIdentity; let photonReply: () => Response; let overpassReply: () => Response; +let placesReply: (url: URL) => Response; let osm: OsmClient; const hosts: string[] = []; @@ -55,6 +56,8 @@ beforeEach(() => { }), { status: 200, headers: { 'content-type': 'application/json' } }, ); + // TREK's own details layer holds nothing unless a test says so. + placesReply = () => new Response(JSON.stringify({ detail: 'no such element' }), { status: 404 }); hosts.length = 0; vi.stubEnv('NOMINATIM_URL', ''); vi.stubGlobal( @@ -64,6 +67,7 @@ beforeEach(() => { hosts.push(url.hostname); if (url.hostname === 'photon.liketrek.com') return photonReply(); if (url.hostname === 'overpass-api.de') return overpassReply(); + if (url.hostname === 'places.liketrek.com') return placesReply(url); const body = url.pathname.endsWith('/reverse') ? NOMINATIM_REVERSE : [NOMINATIM_GATE]; return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); }), @@ -292,7 +296,37 @@ it('OSM-PHOTON-009: a lookup by id is Overpass for the tags and the place, Photo osm_id: 'way:518071791', website: 'https://example.org', }); - expect(hosts).toEqual(['overpass-api.de', 'photon.liketrek.com']); + // TREK's details layer is asked first; it does not hold this element here. + expect(hosts).toEqual(['places.liketrek.com', 'overpass-api.de', 'photon.liketrek.com']); +}); + +it("OSM-PHOTON-021: an element in TREK's own details layer asks no Overpass, and carries the community values", async () => { + placesReply = (url) => { + expect(url.pathname).toBe('/v1/osm/way/518071791'); + return new Response( + JSON.stringify({ + place: { + name: 'Brandenburger Tor', + lat: 52.5163, + lng: 13.3777, + tags: { website: 'https://example.org', opening_hours: '24/7' }, + contact: { phone: '+49 30 1234567' }, + community: { version: 2, fields: ['phone'] }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }; + const details = await osm.elementDetails('way:518071791', 'en'); + expect(details).toMatchObject({ + website: 'https://example.org', + opening_hours: '24/7', + phone: '+49 30 1234567', + community_version: 2, + lat: 52.5163, + lng: 13.3777, + }); + expect(hosts).toEqual(['places.liketrek.com', 'photon.liketrek.com']); }); it('OSM-PHOTON-018: Nominatim names the element only when Overpass cannot place it, or without the TREK services', async () => {