feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge

This commit is contained in:
Maurice
2026-10-14 16:21:30 +02:00
parent a9882a6415
commit 160bc89e78
126 changed files with 5003 additions and 1153 deletions
@@ -0,0 +1,20 @@
import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { FerrataTilesRepository } from '../repositories/FerrataTiles.repository';
export class FerrataTiles {
[EntityRepositoryType]?: FerrataTilesRepository;
[PrimaryKeyProp]?: 'tile_key';
tile_key!: string;
payload!: string;
fetched_at!: number;
}
export const FerrataTilesSchema = defineEntity({
class: FerrataTiles,
repository: () => FerrataTilesRepository,
properties: {
tile_key: p.text().primary(),
payload: p.text(),
fetched_at: p.integer(),
},
});
+2
View File
@@ -31,6 +31,7 @@ import { DocumentConnectionsSchema } from './DocumentConnections.entity';
import { DocumentProviderFieldsSchema } from './DocumentProviderFields.entity';
import { DocumentProvidersSchema } from './DocumentProviders.entity';
import { DocumentSyncItemsSchema } from './DocumentSyncItems.entity';
import { FerrataTilesSchema } from './FerrataTiles.entity';
import { FileLinksSchema } from './FileLinks.entity';
import { GoogleApiUsageSchema } from './GoogleApiUsage.entity';
import { GooglePlacePhotoMetaSchema } from './GooglePlacePhotoMeta.entity';
@@ -165,6 +166,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
DocumentProviderFieldsSchema,
DocumentProvidersSchema,
DocumentSyncItemsSchema,
FerrataTilesSchema,
FileLinksSchema,
GoogleApiUsageSchema,
GooglePlacePhotoMetaSchema,
+18
View File
@@ -1,4 +1,5 @@
import type { JsonColumn } from '../utils/json-column';
import { FERRATAS_RESULT_CAP, ferrataSchema, type Ferrata } from '@trek/shared';
import { z } from 'zod';
@@ -106,3 +107,20 @@ export const BUDGET_ITEM_TICKET: JsonColumn<{ items: { price?: string | number;
.loose(),
fallback: () => null,
};
/** What one cell of the via ferrata grid holds: its ferratas, and whether the Overpass answer they came from was cut. */
export interface FerrataTilePayload {
ferratas: Ferrata[];
truncated: boolean;
}
/**
* One via ferrata grid cell, as Overpass answered it. Checked against the wire
* contract, so a stored row can go out as it is. A broken row reads as a cell
* never fetched (null), and the next request asks Overpass for it again.
*/
export const FERRATA_TILE_PAYLOAD: JsonColumn<FerrataTilePayload | null> = {
column: 'ferrata_tiles.payload',
schema: z.object({ ferratas: z.array(ferrataSchema).max(FERRATAS_RESULT_CAP), truncated: z.boolean() }).nullable(),
fallback: () => null,
};
@@ -0,0 +1,8 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
export interface FerrataTilesTable {
tile_key: string;
payload: string;
fetched_at: number;
}
+2
View File
@@ -34,6 +34,7 @@ import type { DocumentConnectionsTable } from './DocumentConnections.table';
import type { DocumentProviderFieldsTable } from './DocumentProviderFields.table';
import type { DocumentProvidersTable } from './DocumentProviders.table';
import type { DocumentSyncItemsTable } from './DocumentSyncItems.table';
import type { FerrataTilesTable } from './FerrataTiles.table';
import type { FileLinksTable } from './FileLinks.table';
import type { GoogleApiUsageTable } from './GoogleApiUsage.table';
import type { GooglePlacePhotoMetaTable } from './GooglePlacePhotoMeta.table';
@@ -174,6 +175,7 @@ export interface DB {
document_provider_fields: DocumentProviderFieldsTable;
document_providers: DocumentProvidersTable;
document_sync_items: DocumentSyncItemsTable;
ferrata_tiles: FerrataTilesTable;
file_links: FileLinksTable;
google_api_usage: GoogleApiUsageTable;
google_place_photo_meta: GooglePlacePhotoMetaTable;
@@ -0,0 +1,27 @@
import { Migration } from '@mikro-orm/migrations';
/**
* `ferrata_tiles` keeps what Overpass answered for one cell of the via
* ferrata grid (0.05 degrees a side), so a restart does not send every map
* pan back to the public mirrors.
*
* `tile_key` names the cell by its grid indices (`"<lat index>,<lng index>"`),
* `payload` is the JSON of the cell's ferratas and whether the answer was cut,
* and `fetched_at` is the epoch of that answer in seconds (not ms, so it fits
* the 32-bit integer this column is on Postgres). Nothing references the
* table and nothing deletes from it: an old cell is served and refreshed, not
* dropped.
*/
export class Migration20200101043200_via_ferrata_tiles_outlive_a_restart extends Migration {
override name = 'Migration20200101043200_via_ferrata_tiles_outlive_a_restart';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS ferrata_tiles (
tile_key TEXT PRIMARY KEY NOT NULL,
payload TEXT NOT NULL,
fetched_at INTEGER NOT NULL
)
`);
}
}
@@ -0,0 +1,30 @@
import type { FerrataTiles } from '../entities/FerrataTiles.entity';
import { TrekRepository } from './_shared/trek-repository';
export interface FerrataTileRow {
tile_key: string;
payload: string;
/** When Overpass answered for the cell, as an epoch in seconds. */
fetched_at: number;
}
export class FerrataTilesRepository extends TrekRepository<FerrataTiles> {
/** The stored cells among `keys`, in no particular order; a key never stored is simply absent. */
async findByKeys(keys: readonly string[]): Promise<FerrataTileRow[]> {
if (keys.length === 0) return [];
const rows = await this.find({ tile_key: { $in: [...keys] } }, { fields: ['tile_key', 'payload', 'fetched_at'] });
return rows.map((row) => ({ tile_key: row.tile_key, payload: row.payload, fetched_at: row.fetched_at }));
}
/**
* Writes every cell, replacing the payload and fetch time of one already
* stored. The caller wraps it in a transaction, so a batch lands whole.
*/
async upsertTiles(rows: readonly FerrataTileRow[]): Promise<void> {
if (rows.length === 0) return;
await this.upsertMany(
rows.map((row) => ({ ...row })),
{ onConflictFields: ['tile_key'], onConflictAction: 'merge' },
);
}
}
+109 -8
View File
@@ -1,9 +1,15 @@
import { FERRATAS_RESULT_CAP, type Ferrata, type FerrataBbox, type FerrataGrade } from '@trek/shared';
import {
FERRATAS_RESULT_CAP,
ferrataTileBlockKey,
type Ferrata,
type FerrataBbox,
type FerrataGrade,
} from '@trek/shared';
import { z } from 'zod';
/**
* Pure pieces of the via ferrata lookup: the Overpass query, the cache key, the
* Pure pieces of the via ferrata lookup: the Overpass query, the tile grid, the
* grade parser and the mapping from Overpass elements to the wire rows. Kept
* free of I/O so the service and its tests share them.
*/
@@ -11,7 +17,7 @@ import { z } from 'zod';
/** How long Overpass itself may work on one query, in seconds (`[timeout:N]`). */
export const FERRATA_QUERY_TIMEOUT_S = 15;
/** The grid the box is snapped outwards to, so nearby requests share one cache entry. */
/** The grid the box is snapped outwards to; one cell of it is the unit the answers are stored by. */
const GRID_DEG = 0.05;
const GRADE_RE = /^([0-6])([+-])?$/;
@@ -37,9 +43,8 @@ const snapUp = (v: number) => Math.ceil(v / GRID_DEG - 1e-9) * GRID_DEG;
const fixed = (v: number) => Number(v.toFixed(2));
/**
* The box grown outwards to the 0.05 degree grid and clamped to the globe.
* The query asks for the snapped box, so a cache entry always covers every
* request that maps onto it.
* The box grown outwards to the 0.05 degree grid and clamped to the globe,
* so it is made of whole cells.
*/
export function snapFerrataBbox(bbox: FerrataBbox): FerrataBbox {
return {
@@ -50,8 +55,104 @@ export function snapFerrataBbox(bbox: FerrataBbox): FerrataBbox {
};
}
export function ferrataCacheKey(snapped: FerrataBbox): string {
return `${snapped.south},${snapped.west},${snapped.north},${snapped.east}`;
/**
* The grid cells a box covers, as tile keys (`"<lat index>,<lng index>"`, the
* cell's south-west corner divided by the grid), south to north and west to
* east. The box is snapped outwards first, so every cell is whole. `null` when
* the box covers more than `max` cells: the caller refuses it rather than
* walking a grid it never meant to ask for.
*/
export function ferrataTileKeysFor(bbox: FerrataBbox, max: number): string[] | null {
const snapped = snapFerrataBbox(bbox);
const lat0 = Math.round(snapped.south / GRID_DEG);
const lat1 = Math.round(snapped.north / GRID_DEG);
const lng0 = Math.round(snapped.west / GRID_DEG);
const lng1 = Math.round(snapped.east / GRID_DEG);
if ((lat1 - lat0) * (lng1 - lng0) > max) return null;
const keys: string[] = [];
for (let lat = lat0; lat < lat1; lat += 1) {
for (let lng = lng0; lng < lng1; lng += 1) keys.push(tileKey(lat, lng));
}
return keys;
}
const tileKey = (lat: number, lng: number) => `${lat},${lng}`;
function tileIndices(key: string): [number, number] {
const parts = key.split(',').map(Number);
const lat = parts[0] ?? Number.NaN;
const lng = parts[1] ?? Number.NaN;
if (parts.length !== 2 || !Number.isInteger(lat) || !Number.isInteger(lng)) {
throw new Error(`Not a ferrata tile key: ${key}`);
}
return [lat, lng];
}
/** The one box around every given cell, which is what a batch asks Overpass for. */
export function ferrataTilesBbox(keys: readonly string[]): FerrataBbox {
if (keys.length === 0) throw new Error('No ferrata tiles to bound');
let south = Infinity;
let west = Infinity;
let north = -Infinity;
let east = -Infinity;
for (const key of keys) {
const [lat, lng] = tileIndices(key);
south = Math.min(south, lat);
west = Math.min(west, lng);
north = Math.max(north, lat + 1);
east = Math.max(east, lng + 1);
}
return {
south: fixed(Math.max(-90, south * GRID_DEG)),
west: fixed(Math.max(-180, west * GRID_DEG)),
north: fixed(Math.min(90, north * GRID_DEG)),
east: fixed(Math.min(180, east * GRID_DEG)),
};
}
/**
* The cells grouped into the grid-aligned blocks of FERRATA_TILES_PER_REQUEST_SIDE
* cells a side that one Overpass query covers (the blocks the client asks in),
* each block's cells in the order given.
*/
export function ferrataTileBlocks(keys: readonly string[]): string[][] {
const blocks = new Map<string, string[]>();
for (const key of keys) {
const [lat, lng] = tileIndices(key);
const block = ferrataTileBlockKey(lat, lng);
const cells = blocks.get(block);
if (cells) cells.push(key);
else blocks.set(block, [key]);
}
return [...blocks.values()];
}
/** The grid rows (or columns) a coordinate lies in: two when it sits on the line between them. */
function gridIndices(value: number): number[] {
const steps = value / GRID_DEG;
const nearest = Math.round(steps);
return Math.abs(steps - nearest) < 1e-9 ? [nearest - 1, nearest] : [Math.floor(steps)];
}
/**
* The ferratas of one Overpass answer, handed to every cell among `keys` that
* a point of their line lies in (a point on a cell edge counts for both
* cells). A cell no ferrata touches gets an empty list, which is an answer
* too: it is stored, so the cell is not asked for again.
*/
export function splitFerratasByTile(ferratas: readonly Ferrata[], keys: ReadonlySet<string>): Map<string, Ferrata[]> {
const byTile = new Map<string, Ferrata[]>();
for (const key of keys) byTile.set(key, []);
for (const ferrata of ferratas) {
const touched = new Set<string>();
for (const [lat, lng] of ferrata.geometry) {
for (const row of gridIndices(lat)) {
for (const column of gridIndices(lng)) touched.add(tileKey(row, column));
}
}
for (const key of touched) byTile.get(key)?.push(ferrata);
}
return byTile;
}
/**
+6 -2
View File
@@ -1,16 +1,20 @@
import { FerrataTiles } from '../../db/entities/FerrataTiles.entity';
import { PermissionsModule } from '../permissions/permissions.module';
import { FerratasOverpassClient } from './ferratas-overpass.client';
import { FerratasController } from './ferratas.controller';
import { FerratasService } from './ferratas.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
/**
* Via ferratas around a trip, read from OpenStreetMap through Overpass.
* PermissionsModule brings TripAccessGuard; RuntimeEnvService (the Overpass
* endpoints and deadline) comes from the global AppConfigModule.
* endpoints and deadline) comes from the global AppConfigModule. The answers
* are kept per grid cell in `ferrata_tiles`; UnitOfWork and MikroORM come from
* the global OrmModule.
*/
@Module({
imports: [PermissionsModule],
imports: [PermissionsModule, MikroOrmModule.forFeature([FerrataTiles])],
controllers: [FerratasController],
providers: [FerratasService, FerratasOverpassClient],
})
+269 -59
View File
@@ -1,99 +1,309 @@
import { DomainError } from '../common/domain-error';
import { FerrataTiles } from '../../db/entities/FerrataTiles.entity';
import { FERRATA_TILE_PAYLOAD, type FerrataTilePayload } from '../../db/json-columns';
import type { FerrataTileRow, FerrataTilesRepository } from '../../db/repositories/FerrataTiles.repository';
import { decodeJsonResult, encodeJson, logJsonFailure } from '../../utils/json-column';
import { logWarn } from '../audit/audit-log.logger';
import { badRequest, DomainError } from '../common/domain-error';
import { withRequestContext } from '../database/request-context';
import { UnitOfWork } from '../database/unit-of-work';
import { FerratasOverpassClient } from './ferratas-overpass.client';
import { buildFerrataQuery, ferrataCacheKey, snapFerrataBbox, toFerratas } from './ferratas.helpers';
import {
buildFerrataQuery,
ferrataTileBlocks,
ferrataTileKeysFor,
ferrataTilesBbox,
splitFerratasByTile,
toFerratas,
} from './ferratas.helpers';
import { MikroORM } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { FERRATAS_ATTRIBUTION, FERRATAS_UNAVAILABLE_CODE, type FerrataBbox, type FerratasResponse } from '@trek/shared';
import {
FERRATA_BBOX_TOO_LARGE_MESSAGE,
FERRATAS_ATTRIBUTION,
FERRATAS_RESULT_CAP,
FERRATAS_UNAVAILABLE_CODE,
type Ferrata,
type FerrataBbox,
type FerratasResponse,
} from '@trek/shared';
/** Via ferratas change on the scale of seasons; a day keeps Overpass out of every map pan. */
export const FERRATA_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Via ferratas change on the scale of seasons. A cell older than this is still
* served at once, and asked for again in the background.
*/
export const FERRATA_TILE_STALE_MS = 30 * 24 * 60 * 60 * 1000;
/** The most boxes held at once; the oldest goes first, so roaming the globe cannot grow the map without bound. */
export const FERRATA_CACHE_MAX = 300;
/** The most cells held in memory in front of the table; the least recently used goes first. */
const FERRATA_TILE_MEMORY_MAX = 2000;
/**
* The most cells one request may cover. The contract's span cap already keeps
* a box to 11 by 11 cells; this holds the service to a bound of its own.
*/
const FERRATA_TILES_MAX_PER_REQUEST = 200;
/**
* The most Overpass queries this process runs at once. A box is queried in
* blocks of 0.1 degree, so a wide one turns into several queries; they wait
* their turn here rather than all hitting the public mirrors together.
*/
const FERRATA_QUERIES_AT_ONCE = 2;
/** `fetched_at` is kept in seconds, so the epoch fits a 32-bit integer column on Postgres too. */
const MS_PER_S = 1000;
/**
* `fetched_at` of a cell whose answer was cut at the cap: the oldest possible,
* so a restart serves it and asks again rather than keeping a partial cell for
* FERRATA_TILE_STALE_MS. This process keeps it for what it is meanwhile.
*/
const FERRATA_CUT_TILE_FETCHED_AT = 0;
export const FERRATAS_UNAVAILABLE_MESSAGE = 'Via ferrata data is unavailable right now';
interface CacheEntry {
at: number;
value: FerratasResponse;
interface StoredTile {
fetchedAt: number;
payload: FerrataTilePayload;
}
type TileBatch = Promise<ReadonlyMap<string, StoredTile>>;
/**
* The via ferratas inside a box, from OpenStreetMap.
*
* Read-only and per process: answers are cached by the box snapped to a 0.05
* degree grid, and concurrent requests for the same snapped box share one
* Overpass call. A failure is never cached, so the next request tries again;
* it reaches the client as a 502 `{ error, code }` it can show, never as a
* 500 with a stack.
* The unit is one cell of the 0.05 degree grid. A box is resolved into its
* cells, each served from `ferrata_tiles` (with a small LRU in front), and
* Overpass is asked only for the cells never fetched: one query per
* grid-aligned block of FERRATA_TILES_PER_REQUEST_SIDE cells a side (0.1
* degree), at most FERRATA_QUERIES_AT_ONCE at a time, each answer split back
* per cell and stored. A block whose answer was cut at the cap is asked again
* cell by cell, and a cell still cut on its own is kept but stored as old, so
* it is not served as complete for a month. A cell older than
* FERRATA_TILE_STALE_MS is served as it is and refreshed in the background,
* in queries of its own, so a refresh can never fail the request that saw
* it; nothing is deleted. Concurrent requests for a cell share one call.
*
* A request fails only when a cell it needs has never been fetched and
* Overpass fails too: the client gets a 502 `{ error, code }` it can show,
* never a 500 with a stack. A failed background refresh is logged and leaves
* the old cell in place.
*/
@Injectable()
export class FerratasService {
private readonly cache = new Map<string, CacheEntry>();
private readonly pending = new Map<string, Promise<FerratasResponse>>();
private readonly memory = new Map<string, StoredTile>();
private readonly pending = new Map<string, TileBatch>();
/** Overpass queries running now, and the ones waiting for a turn. */
private running = 0;
private readonly waiting: Array<() => void> = [];
constructor(private readonly overpass: FerratasOverpassClient) {}
constructor(
private readonly overpass: FerratasOverpassClient,
@InjectRepository(FerrataTiles) private readonly tiles: FerrataTilesRepository,
private readonly uow: UnitOfWork,
private readonly orm: MikroORM,
) {}
/** The via ferratas that reach into `bbox`; the cache holds the wider snapped box. */
/** The via ferratas that reach into `bbox`, gathered from the cells it covers. */
async list(bbox: FerrataBbox, now: number = Date.now()): Promise<FerratasResponse> {
return withinBbox(await this.listSnapped(bbox, now), bbox);
const keys = ferrataTileKeysFor(bbox, FERRATA_TILES_MAX_PER_REQUEST);
if (keys === null) throw badRequest(FERRATA_BBOX_TOO_LARGE_MESSAGE);
const tiles = new Map(await this.stored(keys));
const stale = keys.filter((key) => {
const tile = tiles.get(key);
return tile !== undefined && now - tile.fetchedAt >= FERRATA_TILE_STALE_MS && !this.pending.has(key);
});
const missing = keys.filter((key) => !tiles.has(key));
if (stale.length > 0) this.refreshInBackground(stale, now);
if (missing.length > 0) {
for (const [key, tile] of await this.fetchMissing(missing, now)) tiles.set(key, tile);
}
return gather(keys, tiles, bbox);
}
private async listSnapped(bbox: FerrataBbox, now: number): Promise<FerratasResponse> {
const snapped = snapFerrataBbox(bbox);
const key = ferrataCacheKey(snapped);
const hit = this.cache.get(key);
if (hit && now - hit.at < FERRATA_CACHE_TTL_MS) return hit.value;
if (hit) this.cache.delete(key);
/** The cells among `keys` that were ever fetched: memory first, the table for the rest. */
private async stored(keys: readonly string[]): Promise<Map<string, StoredTile>> {
const found = new Map<string, StoredTile>();
const unread: string[] = [];
for (const key of keys) {
const hit = this.memory.get(key);
if (hit) {
this.remember(key, hit);
found.set(key, hit);
} else {
unread.push(key);
}
}
for (const row of await this.tiles.findByKeys(unread)) {
const decoded = decodeJsonResult(FERRATA_TILE_PAYLOAD, row.payload);
if (!decoded.ok || decoded.value === null) {
logJsonFailure(FERRATA_TILE_PAYLOAD, decoded.ok ? 'schema' : decoded.reason, row.tile_key);
continue;
}
const tile = { fetchedAt: row.fetched_at * MS_PER_S, payload: decoded.value };
this.remember(row.tile_key, tile);
found.set(row.tile_key, tile);
}
return found;
}
const inFlight = this.pending.get(key);
if (inFlight !== undefined) return inFlight;
// Memoised in-flight load, not a missing await: concurrent callers join the
// promise above, and this frame awaits it below.
const load = this.load(snapped);
this.pending.set(key, load);
try {
const value = await load;
this.remember(key, { at: now, value });
return value;
} finally {
this.pending.delete(key);
/**
* The cells never fetched, awaited. A cell another request is already
* fetching joins that batch; the rest go out one batch per block.
*/
private async fetchMissing(missing: readonly string[], now: number): Promise<Map<string, StoredTile>> {
const tiles = new Map<string, StoredTile>();
const batches = new Set<TileBatch>();
const own: string[] = [];
for (const key of missing) {
const hit = this.memory.get(key);
const inFlight = this.pending.get(key);
if (hit) tiles.set(key, hit);
else if (inFlight !== undefined) batches.add(inFlight);
else own.push(key);
}
for (const block of ferrataTileBlocks(own)) batches.add(this.batch(block, now));
for (const answer of await Promise.all(batches)) {
for (const [key, tile] of answer) tiles.set(key, tile);
}
return tiles;
}
/** Refreshes old cells without holding up the request, a batch per block; a failure only costs the refresh. */
private refreshInBackground(keys: readonly string[], now: number): void {
for (const block of ferrataTileBlocks(keys)) {
this.batch(block, now).catch((err: unknown) => {
const reason = err instanceof Error ? err.message : String(err);
logWarn(`[Ferratas] background refresh of ${block.length} tile(s) failed: ${reason}`);
});
}
}
private async load(snapped: FerrataBbox): Promise<FerratasResponse> {
let elements: unknown[];
/**
* One Overpass query for `keys`, shared with every request that needs one of
* them until it settles. It runs in a request context of its own: it can
* outlive the request that started it.
*/
private batch(keys: readonly string[], now: number): TileBatch {
const run = withRequestContext(this.orm, () => this.fetchAndStore(keys, now));
for (const key of keys) this.pending.set(key, run);
const settle = () => {
for (const key of keys) {
if (this.pending.get(key) === run) this.pending.delete(key);
}
};
void run.then(settle, settle);
return run;
}
/**
* Asks Overpass for `keys` and stores the answer per cell. An answer cut at
* the cap is not stored for a block: its cells are asked again one by one,
* which is the most a cut answer can be narrowed. A single cell still cut is
* kept in memory as it is, and stored as old, so the next start refreshes it.
*/
private async fetchAndStore(keys: readonly string[], now: number): Promise<Map<string, StoredTile>> {
const elements = await this.queryOverpass(keys);
const { ferratas, truncated } = toFerratas(elements);
if (truncated && keys.length > 1) {
const tiles = new Map<string, StoredTile>();
const answers = await Promise.all(keys.map((key) => this.fetchAndStore([key], now)));
for (const answer of answers) for (const [key, tile] of answer) tiles.set(key, tile);
return tiles;
}
const byTile = splitFerratasByTile(ferratas, new Set(keys));
const storedAt = truncated ? FERRATA_CUT_TILE_FETCHED_AT : Math.floor(now / MS_PER_S);
const tiles = new Map<string, StoredTile>();
const rows: FerrataTileRow[] = [];
for (const key of keys) {
const payload: FerrataTilePayload = { ferratas: byTile.get(key) ?? [], truncated };
tiles.set(key, { fetchedAt: now, payload });
rows.push({ tile_key: key, payload: encodeJson(FERRATA_TILE_PAYLOAD, payload), fetched_at: storedAt });
}
try {
elements = await this.overpass.query(buildFerrataQuery(snapped));
await this.uow.transactional(() => this.tiles.upsertTiles(rows));
} catch (err) {
// The answer is still good for this process; the next start asks again.
const reason = err instanceof Error ? err.message : String(err);
logWarn(`[Ferratas] could not store ${rows.length} tile(s): ${reason}`);
}
for (const [key, tile] of tiles) this.remember(key, tile);
return tiles;
}
/** One Overpass query for the box around `keys`, once a turn is free; a failure becomes the 502 the client reads. */
private async queryOverpass(keys: readonly string[]): Promise<unknown[]> {
await this.turn();
try {
return await this.overpass.query(buildFerrataQuery(ferrataTilesBbox(keys)));
} catch {
// The client already logged why each mirror failed.
throw new DomainError(502, FERRATAS_UNAVAILABLE_MESSAGE, {
code: FERRATAS_UNAVAILABLE_CODE,
details: { code: FERRATAS_UNAVAILABLE_CODE },
});
} finally {
// The turn passes straight to the next query waiting, or is given back.
const next = this.waiting.shift();
if (next) next();
else this.running -= 1;
}
const { ferratas, truncated } = toFerratas(elements);
return { ferratas, truncated, source: 'openstreetmap', attribution: FERRATAS_ATTRIBUTION };
}
private remember(key: string, entry: CacheEntry): void {
this.cache.delete(key);
while (this.cache.size >= FERRATA_CACHE_MAX) {
const oldest = this.cache.keys().next();
if (oldest.done) break;
this.cache.delete(oldest.value);
/** Resolves once the caller holds one of FERRATA_QUERIES_AT_ONCE turns, handed over by a query that finished. */
private async turn(): Promise<void> {
if (this.running < FERRATA_QUERIES_AT_ONCE) {
this.running += 1;
return;
}
this.cache.set(key, entry);
await new Promise<void>((resolve) => this.waiting.push(resolve));
}
private remember(key: string, tile: StoredTile): void {
this.memory.delete(key);
while (this.memory.size >= FERRATA_TILE_MEMORY_MAX) {
const oldest = this.memory.keys().next();
if (oldest.done) break;
this.memory.delete(oldest.value);
}
this.memory.set(key, tile);
}
}
/** A ferrata belongs to the box when any point of its line lies inside it. */
function withinBbox(response: FerratasResponse, bbox: FerrataBbox): FerratasResponse {
const inside = ([lat, lng]: readonly unknown[]) =>
typeof lat === 'number' &&
typeof lng === 'number' &&
lat >= bbox.south &&
lat <= bbox.north &&
lng >= bbox.west &&
lng <= bbox.east;
return { ...response, ferratas: response.ferratas.filter((ferrata) => ferrata.geometry.some(inside)) };
function reachesInto(ferrata: Ferrata, bbox: FerrataBbox): boolean {
return ferrata.geometry.some(
([lat, lng]) => lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east,
);
}
/**
* The answer out of the cells: every ferrata once, however many cells it
* crosses, kept when it reaches into the asked box, cut at the contract's cap.
* The answer is marked cut when any cell came from a cut Overpass answer.
*/
function gather(keys: readonly string[], tiles: ReadonlyMap<string, StoredTile>, bbox: FerrataBbox): FerratasResponse {
const seen = new Set<string>();
const ferratas: Ferrata[] = [];
let truncated = false;
for (const key of keys) {
const tile = tiles.get(key);
if (!tile) continue;
truncated ||= tile.payload.truncated;
for (const ferrata of tile.payload.ferratas) {
if (seen.has(ferrata.id)) continue;
seen.add(ferrata.id);
if (reachesInto(ferrata, bbox)) ferratas.push(ferrata);
}
}
return {
ferratas: ferratas.slice(0, FERRATAS_RESULT_CAP),
truncated: truncated || ferratas.length > FERRATAS_RESULT_CAP,
source: 'openstreetmap',
attribution: FERRATAS_ATTRIBUTION,
};
}