mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
fix(tours): name the Overpass kinds of a search along a tour at once while every mirror is down (PLN-02, CW-05)
While the public Overpass mirrors do not answer, every search along a hike with viewpoints, huts or drinking water waited out the whole twelve second deadline only to name those kinds as not searchable. Once every mirror has failed the search along a line, and no other search along a line was answered in the meantime, the next searches for three minutes leave Overpass alone and name its kinds at once, next to the places the index found; a later stretch of a long line that was already being searched does not wait either. A stretch answered shortly before keeps its places from the cache during the pause. After the three minutes the next search asks again for real, and any answer ends the pause. The log holds one line per pause with the time it ends, next to the line of the search that failed. The explore buttons of the map and the other Overpass lookups are unchanged.
This commit is contained in:
@@ -10,7 +10,9 @@
|
||||
* Overpass at the same time, one query near each stretch of the whole line,
|
||||
* with a short deadline. A source that fails is logged and its kinds named in
|
||||
* `failed`, so the caller can say what could not be searched instead of
|
||||
* reporting nothing found.
|
||||
* reporting nothing found. Once every Overpass endpoint failed, the next
|
||||
* searches for a few minutes name its kinds at once instead of waiting out the
|
||||
* deadline each time, while a stretch still cached keeps its places.
|
||||
*
|
||||
* MapsService.poisAlong delegates here and hands in whether the index is
|
||||
* switched on, which is a deployment property it owns.
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
ownedByAsked,
|
||||
} from './maps-index.helpers';
|
||||
import type { OverpassPoi } from './maps.helpers';
|
||||
import { OsmClient } from './providers/osm.client';
|
||||
import { OsmClient, OverpassCoolingDown } from './providers/osm.client';
|
||||
import { trekPlacesNearby } from './trek-places.client';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { LatLng } from '@trek/shared/roadtrip';
|
||||
@@ -89,7 +91,9 @@ function oncePerPlace(pois: OverpassPoi[]): OverpassPoi[] {
|
||||
|
||||
/**
|
||||
* Requests a few at a time, each batch after the last, so no more than a
|
||||
* batch is ever in flight; a failed one leaves the others.
|
||||
* batch is ever in flight; a failed one leaves the others. A stretch refused
|
||||
* while Overpass cools down is missed like any other, but makes no error of
|
||||
* its own: the client logs the cooldown once, not with every search.
|
||||
*/
|
||||
async function inBatches(requests: Array<() => Promise<Page>>, parallel: number): Promise<Batched> {
|
||||
const batches = Array.from({ length: Math.ceil(requests.length / parallel) }, (_, index) =>
|
||||
@@ -100,13 +104,14 @@ async function inBatches(requests: Array<() => Promise<Page>>, parallel: number)
|
||||
Promise.resolve([]),
|
||||
);
|
||||
const pages = settled.flatMap((entry) => (entry.status === 'fulfilled' ? [entry.value] : []));
|
||||
const misses = settled.flatMap((entry) => (entry.status === 'rejected' ? [reason(entry.reason)] : []));
|
||||
const misses = settled.flatMap((entry): unknown[] => (entry.status === 'rejected' ? [entry.reason] : []));
|
||||
const reported = misses.filter((miss) => !(miss instanceof OverpassCoolingDown));
|
||||
return {
|
||||
pois: pages.flatMap((page) => page.pois),
|
||||
truncated: pages.some((page) => page.truncated),
|
||||
answered: pages.length,
|
||||
missed: misses.length,
|
||||
error: misses.length ? `${misses.length} of ${settled.length} requests failed: ${misses[0]}` : null,
|
||||
error: reported.length ? `${misses.length} of ${settled.length} requests failed: ${reason(reported[0])}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,7 +172,9 @@ export class PoisAlongResolver {
|
||||
|
||||
/**
|
||||
* The other kinds near each stretch of the whole line, one Overpass query
|
||||
* per stretch; nothing to ask is nothing found.
|
||||
* per stretch; nothing to ask is nothing found. While the client cools down
|
||||
* after every Overpass endpoint failed, a stretch it holds no cached answer
|
||||
* for is refused at once, so its kinds are unavailable without the wait.
|
||||
*/
|
||||
private async askOverpass(kinds: string[], query: PoisAlongQuery): Promise<SourceAnswer> {
|
||||
if (!kinds.length) return NOTHING;
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
*
|
||||
* The POI cache and the mirror list are module state on purpose, frozen at load
|
||||
* the way they were in the service ("frozen on purpose", see
|
||||
* src/app-config/README.md), so every instance shares one cache.
|
||||
* src/app-config/README.md), so every instance shares one cache. The pause the
|
||||
* search along a line takes after every Overpass endpoint failed it is the
|
||||
* instance's own: MapsModule provides one client per process.
|
||||
*
|
||||
* Never imports MapsService: providers sit below the orchestrator
|
||||
* (lint:boundaries holds that).
|
||||
*/
|
||||
import { logWarn } from '../../audit/audit-log.logger';
|
||||
import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client';
|
||||
import { OVERPASS_ALONG_QUERY_TIMEOUT_S, aroundPath, overpassAlongQuery } from '../maps-along.helpers';
|
||||
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
|
||||
@@ -317,8 +320,34 @@ function readPois(
|
||||
*/
|
||||
const OVERPASS_ALONG_TIMEOUT_MS = (OVERPASS_ALONG_QUERY_TIMEOUT_S + 2) * 1000;
|
||||
|
||||
/**
|
||||
* How long the search along a line leaves Overpass alone once every endpoint
|
||||
* failed it. While the public mirrors do not answer, each search would wait
|
||||
* out the whole deadline only to name the same kinds unavailable; after the
|
||||
* pause the next search asks again for real.
|
||||
*/
|
||||
export const OVERPASS_ALONG_COOLDOWN_MS = 3 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A stretch of a search along a line refused during the cooldown: nothing was
|
||||
* asked, and the cooldown is logged once rather than with every search.
|
||||
*/
|
||||
export class OverpassCoolingDown extends Error {
|
||||
constructor() {
|
||||
super('Overpass unreachable, not retrying yet');
|
||||
this.name = 'OverpassCoolingDown';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OsmClient {
|
||||
/** Until when the search along a line leaves Overpass alone after every endpoint failed it; 0 while it asks. */
|
||||
private overpassAlongCooldownUntil = 0;
|
||||
/** When an along query was last answered, so a failure that settles after an answer starts no cooldown. */
|
||||
private overpassAlongAnsweredAt = 0;
|
||||
/** Whether this cooldown's first skip was logged, so the log holds one line per cooldown. */
|
||||
private overpassAlongSkipLogged = false;
|
||||
|
||||
// ── Nominatim search ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -721,7 +750,9 @@ export class OsmClient {
|
||||
* the cap is spent on the corridor rather than on the box. Unnamed taps,
|
||||
* huts and the like stay, because they are what a hike looks for, while an
|
||||
* unnamed garden or cafe is dropped as on the explore pill. A short deadline
|
||||
* of its own; failing throws as the explore pill's search does.
|
||||
* of its own; failing throws as the explore pill's search does. During the
|
||||
* cooldown after every endpoint failed, a stretch still cached is answered
|
||||
* from the cache and any other throws OverpassCoolingDown at once.
|
||||
*/
|
||||
async searchOverpassPoisAlong(
|
||||
category: string,
|
||||
@@ -739,8 +770,10 @@ export class OsmClient {
|
||||
const cacheKey = `along|${kinds}|${osmLang}|${path.radiusM}|${points}|${cap}`;
|
||||
const cached = cachedPoiAnswer(cacheKey);
|
||||
if (cached) return cached;
|
||||
// Neither a new search nor a later stretch of one under way waits for mirrors that just failed.
|
||||
if (this.skipsOverpassAlong()) throw new OverpassCoolingDown();
|
||||
|
||||
const elements = await overpassFetch(overpassAlongQuery(match.filters, path, cap), OVERPASS_ALONG_TIMEOUT_MS);
|
||||
const elements = await this.fetchOverpassAlong(overpassAlongQuery(match.filters, path, cap));
|
||||
const pois = readPois(elements, match, osmLang, KEPT_UNNAMED_ALONG);
|
||||
const value: PoiSearchResult = {
|
||||
pois: pois.slice(0, cap),
|
||||
@@ -751,4 +784,42 @@ export class OsmClient {
|
||||
cachePoiAnswer(cacheKey, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the search along a line leaves Overpass alone right now, because
|
||||
* every endpoint failed it within the last OVERPASS_ALONG_COOLDOWN_MS. Its
|
||||
* kinds are then unavailable at once instead of after the whole deadline.
|
||||
* The first skip of each cooldown is logged.
|
||||
*/
|
||||
skipsOverpassAlong(): boolean {
|
||||
if (Date.now() >= this.overpassAlongCooldownUntil) return false;
|
||||
if (!this.overpassAlongSkipLogged) {
|
||||
this.overpassAlongSkipLogged = true;
|
||||
const until = new Date(this.overpassAlongCooldownUntil).toISOString();
|
||||
logWarn(`[Maps] OpenStreetMap along a line: every Overpass endpoint failed, so it is not asked until ${until}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The along query on the mirrors. An answer ends the cooldown; every
|
||||
* endpoint failing starts it, unless another along query was answered while
|
||||
* this one was in flight: Overpass is up then, and only this query failed.
|
||||
*/
|
||||
private async fetchOverpassAlong(query: string): Promise<OverpassPoiElement[]> {
|
||||
const askedAt = Date.now();
|
||||
try {
|
||||
const elements = await overpassFetch(query, OVERPASS_ALONG_TIMEOUT_MS);
|
||||
this.overpassAlongAnsweredAt = Date.now();
|
||||
this.overpassAlongCooldownUntil = 0;
|
||||
return elements;
|
||||
} catch (err) {
|
||||
// overpassFetch throws only once every mirror has failed.
|
||||
if (this.overpassAlongAnsweredAt < askedAt) {
|
||||
this.overpassAlongCooldownUntil = Date.now() + OVERPASS_ALONG_COOLDOWN_MS;
|
||||
this.overpassAlongSkipLogged = false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* MAPS-ALONG-010..019: places near a line (PLN-02), from the index in circles
|
||||
* along it and from Overpass, in stretches of the whole line, for the kinds
|
||||
* the index does not know.
|
||||
* the index does not know. After every Overpass mirror failed, its kinds are
|
||||
* named unavailable at once for a cooldown instead of after its deadline,
|
||||
* unless Overpass answered meanwhile or the stretch is still cached.
|
||||
*
|
||||
* The index is asked through a stub here, Overpass through a spy on the OSM
|
||||
* client: these cases are about who is asked for what, and how a source that
|
||||
@@ -10,6 +12,7 @@
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import { ownedByAsked } from '../../../src/nest/maps/maps-index.helpers';
|
||||
import { OVERPASS_ALONG_COOLDOWN_MS } from '../../../src/nest/maps/providers/osm.client';
|
||||
import { INDEX_MAX_CATEGORY_CHARS, INDEX_MAX_LIMIT } from '../../../src/nest/maps/trek-places.client';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
@@ -103,12 +106,19 @@ function make(enabled = true) {
|
||||
const overpass = vi
|
||||
.spyOn(osm, 'searchOverpassPoisAlong')
|
||||
.mockResolvedValue({ pois: [SPRING], source: 'openstreetmap', truncated: false, clamped: false });
|
||||
return { svc, overpass };
|
||||
return { svc, overpass, osm };
|
||||
}
|
||||
|
||||
/** The lines logged as warnings. */
|
||||
const mapsWarnings = () => logWarn.mock.calls.map((call) => String(call[0]));
|
||||
|
||||
/** What every Overpass mirror answers while it is down. */
|
||||
const DOWN = { ok: false, status: 504, json: async () => ({}) };
|
||||
|
||||
/** How many different Overpass queries a stubbed fetch was sent; each goes to every mirror. */
|
||||
const queriesSent = (fetchMock: { mock: { calls: unknown[][] } }) =>
|
||||
new Set(fetchMock.mock.calls.map((call) => decodeURIComponent(String((call[1] as RequestInit).body)))).size;
|
||||
|
||||
/** A line due north from 47°N 11°E, `degrees` of latitude long: about 111 km each. */
|
||||
const north = (degrees: number) => [
|
||||
{ lat: 47, lng: 11 },
|
||||
@@ -359,6 +369,55 @@ describe('MapsService.poisAlong', () => {
|
||||
expect(seen.most).toBe(3);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-019c: after every Overpass mirror failed, the next searches name its kinds at once for a while', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-19T10:00:00Z') });
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// Mirrors that never answer: each request waits out its deadline and is aborted.
|
||||
const fetchMock = vi.fn(
|
||||
(_url: unknown, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(new Error('aborted')))),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
mockNearby.mockResolvedValue([row('c-1', 'Kaffeehaus', CAFE)]);
|
||||
const { svc, overpass } = make();
|
||||
overpass.mockRestore();
|
||||
try {
|
||||
const first = svc.poisAlong(['cafe', 'viewpoint', 'drinking_water'], LINE, 500, 'de', 30);
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(await first).toMatchObject({ failed: ['viewpoint', 'drinking_water'], partial: false });
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
|
||||
// Without waiting for any deadline: nothing is asked, the index places stay, the kinds are named.
|
||||
const again = await svc.poisAlong(['cafe', 'viewpoint', 'drinking_water'], LINE, 500, 'de', 30);
|
||||
const hike = await svc.poisAlong(['viewpoint', 'shelter'], north(0.1), 500, 'de', 30);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(asked);
|
||||
expect(again).toMatchObject({ failed: ['viewpoint', 'drinking_water'], partial: false });
|
||||
expect(again.pois.map((poi) => poi.name)).toEqual(['Kaffeehaus']);
|
||||
expect(hike.failed).toEqual(['viewpoint', 'shelter']);
|
||||
// The search that waited out its twelve seconds says why; the skips after it are one line for the
|
||||
// whole cooldown, three minutes from that failure.
|
||||
expect(mapsWarnings()).toEqual([
|
||||
expect.stringContaining('[Maps] OpenStreetMap along a line: viewpoint, drinking_water: 1 of 1 requests failed'),
|
||||
expect.stringContaining('every Overpass endpoint failed, so it is not asked until 2026-10-19T10:03:12.000Z'),
|
||||
]);
|
||||
|
||||
// After the cooldown it is asked again for real, and an answer ends the pause for good.
|
||||
vi.advanceTimersByTime(OVERPASS_ALONG_COOLDOWN_MS);
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ elements: [] }) });
|
||||
const later = await svc.poisAlong(['viewpoint'], LINE, 500, 'de', 30);
|
||||
expect(later.failed).toEqual([]);
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(asked);
|
||||
const answered = fetchMock.mock.calls.length;
|
||||
await svc.poisAlong(['shelter'], LINE, 500, 'de', 30);
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(answered);
|
||||
expect(mapsWarnings()).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-019b: one stretch failing keeps the places of the others and marks the answer partial', async () => {
|
||||
const { svc, overpass } = make();
|
||||
overpass.mockRejectedValueOnce(new Error('rate limited'));
|
||||
@@ -372,4 +431,95 @@ describe('MapsService.poisAlong', () => {
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-019d: a stretch failing after the others answered starts no cooldown, so the rest of a long line is asked', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// The first of five stretches fails on every mirror, after the two asked beside it answered.
|
||||
const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => {
|
||||
const first = decodeURIComponent(String(init?.body)).includes('47.00000,11.00000');
|
||||
await new Promise((resolve) => setTimeout(resolve, first ? 30 : 5));
|
||||
return first ? DOWN : { ok: true, json: async () => ({ elements: [] }) };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { svc, overpass, osm } = make();
|
||||
overpass.mockRestore();
|
||||
try {
|
||||
const out = await svc.poisAlong(['viewpoint'], north(2), 500, 'de', 30);
|
||||
|
||||
expect(out).toMatchObject({ failed: [], partial: true });
|
||||
// Stretches 4 and 5 are asked as well: Overpass answered while the first one was failing.
|
||||
expect(queriesSent(fetchMock)).toBe(5);
|
||||
expect(osm.skipsOverpassAlong()).toBe(false);
|
||||
expect(mapsWarnings()).toEqual([
|
||||
expect.stringContaining('OpenStreetMap along a line: viewpoint: 1 of 5 requests failed: Could not reach any'),
|
||||
]);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-019e: a line answered before the cooldown keeps its Overpass places during it, from the cache', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-19T11:00:00Z'), toFake: ['Date'] });
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const view = { type: 'node', id: 77, lat: 47.01, lon: 12, tags: { tourism: 'viewpoint', name: 'Aussicht' } };
|
||||
const fetchMock = vi
|
||||
.fn<(url: unknown, init?: RequestInit) => Promise<unknown>>()
|
||||
.mockResolvedValue({ ok: true, json: async () => ({ elements: [view] }) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { svc, overpass } = make();
|
||||
overpass.mockRestore();
|
||||
const at = (lng: number) => [
|
||||
{ lat: 47, lng },
|
||||
{ lat: 47.0269, lng },
|
||||
];
|
||||
try {
|
||||
const first = await svc.poisAlong(['viewpoint'], at(12), 500, 'de', 30);
|
||||
expect(first.pois.map((poi) => poi.name)).toEqual(['Aussicht']);
|
||||
|
||||
// A second later every mirror fails another line, and the cooldown starts.
|
||||
vi.setSystemTime(new Date('2026-10-19T11:00:01Z'));
|
||||
fetchMock.mockResolvedValue(DOWN);
|
||||
expect((await svc.poisAlong(['viewpoint'], at(12.5), 500, 'de', 30)).failed).toEqual(['viewpoint']);
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
|
||||
// A minute on, the first line is answered from the cache as before; a line not asked yet is named at once.
|
||||
vi.setSystemTime(new Date('2026-10-19T11:01:01Z'));
|
||||
const again = await svc.poisAlong(['viewpoint'], at(12), 500, 'de', 30);
|
||||
expect(again).toMatchObject({ failed: [], partial: false });
|
||||
expect(again.pois.map((poi) => poi.name)).toEqual(['Aussicht']);
|
||||
expect((await svc.poisAlong(['viewpoint'], at(13), 500, 'de', 30)).failed).toEqual(['viewpoint']);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(asked);
|
||||
expect(mapsWarnings()).toEqual([
|
||||
expect.stringContaining('OpenStreetMap along a line: viewpoint: 1 of 1 requests failed: Could not reach any'),
|
||||
expect.stringContaining('every Overpass endpoint failed, so it is not asked until 2026-10-19T11:03:01.000Z'),
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-019f: the stretches a cooldown leaves out are counted, and the log names why the first ones failed', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const fetchMock = vi.fn(async (_url: unknown, _init?: RequestInit) => DOWN);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { svc, overpass } = make();
|
||||
overpass.mockRestore();
|
||||
try {
|
||||
const out = await svc.poisAlong(['shelter'], north(2), 500, 'de', 30);
|
||||
|
||||
expect(out).toMatchObject({ pois: [], failed: ['shelter'], partial: false });
|
||||
// Stretches 4 and 5 are not sent: every mirror had just failed the first three.
|
||||
expect(queriesSent(fetchMock)).toBe(3);
|
||||
expect(mapsWarnings()).toEqual([
|
||||
expect.stringContaining('every Overpass endpoint failed, so it is not asked until'),
|
||||
expect.stringContaining('OpenStreetMap along a line: shelter: 5 of 5 requests failed: Could not reach any'),
|
||||
]);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
/**
|
||||
* MAPS-ALONG-020..026: the Overpass query near a line (PLN-02). One query for
|
||||
* MAPS-ALONG-020..029: the Overpass query near a line (PLN-02). One query for
|
||||
* every kind the index does not know, the candidates of the line's box kept
|
||||
* within reach of the line itself, and unnamed taps and huts kept, because
|
||||
* they are what a hike looks for, while an unnamed garden or cafe is not. The
|
||||
* network is a stubbed global fetch.
|
||||
* they are what a hike looks for, while an unnamed garden or cafe is not.
|
||||
* After every mirror failed, the next queries are refused at once for a
|
||||
* cooldown, unless Overpass answered meanwhile or the answer is still cached.
|
||||
* The network is a stubbed global fetch.
|
||||
*/
|
||||
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import {
|
||||
OVERPASS_ALONG_COOLDOWN_MS,
|
||||
OsmClient,
|
||||
OverpassCoolingDown,
|
||||
} from '../../../src/nest/maps/providers/osm.client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const osm = new OsmClient();
|
||||
const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() }));
|
||||
// The skip after every mirror failed is logged once per cooldown; the logger itself writes to trek.log.
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/audit/audit-log.logger')>()),
|
||||
logWarn,
|
||||
}));
|
||||
|
||||
/** A client of its own per case: the cooldown after every mirror failed is the instance's state. */
|
||||
let osm: OsmClient;
|
||||
|
||||
/** A line of two points, a little apart per case, so no case is answered from another's cache. */
|
||||
const line = (n: number) => [
|
||||
@@ -19,11 +33,19 @@ const line = (n: number) => [
|
||||
|
||||
const answer = (elements: unknown[]) => ({ ok: true, json: async () => ({ elements }) });
|
||||
|
||||
/** What every mirror answers while Overpass is down. */
|
||||
const down = () => ({ ok: false, status: 504, json: async () => ({}) });
|
||||
|
||||
/** The line a skip writes, once per cooldown, before the time it ends. */
|
||||
const SKIP_LOGGED = '[Maps] OpenStreetMap along a line: every Overpass endpoint failed, so it is not asked until';
|
||||
|
||||
/** The query of a fetch call, read back the way Overpass reads the form. */
|
||||
const queryOf = (call: unknown[] | undefined) =>
|
||||
decodeURIComponent(String((call?.[1] as RequestInit | undefined)?.body ?? '')).replace(/^data=/, '');
|
||||
|
||||
beforeEach(() => {
|
||||
osm = new OsmClient();
|
||||
logWarn.mockClear();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
@@ -93,11 +115,105 @@ describe('OsmClient.searchOverpassPoisAlong', () => {
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-023: every mirror down throws, so the caller can name the kinds as unavailable', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 504, json: async () => ({}) }));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(down()));
|
||||
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(3), 500)).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-027: after every mirror failed it is not asked for a cooldown, logged once, then asked again', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(down());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.useFakeTimers({ now: new Date('2026-10-19T10:00:00Z'), toFake: ['Date'] });
|
||||
try {
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(7), 500)).rejects.toMatchObject({ status: 502 });
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
expect(asked).toBeGreaterThan(0);
|
||||
|
||||
// Inside the cooldown no line and no kind is asked, and the skip is logged once.
|
||||
vi.setSystemTime(new Date(Date.now() + OVERPASS_ALONG_COOLDOWN_MS - 1));
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(8), 500)).rejects.toThrow('not retrying yet');
|
||||
await expect(osm.searchOverpassPoisAlong('drinking_water', line(7), 500)).rejects.toThrow('not retrying yet');
|
||||
expect(osm.skipsOverpassAlong()).toBe(true);
|
||||
// An unknown kind is still refused as one.
|
||||
await expect(osm.searchOverpassPoisAlong('unicorns', line(7), 500)).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(asked);
|
||||
expect(logWarn).toHaveBeenCalledTimes(1);
|
||||
expect(logWarn).toHaveBeenCalledWith(`${SKIP_LOGGED} 2026-10-19T10:03:00.000Z`);
|
||||
|
||||
// Three minutes on it is asked again for real; still down, it starts a new cooldown with a line of its own.
|
||||
vi.setSystemTime(new Date('2026-10-19T10:03:00Z'));
|
||||
expect(osm.skipsOverpassAlong()).toBe(false);
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(8), 500)).rejects.toMatchObject({ status: 502 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2 * asked);
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(8), 500)).rejects.toThrow('not retrying yet');
|
||||
expect(logWarn).toHaveBeenCalledTimes(2);
|
||||
expect(logWarn).toHaveBeenLastCalledWith(`${SKIP_LOGGED} 2026-10-19T10:06:00.000Z`);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-028: an answer ends the cooldown, so the next search asks at once', async () => {
|
||||
// The first line's query fails on every mirror at once, the second one's answers a moment later.
|
||||
const fetchMock = vi.fn(async (url: unknown, init?: RequestInit) => {
|
||||
if (queryOf([url, init]).includes('47.49000')) return down();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return answer([]);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await Promise.all([
|
||||
expect(osm.searchOverpassPoisAlong('viewpoint', line(9), 500)).rejects.toMatchObject({ status: 502 }),
|
||||
expect(osm.searchOverpassPoisAlong('viewpoint', line(11), 500)).resolves.toMatchObject({ pois: [] }),
|
||||
]);
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
|
||||
expect(osm.skipsOverpassAlong()).toBe(false);
|
||||
await osm.searchOverpassPoisAlong('viewpoint', line(13), 500);
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(asked);
|
||||
expect(logWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-029: a query failing after another was answered starts no cooldown, one sent after the answer does', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-19T10:00:00Z') });
|
||||
// The query along line(15) is answered after a second, every other one fails on every mirror after three.
|
||||
const fetchMock = vi.fn(async (url: unknown, init?: RequestInit) => {
|
||||
const answers = queryOf([url, init]).includes('47.55000,11.10000');
|
||||
await new Promise((resolve) => setTimeout(resolve, answers ? 1000 : 3000));
|
||||
return answers ? answer([]) : down();
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const answered = osm.searchOverpassPoisAlong('viewpoint', line(15), 500);
|
||||
const failed = expect(osm.searchOverpassPoisAlong('viewpoint', line(17), 500)).rejects.toMatchObject({
|
||||
status: 502,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await expect(answered).resolves.toMatchObject({ pois: [] });
|
||||
await failed;
|
||||
// Overpass answered while the failing query was in flight: it is up, and the next search asks.
|
||||
expect(osm.skipsOverpassAlong()).toBe(false);
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
const again = expect(osm.searchOverpassPoisAlong('viewpoint', line(17), 500)).rejects.toMatchObject({
|
||||
status: 502,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await again;
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(asked);
|
||||
|
||||
// That query was sent after the last answer and failed: now the cooldown starts.
|
||||
expect(osm.skipsOverpassAlong()).toBe(true);
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(19), 500)).rejects.toBeInstanceOf(OverpassCoolingDown);
|
||||
// A line still cached is answered from the cache during it.
|
||||
const sent = fetchMock.mock.calls.length;
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(15), 500)).resolves.toMatchObject({ pois: [] });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(sent);
|
||||
expect(logWarn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-024b: an answer older than five minutes is asked again, and the oldest of a full cache goes', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(answer([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
Reference in New Issue
Block a user