fix(maps): keep a slow Photon from holding back the suggestions

The suggestions waited for Photon beside the index for up to its full
2.5 s deadline on every keystroke, and their misses and spent allowance
paused Photon for the search and the reverse lookup as well.

Once the index has answered, Photon now gets 300 ms more and is then
called off; with nothing from the index it is still waited for. The
keystroke question has a shorter deadline and its own tally of misses and
pauses, a question called off by the list counts as no miss, and a pause
of the search still holds the suggestions back.

Refs #2877
This commit is contained in:
Maurice
2026-10-29 15:11:39 +01:00
parent 64f370fd0e
commit 28a577df5e
6 changed files with 194 additions and 50 deletions
@@ -351,6 +351,22 @@ export function mergeSuggestions(fromIndex: Suggestion[], fromPhoton: Suggestion
return interleave(fromIndex, extra, limit);
}
/** How long Photon's suggestions may still take once the index has answered. */
export const PHOTON_GRACE_MS = 300;
/** What `pending` answers within `ms`, or null when it takes longer than that. */
export async function answerWithin<T>(pending: Promise<T | null>, ms: number): Promise<T | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
const late = new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), ms);
});
try {
return await Promise.race([pending, late]);
} finally {
clearTimeout(timer);
}
}
/**
* The details record of a place picked out of the index, with what
* OpenStreetMap knows about the same building filled in.
+21 -16
View File
@@ -5,6 +5,7 @@ import type { GeoLane } from '../geo/nominatim.client';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import type { ApiKeySource } from '../settings/instance-api-keys';
import {
answerWithin,
belonging,
indexPoiAnswer,
indexPoiPlan,
@@ -12,6 +13,7 @@ import {
indexSuggestions,
mergeIndexPages,
mergeSuggestions,
PHOTON_GRACE_MS,
} from './maps-index.helpers';
import { NEARBY_DEFAULT_LIMIT, NEARBY_DEFAULT_RADIUS_M, nearbyCacheKey, nearestFirst } from './maps-nearby.helpers';
import { MapsUrlResolver, type ResolvedMapsUrl } from './maps-url.resolver';
@@ -557,22 +559,25 @@ export class MapsService {
// Photon beside them (#2877). The layer is a slice of OpenStreetMap, and
// a place missing from it showed up only once the search was sent.
// Photon is built for this keystroke and answers null when the services
// are off, so the pair costs the slower one and an install without
// Photon reads exactly as before. Each side swallows its own failure, so
// one that is down does not throw away what the other found.
const [found, photon] = await Promise.all([
trekPlacesSearch(input, {
lat: boxCentre?.lat,
lng: boxCentre?.lng,
limit: 8,
sources: 'index,osm',
lang,
}).catch((err: unknown) => {
console.warn('TREK Places autocomplete failed, falling back:', (err as Error).message);
return [];
}),
this.osm.autocompletePhoton(input, lang, boxCentre),
]);
// are off, so an install without Photon reads exactly as before. Each
// side swallows its own failure, so one that is down does not throw away
// what the other found. Photon is the extra here, so once the index has
// answered it gets a moment more and is then dropped: a slow Photon must
// not hold back what the index already has.
const typing = new AbortController();
const asked = this.osm.autocompletePhoton(input, lang, boxCentre, typing.signal);
const found = await trekPlacesSearch(input, {
lat: boxCentre?.lat,
lng: boxCentre?.lng,
limit: 8,
sources: 'index,osm',
lang,
}).catch((err: unknown) => {
console.warn('TREK Places autocomplete failed, falling back:', (err as Error).message);
return [];
});
const photon = found.length > 0 ? await answerWithin(asked, PHOTON_GRACE_MS) : await asked;
typing.abort();
const suggestions = mergeSuggestions(indexSuggestions(found, lang), photon ?? []);
if (suggestions.length > 0) {
return { suggestions, source: found.length > 0 ? 'trek-places' : 'openstreetmap' };
+4 -3
View File
@@ -529,10 +529,11 @@ export class OsmClient {
/**
* Suggestions from Photon, which is built for search-as-you-type, or null when
* it is not in use or did not answer. Never Nominatim in its place: its usage
* policy rules out autocomplete.
* policy rules out autocomplete. Aborting `signal` drops a question nobody
* waits for any more.
*/
async autocompletePhoton(input: string, lang?: string, bias?: { lat: number; lng: number }) {
const found = await this.photon.search(input, { lang, bias });
async autocompletePhoton(input: string, lang?: string, bias?: { lat: number; lng: number }, signal?: AbortSignal) {
const found = await this.photon.suggest(input, { lang, bias, signal });
return found?.map(asSuggestion).filter((row) => row !== null) ?? null;
}
+60 -28
View File
@@ -14,6 +14,12 @@
* A spent allowance says itself how long to wait (Retry-After), and that wait
* is kept instead.
*
* The suggestions asked on every keystroke keep a tally of their own. They
* come far more often than a search, under a shorter deadline, and many are
* dropped unanswered once the next keystroke is in, so their misses and their
* spent allowance pause only the suggestions. A pause of the search holds the
* suggestions back too: a host the search cannot reach is not asked per letter.
*
* The key comes from serviceIdentitySlot, as it does for the Places request
* (trek-places.fetch.ts), so the maps domain reads the installation identity
* one way and needs no import of ServiceIdentityModule; the slot holds the
@@ -33,6 +39,8 @@ import type { ConfigType } from '@nestjs/config';
/** Short, because Nominatim still has to fit behind it inside the browser's wait. */
const TIMEOUT_MS = 2500;
/** The suggestion list is waiting on the index beside it, and the next keystroke is near. */
const SUGGEST_TIMEOUT_MS = 1500;
/** Ten hits are a few kB. */
const MAX_BYTES = 512_000;
const SEARCH_LIMIT = 10;
@@ -51,22 +59,33 @@ interface PhotonSearchOptions {
bias?: { lat: number; lng: number };
}
/** The misses in a row and the pause of one kind of question. */
interface Tally {
/** What answers instead while Photon does not. */
fallback: string;
misses: number;
pausedUntil: number;
}
@Injectable()
export class PhotonClient {
private misses = 0;
private pausedUntil = 0;
private readonly lookups: Tally = { fallback: 'asking Nominatim', misses: 0, pausedUntil: 0 };
private readonly suggestions: Tally = { fallback: 'suggesting from the index', misses: 0, pausedUntil: 0 };
constructor(@Inject(servicesConfig.KEY) private readonly config: ConfigType<typeof servicesConfig>) {}
/** Places matching the query, best first, or null when Nominatim has to answer. */
search(query: string, opts: PhotonSearchOptions = {}): Promise<PhotonFeature[] | null> {
const params = new URLSearchParams({ q: query, limit: String(SEARCH_LIMIT), lang: photonLang(opts.lang) });
if (opts.bias && Number.isFinite(opts.bias.lat) && Number.isFinite(opts.bias.lng)) {
params.set('lat', String(opts.bias.lat));
params.set('lon', String(opts.bias.lng));
params.set('zoom', String(BIAS_ZOOM));
}
return this.ask('api', params);
return this.ask('api', searchParams(query, opts), this.lookups);
}
/**
* Places for the suggestion list, or null when it goes without them. A caller
* that stops waiting aborts `signal`, and that counts as no miss.
*/
suggest(query: string, opts: PhotonSearchOptions & { signal?: AbortSignal } = {}): Promise<PhotonFeature[] | null> {
if (Date.now() < this.lookups.pausedUntil) return Promise.resolve(null);
return this.ask('api', searchParams(query, opts), this.suggestions, SUGGEST_TIMEOUT_MS, opts.signal);
}
/**
@@ -76,15 +95,18 @@ export class PhotonClient {
reverse(lat: number, lng: number, lang?: string, timeoutMs?: number): Promise<PhotonFeature[] | null> {
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return Promise.resolve(null);
const params = new URLSearchParams({ lat: String(lat), lon: String(lng), limit: '1', lang: photonLang(lang) });
return this.ask('reverse', params, timeoutMs ? Math.min(TIMEOUT_MS, timeoutMs / 2) : TIMEOUT_MS);
const deadline = timeoutMs ? Math.min(TIMEOUT_MS, timeoutMs / 2) : TIMEOUT_MS;
return this.ask('reverse', params, this.lookups, deadline);
}
private async ask(
path: 'api' | 'reverse',
params: URLSearchParams,
tally: Tally,
timeoutMs = TIMEOUT_MS,
signal?: AbortSignal,
): Promise<PhotonFeature[] | null> {
if (!this.mayAsk()) return null;
if (!this.mayAsk(tally)) return null;
const url = new URL(`${this.config.photonUrl}/${path}`);
url.search = params.toString();
const identity = serviceIdentitySlot.get();
@@ -95,13 +117,13 @@ export class PhotonClient {
headers: { Accept: 'application/json', ...headers },
// The key goes to this host alone, never on to wherever it points.
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs),
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
});
identity.observe(url, res);
const wait = res.status === 429 ? retryAfterMs(res.headers.get('retry-after')) : null;
if (wait !== null) {
await res.body?.cancel();
this.pauseFor(wait);
this.pauseFor(tally, wait);
return null;
}
if (!res.ok) {
@@ -109,37 +131,47 @@ export class PhotonClient {
throw new Error(`answered ${res.status}`);
}
const { features } = photonAnswerSchema.parse(await capResponse(res, MAX_BYTES).json());
this.misses = 0;
tally.misses = 0;
return features;
} catch (err) {
this.noteMiss(err);
if (!signal?.aborted) this.noteMiss(tally, err);
return null;
}
}
/** Off with the services, beside an operator's own Nominatim, and during the pause. */
private mayAsk(): boolean {
private mayAsk(tally: Tally): boolean {
if (this.config.mode === 'off' || readEnv().integrations.nominatimOwn) return false;
return Date.now() >= this.pausedUntil;
return Date.now() >= tally.pausedUntil;
}
private noteMiss(err: unknown): void {
private noteMiss(tally: Tally, err: unknown): void {
const reason = err instanceof Error ? err.message : String(err);
this.misses += 1;
if (this.misses < MISSES_BEFORE_PAUSE) {
logDebug(`[Photon] ${reason}; asking Nominatim instead`);
tally.misses += 1;
if (tally.misses < MISSES_BEFORE_PAUSE) {
logDebug(`[Photon] ${reason}; ${tally.fallback} instead`);
return;
}
this.pausedUntil = Date.now() + PAUSE_MS;
tally.pausedUntil = Date.now() + PAUSE_MS;
// One more miss after the pause starts the next one.
this.misses = MISSES_BEFORE_PAUSE - 1;
logWarn(`[Photon] ${reason}; asking Nominatim alone for a minute`);
tally.misses = MISSES_BEFORE_PAUSE - 1;
logWarn(`[Photon] ${reason}; ${tally.fallback} alone for a minute`);
}
private pauseFor(ms: number): void {
private pauseFor(tally: Tally, ms: number): void {
const wait = Math.min(ms, MAX_PAUSE_MS);
this.pausedUntil = Date.now() + wait;
this.misses = MISSES_BEFORE_PAUSE - 1;
logWarn(`[Photon] allowance spent; asking Nominatim alone for ${Math.ceil(wait / 1000)} s`);
tally.pausedUntil = Date.now() + wait;
tally.misses = MISSES_BEFORE_PAUSE - 1;
logWarn(`[Photon] allowance spent; ${tally.fallback} alone for ${Math.ceil(wait / 1000)} s`);
}
}
function searchParams(query: string, opts: PhotonSearchOptions): URLSearchParams {
const params = new URLSearchParams({ q: query, limit: String(SEARCH_LIMIT), lang: photonLang(opts.lang) });
if (opts.bias && Number.isFinite(opts.bias.lat) && Number.isFinite(opts.bias.lng)) {
params.set('lat', String(opts.bias.lat));
params.set('lon', String(opts.bias.lng));
params.set('zoom', String(BIAS_ZOOM));
}
return params;
}