mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c7fed4b1f8
The last file in be-places-helpers-fold, and the one that had to be a provider rather than a move: it carries three in-memory structures that only do their job if the process has exactly one of them. recentFailures stops a screenful of markers hammering an API that is down inFlight stops a stampede when several requests miss the same placeId knownOnDisk saves an existsSync per cache hit A second instance gives the stampede guard its own inFlight map, so two simultaneous requests for the same uncached place both call the provider. That is why the scheduler takes this one from the container instead of constructing it: SchedulerDeps gains a placePhotos entry alongside backups, filled from app.get() in index.ts — the shape PR #1827 was built for. The module-scope mkdirSync becomes onModuleInit, and GOOGLE_PHOTO_DIR is read per call through RuntimeEnvService rather than frozen at import. That is not cosmetic: TREK_PLACE_PHOTO_DIR is the seam the tests use to keep off the real uploads tree, and its own test used to have to import the module dynamically to set the variable in time. The __dirname fallback moved a level with the file and is corrected accordingly. Consumers: maps, places and share inject it; places.helpers.reclaimPhotoCache takes it as a parameter, since it is a free function. Six test seams follow. Four path mocks become injected stubs with the same mock functions behind them; the collections case keeps the REAL implementation, because what it asserts is that removeIfUnreferenced counts collection_places as a reference (#1081) — a stub there would assert nothing. The share e2e overrides the provider in its container. services/ is down to 14 top-level files, from 44 when this started.
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
import { Jimp, JimpMime } from 'jimp';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import fsPromises from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { DatabaseService } from '../database/database.service';
|
|
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
|
|
|
// How long a "no photo for this place" answer stays remembered. Nothing about it
|
|
// changes until a photo appears upstream, so it is worth keeping: without it every
|
|
// trip open replays the whole provider fan-out, one lookup per place. A day is
|
|
// still short enough that a newly configured API key takes effect overnight.
|
|
// Entries are dropped outright when the place goes away (removeIfUnreferenced /
|
|
// sweepOrphans).
|
|
const MISSING_TTL = 24 * 60 * 60 * 1000;
|
|
// A provider call that failed says nothing about the place — only that the API was
|
|
// unreachable or unhappy just now. It is remembered just long enough to stop a
|
|
// screenful of markers from hammering an API that is down, and only in memory, so
|
|
// a restart retries right away.
|
|
const FAILURE_TTL = 5 * 60 * 1000;
|
|
// Sweep expired failures once the map grows past this. Whatever survives the sweep
|
|
// is younger than FAILURE_TTL and expires on its own shortly after.
|
|
const FAILURE_SWEEP_AT = 500;
|
|
|
|
// Marker photos are displayed tiny — cap stored images so an oversized source
|
|
// (e.g. a Wikimedia Commons full-res original) can't bloat the cache. Matches
|
|
// THUMB_MAX/THUMB_QUALITY in memories/thumbnailService.ts.
|
|
const MAX_DIM = 800;
|
|
const JPEG_QUALITY = 80;
|
|
|
|
interface CachedPhoto {
|
|
photoUrl: string;
|
|
filePath: string;
|
|
attribution: string | null;
|
|
}
|
|
|
|
/**
|
|
* Cache for the marker photos the map providers hand back.
|
|
*
|
|
* Its own module because maps, places and share all read it — and because its
|
|
* three in-memory structures only do their job if there is exactly ONE of them.
|
|
* A second instance would give the stampede guard its own inFlight map, so two
|
|
* simultaneous requests for the same uncached place would both call the
|
|
* provider. That is the reason the scheduler takes this from the container
|
|
* rather than constructing it: see SchedulerDeps.
|
|
*/
|
|
@Injectable()
|
|
export class PlacePhotoCacheService implements OnModuleInit {
|
|
/** Provider calls that failed recently — in memory only, so a restart retries. */
|
|
private readonly recentFailures = new Map<string, number>();
|
|
/** In-flight dedup: stops a stampede when several requests miss the same placeId at once. */
|
|
private readonly inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
|
/** placeIds whose file was confirmed on disk this session — saves an existsSync per hit. */
|
|
private readonly knownOnDisk = new Set<string>();
|
|
|
|
constructor(
|
|
private readonly db: DatabaseService,
|
|
private readonly env: RuntimeEnvService,
|
|
) {}
|
|
|
|
/**
|
|
* Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never
|
|
* touches the real uploads tree. Read per call rather than frozen at import:
|
|
* the tests that set TREK_PLACE_PHOTO_DIR do so after this module loads.
|
|
*/
|
|
private get photoDir(): string {
|
|
return this.env.env().paths.placePhotoDir || path.join(__dirname, '../../../uploads/photos/google');
|
|
}
|
|
|
|
/** Ensure the upload dir exists once at startup, so put() never needs a sync FS call. */
|
|
onModuleInit(): void {
|
|
try {
|
|
fs.mkdirSync(this.photoDir, { recursive: true });
|
|
} catch {
|
|
/* already exists */
|
|
}
|
|
}
|
|
|
|
private filePath(placeId: string): string {
|
|
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
|
// collapse identically under sanitization (e.g. ':' and '.' both → '_')
|
|
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
|
|
return path.join(this.photoDir, `${hash}.jpg`);
|
|
}
|
|
|
|
private proxyUrl(placeId: string): string {
|
|
return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
|
}
|
|
|
|
get(placeId: string): CachedPhoto | null {
|
|
const row = this.db.get<{ attribution: string | null }>(
|
|
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL',
|
|
placeId,
|
|
);
|
|
|
|
if (!row) return null;
|
|
|
|
const fp = this.filePath(placeId);
|
|
|
|
if (!this.knownOnDisk.has(placeId)) {
|
|
// First time this placeId is checked this session — verify the file exists on disk.
|
|
// (Guards against volume wipes or manual deletion between server restarts.)
|
|
if (!fs.existsSync(fp)) {
|
|
this.db.run('DELETE FROM google_place_photo_meta WHERE place_id = ?', placeId);
|
|
return null;
|
|
}
|
|
this.knownOnDisk.add(placeId);
|
|
}
|
|
|
|
return { photoUrl: this.proxyUrl(placeId), filePath: fp, attribution: row.attribution };
|
|
}
|
|
|
|
getErrored(placeId: string): boolean {
|
|
const failedAt = this.recentFailures.get(placeId);
|
|
if (failedAt !== undefined) {
|
|
if (Date.now() - failedAt < FAILURE_TTL) return true;
|
|
this.recentFailures.delete(placeId);
|
|
}
|
|
|
|
const row = this.db.get<{ error_at: number }>(
|
|
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL',
|
|
placeId,
|
|
);
|
|
|
|
if (!row) return false;
|
|
return Date.now() - row.error_at < MISSING_TTL;
|
|
}
|
|
|
|
/**
|
|
* Remember that a lookup came back empty. 'no-photo' is the lasting answer — no
|
|
* provider has an image for this place — and is persisted; 'provider-error' is a
|
|
* failed attempt and is only held in memory for a few minutes.
|
|
*/
|
|
markError(placeId: string, kind: 'no-photo' | 'provider-error' = 'no-photo'): void {
|
|
if (kind === 'provider-error') {
|
|
if (this.recentFailures.size >= FAILURE_SWEEP_AT) {
|
|
const cutoff = Date.now() - FAILURE_TTL;
|
|
for (const [id, at] of this.recentFailures) {
|
|
if (at <= cutoff) this.recentFailures.delete(id);
|
|
}
|
|
}
|
|
this.recentFailures.set(placeId, Date.now());
|
|
return;
|
|
}
|
|
|
|
this.recentFailures.delete(placeId);
|
|
this.knownOnDisk.delete(placeId);
|
|
this.db.run(
|
|
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)',
|
|
placeId, Date.now(), Date.now(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Downscale oversized images to MAX_DIM before caching, re-encoding to JPEG.
|
|
* Defense-in-depth: keeps the cache small regardless of what the fetch path hands
|
|
* us. Jimp auto-applies EXIF orientation on read. Falls back to the original bytes
|
|
* on any failure (corrupt/unsupported format) so behaviour is never worse than before.
|
|
*/
|
|
private async downscale(bytes: Buffer): Promise<Buffer> {
|
|
try {
|
|
const img = await Jimp.read(bytes);
|
|
if (img.bitmap.width <= MAX_DIM && img.bitmap.height <= MAX_DIM) return bytes;
|
|
img.scaleToFit({ w: MAX_DIM, h: MAX_DIM });
|
|
return await img.getBuffer(JimpMime.jpeg, { quality: JPEG_QUALITY });
|
|
} catch {
|
|
return bytes;
|
|
}
|
|
}
|
|
|
|
async put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
|
const fp = this.filePath(placeId);
|
|
const tmp = fp + '.tmp';
|
|
|
|
const resized = await this.downscale(bytes);
|
|
await fsPromises.writeFile(tmp, resized);
|
|
await fsPromises.rename(tmp, fp);
|
|
|
|
this.knownOnDisk.add(placeId);
|
|
this.recentFailures.delete(placeId);
|
|
|
|
this.db.run(
|
|
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)',
|
|
placeId, attribution, Date.now(),
|
|
);
|
|
|
|
return { photoUrl: this.proxyUrl(placeId), filePath: fp, attribution };
|
|
}
|
|
|
|
getInFlight(
|
|
placeId: string,
|
|
): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
|
return this.inFlight.get(placeId);
|
|
}
|
|
|
|
setInFlight(
|
|
placeId: string,
|
|
promise: Promise<{ filePath: string; attribution: string | null } | null>,
|
|
): void {
|
|
this.inFlight.set(placeId, promise);
|
|
promise
|
|
.finally(() => this.inFlight.delete(placeId))
|
|
.catch(() => {
|
|
/* awaiter logs; this .catch only prevents unhandledRejection */
|
|
});
|
|
}
|
|
|
|
serveFilePath(placeId: string): string | null {
|
|
if (this.knownOnDisk.has(placeId)) return this.filePath(placeId);
|
|
const fp = this.filePath(placeId);
|
|
if (!fs.existsSync(fp)) return null;
|
|
this.knownOnDisk.add(placeId);
|
|
return fp;
|
|
}
|
|
|
|
/**
|
|
* A cache entry is "referenced" while any place still points at it — either by the
|
|
* Google place_id (the dedup key) or by the stable proxy URL stored in image_url
|
|
* (covers coords: pseudo-ids, which never have a google_place_id).
|
|
*
|
|
* A collection-saved place copies image_url = proxyUrl(google_place_id) and/or
|
|
* the google_place_id itself, so collection_places must count as a referencing
|
|
* table — otherwise the nightly sweep + trip-place delete would evict a photo
|
|
* still shown on a collection thumbnail (#1081 photo-cache pitfall).
|
|
*/
|
|
private isReferenced(placeId: string): boolean {
|
|
const row = this.db.get(
|
|
`SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ?
|
|
UNION ALL
|
|
SELECT 1 FROM collection_places WHERE google_place_id = ? OR image_url = ?
|
|
LIMIT 1`,
|
|
placeId, this.proxyUrl(placeId), placeId, this.proxyUrl(placeId),
|
|
);
|
|
return !!row;
|
|
}
|
|
|
|
private deleteEntry(placeId: string): void {
|
|
try {
|
|
fs.unlinkSync(this.filePath(placeId));
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
this.db.run('DELETE FROM google_place_photo_meta WHERE place_id = ?', placeId);
|
|
this.knownOnDisk.delete(placeId);
|
|
}
|
|
|
|
/**
|
|
* Drop a cache entry if no place references it anymore. Called after a place delete
|
|
* for prompt reclamation; the nightly sweep is the catch-all for every other path.
|
|
*/
|
|
removeIfUnreferenced(placeId: string): void {
|
|
if (this.isReferenced(placeId)) return;
|
|
this.deleteEntry(placeId);
|
|
}
|
|
|
|
/**
|
|
* Reclaim orphaned cache files + meta rows. Runs on startup and nightly (scheduler).
|
|
* Two passes: (1) meta rows no place references; (2) stray .jpg files with no meta row.
|
|
*/
|
|
sweepOrphans(): number {
|
|
let removed = 0;
|
|
|
|
const rows = this.db.all<{ place_id: string }>('SELECT place_id FROM google_place_photo_meta');
|
|
const keepFiles = new Set<string>();
|
|
for (const { place_id } of rows) {
|
|
if (this.isReferenced(place_id)) {
|
|
keepFiles.add(`${crypto.createHash('sha1').update(place_id).digest('hex')}.jpg`);
|
|
} else {
|
|
this.deleteEntry(place_id);
|
|
removed++;
|
|
}
|
|
}
|
|
|
|
// Pass 2: files on disk that no surviving meta row maps to (e.g. left over from a
|
|
// crash between writeFile and the DB upsert, or a meta row deleted out-of-band).
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(this.photoDir);
|
|
} catch {
|
|
entries = [];
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.endsWith('.jpg') || keepFiles.has(entry)) continue;
|
|
try {
|
|
fs.unlinkSync(path.join(this.photoDir, entry));
|
|
removed++;
|
|
} catch {
|
|
/* race */
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
}
|