mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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(),
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+27
@@ -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' },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user