Rebuild the enrichment cache when its pictures were swept

The nightly photo sweep deletes every candidate nobody picked, which is most of
them. The cached result still pointed at those files, and the read filtered them
out - so the morning after, a place with perfectly available pictures showed an
empty column, and kept showing one for the rest of the week the entry lives.

A missing candidate now invalidates the whole entry instead of shrinking it.
This commit is contained in:
Maurice
2026-08-09 23:04:08 +02:00
committed by Maurice
parent 1f1ed630fc
commit fc9ac64496
2 changed files with 36 additions and 7 deletions
@@ -300,10 +300,13 @@ export class PlaceEnrichmentService {
if (!row || Date.now() - row.fetched_at >= CACHE_TTL_MS) return null;
const parsed = JSON.parse(row.payload_json) as CachedEnrichment;
// A cached candidate is only usable while its bytes are still on disk —
// the nightly sweep removes pictures nobody picked, and serving their
// proxy URLs afterwards would fill the strip with broken images.
// A cached candidate is only usable while its bytes are still on disk, and
// the nightly sweep deletes every picture nobody picked — which is most of
// them. Treat any loss as a stale entry and rebuild, rather than serving
// the survivors: filtering alone would leave the column empty for the rest
// of the week for a place that has pictures perfectly available.
const photos = parsed.photos.filter((photo) => this.photoCache.get(photo.key));
if (photos.length !== parsed.photos.length) return null;
return { photos, description: parsed.description ?? null };
} catch {
return null;
@@ -491,9 +491,19 @@ describe('result cache', () => {
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
});
it('ENRICH-023: drops cached candidates whose bytes the nightly sweep removed', async () => {
const maps = mapsStub();
const cache = cacheStub({ get: vi.fn(() => null) }); // nothing on disk any more
it('ENRICH-023: rebuilds when the nightly sweep removed a cached candidate', async () => {
// The sweep deletes every picture nobody picked, so this is the normal state
// of a cached entry the morning after. Serving the survivors would leave the
// column empty for the rest of the week for a place that has pictures.
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
let onDisk = false;
const cache = cacheStub({
get: vi.fn(() => (onDisk ? { photoUrl: '/x', filePath: '/tmp/x', attribution: null } : null)),
put: vi.fn(async (key: string) => {
onDisk = true;
return { photoUrl: `/api/maps/place-photo/${key}/bytes`, filePath: '/tmp/x', attribution: null };
}),
});
mockDbGet.mockImplementation((sql: string) =>
String(sql).includes('place_details_cache')
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
@@ -502,7 +512,23 @@ describe('result cache', () => {
const out = await make(maps, cache).enrich(1, REQ);
expect(out.photos).toEqual([]);
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
expect(out.photos).toHaveLength(1);
});
it('ENRICH-023b: keeps serving a cached entry while all its bytes are still there', async () => {
const maps = mapsStub();
const cache = cacheStub({ get: vi.fn(() => ({ photoUrl: '/x', filePath: '/tmp/x', attribution: null })) });
mockDbGet.mockImplementation((sql: string) =>
String(sql).includes('place_details_cache')
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
: undefined,
);
const out = await make(maps, cache).enrich(1, REQ);
expect(out.photos).toHaveLength(1);
expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled();
});
it('ENRICH-024: ignores an unreadable cache row and rebuilds', async () => {