Skip to content

Commit b2d72ef

Browse files
authored
Merge pull request #678 from netzbegruenung/fix/monitor-loud-fails
fix(monitor): surface silent fails in refresh and read paths
2 parents 815619e + 82c4371 commit b2d72ef

1 file changed

Lines changed: 71 additions & 34 deletions

File tree

apps/api/services/monitor/MonitorService.ts

Lines changed: 71 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { randomUUID } from 'crypto';
22

33
import { getPostgresInstance } from '../../database/services/PostgresService.js';
4+
import { toError } from '../../utils/errors/index.js';
45
import { createLogger } from '../../utils/logger.js';
56
import redisClient from '../../utils/redis/client.js';
67

@@ -169,46 +170,80 @@ export async function refreshMonitor(): Promise<MonitorSnapshot> {
169170
// Store articles in normalized table + snapshot aggregates
170171
await Promise.all([upsertArticles(allArticles), saveSnapshotAggregates(snapshot)]);
171172

172-
// Cache snapshot in Redis
173+
// Cache snapshot in Redis. Non-fatal: DB still has canonical data, but a
174+
// failure here means /monitor/latest pays the rebuild cost on every request.
173175
try {
174176
await redisClient.set(REDIS_SNAPSHOT_KEY, JSON.stringify(snapshot), { EX: REDIS_TTL_SECONDS });
175177
} catch (error) {
176-
log.error(`Failed to cache snapshot in Redis: ${error}`);
178+
log.error(`Failed to cache snapshot in Redis: ${toError(error).message}`);
177179
}
178180

179-
// Warm all caches in background (fire-and-forget)
181+
const warmTasks: Array<{ name: string; run: () => Promise<unknown> }> = [];
182+
180183
if (keywords.length > 0) {
181184
for (const locale of ['de', 'at'] as const) {
182-
generateKeywordInsights(keywords, locale).catch((err) =>
183-
log.warn(`Background keyword-insights warm (${locale}) failed: ${err}`)
184-
);
185+
warmTasks.push({
186+
name: `keyword-insights:${locale}`,
187+
run: () => generateKeywordInsights(keywords, locale),
188+
});
185189
}
186190
}
187191

188192
for (const locale of ['de', 'at'] as const) {
189-
getStimmung(locale).catch((err) =>
190-
log.warn(`Background stimmung warm (${locale}) failed: ${err}`)
191-
);
193+
warmTasks.push({ name: `stimmung:${locale}`, run: () => getStimmung(locale) });
192194
}
193195

194-
getPolls().catch((err) => log.warn(`Background polls warm failed: ${err}`));
196+
warmTasks.push({ name: 'polls', run: () => getPolls() });
195197

196198
for (const locale of ['de', 'at'] as const) {
197-
Promise.all([getLatestSnapshot(locale), getStimmung(locale), getPolls()])
198-
.then(([snap, stimmung, polls]) => {
199-
if (snap) return generateMonitorBriefing(locale, snap, stimmung, polls?.average ?? {});
200-
})
201-
.catch((err) => log.warn(`Background briefing warm (${locale}) failed: ${err}`));
199+
warmTasks.push({
200+
name: `briefing:${locale}`,
201+
run: async () => {
202+
const [snap, stimmung, polls] = await Promise.all([
203+
getLatestSnapshot(locale),
204+
getStimmung(locale),
205+
getPolls(),
206+
]);
207+
if (!snap) throw new Error('no snapshot available');
208+
return generateMonitorBriefing(locale, snap, stimmung, polls?.average ?? {});
209+
},
210+
});
202211
}
203212

204213
for (const entity of WATCHER_ENTITIES) {
205-
searchArticlesByKeywords(entity.keywords, entity.locale, 50, entity.excludePatterns)
206-
.then((articles) => getEntitySummary(entity, articles, entity.locale))
207-
.catch((err) =>
208-
log.warn(`Background entity summary warm (${entity.id}/${entity.locale}) failed: ${err}`)
209-
);
214+
warmTasks.push({
215+
name: `entity-summary:${entity.id}/${entity.locale}`,
216+
run: async () => {
217+
const articles = await searchArticlesByKeywords(
218+
entity.keywords,
219+
entity.locale,
220+
50,
221+
entity.excludePatterns
222+
);
223+
return getEntitySummary(entity, articles, entity.locale);
224+
},
225+
});
210226
}
211227

228+
// Run warmers in parallel, but don't block the refresh response. Aggregate
229+
// failures into a single structured ERROR so a single watch on logs catches
230+
// any background regression instead of 13 independent WARN lines.
231+
void Promise.allSettled(warmTasks.map((t) => t.run())).then((results) => {
232+
const failures = results
233+
.map((r, i) => (r.status === 'rejected' ? { name: warmTasks[i].name, reason: r.reason } : null))
234+
.filter((x): x is { name: string; reason: unknown } => x !== null);
235+
236+
if (failures.length === 0) {
237+
log.info(`Background warm: ${warmTasks.length}/${warmTasks.length} succeeded`);
238+
return;
239+
}
240+
241+
const detail = failures.map((f) => `${f.name}: ${toError(f.reason).message}`).join('; ');
242+
log.error(
243+
`Background warm: ${failures.length}/${warmTasks.length} failed — ${detail}`
244+
);
245+
});
246+
212247
const durationMs = Date.now() - startTime;
213248
log.info(
214249
`Monitor refresh: ${allArticles.length} total, ${classifiedArticles.length} classified, ${keywords.length} keywords (${durationMs}ms)`
@@ -298,7 +333,8 @@ async function upsertArticles(articles: MonitorArticle[]): Promise<void> {
298333

299334
log.info(`Upserted ${articles.length} articles into monitor_articles`);
300335
} catch (error) {
301-
log.error(`Failed to upsert articles: ${error}`);
336+
log.error(`Failed to upsert articles: ${toError(error).message}`);
337+
throw error;
302338
}
303339
}
304340

@@ -318,7 +354,8 @@ async function saveSnapshotAggregates(snapshot: MonitorSnapshot): Promise<void>
318354
]
319355
);
320356
} catch (error) {
321-
log.error(`Failed to save snapshot: ${error}`);
357+
log.error(`Failed to save snapshot: ${toError(error).message}`);
358+
throw error;
322359
}
323360
}
324361

@@ -330,8 +367,8 @@ export async function getLatestSnapshot(locale?: MonitorLocale): Promise<Monitor
330367
try {
331368
const cached = await redisClient.get(REDIS_SNAPSHOT_KEY);
332369
if (cached) return JSON.parse(cached) as MonitorSnapshot;
333-
} catch {
334-
// Fall through
370+
} catch (error) {
371+
log.warn(`Redis snapshot read failed, falling back to DB: ${toError(error).message}`);
335372
}
336373
}
337374

@@ -367,8 +404,8 @@ export async function getLatestSnapshot(locale?: MonitorLocale): Promise<Monitor
367404
if (loc === 'de') snapshot.articlesByLocale.de = cnt;
368405
if (loc === 'at') snapshot.articlesByLocale.at = cnt;
369406
}
370-
} catch {
371-
// Non-critical
407+
} catch (error) {
408+
log.warn(`Failed to load locale article counts: ${toError(error).message}`);
372409
}
373410

374411
if (locale) {
@@ -397,13 +434,13 @@ export async function getLatestSnapshot(locale?: MonitorLocale): Promise<Monitor
397434
await redisClient.set(REDIS_SNAPSHOT_KEY, JSON.stringify(snapshot), {
398435
EX: REDIS_TTL_SECONDS,
399436
});
400-
} catch {
401-
// Ignore
437+
} catch (error) {
438+
log.warn(`Failed to repopulate Redis snapshot cache: ${toError(error).message}`);
402439
}
403440

404441
return snapshot;
405442
} catch (error) {
406-
log.error(`Failed to fetch snapshot: ${error}`);
443+
log.error(`Failed to fetch snapshot: ${toError(error).message}`);
407444
return null;
408445
}
409446
}
@@ -425,8 +462,8 @@ export async function getStimmung(locale?: MonitorLocale): Promise<StimmungResul
425462
try {
426463
const cached = await redisClient.get(stimmungCacheKey);
427464
if (cached) return JSON.parse(cached) as StimmungResult;
428-
} catch {
429-
// Fall through to DB
465+
} catch (error) {
466+
log.warn(`Redis stimmung read failed, falling back to DB: ${toError(error).message}`);
430467
}
431468

432469
try {
@@ -539,13 +576,13 @@ export async function getStimmung(locale?: MonitorLocale): Promise<StimmungResul
539576

540577
try {
541578
await redisClient.set(stimmungCacheKey, JSON.stringify(result), { EX: REDIS_TTL_SECONDS });
542-
} catch {
543-
// Non-critical
579+
} catch (error) {
580+
log.warn(`Failed to cache stimmung in Redis: ${toError(error).message}`);
544581
}
545582

546583
return result;
547584
} catch (error) {
548-
log.error(`Failed to get stimmung: ${error}`);
585+
log.error(`Failed to get stimmung: ${toError(error).message}`);
549586
return { overall: {}, byTopic: [], bySource: [], byKeyword: [], dominantEmotion: null };
550587
}
551588
}

0 commit comments

Comments
 (0)